otto 2.8.0 → 2.8.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.
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,46 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.8.1:
11
+
12
+ 2.8.1 — 2026-08-16
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Opt-in ASN resolution: ``req.asn`` / ``env['otto.privacy.asn']`` resolves
19
+ the client's network operator (``'AS15169'``) from a local MaxMind-format
20
+ database configured via ``configure_ip_privacy(asn: true, asn_db_path:
21
+ ...)`` (or a bring-your-own ``asn_db_reader``). Database-only — no header
22
+ tier exists, so the ``geo_header``/``trusted_proxy_depth`` conflict
23
+ machinery does not apply — and the lookup uses the already-masked IP, which
24
+ is safe because IPv4 BGP routes are not announced longer than /24. Off by
25
+ default: ``nil`` when disabled, ``'**'`` when enabled but unresolved.
26
+
27
+ - Opt-in anonymizer classification: ``req.anonymizer`` /
28
+ ``env['otto.privacy.anonymizer']`` labels the client address as ``'tor'``,
29
+ ``'proxy'``, ``'vpn'``, ``'residential_proxy'``, ``'hosting'``,
30
+ ``'anonymous'``, ``'none'`` (consulted, not listed), or ``'**'`` (no
31
+ answer), from a local anonymous-IP database
32
+ (``configure_ip_privacy(anonymizer: true, anonymizer_db_path: ...)``).
33
+ This is the one database lookup performed on the UNMASKED address:
34
+ anonymizer data lists individual egress nodes at or near /32, so a masked
35
+ lookup would answer for the node's neighbours — flagging innocent
36
+ addresses and missing actual exit nodes. Only the label ever leaves the
37
+ resolver, the same containment contract as ``hash_ip`` and
38
+ ``env['otto.ip_match']``. Off by default.
39
+
40
+ Documentation
41
+ -------------
42
+
43
+ - New ``docs/enrichment.md`` covering both signals, including the corrected
44
+ dataset guidance: ``geo-whois-asn-country`` is a country database (the
45
+ ``asn`` in its name describes its data *sources*, not its record
46
+ contents); deployments wanting ASN data need a separate ASN file, e.g.
47
+ sapics ``origin-asn`` (PDDL, rebuilt daily) — verified against the
48
+ published file, whose records carry a flat ``autonomous_system_number``.
49
+
10
50
  .. _changelog-2.8.0:
11
51
 
12
52
  2.8.0 — 2026-08-07
data/Gemfile CHANGED
@@ -29,7 +29,7 @@ group :development do
29
29
  gem 'debug'
30
30
  gem 'rackup' # Used to boot examples/ apps; not needed by specs
31
31
  gem 'rake', '~> 13.4', require: false # Provides `rake release` for release-gem.yml
32
- gem 'rubocop', '~> 1.88.2', require: false
32
+ gem 'rubocop', '~> 1.89.0', require: false
33
33
  gem 'rubocop-performance', require: false
34
34
  gem 'rubocop-rspec', require: false
35
35
  gem 'rubocop-thread_safety', require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.8.0)
4
+ otto (2.8.1)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
@@ -92,7 +92,7 @@ GEM
92
92
  nokogiri (1.19.4-x86_64-linux-musl)
93
93
  racc (~> 1.4)
94
94
  parallel (1.28.0)
95
- parser (3.3.11.1)
95
+ parser (3.3.12.0)
96
96
  ast (~> 2.4.1)
97
97
  racc
98
98
  pastel (0.8.0)
@@ -148,7 +148,7 @@ GEM
148
148
  diff-lcs (>= 1.2.0, < 2.0)
149
149
  rspec-support (~> 3.13.0)
150
150
  rspec-support (3.13.7)
151
- rubocop (1.88.2)
151
+ rubocop (1.89.0)
152
152
  json (~> 2.3)
153
153
  language_server-protocol (~> 3.17.0.2)
154
154
  lint_roller (~> 1.1.0)
@@ -227,7 +227,7 @@ DEPENDENCIES
227
227
  rake (~> 13.4)
228
228
  reek (~> 6.5)
229
229
  rspec (~> 3.13)
