feat(ipenrich): offline IP ASN + VPN enrichment crate (BUNYIP-437) #34

Merged
longjacksonle merged 1 commit from feat/BUNYIP-437-ipenrich-crate into main 2026-08-04 20:12:54 +02:00

Adds dunite-ipenrich, the ASN + VPN enrichment counterpart to dunite-geoip, as slice 1 of BUNYIP-437 (ASN and VPN enrichment of client IPs as a shared service). This is the standalone shared crate; the bunyip consumer that surfaces the signal to admins is a separate follow-up PR that bumps the pinned rev.

What it does

IpEnrichService::new(path) opens an IP2Proxy PX .BIN and enrich(ip) resolves an address to an IpEnrichment:

  • asn + organization (owning AS) + isp + provider + threat: the raw dataset strings, trimmed, with IP2Proxy's "-" placeholder dropped to None.
  • category: NetworkCategory - residential / commercial / hosting / unknown, classified from the usage_type code.
  • vpn: VpnLikelihood - not-a-proxy / VPN / Tor / public / web / residential proxy / data centre / other / unknown, classified from is_proxy + proxy_type. This is the distinct advisory VPN-likelihood field the ticket asks for.

Why IP2Proxy (not IPinfo)

The ticket named IPinfo, but IPinfo's ASN + privacy dataset needs a paid token and a new dependency and is not offline-free. The ip2location crate already in this workspace reads IP2Proxy PX .BIN databases (the Record::ProxyDb branch dunite-geoip currently discards), one lookup gives every field above, and IP2Proxy has a free LITE tier with monthly refresh. So this mirrors dunite-geoip exactly rather than introducing a new data path. Decision confirmed with the team lead on this pass.

Design (mirrors dunite-geoip)

  • Offline: no per-request external call, no client IP sent to a third party.
  • Framework-agnostic: bunyip (actix) and mokosh (axum) share one implementation, satisfying the "consumable by at least two products without duplicating ingestion" AC.
  • Owns its IpEnrichError; no dunite-core dependency, so it stays a leaf (the DEV-515 version-cascade rule).
  • Reads no config, no env var, and never refreshes the dataset: the consumer owns the path and keeps the file fresh.
  • is_non_public_ip filters private / reserved addresses before any lookup; pointing the service at a plain IP2Location file opens fine but returns None for every lookup, so a misconfiguration degrades to "no signal" rather than a crash.

Advisory, never a verdict

VpnLikelihood describes an address; it never decides a request is abuse. The ticket was explicit that a legitimate user behind a VPN must not be auto-classified as spam, so the type stays descriptive and every decision belongs to the consumer.

Tests

The residential / cloud-provider / VPN classification mappings are pure (string in, enum out) and unit-tested here (classify_category, classify_vpn, from_proxy_record, plus the is_non_public_ip filter): 12 tests. Opening a real .BIN is the consumer's integration test, as dunite-geoip leaves its .BIN lookup to the consuming app (no fixture .BIN is shipped).

