openasn 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f423b3d573d4d10e9062c210b0f1704520518dd0984beb6229092c36aa18c91
4
- data.tar.gz: e9c15ca9d880ee0a2fd762c9b7253bfd5bbe92e50b990ac62191ab3cae59b5eb
3
+ metadata.gz: 9fcde0e4605b2400824b5e03518b626465621cf43418d647a5b55d937cd66f38
4
+ data.tar.gz: beeee7a904311dd4a796c3e8b6306344b92e12d3410d5393dbc15bb3f56e9b02
5
5
  SHA512:
6
- metadata.gz: a269180e3fd9f820e2a24f1ae0f5747fd610c4cc81a2e0b09439970cecba931c4059e3ca21fac194b271709532d40a44cdfc9345ce2c19a48891b36b93401775
7
- data.tar.gz: cc2a87918a1869b661fc5624c36779e0a33ee98c7ba3db9d35686469ab9a7622ebeae77e8d77a6f4e71dc9ef0801c4c00854f32beb9e211726f8b2957463e25f
6
+ metadata.gz: 659b36d6540e8e625e665c8030cc99c73c2c0ff832270a206e4096d2a2ec5ee3428423dca27cf689f0af30606612dc51a9dbf5858400ecc8f5dd91af7fff88b0
7
+ data.tar.gz: be56f2a505e3c127ae65f1caed99f1aadcdf521a7d94e426f82a3a20e9bd8871e5478148905830a1bb01852c327b993462079829228e306f953d4906a69dcc20
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-07-07
4
+
5
+ ### Fixed
6
+
7
+ - `rails generate openasn:install` now inserts `OpenASN::UpdateJob` under an
8
+ existing `production:` entry in `config/recurring.yml` instead of appending
9
+ a duplicate top-level key that can make YAML parsers drop existing jobs.
10
+ - README scheduling guidance now calls out that manual update jobs should run
11
+ after the 03:17 UTC data build, using UTC to avoid daylight-saving drift.
12
+ - Removed the top-level `rexml/document` require from the WLVPN Tier B parser
13
+ so production Ruby 3.4 bundles that exclude the `rexml` bundled gem can boot
14
+ and precompile assets without adding app-side dependencies.
15
+
16
+ ## [0.3.0] - 2026-07-07
17
+
18
+ Ergonomics release, driven by dogfooding the analytics/enrichment use case
19
+ (surfacing IP origin in admin panels) in a production Rails app.
20
+
21
+ ### Added
22
+
23
+ - `Result#label` — the verdict as a short human-readable string
24
+ ("Residential ISP", "Hosting / datacenter", "Privacy relay") for admin
25
+ tables, tooltips, and log lines. One label per verdict, same append-only
26
+ contract as the enum.
27
+ - `Result#flag_names` — the ASN-level flag bitfield decoded to symbols
28
+ (`[:bad_asn, :vpn_provider]`), plus `Result#flag?(name)` and
29
+ `Result#bad_asn?` sugar. No more bit arithmetic to answer "is this ASN in
30
+ bad-asn-list?".
31
+ - `OpenASN.try_lookup(ip)` — nil-safe lookup: returns `nil` on nil/blank/
32
+ unparseable input instead of raising. The right call site for views and
33
+ analytics over historical data, where a garbage IP is data, not an
34
+ exception.
35
+ - `Result#to_h` now includes `flag_names` (append-only key addition; the
36
+ raw `flags` integer was useless in a log line).
37
+
38
+ ### Changed
39
+
40
+ - README reframed analytics-first: enriching admin panels/audit trails is
41
+ the primary documented use case; acting on the signal (step-up
42
+ verification, rate limits) is the optional later step.
43
+
3
44
  ## [0.2.0] - 2026-07-06
4
45
 
5
46
  ### Added
@@ -48,5 +89,7 @@
48
89
  ranges, provider-attributed VPN overlays), Rack middleware, Rails install
49
90
  generator.
50
91
 
92
+ [0.3.1]: https://github.com/openasn/openasn-ruby/compare/v0.3.0...v0.3.1
93
+ [0.3.0]: https://github.com/openasn/openasn-ruby/compare/v0.2.0...v0.3.0
51
94
  [0.2.0]: https://github.com/openasn/openasn-ruby/compare/v0.1.0...v0.2.0
