diff options
| author | hrbrmstr <bob@rud.is> | 2026-02-26 01:49:26 -0500 |
|---|---|---|
| committer | hrbrmstr <bob@rud.is> | 2026-02-26 01:49:26 -0500 |
| commit | 4ca8949a8f2eeb232da52593350e5691ca455bf2 (patch) | |
| tree | 069e5827e875d1cf48495f3965aa18778822272b /2026/2026-02-25-ipasn-net/SPEC.md | |
| parent | e95c6b116c453f9dabd78220d6a80fc08bdd72c2 (diff) | |
add: ipsan docs
Diffstat (limited to '2026/2026-02-25-ipasn-net/SPEC.md')
| -rw-r--r-- | 2026/2026-02-25-ipasn-net/SPEC.md | 485 |
1 files changed, 485 insertions, 0 deletions
diff --git a/2026/2026-02-25-ipasn-net/SPEC.md b/2026/2026-02-25-ipasn-net/SPEC.md new file mode 100644 index 0000000..ee4f0e9 --- /dev/null +++ b/2026/2026-02-25-ipasn-net/SPEC.md @@ -0,0 +1,485 @@ +# ipasn.net Client — Implementation Specification + +Version 0.1.0 — 2026-02-26 + +This document specifies the behavior of a client library and companion CLI +for the `ipasn.net` DNS-based IP enrichment service. It is deliberately +language-agnostic. Implementations should follow the contracts described +here and adapt naming conventions to the target language's idioms (e.g., +`snake_case` in Go/Rust, `camelCase` in TypeScript, etc.). + +--- + +## 1. Upstream Service + +`ipasn.net` is a DNS authoritative server (PowerDNS + custom backend) that +accepts TXT record queries and returns IP address metadata assembled from +BGP routing tables, geolocation data, RIR statistics, and RPKI/ROA +validation state. + +Reference: https://blog.apnic.net/2026/02/09/from-the-stupid-dns-tricks-department-ipasn-net/ + +--- + +## 2. Canonical Data Model + +Every enrichment result is represented as a single structured record. The +library normalizes all upstream response formats (pipe-delimited, JSON, +single-attribute) into this canonical shape. Field names below are logical; +implementations should use idiomatic casing. + +``` +IpasnRecord { + address : string // queried or resolved IP address + address_family : "IPv4" | "IPv6" + bgp_status : string // e.g. "ADVERTISED" + advertised_prefix: string // CIDR notation + origin_asn : integer // autonomous system number (numeric only, no "AS" prefix) + org_name : string // organization name, underscores replaced with spaces + country_code : string // ISO 3166-1 alpha-2 + country_name : string // full name, underscores replaced with spaces + rir : string // e.g. "arin", "apnic", "ripe", "afrinic", "lacnic" + rir_prefix : string // CIDR notation as registered + reg_status : string // e.g. "assigned", "allocated" + reg_date : string // ISO 8601 date (YYYY-MM-DD) + rov_status : string // e.g. "VLD", or empty/null if no ROA + roa_prefix : string | null // CIDR notation of the covering ROA + roa_max_length : integer | null + roa_origin_asn : integer | null + roa_trust_anchor : string | null // e.g. "ARIN", "APNIC_RPKI_Root" +} +``` + +### 2.1 Normalization Rules + +- `origin_asn`, `roa_origin_asn`, `roa_max_length`: parse to integer. Strip any leading "AS" if present. +- `org_name`, `country_name`, `roa_trust_anchor`: replace all `_` with space. +- If RPKI/ROA fields are absent or empty in the upstream response, set the four `roa_*` fields to null and `rov_status` to an empty string or null per the language's convention. +- If a compact/cymru-compat response is returned, the fields not present in that format (`org_name`, `country_name`, `bgp_status`, `reg_status`, `rov_status`, `roa_*`) are null. + +--- + +## 3. Library API + +The library exposes a stateless client that performs DNS TXT lookups and +returns `IpasnRecord` values. All public functions return structured +results; raw DNS string handling is internal. + +### 3.1 Constructor / Configuration + +``` +IpasnClient { + dns_server : string // resolver address; default = system resolver + timeout : duration // per-query timeout; default = 5 seconds + max_retries : integer // retry count on transient DNS failure; default = 1 +} +``` + +Implementations that use the system stub resolver by default are fine. An +explicit resolver override allows callers to bypass local caching or use a +specific recursive resolver (e.g., `9.9.9.9`). + +### 3.2 Core Methods + +#### `lookup(target: string, options?: LookupOptions) -> IpasnRecord` + +Primary entry point. Accepts an IPv4 address, IPv6 address, or FQDN. + +``` +LookupOptions { + mode : "full" | "json" | "compact" // default: "json" + force : "ipv4" | "ipv6" | null // only meaningful when target is an FQDN; default: null +} +``` + +Behavior: + +1. Classify `target` as IPv4, IPv6, or hostname. +2. Build the DNS query name per the table in section 4. +3. Perform a TXT record lookup. +4. Parse the response into `IpasnRecord` using the appropriate parser (section 5). +5. Return the record or raise/return an error. + +When `target` is a hostname and `force` is null, the upstream service +resolves the name itself (preferring AAAA). When `force` is `"ipv4"`, the +`a.dns.` prefix is inserted so the upstream resolves to an A record. + +#### `lookup_attribute(ip: string, attribute: string) -> string` + +Query a single attribute. `ip` must be an IPv4 or IPv6 address (not a +hostname). `attribute` is inserted as a subdomain label (e.g., `"cc"`, +`"rpki"`). + +Returns the raw TXT string value. The caller is responsible for +interpretation. This method exists for forward-compatibility with +undocumented attributes. + +Known attributes: `cc`, `rpki`. The attribute namespace is not fully +documented upstream and may expand. + +#### `lookup_batch(targets: []string, options?: LookupOptions) -> []Result<IpasnRecord, Error>` + +Batch variant. Performs lookups concurrently (respecting a configurable +concurrency limit, default 10). Returns results in input order. Each +element is independently either a record or an error. + +``` +BatchOptions extends LookupOptions { + concurrency : integer // max parallel DNS queries; default: 10 +} +``` + +### 3.3 Error Types + +``` +IpasnError + ├── DnsError // DNS transport failure (SERVFAIL, timeout, network) + ├── NxdomainError // NXDOMAIN — address/name not found upstream + ├── ParseError // response received but could not be parsed + └── InvalidInputError // target is not a valid IP address or hostname +``` + +Implementations should carry the original DNS response code or transport +error as context. + +--- + +## 4. DNS Query Construction + +Given a classified target and mode, build the QNAME as follows. + +### 4.1 IP Address Targets + +| Mode | IPv4 QNAME | IPv6 QNAME | +|---------|-------------------------------------|--------------------------------------| +| full | `{ip}.ipasn.net` | `{ip}.ipasn.net` | +| json | `{ip}.json.ipasn.net` | `{ip}.json.ipasn.net` | +| compact | `{ip}.origin.asn.ipasn.net` | `{ip}.origin6.asn.ipasn.net` | + +Note: compact mode uses `origin.` for IPv4 and `origin6.` for IPv6. + +### 4.2 Hostname Targets + +| Force | QNAME | +|-------|-------------------------------------| +| null | `{fqdn}.dns.ipasn.net` | +| ipv4 | `{fqdn}.a.dns.ipasn.net` | + +Hostname targets always return full/pipe-delimited detail regardless of `mode`. The library should parse accordingly and ignore the `mode` parameter for hostname targets (or document this behavior). + +### 4.3 Attribute Targets + +``` +{ip}.{attribute}.ipasn.net +``` + +IPv4 and IPv6 only. Not available for hostnames. + +### 4.4 Input Validation + +Before constructing the QNAME: + +- IPv4: validate dotted-quad, 0-255 per octet, 1-4 octets. +- IPv6: validate per RFC 5952. Accept compressed forms (e.g., `2001:db8::1`). +- Hostname: validate as a syntactically valid FQDN. Do not resolve locally — the upstream backend handles resolution. +- Reject inputs containing whitespace, null bytes, or characters outside the DNS label-safe set after classification. + +--- + +## 5. Response Parsing + +### 5.1 TXT Record Reassembly + +DNS TXT records may be split across multiple character strings (each <= 255 +bytes). Concatenate all strings in order before parsing. Strip surrounding +double-quote characters if present (artifact of `dig` output format; raw +DNS libraries typically return bare strings). + +### 5.2 JSON Mode Parser + +Concatenate TXT strings, then JSON-decode into a map. Map upstream JSON +keys to `IpasnRecord` fields: + +| Upstream Key | Record Field | +|-------------------|-------------------| +| `Address` | `address` | +| `Class` | `address_family` | +| `BGP` | `bgp_status` | +| `Advertised_Prefix` | `advertised_prefix` | +| `Origin_AS` | `origin_asn` | +| `Org_Name` | `org_name` | +| `CC` | `country_code` | +| `CC_Name` | `country_name` | +| `RIR` | `rir` | +| `RIR_Prefix` | `rir_prefix` | +| `Reg_Status` | `reg_status` | +| `Reg_Date` | `reg_date` | +| `ROV` | `rov_status` | +| `ROA_Prefix` | `roa_prefix` | +| `ROA_Maxlen` | `roa_max_length` | +| `ROA_AS` | `roa_origin_asn` | +| `ROA_TAL` | `roa_trust_anchor` | + +Apply normalization rules from section 2.1 after mapping. + +### 5.3 Full (Pipe-Delimited) Parser + +Split the concatenated TXT string on `|`. Fields are positional (17 fields, +0-indexed): + +``` +0: address +1: address_family +2: bgp_status +3: advertised_prefix +4: origin_asn +5: org_name +6: country_code +7: country_name +8: rir +9: rir_prefix +10: reg_status +11: reg_date +12: rov_status +13: roa_prefix +14: roa_max_length +15: roa_origin_asn +16: roa_trust_anchor +``` + +Trim whitespace from each field after splitting. Apply normalization rules. + +If fewer than 17 fields are present, set missing fields to null and do not +error. This provides forward-compatibility if the upstream format changes. + +### 5.4 Compact (Cymru-Compatible) Parser + +Split on ` | ` (pipe with surrounding spaces). Fields are positional (5 +fields, 0-indexed): + +``` +0: advertised_prefix (CIDR) +1: origin_asn +2: country_code +3: rir +4: reg_date +``` + +All other `IpasnRecord` fields are null. Derive `address_family` from the +prefix (contains `:` = IPv6, otherwise IPv4). Set `address` from the +queried input. + +### 5.5 Attribute Parser + +Return the raw TXT string. No structured parsing. The `rpki` attribute +returns a compound string (e.g., `VLD_216.88.0.0/14-24_3561_ARIN`) which +the caller may decompose if needed; the library does not parse it further. + +--- + +## 6. CLI Specification + +The CLI is a thin wrapper around the library. It reads targets from +arguments or stdin, performs lookups, and writes JSON to stdout. + +### 6.1 Name + +`ipasn` + +### 6.2 Usage + +``` +ipasn [options] <target> [target ...] +cat ips.txt | ipasn [options] --stdin +``` + +### 6.3 Options + +| Flag | Short | Default | Description | +|-----------------|-------|----------|----------------------------------------------| +| `--mode` | `-m` | `json` | Query mode: `full`, `json`, `compact` | +| `--force` | `-f` | (none) | Force resolution: `ipv4` or `ipv6` | +| `--attribute` | `-a` | (none) | Query single attribute (e.g., `cc`, `rpki`) | +| `--dns-server` | `-d` | system | DNS resolver address | +| `--timeout` | `-t` | `5s` | Per-query timeout | +| `--concurrency` | `-c` | `10` | Max parallel queries | +| `--stdin` | `-` | false | Read targets from stdin (one per line) | +| `--compact` | | false | Output NDJSON (one object per line) | +| `--raw` | | false | Output raw TXT response, no parsing | +| `--quiet` | `-q` | false | Suppress errors to stderr | +| `--version` | `-V` | | Print version and exit | +| `--help` | `-h` | | Print help and exit | + +### 6.4 Output Formats + +#### Single target (default) + +Outputs a single JSON object to stdout: + +```json +{ + "address": "216.88.0.0", + "address_family": "IPv4", + "bgp_status": "ADVERTISED", + "advertised_prefix": "216.88.0.0/14", + "origin_asn": 3561, + "org_name": "CenturyLink Communications, LLC", + "country_code": "US", + "country_name": "United States of America", + "rir": "arin", + "rir_prefix": "216.88.0.0/14", + "reg_status": "assigned", + "reg_date": "1998-09-25", + "rov_status": "VLD", + "roa_prefix": "216.88.0.0/14", + "roa_max_length": 24, + "roa_origin_asn": 3561, + "roa_trust_anchor": "ARIN" +} +``` + +#### Multiple targets (default) + +Outputs a JSON array of objects: + +```json +[ + { "address": "216.88.0.0", ... }, + { "address": "2001:4860::", ... } +] +``` + +#### Multiple targets with `--compact` + +Outputs NDJSON (newline-delimited JSON), one object per line: + +``` +{"address":"216.88.0.0",...} +{"address":"2001:4860::",...} +``` + +#### `--attribute` mode + +Outputs a JSON object with the target as key and the raw attribute value: + +```json +{ "target": "216.88.0.0", "attribute": "cc", "value": "US" } +``` + +#### `--raw` mode + +Outputs the raw DNS TXT string, one per line, no JSON wrapping: + +``` +216.88.0.0|IPv4|ADVERTISED|216.88.0.0/14|3561|... +``` + +### 6.5 Error Output + +Errors are written to stderr as JSON: + +```json +{ "error": "dns_error", "target": "999.999.999.999", "message": "NXDOMAIN" } +``` + +When processing multiple targets, errors for individual targets do not halt +execution. The corresponding position in the output array contains an error +object: + +```json +{ "address": null, "error": "NXDOMAIN", "target": "999.999.999.999" } +``` + +### 6.6 Exit Codes + +| Code | Meaning | +|------|----------------------------------------------| +| 0 | All lookups succeeded | +| 1 | One or more lookups failed (partial results) | +| 2 | Invalid arguments / usage error | +| 3 | Fatal error (no DNS connectivity, etc.) | + +### 6.7 Stdin Processing + +When `--stdin` is specified, the CLI reads lines from stdin. Blank lines +and lines starting with `#` are skipped. Leading/trailing whitespace is +trimmed. This allows feeding output from other tools or newline-separated +IP lists directly. + +--- + +## 7. Testing + +### 7.1 Unit Tests + +Implementations must include unit tests for: + +- Input classification (IPv4 vs IPv6 vs hostname). +- QNAME construction for every mode/force/attribute combination. +- Parsing of all three response formats (JSON, pipe-delimited, compact) using static fixture strings. +- Normalization: underscore replacement, integer coercion, null handling for missing fields. +- Error cases: malformed responses, empty TXT records, fewer-than-expected fields. + +### 7.2 Integration Tests + +Integration tests issue real DNS queries against `ipasn.net`. They should +be gated behind an opt-in flag or environment variable (`IPASN_LIVE_TESTS=1`) +since they depend on network access and upstream availability. + +Suggested live test targets: + +| Target | Expected Assertions | +|--------------------|----------------------------------------------| +| `216.88.0.0` | origin_asn is integer, country_code = "US" | +| `2001:4860::` | address_family = "IPv6" | +| `www.google.com` | address is populated, origin_asn > 0 | +| `192.0.2.1` | graceful error (TEST-NET, may not be routed) | + +### 7.3 CLI Tests + +Test the CLI as a subprocess: + +- Single IP, verify valid JSON on stdout. +- Multiple IPs, verify JSON array length matches input count. +- `--compact` flag, verify NDJSON (one valid JSON object per line). +- `--stdin` with piped input. +- `--attribute cc`, verify `value` field present. +- `--raw`, verify no JSON wrapping. +- Invalid input, verify exit code 2. + +--- + +## 8. Implementation Notes + +### 8.1 DNS Library Selection + +Use a DNS library that provides access to raw TXT record data (not just +the first string). The library must support querying specific nameservers +and setting timeouts. Avoid shelling out to `dig`. + +### 8.2 Concurrency + +Batch operations should use a bounded worker pool or semaphore pattern. +DNS queries are I/O-bound and benefit from concurrency, but unbounded +parallelism can trigger rate limiting or resolver overload. + +### 8.3 Caching + +The library should NOT implement its own DNS cache. The system resolver +or configured recursive resolver already caches based on TTL. Adding +another cache layer creates staleness bugs and complicates the +implementation for no measurable benefit. + +### 8.4 Rate Limiting + +The upstream service has no documented rate limits, but it is a community +resource. Implementations should default to reasonable concurrency (10) and +provide a mechanism for callers to throttle further if needed. + +### 8.5 Underscore Handling + +The upstream service uses underscores as space substitutes in `Org_Name`, +`CC_Name`, and `ROA_TAL` fields. The library normalizes these to spaces in +the canonical record. The CLI outputs the normalized (spaces) form. If a +caller needs the raw underscore form, they can use `lookup_attribute` or +`--raw`. |
