mychron 0.3.2
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/Gemfile +5 -0
- data/IMPLEMENTATION_NOTES.md +539 -0
- data/LICENSE +21 -0
- data/PROTOCOL.md +848 -0
- data/README.md +413 -0
- data/lib/mychron/configuration.rb +112 -0
- data/lib/mychron/device.rb +196 -0
- data/lib/mychron/discovery/detector.rb +242 -0
- data/lib/mychron/discovery/scorer.rb +123 -0
- data/lib/mychron/errors.rb +27 -0
- data/lib/mychron/logging.rb +79 -0
- data/lib/mychron/monitor/watcher.rb +165 -0
- data/lib/mychron/network/arp.rb +257 -0
- data/lib/mychron/network/http_probe.rb +121 -0
- data/lib/mychron/network/scanner.rb +167 -0
- data/lib/mychron/protocol/client.rb +946 -0
- data/lib/mychron/protocol/discovery.rb +163 -0
- data/lib/mychron/session.rb +118 -0
- data/lib/mychron/version.rb +5 -0
- data/lib/mychron.rb +193 -0
- data/mychron.gemspec +48 -0
- metadata +127 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MyChron
|
|
4
|
+
module Discovery
|
|
5
|
+
class Detector
|
|
6
|
+
include Logging
|
|
7
|
+
|
|
8
|
+
attr_reader :interface, :subnet, :local_ip
|
|
9
|
+
|
|
10
|
+
def initialize(interface: nil, subnet: nil)
|
|
11
|
+
@interface = interface
|
|
12
|
+
@subnet = subnet
|
|
13
|
+
@local_ip = nil
|
|
14
|
+
@config = MyChron.config
|
|
15
|
+
|
|
16
|
+
detect_network_info unless @subnet
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Run full discovery
|
|
20
|
+
# Returns discovery result hash
|
|
21
|
+
def run(scan_ports: nil, probe_http: true)
|
|
22
|
+
scan_ports ||= @config.scan_ports
|
|
23
|
+
scan_ports = Array(scan_ports)
|
|
24
|
+
|
|
25
|
+
log_info("Network discovery started on #{@subnet} (#{@interface}), scanning #{scan_ports.length} ports")
|
|
26
|
+
|
|
27
|
+
result = {
|
|
28
|
+
timestamp: Time.now.utc.iso8601,
|
|
29
|
+
local_ip: @local_ip,
|
|
30
|
+
interface: @interface,
|
|
31
|
+
subnet: @subnet,
|
|
32
|
+
discovered_hosts: [],
|
|
33
|
+
likely_candidates: [],
|
|
34
|
+
wifi_aps: []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Step 1: Discover hosts via ARP
|
|
38
|
+
arp = Network::ARP.new(interface: @interface)
|
|
39
|
+
hosts = arp.discover
|
|
40
|
+
|
|
41
|
+
log_info("ARP discovery complete: #{hosts.length} hosts")
|
|
42
|
+
|
|
43
|
+
result[:discovered_hosts] = hosts.map do |h|
|
|
44
|
+
{ ip: h[:ip], mac: h[:mac], vendor: h[:vendor], hostname: h[:hostname] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Step 2: Scan WiFi APs (for AP mode detection)
|
|
48
|
+
result[:wifi_aps] = scan_wifi_aps
|
|
49
|
+
|
|
50
|
+
# Step 3: Port scan each host
|
|
51
|
+
scanner = Network::Scanner.new(timeout: @config.scan_timeout)
|
|
52
|
+
http_probe = Network::HttpProbe.new(timeout: @config.http_timeout)
|
|
53
|
+
scorer = Scorer.new
|
|
54
|
+
|
|
55
|
+
candidates = []
|
|
56
|
+
|
|
57
|
+
hosts.each do |host|
|
|
58
|
+
log_debug("Scanning #{host[:ip]}")
|
|
59
|
+
|
|
60
|
+
# Port scan
|
|
61
|
+
ports = scanner.scan(host[:ip], ports: scan_ports)
|
|
62
|
+
|
|
63
|
+
candidate = host.merge(ports: ports)
|
|
64
|
+
|
|
65
|
+
# Check for WiFi AP match
|
|
66
|
+
candidate[:wifi_ap_match] = result[:wifi_aps].any? do |ap|
|
|
67
|
+
ap[:ssid]&.downcase&.include?("aim") ||
|
|
68
|
+
ap[:ssid]&.downcase&.include?("myc")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# HTTP probe if enabled and web port is open
|
|
72
|
+
if probe_http
|
|
73
|
+
web_ports = ports.select { |p, v| v[:open] && [80, 443, 8080].include?(p) }.keys
|
|
74
|
+
web_ports.each do |port|
|
|
75
|
+
probe_result = http_probe.probe(host[:ip], port: port)
|
|
76
|
+
candidate[:http_probe] = probe_result
|
|
77
|
+
break if probe_result[:reachable]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Score the candidate
|
|
82
|
+
scored = scorer.score(candidate)
|
|
83
|
+
candidate = candidate.merge(
|
|
84
|
+
score: scored[:score],
|
|
85
|
+
reasons: scored[:reasons],
|
|
86
|
+
is_likely: scored[:is_likely]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
open_ports = ports.select { |_, v| v[:open] }.keys
|
|
90
|
+
log_info("Scanned #{host[:ip]} (#{host[:mac]}): ports=#{open_ports}, score=#{scored[:score]}, reasons=#{scored[:reasons]}")
|
|
91
|
+
|
|
92
|
+
candidates << candidate
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Sort by score descending
|
|
96
|
+
result[:likely_candidates] = candidates
|
|
97
|
+
.select { |c| c[:score] > 0 }
|
|
98
|
+
.sort_by { |c| -c[:score] }
|
|
99
|
+
.map do |c|
|
|
100
|
+
{
|
|
101
|
+
ip: c[:ip],
|
|
102
|
+
mac: c[:mac],
|
|
103
|
+
score: c[:score],
|
|
104
|
+
reasons: c[:reasons],
|
|
105
|
+
is_likely: c[:is_likely],
|
|
106
|
+
ports: format_ports(c[:ports]),
|
|
107
|
+
http_probe: c[:http_probe]
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
log_info("Discovery complete: #{hosts.length} hosts, #{result[:likely_candidates].length} candidates")
|
|
112
|
+
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Quick scan for a specific IP
|
|
117
|
+
def probe_host(ip, scan_ports: nil)
|
|
118
|
+
scan_ports ||= @config.scan_ports
|
|
119
|
+
|
|
120
|
+
scanner = Network::Scanner.new(timeout: @config.scan_timeout)
|
|
121
|
+
http_probe = Network::HttpProbe.new(timeout: @config.http_timeout)
|
|
122
|
+
scorer = Scorer.new
|
|
123
|
+
|
|
124
|
+
ports = scanner.scan(ip, ports: scan_ports)
|
|
125
|
+
|
|
126
|
+
candidate = {
|
|
127
|
+
ip: ip,
|
|
128
|
+
mac: nil,
|
|
129
|
+
ports: ports
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# HTTP probe
|
|
133
|
+
web_ports = ports.select { |p, v| v[:open] && [80, 443, 8080].include?(p) }.keys
|
|
134
|
+
web_ports.each do |port|
|
|
135
|
+
probe_result = http_probe.probe(ip, port: port)
|
|
136
|
+
candidate[:http_probe] = probe_result
|
|
137
|
+
break if probe_result[:reachable]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
scored = scorer.score(candidate)
|
|
141
|
+
candidate.merge(
|
|
142
|
+
score: scored[:score],
|
|
143
|
+
reasons: scored[:reasons],
|
|
144
|
+
is_likely: scored[:is_likely],
|
|
145
|
+
ports: format_ports(ports)
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def detect_network_info
|
|
152
|
+
info = Network::ARP.detect_subnet
|
|
153
|
+
return unless info
|
|
154
|
+
|
|
155
|
+
@interface = info[:interface]
|
|
156
|
+
@subnet = info[:subnet]
|
|
157
|
+
@local_ip = info[:local_ip]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def scan_wifi_aps
|
|
161
|
+
aps = []
|
|
162
|
+
|
|
163
|
+
if Network::ARP.macos?
|
|
164
|
+
aps = scan_wifi_macos
|
|
165
|
+
else
|
|
166
|
+
aps = scan_wifi_linux
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
aps.select { |ap| ap[:ssid]&.downcase&.match?(/aim|myc/) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def scan_wifi_macos
|
|
173
|
+
airport_path = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport"
|
|
174
|
+
return [] unless File.exist?(airport_path)
|
|
175
|
+
|
|
176
|
+
output, status = Open3.capture2(airport_path, "-s")
|
|
177
|
+
return [] unless status.success?
|
|
178
|
+
|
|
179
|
+
aps = []
|
|
180
|
+
output.each_line.with_index do |line, idx|
|
|
181
|
+
next if idx == 0 # Skip header
|
|
182
|
+
|
|
183
|
+
# Format: SSID BSSID RSSI CHANNEL HT CC SECURITY
|
|
184
|
+
parts = line.strip.split(/\s+/)
|
|
185
|
+
next if parts.length < 2
|
|
186
|
+
|
|
187
|
+
aps << { ssid: parts[0], bssid: parts[1] }
|
|
188
|
+
end
|
|
189
|
+
aps
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def scan_wifi_linux
|
|
193
|
+
# Try nmcli first
|
|
194
|
+
output, status = Open3.capture2("nmcli", "-t", "-f", "SSID,BSSID", "device", "wifi", "list")
|
|
195
|
+
return [] unless status.success?
|
|
196
|
+
|
|
197
|
+
aps = []
|
|
198
|
+
output.each_line do |line|
|
|
199
|
+
parts = line.strip.split(":")
|
|
200
|
+
next if parts.length < 2
|
|
201
|
+
|
|
202
|
+
aps << { ssid: parts[0], bssid: parts[1..-1].join(":") }
|
|
203
|
+
end
|
|
204
|
+
aps
|
|
205
|
+
rescue Errno::ENOENT
|
|
206
|
+
# nmcli not available, try iwlist
|
|
207
|
+
scan_wifi_iwlist
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def scan_wifi_iwlist
|
|
211
|
+
# Find wireless interface
|
|
212
|
+
interfaces = Dir["/sys/class/net/*/wireless"].map { |p| p.split("/")[-2] }
|
|
213
|
+
return [] if interfaces.empty?
|
|
214
|
+
|
|
215
|
+
iface = interfaces.first
|
|
216
|
+
output, status = Open3.capture2("sudo", "iwlist", iface, "scan")
|
|
217
|
+
return [] unless status.success?
|
|
218
|
+
|
|
219
|
+
aps = []
|
|
220
|
+
current_bssid = nil
|
|
221
|
+
|
|
222
|
+
output.each_line do |line|
|
|
223
|
+
if line.include?("Address:")
|
|
224
|
+
current_bssid = line.split("Address:").last.strip
|
|
225
|
+
elsif line.include?("ESSID:")
|
|
226
|
+
ssid = line.split("ESSID:").last.strip.tr('"', "")
|
|
227
|
+
aps << { ssid: ssid, bssid: current_bssid } if current_bssid
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
aps
|
|
231
|
+
rescue StandardError
|
|
232
|
+
[]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def format_ports(ports)
|
|
236
|
+
ports.transform_values do |v|
|
|
237
|
+
{ open: v[:open], banner: v[:banner] }
|
|
238
|
+
end.select { |_, v| v[:open] }
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MyChron
|
|
4
|
+
module Discovery
|
|
5
|
+
class Scorer
|
|
6
|
+
include Logging
|
|
7
|
+
|
|
8
|
+
# Scoring weights
|
|
9
|
+
WEIGHTS = {
|
|
10
|
+
wifi_ap_match: 50, # WiFi AP SSID matches AiM-MYC6-*
|
|
11
|
+
mychron_data_port: 40, # TCP port 2000 open (MyChron data protocol)
|
|
12
|
+
mac_oui_match: 30, # MAC vendor matches AiM OUI (confirmed)
|
|
13
|
+
espressif_mac: 20, # Espressif OUI (MyChron uses ESP32)
|
|
14
|
+
http_server: 20, # HTTP server responds
|
|
15
|
+
aim_banner: 10, # Banner contains AiM indicators
|
|
16
|
+
mdns_service: 5, # mDNS service discovered
|
|
17
|
+
common_port: 5, # Common service ports open (80, 8080)
|
|
18
|
+
unusual_port: 3 # Unusual ports open (suggests embedded device)
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Ports that suggest embedded device
|
|
22
|
+
EMBEDDED_PORTS = [5000, 6000, 7000, 9000] + (10_000..10_100).to_a
|
|
23
|
+
|
|
24
|
+
def initialize; end
|
|
25
|
+
|
|
26
|
+
# Score a candidate based on available evidence
|
|
27
|
+
# Returns { score:, reasons: [], is_likely: bool }
|
|
28
|
+
def score(candidate)
|
|
29
|
+
score = 0
|
|
30
|
+
reasons = []
|
|
31
|
+
|
|
32
|
+
# WiFi AP match (highest confidence)
|
|
33
|
+
if candidate[:wifi_ap_match]
|
|
34
|
+
score += WEIGHTS[:wifi_ap_match]
|
|
35
|
+
reasons << "wifi_ap_match"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# MAC OUI match (confirmed AiM)
|
|
39
|
+
if candidate[:is_aim]
|
|
40
|
+
score += WEIGHTS[:mac_oui_match]
|
|
41
|
+
reasons << "mac_oui_match"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Espressif MAC (MyChron uses ESP32 WiFi)
|
|
45
|
+
if candidate[:is_espressif]
|
|
46
|
+
score += WEIGHTS[:espressif_mac]
|
|
47
|
+
reasons << "espressif_mac"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# HTTP probe results
|
|
51
|
+
if candidate[:http_probe]
|
|
52
|
+
http = candidate[:http_probe]
|
|
53
|
+
|
|
54
|
+
if http[:reachable]
|
|
55
|
+
score += WEIGHTS[:http_server]
|
|
56
|
+
reasons << "http_server"
|
|
57
|
+
|
|
58
|
+
if http[:aim_score] && http[:aim_score] > 0
|
|
59
|
+
score += http[:aim_score]
|
|
60
|
+
reasons << "aim_indicators:#{http[:aim_indicators].join(',')}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Port scan results
|
|
66
|
+
if candidate[:ports]
|
|
67
|
+
open_ports = candidate[:ports].select { |_, v| v[:open] }.keys
|
|
68
|
+
|
|
69
|
+
# MyChron TCP data port (strongest network signal)
|
|
70
|
+
if open_ports.include?(2000)
|
|
71
|
+
score += WEIGHTS[:mychron_data_port]
|
|
72
|
+
reasons << "mychron_data_port"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Common web ports
|
|
76
|
+
if open_ports.intersect?([80, 443, 8080])
|
|
77
|
+
score += WEIGHTS[:common_port]
|
|
78
|
+
reasons << "common_ports"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Embedded device ports
|
|
82
|
+
embedded_open = open_ports & EMBEDDED_PORTS
|
|
83
|
+
if embedded_open.any?
|
|
84
|
+
score += WEIGHTS[:unusual_port] * [embedded_open.length, 5].min
|
|
85
|
+
reasons << "embedded_ports:#{embedded_open.join(',')}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check banners for AiM indicators
|
|
89
|
+
candidate[:ports].each do |port, info|
|
|
90
|
+
next unless info[:open] && info[:banner]
|
|
91
|
+
|
|
92
|
+
banner_lower = info[:banner].downcase
|
|
93
|
+
if %w[aim mychron racestudio].any? { |ind| banner_lower.include?(ind) }
|
|
94
|
+
score += WEIGHTS[:aim_banner]
|
|
95
|
+
reasons << "banner_match:#{port}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
score: score,
|
|
102
|
+
reasons: reasons,
|
|
103
|
+
is_likely: score >= 30 # Threshold for "likely" candidate
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Compare two candidates, return the better one
|
|
108
|
+
def compare(a, b)
|
|
109
|
+
score_a = score(a)[:score]
|
|
110
|
+
score_b = score(b)[:score]
|
|
111
|
+
score_a >= score_b ? a : b
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Sort candidates by score descending
|
|
115
|
+
def rank(candidates)
|
|
116
|
+
candidates.map do |c|
|
|
117
|
+
scored = score(c)
|
|
118
|
+
c.merge(score: scored[:score], reasons: scored[:reasons], is_likely: scored[:is_likely])
|
|
119
|
+
end.sort_by { |c| -c[:score] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MyChron
|
|
4
|
+
# Base error class for all MyChron errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when unable to connect to the device
|
|
8
|
+
class ConnectionError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the protocol communication fails
|
|
11
|
+
class ProtocolError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when an operation times out
|
|
14
|
+
class TimeoutError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when no sessions are found on the device
|
|
17
|
+
class NoSessionsError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a file download fails
|
|
20
|
+
class DownloadError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when device discovery fails
|
|
23
|
+
class DiscoveryError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when the device is not reachable
|
|
26
|
+
class DeviceNotFoundError < Error; end
|
|
27
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module MyChron
|
|
6
|
+
# Logging module that integrates with Rails.logger when available
|
|
7
|
+
# Falls back to a null logger for silent library operation
|
|
8
|
+
module Logging
|
|
9
|
+
class << self
|
|
10
|
+
# Get the current logger instance
|
|
11
|
+
# Auto-detects Rails.logger if available
|
|
12
|
+
def logger
|
|
13
|
+
@logger ||= detect_logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Set a custom logger
|
|
17
|
+
# @param logger [Logger] A Logger-compatible instance
|
|
18
|
+
def logger=(logger)
|
|
19
|
+
@logger = logger
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Reset to auto-detection
|
|
23
|
+
def reset!
|
|
24
|
+
@logger = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def detect_logger
|
|
30
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
31
|
+
Rails.logger
|
|
32
|
+
else
|
|
33
|
+
# Silent by default for library use
|
|
34
|
+
# Users can set MyChron::Logging.logger = Logger.new(STDOUT) if needed
|
|
35
|
+
null_logger
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def null_logger
|
|
40
|
+
Logger.new(IO::NULL)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Instance method to access logger from including classes
|
|
45
|
+
def logger
|
|
46
|
+
MyChron::Logging.logger
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Log at debug level
|
|
50
|
+
def log_debug(message, context = {})
|
|
51
|
+
logger.debug { format_message(message, context) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Log at info level
|
|
55
|
+
def log_info(message, context = {})
|
|
56
|
+
logger.info { format_message(message, context) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Log at warn level
|
|
60
|
+
def log_warn(message, context = {})
|
|
61
|
+
logger.warn { format_message(message, context) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Log at error level
|
|
65
|
+
def log_error(message, context = {})
|
|
66
|
+
logger.error { format_message(message, context) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def format_message(message, context)
|
|
72
|
+
if context.empty?
|
|
73
|
+
"[MyChron] #{message}"
|
|
74
|
+
else
|
|
75
|
+
"[MyChron] #{message} #{context.inspect}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MyChron
|
|
4
|
+
module Monitor
|
|
5
|
+
class Watcher
|
|
6
|
+
include Logging
|
|
7
|
+
|
|
8
|
+
attr_reader :subnet, :interface, :local_ip
|
|
9
|
+
attr_reader :known_candidates, :best_candidate
|
|
10
|
+
|
|
11
|
+
def initialize(subnet: nil, interface: nil)
|
|
12
|
+
@subnet = subnet
|
|
13
|
+
@interface = interface
|
|
14
|
+
@local_ip = nil
|
|
15
|
+
@config = MyChron.config
|
|
16
|
+
@running = false
|
|
17
|
+
@known_candidates = {}
|
|
18
|
+
@best_candidate = nil
|
|
19
|
+
|
|
20
|
+
detect_network_info unless @subnet
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Start watching loop
|
|
24
|
+
def start(interval: nil)
|
|
25
|
+
interval ||= @config.watch_interval
|
|
26
|
+
@running = true
|
|
27
|
+
|
|
28
|
+
log_info("Watch started on #{@subnet} (#{@interface}), interval: #{interval}s")
|
|
29
|
+
|
|
30
|
+
trap("INT") { stop }
|
|
31
|
+
trap("TERM") { stop }
|
|
32
|
+
|
|
33
|
+
detector = Discovery::Detector.new(interface: @interface, subnet: @subnet)
|
|
34
|
+
|
|
35
|
+
while @running
|
|
36
|
+
begin
|
|
37
|
+
check_cycle(detector)
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
log_error("Watch error: #{e.message}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sleep(interval) if @running
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
log_info("Watch stopped")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Stop watching
|
|
49
|
+
def stop
|
|
50
|
+
@running = false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One-shot probe of specific IP
|
|
54
|
+
def probe(ip)
|
|
55
|
+
detector = Discovery::Detector.new(interface: @interface, subnet: @subnet)
|
|
56
|
+
result = detector.probe_host(ip)
|
|
57
|
+
|
|
58
|
+
log_info("Probe #{ip}: score=#{result[:score]}, likely=#{result[:is_likely]}, ports=#{result[:ports].keys}")
|
|
59
|
+
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Attempt download handshake (dry-run)
|
|
64
|
+
def download_attempt(ip, dry_run: true)
|
|
65
|
+
log_info("Download attempt for #{ip} (dry_run=#{dry_run})")
|
|
66
|
+
|
|
67
|
+
# First, probe the host
|
|
68
|
+
result = probe(ip)
|
|
69
|
+
|
|
70
|
+
unless result[:is_likely]
|
|
71
|
+
log_warn("#{ip} is unlikely candidate (score=#{result[:score]})")
|
|
72
|
+
return { success: false, reason: "unlikely_candidate", probe: result }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Try to identify protocol
|
|
76
|
+
protocol = identify_protocol(ip, result)
|
|
77
|
+
|
|
78
|
+
log_info("Protocol identified for #{ip}: #{protocol}")
|
|
79
|
+
|
|
80
|
+
if dry_run
|
|
81
|
+
return {
|
|
82
|
+
success: true,
|
|
83
|
+
dry_run: true,
|
|
84
|
+
protocol: protocol,
|
|
85
|
+
probe: result,
|
|
86
|
+
message: "Dry run complete. Would attempt #{protocol} download."
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Actual download would go here
|
|
91
|
+
{ success: false, reason: "download_not_implemented", protocol: protocol }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def detect_network_info
|
|
97
|
+
info = Network::ARP.detect_subnet
|
|
98
|
+
return unless info
|
|
99
|
+
|
|
100
|
+
@interface = info[:interface]
|
|
101
|
+
@subnet = info[:subnet]
|
|
102
|
+
@local_ip = info[:local_ip]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_cycle(detector)
|
|
106
|
+
log_debug("Check cycle started")
|
|
107
|
+
|
|
108
|
+
# Quick ARP check
|
|
109
|
+
arp = Network::ARP.new(interface: @interface)
|
|
110
|
+
hosts = arp.discover
|
|
111
|
+
|
|
112
|
+
current_ips = hosts.map { |h| h[:ip] }
|
|
113
|
+
known_ips = @known_candidates.keys
|
|
114
|
+
|
|
115
|
+
# Detect new hosts
|
|
116
|
+
new_ips = current_ips - known_ips
|
|
117
|
+
lost_ips = known_ips - current_ips
|
|
118
|
+
|
|
119
|
+
# Log lost candidates
|
|
120
|
+
lost_ips.each do |ip|
|
|
121
|
+
candidate = @known_candidates.delete(ip)
|
|
122
|
+
if candidate && candidate[:is_likely]
|
|
123
|
+
log_info("Candidate lost: #{ip} (mac=#{candidate[:mac]}, score=#{candidate[:score]})")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Probe new hosts
|
|
128
|
+
new_ips.each do |ip|
|
|
129
|
+
host = hosts.find { |h| h[:ip] == ip }
|
|
130
|
+
next unless host
|
|
131
|
+
|
|
132
|
+
result = detector.probe_host(ip, scan_ports: quick_ports)
|
|
133
|
+
|
|
134
|
+
@known_candidates[ip] = result.merge(mac: host[:mac])
|
|
135
|
+
|
|
136
|
+
if result[:is_likely]
|
|
137
|
+
log_info("Candidate detected: #{ip} (mac=#{host[:mac]}, score=#{result[:score]}, reasons=#{result[:reasons]})")
|
|
138
|
+
|
|
139
|
+
# Update best candidate
|
|
140
|
+
if @best_candidate.nil? || result[:score] > @best_candidate[:score]
|
|
141
|
+
@best_candidate = result.merge(ip: ip, mac: host[:mac])
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
log_debug("Check cycle complete: #{hosts.length} hosts, #{new_ips.length} new, #{lost_ips.length} lost")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def quick_ports
|
|
150
|
+
# Smaller port set for quick checks
|
|
151
|
+
[21, 22, 80, 443, 8080, 5000, 7000, 9000, 10000, 10080]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def identify_protocol(ip, probe_result)
|
|
155
|
+
open_ports = probe_result[:ports].keys
|
|
156
|
+
|
|
157
|
+
return "ftp" if open_ports.include?(21)
|
|
158
|
+
return "http" if open_ports.intersect?([80, 8080])
|
|
159
|
+
return "https" if open_ports.include?(443)
|
|
160
|
+
|
|
161
|
+
"unknown"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|