52
95
  [0.1.0]: https://github.com/openasn/openasn-ruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
  result = OpenASN.lookup(request.remote_ip)
12
12
 
13
13
  result.verdict # => :residential_isp
14
+ result.label # => "Residential ISP" — human-readable, for admin UIs & logs
14
15
  result.infrastructure? # => false (true only for :hosting / :vpn / :tor_exit)
15
16
  result.likely_human? # => true (:residential_isp, :mobile, :relay, :cgnat, :enterprise_gateway)
16
17
  result.asn # => 3352
@@ -18,10 +19,10 @@ result.as_org # => "TELEFONICA DE ESPANA S.A.U."
18
19
  result.sources # => [:asn_category] — every verdict is auditable
19
20
  ```
20
21
 
21
- It's the missing first line of defense for signups, checkouts, and abuse-prone endpoints: the signal that today costs $79–$200/month from commercial IP-intelligence APIs, as free, open, auditable data — compiled nightly from legally clean sources by the [OpenASN data project](https://github.com/openasn/openasn), with a data seed bundled right in the gem so it works on first boot, offline.
22
+ It answers, at zero marginal cost, the question every product eventually asks: **where do my signups, logins, and checkouts actually come from?** Enrich your admin panel, analytics, and audit trails with network origin — and if you ever decide to *act* on the signal, the same verdict drives step-up verification or rate limits. It's the data that costs $79–$200/month from commercial IP-intelligence APIs, as free, open, auditable data — compiled nightly from legally clean sources by the [OpenASN data project](https://github.com/openasn/openasn), with a data seed bundled right in the gem so it works on first boot, offline.
22
23
 
23
24
  > [!IMPORTANT]
24
- > **What this is NOT.** A clean or `:residential_isp` verdict is **absence of evidence, not proof of innocence**. Residential proxies — malicious traffic exiting through real home IPs — are structurally hard to detect offline, and OpenASN does not claim to detect them. `:vpn`, `:hosting`, and `:tor_exit` are high-confidence; treat everything else as a signal, not a sentence. **Never hard-block `:relay`, `:cgnat`, or `:mobile` — those are real people.** OpenASN is a first line of defense, not a fraud engine.
25
+ > **What this is NOT.** A clean or `:residential_isp` verdict is **absence of evidence, not proof of innocence**. Residential proxies — malicious traffic exiting through real home IPs — are structurally hard to detect offline, and OpenASN does not claim to detect them. `:vpn`, `:hosting`, and `:tor_exit` are high-confidence; treat everything else as a signal, not a sentence. **Never hard-block `:relay`, `:cgnat`, or `:mobile` — those are real people.** OpenASN tells you what the network *is*; it is not a fraud engine, and a verdict is a fact to weigh, never a sentence to execute.
25
26
 
26
27
  ## How it works
27
28
 
@@ -58,6 +59,11 @@ rails generate openasn:install
58
59
 
59
60
  The generator creates `config/initializers/openasn.rb` (documented defaults) and, if you're on Solid Queue, schedules the daily `OpenASN::UpdateJob` in `config/recurring.yml`. **No migrations, no database** — data lives in `storage/openasn/` as memory-mapped-style packed files.
60
61
 
62
+ If you wire the job yourself, schedule it after the OpenASN data build
63
+ publishes at **03:17 UTC**. The generator uses `every day at 4:12am UTC` on
64
+ purpose; local time zones with daylight saving can drift before the UTC build
65
+ and fetch yesterday's release.
66
+
61
67
  Not on Rails? `OpenASN.lookup` works anywhere Ruby ≥ 3.1 runs; schedule `OpenASN.update!` daily with cron/whenever.
62
68
 
63
69
  ## The API
@@ -68,9 +74,14 @@ Not on Rails? `OpenASN.lookup` works anywhere Ruby ≥ 3.1 runs; schedule `OpenA
68
74
  r = OpenASN.lookup("146.70.107.100") # aliases: OpenASN.check, OpenASN.[]
69
75
  # Never nil. Raises OpenASN::InvalidIPError (an ArgumentError) on garbage input.
