shared_tools 0.3.1 → 0.4.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -16
  3. data/README.md +257 -262
  4. data/lib/shared_tools/browser_tool.rb +5 -0
  5. data/lib/shared_tools/calculator_tool.rb +4 -0
  6. data/lib/shared_tools/clipboard_tool.rb +4 -0
  7. data/lib/shared_tools/composite_analysis_tool.rb +4 -0
  8. data/lib/shared_tools/computer_tool.rb +5 -0
  9. data/lib/shared_tools/cron_tool.rb +4 -0
  10. data/lib/shared_tools/current_date_time_tool.rb +4 -0
  11. data/lib/shared_tools/data_science_kit.rb +4 -0
  12. data/lib/shared_tools/database.rb +4 -0
  13. data/lib/shared_tools/database_query_tool.rb +4 -0
  14. data/lib/shared_tools/database_tool.rb +5 -0
  15. data/lib/shared_tools/disk_tool.rb +5 -0
  16. data/lib/shared_tools/dns_tool.rb +4 -0
  17. data/lib/shared_tools/doc_tool.rb +5 -0
  18. data/lib/shared_tools/error_handling_tool.rb +4 -0
  19. data/lib/shared_tools/eval_tool.rb +5 -0
  20. data/lib/shared_tools/mcp/brave_search_client.rb +37 -0
  21. data/lib/shared_tools/mcp/chart_client.rb +32 -0
  22. data/lib/shared_tools/mcp/github_client.rb +38 -0
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +43 -0
  24. data/lib/shared_tools/mcp/memory_client.rb +33 -0
  25. data/lib/shared_tools/mcp/notion_client.rb +40 -0
  26. data/lib/shared_tools/mcp/sequential_thinking_client.rb +33 -0
  27. data/lib/shared_tools/mcp/slack_client.rb +54 -0
  28. data/lib/shared_tools/mcp/streamable_http_patch.rb +42 -0
  29. data/lib/shared_tools/mcp/tavily_client.rb +41 -0
  30. data/lib/shared_tools/mcp.rb +45 -16
  31. data/lib/shared_tools/system_info_tool.rb +4 -0
  32. data/lib/shared_tools/tools/browser/base_tool.rb +8 -12
  33. data/lib/shared_tools/tools/browser/click_tool.rb +4 -2
  34. data/lib/shared_tools/tools/browser/ferrum_driver.rb +119 -0
  35. data/lib/shared_tools/tools/browser/inspect_tool.rb +4 -2
  36. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +4 -2
  37. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +19 -7
  38. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +4 -2
  39. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +4 -2
  40. data/lib/shared_tools/tools/browser/visit_tool.rb +4 -2
  41. data/lib/shared_tools/tools/browser.rb +31 -2
  42. data/lib/shared_tools/tools/browser_tool.rb +6 -0
  43. data/lib/shared_tools/tools/clipboard_tool.rb +69 -144
  44. data/lib/shared_tools/tools/composite_analysis_tool.rb +60 -4
  45. data/lib/shared_tools/tools/computer/mac_driver.rb +37 -4
  46. data/lib/shared_tools/tools/cron_tool.rb +237 -379
  47. data/lib/shared_tools/tools/current_date_time_tool.rb +54 -120
  48. data/lib/shared_tools/tools/data_science_kit.rb +63 -13
  49. data/lib/shared_tools/tools/dns_tool.rb +335 -269
  50. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
  51. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
  52. data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
  53. data/lib/shared_tools/tools/doc.rb +3 -0
  54. data/lib/shared_tools/tools/doc_tool.rb +101 -6
  55. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  56. data/lib/shared_tools/tools/enabler.rb +42 -0
  57. data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
  58. data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
  59. data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
  60. data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
  61. data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
  62. data/lib/shared_tools/tools/notification.rb +12 -0
  63. data/lib/shared_tools/tools/notification_tool.rb +99 -0
  64. data/lib/shared_tools/tools/system_info_tool.rb +130 -343
  65. data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
  66. data/lib/shared_tools/utilities.rb +193 -0
  67. data/lib/shared_tools/version.rb +1 -1
  68. data/lib/shared_tools/weather_tool.rb +4 -0
  69. data/lib/shared_tools/workflow_manager_tool.rb +4 -0
  70. data/lib/shared_tools.rb +28 -38
  71. metadata +74 -9
  72. data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
  73. data/lib/shared_tools/mcp/imcp.rb +0 -28
  74. data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
  75. data/lib/shared_tools/tools/devops_toolkit.rb +0 -420