Verification

  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace all pass in the pinned rust-builder-glibc:v1.0.1-rust1.94-trixie image. No new workspace dependency (reuses ip2location), no members-list edit (workspace globs crates/*).

🤖 Generated with Claude Code

https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9

Adds `dunite-ipenrich`, the ASN + VPN enrichment counterpart to `dunite-geoip`, as slice 1 of [BUNYIP-437](https://niceguyit.myjetbrains.com/youtrack/issue/BUNYIP-437) (ASN and VPN enrichment of client IPs as a shared service). This is the standalone shared crate; the bunyip consumer that surfaces the signal to admins is a separate follow-up PR that bumps the pinned rev. ## What it does `IpEnrichService::new(path)` opens an IP2Proxy PX `.BIN` and `enrich(ip)` resolves an address to an `IpEnrichment`: - `asn` + `organization` (owning AS) + `isp` + `provider` + `threat`: the raw dataset strings, trimmed, with IP2Proxy's `"-"` placeholder dropped to `None`. - `category: NetworkCategory` - residential / commercial / hosting / unknown, classified from the `usage_type` code. - `vpn: VpnLikelihood` - not-a-proxy / VPN / Tor / public / web / residential proxy / data centre / other / unknown, classified from `is_proxy` + `proxy_type`. This is the distinct advisory VPN-likelihood field the ticket asks for. ## Why IP2Proxy (not IPinfo) The ticket named IPinfo, but IPinfo's ASN + privacy dataset needs a paid token and a new dependency and is not offline-free. The `ip2location` crate already in this workspace reads IP2Proxy PX `.BIN` databases (the `Record::ProxyDb` branch `dunite-geoip` currently discards), one lookup gives every field above, and IP2Proxy has a free LITE tier with monthly refresh. So this mirrors `dunite-geoip` exactly rather than introducing a new data path. Decision confirmed with the team lead on this pass. ## Design (mirrors dunite-geoip) - Offline: no per-request external call, no client IP sent to a third party. - Framework-agnostic: bunyip (actix) and mokosh (axum) share one implementation, satisfying the "consumable by at least two products without duplicating ingestion" AC. - Owns its `IpEnrichError`; no `dunite-core` dependency, so it stays a leaf (the DEV-515 version-cascade rule). - Reads no config, no env var, and never refreshes the dataset: the consumer owns the path and keeps the file fresh. - `is_non_public_ip` filters private / reserved addresses before any lookup; pointing the service at a plain IP2Location file opens fine but returns `None` for every lookup, so a misconfiguration degrades to "no signal" rather than a crash. ## Advisory, never a verdict `VpnLikelihood` describes an address; it never decides a request is abuse. The ticket was explicit that a legitimate user behind a VPN must not be auto-classified as spam, so the type stays descriptive and every decision belongs to the consumer. ## Tests The residential / cloud-provider / VPN classification mappings are pure (string in, enum out) and unit-tested here (`classify_category`, `classify_vpn`, `from_proxy_record`, plus the `is_non_public_ip` filter): 12 tests. Opening a real `.BIN` is the consumer's integration test, as `dunite-geoip` leaves its `.BIN` lookup to the consuming app (no fixture `.BIN` is shipped). ## Verification - `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace` all pass in the pinned `rust-builder-glibc:v1.0.1-rust1.94-trixie` image. No new workspace dependency (reuses `ip2location`), no members-list edit (workspace globs `crates/*`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9
feat(ipenrich): offline IP ASN + VPN enrichment crate (BUNYIP-437)
All checks were successful
Check / fmt + clippy + test (pull_request) Successful in 17s
create-release / create-release (pull_request) Has been skipped
61de8e52ec
Adds dunite-ipenrich, the ASN / VPN enrichment counterpart to dunite-geoip. Where geoip answers "what country is this IP in" against an IP2Location LITE .BIN, this crate answers "who owns this IP, what kind of network is it, and does it look like a VPN / proxy / data centre" against an IP2Proxy PX .BIN, read through the same ip2location crate already in the workspace (the ProxyDb branch geoip discards is exactly what this crate consumes).

IpEnrichService::new opens a PX .BIN and enrich(ip) resolves an address to an IpEnrichment: the raw ASN / organization / ISP / provider / threat strings plus two classified enums, NetworkCategory (residential / commercial / hosting / unknown, from the usage_type code) and VpnLikelihood (not-a-proxy / VPN / Tor / public / web / residential proxy / data centre / other / unknown, from is_proxy + proxy_type). Private and reserved addresses are filtered by is_non_public_ip before any lookup, and pointing the service at a plain IP2Location file opens fine but returns None for every lookup, so a misconfiguration degrades to "no signal" rather than a crash.

The crate mirrors the dunite-geoip contract exactly: offline (no per-request external call, no client IP sent to a third party), framework-agnostic so bunyip (actix) and mokosh (axum) share one implementation, and it owns its IpEnrichError rather than returning dunite_core::AppError, keeping it a leaf with no internal dependency (the DEV-515 version-cascade rule). It reads no config, no environment variable, and never refreshes the dataset: the consumer owns the path and keeps the file fresh.

The signal is advisory by construction. VpnLikelihood describes an address; it never decides that a request is abuse. BUNYIP-437 was explicit that a legitimate user behind a VPN must not be auto-classified as spam, so the type stays descriptive and every decision belongs to the consumer.

The residential / cloud-provider / VPN classification mappings are pure (string in, enum out) and unit-tested here; opening a real .BIN is the consumer's integration test, as geoip leaves its .BIN lookup to the consuming app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9
longjacksonle deleted branch feat/BUNYIP-437-ipenrich-crate 2026-08-04 20:12:55 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/dunite!34
No description provided.