70
76
 
77
+ r = OpenASN.try_lookup(user.signup_ip) # nil-safe variant: nil/blank/garbage in,
78
+ # nil out — right for views and analytics
79
+ # over historical data
80
+
71
81
  r.verdict # one of: :residential_isp :mobile :business :hosting :vpn
72
82
  # :tor_exit :relay :enterprise_gateway :education
73
83
  # :government :cgnat :private :unknown
84
+ r.label # "VPN" — the verdict as a short human-readable string
74
85
  r.infrastructure? # :hosting | :vpn | :tor_exit — the honest, high-confidence boolean
75
86
  r.likely_human? # :residential_isp | :mobile | :relay | :cgnat | :enterprise_gateway
76
87
  r.vpn? r.hosting? r.tor? r.relay? r.mobile? r.private? r.cgnat?
@@ -81,6 +92,9 @@ r.category # "hosting" (raw upstream category)
81
92
  r.network_role # "major_transit" (raw upstream routing role)
82
93
  r.provider # "aws" | "ProtonVPN" | "iCloud Private Relay" | nil (overlay attribution)
83
94
  r.sources # [:x4b_vpn] — which data layer decided; every verdict is auditable
95
+ r.flag_names # [:vpn_provider, :bad_asn] — ASN-level signals, decoded to names
96
+ r.bad_asn? # ASN is in brianhama/bad-asn-list (infrastructure signal, not an accusation)
97
+ r.flag?(:cdn) # named check for any flag in r.flag_names' vocabulary
84
98
  r.context_flags # [:cloudflare_range] — context that never decides verdicts
85
99
  r.unrouted? # true when no ASN announces this IP
86
100
  r.flags # raw u16 for power users (see the data project's FORMAT.md)
@@ -103,13 +117,14 @@ OpenASN returns one object, but its fields answer different questions:
103
117
  | `sources` | Which rule/data layer decided the verdict | Best debugging field. Log it. |
104
118
  | `provider` | Provider attribution from an exact overlay hit (`"aws"`, `"azure"`, `"ProtonVPN"`, ...) | Display/log it when present; nil is normal. |
105
119
  | `context_flags` | Extra context that never decides the verdict | Useful for policy experiments; do not block solely on it. |
106
- | `flags` | Packed low-level artifact bits | Power-user/debug field; prefer `verdict` and `sources` in app code. |
120
+ | `flag_names` | ASN-level signals, decoded (`:bad_asn`, `:vpn_provider`, `:mobile_carrier`, `:enterprise_gw`, `:cdn`, `:hosting_extra`) | Display/log them; `bad_asn?` and `flag?(:name)` are the sugar. |
121
+ | `flags` | Packed low-level artifact bits | Wire detail; prefer `flag_names` in app code. |
107
122
 
108
123
  Common examples:
109
124
 
110
125
  - A DIGI Spain home IP is usually `category: "isp"` and `verdict: :residential_isp`. That is expected: `category` describes the ASN; `verdict` is OpenASN's safer application-level label.
111
126
  - An Amazon IP may be `category: "hosting"`, `sources: [:x4b_dc]`, and with Tier B enabled `provider: "aws"`. The verdict remains `:hosting`; Tier B only improves attribution.
112
- - `bad_asn` is not an accusation and not a verdict. It means the ASN appears in `brianhama/bad-asn-list`, a curated MIT-licensed hosting/cloud/colo ASN list. When that bit decides classification, `sources` includes `:asn_bad_asn`.
127
+ - `bad_asn?` is not an accusation and not a verdict. It means the ASN appears in `brianhama/bad-asn-list`, a curated MIT-licensed hosting/cloud/colo ASN list. When that signal decides classification, `sources` includes `:asn_bad_asn`.
113
128
  - `:residential_isp` means "known eyeball ISP, no stronger infrastructure signal found." It does **not** mean "safe user."
114
129
 
115
130
  ### Verdict cheat sheet
@@ -179,12 +194,16 @@ request.env["openasn.result"]&.infrastructure?
179
194
  > [!WARNING]
