cgminer_api_client 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +50 -1
- data/bin/cgminer_api_client +44 -2
- data/lib/cgminer_api_client/errors.rb +72 -1
- data/lib/cgminer_api_client/miner/commands.rb +9 -1
- data/lib/cgminer_api_client/miner.rb +85 -24
- data/lib/cgminer_api_client/miner_pool.rb +13 -6
- data/lib/cgminer_api_client/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d045472d7098443d965f6bb948ad45aef2698f84e188f6b41769905dcccf6ce4
|
|
4
|
+
data.tar.gz: a3ac02b6c4b94d9a797a3fd501cb43f7f35ee421b4234af49b92a3fcc48c7832
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e75c9159055d5a5a0507b11e93f0f0a25512f83e2bebb0de9a9495aa7829b018b4c9a2da555d795f8a6d6372ea99b75ebc363bf84f5c9a3fd7d085a63e34b3d2
|
|
7
|
+
data.tar.gz: d112135f844f48772daac286dbf6f3d81224820a6cf91cc7039b9e56ef3164dc38e2357c0f48c9a4d484a40dd2cb9346f0ba8522a5094cbb027cf6356863d4b9
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,114 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.4.1] - 2026-09-22
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `Miner` keeps parsing responses that repeat a key under json 3.0,
|
|
14
|
+
which rejects duplicate keys by default. The client now passes
|
|
15
|
+
`allow_duplicate_key: true`, preserving json 2.x's last-one-wins
|
|
16
|
+
behavior, so a miner whose firmware repeats a key no longer raises
|
|
17
|
+
`JSON::ParserError` mid-poll.
|
|
18
|
+
|
|
19
|
+
## [0.4.0] - 2026-04-25
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **`CgminerApiClient::AccessDeniedError`**, a subclass of `ApiError`
|
|
23
|
+
for the most commonly dispatched-on case (cgminer Code 45 + the
|
|
24
|
+
`access_denied?` local guard). Inherits from `ApiError` so existing
|
|
25
|
+
`rescue ApiError` clauses still catch it; callers wanting finer
|
|
26
|
+
dispatch use `rescue AccessDeniedError`. Constructor pins
|
|
27
|
+
`code: :access_denied` so the symbolic tag is consistent. The
|
|
28
|
+
wire-side `Miner#check_status` delegates to a new factory
|
|
29
|
+
`ApiError.for_status(c, msg)` that picks the right subclass
|
|
30
|
+
based on the integer Code.
|
|
31
|
+
- **Structured error codes on `CgminerApiClient::ApiError`.** Two new
|
|
32
|
+
reader methods alongside the existing `#message`:
|
|
33
|
+
`#cgminer_code` (the integer Code from cgminer's STATUS hash —
|
|
34
|
+
e.g., `45` for access denied — or `nil` if the wire integer was
|
|
35
|
+
discarded, as happens when the `access_denied?` local guard's
|
|
36
|
+
call to `privileged` hits the wire and the rescue inside
|
|
37
|
+
`privileged` drops the integer before re-raising) and `#code`
|
|
38
|
+
(a symbolic tag derived from the integer via
|
|
39
|
+
`ApiError::CGMINER_CODES`, falling back to `:unknown` for codes
|
|
40
|
+
not in the map). Callers can now `case e.code; when :access_denied`
|
|
41
|
+
instead of parsing English error strings. **Prefer `e.code` for
|
|
42
|
+
dispatch over `e.cgminer_code`** — `cgminer_code` can be nil even
|
|
43
|
+
when the symbolic tag is set. The map is intentionally
|
|
44
|
+
conservative — `14 → :invalid_command`, `45 → :access_denied`,
|
|
45
|
+
the codes observed against real cgminer wire fixtures.
|
|
46
|
+
Backward-compatible: `raise ApiError, "msg"` still works and
|
|
47
|
+
`e.message` is unchanged.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- `ApiError` constructor now validates `cgminer_code:` is `Integer`
|
|
51
|
+
or `nil` and `code:` is `Symbol`, `String`, or `nil`. Bad input
|
|
52
|
+
raises `ArgumentError` with a clear message instead of silently
|
|
53
|
+
producing `code: :unknown` (string `cgminer_code`) or
|
|
54
|
+
`NoMethodError` deep in the constructor (`code: 42`). Wire-side
|
|
55
|
+
callers go through `ApiError.for_status` which coerces a
|
|
56
|
+
non-numeric Code to `nil` so dispatch falls through to `:unknown`.
|
|
57
|
+
- **`docs/logging.md`** — short stub stating that `cgminer_api_client`
|
|
58
|
+
is intentionally silent: no `Logger` module, no structured log
|
|
59
|
+
events. The library raises on failure and returns result objects
|
|
60
|
+
on success; callers (`cgminer_monitor`, `cgminer_manager`, the
|
|
61
|
+
operator CLIs) own the log call sites. Points at
|
|
62
|
+
`cgminer_monitor/docs/log_schema.md` for the cross-repo schema
|
|
63
|
+
contract and names the events (`poll.miner_failed`,
|
|
64
|
+
`poll.unexpected_error`) that surface api_client exception classes.
|
|
65
|
+
- **`bundle-audit` in CI** (`.github/workflows/ci.yml`). New `audit`
|
|
66
|
+
job runs `bundle exec bundle-audit check --update` on every push
|
|
67
|
+
and PR, gating merges on known CVEs in `Gemfile.lock`. Advisory
|
|
68
|
+
DB is refreshed on each run from `rubysec/ruby-advisory-db`. Also
|
|
69
|
+
available locally as `bundle exec rake audit`.
|
|
70
|
+
- **Dependabot config** (`.github/dependabot.yml`). Weekly bump PRs
|
|
71
|
+
for Bundler and GitHub Actions, with `open-pull-requests-limit: 3`
|
|
72
|
+
per ecosystem. `versioning-strategy: lockfile-only` on bundler, so
|
|
73
|
+
Gemfile / gemspec constraints are never auto-widened — humans
|
|
74
|
+
widen `~>` bounds intentionally. Targets `develop` so bumps flow
|
|
75
|
+
through the normal release cycle alongside feature work.
|
|
76
|
+
- **`-v` / `--verbose` flag on the `cgminer_api_client` CLI.** Logs the
|
|
77
|
+
JSON request and raw response to stderr, one line each, with a
|
|
78
|
+
`host:port` prefix so multi-miner fan-out output stays grep-able. The
|
|
79
|
+
formatted result still goes to stdout unchanged. Parsed via
|
|
80
|
+
`OptionParser#permute!`, so the flag works before or after the
|
|
81
|
+
command. Passwords in `addpool`, `setconfig`, `ascset`, and `pgaset`
|
|
82
|
+
are replaced with `[REDACTED]` in the log output (wire bytes are
|
|
83
|
+
unaffected).
|
|
84
|
+
- **`on_wire:` kwarg on `Miner#initialize` and `MinerPool#initialize`.**
|
|
85
|
+
Library-level hook used by the CLI's `-v` flag. Accepts a Proc of
|
|
86
|
+
shape `(direction, host, port, payload)` where direction is
|
|
87
|
+
`:request`, `:response`, or `:response_repaired`. Default `nil` is a
|
|
88
|
+
no-op; library code does not write to stderr itself.
|
|
89
|
+
|
|
90
|
+
### Changed
|
|
91
|
+
- **`Miner::Commands::Privileged#access_denied?`** now raises
|
|
92
|
+
`CgminerApiClient::ApiError` with message `'access denied'`
|
|
93
|
+
instead of a bare `RuntimeError` with message `'access_denied'`.
|
|
94
|
+
Callers using `rescue CgminerApiClient::Error` or
|
|
95
|
+
`rescue StandardError` are unaffected; only code that specifically
|
|
96
|
+
pattern-matched on `RuntimeError` from a privileged command on an
|
|
97
|
+
unprivileged miner needs to update.
|
|
98
|
+
- **`MinerPool#load_miners!`** now raises `CgminerApiClient::Error`
|
|
99
|
+
(was `RuntimeError`) when `config/miners.yml` is missing. Existing
|
|
100
|
+
`rescue StandardError` clauses still work.
|
|
101
|
+
- Test-support code (FakeCgminer, CgminerFixtures) extracted to the
|
|
102
|
+
shared `cgminer_test_support` gem. Spec references now use
|
|
103
|
+
`CgminerTestSupport::FakeCgminer` and
|
|
104
|
+
`CgminerTestSupport::Fixtures::SUMMARY` etc. `script/fake_cgminer`
|
|
105
|
+
is now a thin shim that delegates to `bundle exec fake_cgminer`;
|
|
106
|
+
operator muscle memory unchanged.
|
|
107
|
+
|
|
108
|
+
### Fixed
|
|
109
|
+
- **`MinerPool` no longer silently defaults a miners.yml entry
|
|
110
|
+
missing `host` to `CgminerApiClient.default_host`.** A typo'd
|
|
111
|
+
config entry like `{port: 4028}` used to quietly produce a Miner
|
|
112
|
+
pointing at `127.0.0.1`; now raises
|
|
113
|
+
`CgminerApiClient::Error "config/miners.yml: entry N is missing 'host'"`
|
|
114
|
+
on `MinerPool.new`.
|
|
115
|
+
|
|
8
116
|
## [0.3.0] - 2026-04-07
|
|
9
117
|
|
|
10
118
|
### Removed
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/jramos/cgminer_api_client/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
A gem that allows sending API commands to a pool of [cgminer](https://github.com/ckolivas/cgminer) instances.
|
|
5
|
+
A gem that allows sending API commands to a pool of [cgminer](https://github.com/ckolivas/cgminer) instances. Ships as both a Ruby library and a CLI (`cgminer_api_client <command>`). Zero runtime dependencies beyond the Ruby standard library.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
@@ -131,12 +131,54 @@ distinct conditions, unlike in 0.2.x where they were conflated.
|
|
|
131
131
|
pool.restart # PoolResult of per-miner outcomes
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
### Errors
|
|
135
|
+
|
|
136
|
+
All gem-specific errors descend from `CgminerApiClient::Error < StandardError`:
|
|
137
|
+
|
|
138
|
+
- `CgminerApiClient::ConnectionError` — transport-level: the miner
|
|
139
|
+
was unreachable (DNS failure, connection refused, etc.).
|
|
140
|
+
- `CgminerApiClient::TimeoutError` — a `ConnectionError` subclass
|
|
141
|
+
for connect timeouts specifically.
|
|
142
|
+
- `CgminerApiClient::ApiError` — protocol-level: the miner answered
|
|
143
|
+
and returned a `STATUS=E`/`F` response.
|
|
144
|
+
|
|
145
|
+
`rescue CgminerApiClient::Error` catches everything gem-specific.
|
|
146
|
+
`rescue CgminerApiClient::ConnectionError` catches both generic
|
|
147
|
+
transport failures and connect timeouts. A `MinerPool` query never
|
|
148
|
+
raises per-miner errors — they land on the corresponding
|
|
149
|
+
`MinerResult.failure` inside the returned `PoolResult`.
|
|
150
|
+
|
|
134
151
|
## CLI Usage
|
|
135
152
|
|
|
136
153
|
API commands can be sent to your miner pool from the command line.
|
|
137
154
|
|
|
138
155
|
$ cgminer_api_client <command> (<arguments>)
|
|
139
156
|
|
|
157
|
+
### Exit Codes and Streams
|
|
158
|
+
|
|
159
|
+
- exit `0` — at least one miner's command succeeded.
|
|
160
|
+
- exit `1` — every miner failed, or a top-level exception bubbled up.
|
|
161
|
+
- exit `64` — unknown command or missing command argument (`EX_USAGE`).
|
|
162
|
+
|
|
163
|
+
Per-miner responses are printed to stdout with a `host:port:` header.
|
|
164
|
+
Per-miner errors are printed to stderr as
|
|
165
|
+
`host:port: ErrorClass: message`. Set `DEBUG=1` to also print full
|
|
166
|
+
backtraces for any top-level exception:
|
|
167
|
+
|
|
168
|
+
$ DEBUG=1 cgminer_api_client summary
|
|
169
|
+
|
|
170
|
+
Pass `-v` / `--verbose` to log the JSON request and raw response to
|
|
171
|
+
stderr for wire-level debugging. Each line carries a `host:port`
|
|
172
|
+
prefix so fan-out across multiple miners stays grep-able:
|
|
173
|
+
|
|
174
|
+
$ cgminer_api_client -v summary
|
|
175
|
+
>>> 10.0.0.1:4028 {"command":"summary"}
|
|
176
|
+
<<< 10.0.0.1:4028 {"STATUS":[{"STATUS":"S",...}],"SUMMARY":[...]}
|
|
177
|
+
|
|
178
|
+
Password-bearing arguments to `addpool`, `setconfig`, `ascset`, and
|
|
179
|
+
`pgaset` are replaced with `[REDACTED]` in the log output (the real
|
|
180
|
+
value is still sent on the wire).
|
|
181
|
+
|
|
140
182
|
### Commands & Arguments
|
|
141
183
|
|
|
142
184
|
#### Read-Only
|
|
@@ -201,6 +243,13 @@ The following privileged miner and pool commands are currently available:
|
|
|
201
243
|
|
|
202
244
|
Any cgminer API commands not explictly defined above are implemented using `method_missing`. A complete list of available API commands and options can be found in the [cgminer API-README](https://github.com/ckolivas/cgminer/blob/master/API-README).
|
|
203
245
|
|
|
246
|
+
## Further Reading
|
|
247
|
+
|
|
248
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — release history and the 0.2.x → 0.3.0 migration guide.
|
|
249
|
+
- [`AGENTS.md`](AGENTS.md) — context for AI coding assistants; also a useful conventions-and-extension guide for human contributors.
|
|
250
|
+
- [`docs/`](docs/) — topic-split deep dives on architecture, components, interfaces, data models, workflows, and dependencies. Start with [`docs/index.md`](docs/index.md).
|
|
251
|
+
- [cgminer API-README](https://github.com/ckolivas/cgminer/blob/master/API-README) — upstream documentation for the JSON API surface this gem wraps.
|
|
252
|
+
|
|
204
253
|
## Contributing
|
|
205
254
|
|
|
206
255
|
1. Fork it ( <https://github.com/jramos/cgminer_api_client/fork> )
|
data/bin/cgminer_api_client
CHANGED
|
@@ -5,20 +5,62 @@ $LOAD_PATH.unshift("#{File.dirname(__FILE__)}/../lib/")
|
|
|
5
5
|
|
|
6
6
|
require 'cgminer_api_client'
|
|
7
7
|
require 'pp'
|
|
8
|
+
require 'optparse'
|
|
9
|
+
|
|
10
|
+
# permute! (rather than order!) lets a flag appear after the command —
|
|
11
|
+
# `cgminer_api_client summary -v` works the same as `-v summary`.
|
|
12
|
+
verbose = false
|
|
13
|
+
begin
|
|
14
|
+
OptionParser.new do |opts|
|
|
15
|
+
opts.banner = 'USAGE: cgminer_api_client [-v|--verbose] command (arguments)'
|
|
16
|
+
opts.on('-v', '--verbose', 'Log JSON request and raw response to stderr') do
|
|
17
|
+
verbose = true
|
|
18
|
+
end
|
|
19
|
+
end.permute!(ARGV)
|
|
20
|
+
rescue OptionParser::ParseError => e
|
|
21
|
+
warn "cgminer_api_client: #{e.message}"
|
|
22
|
+
warn 'USAGE: cgminer_api_client [-v|--verbose] command (arguments)'
|
|
23
|
+
exit 64 # EX_USAGE
|
|
24
|
+
end
|
|
8
25
|
|
|
9
26
|
command = ARGV.shift&.to_sym
|
|
10
27
|
commands = CgminerApiClient::Miner::Commands.instance_methods
|
|
11
28
|
|
|
12
29
|
unless command && commands.include?(command)
|
|
13
|
-
warn 'USAGE: cgminer_api_client command (arguments)'
|
|
30
|
+
warn 'USAGE: cgminer_api_client [-v|--verbose] command (arguments)'
|
|
14
31
|
warn "commands: #{commands.sort.join(', ')}"
|
|
15
32
|
warn ''
|
|
16
33
|
warn 'Set DEBUG=1 to see full backtraces on errors.'
|
|
17
34
|
exit 64 # EX_USAGE
|
|
18
35
|
end
|
|
19
36
|
|
|
37
|
+
# `MinerPool` fans out across miners in separate threads; Ruby's
|
|
38
|
+
# `warn` / `$stderr.write` is not atomic across threads for
|
|
39
|
+
# arbitrary-length payloads, so multi-miner fan-out can interleave
|
|
40
|
+
# mid-JSON without a mutex. The mutex also keeps a request/response
|
|
41
|
+
# pair contiguous. The host:port prefix lets operators grep a single
|
|
42
|
+
# miner out of the mixed stream.
|
|
43
|
+
on_wire = nil
|
|
44
|
+
if verbose
|
|
45
|
+
wire_mutex = Mutex.new
|
|
46
|
+
wire_prefix = {
|
|
47
|
+
request: '>>>',
|
|
48
|
+
response: '<<<',
|
|
49
|
+
response_repaired: '<<< (repaired)'
|
|
50
|
+
}.freeze
|
|
51
|
+
on_wire = lambda do |direction, host, port, payload|
|
|
52
|
+
wire_mutex.synchronize do
|
|
53
|
+
warn "#{wire_prefix[direction]} #{host}:#{port} #{payload}"
|
|
54
|
+
end
|
|
55
|
+
rescue Errno::EPIPE, IOError
|
|
56
|
+
# stderr was closed mid-run (piped through a command that exited, etc.).
|
|
57
|
+
# Best-effort logging must not take down the query fan-out.
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
20
62
|
begin
|
|
21
|
-
pool = CgminerApiClient::MinerPool.new
|
|
63
|
+
pool = CgminerApiClient::MinerPool.new(on_wire: on_wire)
|
|
22
64
|
result = ARGV.empty? ? pool.query(command) : pool.query(command, *ARGV)
|
|
23
65
|
|
|
24
66
|
result.each do |r|
|
|
@@ -19,5 +19,76 @@ module CgminerApiClient
|
|
|
19
19
|
# Raised when the miner returned a response whose STATUS field
|
|
20
20
|
# indicates an error (cgminer status code 'E' or 'F'). The message
|
|
21
21
|
# contains the cgminer code and message verbatim.
|
|
22
|
-
|
|
22
|
+
#
|
|
23
|
+
# Carries two structured fields for dispatch: callers `case e.code`
|
|
24
|
+
# instead of parsing English messages. The integer (#cgminer_code)
|
|
25
|
+
# is preserved verbatim from cgminer; the symbol (#code) is
|
|
26
|
+
# best-effort — cgminer's MSG enum names are stable but the
|
|
27
|
+
# integers occasionally shift between firmware versions, so add a
|
|
28
|
+
# row to CGMINER_CODES when you find a wire-observed integer worth
|
|
29
|
+
# dispatching on.
|
|
30
|
+
#
|
|
31
|
+
# Prefer #code for dispatch over #cgminer_code: paths that raise
|
|
32
|
+
# without a wire integer (the access_denied? local guard's call
|
|
33
|
+
# to #privileged hits the wire, but the rescue inside #privileged
|
|
34
|
+
# drops the integer) leave #cgminer_code nil while still setting
|
|
35
|
+
# #code consistently.
|
|
36
|
+
#
|
|
37
|
+
# Backward compatibility: `raise ApiError, "msg"` keeps working
|
|
38
|
+
# and #message is unchanged at every emission site.
|
|
39
|
+
class ApiError < Error
|
|
40
|
+
CGMINER_CODES = {
|
|
41
|
+
14 => :invalid_command,
|
|
42
|
+
45 => :access_denied
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
attr_reader :cgminer_code, :code
|
|
46
|
+
|
|
47
|
+
# Factory used at the wire-side emission point in Miner#check_status.
|
|
48
|
+
# Picks AccessDeniedError when the cgminer integer maps to
|
|
49
|
+
# :access_denied so callers can `rescue AccessDeniedError` for
|
|
50
|
+
# the most commonly dispatched-on case; falls back to ApiError
|
|
51
|
+
# for everything else. Wire boundary stays best-effort: a
|
|
52
|
+
# non-numeric Code coerces to nil and the symbolic tag becomes
|
|
53
|
+
# :unknown rather than raising mid-poll.
|
|
54
|
+
def self.for_status(status_code, message)
|
|
55
|
+
cgminer_code = Integer(status_code, exception: false)
|
|
56
|
+
klass = CGMINER_CODES[cgminer_code] == :access_denied ? AccessDeniedError : ApiError
|
|
57
|
+
klass.new("#{status_code}: #{message}", cgminer_code: cgminer_code)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(message = nil, cgminer_code: nil, code: nil)
|
|
61
|
+
# Fail loud at the library boundary on bad input. Without these
|
|
62
|
+
# guards, cgminer_code: "45" or 45.0 silently produces code:
|
|
63
|
+
# :unknown (CGMINER_CODES uses integer keys), and code: 42 raises
|
|
64
|
+
# NoMethodError on .to_sym deep in the constructor — both
|
|
65
|
+
# opaque failure modes. Wire-side callers that want best-effort
|
|
66
|
+
# Integer coercion go through ApiError.for_status.
|
|
67
|
+
unless cgminer_code.nil? || cgminer_code.is_a?(Integer)
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"cgminer_code must be Integer or nil, got #{cgminer_code.class}: #{cgminer_code.inspect}"
|
|
70
|
+
end
|
|
71
|
+
unless code.nil? || code.is_a?(Symbol) || code.is_a?(String)
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"code must be Symbol, String, or nil, got #{code.class}: #{code.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
super(message)
|
|
77
|
+
@cgminer_code = cgminer_code
|
|
78
|
+
@code = (code || CGMINER_CODES[cgminer_code] || :unknown).to_sym
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Specific subclass for cgminer's "access denied" response (STATUS=E
|
|
83
|
+
# Code 45) and the gem's own #access_denied? local guard. Inherits
|
|
84
|
+
# from ApiError so existing `rescue ApiError` clauses still catch
|
|
85
|
+
# it; callers wanting finer dispatch use `rescue AccessDeniedError`
|
|
86
|
+
# instead of `case e.code; when :access_denied`. Constructor pins
|
|
87
|
+
# code: :access_denied so the symbolic tag is consistent regardless
|
|
88
|
+
# of which call site raised.
|
|
89
|
+
class AccessDeniedError < ApiError
|
|
90
|
+
def initialize(message = nil, cgminer_code: nil)
|
|
91
|
+
super(message, cgminer_code: cgminer_code, code: :access_denied)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
23
94
|
end
|
|
@@ -186,7 +186,15 @@ module CgminerApiClient
|
|
|
186
186
|
private
|
|
187
187
|
|
|
188
188
|
def access_denied?
|
|
189
|
-
|
|
189
|
+
# privileged calls query(:privileged), so the wire IS hit; if
|
|
190
|
+
# the miner answers with STATUS=E Code 45, check_status raises
|
|
191
|
+
# AccessDeniedError and privileged rescues + returns false,
|
|
192
|
+
# dropping the cgminer integer in the rescue. Re-raise the
|
|
193
|
+
# specific subclass so callers can't tell which call path
|
|
194
|
+
# raised — `rescue AccessDeniedError` works identically for
|
|
195
|
+
# "real wire denied" and "guard-locally denied". cgminer_code
|
|
196
|
+
# stays nil here because it was discarded by privileged's rescue.
|
|
197
|
+
raise CgminerApiClient::AccessDeniedError, 'access denied' unless privileged
|
|
190
198
|
|
|
191
199
|
false
|
|
192
200
|
end
|
|
@@ -10,27 +10,29 @@ module CgminerApiClient
|
|
|
10
10
|
|
|
11
11
|
attr_accessor :host, :port, :timeout
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# Positional parameters at these indices carry user-controlled values
|
|
14
|
+
# (pool passwords, setconfig values, ascset/pgaset option values)
|
|
15
|
+
# that should not appear verbatim in wire-logs. The wire request is
|
|
16
|
+
# never modified; only the copy passed to the on_wire callback is
|
|
17
|
+
# redacted. If a new privileged command is added that accepts a
|
|
18
|
+
# secret positional arg, register its index here.
|
|
19
|
+
REDACTED_PARAM_INDEX = {
|
|
20
|
+
addpool: 2,
|
|
21
|
+
setconfig: 1,
|
|
22
|
+
ascset: 2,
|
|
23
|
+
pgaset: 2
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def initialize(host = nil, port = nil, timeout = nil, on_wire: nil)
|
|
14
27
|
@host = host || CgminerApiClient.default_host
|
|
15
28
|
@port = port || CgminerApiClient.default_port
|
|
16
29
|
@timeout = timeout || CgminerApiClient.default_timeout
|
|
30
|
+
@on_wire = on_wire
|
|
17
31
|
end
|
|
18
32
|
|
|
19
33
|
def query(method, *params)
|
|
20
|
-
request =
|
|
21
|
-
|
|
22
|
-
unless params.empty?
|
|
23
|
-
# cgminer uses comma to separate parameters, so any literal commas in
|
|
24
|
-
# parameter values must be backslash-escaped, and any literal
|
|
25
|
-
# backslashes must themselves be doubled. The block form of gsub is
|
|
26
|
-
# used so the replacement string isn't interpreted (in gsub's
|
|
27
|
-
# replacement-string syntax, '\\' means a single literal backslash,
|
|
28
|
-
# which makes the obvious gsub('\\', '\\\\') a silent no-op).
|
|
29
|
-
params = params.map { |p| p.to_s.gsub('\\') { '\\\\' }.gsub(',') { '\\,' } }
|
|
30
|
-
request[:parameter] = params.join(',')
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
response = perform_request(request)
|
|
34
|
+
request, loggable_request = build_requests(method, params)
|
|
35
|
+
response = perform_request(request, loggable_request: loggable_request)
|
|
34
36
|
data = sanitized(response)
|
|
35
37
|
method.to_s.match?('\+') ? data : data[method.to_sym]
|
|
36
38
|
end
|
|
@@ -60,21 +62,77 @@ module CgminerApiClient
|
|
|
60
62
|
|
|
61
63
|
private
|
|
62
64
|
|
|
63
|
-
def
|
|
65
|
+
def build_requests(method, params)
|
|
66
|
+
return [{ command: method }, { command: method }] if params.empty?
|
|
67
|
+
|
|
68
|
+
escaped = params.map { |p| escape_param(p) }
|
|
69
|
+
loggable_escaped = redact_params(method, params).map { |p| escape_param(p) }
|
|
70
|
+
|
|
71
|
+
[
|
|
72
|
+
{ command: method, parameter: escaped.join(',') },
|
|
73
|
+
{ command: method, parameter: loggable_escaped.join(',') }
|
|
74
|
+
]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# cgminer uses comma to separate parameters, so any literal commas in
|
|
78
|
+
# parameter values must be backslash-escaped, and any literal
|
|
79
|
+
# backslashes must themselves be doubled. The block form of gsub is
|
|
80
|
+
# used so the replacement string isn't interpreted (in gsub's
|
|
81
|
+
# replacement-string syntax, '\\' means a single literal backslash,
|
|
82
|
+
# which makes the obvious gsub('\\', '\\\\') a silent no-op).
|
|
83
|
+
def escape_param(param)
|
|
84
|
+
param.to_s.gsub('\\') { '\\\\' }.gsub(',') { '\\,' }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def redact_params(method, params)
|
|
88
|
+
idx = REDACTED_PARAM_INDEX[method.to_sym]
|
|
89
|
+
return params unless idx && params[idx]
|
|
90
|
+
|
|
91
|
+
redacted = params.dup
|
|
92
|
+
redacted[idx] = '[REDACTED]'
|
|
93
|
+
redacted
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# on_wire is best-effort telemetry — a callback that raises must
|
|
97
|
+
# not break the real query path or leak the connection. Operators
|
|
98
|
+
# who suspect their callback is broken can remove -v to isolate.
|
|
99
|
+
def safe_on_wire(direction, payload)
|
|
100
|
+
return unless @on_wire
|
|
101
|
+
|
|
102
|
+
@on_wire.call(direction, @host, @port, payload)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def perform_request(request, loggable_request: request)
|
|
64
108
|
begin
|
|
65
109
|
s = open_socket(@host, @port, @timeout)
|
|
66
110
|
rescue StandardError => e
|
|
67
111
|
raise ConnectionError, "Connection to #{@host}:#{@port} failed: #{e.class}: #{e.message}"
|
|
68
112
|
end
|
|
69
113
|
|
|
114
|
+
safe_on_wire(:request, loggable_request.to_json)
|
|
70
115
|
s.write(request.to_json)
|
|
71
116
|
response = s.read.strip.chars.map { |c| c.ord >= 32 ? c : format('\\u%04x', c.ord) }.join
|
|
72
117
|
s.close
|
|
118
|
+
safe_on_wire(:response, response)
|
|
119
|
+
|
|
120
|
+
# Legacy defensive repair for malformed multi-object responses. We
|
|
121
|
+
# haven't reproduced a case where this actually fires on modern
|
|
122
|
+
# cgminer; see spec/support/cgminer_fixtures.rb for commentary.
|
|
123
|
+
# Keep in place until we can confirm it isn't needed on real traffic.
|
|
124
|
+
# If the repair ever fires, emit an additional :response_repaired
|
|
125
|
+
# callback so a broken-looking JSON log isn't mysterious.
|
|
126
|
+
repaired = response.gsub('}{', '}, {').gsub('[,{', '[ {')
|
|
127
|
+
if repaired != response
|
|
128
|
+
safe_on_wire(:response_repaired, repaired)
|
|
129
|
+
response = repaired
|
|
130
|
+
end
|
|
73
131
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
data = JSON.parse(response)
|
|
132
|
+
# json 3.0 rejects duplicate keys by default; keep json 2.x's
|
|
133
|
+
# last-one-wins behavior since some cgminer forks repeat keys.
|
|
134
|
+
# Older json versions ignore the option.
|
|
135
|
+
data = JSON.parse(response, allow_duplicate_key: true)
|
|
78
136
|
|
|
79
137
|
if request[:command].to_s.match?('\+')
|
|
80
138
|
data.each_pair do |_command, response|
|
|
@@ -94,9 +152,12 @@ module CgminerApiClient
|
|
|
94
152
|
msg = status['Msg']
|
|
95
153
|
|
|
96
154
|
# cgminer STATUS codes: S=Success (silent), I=Info, W=Warning,
|
|
97
|
-
# E=Error, F=Fatal. Errors and Fatals raise ApiError
|
|
98
|
-
#
|
|
99
|
-
#
|
|
155
|
+
# E=Error, F=Fatal. Errors and Fatals raise via ApiError.for_status
|
|
156
|
+
# which picks AccessDeniedError for Code 45 (so callers can
|
|
157
|
+
# `rescue AccessDeniedError`) and falls back to ApiError otherwise.
|
|
158
|
+
# The wire boundary stays best-effort — non-numeric Codes coerce
|
|
159
|
+
# to nil and the symbolic tag becomes :unknown rather than
|
|
160
|
+
# raising mid-poll.
|
|
100
161
|
case sc
|
|
101
162
|
when 'S'
|
|
102
163
|
# no-op: success needs no notification
|
|
@@ -105,7 +166,7 @@ module CgminerApiClient
|
|
|
105
166
|
when 'W'
|
|
106
167
|
puts "Warning from API [#{c}]: #{msg}"
|
|
107
168
|
else
|
|
108
|
-
raise ApiError,
|
|
169
|
+
raise ApiError.for_status(c, msg)
|
|
109
170
|
end
|
|
110
171
|
end
|
|
111
172
|
|
|
@@ -6,7 +6,8 @@ module CgminerApiClient
|
|
|
6
6
|
|
|
7
7
|
attr_accessor :miners
|
|
8
8
|
|
|
9
|
-
def initialize
|
|
9
|
+
def initialize(on_wire: nil)
|
|
10
|
+
@on_wire = on_wire
|
|
10
11
|
load_miners!
|
|
11
12
|
end
|
|
12
13
|
|
|
@@ -99,14 +100,20 @@ module CgminerApiClient
|
|
|
99
100
|
end
|
|
100
101
|
|
|
101
102
|
def load_miners!
|
|
102
|
-
raise 'Please create config/miners.yml' unless File.exist?('config/miners.yml')
|
|
103
|
+
raise CgminerApiClient::Error, 'Please create config/miners.yml' unless File.exist?('config/miners.yml')
|
|
103
104
|
|
|
104
105
|
miners_config = YAML.safe_load_file('config/miners.yml')
|
|
105
|
-
@miners = miners_config.
|
|
106
|
+
@miners = miners_config.each_with_index.map do |entry, index|
|
|
107
|
+
unless entry.is_a?(Hash) && entry['host']
|
|
108
|
+
raise CgminerApiClient::Error,
|
|
109
|
+
"config/miners.yml: entry #{index} is missing 'host'"
|
|
110
|
+
end
|
|
111
|
+
|
|
106
112
|
CgminerApiClient::Miner.new(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
entry['host'],
|
|
114
|
+
entry['port'],
|
|
115
|
+
entry['timeout'],
|
|
116
|
+
on_wire: @on_wire
|
|
110
117
|
)
|
|
111
118
|
end
|
|
112
119
|
end
|