@@ -1,354 +1,420 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ruby_llm/tool'
4
3
  require 'resolv'
5
4
  require 'socket'
5
+ require 'net/http'
6
+ require 'uri'
7
+ require_relative '../../shared_tools'
6
8
 
7
9
  module SharedTools
8
10
  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
+ # DNS lookup tool supporting A, AAAA, MX, TXT, NS, CNAME, reverse, all-records,
12
+ # external IP detection, and WHOIS database queries.
11
13
  #
12
14
  # @example
13
15
  # 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.
16
+ # tool.execute(action: 'a', host: 'ruby-lang.org')
17
+ # tool.execute(action: 'mx', host: 'gmail.com')
18
+ # tool.execute(action: 'reverse', host: '8.8.8.8')
19
+ # tool.execute(action: 'external_ip')
20
+ # tool.execute(action: 'whois', host: 'github.com')
21
+ # tool.execute(action: 'whois', host: '8.8.8.8')
22
+ class DnsTool < ::RubyLLM::Tool
23
+ def self.name = 'dns_tool'
24
+
25
+ description <<~DESC
26
+ Perform DNS lookups, reverse lookups, record queries, external IP detection,
27
+ and WHOIS database queries for any hostname, domain name, or IP address.
23
28
 
24
29
  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
30
+ - 'a' IPv4 address records (A)
31
+ - 'aaaa' IPv6 address records (AAAA)
32
+ - 'mx' Mail exchange records, sorted by priority
33
+ - 'txt' TXT records (SPF, DKIM, verification tokens, etc.)
34
+ - 'ns' Authoritative nameserver records
35
+ - 'cname' Canonical name (alias) records
36
+ - 'reverse' — Reverse PTR lookup for an IP address
37
+ - 'all' — A, MX, TXT, NS, and CNAME records combined
38
+ - 'external_ip' Detect the current machine's public-facing IP address
39
+ - 'ip_location' Geolocate an IP address (city, region, country, lat/lon, timezone, ISP)
40
+ - 'whois' Query the WHOIS database for a domain name or IP address
41
+ DESC
53
42
 
54
43
  params do
55
44
  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
45
+ The DNS or network operation to perform. One of:
46
+ - 'a' Look up IPv4 A records for a hostname. Returns the IPv4 addresses
47
+ that the hostname resolves to. Useful for verifying DNS propagation,
48
+ checking load balancer IPs, or confirming a domain points to the
49
+ expected server.
50
+ - 'aaaa' Look up IPv6 AAAA records for a hostname. Returns IPv6 addresses
51
+ that the hostname resolves to. Important for dual-stack network
52
+ verification and IPv6 connectivity testing.
53
+ - 'mx' — Look up Mail Exchange (MX) records for a domain, sorted by
54
+ priority (lowest number = highest priority). Essential for diagnosing
55
+ email delivery issues, verifying mail server configuration, and
56
+ confirming that a domain is using the expected mail provider.
57
+ - 'txt' — Look up TXT records for a domain. TXT records carry human-readable
58
+ text and machine-readable data including SPF policies (which servers
59
+ may send mail for the domain), DKIM public keys (for email signing),
60
+ DMARC policies, domain ownership verification tokens (Google, GitHub,
61
+ etc.), and BIMI brand indicators.
62
+ - 'ns' — Look up the authoritative Name Server (NS) records for a domain.
63
+ Returns the hostnames of the DNS servers that are authoritative for
64
+ the domain. Useful for verifying registrar settings, diagnosing DNS
65
+ delegation problems, and confirming a domain is using a specific
66
+ DNS provider.
67
+ - 'cname' — Look up Canonical Name (CNAME) alias records for a hostname.
68
+ Returns the target hostname that this alias points to. Common for
69
+ CDN configurations, third-party service integrations (e.g. Shopify,
70
+ Heroku), and subdomain aliases.
71
+ - 'reverse' — Perform a reverse PTR (pointer) lookup for an IP address. Returns
72
+ the hostname associated with the IP, if one is configured. Important
73
+ for mail server deliverability (forward-confirmed reverse DNS),
74
+ identifying unknown IP addresses, and network forensics.
75
+ - 'all' — Retrieve A, MX, TXT, NS, and CNAME records for a domain in a
76
+ single call. Provides a comprehensive snapshot of a domain's DNS
77
+ configuration. Useful for domain audits, migration planning, and
78
+ quick overviews.
79
+ - 'external_ip' — Detect the current machine's public-facing (external) IP address
80
+ as seen by the internet. Does not require a host parameter. Useful
81
+ for firewall rule generation, VPN verification, geolocation context,
82
+ abuse report submissions, and confirming that traffic is routing
83
+ through the expected network path (e.g. a VPN or proxy).
84
+ - 'ip_location' — Geolocate an IP address using a free geolocation API. Returns the
85
+ city, region, country, country code, latitude, longitude, timezone,
86
+ ISP name, and organisation. Accepts any public IPv4 address in the
87
+ host parameter; omit host (or pass the result of an 'external_ip'
88
+ call) to geolocate your own public IP. Useful for determining a
89
+ user's approximate location from their IP address, cross-referencing
90
+ IP ownership with physical geography, building location-aware
91
+ workflows (e.g. routing to the nearest server), and providing
92
+ contextual information such as local time and weather.
93
+ - 'whois' — Query the WHOIS database for a domain name or IP address. For
94
+ domain names, returns registrar information, registration and
95
+ expiry dates, name servers, registrant organization (when not
96
+ privacy-protected), and domain status flags. For IP addresses,
97
+ returns the network owner, ASN (Autonomous System Number), CIDR
98
+ netblock, country of allocation, and abuse contact information.
99
+ Useful for identifying who owns an IP attacking your server,
100
+ checking domain expiry dates, verifying registrar lock status,
101
+ finding abuse contacts, and threat intelligence workflows.
63
102
  DESC
