# 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` 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 ...] 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`.