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.
@@ -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
+ }
@@ -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