64
103
 
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
104
+ string :host, description: <<~DESC.strip, required: false
105
+ The hostname, domain name, or IP address to query. Required for all actions
106
+ except 'external_ip'. Examples:
107
+ - Hostname: 'ruby-lang.org', 'mail.google.com'
108
+ - Domain: 'github.com', 'cloudflare.com'
109
+ - IP address: '8.8.8.8', '2001:4860:4860::8888'
110
+ For the 'reverse' action, provide an IP address.
111
+ For the 'whois' action, provide either a domain name or an IP address.
112
+ For the 'external_ip' action, this parameter is ignored.
113
+ For the 'ip_location' action, provide a public IPv4 address, or omit to geolocate your own external IP.
81
114
  DESC
82
115
  end
83
116
 
117
+ WHOIS_PORT = 43
118
+ WHOIS_TIMEOUT = 10
119
+ IANA_WHOIS = 'whois.iana.org'
120
+ ARIN_WHOIS = 'whois.arin.net'
121
+ IP_SERVICES = %w[
122
+ https://api.ipify.org
123
+ https://ifconfig.me/ip
124
+ https://icanhazip.com
125
+ ].freeze
126
+
84
127
  # @param logger [Logger] optional logger
85
128
  def initialize(logger: nil)
86
129
  @logger = logger || RubyLLM.logger
87
130
  end
88
131
 
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}")
132
+ # @param action [String] lookup type
133
+ # @param host [String] hostname or IP (not required for external_ip)
134
+ # @return [Hash] results
135
+ def execute(action:, host: nil)
136
+ @logger.info("DnsTool#execute action=#{action} host=#{host}")
98
137
 
99
138
  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)
139
+ when 'a' then lookup_a(host)
140
+ when 'aaaa' then lookup_aaaa(host)
141
+ when 'mx' then lookup_mx(host)
142
+ when 'txt' then lookup_txt(host)
143
+ when 'ns' then lookup_ns(host)
144
+ when 'cname' then lookup_cname(host)
145
+ when 'reverse' then lookup_reverse(host)
146
+ when 'all' then lookup_all(host)
147
+ when 'external_ip' then lookup_external_ip
148
+ when 'ip_location' then lookup_ip_location(host)
149
+ when 'whois' then lookup_whois(host)
112
150
  else
113
- {
114
- success: false,
115
- error: "Unknown action: #{action}. Valid actions are: lookup, reverse, mx, txt, ns, all"
116
- }
151
+ { success: false, error: "Unknown action '#{action}'. Use: a, aaaa, mx, txt, ns, cname, reverse, all, external_ip, ip_location, whois" }
117
152
  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
153
  rescue => e
125
- @logger.error("DnsTool error: #{e.message}")
126
- {
127
- success: false,
128
- error: e.message
129
- }
154
+ @logger.error("DnsTool error for #{host}: #{e.message}")
155
+ { success: false, host: host, error: e.message }
130
156
  end
131
157
 
132
158
  private
133
159
 
