ipscanner-io 0.1.0
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/LICENSE +21 -0
- data/README.md +154 -0
- data/lib/ipscanner/client.rb +25 -0
- data/lib/ipscanner/errors.rb +53 -0
- data/lib/ipscanner/http.rb +190 -0
- data/lib/ipscanner/resources/account.rb +18 -0
- data/lib/ipscanner/resources/agentscan.rb +38 -0
- data/lib/ipscanner/resources/asn_directory.rb +25 -0
- data/lib/ipscanner/resources/base.rb +22 -0
- data/lib/ipscanner/resources/bulk.rb +44 -0
- data/lib/ipscanner/resources/crawlers.rb +14 -0
- data/lib/ipscanner/resources/ip.rb +50 -0
- data/lib/ipscanner/resources/provenance.rb +36 -0
- data/lib/ipscanner/version.rb +5 -0
- data/lib/ipscanner.rb +17 -0
- metadata +58 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c8ea296db01a984ba8d7229954dc752f1daf4a8c53ac9316724a7ed563d89f49
|
|
4
|
+
data.tar.gz: 189505d178d0f44e8622cedc5104aeb84d9849a1c1d440051c9b75fa9c8bcb61
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a6a9d4b0640499a25f03939275d1d133fa709351a9159472da883d6860810f4445e67e3d44e3a53936cb00510931a843ebaf1d2a0afecedc4ed674723d5821a4
|
|
7
|
+
data.tar.gz: 66727a60ddf55a4499edbc3b4be1a8bcd3be7b05aafe8446d27055bbb274587e5d35925220aaa16790518568ae1ea1cca57da0bec4ab1023ba076812de6454dd
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 IPScanner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# IPScanner Ruby
|
|
2
|
+
|
|
3
|
+
Official Ruby client for the [IPScanner](https://ipscanner.io) API.
|
|
4
|
+
|
|
5
|
+
Full API reference: https://ipscanner.io/api-documentation
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install ipscanner-io
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in a Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "ipscanner-io"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Ruby 3.0 or newer. No runtime dependencies.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "ipscanner"
|
|
25
|
+
|
|
26
|
+
client = IPScanner::Client.new(api_key: "your-api-key")
|
|
27
|
+
|
|
28
|
+
result = client.ip.lookup("8.8.8.8")
|
|
29
|
+
puts result["networkClass"]
|
|
30
|
+
puts result["purity"]["grade"]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Every method returns the parsed JSON response as a Hash with the same string keys the API sends.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### IP
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
client.ip.lookup("example.com") # IP, CIDR or hostname
|
|
41
|
+
client.ip.vpn("1.1.1.1")
|
|
42
|
+
client.ip.proxy("1.1.1.1")
|
|
43
|
+
client.ip.geo("2001:4860:4860::8888")
|
|
44
|
+
client.ip.asn("1.1.1.1")
|
|
45
|
+
client.ip.whois("example.com")
|
|
46
|
+
client.ip.history(limit: 50, verdict: "vpn")
|
|
47
|
+
client.ip.demo("1.1.1.1") # no key needed
|
|
48
|
+
client.ip.myip # no key needed
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Bulk
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
report = client.bulk.check(ips: ["1.1.1.1", "8.8.8.8"])
|
|
55
|
+
report = client.bulk.check(input: File.read("ips.txt"))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`stream` reads the NDJSON response line by line. Pass a block, or call it without one to get an Enumerator.
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
client.bulk.stream(input: File.read("ips.txt")) do |event|
|
|
62
|
+
case event["type"]
|
|
63
|
+
when "result" then puts "#{event['ip']} #{event['grade']}"
|
|
64
|
+
when "error" then warn "#{event['input']}: #{event['reason']}"
|
|
65
|
+
when "done" then puts "finished: #{event['complete']}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Fields the server leaves out are filled with their zero value. The `done` event has an extra `"complete"` key that is `true` only when every address was processed.
|
|
71
|
+
|
|
72
|
+
### Agentscan
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
client.agentscan.check(ip: "203.0.113.7", user_agent: request.user_agent, headers: { "accept-language" => "en" })
|
|
76
|
+
client.agentscan.verify(ip: "66.249.66.1", bot: "googlebot")
|
|
77
|
+
client.agentscan.batch([{ line: 1, ip: "203.0.113.7", user_agent: "curl/8.0" }])
|
|
78
|
+
client.agentscan.allowlist
|
|
79
|
+
client.agentscan.self_check
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Provenance
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
client.provenance.check(ip: "203.0.113.7", claimed_jurisdiction: "eu")
|
|
86
|
+
client.provenance.verify
|
|
87
|
+
client.provenance.verify_anchored
|
|
88
|
+
client.provenance.chain(limit: 100)
|
|
89
|
+
client.provenance.jurisdictions
|
|
90
|
+
csv = client.provenance.export(from: "2026-01-01", to: "2026-01-31")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Account
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
client.account.limits
|
|
97
|
+
client.account.usage
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### ASN directory
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
client.asn_directory.top(top: 20, by: "prefixes")
|
|
104
|
+
client.asn_directory.search("cloudflare", limit: 10)
|
|
105
|
+
client.asn_directory.get("AS15169")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Crawlers
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
client.crawlers.list
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Errors
|
|
115
|
+
|
|
116
|
+
All errors inherit from `IPScanner::Error`.
|
|
117
|
+
|
|
118
|
+
| Class | When |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| `IPScanner::APIError` | Any non-2xx response. Has `status`, `code`, `message`, `details`, `body`. |
|
|
121
|
+
| `IPScanner::AuthenticationError` | 401 or 403 |
|
|
122
|
+
| `IPScanner::NotFoundError` | 404 |
|
|
123
|
+
| `IPScanner::RateLimitError` | 429. Adds `reason`, `retry_after`, `reset_at`, `limit`, `usage`, `remaining`, `needed`, `plan`, `upgrade_url`, `rate_limit`. |
|
|
124
|
+
| `IPScanner::ConnectionError` | The API could not be reached. |
|
|
125
|
+
| `IPScanner::TimeoutError` | The request timed out. |
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
begin
|
|
129
|
+
client.ip.vpn("1.1.1.1")
|
|
130
|
+
rescue IPScanner::RateLimitError => e
|
|
131
|
+
sleep e.retry_after if e.retry_after
|
|
132
|
+
rescue IPScanner::APIError => e
|
|
133
|
+
warn "#{e.status} #{e.code}: #{e.message}"
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
GET requests are retried up to `max_retries` times on network errors and 502, 503 and 504 responses. POST requests and 429 responses are never retried.
|
|
138
|
+
|
|
139
|
+
## Configuration
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
IPScanner::Client.new(
|
|
143
|
+
api_key: "your-api-key", # default: ENV["IPSCANNER_API_KEY"]
|
|
144
|
+
base_url: "https://ipscanner.io", # default: ENV["IPSCANNER_API_URL"] or https://ipscanner.io
|
|
145
|
+
timeout: 30, # seconds
|
|
146
|
+
max_retries: 2
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Keyless endpoints (`ip.demo`, `ip.myip`, `asn_directory`, `crawlers`) work without an API key.
|
|
151
|
+
|
|
152
|
+
## Licence
|
|
153
|
+
|
|
154
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IPScanner
|
|
4
|
+
# Entry point for the IPScanner API.
|
|
5
|
+
class Client
|
|
6
|
+
DEFAULT_BASE_URL = "https://ipscanner.io"
|
|
7
|
+
|
|
8
|
+
attr_reader :ip, :bulk, :agentscan, :provenance, :account, :asn_directory, :crawlers
|
|
9
|
+
|
|
10
|
+
def initialize(api_key: nil, base_url: nil, timeout: 30, max_retries: 2)
|
|
11
|
+
api_key ||= ENV.fetch("IPSCANNER_API_KEY", nil)
|
|
12
|
+
base_url ||= ENV.fetch("IPSCANNER_API_URL", nil)
|
|
13
|
+
base_url = DEFAULT_BASE_URL if base_url.nil? || base_url.empty?
|
|
14
|
+
http = HTTP.new(api_key: api_key, base_url: base_url, timeout: timeout, max_retries: max_retries)
|
|
15
|
+
|
|
16
|
+
@ip = Resources::IP.new(http)
|
|
17
|
+
@bulk = Resources::Bulk.new(http)
|
|
18
|
+
@agentscan = Resources::Agentscan.new(http)
|
|
19
|
+
@provenance = Resources::Provenance.new(http)
|
|
20
|
+
@account = Resources::Account.new(http)
|
|
21
|
+
@asn_directory = Resources::AsnDirectory.new(http)
|
|
22
|
+
@crawlers = Resources::Crawlers.new(http)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IPScanner
|
|
4
|
+
# Base class for every error raised by this library.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the request could not reach the API.
|
|
8
|
+
class ConnectionError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the request timed out.
|
|
11
|
+
class TimeoutError < ConnectionError; end
|
|
12
|
+
|
|
13
|
+
# Raised for any non-2xx response.
|
|
14
|
+
class APIError < Error
|
|
15
|
+
attr_reader :status, :code, :details, :body
|
|
16
|
+
|
|
17
|
+
def initialize(status:, code:, message:, details: nil, body: nil)
|
|
18
|
+
super(message)
|
|
19
|
+
@status = status
|
|
20
|
+
@code = code
|
|
21
|
+
@details = details
|
|
22
|
+
@body = body
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raised on 401 and 403 responses.
|
|
27
|
+
class AuthenticationError < APIError; end
|
|
28
|
+
|
|
29
|
+
# Raised on 404 responses.
|
|
30
|
+
class NotFoundError < APIError; end
|
|
31
|
+
|
|
32
|
+
# Raised on 429 responses.
|
|
33
|
+
class RateLimitError < APIError
|
|
34
|
+
attr_reader :reason, :retry_after, :reset_at, :limit, :usage, :remaining,
|
|
35
|
+
:needed, :plan, :upgrade_url, :rate_limit
|
|
36
|
+
|
|
37
|
+
def initialize(rate_limit: {}, **kwargs)
|
|
38
|
+
super(**kwargs)
|
|
39
|
+
data = body.is_a?(Hash) ? body : {}
|
|
40
|
+
upgrade = data["upgrade"].is_a?(Hash) ? data["upgrade"] : {}
|
|
41
|
+
@reason = data["reason"]
|
|
42
|
+
@retry_after = data["retryAfter"] || rate_limit[:retry_after]
|
|
43
|
+
@reset_at = data["resetAt"]
|
|
44
|
+
@limit = data["limit"]
|
|
45
|
+
@usage = data["usage"]
|
|
46
|
+
@remaining = data["remaining"]
|
|
47
|
+
@needed = data["needed"]
|
|
48
|
+
@plan = data["plan"]
|
|
49
|
+
@upgrade_url = upgrade["url"]
|
|
50
|
+
@rate_limit = rate_limit
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module IPScanner
|
|
9
|
+
# Low-level transport shared by every resource.
|
|
10
|
+
class HTTP
|
|
11
|
+
RETRY_STATUSES = [502, 503, 504].freeze
|
|
12
|
+
STREAM_TIMEOUT = 300
|
|
13
|
+
NETWORK_ERRORS = [
|
|
14
|
+
SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError
|
|
15
|
+
].freeze
|
|
16
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
|
|
17
|
+
|
|
18
|
+
RATE_LIMIT_HEADERS = {
|
|
19
|
+
meter: ["X-RateLimit-Meter", :string],
|
|
20
|
+
limit: ["X-RateLimit-Limit", :int],
|
|
21
|
+
remaining: ["X-RateLimit-Remaining", :int],
|
|
22
|
+
reset: ["X-RateLimit-Reset", :int],
|
|
23
|
+
daily_limit: ["X-RateLimit-Daily-Limit", :int],
|
|
24
|
+
daily_remaining: ["X-RateLimit-Daily-Remaining", :int],
|
|
25
|
+
daily_reset: ["X-RateLimit-Daily-Reset", :int],
|
|
26
|
+
hourly_limit: ["X-RateLimit-Hourly-Limit", :int],
|
|
27
|
+
hourly_remaining: ["X-RateLimit-Hourly-Remaining", :int],
|
|
28
|
+
hourly_reset: ["X-RateLimit-Hourly-Reset", :int],
|
|
29
|
+
usage_percent: ["X-RateLimit-Usage-Percent", :int],
|
|
30
|
+
upgrade_hint: ["X-Quota-Upgrade-Hint", :string],
|
|
31
|
+
upgrade_url: ["X-Quota-Upgrade-Url", :string],
|
|
32
|
+
upgrade_plan: ["X-Quota-Upgrade-Plan", :string],
|
|
33
|
+
retry_after: ["Retry-After", :int]
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def self.escape(segment)
|
|
37
|
+
segment.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |c| format("%%%02X", c.ord) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(api_key:, base_url:, timeout:, max_retries:)
|
|
41
|
+
@api_key = api_key
|
|
42
|
+
@base_url = base_url.chomp("/")
|
|
43
|
+
@timeout = timeout
|
|
44
|
+
@max_retries = max_retries
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def get(path, query = nil, raw: false)
|
|
48
|
+
request(Net::HTTP::Get, path, query: query, raw: raw)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def post(path, body = {})
|
|
52
|
+
request(Net::HTTP::Post, path, body: body)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stream(path, body)
|
|
56
|
+
uri = build_uri(path)
|
|
57
|
+
req = build_request(Net::HTTP::Post, uri, body)
|
|
58
|
+
req["Accept"] = "application/x-ndjson"
|
|
59
|
+
with_errors do
|
|
60
|
+
connection(uri, STREAM_TIMEOUT).start do |http|
|
|
61
|
+
http.request(req) do |res|
|
|
62
|
+
raise error_for(res, res.body) unless res.is_a?(Net::HTTPSuccess)
|
|
63
|
+
|
|
64
|
+
buffer = +""
|
|
65
|
+
res.read_body do |chunk|
|
|
66
|
+
buffer << chunk
|
|
67
|
+
while (newline = buffer.index("\n"))
|
|
68
|
+
line = buffer.slice!(0, newline + 1).strip
|
|
69
|
+
yield JSON.parse(line) unless line.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
rest = buffer.strip
|
|
73
|
+
yield JSON.parse(rest) unless rest.empty?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def request(klass, path, query: nil, body: nil, raw: false)
|
|
82
|
+
uri = build_uri(path, query)
|
|
83
|
+
attempts = 0
|
|
84
|
+
loop do
|
|
85
|
+
attempts += 1
|
|
86
|
+
begin
|
|
87
|
+
res = with_errors do
|
|
88
|
+
connection(uri, @timeout).start { |http| http.request(build_request(klass, uri, body)) }
|
|
89
|
+
end
|
|
90
|
+
rescue ConnectionError
|
|
91
|
+
raise unless retryable?(klass, attempts)
|
|
92
|
+
|
|
93
|
+
backoff(attempts)
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
if RETRY_STATUSES.include?(res.code.to_i) && retryable?(klass, attempts)
|
|
97
|
+
backoff(attempts)
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
raise error_for(res, res.body) unless res.is_a?(Net::HTTPSuccess)
|
|
101
|
+
|
|
102
|
+
return raw ? res.body.to_s : parse(res.body)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def retryable?(klass, attempts)
|
|
107
|
+
klass == Net::HTTP::Get && attempts <= @max_retries
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def backoff(attempt)
|
|
111
|
+
sleep(0.5 * (2**(attempt - 1)))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def with_errors
|
|
115
|
+
yield
|
|
116
|
+
rescue *TIMEOUT_ERRORS => e
|
|
117
|
+
raise TimeoutError, e.message
|
|
118
|
+
rescue *NETWORK_ERRORS => e
|
|
119
|
+
raise ConnectionError, e.message
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_uri(path, query = nil)
|
|
123
|
+
uri = URI.parse(@base_url + path)
|
|
124
|
+
params = (query || {}).compact
|
|
125
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
126
|
+
uri
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def connection(uri, timeout)
|
|
130
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
131
|
+
http.use_ssl = uri.scheme == "https"
|
|
132
|
+
http.open_timeout = timeout
|
|
133
|
+
http.read_timeout = timeout
|
|
134
|
+
http.write_timeout = timeout
|
|
135
|
+
http
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_request(klass, uri, body)
|
|
139
|
+
req = klass.new(uri)
|
|
140
|
+
req["Accept"] = "application/json"
|
|
141
|
+
req["User-Agent"] = "ipscanner-ruby/#{VERSION}"
|
|
142
|
+
req["Authorization"] = "Bearer #{@api_key}" if @api_key && !@api_key.empty?
|
|
143
|
+
unless body.nil?
|
|
144
|
+
req["Content-Type"] = "application/json"
|
|
145
|
+
req.body = JSON.generate(body)
|
|
146
|
+
end
|
|
147
|
+
req
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def parse(text)
|
|
151
|
+
return nil if text.nil? || text.empty?
|
|
152
|
+
|
|
153
|
+
JSON.parse(text)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def error_for(res, text)
|
|
157
|
+
status = res.code.to_i
|
|
158
|
+
data = begin
|
|
159
|
+
JSON.parse(text.to_s)
|
|
160
|
+
rescue JSON::ParserError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
data = nil unless data.is_a?(Hash) && data["error"].is_a?(String)
|
|
164
|
+
code = data ? data["error"] : "http_#{status}"
|
|
165
|
+
message = data && data["message"] ? data["message"] : status_text(res, status)
|
|
166
|
+
args = { status: status, code: code, message: message, details: data && data["details"], body: data || text }
|
|
167
|
+
|
|
168
|
+
case status
|
|
169
|
+
when 401, 403 then AuthenticationError.new(**args)
|
|
170
|
+
when 404 then NotFoundError.new(**args)
|
|
171
|
+
when 429 then RateLimitError.new(rate_limit: rate_limit(res), **args)
|
|
172
|
+
else APIError.new(**args)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def status_text(res, status)
|
|
177
|
+
text = res.message.to_s.strip
|
|
178
|
+
text.empty? ? "HTTP #{status}" : text
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def rate_limit(res)
|
|
182
|
+
RATE_LIMIT_HEADERS.each_with_object({}) do |(key, (header, type)), out|
|
|
183
|
+
value = res[header]
|
|
184
|
+
next if value.nil? || value.empty?
|
|
185
|
+
|
|
186
|
+
out[key] = type == :int ? Integer(value, exception: false) || value : value
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module IPScanner
|
|
6
|
+
module Resources
|
|
7
|
+
# Quota and usage for the current key.
|
|
8
|
+
class Account < Base
|
|
9
|
+
def limits
|
|
10
|
+
@http.get("/v1/user/limits")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def usage
|
|
14
|
+
@http.get("/v1/usage/summary")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module IPScanner
|
|
6
|
+
module Resources
|
|
7
|
+
# Agentscan: classify the client behind a request.
|
|
8
|
+
class Agentscan < Base
|
|
9
|
+
def check(ip:, user_agent: nil, ja4: nil, headers: nil, headless_flags: nil, request_id: nil)
|
|
10
|
+
body = {
|
|
11
|
+
ip: ip, user_agent: user_agent, ja4: ja4, headers: headers,
|
|
12
|
+
headless_flags: headless_flags, request_id: request_id
|
|
13
|
+
}
|
|
14
|
+
@http.post("/v1/agentscan/check", compact(body))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Classifies parsed log lines, each a Hash with line, ip and optional user_agent, ja4, headers.
|
|
18
|
+
def batch(lines)
|
|
19
|
+
@http.post("/v1/agentscan/batch", { lines: lines })
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Checks whether a request claiming to be a known crawler is genuine.
|
|
23
|
+
def verify(ip:, bot: nil, user_agent: nil)
|
|
24
|
+
@http.post("/v1/agentscan/verify", compact({ ip: ip, bot: bot, user_agent: user_agent }))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def allowlist
|
|
28
|
+
@http.get("/v1/agentscan/allowlist")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Classifies the caller. Not metered.
|
|
32
|
+
def self_check(ip: nil, user_agent: nil, headers: nil, ja4: nil)
|
|
33
|
+
body = { ip: ip, user_agent: user_agent, headers: headers, ja4: ja4 }
|
|
34
|
+
@http.post("/v1/agentscan/self", compact(body))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module IPScanner
|
|
6
|
+
module Resources
|
|
7
|
+
# Keyless directory of autonomous systems.
|
|
8
|
+
class AsnDirectory < Base
|
|
9
|
+
# Largest networks, ranked by addresses or prefixes.
|
|
10
|
+
def top(top: nil, by: nil)
|
|
11
|
+
@http.get("/v1/asn/directory", { top: top, by: by })
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def search(query, limit: nil)
|
|
15
|
+
@http.get("/v1/asn/directory", { q: query, limit: limit })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Accepts 15169 or "AS15169".
|
|
19
|
+
def get(asn)
|
|
20
|
+
number = asn.to_s.strip.sub(/\AAS/i, "")
|
|
21
|
+
@http.get("/v1/asn/directory/#{escape(number)}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IPScanner
|
|
4
|
+
module Resources
|
|
5
|
+
# Shared plumbing for resource classes.
|
|
6
|
+
class Base
|
|
7
|
+
def initialize(http)
|
|
8
|
+
@http = http
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def escape(segment)
|
|
14
|
+
HTTP.escape(segment)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def compact(hash)
|
|
18
|
+
hash.compact
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module IPScanner
|
|
6
|
+
module Resources
|
|
7
|
+
# Many addresses per request.
|
|
8
|
+
class Bulk < Base
|
|
9
|
+
EVENT_DEFAULTS = {
|
|
10
|
+
"type" => "", "index" => 0, "total" => 0, "input" => "", "reason" => "",
|
|
11
|
+
"message" => "", "error" => "", "ip" => "", "verdict" => "", "classification" => "",
|
|
12
|
+
"confidence" => 0.0, "anonymized" => false, "score" => 0, "grade" => "",
|
|
13
|
+
"isTorExit" => false, "asn" => "", "asnName" => "", "asnType" => "", "country" => "",
|
|
14
|
+
"processed" => 0, "failed" => 0, "metered" => 0
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Scores a list of addresses or a pasted blob in one response.
|
|
18
|
+
def check(ips: nil, input: nil)
|
|
19
|
+
@http.post("/v1/bulk/check", body(ips, input))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Yields each NDJSON event as it arrives, or returns an Enumerator without a block.
|
|
23
|
+
def stream(ips: nil, input: nil)
|
|
24
|
+
return enum_for(:stream, ips: ips, input: input) unless block_given?
|
|
25
|
+
|
|
26
|
+
@http.stream("/v1/ip/bulk", body(ips, input)) do |line|
|
|
27
|
+
event = EVENT_DEFAULTS.merge(line)
|
|
28
|
+
if event["type"] == "done"
|
|
29
|
+
event["complete"] = event["reason"] == "complete" &&
|
|
30
|
+
event["processed"] + event["failed"] >= event["total"]
|
|
31
|
+
end
|
|
32
|
+
yield event
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def body(ips, input)
|
|
40
|
+
compact({ ips: ips, input: input })
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module IPScanner
|
|
6
|
+
module Resources
|
|
7
|
+
# Single-address lookups.
|
|
8
|
+
class IP < Base
|
|
9
|
+
# Full lookup for an IP, CIDR or hostname.
|
|
10
|
+
def lookup(target)
|
|
11
|
+
@http.post("/v1/ip/lookup", { target: target })
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def vpn(ip)
|
|
15
|
+
@http.get("/v1/vpn/#{escape(ip)}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def proxy(ip)
|
|
19
|
+
@http.get("/v1/proxy/#{escape(ip)}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def geo(ip)
|
|
23
|
+
@http.get("/v1/geo/#{escape(ip)}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def asn(ip)
|
|
27
|
+
@http.get("/v1/asn/#{escape(ip)}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def whois(domain)
|
|
31
|
+
@http.get("/v1/whois/#{escape(domain)}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Recent lookups made by this account.
|
|
35
|
+
def history(limit: nil, before: nil, verdict: nil)
|
|
36
|
+
@http.get("/v1/ip/history", { limit: limit, before: before, verdict: verdict })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Keyless lookup with the same response shape as lookup.
|
|
40
|
+
def demo(ip)
|
|
41
|
+
@http.get("/v1/demo/#{escape(ip)}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Describes the address the request came from.
|
|
45
|
+
def myip
|
|
46
|
+
@http.get("/v1/myip")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module IPScanner
|
|
6
|
+
module Resources
|
|
7
|
+
# Network provenance attestations.
|
|
8
|
+
class Provenance < Base
|
|
9
|
+
def check(ip:, claimed_jurisdiction: nil, request_context: nil)
|
|
10
|
+
body = { ip: ip, claimed_jurisdiction: claimed_jurisdiction, request_context: request_context }
|
|
11
|
+
@http.post("/v1/provenance/check", compact(body))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def verify
|
|
15
|
+
@http.get("/v1/provenance/verify")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def verify_anchored
|
|
19
|
+
@http.post("/v1/provenance/verify", {})
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def chain(limit: nil, before: nil)
|
|
23
|
+
@http.get("/v1/provenance/chain", { limit: limit, before: before })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def jurisdictions
|
|
27
|
+
@http.get("/v1/provenance/jurisdictions")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the attestation log as a CSV string.
|
|
31
|
+
def export(from: nil, to: nil)
|
|
32
|
+
@http.get("/v1/provenance/export", { from: from, to: to }, raw: true)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ipscanner.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ipscanner/version"
|
|
4
|
+
require_relative "ipscanner/errors"
|
|
5
|
+
require_relative "ipscanner/http"
|
|
6
|
+
require_relative "ipscanner/resources/ip"
|
|
7
|
+
require_relative "ipscanner/resources/bulk"
|
|
8
|
+
require_relative "ipscanner/resources/agentscan"
|
|
9
|
+
require_relative "ipscanner/resources/provenance"
|
|
10
|
+
require_relative "ipscanner/resources/account"
|
|
11
|
+
require_relative "ipscanner/resources/asn_directory"
|
|
12
|
+
require_relative "ipscanner/resources/crawlers"
|
|
13
|
+
require_relative "ipscanner/client"
|
|
14
|
+
|
|
15
|
+
# Ruby client for the IPScanner API.
|
|
16
|
+
module IPScanner
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ipscanner-io
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- IPScanner
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Official Ruby client for the IPScanner API: IP lookups, bulk checks,
|
|
13
|
+
Agentscan and provenance.'
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- LICENSE
|
|
19
|
+
- README.md
|
|
20
|
+
- lib/ipscanner.rb
|
|
21
|
+
- lib/ipscanner/client.rb
|
|
22
|
+
- lib/ipscanner/errors.rb
|
|
23
|
+
- lib/ipscanner/http.rb
|
|
24
|
+
- lib/ipscanner/resources/account.rb
|
|
25
|
+
- lib/ipscanner/resources/agentscan.rb
|
|
26
|
+
- lib/ipscanner/resources/asn_directory.rb
|
|
27
|
+
- lib/ipscanner/resources/base.rb
|
|
28
|
+
- lib/ipscanner/resources/bulk.rb
|
|
29
|
+
- lib/ipscanner/resources/crawlers.rb
|
|
30
|
+
- lib/ipscanner/resources/ip.rb
|
|
31
|
+
- lib/ipscanner/resources/provenance.rb
|
|
32
|
+
- lib/ipscanner/version.rb
|
|
33
|
+
homepage: https://ipscanner.io
|
|
34
|
+
licenses:
|
|
35
|
+
- MIT
|
|
36
|
+
metadata:
|
|
37
|
+
homepage_uri: https://ipscanner.io
|
|
38
|
+
source_code_uri: https://github.com/ipscanner/ipscanner-ruby
|
|
39
|
+
documentation_uri: https://ipscanner.io/api-documentation
|
|
40
|
+
rubygems_mfa_required: 'true'
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '3.0'
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 4.0.20
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: Ruby client for the IPScanner API.
|
|
58
|
+
test_files: []
|