shared_tools 0.3.0 → 0.3.1

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,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm/tool'
4
+ require 'resolv'
5
+ require 'socket'
6
+
7
+ module SharedTools
8
+ module Tools
9
+ # A tool for performing DNS lookups and reverse DNS queries.
10
+ # Uses Ruby's built-in Resolv library for cross-platform support.
11
+ #
12
+ # @example
13
+ # tool = SharedTools::Tools::DnsTool.new
14
+ # result = tool.execute(action: 'lookup', hostname: 'google.com')
15
+ # puts result[:addresses]
16
+ class DnsTool < RubyLLM::Tool
17
+ def self.name = 'dns'
18
+
19
+ description <<~'DESCRIPTION'
20
+ Perform DNS lookups and reverse DNS queries.
21
+
22
+ Uses Ruby's built-in Resolv library for cross-platform DNS resolution.
23
+
24
+ Actions:
25
+ - 'lookup': Resolve a hostname to IP addresses
26
+ - 'reverse': Perform reverse DNS lookup (IP to hostname)
27
+ - 'mx': Get MX (mail exchange) records for a domain
28
+ - 'txt': Get TXT records for a domain
29
+ - 'ns': Get NS (nameserver) records for a domain
30
+ - 'all': Get all available DNS records for a domain
31
+
32
+ Record Types (for lookup action):
33
+ - 'A': IPv4 addresses (default)
34
+ - 'AAAA': IPv6 addresses
35
+ - 'CNAME': Canonical name records
36
+ - 'ANY': All record types
37
+
38
+ Example usage:
39
+ tool = SharedTools::Tools::DnsTool.new
40
+
41
+ # Basic lookup
42
+ tool.execute(action: 'lookup', hostname: 'google.com')
43
+
44
+ # Get MX records
45
+ tool.execute(action: 'mx', hostname: 'gmail.com')
46
+
47
+ # Reverse lookup
48
+ tool.execute(action: 'reverse', ip: '8.8.8.8')
49
+
50
+ # Get all records
51
+ tool.execute(action: 'all', hostname: 'example.com')
52
+ DESCRIPTION
53
+
54
+ params do
55
+ string :action, description: <<~DESC.strip
56
+ The DNS action to perform:
57
+ - 'lookup': Resolve hostname to IP addresses
58
+ - 'reverse': IP to hostname lookup
59
+ - 'mx': Get mail exchange records
60
+ - 'txt': Get TXT records
61
+ - 'ns': Get nameserver records
62
+ - 'all': Get all available records
63
+ DESC
64
+
65
+ string :hostname, description: <<~DESC.strip, required: false
66
+ The hostname/domain to look up.
67
+ Required for 'lookup', 'mx', 'txt', 'ns', and 'all' actions.
68
+ DESC
69
+
70
+ string :ip, description: <<~DESC.strip, required: false
71
+ The IP address for reverse DNS lookup.
72
+ Required for 'reverse' action.
73
+ DESC
74
+
75
+ string :record_type, description: <<~DESC.strip, required: false
76
+ Record type for 'lookup' action:
77
+ - 'A': IPv4 addresses (default)
78
+ - 'AAAA': IPv6 addresses
79
+ - 'CNAME': Canonical name
80
+ - 'ANY': All types
81
+ DESC
82
+ end
83
+
84
+ # @param logger [Logger] optional logger
85
+ def initialize(logger: nil)
86
+ @logger = logger || RubyLLM.logger
87
+ end
88
+
89
+ # Execute DNS action
90
+ #
91
+ # @param action [String] DNS action to perform
92
+ # @param hostname [String, nil] hostname to look up
93
+ # @param ip [String, nil] IP for reverse lookup
94
+ # @param record_type [String, nil] record type for lookup
95
+ # @return [Hash] DNS results
96
+ def execute(action:, hostname: nil, ip: nil, record_type: nil)
97
+ @logger.info("DnsTool#execute action=#{action.inspect}")
98
+
99
+ case action.to_s.downcase
100
+ when 'lookup'
101
+ lookup(hostname, record_type || 'A')
102
+ when 'reverse'
103
+ reverse_lookup(ip)
104
+ when 'mx'
105
+ mx_lookup(hostname)
106
+ when 'txt'
107
+ txt_lookup(hostname)
108
+ when 'ns'
109
+ ns_lookup(hostname)
110
+ when 'all'
111
+ all_records(hostname)
112
+ else
113
+ {
114
+ success: false,
115
+ error: "Unknown action: #{action}. Valid actions are: lookup, reverse, mx, txt, ns, all"
116
+ }
117
+ end
118
+ rescue Resolv::ResolvError => e
119
+ @logger.error("DnsTool DNS error: #{e.message}")
120
+ {
121
+ success: false,
122
+ error: "DNS resolution failed: #{e.message}"
123
+ }
124
+ rescue => e
125
+ @logger.error("DnsTool error: #{e.message}")
126
+ {
127
+ success: false,
128
+ error: e.message
129
+ }
130
+ end
131
+
132
+ private
133
+
134
+ def lookup(hostname, record_type)
135
+ return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
136
+
137
+ results = {
138
+ success: true,
139
+ hostname: hostname,
140
+ record_type: record_type.upcase,
141
+ addresses: []
142
+ }
143
+
144
+ resolver = Resolv::DNS.new
145
+
146
+ case record_type.upcase
147
+ when 'A'
148
+ addresses = resolver.getresources(hostname, Resolv::DNS::Resource::IN::A)
149
+ results[:addresses] = addresses.map { |a| { type: 'A', address: a.address.to_s } }
150
+ when 'AAAA'
151
+ addresses = resolver.getresources(hostname, Resolv::DNS::Resource::IN::AAAA)
152
+ results[:addresses] = addresses.map { |a| { type: 'AAAA', address: a.address.to_s } }
153
+ when 'CNAME'
154
+ cnames = resolver.getresources(hostname, Resolv::DNS::Resource::IN::CNAME)
155
+ results[:addresses] = cnames.map { |c| { type: 'CNAME', name: c.name.to_s } }
156
+ when 'ANY'
157
+ # Get A records
158
+ a_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::A)
159
+ results[:addresses] += a_records.map { |a| { type: 'A', address: a.address.to_s } }
160
+
161
+ # Get AAAA records
162
+ aaaa_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::AAAA)
163
+ results[:addresses] += aaaa_records.map { |a| { type: 'AAAA', address: a.address.to_s } }
164
+
165
+ # Get CNAME records
166
+ cname_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::CNAME)
167
+ results[:addresses] += cname_records.map { |c| { type: 'CNAME', name: c.name.to_s } }
168
+ else
169
+ return { success: false, error: "Unknown record type: #{record_type}. Valid types are: A, AAAA, CNAME, ANY" }
170
+ end
171
+
172
+ resolver.close
173
+ results
174
+ end
175
+
176
+ def reverse_lookup(ip)
177
+ return { success: false, error: "IP address is required" } if ip.nil? || ip.empty?
178
+
179
+ # Validate IP address format
180
+ unless valid_ip?(ip)
181
+ return { success: false, error: "Invalid IP address format: #{ip}" }
182
+ end
183
+
184
+ hostnames = []
185
+
186
+ begin
187
+ # Try using Resolv for reverse lookup
188
+ name = Resolv.getname(ip)
189
+ hostnames << name
190
+ rescue Resolv::ResolvError
191
+ # Try alternative method using DNS PTR record
192
+ begin
193
+ resolver = Resolv::DNS.new
194
+ ptr_name = ip_to_ptr(ip)
195
+ ptrs = resolver.getresources(ptr_name, Resolv::DNS::Resource::IN::PTR)
196
+ hostnames = ptrs.map { |p| p.name.to_s }
197
+ resolver.close
198
+ rescue
199
+ # No reverse DNS record found
200
+ end
201
+ end
202
+
203
+ {
204
+ success: true,
205
+ ip: ip,
206
+ hostnames: hostnames,
207
+ found: !hostnames.empty?
208
+ }
209
+ end
210
+
211
+ def mx_lookup(hostname)
212
+ return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
213
+
214
+ resolver = Resolv::DNS.new
215
+ mx_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::MX)
216
+
217
+ records = mx_records.map do |mx|
218
+ {
219
+ priority: mx.preference,
220
+ exchange: mx.exchange.to_s
221
+ }
222
+ end.sort_by { |r| r[:priority] }
223
+
224
+ resolver.close
225
+
226
+ {
227
+ success: true,
228
+ hostname: hostname,
229
+ mx_records: records,
230
+ count: records.length
231
+ }
232
+ end
233
+
234
+ def txt_lookup(hostname)
235
+ return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
236
+
237
+ resolver = Resolv::DNS.new
238
+ txt_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::TXT)
239
+
240
+ records = txt_records.map do |txt|
241
+ txt.strings.join
242
+ end
243
+
244
+ resolver.close
245
+
246
+ {
247
+ success: true,
248
+ hostname: hostname,
249
+ txt_records: records,
250
+ count: records.length
251
+ }
252
+ end
253
+
254
+ def ns_lookup(hostname)
255
+ return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
256
+
257
+ resolver = Resolv::DNS.new
258
+ ns_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::NS)
259
+
260
+ records = ns_records.map { |ns| ns.name.to_s }
261
+
262
+ resolver.close
263
+
264
+ {
265
+ success: true,
266
+ hostname: hostname,
267
+ nameservers: records,
268
+ count: records.length
269
+ }
270
+ end
271
+
272
+ def all_records(hostname)
273
+ return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
274
+
275
+ resolver = Resolv::DNS.new
276
+
277
+ results = {
278
+ success: true,
279
+ hostname: hostname,
280
+ records: {}
281
+ }
282
+
283
+ # A records
284
+ a_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::A)
285
+ results[:records][:A] = a_records.map { |a| a.address.to_s } unless a_records.empty?
286
+
287
+ # AAAA records
288
+ aaaa_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::AAAA)
289
+ results[:records][:AAAA] = aaaa_records.map { |a| a.address.to_s } unless aaaa_records.empty?
290
+
291
+ # MX records
292
+ mx_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::MX)
293
+ unless mx_records.empty?
294
+ results[:records][:MX] = mx_records.map do |mx|
295
+ { priority: mx.preference, exchange: mx.exchange.to_s }
296
+ end.sort_by { |r| r[:priority] }
297
+ end
298
+
299
+ # TXT records
300
+ txt_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::TXT)
301
+ results[:records][:TXT] = txt_records.map { |t| t.strings.join } unless txt_records.empty?
302
+
303
+ # NS records
304
+ ns_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::NS)
305
+ results[:records][:NS] = ns_records.map { |ns| ns.name.to_s } unless ns_records.empty?
306
+
307
+ # CNAME records
308
+ cname_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::CNAME)
309
+ results[:records][:CNAME] = cname_records.map { |c| c.name.to_s } unless cname_records.empty?
310
+
311
+ # SOA record
312
+ begin
313
+ soa_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::SOA)
314
+ unless soa_records.empty?
315
+ soa = soa_records.first
316
+ results[:records][:SOA] = {
317
+ mname: soa.mname.to_s,
318
+ rname: soa.rname.to_s,
319
+ serial: soa.serial,
320
+ refresh: soa.refresh,
321
+ retry: soa.retry,
322
+ expire: soa.expire,
323
+ minimum: soa.minimum
324
+ }
325
+ end
326
+ rescue
327
+ # SOA might not be available for all domains
328
+ end
329
+
330
+ resolver.close
331
+ results
332
+ end
333
+
334
+ def valid_ip?(ip)
335
+ # Check IPv4
336
+ return true if ip =~ /\A(\d{1,3}\.){3}\d{1,3}\z/ &&
337
+ ip.split('.').all? { |octet| octet.to_i.between?(0, 255) }
338
+
339
+ # Check IPv6 (simplified check)
340
+ return true if ip =~ /\A[\da-fA-F:]+\z/ && ip.include?(':')
341
+
342
+ false
343
+ end
344
+
345
+ def ip_to_ptr(ip)
346
+ if ip.include?(':')
347
+ # IPv6 - not fully implemented, but basic support
348
+ ip.gsub(':', '').chars.reverse.join('.') + '.ip6.arpa'
349
+ else
350
+ # IPv4
351
+ ip.split('.').reverse.join('.') + '.in-addr.arpa'
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end