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.
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyChron
4
+ module Network
5
+ class ARP
6
+ # Known AiM MAC OUI prefixes
7
+ # MyChron uses Espressif (ESP32) WiFi chips
8
+ AIM_OUI_PREFIXES = [
9
+ "88:57:21", # Espressif Inc. - confirmed MyChron 6
10
+ ].freeze
11
+
12
+ # Espressif OUI prefixes (common in IoT devices)
13
+ ESPRESSIF_OUIS = %w[
14
+ 24:0a:c4 24:6f:28 24:b2:de 30:ae:a4 3c:61:05 3c:71:bf
15
+ 40:f5:20 48:3f:da 4c:11:ae 54:5a:a6 5c:cf:7f 60:01:94
16
+ 68:c6:3a 70:03:9f 7c:9e:bd 80:7d:3a 84:0d:8e 84:cc:a8
17
+ 84:f3:eb 88:57:21 8c:aa:b5 90:38:0c 94:b5:55 94:b9:7e
18
+ 98:cd:ac 98:f4:ab a0:20:a6 a4:7b:9d a4:cf:12 ac:0b:fb
19
+ ac:67:b2 b4:e6:2d bc:dd:c2 c4:4f:33 c4:5b:be c8:2b:96
20
+ cc:50:e3 d8:a0:1d d8:bf:c0 dc:4f:22 e0:98:06 e8:68:e7
21
+ ec:94:cb f0:08:d1 f4:cf:a2 fc:f5:c4
22
+ ].freeze
23
+
24
+ attr_reader :interface
25
+
26
+ def initialize(interface: nil)
27
+ @interface = interface
28
+ end
29
+
30
+ # Discover hosts on local network via ARP table
31
+ # Returns array of { ip:, mac:, vendor: }
32
+ def discover
33
+ hosts = []
34
+
35
+ if macos?
36
+ hosts = parse_macos_arp
37
+ else
38
+ hosts = parse_linux_arp
39
+ end
40
+
41
+ hosts.map do |host|
42
+ host[:vendor] = lookup_vendor(host[:mac])
43
+ host[:is_aim] = aim_device?(host[:mac])
44
+ host[:is_espressif] = espressif_device?(host[:mac])
45
+ host
46
+ end
47
+ end
48
+
49
+ # Get subnet info from system
50
+ def self.detect_subnet
51
+ if macos?
52
+ detect_subnet_macos
53
+ else
54
+ detect_subnet_linux
55
+ end
56
+ end
57
+
58
+ def self.macos?
59
+ RUBY_PLATFORM.include?("darwin")
60
+ end
61
+
62
+ def macos?
63
+ self.class.macos?
64
+ end
65
+
66
+ private
67
+
68
+ def parse_macos_arp
69
+ output, status = Open3.capture2("arp", "-a")
70
+ return [] unless status.success?
71
+
72
+ hosts = []
73
+ output.each_line do |line|
74
+ # Format: ? (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]
75
+ match = line.match(/\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)/i)
76
+ next unless match
77
+
78
+ ip = match[1]
79
+ mac = normalize_mac(match[2])
80
+ next if mac == "(incomplete)" || mac.nil? || mac.empty?
81
+
82
+ hosts << { ip: ip, mac: mac, hostname: nil }
83
+ end
84
+ hosts
85
+ end
86
+
87
+ def parse_linux_arp
88
+ # Try ip neigh first (modern), fall back to arp -a
89
+ hosts = parse_ip_neigh
90
+ return hosts unless hosts.empty?
91
+
92
+ parse_arp_a_linux
93
+ end
94
+
95
+ def parse_ip_neigh
96
+ output, status = Open3.capture2("ip", "neigh")
97
+ return [] unless status.success?
98
+
99
+ hosts = []
100
+ output.each_line do |line|
101
+ # Format: 192.168.1.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
102
+ parts = line.split
103
+ next if parts.length < 5
104
+
105
+ ip = parts[0]
106
+ next unless valid_ipv4?(ip)
107
+
108
+ lladdr_idx = parts.index("lladdr")
109
+ next unless lladdr_idx
110
+
111
+ mac = normalize_mac(parts[lladdr_idx + 1])
112
+ next if mac.nil? || mac.empty?
113
+
114
+ hosts << { ip: ip, mac: mac, hostname: nil }
115
+ end
116
+ hosts
117
+ end
118
+
119
+ def parse_arp_a_linux
120
+ output, status = Open3.capture2("arp", "-a")
121
+ return [] unless status.success?
122
+
123
+ hosts = []
124
+ output.each_line do |line|
125
+ # Format varies, but typically: hostname (ip) at mac [ether] on iface
126
+ match = line.match(/\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)/i)
127
+ next unless match
128
+
129
+ ip = match[1]
130
+ mac = normalize_mac(match[2])
131
+ next if mac.nil? || mac.empty?
132
+
133
+ hosts << { ip: ip, mac: mac, hostname: nil }
134
+ end
135
+ hosts
136
+ end
137
+
138
+ def normalize_mac(mac)
139
+ return nil if mac.nil?
140
+ return nil if mac == "(incomplete)"
141
+
142
+ # Split by common separators and normalize each octet
143
+ parts = mac.downcase.split(/[:\-.]/)
144
+
145
+ # If split worked (should have 6 parts for MAC)
146
+ if parts.length == 6
147
+ # Pad each octet to 2 digits
148
+ normalized = parts.map { |p| p.rjust(2, "0") }.join(":")
149
+ return normalized if normalized.match?(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/)
150
+ end
151
+
152
+ # Fallback: remove all non-hex, must be 12 chars
153
+ clean = mac.gsub(/[^0-9a-f]/i, "").downcase
154
+ return nil if clean.length != 12
155
+
156
+ clean.chars.each_slice(2).map(&:join).join(":")
157
+ end
158
+
159
+ def valid_ipv4?(ip)
160
+ parts = ip.split(".")
161
+ return false unless parts.length == 4
162
+
163
+ parts.all? { |p| p.to_i.between?(0, 255) && p == p.to_i.to_s }
164
+ end
165
+
166
+ def lookup_vendor(mac)
167
+ return "Unknown" if mac.nil?
168
+
169
+ # OUI lookup could be implemented with a database
170
+ # For now, return Unknown
171
+ "Unknown"
172
+ end
173
+
174
+ def aim_device?(mac)
175
+ return false if mac.nil?
176
+
177
+ oui = mac[0..7] # First 3 octets (aa:bb:cc)
178
+ AIM_OUI_PREFIXES.include?(oui)
179
+ end
180
+
181
+ def espressif_device?(mac)
182
+ return false if mac.nil?
183
+
184
+ oui = mac[0..7]
185
+ ESPRESSIF_OUIS.include?(oui)
186
+ end
187
+
188
+ def self.detect_subnet_macos
189
+ # Get default route interface
190
+ output, status = Open3.capture2("route", "get", "default")
191
+ return nil unless status.success?
192
+
193
+ iface_match = output.match(/interface:\s*(\S+)/)
194
+ return nil unless iface_match
195
+
196
+ interface = iface_match[1]
197
+
198
+ # Get IP and netmask for interface
199
+ ifconfig_out, ifconfig_status = Open3.capture2("ifconfig", interface)
200
+ return nil unless ifconfig_status.success?
201
+
202
+ ip_match = ifconfig_out.match(/inet\s+(\d+\.\d+\.\d+\.\d+)\s+netmask\s+(0x[0-9a-f]+)/i)
203
+ return nil unless ip_match
204
+
205
+ ip = ip_match[1]
206
+ netmask_hex = ip_match[2]
207
+
208
+ # Convert hex netmask to CIDR
209
+ netmask_int = netmask_hex.to_i(16)
210
+ cidr = netmask_int.to_s(2).count("1")
211
+
212
+ # Calculate network address
213
+ ip_addr = IPAddr.new("#{ip}/#{cidr}")
214
+ subnet = "#{ip_addr.to_s}/#{cidr}"
215
+
216
+ {
217
+ interface: interface,
218
+ local_ip: ip,
219
+ subnet: subnet,
220
+ netmask: cidr
221
+ }
222
+ end
223
+
224
+ def self.detect_subnet_linux
225
+ # Get default route
226
+ output, status = Open3.capture2("ip", "route", "get", "1.1.1.1")
227
+ return nil unless status.success?
228
+
229
+ # Parse: 1.1.1.1 via 192.168.1.1 dev eth0 src 192.168.1.100
230
+ src_match = output.match(/src\s+(\d+\.\d+\.\d+\.\d+)/)
231
+ dev_match = output.match(/dev\s+(\S+)/)
232
+ return nil unless src_match && dev_match
233
+
234
+ local_ip = src_match[1]
235
+ interface = dev_match[1]
236
+
237
+ # Get subnet from ip addr
238
+ addr_out, addr_status = Open3.capture2("ip", "-4", "addr", "show", interface)
239
+ return nil unless addr_status.success?
240
+
241
+ cidr_match = addr_out.match(/inet\s+(\d+\.\d+\.\d+\.\d+)\/(\d+)/)
242
+ return nil unless cidr_match
243
+
244
+ cidr = cidr_match[2].to_i
245
+ ip_addr = IPAddr.new("#{local_ip}/#{cidr}")
246
+ subnet = "#{ip_addr.to_s}/#{cidr}"
247
+
248
+ {
249
+ interface: interface,
250
+ local_ip: local_ip,
251
+ subnet: subnet,
252
+ netmask: cidr
253
+ }
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module MyChron
7
+ module Network
8
+ class HttpProbe
9
+ DEFAULT_ENDPOINTS = ["/", "/api/", "/sessions/", "/data/", "/info"].freeze
10
+ AIM_INDICATORS = %w[aim mychron racestudio telemetry session].freeze
11
+
12
+ attr_reader :timeout
13
+
14
+ def initialize(timeout: 5)
15
+ @timeout = timeout
16
+ end
17
+
18
+ # Probe HTTP server at given IP and port
19
+ # Returns { status:, headers:, body_preview:, aim_score: }
20
+ def probe(ip, port: 80, endpoints: DEFAULT_ENDPOINTS)
21
+ base_url = port == 443 ? "https://#{ip}" : "http://#{ip}:#{port}"
22
+
23
+ results = {
24
+ reachable: false,
25
+ base_url: base_url,
26
+ endpoints: {},
27
+ aim_score: 0,
28
+ aim_indicators: []
29
+ }
30
+
31
+ endpoints.each do |endpoint|
32
+ result = probe_endpoint(base_url, endpoint)
33
+ results[:endpoints][endpoint] = result
34
+
35
+ if result[:status]
36
+ results[:reachable] = true
37
+
38
+ # Check for AiM indicators
39
+ indicators = check_aim_indicators(result)
40
+ unless indicators.empty?
41
+ results[:aim_indicators].concat(indicators)
42
+ results[:aim_score] += indicators.length * 10
43
+ end
44
+ end
45
+ end
46
+
47
+ results[:aim_indicators].uniq!
48
+ results
49
+ end
50
+
51
+ # Quick check if HTTP is available
52
+ def reachable?(ip, port: 80)
53
+ result = probe_endpoint("http://#{ip}:#{port}", "/")
54
+ !result[:status].nil?
55
+ end
56
+
57
+ private
58
+
59
+ def probe_endpoint(base_url, path)
60
+ uri = URI.join(base_url, path)
61
+
62
+ http = Net::HTTP.new(uri.host, uri.port)
63
+ http.open_timeout = @timeout
64
+ http.read_timeout = @timeout
65
+ http.use_ssl = (uri.scheme == "https")
66
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE if http.use_ssl?
67
+
68
+ request = Net::HTTP::Get.new(uri)
69
+ request["User-Agent"] = "MyChron-Discovery/#{MyChron::VERSION}"
70
+
71
+ response = http.request(request)
72
+
73
+ {
74
+ status: response.code.to_i,
75
+ headers: response.to_hash,
76
+ body_preview: response.body&.slice(0, 500),
77
+ content_type: response["content-type"],
78
+ server: response["server"]
79
+ }
80
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
81
+ Errno::ENOTCONN, Errno::ECONNRESET, Errno::ENETUNREACH,
82
+ Errno::EPIPE, Errno::ECONNABORTED, IOError,
83
+ Net::OpenTimeout, Net::ReadTimeout, SocketError,
84
+ OpenSSL::SSL::SSLError => e
85
+ {
86
+ status: nil,
87
+ error: e.class.name,
88
+ message: e.message
89
+ }
90
+ end
91
+
92
+ def check_aim_indicators(result)
93
+ indicators = []
94
+
95
+ # Check headers
96
+ server = result[:server]&.downcase
97
+ if server
98
+ AIM_INDICATORS.each do |indicator|
99
+ indicators << "server:#{indicator}" if server.include?(indicator)
100
+ end
101
+ end
102
+
103
+ # Check body
104
+ body = result[:body_preview]&.downcase
105
+ if body
106
+ AIM_INDICATORS.each do |indicator|
107
+ indicators << "body:#{indicator}" if body.include?(indicator)
108
+ end
109
+ end
110
+
111
+ # Check content type
112
+ ct = result[:content_type]&.downcase
113
+ if ct&.include?("json")
114
+ indicators << "json_api"
115
+ end
116
+
117
+ indicators
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyChron
4
+ module Network
5
+ class Scanner
6
+ DEFAULT_PORTS = [21, 22, 80, 443, 8080, 5000, 6000, 7000, 9000] + (10_000..10_100).to_a
7
+
8
+ attr_reader :timeout, :use_nmap
9
+
10
+ def initialize(timeout: 0.5, use_nmap: nil)
11
+ @timeout = timeout
12
+ @use_nmap = use_nmap.nil? ? nmap_available? : use_nmap
13
+ end
14
+
15
+ # Scan a single host for open ports
16
+ # Returns hash of { port => { open: bool, banner: string|nil } }
17
+ def scan(ip, ports: DEFAULT_PORTS)
18
+ if @use_nmap
19
+ scan_with_nmap(ip, ports)
20
+ else
21
+ scan_with_ruby(ip, ports)
22
+ end
23
+ end
24
+
25
+ # Quick check if any common ports are open
26
+ def quick_scan(ip, ports: [80, 443, 8080, 21, 22])
27
+ results = scan(ip, ports: ports)
28
+ results.select { |_, v| v[:open] }
29
+ end
30
+
31
+ # Check if a single port is open
32
+ def port_open?(ip, port)
33
+ result = scan(ip, ports: [port])
34
+ result[port]&.fetch(:open, false)
35
+ end
36
+
37
+ private
38
+
39
+ def nmap_available?
40
+ _, status = Open3.capture2("which", "nmap")
41
+ status.success?
42
+ end
43
+
44
+ def scan_with_nmap(ip, ports)
45
+ port_str = ports.join(",")
46
+
47
+ # -sT: TCP connect scan (no root required)
48
+ # -sV: Version detection for banners
49
+ # --open: Only show open ports
50
+ # -T4: Aggressive timing
51
+ cmd = ["nmap", "-sT", "-sV", "--open", "-T4", "-p", port_str, ip]
52
+
53
+ output, status = Open3.capture2(*cmd)
54
+ return {} unless status.success?
55
+
56
+ parse_nmap_output(output, ports)
57
+ end
58
+
59
+ def parse_nmap_output(output, all_ports)
60
+ results = all_ports.each_with_object({}) do |port, hash|
61
+ hash[port] = { open: false, banner: nil }
62
+ end
63
+
64
+ output.each_line do |line|
65
+ # Format: 80/tcp open http Apache httpd 2.4.41
66
+ match = line.match(/^(\d+)\/tcp\s+open\s+(\S+)\s*(.*)/)
67
+ next unless match
68
+
69
+ port = match[1].to_i
70
+ service = match[2]
71
+ version = match[3].strip
72
+
73
+ banner = version.empty? ? service : "#{service} #{version}"
74
+ results[port] = { open: true, banner: banner }
75
+ end
76
+
77
+ results
78
+ end
79
+
80
+ def scan_with_ruby(ip, ports)
81
+ results = {}
82
+ mutex = Mutex.new
83
+
84
+ # Process in batches to avoid too many concurrent connections
85
+ ports.each_slice(20) do |port_batch|
86
+ threads = port_batch.map do |port|
87
+ Thread.new do
88
+ Thread.current.report_on_exception = false
89
+ begin
90
+ result = check_port(ip, port)
91
+ mutex.synchronize { results[port] = result }
92
+ rescue StandardError => e
93
+ mutex.synchronize { results[port] = { open: false, banner: nil, error: e.message } }
94
+ end
95
+ end
96
+ end
97
+
98
+ # Wait for batch to complete
99
+ threads.each(&:join)
100
+ end
101
+
102
+ results
103
+ end
104
+
105
+ def check_port(ip, port)
106
+ socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
107
+ sockaddr = Socket.sockaddr_in(port, ip)
108
+
109
+ begin
110
+ socket.connect_nonblock(sockaddr)
111
+ banner = grab_banner(socket)
112
+ { open: true, banner: banner }
113
+ rescue IO::WaitWritable, IO::EINPROGRESSWaitWritable
114
+ # Connection in progress, wait for socket to become writable
115
+ if IO.select(nil, [socket], nil, @timeout)
116
+ begin
117
+ # Check if connection succeeded
118
+ socket.connect_nonblock(sockaddr)
119
+ banner = grab_banner(socket)
120
+ { open: true, banner: banner }
121
+ rescue Errno::EISCONN
122
+ # Already connected - success
123
+ banner = grab_banner(socket)
124
+ { open: true, banner: banner }
125
+ rescue Errno::EALREADY
126
+ # Still connecting, wait a bit more
127
+ sleep(0.1)
128
+ begin
129
+ socket.connect_nonblock(sockaddr)
130
+ { open: true, banner: nil }
131
+ rescue Errno::EISCONN
132
+ { open: true, banner: nil }
133
+ rescue StandardError
134
+ { open: false, banner: nil }
135
+ end
136
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
137
+ Errno::ENOTCONN, Errno::ECONNRESET
138
+ { open: false, banner: nil }
139
+ end
140
+ else
141
+ { open: false, banner: nil }
142
+ end
143
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
144
+ Errno::ENETUNREACH, Errno::ECONNRESET
145
+ { open: false, banner: nil }
146
+ ensure
147
+ socket.close rescue nil
148
+ end
149
+ end
150
+
151
+ def grab_banner(socket)
152
+ # Try to read banner with short timeout
153
+ socket.read_nonblock(1024)
154
+ rescue IO::WaitReadable
155
+ if IO.select([socket], nil, nil, 0.5)
156
+ begin
157
+ socket.read_nonblock(1024)
158
+ rescue StandardError
159
+ nil
160
+ end
161
+ end
162
+ rescue StandardError
163
+ nil
164
+ end
165
+ end
166
+ end
167
+ end