134
- def lookup(hostname, record_type)
135
- return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
160
+ def lookup_a(host)
161
+ records = Resolv.getaddresses(host).select { |a| a.match?(/\A\d+\.\d+\.\d+\.\d+\z/) }
162
+ { success: true, host: host, type: 'A', records: records }
163
+ end
136
164
 
137
- results = {
138
- success: true,
139
- hostname: hostname,
140
- record_type: record_type.upcase,
141
- addresses: []
142
- }
165
+ def lookup_aaaa(host)
166
+ records = Resolv.getaddresses(host).reject { |a| a.match?(/\A\d+\.\d+\.\d+\.\d+\z/) }
167
+ { success: true, host: host, type: 'AAAA', records: records }
168
+ end
143
169
 
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
+ def lookup_mx(host)
171
+ records = []
172
+ Resolv::DNS.open do |dns|
173
+ dns.getresources(host, Resolv::DNS::Resource::IN::MX).each do |r|
174
+ records << { priority: r.preference, exchange: r.exchange.to_s }
175
+ end
170
176
  end
171
-
172
- resolver.close
173
- results
177
+ records.sort_by! { |r| r[:priority] }
178
+ { success: true, host: host, type: 'MX', records: records }
174
179
  end
175
180
 
176
- def reverse_lookup(ip)
177
- return { success: false, error: "IP address is required" } if ip.nil? || ip.empty?
181
+ def lookup_txt(host)
182
+ records = []
183
+ Resolv::DNS.open do |dns|
184
+ dns.getresources(host, Resolv::DNS::Resource::IN::TXT).each do |r|
185
+ records << r.strings.join(' ')
186
+ end
187
+ end
188
+ { success: true, host: host, type: 'TXT', records: records }
189
+ end
178
190
 
179
- # Validate IP address format
180
- unless valid_ip?(ip)
181
- return { success: false, error: "Invalid IP address format: #{ip}" }
191
+ def lookup_ns(host)
192
+ records = []
193
+ Resolv::DNS.open do |dns|
194
+ dns.getresources(host, Resolv::DNS::Resource::IN::NS).each do |r|
195
+ records << r.name.to_s
196
+ end
182
197
  end
198
+ { success: true, host: host, type: 'NS', records: records.sort }
199
+ end
183
200
 
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
201
+ def lookup_cname(host)
202
+ records = []
203
+ Resolv::DNS.open do |dns|
204
+ dns.getresources(host, Resolv::DNS::Resource::IN::CNAME).each do |r|
205
+ records << r.name.to_s
200
206
  end
201
207
  end
208
+ { success: true, host: host, type: 'CNAME', records: records }
209
+ end
202
210
 
211
+ def lookup_reverse(ip)
212
+ hostname = Resolv.getname(ip)
213
+ { success: true, ip: ip, type: 'PTR', hostname: hostname }
214
+ rescue Resolv::ResolvError => e
215
+ { success: false, ip: ip, type: 'PTR', error: "No reverse DNS entry found: #{e.message}" }
216
+ end
217
+
218
+ def lookup_all(host)
203
219
  {
204
220
  success: true,
205
- ip: ip,
206
- hostnames: hostnames,
207
- found: !hostnames.empty?
221
+ host: host,
222
+ a: lookup_a(host)[:records],
223
+ mx: lookup_mx(host)[:records],
224
+ txt: lookup_txt(host)[:records],
225
+ ns: lookup_ns(host)[:records],
226
+ cname: lookup_cname(host)[:records]
208
227
  }
209
228
  end
210
229
 
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
230
+ # Detect external IP by querying well-known public IP echo services.
231
+ # Tries each service in order and returns the first successful response.
232
+ def lookup_external_ip
233
+ IP_SERVICES.each do |url|
234
+ ip = http_get(url)&.strip
235
+ next unless ip&.match?(/\A[\d.:a-fA-F]+\z/)
236
+
237
+ @logger.info("External IP resolved via #{url}: #{ip}")
238
+ return {
239
+ success: true,
240
+ type: 'external_ip',
241
+ ip: ip,
242
+ source: url,
243
+ note: 'This is your public-facing IP address as seen by the internet.'
221
244
  }
222
- end.sort_by { |r| r[:priority] }
223
-
224
- resolver.close
245
+ rescue => e
246
+ @logger.warn("IP service #{url} failed: #{e.message}")
247
+ next
248
+ end
225
249
 
226
- {
227
- success: true,
228
- hostname: hostname,
229
- mx_records: records,
230
- count: records.length
231
- }
250
+ { success: false, type: 'external_ip', error: 'All external IP services unreachable' }
232
251
  end
