openasn 0.2.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/.simplecov +25 -0
- data/AGENTS.md +23 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +242 -0
- data/Rakefile +34 -0
- data/lib/generators/openasn/install/install_generator.rb +68 -0
- data/lib/generators/openasn/install/templates/openasn.rb +68 -0
- data/lib/openasn/binary_format.rb +305 -0
- data/lib/openasn/cidr_utils.rb +49 -0
- data/lib/openasn/classifier.rb +152 -0
- data/lib/openasn/configuration.rb +163 -0
- data/lib/openasn/data/seed/fetch-manifest.json +589 -0
- data/lib/openasn/data/seed/manifest.json +99 -0
- data/lib/openasn/data/seed/openasn-ipv4.bin +0 -0
- data/lib/openasn/data/seed/openasn-ipv6.bin +0 -0
- data/lib/openasn/dataset.rb +117 -0
- data/lib/openasn/errors.rb +22 -0
- data/lib/openasn/http_client.rb +86 -0
- data/lib/openasn/ip.rb +70 -0
- data/lib/openasn/middleware.rb +49 -0
- data/lib/openasn/overlay_store.rb +124 -0
- data/lib/openasn/parsers.rb +515 -0
- data/lib/openasn/railtie.rb +30 -0
- data/lib/openasn/result.rb +93 -0
- data/lib/openasn/snapshot.rb +171 -0
- data/lib/openasn/special_ranges.rb +44 -0
- data/lib/openasn/tier_b.rb +223 -0
- data/lib/openasn/update_job.rb +32 -0
- data/lib/openasn/updater.rb +173 -0
- data/lib/openasn/version.rb +5 -0
- data/lib/openasn.rb +115 -0
- metadata +85 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format_version": 1,
|
|
3
|
+
"edition": "core",
|
|
4
|
+
"build_id": "2026-07-04T20:36:12Z",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"name": "openasn-ipv4.bin",
|
|
8
|
+
"sha256": "724015a457a90572670290641060330b5a11327867b22486566b54abd630fcb4",
|
|
9
|
+
"bytes": 6351346,
|
|
10
|
+
"records": 433579
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "openasn-ipv6.bin",
|
|
14
|
+
"sha256": "753f2c2ff237ac18a32a24c831622754dfef252c2a301a449d8ad24a8bbd353c",
|
|
15
|
+
"bytes": 4772984,
|
|
16
|
+
"records": 125604
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "openasn-orgs.bin",
|
|
20
|
+
"sha256": "61dc40bd72e7d12a68606e091f818474bfce89054c10da55dd70ae0d5284cf77",
|
|
21
|
+
"bytes": 4060956,
|
|
22
|
+
"records": 123392
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "asn-categories.csv",
|
|
26
|
+
"sha256": "d8ed709f56291bd55af399dbb159aff03776e6930f4bc786d3f0cc211c0ee16b",
|
|
27
|
+
"bytes": 6362920,
|
|
28
|
+
"records": 123392
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "fetch-manifest.json",
|
|
32
|
+
"sha256": "aceadec08e2c13992f5667724079da73ef20b7b532e6c0618f9c21fdc31357c6",
|
|
33
|
+
"bytes": 20760,
|
|
34
|
+
"records": 0
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "ATTRIBUTION.md",
|
|
38
|
+
"sha256": "ba4cd2c03117800e068762d2ebe29bdfdea182e82bb2f6dd2bc310074760afdc",
|
|
39
|
+
"bytes": 2399,
|
|
40
|
+
"records": 0
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"sources": [
|
|
44
|
+
{
|
|
45
|
+
"id": "sapics-origin-asn",
|
|
46
|
+
"url": "https://github.com/sapics/ip-location-db",
|
|
47
|
+
"license": "PDDL-1.0",
|
|
48
|
+
"license_sha256": "e8756fd515b673f3e868bf8d9aa5cbbb28d4611366c654e36977ea5279e6551a",
|
|
49
|
+
"fetched_at": "2026-07-04T20:26:23Z"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "ipverse-as-metadata",
|
|
53
|
+
"url": "https://github.com/ipverse/as-metadata",
|
|
54
|
+
"license": "CC0-1.0",
|
|
55
|
+
"license_sha256": "a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499",
|
|
56
|
+
"fetched_at": "2026-07-04T20:02:11Z"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "ipverse-as-ip-blocks",
|
|
60
|
+
"url": "https://github.com/ipverse/as-ip-blocks",
|
|
61
|
+
"license": "CC0-1.0",
|
|
62
|
+
"license_sha256": "a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499",
|
|
63
|
+
"fetched_at": "2026-07-04T20:36:14Z"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "x4bnet-lists_vpn",
|
|
67
|
+
"url": "https://github.com/X4BNet/lists_vpn",
|
|
68
|
+
"license": "MIT",
|
|
69
|
+
"license_sha256": "8826b5fbb9d7380264deb636df4adf349eab0e640ca3f2f5481d74cea96ba7a5",
|
|
70
|
+
"fetched_at": "2026-07-04T20:02:11Z"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "brianhama-bad-asn-list",
|
|
74
|
+
"url": "https://github.com/brianhama/bad-asn-list",
|
|
75
|
+
"license": "MIT",
|
|
76
|
+
"license_sha256": "67690d0688b2b17b2fb74446e0bf58132a389b2e543d9ba0c4c44a60fedd4e67",
|
|
77
|
+
"fetched_at": "2026-07-04T20:02:12Z"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "openasn-overrides",
|
|
81
|
+
"url": "https://github.com/openasn/openasn",
|
|
82
|
+
"license": "CC0-1.0",
|
|
83
|
+
"license_sha256": null,
|
|
84
|
+
"fetched_at": "2026-07-04T20:36:14Z"
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"stats": {
|
|
88
|
+
"layer_counts": {
|
|
89
|
+
"base_ipv4": 433579,
|
|
90
|
+
"vpn_ipv4": 6405,
|
|
91
|
+
"dc_ipv4": 28746,
|
|
92
|
+
"base_ipv6": 125604
|
|
93
|
+
},
|
|
94
|
+
"hosting_asns": 12257,
|
|
95
|
+
"reference_dc_asns": 899,
|
|
96
|
+
"reference_coverage": 0.9132
|
|
97
|
+
},
|
|
98
|
+
"signature": null
|
|
99
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenASN
|
|
4
|
+
# Owns the live Snapshot and its lifecycle. Concurrency model (the whole
|
|
5
|
+
# point of this class — do not "simplify" it):
|
|
6
|
+
#
|
|
7
|
+
# * Readers call #snapshot and get whatever is current. The swap is a
|
|
8
|
+
# single ivar assignment — atomic under MRI's GVL — so readers see
|
|
9
|
+
# either the old complete snapshot or the new complete snapshot,
|
|
10
|
+
# never partial state. No locks on the read path, ever.
|
|
11
|
+
# * The lazy first load and explicit reloads are serialized with a
|
|
12
|
+
# Mutex so N threads arriving on a cold process build one snapshot,
|
|
13
|
+
# not N.
|
|
14
|
+
# * Multi-process reality (puma workers): the updater process writes
|
|
15
|
+
# new files atomically; OTHER processes notice via a cheap periodic
|
|
16
|
+
# freshness probe (#maybe_reload_from_disk — a File.mtime call at
|
|
17
|
+
# most every RELOAD_CHECK_INTERVAL seconds, amortized to ~zero) and
|
|
18
|
+
# rebuild their in-memory snapshot from disk.
|
|
19
|
+
class Dataset
|
|
20
|
+
RELOAD_CHECK_INTERVAL = 300 # seconds
|
|
21
|
+
STALE_AFTER = 7 * 86_400 # boot-time "kick a refresh" threshold (documented in README "Updates")
|
|
22
|
+
|
|
23
|
+
def initialize(config)
|
|
24
|
+
@config = config
|
|
25
|
+
@snapshot = nil
|
|
26
|
+
@load_mutex = Mutex.new
|
|
27
|
+
@last_disk_check = 0.0
|
|
28
|
+
@loaded_manifest_mtime = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def snapshot
|
|
32
|
+
snap = @snapshot
|
|
33
|
+
return snap if snap && !disk_check_due?
|
|
34
|
+
|
|
35
|
+
snap ? maybe_reload_from_disk : load!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def eager_load!
|
|
39
|
+
load!
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Called by the updater after it wrote new files: rebuild + swap now.
|
|
44
|
+
def reload!
|
|
45
|
+
@load_mutex.synchronize do
|
|
46
|
+
@snapshot = Snapshot.build(@config)
|
|
47
|
+
@loaded_manifest_mtime = manifest_mtime
|
|
48
|
+
@last_disk_check = monotonic_now
|
|
49
|
+
@snapshot
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def loaded? = !@snapshot.nil?
|
|
54
|
+
|
|
55
|
+
def stale?
|
|
56
|
+
snap = snapshot
|
|
57
|
+
snap.age_seconds > STALE_AFTER
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def info
|
|
61
|
+
snap = snapshot
|
|
62
|
+
{
|
|
63
|
+
build_id: snap.build_id,
|
|
64
|
+
built_at: Time.at(snap.build_ts).utc,
|
|
65
|
+
loaded_at: snap.loaded_at,
|
|
66
|
+
origin: snap.origin,
|
|
67
|
+
memory_mode: @config.memory_mode,
|
|
68
|
+
records: snap.record_counts,
|
|
69
|
+
tier_b_status: snap.tier_b_status
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def load!
|
|
76
|
+
@load_mutex.synchronize do
|
|
77
|
+
# Another thread may have loaded while we waited on the mutex.
|
|
78
|
+
return @snapshot if @snapshot && !disk_check_due?
|
|
79
|
+
|
|
80
|
+
@snapshot = Snapshot.build(@config)
|
|
81
|
+
@loaded_manifest_mtime = manifest_mtime
|
|
82
|
+
@last_disk_check = monotonic_now
|
|
83
|
+
@snapshot
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def disk_check_due?
|
|
88
|
+
monotonic_now - @last_disk_check > RELOAD_CHECK_INTERVAL
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Cheap cross-process freshness: compare the on-disk manifest mtime to
|
|
92
|
+
# what this process loaded. Runs at most once per RELOAD_CHECK_INTERVAL
|
|
93
|
+
# per process; costs one stat() when it does.
|
|
94
|
+
def maybe_reload_from_disk
|
|
95
|
+
@load_mutex.synchronize do
|
|
96
|
+
@last_disk_check = monotonic_now
|
|
97
|
+
current = manifest_mtime
|
|
98
|
+
if current != @loaded_manifest_mtime
|
|
99
|
+
@config.logger.info("openasn: dataset changed on disk — reloading")
|
|
100
|
+
@snapshot = Snapshot.build(@config)
|
|
101
|
+
@loaded_manifest_mtime = current
|
|
102
|
+
end
|
|
103
|
+
@snapshot
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
@config.logger.warn("openasn: reload check failed (#{e.message}); keeping current snapshot")
|
|
107
|
+
@snapshot
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def manifest_mtime
|
|
111
|
+
path = File.join(@config.data_dir, "manifest.json")
|
|
112
|
+
File.exist?(path) ? File.mtime(path) : nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenASN
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised for unparseable IP input. Inherits ArgumentError so bare
|
|
7
|
+
# `rescue ArgumentError` in caller code behaves as documented
|
|
8
|
+
# ("raises on invalid IP").
|
|
9
|
+
class InvalidIPError < ArgumentError; end
|
|
10
|
+
|
|
11
|
+
# Artifact bytes don't match the OASN/OORG format (truncated download,
|
|
12
|
+
# foreign file, or a format_version this gem doesn't speak).
|
|
13
|
+
class FormatError < Error; end
|
|
14
|
+
|
|
15
|
+
# Canonical refresh failed in a way worth surfacing to the caller of
|
|
16
|
+
# OpenASN.update! (Tier B sources never raise — they keep stale data).
|
|
17
|
+
class UpdateError < Error; end
|
|
18
|
+
|
|
19
|
+
# A downloaded artifact's SHA-256 didn't match the manifest. The file is
|
|
20
|
+
# discarded and the previous data stays live.
|
|
21
|
+
class IntegrityError < UpdateError; end
|
|
22
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module OpenASN
|
|
7
|
+
# Minimal stdlib HTTP client for the updater and Tier B executor.
|
|
8
|
+
#
|
|
9
|
+
# * Always sends a descriptive User-Agent (some endpoints 403 UA-less
|
|
10
|
+
# clients; it's also basic politeness toward the volunteer-run sources
|
|
11
|
+
# this gem depends on).
|
|
12
|
+
# * Follows redirects across hosts — GitHub release downloads ALWAYS
|
|
13
|
+
# redirect to objects.githubusercontent.com / release-assets.…; if your
|
|
14
|
+
# egress is allowlisted, those hosts must be on the list too.
|
|
15
|
+
# * Supports conditional GET via ETag (returns :not_modified).
|
|
16
|
+
# * Never talks to api.github.com (60 req/hr unauthenticated limit);
|
|
17
|
+
# releases/download/<tag>/ asset URLs redirect fine without auth.
|
|
18
|
+
class HttpClient
|
|
19
|
+
MAX_REDIRECTS = 5
|
|
20
|
+
OPEN_TIMEOUT = 10
|
|
21
|
+
READ_TIMEOUT = 120 # artifacts are ~6MB; Apple's relay CSV ~10MB
|
|
22
|
+
|
|
23
|
+
Response = Struct.new(:body, :etag, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
def initialize(user_agent:, logger:)
|
|
26
|
+
@user_agent = user_agent
|
|
27
|
+
@logger = logger
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# -> Response | :not_modified. Raises on HTTP errors / timeouts.
|
|
31
|
+
def get(url, etag: nil)
|
|
32
|
+
headers = { "User-Agent" => @user_agent, "Accept-Encoding" => "identity" }
|
|
33
|
+
headers["If-None-Match"] = etag if etag
|
|
34
|
+
|
|
35
|
+
response = request(Net::HTTP::Get, url, headers, MAX_REDIRECTS)
|
|
36
|
+
case response
|
|
37
|
+
when Net::HTTPNotModified then :not_modified
|
|
38
|
+
when Net::HTTPSuccess then Response.new(body: response.body, etag: response["etag"])
|
|
39
|
+
else raise UpdateError, "HTTP #{response.code} for #{url}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def post_form(url, form)
|
|
44
|
+
headers = { "User-Agent" => @user_agent,
|
|
45
|
+
"Accept-Encoding" => "identity",
|
|
46
|
+
"Content-Type" => "application/x-www-form-urlencoded" }
|
|
47
|
+
response = request(Net::HTTP::Post, url, headers, MAX_REDIRECTS, URI.encode_www_form(form))
|
|
48
|
+
case response
|
|
49
|
+
when Net::HTTPSuccess then Response.new(body: response.body, etag: response["etag"])
|
|
50
|
+
else raise UpdateError, "HTTP #{response.code} for #{url}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def request(method, url, headers, redirects_left, body = nil)
|
|
57
|
+
raise UpdateError, "too many redirects for #{url}" if redirects_left.zero?
|
|
58
|
+
|
|
59
|
+
uri = URI(url)
|
|
60
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
61
|
+
http.use_ssl = uri.scheme == "https"
|
|
62
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
63
|
+
http.read_timeout = READ_TIMEOUT
|
|
64
|
+
|
|
65
|
+
request = method.new(uri, headers)
|
|
66
|
+
request.body = body if body
|
|
67
|
+
response = http.request(request)
|
|
68
|
+
# Ruby models 304 Not Modified as a 3xx response, but it is not a
|
|
69
|
+
# redirect and correctly has no Location header. Return it to #get so
|
|
70
|
+
# conditional GETs are clean :not_modified events instead of noisy
|
|
71
|
+
# keep-stale failures.
|
|
72
|
+
if response.is_a?(Net::HTTPRedirection) && !response.is_a?(Net::HTTPNotModified)
|
|
73
|
+
location = response["location"]
|
|
74
|
+
raise UpdateError, "redirect without Location from #{url}" if location.nil?
|
|
75
|
+
|
|
76
|
+
location = URI.join(url, location).to_s unless location.start_with?("http")
|
|
77
|
+
# Conditional headers stay on the ORIGINAL url's cache identity;
|
|
78
|
+
# redirect targets are one-off signed URLs. Keep method headers such
|
|
79
|
+
# as Content-Type for POST-form provider endpoints.
|
|
80
|
+
redirect_headers = headers.reject { |key, _| key.casecmp("If-None-Match").zero? }
|
|
81
|
+
return request(method, location, redirect_headers, redirects_left - 1, body)
|
|
82
|
+
end
|
|
83
|
+
response
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/openasn/ip.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module OpenASN
|
|
6
|
+
# IP input parsing. Hot path: dotted-quad IPv4 strings (the overwhelming
|
|
7
|
+
# majority of real lookups) are parsed by hand — ~6x faster than
|
|
8
|
+
# IPAddr.new, which matters when the whole lookup budget is ~20µs.
|
|
9
|
+
# Everything else (IPv6, IPAddr instances, mapped addresses) goes through
|
|
10
|
+
# IPAddr for correctness.
|
|
11
|
+
module IP
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# -> [family(:ipv4 | :ipv6), Integer]
|
|
15
|
+
# Raises OpenASN::InvalidIPError (an ArgumentError) on anything else.
|
|
16
|
+
def parse(input)
|
|
17
|
+
case input
|
|
18
|
+
when IPAddr
|
|
19
|
+
from_ipaddr(input)
|
|
20
|
+
when String
|
|
21
|
+
fast_v4(input) || from_string(input)
|
|
22
|
+
else
|
|
23
|
+
raise InvalidIPError, "expected an IP address String or IPAddr, got #{input.class}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fast_v4(str)
|
|
28
|
+
parts = str.split(".")
|
|
29
|
+
return nil unless parts.length == 4
|
|
30
|
+
|
|
31
|
+
int = 0
|
|
32
|
+
parts.each do |p|
|
|
33
|
+
# Reject empty octets, leading zeros ("01" is ambiguous octal in
|
|
34
|
+
# many parsers — safer to fall through to IPAddr, which rejects it),
|
|
35
|
+
# non-digits, and >255.
|
|
36
|
+
return nil if p.empty? || p.length > 3 || (p.length > 1 && p.start_with?("0"))
|
|
37
|
+
|
|
38
|
+
n = 0
|
|
39
|
+
p.each_char do |c|
|
|
40
|
+
d = c.ord - 48
|
|
41
|
+
return nil if d.negative? || d > 9
|
|
42
|
+
|
|
43
|
+
n = n * 10 + d
|
|
44
|
+
end
|
|
45
|
+
return nil if n > 255
|
|
46
|
+
|
|
47
|
+
int = (int << 8) | n
|
|
48
|
+
end
|
|
49
|
+
[:ipv4, int]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def from_string(str)
|
|
53
|
+
from_ipaddr(IPAddr.new(str))
|
|
54
|
+
rescue IPAddr::Error
|
|
55
|
+
raise InvalidIPError, "invalid IP address: #{str.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def from_ipaddr(ip)
|
|
59
|
+
if ip.ipv4?
|
|
60
|
+
[:ipv4, ip.to_i]
|
|
61
|
+
elsif ip.ipv4_mapped?
|
|
62
|
+
# ::ffff:1.2.3.4 arrives on dual-stack sockets constantly; classify
|
|
63
|
+
# as the embedded IPv4 — that's the address doing the talking.
|
|
64
|
+
[:ipv4, ip.native.to_i]
|
|
65
|
+
else
|
|
66
|
+
[:ipv6, ip.to_i]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenASN
|
|
4
|
+
# Rack middleware: classifies the request IP once and exposes the result
|
|
5
|
+
# at env["openasn.result"] (nil when the IP is missing/unparseable —
|
|
6
|
+
# never raises into the request cycle).
|
|
7
|
+
#
|
|
8
|
+
# use OpenASN::Middleware
|
|
9
|
+
# # then anywhere downstream:
|
|
10
|
+
# request.env["openasn.result"]&.infrastructure?
|
|
11
|
+
#
|
|
12
|
+
# THE CLASSIC INTEGRATION BUG (read this before filing "everything is
|
|
13
|
+
# :private/:hosting"): if your app sits behind a proxy/load balancer and
|
|
14
|
+
# trusted proxies aren't configured, the IP you classify is your own
|
|
15
|
+
# infrastructure's. Inside Rails we use ActionDispatch's remote_ip
|
|
16
|
+
# (which honors config.action_dispatch.trusted_proxies); bare Rack falls
|
|
17
|
+
# back to REMOTE_ADDR. Behind Cloudflare or a CDN, make sure the real
|
|
18
|
+
# client IP reaches Rails (e.g. cloudflare-rails gem or equivalent
|
|
19
|
+
# trusted_proxies setup) BEFORE trusting these verdicts.
|
|
20
|
+
class Middleware
|
|
21
|
+
def initialize(app)
|
|
22
|
+
@app = app
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(env)
|
|
26
|
+
env["openasn.result"] = classify(env)
|
|
27
|
+
@app.call(env)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def classify(env)
|
|
33
|
+
ip = if defined?(ActionDispatch::Request)
|
|
34
|
+
ActionDispatch::Request.new(env).remote_ip
|
|
35
|
+
else
|
|
36
|
+
env["REMOTE_ADDR"]
|
|
37
|
+
end
|
|
38
|
+
return nil if ip.nil? || ip.empty?
|
|
39
|
+
|
|
40
|
+
OpenASN.lookup(ip)
|
|
41
|
+
rescue InvalidIPError
|
|
42
|
+
nil
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
# Never let classification break a request; log and move on.
|
|
45
|
+
OpenASN.configuration.logger.warn("openasn middleware: #{e.class}: #{e.message}")
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module OpenASN
|
|
7
|
+
# On-disk store for Tier B overlays under {data_dir}/overlays/.
|
|
8
|
+
#
|
|
9
|
+
# Files: {source_id}-ipv4.bin / {source_id}-ipv6.bin — raw concatenated
|
|
10
|
+
# big-endian (start, end) pairs, sorted and merged. No header: these are
|
|
11
|
+
# internal files owned by this gem, versioned by the `schema` field in
|
|
12
|
+
# state.json, and always written atomically (tmp + rename) so a reader
|
|
13
|
+
# process can never observe a torn file.
|
|
14
|
+
#
|
|
15
|
+
# state.json tracks per-source metadata: what it maps to, when it was
|
|
16
|
+
# fetched, ETag for conditional GETs, and the last error (keep-stale
|
|
17
|
+
# semantics: a failing source keeps serving its previous data, loudly).
|
|
18
|
+
class OverlayStore
|
|
19
|
+
SCHEMA = 1
|
|
20
|
+
|
|
21
|
+
Entry = Struct.new(:id, :maps_to, :provider, :v4, :v6, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
def initialize(data_dir)
|
|
24
|
+
@dir = File.join(data_dir, "overlays")
|
|
25
|
+
@state_path = File.join(@dir, "state.json")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def state
|
|
29
|
+
return { "schema" => SCHEMA, "sources" => {} } unless File.exist?(@state_path)
|
|
30
|
+
|
|
31
|
+
parsed = JSON.parse(File.read(@state_path))
|
|
32
|
+
# Unknown future schema: treat as empty rather than misread it.
|
|
33
|
+
parsed["schema"] == SCHEMA ? parsed : { "schema" => SCHEMA, "sources" => {} }
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
{ "schema" => SCHEMA, "sources" => {} }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def source_state(id) = state.dig("sources", id) || {}
|
|
39
|
+
|
|
40
|
+
# ranges_by_family: { ipv4: [[s,e],...] (sorted, merged), ipv6: [...] }
|
|
41
|
+
def write(id, maps_to:, provider: nil, etag: nil, ranges_by_family:)
|
|
42
|
+
FileUtils.mkdir_p(@dir)
|
|
43
|
+
counts = {}
|
|
44
|
+
%i[ipv4 ipv6].each do |family|
|
|
45
|
+
ranges = ranges_by_family[family] || []
|
|
46
|
+
counts[family] = ranges.length
|
|
47
|
+
packed = ranges.map { |(s, e)| BinaryFormat.pack_addr(s, family) + BinaryFormat.pack_addr(e, family) }.join
|
|
48
|
+
path = file_path(id, family)
|
|
49
|
+
File.binwrite("#{path}.tmp", packed)
|
|
50
|
+
File.rename("#{path}.tmp", path)
|
|
51
|
+
end
|
|
52
|
+
update_state(id) do |entry|
|
|
53
|
+
entry.merge(
|
|
54
|
+
"maps_to" => maps_to.to_s, "provider" => provider, "etag" => etag,
|
|
55
|
+
"fetched_at" => Time.now.utc.iso8601,
|
|
56
|
+
"records_ipv4" => counts[:ipv4], "records_ipv6" => counts[:ipv6],
|
|
57
|
+
"last_error" => nil
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def record_failure(id, error_message)
|
|
63
|
+
update_state(id) do |entry|
|
|
64
|
+
entry.merge("last_error" => error_message, "last_attempt_at" => Time.now.utc.iso8601)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def record_fresh(id)
|
|
69
|
+
update_state(id) do |entry|
|
|
70
|
+
entry.merge("fetched_at" => Time.now.utc.iso8601, "last_error" => nil)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def fetched_at(id)
|
|
75
|
+
ts = source_state(id)["fetched_at"]
|
|
76
|
+
ts && Time.parse(ts)
|
|
77
|
+
rescue ArgumentError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Load every stored overlay among enabled_ids into memory.
|
|
82
|
+
def load(enabled_ids, mode)
|
|
83
|
+
sources = state["sources"]
|
|
84
|
+
enabled_ids.filter_map do |id|
|
|
85
|
+
meta = sources[id]
|
|
86
|
+
next unless meta
|
|
87
|
+
|
|
88
|
+
v4 = load_family(id, :ipv4, mode)
|
|
89
|
+
v6 = load_family(id, :ipv6, mode)
|
|
90
|
+
next unless v4 || v6
|
|
91
|
+
|
|
92
|
+
Entry.new(id: id, maps_to: meta["maps_to"], provider: meta["provider"],
|
|
93
|
+
v4: v4, v6: v6)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def clear!(id)
|
|
98
|
+
%i[ipv4 ipv6].each { |f| FileUtils.rm_f(file_path(id, f)) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def file_path(id, family) = File.join(@dir, "#{id}-#{family}.bin")
|
|
104
|
+
|
|
105
|
+
def load_family(id, family, mode)
|
|
106
|
+
path = file_path(id, family)
|
|
107
|
+
return nil unless File.exist?(path)
|
|
108
|
+
|
|
109
|
+
bytes = File.binread(path)
|
|
110
|
+
# A torn/odd-sized file would corrupt binary search: drop the tail.
|
|
111
|
+
rec = BinaryFormat.overlay_rec_size(family)
|
|
112
|
+
bytes = bytes[0, bytes.bytesize - (bytes.bytesize % rec)] if (bytes.bytesize % rec).positive?
|
|
113
|
+
BinaryFormat::OverlayLayer.build(bytes, family, mode)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def update_state(id)
|
|
117
|
+
FileUtils.mkdir_p(@dir)
|
|
118
|
+
current = state
|
|
119
|
+
current["sources"][id] = yield(current["sources"][id] || {})
|
|
120
|
+
File.write("#{@state_path}.tmp", JSON.pretty_generate(current))
|
|
121
|
+
File.rename("#{@state_path}.tmp", @state_path)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|