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.
@@ -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 and commands.include?(command)
12
- puts "USAGE: cgminer_api_client command (arguments)"
13
- puts "commands: " + commands.sort.collect(&:to_s).join(', ')
14
- exit(0)
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
- miner_pool = CgminerApiClient::MinerPool.new
19
- pp(ARGV.empty? ? miner_pool.query(command) : miner_pool.query(command, *ARGV))
20
- rescue => e
21
- puts e.message
22
- end
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
@@ -1,5 +1,6 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
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 = %q{A gem that allows sending API commands to a pool of cgminer instances}
12
- spec.description = %q{}
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.files = `git ls-files -z`.split("\x0")
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
@@ -1,2 +1,3 @@
1
1
  - host: 127.0.0.1
2
- port: 4028
2
+ port: 4028
3
+ timeout: 5
@@ -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
- query(:privileged)
47
- true
48
- rescue
49
- false
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
- unless access_denied?
90
- value ? query(:ascset, number, option, value) : query(:ascset, number, option)
91
- end
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
- unless access_denied?
110
- value ? query(:pgaset, number, option, value) : query(:pgaset, number, option)
111
- end
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
- unless access_denied?
168
- filename ? query(:save, filename) : query(:save)
169
- end
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
- if !privileged
185
- raise 'access_denied'
186
- else
187
- return false
188
- end
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
- def initialize(host = nil, port = nil, timeout = nil)
12
- @host = host ? host : CgminerApiClient.default_host
13
- @port = port ? port : CgminerApiClient.default_port
14
- @timeout = timeout ? timeout : CgminerApiClient.default_timeout
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
- if available?
19
- request = {command: method}
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
- unless params.length == 0
22
- params = params.map { |p| p.to_s.gsub('\\', '\\\\').gsub(',', '\,') }
23
- request[:parameter] = params.join(',')
24
- end
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
- response = perform_request(request)
27
- data = sanitized(response)
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
- def available?(force_reload = false)
33
- @available = nil if force_reload
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
- @available ||= begin
36
- open_socket(@host, @port, @timeout).close
37
- true
38
- rescue
39
- false
40
- end
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
- def method_missing(name, *args)
44
- query(name, *args)
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
- private
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 : "\\u#{'%04x' % c.ord}" }.join
116
+ response = s.read.strip.chars.map { |c| c.ord >= 32 ? c : format('\\u%04x', c.ord) }.join
58
117
  s.close
59
-
60
- response.gsub! '}{', '}, {'
61
- response.gsub! '[,{', '[ {'
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 |command, response|
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
- return data
142
+ data
74
143
  end
75
144
 
76
145
  def check_status(data)
77
- status = data['STATUS'][0]
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
- when 'S'
84
- when 'I'
85
- puts "Info from API [#{c}]: #{msg}"
86
- when 'W'
87
- puts "Warning from API [#{c}]: #{msg}"
88
- else
89
- raise "#{c}: #{msg}"
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.inject({}) { |n, (k, v)| n[k.to_s.downcase.tr(' ', '_').to_sym] = sanitized(v); n }
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