233
252
 
234
- def txt_lookup(hostname)
235
- return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
253
+ # Geolocate an IP address using the ip-api.com free JSON endpoint.
254
+ # If no IP is supplied, geolocates the caller's own external IP.
255
+ def lookup_ip_location(ip = nil)
256
+ target = ip.to_s.strip.empty? ? '' : "/#{ip.strip}"
257
+ url = "http://ip-api.com/json#{target}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,query"
236
258
 
237
- resolver = Resolv::DNS.new
238
- txt_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::TXT)
259
+ @logger.info("IP geolocation query: #{url}")
260
+ raw = http_get(url)
261
+ data = JSON.parse(raw)
239
262
 
240
- records = txt_records.map do |txt|
241
- txt.strings.join
263
+ if data['status'] == 'fail'
264
+ return { success: false, type: 'ip_location', error: data['message'], ip: ip }
242
265
  end
243
266
 
244
- resolver.close
245
-
246
267
  {
247
- success: true,
248
- hostname: hostname,
249
- txt_records: records,
250
- count: records.length
268
+ success: true,
269
+ type: 'ip_location',
270
+ ip: data['query'],
271
+ city: data['city'],
272
+ region: data['regionName'],
273
+ region_code: data['region'],
274
+ country: data['country'],
275
+ country_code: data['countryCode'],
276
+ zip: data['zip'],
277
+ latitude: data['lat'],
278
+ longitude: data['lon'],
279
+ timezone: data['timezone'],
280
+ isp: data['isp'],
281
+ organization: data['org'],
282
+ asn: data['as'],
283
+ note: 'Geolocation is approximate. Accuracy varies by ISP and region.'
251
284
  }
285
+ rescue => e
286
+ @logger.error("IP geolocation failed for #{ip}: #{e.message}")
287
+ { success: false, type: 'ip_location', error: e.message, ip: ip }
252
288
  end
253
289
 
254
- def ns_lookup(hostname)
255
- return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
290
+ # Query the WHOIS database for a domain name or IP address.
291
+ # For domains, queries IANA first to find the authoritative WHOIS server,
292
+ # then queries that server for full registration details.
293
+ # For IPs, queries ARIN (which redirects to the appropriate RIR).
294
+ def lookup_whois(host)
295
+ return { success: false, error: "host is required for whois lookup" } if host.nil? || host.strip.empty?
256
296
 
257
- resolver = Resolv::DNS.new
258
- ns_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::NS)
297
+ host = host.strip.downcase
259
298
 
260
- records = ns_records.map { |ns| ns.name.to_s }
299
+ if ip_address?(host)
300
+ whois_server = ARIN_WHOIS
301
+ raw = whois_query(whois_server, host)
302
+ parsed = parse_whois_ip(raw)
303
+ else
304
+ # Step 1: ask IANA which server is authoritative for this TLD
305
+ iana_response = whois_query(IANA_WHOIS, host)
306
+ whois_server = extract_whois_server(iana_response) || IANA_WHOIS
261
307
 
262
- resolver.close
308
+ # Step 2: query the authoritative server
309
+ raw = whois_query(whois_server, host)
310
+ parsed = parse_whois_domain(raw)
311
+ end
263
312
 
264
313
  {
265
- success: true,
266
- hostname: hostname,
267
- nameservers: records,
268
- count: records.length
314
+ success: true,
315
+ host: host,
316
+ type: ip_address?(host) ? 'whois_ip' : 'whois_domain',
317
+ whois_server: whois_server,
318
+ parsed: parsed,
319
+ raw: raw
269
320
  }
321
+ rescue => e
322
+ @logger.error("WHOIS lookup failed for #{host}: #{e.message}")
323
+ { success: false, host: host, type: 'whois', error: e.message }
270
324
  end
271
325
 
272
- def all_records(hostname)
273
- return { success: false, error: "Hostname is required" } if hostname.nil? || hostname.empty?
326
+ # Open a TCP connection to a WHOIS server on port 43, send the query,
327
+ # and return the full plain-text response.
328
+ def whois_query(server, query)
329
+ @logger.debug("WHOIS query: #{server} <- #{query}")
330
+ response = String.new(encoding: 'binary')
274
331
 
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?
332
+ Socket.tcp(server, WHOIS_PORT, connect_timeout: WHOIS_TIMEOUT) do |sock|
333
+ sock.write("#{query}\r\n")
334
+ response << sock.read
335
+ end
286
336
 
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?
337
+ response.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
338
+ end
290
339
 
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] }
340
+ # Extract the 'whois:' referral server from an IANA response.
341
+ def extract_whois_server(iana_response)
342
+ iana_response.each_line do |line|
343
+ return $1.strip if line.match?(/^whois:/i) && line =~ /^whois:\s+(\S+)/i
297
344
  end
