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,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
|