raygatherer 0.1.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 +7 -0
- data/.rspec +3 -0
- data/CLAUDE.md +102 -0
- data/CODE_OF_CONDUCT.md +133 -0
- data/LICENSE.txt +21 -0
- data/Makefile +23 -0
- data/README.md +220 -0
- data/Rakefile +8 -0
- data/exe/raygatherer +5 -0
- data/lib/raygatherer/api_client.rb +267 -0
- data/lib/raygatherer/cli.rb +193 -0
- data/lib/raygatherer/commands/alerts.rb +167 -0
- data/lib/raygatherer/commands/analysis/run.rb +81 -0
- data/lib/raygatherer/commands/analysis/status.rb +60 -0
- data/lib/raygatherer/commands/base.rb +41 -0
- data/lib/raygatherer/commands/config/set.rb +76 -0
- data/lib/raygatherer/commands/config/show.rb +61 -0
- data/lib/raygatherer/commands/config/test_notification.rb +52 -0
- data/lib/raygatherer/commands/recording/delete.rb +55 -0
- data/lib/raygatherer/commands/recording/download.rb +167 -0
- data/lib/raygatherer/commands/recording/list.rb +60 -0
- data/lib/raygatherer/commands/recording/start.rb +56 -0
- data/lib/raygatherer/commands/recording/stop.rb +56 -0
- data/lib/raygatherer/commands/stats.rb +58 -0
- data/lib/raygatherer/config.rb +40 -0
- data/lib/raygatherer/format_helpers.rb +19 -0
- data/lib/raygatherer/formatters/analysis_status_human.rb +38 -0
- data/lib/raygatherer/formatters/analysis_status_json.rb +13 -0
- data/lib/raygatherer/formatters/config_human.rb +29 -0
- data/lib/raygatherer/formatters/config_json.rb +13 -0
- data/lib/raygatherer/formatters/human.rb +38 -0
- data/lib/raygatherer/formatters/json.rb +22 -0
- data/lib/raygatherer/formatters/recording_list_human.rb +72 -0
- data/lib/raygatherer/formatters/recording_list_json.rb +13 -0
- data/lib/raygatherer/formatters/stats_human.rb +29 -0
- data/lib/raygatherer/formatters/stats_json.rb +13 -0
- data/lib/raygatherer/spinner.rb +31 -0
- data/lib/raygatherer/version.rb +5 -0
- data/lib/raygatherer.rb +34 -0
- metadata +111 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Raygatherer
|
|
8
|
+
class ApiClient
|
|
9
|
+
class ApiError < StandardError; end
|
|
10
|
+
class ConnectionError < StandardError; end
|
|
11
|
+
class ParseError < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize(host, username: nil, password: nil, verbose: false, stderr: $stderr)
|
|
14
|
+
@host = normalize_host(host)
|
|
15
|
+
@username = username
|
|
16
|
+
@password = password
|
|
17
|
+
@verbose = verbose
|
|
18
|
+
@stderr = stderr
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch_live_analysis_report
|
|
22
|
+
get("/api/analysis-report/live") do |body|
|
|
23
|
+
log_verbose "Parsing NDJSON response..."
|
|
24
|
+
result = parse_ndjson(body)
|
|
25
|
+
log_verbose "Parsed successfully: metadata + #{result[:rows].length} rows"
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch_analysis_report(name)
|
|
31
|
+
encoded = URI.encode_www_form_component(name)
|
|
32
|
+
get("/api/analysis-report/#{encoded}") do |body|
|
|
33
|
+
log_verbose "Parsing NDJSON response..."
|
|
34
|
+
result = parse_ndjson(body)
|
|
35
|
+
log_verbose "Parsed successfully: metadata + #{result[:rows].length} rows"
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fetch_manifest
|
|
41
|
+
get("/api/qmdl-manifest") do |body|
|
|
42
|
+
log_verbose "Parsing JSON response..."
|
|
43
|
+
result = parse_json(body)
|
|
44
|
+
log_verbose "Parsed successfully: #{result["entries"]&.length || 0} entries"
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch_analysis_status
|
|
50
|
+
get("/api/analysis") do |body|
|
|
51
|
+
log_verbose "Parsing JSON response..."
|
|
52
|
+
result = parse_json(body)
|
|
53
|
+
log_verbose "Parsed successfully"
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch_config
|
|
59
|
+
get("/api/config") do |body|
|
|
60
|
+
log_verbose "Parsing JSON response..."
|
|
61
|
+
result = parse_json(body)
|
|
62
|
+
log_verbose "Parsed successfully"
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetch_system_stats
|
|
68
|
+
get("/api/system-stats") do |body|
|
|
69
|
+
log_verbose "Parsing JSON response..."
|
|
70
|
+
result = parse_json(body)
|
|
71
|
+
log_verbose "Parsed successfully"
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def test_notification
|
|
77
|
+
post("/api/test-notification", expected_code: "200")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def set_config(json_body)
|
|
81
|
+
post("/api/config", body: json_body, content_type: "application/json")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def download_recording(name, io:, format: :qmdl)
|
|
85
|
+
encoded = URI.encode_www_form_component(name)
|
|
86
|
+
path = case format
|
|
87
|
+
when :qmdl then "/api/qmdl/#{encoded}"
|
|
88
|
+
when :pcap then "/api/pcap/#{encoded}"
|
|
89
|
+
when :zip then "/api/zip/#{encoded}"
|
|
90
|
+
end
|
|
91
|
+
stream_to(path, io)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def delete_recording(name)
|
|
95
|
+
post("/api/delete-recording/#{URI.encode_www_form_component(name)}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def start_analysis(name)
|
|
99
|
+
encoded = URI.encode_www_form_component(name)
|
|
100
|
+
body = post("/api/analysis/#{encoded}")
|
|
101
|
+
parse_json(body)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def stop_recording
|
|
105
|
+
post("/api/stop-recording")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def start_recording
|
|
109
|
+
post("/api/start-recording")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def get(path)
|
|
115
|
+
response, body = request(:get, path, ok_code: "200", ok_status_text: "OK")
|
|
116
|
+
|
|
117
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
118
|
+
raise ApiError, server_error_message(response, body)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
yield body
|
|
122
|
+
rescue ParseError => e
|
|
123
|
+
log_verbose "Parse failed: #{e.message}"
|
|
124
|
+
raise
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def post(path, expected_code: "202", body: nil, content_type: nil)
|
|
128
|
+
ok_status_text = (expected_code == "202") ? "Accepted" : "OK"
|
|
129
|
+
response, resp_body = request(:post, path, ok_code: expected_code,
|
|
130
|
+
ok_status_text: ok_status_text, body: body, content_type: content_type)
|
|
131
|
+
|
|
132
|
+
unless response.code == expected_code
|
|
133
|
+
raise ApiError, server_error_message(response, resp_body)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
resp_body
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def request(method, path, ok_code:, ok_status_text:, body: nil, content_type: nil)
|
|
140
|
+
url = "#{@host}#{path}"
|
|
141
|
+
uri = URI.parse(url)
|
|
142
|
+
|
|
143
|
+
log_verbose "HTTP #{method.to_s.upcase} #{url}"
|
|
144
|
+
log_verbose "Basic Auth: user=#{@username}" if @username
|
|
145
|
+
|
|
146
|
+
req = build_request(method, uri, body: body, content_type: content_type)
|
|
147
|
+
response, resp_body = execute_request(uri, req, ok_code: ok_code, ok_status_text: ok_status_text)
|
|
148
|
+
|
|
149
|
+
[response, resp_body]
|
|
150
|
+
rescue SocketError, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e
|
|
151
|
+
log_verbose "Connection error: #{e.class} - #{e.message}"
|
|
152
|
+
raise ConnectionError, "Failed to connect to #{@host}: #{e.message}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def build_request(method, uri, body: nil, content_type: nil)
|
|
156
|
+
req = case method
|
|
157
|
+
when :get then Net::HTTP::Get.new(uri.request_uri)
|
|
158
|
+
when :post then Net::HTTP::Post.new(uri.request_uri)
|
|
159
|
+
end
|
|
160
|
+
req.basic_auth(@username, @password) if @username && @password
|
|
161
|
+
if body
|
|
162
|
+
req.body = body
|
|
163
|
+
req["Content-Type"] = content_type if content_type
|
|
164
|
+
end
|
|
165
|
+
req
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def execute_request(uri, req, ok_code:, ok_status_text:)
|
|
169
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
170
|
+
http.use_ssl = (uri.scheme == "https")
|
|
171
|
+
|
|
172
|
+
start_time = Time.now
|
|
173
|
+
log_verbose "Request started at: #{start_time.utc}"
|
|
174
|
+
|
|
175
|
+
response = http.request(req)
|
|
176
|
+
|
|
177
|
+
elapsed = Time.now - start_time
|
|
178
|
+
status_text = (response.code == ok_code) ? ok_status_text : response.message.to_s
|
|
179
|
+
log_verbose "Response received: #{response.code} #{status_text} (#{format("%.3f", elapsed)}s)"
|
|
180
|
+
|
|
181
|
+
body = response.body.to_s
|
|
182
|
+
log_verbose "Raw response body (#{body.bytesize} bytes):"
|
|
183
|
+
log_verbose body if @verbose
|
|
184
|
+
|
|
185
|
+
[response, body]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def stream_to(path, io)
|
|
189
|
+
uri = URI.parse("#{@host}#{path}")
|
|
190
|
+
|
|
191
|
+
log_verbose "HTTP GET #{uri} (streaming)"
|
|
192
|
+
|
|
193
|
+
start_time = Time.now
|
|
194
|
+
log_verbose "Request started at: #{start_time.utc}"
|
|
195
|
+
|
|
196
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
197
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
198
|
+
request.basic_auth(@username, @password) if @username && @password
|
|
199
|
+
|
|
200
|
+
http.request(request) do |response|
|
|
201
|
+
elapsed = Time.now - start_time
|
|
202
|
+
status_text = (response.code == "200") ? "OK" : response.message.to_s
|
|
203
|
+
log_verbose "Response received: #{response.code} #{status_text} (#{format("%.3f", elapsed)}s)"
|
|
204
|
+
|
|
205
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
206
|
+
error_body = response.read_body
|
|
207
|
+
raise ApiError, server_error_message(response, error_body)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
response.read_body do |chunk|
|
|
211
|
+
io.write(chunk)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
rescue SocketError, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e
|
|
216
|
+
log_verbose "Connection error: #{e.class} - #{e.message}"
|
|
217
|
+
raise ConnectionError, "Failed to connect to #{@host}: #{e.message}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def server_error_message(response, body)
|
|
221
|
+
detail = body.to_s.strip
|
|
222
|
+
detail = response.message if detail.empty?
|
|
223
|
+
"Server returned #{response.code}: #{detail}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def log_verbose(message)
|
|
227
|
+
return unless @verbose
|
|
228
|
+
@stderr.puts message
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def parse_ndjson(body)
|
|
232
|
+
lines = body.split("\n").reject(&:empty?)
|
|
233
|
+
|
|
234
|
+
raise ParseError, "No data received from server" if lines.empty?
|
|
235
|
+
|
|
236
|
+
metadata = parse_line(lines.first, "metadata")
|
|
237
|
+
rows = lines[1..].map.with_index do |line, index|
|
|
238
|
+
parse_line(line, "row #{index + 1}")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
{metadata: metadata, rows: rows}
|
|
242
|
+
rescue JSON::ParserError => e
|
|
243
|
+
raise ParseError, "Failed to parse response: #{e.message}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def parse_line(line, context)
|
|
247
|
+
JSON.parse(line)
|
|
248
|
+
rescue JSON::ParserError => e
|
|
249
|
+
raise ParseError, "Failed to parse #{context}: #{e.message}"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def parse_json(body)
|
|
253
|
+
JSON.parse(body)
|
|
254
|
+
rescue JSON::ParserError => e
|
|
255
|
+
raise ParseError, "Failed to parse response: #{e.message}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def normalize_host(host)
|
|
259
|
+
# Add http:// if no scheme is present
|
|
260
|
+
if !%r{^https?://}.match?(host)
|
|
261
|
+
"http://#{host}"
|
|
262
|
+
else
|
|
263
|
+
host
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
|
|
6
|
+
module Raygatherer
|
|
7
|
+
class CLI
|
|
8
|
+
# Custom exception to handle early returns without calling exit
|
|
9
|
+
class EarlyExit < StandardError
|
|
10
|
+
attr_reader :exit_code
|
|
11
|
+
|
|
12
|
+
def initialize(exit_code)
|
|
13
|
+
@exit_code = exit_code
|
|
14
|
+
super()
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ROUTES = {
|
|
19
|
+
["stats", nil] => {file: "commands/stats", klass: "Commands::Stats", json: true},
|
|
20
|
+
["alerts", nil] => {file: "commands/alerts", klass: "Commands::Alerts", json: true},
|
|
21
|
+
["recording", "list"] => {file: "commands/recording/list", klass: "Commands::Recording::List", json: true},
|
|
22
|
+
["recording", "download"] => {file: "commands/recording/download", klass: "Commands::Recording::Download", json: false},
|
|
23
|
+
["recording", "delete"] => {file: "commands/recording/delete", klass: "Commands::Recording::Delete", json: false},
|
|
24
|
+
["recording", "stop"] => {file: "commands/recording/stop", klass: "Commands::Recording::Stop", json: false},
|
|
25
|
+
["recording", "start"] => {file: "commands/recording/start", klass: "Commands::Recording::Start", json: false},
|
|
26
|
+
["analysis", "status"] => {file: "commands/analysis/status", klass: "Commands::Analysis::Status", json: true},
|
|
27
|
+
["analysis", "run"] => {file: "commands/analysis/run", klass: "Commands::Analysis::Run", json: true},
|
|
28
|
+
["config", "show"] => {file: "commands/config/show", klass: "Commands::Config::Show", json: true},
|
|
29
|
+
["config", "set"] => {file: "commands/config/set", klass: "Commands::Config::Set", json: false},
|
|
30
|
+
["config", "test-notification"] => {file: "commands/config/test_notification", klass: "Commands::Config::TestNotification", json: false}
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def self.run(argv, stdout: $stdout, stderr: $stderr, config: Config.new)
|
|
34
|
+
new(argv, stdout: stdout, stderr: stderr, config: config).run
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, config: Config.new)
|
|
38
|
+
@argv = argv
|
|
39
|
+
@stdout = stdout
|
|
40
|
+
@stderr = stderr
|
|
41
|
+
@config = config
|
|
42
|
+
@verbose = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
# Extract global flags BEFORE processing
|
|
47
|
+
cli_verbose = @argv.delete("--verbose") ? true : nil
|
|
48
|
+
cli_json = @argv.delete("--json") ? true : nil
|
|
49
|
+
cli_host = extract_value_flag("--host")
|
|
50
|
+
cli_username = extract_value_flag("--basic-auth-user")
|
|
51
|
+
cli_password = extract_value_flag("--basic-auth-password")
|
|
52
|
+
|
|
53
|
+
config_values = @config.load
|
|
54
|
+
|
|
55
|
+
@verbose = cli_verbose.nil? ? (config_values["verbose"] || false) : cli_verbose
|
|
56
|
+
@json = cli_json.nil? ? (config_values["json"] || false) : cli_json
|
|
57
|
+
@host = cli_host || config_values["host"]
|
|
58
|
+
@username = cli_username || config_values["basic_auth_user"]
|
|
59
|
+
@password = cli_password || config_values["basic_auth_password"]
|
|
60
|
+
|
|
61
|
+
if @argv.empty?
|
|
62
|
+
show_help
|
|
63
|
+
return Commands::EXIT_CODE_SUCCESS
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if first argument is a flag
|
|
67
|
+
if /^-/.match?(@argv.first)
|
|
68
|
+
parse_options
|
|
69
|
+
return Commands::EXIT_CODE_SUCCESS
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Route to commands
|
|
73
|
+
command = @argv.shift
|
|
74
|
+
subcommand = @argv.first
|
|
75
|
+
|
|
76
|
+
route = ROUTES[[command, subcommand]]
|
|
77
|
+
if route
|
|
78
|
+
@argv.shift # consume subcommand
|
|
79
|
+
else
|
|
80
|
+
route = ROUTES[[command, nil]]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unless route
|
|
84
|
+
@stderr.puts "Unknown command: #{[command, subcommand].compact.join(" ")}"
|
|
85
|
+
show_help(@stderr)
|
|
86
|
+
return Commands::EXIT_CODE_ERROR
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
require_relative route[:file]
|
|
90
|
+
return Commands::EXIT_CODE_ERROR unless require_host!
|
|
91
|
+
|
|
92
|
+
kwargs = {stdout: @stdout, stderr: @stderr, api_client: build_api_client}
|
|
93
|
+
kwargs[:json] = @json if route[:json]
|
|
94
|
+
|
|
95
|
+
resolve_class(route[:klass]).run(@argv, **kwargs)
|
|
96
|
+
rescue Config::ConfigError => e
|
|
97
|
+
@stderr.puts "Error: #{e.message}"
|
|
98
|
+
Commands::EXIT_CODE_ERROR
|
|
99
|
+
rescue OptionParser::InvalidOption => e
|
|
100
|
+
@stderr.puts e.message
|
|
101
|
+
show_help(@stderr)
|
|
102
|
+
Commands::EXIT_CODE_ERROR
|
|
103
|
+
rescue EarlyExit => e
|
|
104
|
+
e.exit_code
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def parse_options
|
|
110
|
+
OptionParser.new do |opts|
|
|
111
|
+
opts.banner = "Usage: raygatherer [options] [command]"
|
|
112
|
+
opts.separator ""
|
|
113
|
+
opts.separator "Options:"
|
|
114
|
+
|
|
115
|
+
opts.on("-v", "--version", "Show version") do
|
|
116
|
+
@stdout.puts "raygatherer version #{Raygatherer::VERSION}"
|
|
117
|
+
raise EarlyExit, Commands::EXIT_CODE_SUCCESS
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
opts.on("-h", "--help", "Show this help message") do
|
|
121
|
+
show_help
|
|
122
|
+
raise EarlyExit, Commands::EXIT_CODE_SUCCESS
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
opts.separator ""
|
|
126
|
+
opts.separator "Commands will be added in future iterations."
|
|
127
|
+
end.parse!(@argv)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def require_host!
|
|
131
|
+
return true if @host
|
|
132
|
+
return true if @argv.include?("--help") || @argv.include?("-h")
|
|
133
|
+
@stderr.puts "Error: --host is required"
|
|
134
|
+
show_help(@stderr)
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_api_client
|
|
139
|
+
return nil unless @host
|
|
140
|
+
ApiClient.new(@host, username: @username, password: @password,
|
|
141
|
+
verbose: @verbose, stderr: @stderr)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def resolve_class(name)
|
|
145
|
+
name.split("::").reduce(Raygatherer) { |mod, part| mod.const_get(part) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def extract_value_flag(flag)
|
|
149
|
+
index = @argv.index(flag)
|
|
150
|
+
return nil unless index
|
|
151
|
+
@argv.delete_at(index) # remove the flag
|
|
152
|
+
@argv.delete_at(index) # remove the value (shifted into same index)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def show_help(output = @stdout)
|
|
156
|
+
output.puts "Usage: raygatherer [options] [command]"
|
|
157
|
+
output.puts ""
|
|
158
|
+
output.puts "Options:"
|
|
159
|
+
output.puts " -v, --version Show version"
|
|
160
|
+
output.puts " -h, --help Show this help message"
|
|
161
|
+
output.puts " --verbose Show detailed HTTP request/response information"
|
|
162
|
+
output.puts " --host HOST Rayhunter host URL (required)"
|
|
163
|
+
output.puts " --basic-auth-user USER Basic auth username"
|
|
164
|
+
output.puts " --basic-auth-password PASS Basic auth password"
|
|
165
|
+
output.puts " --json Output JSON (for scripts/piping)"
|
|
166
|
+
output.puts ""
|
|
167
|
+
output.puts "Commands:"
|
|
168
|
+
output.puts " alerts Check for active IMSI catcher alerts"
|
|
169
|
+
output.puts " recording list List recordings on the device"
|
|
170
|
+
output.puts " recording download <name> Download a recording from the device"
|
|
171
|
+
output.puts " recording delete <name> Delete a recording from the device"
|
|
172
|
+
output.puts " recording stop Stop the current recording"
|
|
173
|
+
output.puts " recording start Start a new recording"
|
|
174
|
+
output.puts " analysis status Show analysis queue status"
|
|
175
|
+
output.puts " analysis run <name> Queue a recording for analysis"
|
|
176
|
+
output.puts " analysis run --all Queue all recordings for analysis"
|
|
177
|
+
output.puts " config show Show device configuration"
|
|
178
|
+
output.puts " config set Update device configuration (reads JSON from stdin)"
|
|
179
|
+
output.puts " config test-notification Send a test notification"
|
|
180
|
+
output.puts " stats Show device system stats"
|
|
181
|
+
output.puts ""
|
|
182
|
+
output.puts "Configuration:"
|
|
183
|
+
output.puts " Config file: ~/.config/raygatherer/config.yml"
|
|
184
|
+
output.puts " (or $XDG_CONFIG_HOME/raygatherer/config.yml)"
|
|
185
|
+
output.puts ""
|
|
186
|
+
output.puts " Supported keys: host, basic_auth_user, basic_auth_password, json, verbose"
|
|
187
|
+
output.puts " CLI flags always override config file values."
|
|
188
|
+
output.puts " Note: config file may contain credentials; consider restricting file permissions."
|
|
189
|
+
output.puts ""
|
|
190
|
+
output.puts "Run 'raygatherer COMMAND --help' for more information on a command."
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "../formatters/json"
|
|
7
|
+
|
|
8
|
+
module Raygatherer
|
|
9
|
+
module Commands
|
|
10
|
+
class Alerts < Base
|
|
11
|
+
SEVERITY_ORDER = {
|
|
12
|
+
"Informational" => 0,
|
|
13
|
+
"Low" => 1,
|
|
14
|
+
"Medium" => 2,
|
|
15
|
+
"High" => 3
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
EXIT_CODE_LOW_SEVERITY = 10
|
|
19
|
+
EXIT_CODE_MEDIUM_SEVERITY = 11
|
|
20
|
+
EXIT_CODE_HIGH_SEVERITY = 12
|
|
21
|
+
|
|
22
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, api_client: nil, json: false)
|
|
23
|
+
super(argv, stdout: stdout, stderr: stderr, api_client: api_client)
|
|
24
|
+
@json = json
|
|
25
|
+
@latest = false
|
|
26
|
+
@after = nil
|
|
27
|
+
@recording = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
with_error_handling do
|
|
32
|
+
parse_options
|
|
33
|
+
|
|
34
|
+
data = if @recording
|
|
35
|
+
@api_client.fetch_analysis_report(@recording)
|
|
36
|
+
else
|
|
37
|
+
@api_client.fetch_live_analysis_report
|
|
38
|
+
end
|
|
39
|
+
alerts = extract_alerts(data[:rows], data[:metadata])
|
|
40
|
+
alerts = filter_after(alerts) if @after
|
|
41
|
+
alerts = filter_latest(alerts, data[:rows]) if @latest
|
|
42
|
+
|
|
43
|
+
# Select formatter based on --json flag
|
|
44
|
+
formatter = @json ? Formatters::JSON.new : Formatters::Human.new
|
|
45
|
+
@stdout.puts formatter.format(alerts)
|
|
46
|
+
|
|
47
|
+
# Return severity-based exit code
|
|
48
|
+
severity_exit_code(alerts)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def parse_options
|
|
55
|
+
OptionParser.new do |opts|
|
|
56
|
+
opts.banner = "Usage: raygatherer alerts [options]"
|
|
57
|
+
opts.separator ""
|
|
58
|
+
opts.separator "Options:"
|
|
59
|
+
|
|
60
|
+
opts.on("--recording NAME", "Analyze a past recording instead of live") do |name|
|
|
61
|
+
@recording = name
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
opts.on("--latest", "Show only alerts from the most recent message") do
|
|
65
|
+
@latest = true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
opts.on("--after TIMESTAMP", "Show only alerts after this time (ISO 8601)") do |ts|
|
|
69
|
+
@after = parse_timestamp(ts)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
opts.on("-h", "--help", "Show this help message") do
|
|
73
|
+
show_help
|
|
74
|
+
raise CLI::EarlyExit, EXIT_CODE_SUCCESS
|
|
75
|
+
end
|
|
76
|
+
end.parse!(@argv)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def extract_alerts(rows, metadata)
|
|
80
|
+
analyzers = metadata&.dig("analyzers") || []
|
|
81
|
+
alerts = []
|
|
82
|
+
|
|
83
|
+
rows.each do |row|
|
|
84
|
+
events = row["events"] || []
|
|
85
|
+
|
|
86
|
+
events.each_with_index do |event, index|
|
|
87
|
+
next if event.nil?
|
|
88
|
+
|
|
89
|
+
event_type = event["event_type"]
|
|
90
|
+
next unless event_type
|
|
91
|
+
|
|
92
|
+
severity_level = SEVERITY_ORDER[event_type] || 0
|
|
93
|
+
next if severity_level == 0
|
|
94
|
+
|
|
95
|
+
alerts << {
|
|
96
|
+
severity: event_type,
|
|
97
|
+
message: event["message"],
|
|
98
|
+
packet_timestamp: row["packet_timestamp"],
|
|
99
|
+
analyzer: analyzers.dig(index, "name")
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
alerts
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_timestamp(str)
|
|
108
|
+
Time.parse(str)
|
|
109
|
+
rescue ArgumentError
|
|
110
|
+
raise ArgumentError, "invalid timestamp: #{str}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def filter_after(alerts)
|
|
114
|
+
alerts.select { |a| a[:packet_timestamp] && Time.parse(a[:packet_timestamp]) > @after }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def filter_latest(alerts, rows)
|
|
118
|
+
latest_timestamp = if @after
|
|
119
|
+
alerts.map { |a| a[:packet_timestamp] }.compact.max
|
|
120
|
+
else
|
|
121
|
+
rows.map { |r| r["packet_timestamp"] }.compact.max
|
|
122
|
+
end
|
|
123
|
+
return [] if latest_timestamp.nil?
|
|
124
|
+
|
|
125
|
+
alerts.select { |a| a[:packet_timestamp] == latest_timestamp }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def show_help(output = @stdout)
|
|
129
|
+
output.puts "Usage: raygatherer [global options] alerts [options]"
|
|
130
|
+
output.puts ""
|
|
131
|
+
output.puts "Options:"
|
|
132
|
+
output.puts " --recording NAME Analyze a past recording instead of live"
|
|
133
|
+
output.puts " --after TIMESTAMP Show only alerts after this time (ISO 8601, exclusive)"
|
|
134
|
+
output.puts " --latest Show only alerts from the most recent message"
|
|
135
|
+
output.puts " -h, --help Show this help message"
|
|
136
|
+
output.puts ""
|
|
137
|
+
print_global_options(output, json: true)
|
|
138
|
+
output.puts ""
|
|
139
|
+
output.puts "Exit Codes:"
|
|
140
|
+
output.puts " #{EXIT_CODE_SUCCESS} No alerts detected"
|
|
141
|
+
output.puts " #{EXIT_CODE_ERROR} Error (connection, parse, missing --host, etc.)"
|
|
142
|
+
output.puts " #{EXIT_CODE_LOW_SEVERITY} Low severity alert"
|
|
143
|
+
output.puts " #{EXIT_CODE_MEDIUM_SEVERITY} Medium severity alert"
|
|
144
|
+
output.puts " #{EXIT_CODE_HIGH_SEVERITY} High severity alert"
|
|
145
|
+
output.puts ""
|
|
146
|
+
output.puts "Examples:"
|
|
147
|
+
output.puts " raygatherer --host http://192.168.1.100:8080 alerts"
|
|
148
|
+
output.puts " raygatherer --host http://192.168.1.100:8080 --json alerts"
|
|
149
|
+
output.puts " raygatherer --host http://rayhunter --json alerts"
|
|
150
|
+
output.puts " raygatherer --host http://rayhunter alerts --after 2024-02-07T14:25:33Z"
|
|
151
|
+
output.puts " [ $? -ge #{EXIT_CODE_MEDIUM_SEVERITY} ] && telegram-send 'Medium+ severity alert!'"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def severity_exit_code(alerts)
|
|
155
|
+
return EXIT_CODE_SUCCESS if alerts.empty?
|
|
156
|
+
|
|
157
|
+
max_severity = alerts.map { |a| SEVERITY_ORDER[a[:severity]] || 0 }.max
|
|
158
|
+
case max_severity
|
|
159
|
+
when 1 then EXIT_CODE_LOW_SEVERITY
|
|
160
|
+
when 2 then EXIT_CODE_MEDIUM_SEVERITY
|
|
161
|
+
when 3 then EXIT_CODE_HIGH_SEVERITY
|
|
162
|
+
else EXIT_CODE_SUCCESS
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|