345
+ nil
346
+ end
298
347
 
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
348
+ # Parse key fields from a domain WHOIS response into a structured hash.
349
+ def parse_whois_domain(raw)
350
+ fields = {
351
+ registrar: extract_field(raw, /registrar:\s+(.+)/i),
352
+ registrar_url: extract_field(raw, /registrar url:\s+(.+)/i),
353
+ created: extract_field(raw, /creation date:\s+(.+)/i) ||
354
+ extract_field(raw, /registered:\s+(.+)/i),
355
+ updated: extract_field(raw, /updated date:\s+(.+)/i) ||
356
+ extract_field(raw, /last[\s-]updated?:\s+(.+)/i),
357
+ expires: extract_field(raw, /registry expiry date:\s+(.+)/i) ||
358
+ extract_field(raw, /expir(?:y|ation) date:\s+(.+)/i) ||
359
+ extract_field(raw, /paid[\s-]till:\s+(.+)/i),
360
+ status: extract_all_fields(raw, /domain status:\s+(.+)/i),
361
+ name_servers: extract_all_fields(raw, /name server:\s+(.+)/i),
362
+ registrant_org: extract_field(raw, /registrant\s+organization:\s+(.+)/i) ||
363
+ extract_field(raw, /registrant:\s+(.+)/i),
364
+ registrant_country: extract_field(raw, /registrant\s+country:\s+(.+)/i),
365
+ dnssec: extract_field(raw, /dnssec:\s+(.+)/i)
366
+ }.compact
367
+
368
+ fields[:name_servers] = fields[:name_servers]&.map(&:downcase)&.sort&.uniq
369
+ fields
370
+ end
329
371
 
330
- resolver.close
331
- results
372
+ # Parse key fields from an IP/network WHOIS response into a structured hash.
373
+ def parse_whois_ip(raw)
374
+ {
375
+ organization: extract_field(raw, /orgname:\s+(.+)/i) ||
376
+ extract_field(raw, /org-name:\s+(.+)/i) ||
377
+ extract_field(raw, /netname:\s+(.+)/i),
378
+ network: extract_field(raw, /(?:inetnum|netrange|cidr):\s+(.+)/i),
379
+ cidr: extract_field(raw, /cidr:\s+(.+)/i),
380
+ country: extract_field(raw, /country:\s+(.+)/i),
381
+ asn: extract_field(raw, /originas:\s+(.+)/i) ||
382
+ extract_field(raw, /aut-num:\s+(.+)/i),
383
+ abuse_email: extract_field(raw, /orgabuseemail:\s+(.+)/i) ||
384
+ extract_field(raw, /abuse-mailbox:\s+(.+)/i),
385
+ abuse_phone: extract_field(raw, /orgabusephone:\s+(.+)/i),
386
+ updated: extract_field(raw, /updated:\s+(.+)/i) ||
387
+ extract_field(raw, /last[\s-]modified:\s+(.+)/i)
388
+ }.compact
332
389
  end
333
390
 
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) }
391
+ def extract_field(text, pattern)
392
+ text.each_line do |line|
393
+ m = line.match(pattern)
394
+ return m[1].strip if m
395
+ end
396
+ nil
397
+ end
338
398
 
339
- # Check IPv6 (simplified check)
340
- return true if ip =~ /\A[\da-fA-F:]+\z/ && ip.include?(':')
399
+ def extract_all_fields(text, pattern)
400
+ results = []
401
+ text.each_line do |line|
402
+ m = line.match(pattern)
403
+ results << m[1].strip if m
404
+ end
405
+ results.empty? ? nil : results
406
+ end
341
407
 
342
- false
408
+ def ip_address?(str)
409
+ str.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/) ||
410
+ str.match?(/\A[0-9a-fA-F:]+\z/) && str.include?(':')
343
411
  end
344
412
 
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'
413
+ def http_get(url)
414
+ uri = URI(url)
415
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
416
+ open_timeout: 5, read_timeout: 5) do |http|
417
+ http.get(uri.path.empty? ? '/' : uri.path).body
352
418
  end
353
419
  end
354
420
  end