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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CLAUDE.md +102 -0
  4. data/CODE_OF_CONDUCT.md +133 -0
  5. data/LICENSE.txt +21 -0
  6. data/Makefile +23 -0
  7. data/README.md +220 -0
  8. data/Rakefile +8 -0
  9. data/exe/raygatherer +5 -0
  10. data/lib/raygatherer/api_client.rb +267 -0
  11. data/lib/raygatherer/cli.rb +193 -0
  12. data/lib/raygatherer/commands/alerts.rb +167 -0
  13. data/lib/raygatherer/commands/analysis/run.rb +81 -0
  14. data/lib/raygatherer/commands/analysis/status.rb +60 -0
  15. data/lib/raygatherer/commands/base.rb +41 -0
  16. data/lib/raygatherer/commands/config/set.rb +76 -0
  17. data/lib/raygatherer/commands/config/show.rb +61 -0
  18. data/lib/raygatherer/commands/config/test_notification.rb +52 -0
  19. data/lib/raygatherer/commands/recording/delete.rb +55 -0
  20. data/lib/raygatherer/commands/recording/download.rb +167 -0
  21. data/lib/raygatherer/commands/recording/list.rb +60 -0
  22. data/lib/raygatherer/commands/recording/start.rb +56 -0
  23. data/lib/raygatherer/commands/recording/stop.rb +56 -0
  24. data/lib/raygatherer/commands/stats.rb +58 -0
  25. data/lib/raygatherer/config.rb +40 -0
  26. data/lib/raygatherer/format_helpers.rb +19 -0
  27. data/lib/raygatherer/formatters/analysis_status_human.rb +38 -0
  28. data/lib/raygatherer/formatters/analysis_status_json.rb +13 -0
  29. data/lib/raygatherer/formatters/config_human.rb +29 -0
  30. data/lib/raygatherer/formatters/config_json.rb +13 -0
  31. data/lib/raygatherer/formatters/human.rb +38 -0
  32. data/lib/raygatherer/formatters/json.rb +22 -0
  33. data/lib/raygatherer/formatters/recording_list_human.rb +72 -0
  34. data/lib/raygatherer/formatters/recording_list_json.rb +13 -0
  35. data/lib/raygatherer/formatters/stats_human.rb +29 -0
  36. data/lib/raygatherer/formatters/stats_json.rb +13 -0
  37. data/lib/raygatherer/spinner.rb +31 -0
  38. data/lib/raygatherer/version.rb +5 -0
  39. data/lib/raygatherer.rb +34 -0
  40. 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