180
195
  > **The classic integration bug:** behind a proxy/CDN without trusted-proxy configuration, you'll classify your own load balancer on every request (everything comes back `:private` or your host's datacenter). Inside Rails the middleware uses `remote_ip` semantics (honors `config.action_dispatch.trusted_proxies`); make sure the real client IP reaches your app — e.g. with the `cloudflare-rails` gem when behind Cloudflare.
181
196
 
182
- ## Recommended rollout: shadow mode first
197
+ ## Recommended rollout: enrich first, act later (maybe never)
183
198
 
184
- Log for two weeks. Then decide. (This is how we dogfood it.)
199
+ Start where the value is certain and the risk is zero: **visibility**. Put the verdict next to every IP your team already looks at. For many apps that's the whole integration — and it's how we dogfood it ourselves.
185
200
 
186
201
  ```ruby
187
- # 1. SHADOWmeasure, block nothing:
202
+ # 1. ENRICHpure analytics, blocks nothing, breaks nothing:
203
+ # surface origin in your admin panel and audit trails…
204
+ OpenASN.try_lookup(user.signup_ip)&.label # "Residential ISP" / "VPN" / "Hosting / datacenter"
205
+
206
+ # …and/or log it on the flows you care about:
188
207
  class RegistrationsController < ApplicationController
189
208
  def create
190
209
  Rails.logger.info(openasn: OpenASN.lookup(request.remote_ip).to_h.merge(flow: "signup"))
@@ -192,8 +211,9 @@ class RegistrationsController < ApplicationController
192
211
  end
193
212
  end
194
213
 
195
- # 2. Weeks later, with YOUR false-positive data in hand, act prefer
196
- # step-up verification (email confirm, captcha, phone) over hard blocks:
214
+ # 2. If (and only if) your OWN data says you have an abuse problem worth the
215
+ # friction, act — prefer step-up verification (email confirm, captcha,
216
+ # phone) over hard blocks:
197
217
  Rack::Attack.blocklist("openasn: infrastructure on signup") do |req|
198
218
  req.post? && req.path == "/signup" && OpenASN.lookup(req.ip).infrastructure?
199
219
  end
@@ -12,6 +12,14 @@ module Openasn
12
12
  class InstallGenerator < Rails::Generators::Base
13
13
  source_root File.expand_path("templates", __dir__)
14
14
 
15
+ RECURRING_JOB = <<~YAML
16
+ # Added by `rails g openasn:install` — daily IP-origin data refresh
17
+ openasn_update:
18
+ class: OpenASN::UpdateJob
19
+ queue: default
20
+ schedule: every day at 4:12am UTC
21
+ YAML
22
+
15
23
  def create_initializer
16
24
  template "openasn.rb", "config/initializers/openasn.rb"
17
25
  end
@@ -30,15 +38,8 @@ module Openasn
30
38
 
31
39
  # 4:12am UTC: after the nightly data build (03:17 UTC) completes,
32
40
  # off-hour to be kind to the Tier B upstreams.
33
- append_to_file recurring, <<~YAML
34
-
35
- # Added by `rails g openasn:install` — daily IP-origin data refresh
36
- production:
37
- openasn_update:
38
- class: OpenASN::UpdateJob
39
- queue: default
40
- schedule: every day at 4:12am UTC
41
- YAML
41
+ recurring_path = File.expand_path(recurring, destination_root)
42
+ File.write(recurring_path, recurring_with_openasn_job(File.read(recurring_path)))
42
43
  end
43
44
 
44
45
  def display_post_install_message
@@ -63,6 +64,27 @@ module Openasn
63
64
  say " and :relay/:cgnat/:mobile are real humans — never hard-block them.", :yellow
64
65
  say ""
65
66
  end
67
+
68
+ private
69
+
70
+ def recurring_with_openasn_job(contents)
71
+ production = contents.match(/^production:\s*$/)
72
+ return "#{contents.rstrip}\n\nproduction:\n#{indent(RECURRING_JOB, 2)}" unless production
73
+
74
+ insert_at = next_environment_index(contents, production.end(0)) || contents.length
75
+ "#{contents[0...insert_at].rstrip}\n\n#{indent(RECURRING_JOB, 2)}#{contents[insert_at..]}"
76
+ end
77
+
78
+ def next_environment_index(contents, offset)
79
+ tail = contents[offset..]
80
+ match = tail.match(/\n(?=\S[^:\n]*:\s*$)/)
81
+ match && offset + match.begin(0)
82
+ end
83
+
84
+ def indent(text, spaces)
85
+ prefix = " " * spaces
86
+ text.lines.map { |line| line == "\n" ? line : "#{prefix}#{line}" }.join
87
+ end
66
88
  end
67
89
  end
68
90
  end
@@ -18,7 +18,7 @@ OpenASN.configure do |config|
18
18
 
19
19
  # Self-host or pin the data source if you need to:
20
20
  # config.release_url = "https://github.com/openasn/openasn/releases/download/latest/"
21
- # config.pin_version = "2026-07-04" # a dated release tag, for reproducible builds
21
+ # config.pin_version = "v2026.07.04" # a dated release tag, for reproducible builds
22
22
 
23
23
  # Tier B overlays — fetched by YOUR server from the original authorities
24
24
  # (Apple, Tor Project, AWS…), per the project's fetch-manifest. Defaults:
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "rexml/document"
5
4
  require "zlib"
6
5
 
7
6
  module OpenASN
@@ -219,13 +218,11 @@ module OpenASN
219
218
  end
220
219
 
221
220
  register "wlvpn_server_list_xml" do |body|
222
- doc = REXML::Document.new(body)
223
- tokens = []
224
- doc.elements.each("//server") do |server|
225
- next unless server.attributes["visible"].to_s == "1" && server.attributes["status"].to_s == "1"
221
+ tokens = xml_tag_attributes(body, "server").filter_map do |attrs|
222
+ next unless attrs["visible"].to_s == "1" && attrs["status"].to_s == "1"
226
223
 
227
- ip = server.attributes["ip"].to_s.strip
228
- tokens << ip unless ip.empty?
224
+ ip = attrs["ip"].to_s.strip
225
+ ip unless ip.empty?
229
226
  end
230
227
  raise ParseError, "wlvpn_server_list_xml: no visible active server IPs — schema changed?" if tokens.empty?
231
228
 
@@ -433,6 +430,20 @@ module OpenASN
433
430
  .strip
434
431
  end
435
432
 
433
+ # Tiny tag-attribute scanner for provider XML-ish feeds. We only need
434
+ # exact attributes from a trusted source-specific tag; pulling in REXML
435
+ # would make production apps depend on a bundled gem that Ruby 3.4 no
436
+ # longer guarantees is installed.
437
+ def xml_tag_attributes(body, tag_name)
438
+ body.scan(/<#{Regexp.escape(tag_name)}\b[^>]*>/i).map do |tag|
439
+ attrs = {}
440
+ tag.scan(/\b([a-z_:][\w:.-]*)\s*=\s*(["'])(.*?)\2/im).each do |key, _quote, value|
441
+ attrs[key.downcase] = value
442
+ end
443
+ attrs
444
+ end
445
+ end
446
+
436
447
  def openvpn_remote_hosts(content)
437
448
  content.each_line.filter_map do |line|
438
449
  match = line.match(/\Aremote\s+([^\s]+)(?:\s|$)/i)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "binary_format"
4
+
3
5
  module OpenASN
4
6
  # The answer to "where is this IP really coming from?". Immutable.
5
7
  #
@@ -31,8 +33,29 @@ module OpenASN
31
33
  # app decides those.
32
34
  LIKELY_HUMAN_VERDICTS = %i[residential_isp mobile relay cgnat enterprise_gateway].freeze
33
35
 
36
+ # Short human-readable names for every verdict — what you'd print in an
37
+ # admin table or a log line a human reads. Deliberately neutral wording
38
+ # (no "suspicious", no "risky"): the label states what the network IS;
39
+ # what to do about it is your app's policy. Keys follow the VERDICTS
40
+ # append-only contract.
41
+ LABELS = {
42
+ residential_isp: "Residential ISP",
43
+ mobile: "Mobile carrier",
44
+ business: "Business",
45
+ hosting: "Hosting / datacenter",
46
+ vpn: "VPN",
47
+ tor_exit: "Tor exit",
48
+ relay: "Privacy relay",
49
+ enterprise_gateway: "Corporate gateway",
50
+ education: "University / research",
51
+ government: "Government",
52
+ cgnat: "Carrier NAT",
53
+ private: "Private IP",
54
+ unknown: "Unknown"
55
+ }.freeze
56
+
34
57
  attr_reader :ip, :verdict, :asn, :as_org, :category, :network_role,
35
- :provider, :sources, :flags, :context_flags
58
+ :provider, :sources, :flags, :context_flags, :flag_names
36
59
 
37
60
  def initialize(ip:, verdict:, asn: nil, as_org: nil, category: nil,
38
61
  network_role: nil, provider: nil, sources: [], flags: 0,
@@ -46,14 +69,30 @@ module OpenASN
46
69
  @provider = provider
47
70
  @sources = sources.freeze
48
71
  @flags = flags
72
+ # The raw u16 is a wire detail; apps want names. Decoded once here so
73
+ # nobody downstream needs BinaryFormat bit knowledge.
74
+ @flag_names = BinaryFormat.flag_names(flags).freeze
49
75
  @context_flags = context_flags.freeze
50
76
  @unrouted = unrouted
51
77
  freeze
52
78
  end
53
79
 
80
+ # The verdict as a short human-readable string ("Residential ISP",
81
+ # "Hosting / datacenter") — for admin tables, tooltips, log lines.
82
+ def label = LABELS.fetch(verdict)
83
+
54
84
  def infrastructure? = INFRASTRUCTURE_VERDICTS.include?(verdict)
55
85
  def likely_human? = LIKELY_HUMAN_VERDICTS.include?(verdict)
56
86
 
87
+ # Named ASN-level flag check: flag?(:bad_asn), flag?(:cdn), … (the full
88
+ # vocabulary is BinaryFormat::FLAG_NAMES.values / the README table).
89
+ def flag?(name) = flag_names.include?(name)
90
+
91
+ # The ASN appears in brianhama/bad-asn-list — a curated catalog of
92
+ # hosting/cloud/colo ASNs. An infrastructure signal worth an admin's
93
+ # glance, NOT a verdict override and NOT a claim of abuse.
94
+ def bad_asn? = flag?(:bad_asn)
95
+
57
96
  def vpn? = verdict == :vpn
58
97
  def hosting? = verdict == :hosting
59
98
  def tor? = verdict == :tor_exit
@@ -67,6 +106,7 @@ module OpenASN
67
106
 
68
107
  # Everything, for logging and shadow mode. Stable keys — CarHey-style
69
108
  # shadow analyses depend on this shape staying append-only.
109
+ # (flag_names added in 0.3.0: the raw bitfield is useless in a log line.)
70
110
  def to_h
71
111
  {
72
112
  ip: ip,
@@ -80,6 +120,7 @@ module OpenASN
80
120
  provider: provider,
81
121
  sources: sources,
82
122
  flags: flags,
123
+ flag_names: flag_names,
83
124
  context_flags: context_flags,
84
125
  unrouted: unrouted?
85
126
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenASN
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/openasn.rb CHANGED
@@ -72,6 +72,18 @@ module OpenASN
72
72
  alias check lookup
73
73
  alias [] lookup
74
74
 
75
+ # Like lookup, but returns nil on nil/blank/unparseable input instead of
76
+ # raising — for analytics and rendering call sites where a missing or
77
+ # garbage IP is ordinary data (an old DB row, a stripped header), not an
78
+ # exceptional condition worth a begin/rescue at every call site.
79
+ def try_lookup(ip)
80
+ return nil if ip.nil? || (ip.is_a?(String) && ip.strip.empty?)
81
+
82
+ lookup(ip)
83
+ rescue InvalidIPError
84
+ nil
85
+ end
86
+
75
87
  # Refresh canonical artifacts + Tier B overlays now, atomically
76
88
  # swapping the in-memory dataset on success.
77
89
  # -> :updated | :tier_b_only | :unchanged | :locked
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openasn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-07-06 00:00:00.000000000 Z
10
+ date: 2026-08-08 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: 'OpenASN classifies where an IP address is really coming from — residential
13
13
  ISP, mobile carrier, hosting/datacenter, VPN, Tor exit, iCloud Private Relay, enterprise