230
- rubocop (~> 1.88.2)
230
+ rubocop (~> 1.89.0)
231
231
  rubocop-performance
232
232
  rubocop-rspec
233
233
  rubocop-thread_safety
data/docs/.gitignore CHANGED
@@ -3,6 +3,7 @@
3
3
  !migrating/
4
4
  !migrating/*.md
5
5
  !ipaddr-encoding-quirk.md
6
+ !enrichment.md
6
7
  !geo-country.md
7
8
  !modern-authentication-authorization-landscape.md
8
9
  !multi-strategy-authentication-design.md
@@ -0,0 +1,128 @@
1
+ # ASN and anonymizer enrichment
2
+
3
+ Two opt-in, country-adjacent signals layered on the same privacy pipeline as
4
+ [geo-country](geo-country.md):
5
+
6
+ - **ASN** — the network operator an address belongs to, as `req.asn` /
7
+ `env['otto.privacy.asn']`: `'AS15169'`.
8
+ - **Anonymizer** — whether the address is a known anonymizing egress, as
9
+ `req.anonymizer` / `env['otto.privacy.anonymizer']`: `'tor'`, `'proxy'`,
10
+ `'vpn'`, `'residential_proxy'`, `'hosting'`, `'anonymous'`, `'none'`, `'**'`.
11
+
12
+ Both are **off by default** (unlike geo) and **database-only**: no CDN
13
+ publishes a client-ASN or anonymizer header with meaningful deployment, so
14
+ there is no header tier and none of geo's header-trust machinery applies —
15
+ including the `geo_header`/`trusted_proxy_depth` boot conflict.
16
+
17
+ Every signal keeps the same three-state contract:
18
+
19
+ | Value | Meaning |
20
+ | --- | --- |
21
+ | `nil` | the signal is switched off |
22
+ | `'**'` | switched on, but nothing resolved (no database, or lookup failed) |
23
+ | a label | a real answer |
24
+
25
+ ## Configuration
26
+
27
+ ```ruby
28
+ otto.configure_ip_privacy(
29
+ asn: true,
30
+ asn_db_path: 'data/origin-asn.mmdb',
31
+ anonymizer: true,
32
+ anonymizer_db_path: 'data/anonymizer.mmdb',
33
+ )
34
+ ```
35
+
36
+ Both accept the same bring-your-own-reader seam as geo (`asn_db_reader:` /
37
+ `anonymizer_db_reader:` — any object responding to `#get(ip)`; a reader
38
+ supplied in the same call wins over a path). Bad paths raise at boot, not
39
+ per-request. The [`maxmind-db`](https://rubygems.org/gems/maxmind-db) gem is
40
+ required only when a `*_db_path` is configured.
41
+
42
+ ## ASN: which address is looked up, and why that's safe
43
+
44
+ The ASN lookup uses the **masked** IP, same as geo. This is not a compromise:
45
+ IPv4 BGP routes are not announced longer than /24, so a /24-masked address
46
+ falls inside the same announced prefix — and therefore the same ASN — as the
47
+ real one. (IPv6 is coarser: at `octet_precision: 1` Otto zeroes the last 80
48
+ bits, wider than many IPv6 announcements, so treat IPv6 ASN as best-effort.)
49
+
50
+ ### Data file
51
+
52
+ > **Naming caution:** the country database this project recommends,
53
+ > `geo-whois-asn-country`, does **not** contain ASN data. In
54
+ > sapics/ip-location-db naming, `geo-whois-asn` describes the *sources* the
55
+ > country data was derived from; the final token (`-country`) is what the
56
+ > records contain. ASN data is a separate file type in that project.
57
+
58
+ The recommended ASN file is
59
+ [`origin-asn`](https://github.com/sapics/ip-location-db/tree/main/origin-asn/)
60
+ from sapics/ip-location-db — like `geo-whois-asn-country` it is **PDDL v1.0
61
+ (public domain)** and rebuilt daily, so the licensing/freshness posture
62
+ matches the geo guidance:
63
+
64
+ ```bash
65
+ curl -fsSL -o data/origin-asn.mmdb \
66
+ https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn.mmdb
67
+ ```
68
+
69
+ Its records carry a flat `autonomous_system_number` (verified against the
70
+ published file), which is the primary key `AsnResolver` reads. GeoLite2-ASN
71
+ and DB-IP ASN Lite MMDBs work too (same key; GeoLite2's EULA caveats from the
72
+ geo doc apply). Reserved ASNs (0 per RFC 7607, the AS_TRANS placeholder 23456
73
+ per RFC 6793) resolve to `'**'` rather than being reported as operators.
74
+
75
+ ## Anonymizer: the one unmasked lookup
76
+
77
+ Anonymizer classification reads the **unmasked** address. This is deliberate
78
+ and documented in `AnonymizerResolver` itself: anonymizer databases list
79
+ individual egress nodes at or near /32, so the /24 equivalence that justifies
80
+ masked geo and ASN lookups does not hold. A masked lookup would flag a whole
81
+ /24 because one host in it runs a Tor exit, and miss the exit node itself —
82
+ wrong in both directions.
83
+
84
+ The privacy containment is the same one Otto already relies on for
85
+ `hash_ip` and `env['otto.ip_match']`, which also consume the full IP: **only
86
+ the derived value leaves**. The resolver returns a label; the address is
87
+ never persisted, serialized, or handed downstream.
88
+
89
+ ### Reading the labels
90
+
91
+ When several database flags are set at once (a Tor exit hosted at a cloud
92
+ provider), the **most specific** label wins: `tor` > `proxy` > `vpn` >
93
+ `residential_proxy` > `hosting` > `anonymous`.
94
+
95
+ Two labels deserve care:
96
+
97
+ - **`'none'`** means the database was consulted and does not list the
98
+ address. For an anonymizer database that is a real answer (these files
99
+ record only flagged addresses), but it is *not* a positive assertion the
100
+ visitor is residential — it is only as fresh as your database file.
101
+ - **`'**'`** means no database answered at all. Do not collapse it into
102
+ `'none'`: `'none'` is evidence, `'**'` is the absence of evidence. A
103
+ "block anonymizers" rule that treats `'**'` as `'none'` fails open when
104
+ the database file goes missing.
105
+
106
+ ### Data file
107
+
108
+ There is no public-domain anonymizer dataset of `origin-asn`'s quality; this
109
+ signal is bring-your-own-database. `AnonymizerResolver` reads the MaxMind
110
+ GeoIP2 Anonymous-IP flag schema (`is_tor_exit_node`, `is_public_proxy`,
111
+ `is_anonymous_vpn`, `is_residential_proxy`, `is_hosting_provider`,
112
+ `is_anonymous`), which commercial and self-built MMDBs alike use. A workable
113
+ self-built option: compile the [Tor bulk exit
114
+ list](https://check.torproject.org/torbulkexitlist) into an MMDB with
115
+ `is_tor_exit_node` set — that covers the highest-signal label with fully
116
+ public data.
117
+
118
+ ## Acceptance behavior summary
119
+
120
+ | Scenario | Result |
121
+ | --- | --- |
122
+ | Signal not enabled | `nil` everywhere (env key absent for exempt IPs) |
123
+ | Enabled, no database configured | `'**'` |
124
+ | Database read raises | `'**'` (a lookup must never crash a request) |
125
+ | ASN lookup | masked IP only (re-masked defensively in the resolver) |
126
+ | Anonymizer lookup | unmasked IP in, label out, nothing else retained |
127
+ | Private/localhost client (privacy-exempt) | no enrichment keys in env |
128
+ | Bad `*_db_path` | raises at boot, not per-request |
data/docs/geo-country.md CHANGED
@@ -3,7 +3,8 @@
3
3
  Otto resolves a country-level ISO 3166-1 alpha-2 code for each request and
4
4
  exposes it as `req.geo_country` / `env['otto.privacy.geo_country']`. Resolution
5
5
  is country-only by design — that is the privacy posture; there is no city or
6
- region lookup.
6
+ region lookup. (Two opt-in, database-only companion signals — ASN and
7
+ anonymizer classification — are covered in [enrichment](enrichment.md).)
7
8
 
8
9
  ## Resolution order
9
10
 
@@ -11,8 +11,8 @@ class Otto
11
11
  # Error handling module providing secure error reporting and logging functionality
12
12
  module ErrorHandler
13
13
  def handle_error(error, env)
14
- # Check if this is a registered expected error
15
- if handler_config = @error_handlers[error.class.name]
14
+ # Check if this is a registered expected error (exact match first, then ancestors)
15
+ if handler_config = find_error_handler(error)
16
16
  return handle_expected_error(error, env, handler_config)
17
17
  end
18
18
 
@@ -116,6 +116,19 @@ class Otto
116
116
 
117
117
  private
118
118
 
119
+ # Find the best matching error handler by walking the exception's ancestor chain.
120
+ # Returns the handler config for the most specific registered class, or nil.
121
+ def find_error_handler(error)
122
+ error.class.ancestors.each do |klass|
123
+ next unless klass.is_a?(Class)
124
+
125
+ if handler_config = @error_handlers[klass.name]
126
+ return handler_config
127
+ end
128
+ end
129
+ nil
130
+ end
131
+
119
132
  # Register all Otto framework error classes with appropriate status codes
120
133
  #
121
134
  # This method auto-registers base HTTP error classes and all framework-specific
data/lib/otto/env_keys.rb CHANGED
@@ -193,6 +193,30 @@ class Otto
193
193
  # Used by: Analytics, localization
194
194
  GEO_COUNTRY = 'otto.privacy.geo_country'
195
195
 
196
+ # Autonomous System Number of the network the client belongs to
197
+ # Type: String ('AS15169'), '**' when unresolved, or nil when disabled
198
+ # Set by: IPPrivacyMiddleware (opt-in; off unless asn_enabled)
199
+ # Used by: Network-operator allow/deny rules, dynamic access zones
200
+ # Read via: Otto::Request#asn
201
+ # Note: resolved from a local database against the MASKED IP, so it is
202
+ # subject to the same /24 equivalence country relies on.
203
+ ASN = 'otto.privacy.asn'
204
+
205
+ # Anonymizing-egress classification for the client address
206
+ # Type: String label, or nil when disabled. One of: 'tor', 'proxy',
207
+ # 'vpn', 'residential_proxy', 'hosting', 'anonymous', 'none', '**'
208
+ # Set by: IPPrivacyMiddleware (opt-in; off unless anonymizer_enabled)
209
+ # Used by: "Block anonymizers" access zones, abuse triage
210
+ # Read via: Otto::Request#anonymizer
211
+ # Note: 'none' means the database was consulted and did not list the
212
+ # address — a real answer, since these databases record only flagged
213
+ # addresses. '**' means no database answered at all. The two are not
214
+ # interchangeable: 'none' is evidence, '**' is the absence of it.
215
+ # Contrast: this is the one lookup performed on the UNMASKED address,
216
+ # because anonymizer data is per-node (~/32) and a masked lookup would
217
+ # answer for the node's neighbours. Only the label is retained.
218
+ ANONYMIZER = 'otto.privacy.anonymizer'
219
+
196
220
  # Daily-rotating IP hash for session correlation
197
221
  # Type: String (hexadecimal)
198
222
  # Set by: IPPrivacyMiddleware
@@ -0,0 +1,138 @@
1
+ # lib/otto/privacy/anonymizer_resolver.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Privacy
7
+ # Anonymizer (Tor / VPN / proxy / hosting) classification for IP addresses
8
+ #
9
+ # Answers one question — "is this address a known anonymizing egress, and
10
+ # of what kind?" — as a single label a downstream allow/deny rule can
11
+ # compare directly. Database-only, and the ONLY thing that ever leaves
12
+ # this resolver is the label.
13
+ #
14
+ # ## Why this one reads the UNMASKED address
15
+ #
16
+ # Every other database lookup in Otto operates on the masked IP, because
17
+ # country and ASN networks are >= /24 and a masked address lands in the
18
+ # same network as the real one. Anonymizer data breaks that equivalence:
19
+ # providers list individual egress nodes at or near /32, so a /24-masked
20
+ # lookup answers a question about the node's *neighbours* rather than the
21
+ # node. That produces wrong verdicts in both directions — flagging a whole
22
+ # /24 because one host in it is an exit node, and missing the exit node
23
+ # itself. A signal that quietly lies is worse than no signal.
24
+ #
25
+ # So this resolver takes the real address and returns only a label, which
26
+ # is the same trade Otto already makes twice: {IPPrivacy.hash_ip} consumes
27
+ # the full IP to emit an opaque digest, and +env['otto.ip_match']+ closes
28
+ # over the full IP to emit a boolean. The invariant Otto defends is that a
29
+ # raw address is never persisted, serialized, or handed downstream — not
30
+ # that it is unreachable in-process. A label honours that invariant; a
31
+ # masked lookup here would honour the letter of it while breaking the
32
+ # feature.
33
+ #
34
+ # ## Reading the 'none' label
35
+ #
36
+ # 'none' means "the database was consulted and did not list this address".
37
+ # Anonymizer databases are allow-list-by-omission — an address absent from
38
+ # the file is simply not a known egress — so absence is a real answer, not
39
+ # a miss. It is NOT a positive assertion that the address is a residential
40
+ # user, and it is only as fresh as the database file. '**' is reserved for
41
+ # "no database, or the lookup failed" — genuinely no answer.
42
+ #
43
+ # @example Configuring
44
+ # otto.configure_ip_privacy(
45
+ # anonymizer: true,
46
+ # anonymizer_db_path: 'data/GeoIP2-Anonymous-IP.mmdb',
47
+ # )
48
+ #
49
+ # @example Reading
50
+ # req.anonymizer # => 'tor' | 'vpn' | 'none' | '**' | nil
51
+ # env['otto.privacy.anonymizer']
52
+ #
53
+ class AnonymizerResolver
54
+ # Returned when classification is enabled but no database answered.
55
+ UNKNOWN = '**'
56
+
57
+ # Returned when the database was consulted and did not list the address.
58
+ NONE = 'none'
59
+
60
+ # Database flag => label, in precedence order. An address can carry
61
+ # several flags at once (a Tor exit node hosted at a cloud provider sets
62
+ # both +is_tor_exit_node+ and +is_hosting_provider+); the FIRST match
63
+ # wins, so the most specific and most access-relevant classification is
64
+ # what surfaces. Ordering rationale: Tor and public proxies are
65
+ # deliberate anonymity, commercial VPNs next, then residential proxies
66
+ # (frequently abuse infrastructure), then hosting — which is merely "not
67
+ # an eyeball network" and the weakest signal of the set.
68
+ CLASSIFICATIONS = [
69
+ %w[is_tor_exit_node tor],
70
+ %w[is_public_proxy proxy],
71
+ %w[is_anonymous_vpn vpn],
72
+ %w[is_residential_proxy residential_proxy],
73
+ %w[is_hosting_provider hosting],
74
+ # Generic catch-all: the provider says "anonymous" without saying how.
75
+ # Last, so it never masks a specific classification above.
76
+ %w[is_anonymous anonymous],
77
+ ].freeze
78
+
79
+ # Every label this resolver can emit, for consumers building zone rules.
80
+ LABELS = (CLASSIFICATIONS.map(&:last) + [NONE, UNKNOWN]).freeze
81
+
82
+ class << self
83
+ # Classify an IP address.
84
+ #
85
+ # @param ip [String] the UNMASKED client IP (see the class docs)
86
+ # @param config [Otto::Privacy::Config, nil] privacy configuration
87
+ # @return [String] one of {LABELS}
88
+ def resolve(ip, config = nil)
89
+ return UNKNOWN if ip.nil? || ip.empty?
90
+
91
+ check_anonymizer_database(ip, config) || UNKNOWN
92
+ end
93
+
94
+ private
95
+
96
+ # Look the address up in the configured anonymizer database.
97
+ #
98
+ # No masking: see the class documentation for why this resolver is the
99
+ # one exception, and note the record is reduced to a label before it
100
+ # returns, so the address itself goes no further.
101
+ #
102
+ # @return [String, nil] a label, or nil to fall through to UNKNOWN
103
+ def check_anonymizer_database(ip, config)
104
+ reader = config&.anonymizer_db_reader
105
+ return nil unless reader
106
+
107
+ classify(reader.get(ip))
108
+ rescue StandardError => e
109
+ warn "AnonymizerResolver database lookup error: #{e.message}" if $DEBUG
110
+ nil
111
+ end
112
+
113
+ # Reduce a database record to a single label.
114
+ #
115
+ # A nil record means "not listed", which for an anonymizer database is
116
+ # the meaningful answer NONE rather than a miss — these files record
117
+ # only flagged addresses. A non-Hash record is a malformed answer and
118
+ # falls through to UNKNOWN.
119
+ #
120
+ # @return [String, nil]
121
+ def classify(record)
122
+ return NONE if record.nil?
123
+ return nil unless record.is_a?(Hash)
124
+
125
+ match = CLASSIFICATIONS.find { |flag, _label| flagged?(record[flag]) }
126
+ match ? match.last : NONE
127
+ end
128
+
129
+ # MaxMind omits these keys entirely when false, so absent and false are
130
+ # the same answer. Accept the string forms too, since MMDB builds from
131
+ # other vendors sometimes encode the flags as text.
132
+ def flagged?(value)
133
+ value == true || value.to_s == 'true' || value.to_s == '1'
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,149 @@
1
+ # lib/otto/privacy/asn_resolver.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Privacy
7
+ # Autonomous System Number (ASN) resolution for IP addresses
8
+ #
9
+ # Provides the network operator an address belongs to, as a privacy-safe
10
+ # label a downstream allow/deny rule can compare directly. Resolution is
11
+ # database-only and operates on Otto's already-MASKED IP, exactly as
12
+ # {GeoResolver}'s database fallback does — the unmasked address never
13
+ # reaches this resolver.
14
+ #
15
+ # Resolution order (first hit wins), when a privacy Config is supplied:
16
+ # 1. Local MMDB lookup, masked before lookup (Config#asn_db_reader)
17
+ # 2. '**' (unknown)
18
+ #
19
+ # Resolution is honest: when no database resolves an ASN, the answer is
20
+ # '**' — never a guess. A caller can therefore distinguish three states:
21
+ # nil (ASN resolution is switched off), '**' (on, but no answer), and a
22
+ # real label.
23
+ #
24
+ # ## Why database-only
25
+ #
26
+ # Unlike country, no CDN publishes a client-ASN header with meaningful
27
+ # deployment, so there is no header tier to trust. Staying database-only
28
+ # also keeps ASN clear of the geo_header/trusted_proxy_depth boot conflict
29
+ # ({Otto::Security::Config::GEO_HEADER_DEPTH_CONFLICT_MESSAGE}): there is
30
+ # no header to be silently ignored under count-based proxy trust.
31
+ #
32
+ # ## Masking and accuracy
33
+ #
34
+ # IPv4 BGP routes are not announced longer than /24, so a /24-masked
35
+ # address lands in the same announced prefix — and therefore the same
36
+ # ASN — as the real one. That equivalence is what makes a masked lookup
37
+ # honest here, and it is weaker than it looks for IPv6: at
38
+ # +octet_precision: 1+ Otto zeroes the last 80 bits (a /48), which is
39
+ # coarser than many IPv6 announcements. Treat IPv6 ASN as best-effort.
40
+ #
41
+ # @example Configuring
42
+ # otto.configure_ip_privacy(asn: true, asn_db_path: 'data/GeoLite2-ASN.mmdb')
43
+ #
44
+ # @example Reading
45
+ # req.asn # => 'AS15169' | '**' | nil
46
+ # env['otto.privacy.asn']
47
+ #
48
+ class AsnResolver
49
+ # Returned when ASN resolution is enabled but nothing resolved. Shared
50
+ # spelling with {GeoResolver::UNKNOWN} so consumers can treat every
51
+ # privacy label the same way.
52
+ UNKNOWN = '**'
53
+
54
+ # Reserved ASNs that carry no operator meaning: 0 is "reserved by the
55
+ # IANA" (RFC 7607) and 23456 is the AS_TRANS placeholder a 2-byte-only
56
+ # speaker substitutes for a 4-byte ASN (RFC 6793). A database that
57
+ # returns either has told us nothing, so both resolve to UNKNOWN rather
58
+ # than being dressed up as an answer.
59
+ RESERVED = [0, 23_456].freeze
60
+
61
+ # Highest assignable ASN; 4_294_967_295 is reserved (RFC 7300).
62
+ MAX_ASN = 4_294_967_294
63
+
64
+ class << self
65
+ # Resolve an ASN label for an IP address.
66
+ #
67
+ # @param ip [String] the ALREADY-MASKED client IP
68
+ # @param config [Otto::Privacy::Config, nil] privacy configuration
69
+ # @return [String] 'AS<number>' or {UNKNOWN}
70
+ def resolve(ip, config = nil)
71
+ return UNKNOWN if ip.nil? || ip.empty?
72
+
73
+ check_asn_database(ip, config) || UNKNOWN
74
+ end
75
+
76
+ private
77
+
78
+ # Look the masked IP up in the configured ASN database.
79
+ #
80
+ # The reader is any object responding to +#get(ip)+. A database read
81
+ # must never crash a request, so every StandardError falls through to
82
+ # "unknown" — the same posture {GeoResolver.check_geo_database} takes.
83
+ #
84
+ # @return [String, nil] 'AS<number>', or nil to fall through
85
+ def check_asn_database(ip, config)
86
+ reader = config&.asn_db_reader
87
+ return nil unless reader
88
+
89
+ # Re-mask defensively: masking is idempotent, so this costs nothing
90
+ # for an already-masked address and closes the hole if a caller ever
91
+ # hands us a raw one directly.
92
+ lookup_ip = IPPrivacy.mask_ip(ip, config.octet_precision) || ip
93
+ format_asn(extract_db_asn(reader.get(lookup_ip)))
94
+ rescue StandardError => e
95
+ warn "AsnResolver database lookup error: #{e.message}" if $DEBUG
96
+ nil
97
+ end
98
+
99
+ # Pull the AS number out of a database record.
100
+ #
101
+ # MaxMind's GeoLite2-ASN stores a flat +autonomous_system_number+ as a
102
+ # uint32. Some combined builds nest it under an 'asn' map, and a few
103
+ # emit the bare key 'asn'. Accept all three; anything else is not an
104
+ # answer.
105
+ #
106
+ # NOTE: the value is an Integer, not a String — the opposite of
107
+ # {GeoResolver.extract_db_country}'s terminal guard. Copying that
108
+ # method's String check here would discard every real hit.
109
+ #
110
+ # @return [Integer, nil]
111
+ def extract_db_asn(result)
112
+ return nil unless result.is_a?(Hash)
113
+
114
+ asn = result['asn']
115
+ number =
116
+ if asn.is_a?(Hash)
117
+ asn['autonomous_system_number'] || asn['number']
118
+ else
119
+ result['autonomous_system_number'] || asn
120
+ end
121
+ valid_asn?(number) ? number : nil
122
+ end
123
+
124
+ # @return [Boolean] whether the number is an assignable, meaningful ASN
125
+ def valid_asn?(number)
126
+ number.is_a?(Integer) &&
127
+ number.positive? &&
128
+ number <= MAX_ASN &&
129
+ !RESERVED.include?(number)
130
+ end
131
+
132
+ # Render as the conventional 'AS<number>' text form.
133
+ #
134
+ # A String — never the bare Integer — because the whole resolution
135
+ # chain, and Otto::Request's env fallbacks, treat a falsey value as
136
+ # "keep looking". A label is also what a downstream zone rule compares
137
+ # against, and it leaves room for the '**' sentinel that an Integer
138
+ # representation has no way to express.
139
+ #
140
+ # @return [String, nil]
141
+ def format_asn(number)
142
+ return nil if number.nil?
143
+
144
+ "AS#{number}"
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end