cgminer_api_client 0.2.6 → 0.4.0
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 +385 -0
- data/README.md +124 -43
- data/bin/cgminer_api_client +70 -9
- data/cgminer_api_client.gemspec +25 -6
- data/config/miners.yml.example +2 -1
- data/lib/cgminer_api_client/errors.rb +94 -0
- data/lib/cgminer_api_client/miner/commands.rb +31 -20
- data/lib/cgminer_api_client/miner.rb +123 -44
- data/lib/cgminer_api_client/miner_pool.rb +84 -31
- data/lib/cgminer_api_client/miner_result.rb +51 -0
- data/lib/cgminer_api_client/pool_result.rb +94 -0
- data/lib/cgminer_api_client/socket_with_timeout.rb +11 -5
- data/lib/cgminer_api_client/version.rb +3 -1
- data/lib/cgminer_api_client.rb +9 -7
- metadata +17 -26
- data/.gitignore +0 -16
- data/.rspec +0 -3
- data/.travis.yml +0 -5
- data/.whitesource +0 -8
- data/Gemfile +0 -10
- data/Rakefile +0 -7
- data/spec/cgminer_api_client/miner/commands_spec.rb +0 -501
- data/spec/cgminer_api_client/miner_pool_spec.rb +0 -134
- data/spec/cgminer_api_client/miner_spec.rb +0 -296
- data/spec/cgminer_api_client_spec.rb +0 -41
- data/spec/spec_helper.rb +0 -25
data/bin/cgminer_api_client
CHANGED
|
@@ -1,22 +1,83 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
$LOAD_PATH.unshift("#{File.dirname(__FILE__)}/../lib/")
|
|
4
5
|
|
|
5
6
|
require 'cgminer_api_client'
|
|
6
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
|
|
7
25
|
|
|
8
26
|
command = ARGV.shift&.to_sym
|
|
9
27
|
commands = CgminerApiClient::Miner::Commands.instance_methods
|
|
10
28
|
|
|
11
|
-
unless command
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
29
|
+
unless command && commands.include?(command)
|
|
30
|
+
warn 'USAGE: cgminer_api_client [-v|--verbose] command (arguments)'
|
|
31
|
+
warn "commands: #{commands.sort.join(', ')}"
|
|
32
|
+
warn ''
|
|
33
|
+
warn 'Set DEBUG=1 to see full backtraces on errors.'
|
|
34
|
+
exit 64 # EX_USAGE
|
|
35
|
+
end
|
|
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
|
|
15
60
|
end
|
|
16
61
|
|
|
17
62
|
begin
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
63
|
+
pool = CgminerApiClient::MinerPool.new(on_wire: on_wire)
|
|
64
|
+
result = ARGV.empty? ? pool.query(command) : pool.query(command, *ARGV)
|
|
65
|
+
|
|
66
|
+
result.each do |r|
|
|
67
|
+
if r.ok?
|
|
68
|
+
puts "#{r.miner.host}:#{r.miner.port}:"
|
|
69
|
+
pp r.value
|
|
70
|
+
else
|
|
71
|
+
warn "#{r.miner.host}:#{r.miner.port}: #{r.error.class}: #{r.error.message}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Exit 0 if at least one miner succeeded; 1 only if every miner
|
|
76
|
+
# failed. Matches how fleet-oriented CLIs (ping, ssh -o) typically
|
|
77
|
+
# behave — partial failures don't trigger alerts.
|
|
78
|
+
exit(result.any_succeeded? ? 0 : 1)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
warn "cgminer_api_client: #{e.class}: #{e.message}"
|
|
81
|
+
warn e.full_message(highlight: false) if ENV['DEBUG']
|
|
82
|
+
exit 1
|
|
83
|
+
end
|
data/cgminer_api_client.gemspec
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
|
3
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
5
|
require 'cgminer_api_client/version'
|
|
5
6
|
|
|
@@ -8,13 +9,31 @@ Gem::Specification.new do |spec|
|
|
|
8
9
|
spec.version = CgminerApiClient::VERSION
|
|
9
10
|
spec.authors = ["Justin Ramos"]
|
|
10
11
|
spec.email = ["justin.ramos@gmail.com"]
|
|
11
|
-
spec.summary =
|
|
12
|
-
spec.description =
|
|
12
|
+
spec.summary = "A gem that allows sending API commands to a pool of cgminer instances"
|
|
13
|
+
spec.description = "Ruby client for the cgminer JSON API. Supports querying a single miner " \
|
|
14
|
+
"or a pool of miners in parallel, with full coverage of read-only and " \
|
|
15
|
+
"privileged commands."
|
|
13
16
|
spec.homepage = "https://github.com/jramos/cgminer_api_client"
|
|
14
17
|
spec.license = "MIT"
|
|
15
18
|
|
|
16
|
-
spec.
|
|
19
|
+
spec.required_ruby_version = ">= 3.2"
|
|
20
|
+
|
|
21
|
+
spec.metadata = {
|
|
22
|
+
"source_code_uri" => spec.homepage,
|
|
23
|
+
"changelog_uri" => "#{spec.homepage}/blob/master/CHANGELOG.md",
|
|
24
|
+
"bug_tracker_uri" => "#{spec.homepage}/issues",
|
|
25
|
+
"rubygems_mfa_required" => "true"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
spec.files = Dir.glob([
|
|
29
|
+
"lib/**/*.rb",
|
|
30
|
+
"bin/*",
|
|
31
|
+
"config/*.example",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE.txt",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"cgminer_api_client.gemspec"
|
|
36
|
+
])
|
|
17
37
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
18
|
-
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
19
38
|
spec.require_paths = ["lib"]
|
|
20
39
|
end
|
data/config/miners.yml.example
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CgminerApiClient
|
|
4
|
+
# Base class for all errors raised by the gem. Catch this if you want
|
|
5
|
+
# to handle every cgminer-specific failure together.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when the gem cannot reach a miner: socket open failure, DNS
|
|
9
|
+
# failure, connect timeout, or any other transport-level problem.
|
|
10
|
+
# Distinct from ApiError so callers can tell "I never spoke to the
|
|
11
|
+
# miner" apart from "the miner spoke to me and refused."
|
|
12
|
+
class ConnectionError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised specifically for connect-timeout failures, as a subclass
|
|
15
|
+
# of ConnectionError. Lets callers distinguish "the miner took too
|
|
16
|
+
# long to answer the SYN" from other connection-layer problems.
|
|
17
|
+
class TimeoutError < ConnectionError; end
|
|
18
|
+
|
|
19
|
+
# Raised when the miner returned a response whose STATUS field
|
|
20
|
+
# indicates an error (cgminer status code 'E' or 'F'). The message
|
|
21
|
+
# contains the cgminer code and message verbatim.
|
|
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
|
|
94
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module CgminerApiClient
|
|
2
4
|
class Miner
|
|
3
5
|
module Commands
|
|
@@ -43,11 +45,14 @@ module CgminerApiClient
|
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
def privileged
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
query(:privileged)
|
|
49
|
+
true
|
|
50
|
+
rescue CgminerApiClient::ApiError
|
|
51
|
+
# The miner answered and rejected: not privileged.
|
|
52
|
+
false
|
|
53
|
+
# ConnectionError and any other StandardError propagate so
|
|
54
|
+
# callers don't misinterpret a transient network blip as
|
|
55
|
+
# "access denied".
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
def notify
|
|
@@ -86,9 +91,9 @@ module CgminerApiClient
|
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
def ascset(number, option, value = nil)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
return if access_denied?
|
|
95
|
+
|
|
96
|
+
value ? query(:ascset, number, option, value) : query(:ascset, number, option)
|
|
92
97
|
end
|
|
93
98
|
end
|
|
94
99
|
|
|
@@ -106,9 +111,9 @@ module CgminerApiClient
|
|
|
106
111
|
end
|
|
107
112
|
|
|
108
113
|
def pgaset(number, option, value = nil)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
return if access_denied?
|
|
115
|
+
|
|
116
|
+
value ? query(:pgaset, number, option, value) : query(:pgaset, number, option)
|
|
112
117
|
end
|
|
113
118
|
end
|
|
114
119
|
|
|
@@ -164,9 +169,9 @@ module CgminerApiClient
|
|
|
164
169
|
end
|
|
165
170
|
|
|
166
171
|
def save(filename = nil)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
172
|
+
return if access_denied?
|
|
173
|
+
|
|
174
|
+
filename ? query(:save, filename) : query(:save)
|
|
170
175
|
end
|
|
171
176
|
|
|
172
177
|
def setconfig(name, value)
|
|
@@ -181,11 +186,17 @@ module CgminerApiClient
|
|
|
181
186
|
private
|
|
182
187
|
|
|
183
188
|
def access_denied?
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
198
|
+
|
|
199
|
+
false
|
|
189
200
|
end
|
|
190
201
|
|
|
191
202
|
include Miner::Commands::Privileged::Asc
|
|
@@ -198,4 +209,4 @@ module CgminerApiClient
|
|
|
198
209
|
include Miner::Commands::Privileged
|
|
199
210
|
end
|
|
200
211
|
end
|
|
201
|
-
end
|
|
212
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'cgminer_api_client/socket_with_timeout'
|
|
2
4
|
require 'cgminer_api_client/miner/commands'
|
|
3
5
|
|
|
@@ -8,91 +10,168 @@ module CgminerApiClient
|
|
|
8
10
|
|
|
9
11
|
attr_accessor :host, :port, :timeout
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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)
|
|
27
|
+
@host = host || CgminerApiClient.default_host
|
|
28
|
+
@port = port || CgminerApiClient.default_port
|
|
29
|
+
@timeout = timeout || CgminerApiClient.default_timeout
|
|
30
|
+
@on_wire = on_wire
|
|
15
31
|
end
|
|
16
32
|
|
|
17
33
|
def query(method, *params)
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
request, loggable_request = build_requests(method, params)
|
|
35
|
+
response = perform_request(request, loggable_request: loggable_request)
|
|
36
|
+
data = sanitized(response)
|
|
37
|
+
method.to_s.match?('\+') ? data : data[method.to_sym]
|
|
38
|
+
end
|
|
20
39
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
# Reachability probe. Opens a fresh socket every call — no
|
|
41
|
+
# caching. Returns true on a successful connect, false on
|
|
42
|
+
# transport-level failure (DNS, refused, unreachable, timeout).
|
|
43
|
+
# Bugs like ArgumentError or NoMethodError propagate instead
|
|
44
|
+
# of being silently swallowed.
|
|
45
|
+
def available?
|
|
46
|
+
open_socket(@host, @port, @timeout).close
|
|
47
|
+
true
|
|
48
|
+
rescue SocketError, SystemCallError, CgminerApiClient::TimeoutError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
25
51
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
method.to_s.match('\+') ? data : data[method.to_sym]
|
|
29
|
-
end
|
|
52
|
+
def method_missing(name, *)
|
|
53
|
+
query(name, *)
|
|
30
54
|
end
|
|
31
55
|
|
|
32
|
-
|
|
33
|
-
|
|
56
|
+
# method_missing forwards everything to query as a cgminer command,
|
|
57
|
+
# so respond to anything except names that look like Ruby internals
|
|
58
|
+
# or implicit conversion probes (to_ary, to_str, to_int, to_hash, ...).
|
|
59
|
+
def respond_to_missing?(name, _include_private = false)
|
|
60
|
+
!name.to_s.start_with?('to_', '_')
|
|
61
|
+
end
|
|
34
62
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
private
|
|
64
|
+
|
|
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
|
+
]
|
|
41
75
|
end
|
|
42
76
|
|
|
43
|
-
|
|
44
|
-
|
|
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(',') { '\\,' }
|
|
45
85
|
end
|
|
46
86
|
|
|
47
|
-
|
|
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
|
|
48
106
|
|
|
49
|
-
def perform_request(request)
|
|
107
|
+
def perform_request(request, loggable_request: request)
|
|
50
108
|
begin
|
|
51
109
|
s = open_socket(@host, @port, @timeout)
|
|
52
|
-
rescue
|
|
53
|
-
raise "Connection to #{@host}:#{@port} failed"
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
raise ConnectionError, "Connection to #{@host}:#{@port} failed: #{e.class}: #{e.message}"
|
|
54
112
|
end
|
|
55
113
|
|
|
114
|
+
safe_on_wire(:request, loggable_request.to_json)
|
|
56
115
|
s.write(request.to_json)
|
|
57
|
-
response = s.read.strip.chars.map { |c| c.ord >= 32 ? c :
|
|
116
|
+
response = s.read.strip.chars.map { |c| c.ord >= 32 ? c : format('\\u%04x', c.ord) }.join
|
|
58
117
|
s.close
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
62
131
|
|
|
63
132
|
data = JSON.parse(response)
|
|
64
133
|
|
|
65
|
-
if request[:command].to_s.match('\+')
|
|
66
|
-
data.each_pair do |
|
|
134
|
+
if request[:command].to_s.match?('\+')
|
|
135
|
+
data.each_pair do |_command, response|
|
|
67
136
|
check_status(response.first) if response.respond_to?(:first)
|
|
68
137
|
end
|
|
69
138
|
else
|
|
70
139
|
check_status(data)
|
|
71
140
|
end
|
|
72
141
|
|
|
73
|
-
|
|
142
|
+
data
|
|
74
143
|
end
|
|
75
144
|
|
|
76
145
|
def check_status(data)
|
|
77
|
-
status =
|
|
146
|
+
status = data['STATUS'][0]
|
|
78
147
|
sc = status['STATUS']
|
|
79
148
|
c = status['Code']
|
|
80
149
|
msg = status['Msg']
|
|
81
150
|
|
|
151
|
+
# cgminer STATUS codes: S=Success (silent), I=Info, W=Warning,
|
|
152
|
+
# E=Error, F=Fatal. Errors and Fatals raise via ApiError.for_status
|
|
153
|
+
# which picks AccessDeniedError for Code 45 (so callers can
|
|
154
|
+
# `rescue AccessDeniedError`) and falls back to ApiError otherwise.
|
|
155
|
+
# The wire boundary stays best-effort — non-numeric Codes coerce
|
|
156
|
+
# to nil and the symbolic tag becomes :unknown rather than
|
|
157
|
+
# raising mid-poll.
|
|
82
158
|
case sc
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
159
|
+
when 'S'
|
|
160
|
+
# no-op: success needs no notification
|
|
161
|
+
when 'I'
|
|
162
|
+
puts "Info from API [#{c}]: #{msg}"
|
|
163
|
+
when 'W'
|
|
164
|
+
puts "Warning from API [#{c}]: #{msg}"
|
|
165
|
+
else
|
|
166
|
+
raise ApiError.for_status(c, msg)
|
|
90
167
|
end
|
|
91
168
|
end
|
|
92
169
|
|
|
93
170
|
def sanitized(data)
|
|
94
171
|
if data.is_a?(Hash)
|
|
95
|
-
data.
|
|
172
|
+
data.each_with_object({}) do |(k, v), n|
|
|
173
|
+
n[k.to_s.downcase.tr(' ', '_').to_sym] = sanitized(v)
|
|
174
|
+
end
|
|
96
175
|
elsif data.is_a?(Array)
|
|
97
176
|
data.map { |v| sanitized(v) }
|
|
98
177
|
else
|