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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -16
- data/README.md +257 -262
- data/lib/shared_tools/browser_tool.rb +5 -0
- data/lib/shared_tools/calculator_tool.rb +4 -0
- data/lib/shared_tools/clipboard_tool.rb +4 -0
- data/lib/shared_tools/composite_analysis_tool.rb +4 -0
- data/lib/shared_tools/computer_tool.rb +5 -0
- data/lib/shared_tools/cron_tool.rb +4 -0
- data/lib/shared_tools/current_date_time_tool.rb +4 -0
- data/lib/shared_tools/data_science_kit.rb +4 -0
- data/lib/shared_tools/database.rb +4 -0
- data/lib/shared_tools/database_query_tool.rb +4 -0
- data/lib/shared_tools/database_tool.rb +5 -0
- data/lib/shared_tools/disk_tool.rb +5 -0
- data/lib/shared_tools/dns_tool.rb +4 -0
- data/lib/shared_tools/doc_tool.rb +5 -0
- data/lib/shared_tools/error_handling_tool.rb +4 -0
- data/lib/shared_tools/eval_tool.rb +5 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +37 -0
- data/lib/shared_tools/mcp/chart_client.rb +32 -0
- data/lib/shared_tools/mcp/github_client.rb +38 -0
- data/lib/shared_tools/mcp/hugging_face_client.rb +43 -0
- data/lib/shared_tools/mcp/memory_client.rb +33 -0
- data/lib/shared_tools/mcp/notion_client.rb +40 -0
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +33 -0
- data/lib/shared_tools/mcp/slack_client.rb +54 -0
- data/lib/shared_tools/mcp/streamable_http_patch.rb +42 -0
- data/lib/shared_tools/mcp/tavily_client.rb +41 -0
- data/lib/shared_tools/mcp.rb +45 -16
- data/lib/shared_tools/system_info_tool.rb +4 -0
- data/lib/shared_tools/tools/browser/base_tool.rb +8 -12
- data/lib/shared_tools/tools/browser/click_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/ferrum_driver.rb +119 -0
- data/lib/shared_tools/tools/browser/inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +19 -7
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/visit_tool.rb +4 -2
- data/lib/shared_tools/tools/browser.rb +31 -2
- data/lib/shared_tools/tools/browser_tool.rb +6 -0
- data/lib/shared_tools/tools/clipboard_tool.rb +69 -144
- data/lib/shared_tools/tools/composite_analysis_tool.rb +60 -4
- data/lib/shared_tools/tools/computer/mac_driver.rb +37 -4
- data/lib/shared_tools/tools/cron_tool.rb +237 -379
- data/lib/shared_tools/tools/current_date_time_tool.rb +54 -120
- data/lib/shared_tools/tools/data_science_kit.rb +63 -13
- data/lib/shared_tools/tools/dns_tool.rb +335 -269
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
- data/lib/shared_tools/tools/doc.rb +3 -0
- data/lib/shared_tools/tools/doc_tool.rb +101 -6
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/enabler.rb +42 -0
- data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
- data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
- data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
- data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
- data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
- data/lib/shared_tools/tools/notification.rb +12 -0
- data/lib/shared_tools/tools/notification_tool.rb +99 -0
- data/lib/shared_tools/tools/system_info_tool.rb +130 -343
- data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
- data/lib/shared_tools/utilities.rb +193 -0
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/weather_tool.rb +4 -0
- data/lib/shared_tools/workflow_manager_tool.rb +4 -0
- data/lib/shared_tools.rb +28 -38
- metadata +74 -9
- data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
- data/lib/shared_tools/mcp/imcp.rb +0 -28
- data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
- 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
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
- '
|
|
26
|
-
- '
|
|
27
|
-
- 'mx'
|
|
28
|
-
- 'txt'
|
|
29
|
-
- 'ns'
|
|
30
|
-
- '
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- '
|
|
34
|
-
- '
|
|
35
|
-
- '
|
|
36
|
-
|
|
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
|
|
57
|
-
- '
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- '
|
|
62
|
-
|
|
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 :
|
|
66
|
-
The hostname
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# @
|
|
92
|
-
|
|
93
|
-
|
|
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 '
|
|
101
|
-
|
|
102
|
-
when '
|
|
103
|
-
|
|
104
|
-
when '
|
|
105
|
-
|
|
106
|
-
when '
|
|
107
|
-
|
|
108
|
-
when '
|
|
109
|
-
|
|
110
|
-
when '
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
259
|
+
@logger.info("IP geolocation query: #{url}")
|
|
260
|
+
raw = http_get(url)
|
|
261
|
+
data = JSON.parse(raw)
|
|
239
262
|
|
|
240
|
-
|
|
241
|
-
|
|
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:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
ns_records = resolver.getresources(hostname, Resolv::DNS::Resource::IN::NS)
|
|
297
|
+
host = host.strip.downcase
|
|
259
298
|
|
|
260
|
-
|
|
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
|
-
|
|
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:
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|