hostinfo 0.0.2 → 2.0.0

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.
package/dist/trace.js ADDED
@@ -0,0 +1,146 @@
1
+ import childProcess from 'node:child_process';
2
+ import { isIPv4 } from 'node:net';
3
+ import { createInterface } from 'node:readline';
4
+ import { hintFromHostname } from './hints.js';
5
+ import { HostInfoError, lookup } from './lookup.js';
6
+ import { isPrivateIPv4 } from './private.js';
7
+ import { reverse } from './reverse.js';
8
+ const IPV4 = /\b(\d{1,3}(?:\.\d{1,3}){3})\b/;
9
+ const RTT = /<?(\d+(?:\.\d+)?)\s*ms\b/;
10
+ // Parses one hop line from BSD/Linux `traceroute -n` or Windows `tracert -d`:
11
+ // 3 108.254.2.1 11.268 ms
12
+ // 7 *
13
+ // 2 <1 ms <1 ms <1 ms 192.168.1.254
14
+ // 4 * * * Request timed out.
15
+ function parseHopLine(line) {
16
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
17
+ if (!match)
18
+ return null;
19
+ const rest = match[2];
20
+ const ip = IPV4.exec(rest)?.[1] ?? null;
21
+ const rtt = RTT.exec(rest)?.[1];
22
+ return {
23
+ hop: Number(match[1]),
24
+ ip: ip !== null && isIPv4(ip) ? ip : null,
25
+ rtt: ip !== null && rtt !== undefined ? Number(rtt) : null,
26
+ };
27
+ }
28
+ function tracerouteCommand(ip, maxHops, probeTimeout) {
29
+ if (process.platform === 'win32') {
30
+ return ['tracert', ['-d', '-h', String(maxHops), '-w', String(probeTimeout), ip]];
31
+ }
32
+ const seconds = String(Math.max(1, Math.ceil(probeTimeout / 1000)));
33
+ return ['traceroute', ['-n', '-q', '1', '-w', seconds, '-m', String(maxHops), ip]];
34
+ }
35
+ function runTraceroute(ip, options) {
36
+ const { maxHops, probeTimeout, giveUpAfter, timeout, signal } = options;
37
+ signal?.throwIfAborted();
38
+ return new Promise((resolve, reject) => {
39
+ const [command, args] = tracerouteCommand(ip, maxHops, probeTimeout);
40
+ const child = childProcess.spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
41
+ const hops = [];
42
+ let stopped;
43
+ let silentRun = 0;
44
+ let stderr = '';
45
+ let settled = false;
46
+ const stop = (reason) => {
47
+ stopped ??= reason;
48
+ child.kill();
49
+ };
50
+ const timer = timeout > 0 ? setTimeout(() => stop('timeout'), timeout) : undefined;
51
+ const onAbort = () => {
52
+ child.kill();
53
+ finish(() => reject(signal.reason));
54
+ };
55
+ signal?.addEventListener('abort', onAbort, { once: true });
56
+ function finish(settle) {
57
+ if (settled)
58
+ return;
59
+ settled = true;
60
+ clearTimeout(timer);
61
+ signal?.removeEventListener('abort', onAbort);
62
+ settle();
63
+ }
64
+ createInterface({ input: child.stdout }).on('line', (line) => {
65
+ const hop = parseHopLine(line);
66
+ // Ignore headers, repeated hop numbers (extra probes), and anything
67
+ // printed after we decided to stop.
68
+ if (hop === null || stopped !== undefined || hops.some((h) => h.hop === hop.hop))
69
+ return;
70
+ hops.push(hop);
71
+ if (hop.ip === ip)
72
+ return stop('reached');
73
+ silentRun = hop.ip === null ? silentRun + 1 : 0;
74
+ if (giveUpAfter > 0 && silentRun >= giveUpAfter)
75
+ stop('gave-up');
76
+ });
77
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
78
+ stderr += chunk;
79
+ });
80
+ child.on('error', (error) => {
81
+ const message = error.code === 'ENOENT'
82
+ ? `\`${command}\` was not found; install it to use trace()`
83
+ : `Could not run ${command}: ${error.message}`;
84
+ finish(() => reject(new HostInfoError(message, { cause: error })));
85
+ });
86
+ child.on('close', (code) => {
87
+ if (stopped === undefined && code !== 0 && hops.length === 0) {
88
+ const detail = stderr.trim() || `exit code ${code}`;
89
+ return finish(() => reject(new HostInfoError(`${command} failed: ${detail}`)));
90
+ }
91
+ finish(() => resolve({ hops, stopped: stopped ?? 'max-hops' }));
92
+ });
93
+ });
94
+ }
95
+ async function mapLimit(items, limit, fn) {
96
+ const results = new Array(items.length);
97
+ let next = 0;
98
+ const worker = async () => {
99
+ while (next < items.length) {
100
+ const i = next++;
101
+ results[i] = await fn(items[i]);
102
+ }
103
+ };
104
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
105
+ return results;
106
+ }
107
+ async function geolocateHop(hop, options) {
108
+ const located = { ...hop, hostname: null, info: null, hint: null };
109
+ if (hop.ip === null || isPrivateIPv4(hop.ip))
110
+ return located;
111
+ const { signal, endpoint } = options;
112
+ // One failed lookup shouldn't sink the whole trace, so failures become null.
113
+ const [hostnames, info] = await Promise.all([
114
+ reverse(hop.ip, signal ? { signal } : {}).catch(() => []),
115
+ lookup(hop.ip, { ...(signal && { signal }), ...(endpoint !== undefined && { endpoint }) }).catch(() => null),
116
+ ]);
117
+ signal?.throwIfAborted();
118
+ located.hostname = hostnames[0] ?? null;
119
+ located.info = info;
120
+ located.hint = hostnames.map(hintFromHostname).find((hint) => hint !== null) ?? null;
121
+ return located;
122
+ }
123
+ /**
124
+ * Run the system traceroute (`traceroute` on macOS/Linux, `tracert` on
125
+ * Windows) to an IPv4 address and report each hop, optionally with its
126
+ * hostname and location. When the target never answers, `lastResponding` is
127
+ * the furthest router that did — often the edge of the target's network.
128
+ */
129
+ export async function trace(ip, options = {}) {
130
+ const { maxHops = 30, probeTimeout = 1000, giveUpAfter = 5, timeout = 60_000, signal, geolocate = true, endpoint, } = options;
131
+ if (!isIPv4(ip)) {
132
+ throw new TypeError(`Expected an IPv4 address, got ${JSON.stringify(ip)}`);
133
+ }
134
+ const raw = await runTraceroute(ip, { maxHops, probeTimeout, giveUpAfter, timeout, signal });
135
+ const hops = geolocate
136
+ ? await mapLimit(raw.hops, 4, (hop) => geolocateHop(hop, { signal, endpoint }))
137
+ : raw.hops.map((hop) => ({ ...hop, hostname: null, info: null, hint: null }));
138
+ const reached = raw.stopped === 'reached' || hops.some((hop) => hop.ip === ip);
139
+ return {
140
+ target: ip,
141
+ hops,
142
+ reached,
143
+ stopped: reached ? 'reached' : raw.stopped,
144
+ lastResponding: hops.findLast((hop) => hop.ip !== null) ?? null,
145
+ };
146
+ }
@@ -0,0 +1,56 @@
1
+ export interface WhoisOptions {
2
+ /** Milliseconds before the request is aborted. Set to 0 to disable. Default: 10000. */
3
+ timeout?: number;
4
+ /** Abort the request yourself. Combined with `timeout`; whichever fires first wins. */
5
+ signal?: AbortSignal;
6
+ /** Alternate RDAP base URL; the IP is appended. Default: "https://rdap.arin.net/registry/ip/". */
7
+ endpoint?: string | URL;
8
+ }
9
+ export interface WhoisContact {
10
+ /** Registry handle, e.g. "ABUSE5250-ARIN". */
11
+ handle: string | null;
12
+ /** RDAP roles such as "registrant", "abuse", "technical", "administrative". */
13
+ roles: string[];
14
+ /** vCard kind: "org", "group", "individual", ... */
15
+ kind: string | null;
16
+ name: string | null;
17
+ org: string | null;
18
+ email: string | null;
19
+ phone: string | null;
20
+ /** Postal address as a single multi-line string. */
21
+ address: string | null;
22
+ }
23
+ export interface WhoisInfo {
24
+ /** The address that was looked up. */
25
+ ip: string;
26
+ /** Network handle, e.g. "NET-8-8-8-0-2". */
27
+ handle: string | null;
28
+ /** Network name, e.g. "GOGL". */
29
+ name: string | null;
30
+ /** Allocation type, e.g. "DIRECT ALLOCATION". */
31
+ type: string | null;
32
+ parentHandle: string | null;
33
+ /** Two-letter country code, when the registry records one. */
34
+ country: string | null;
35
+ startAddress: string | null;
36
+ endAddress: string | null;
37
+ /** The network in CIDR notation, e.g. ["8.8.8.0/24"]. */
38
+ cidrs: string[];
39
+ status: string[];
40
+ /** Organization the network is registered to. */
41
+ organization: string | null;
42
+ /** Where to report abuse from this network. */
43
+ abuseEmail: string | null;
44
+ /** ISO 8601 timestamps from the registry. */
45
+ registered: string | null;
46
+ updated: string | null;
47
+ /** The registry's whois server, e.g. "whois.arin.net" or "whois.ripe.net". */
48
+ registry: string | null;
49
+ contacts: WhoisContact[];
50
+ }
51
+ /**
52
+ * Registration data for the network an IPv4 or IPv6 address belongs to: who
53
+ * owns it, its range, and its abuse contact. Queries ARIN's RDAP service,
54
+ * which hands off to the right regional registry for non-ARIN space.
55
+ */
56
+ export declare function whois(ip: string, options?: WhoisOptions): Promise<WhoisInfo>;
package/dist/whois.js ADDED
@@ -0,0 +1,136 @@
1
+ import { isIP } from 'node:net';
2
+ import { HostInfoError } from './lookup.js';
3
+ // ARIN's RDAP service answers for its own space and redirects everything else
4
+ // to the registry that holds it (RIPE, APNIC, LACNIC, AFRINIC), which fetch
5
+ // follows for us.
6
+ const DEFAULT_ENDPOINT = 'https://rdap.arin.net/registry/ip/';
7
+ const DEFAULT_TIMEOUT = 10_000;
8
+ function text(value) {
9
+ if (typeof value !== 'string')
10
+ return null;
11
+ const trimmed = value.trim();
12
+ return trimmed === '' ? null : trimmed;
13
+ }
14
+ function vcardField(entity, name) {
15
+ const property = entity.vcardArray?.[1]?.find((p) => p[0] === name);
16
+ if (!property)
17
+ return null;
18
+ // Addresses carry the formatted text in a "label" parameter; the structured
19
+ // value is usually blank.
20
+ if (name === 'adr')
21
+ return text(property[1]['label']);
22
+ return text(property[3]);
23
+ }
24
+ function toContact(entity) {
25
+ return {
26
+ handle: text(entity.handle),
27
+ roles: [...(entity.roles ?? [])],
28
+ kind: vcardField(entity, 'kind'),
29
+ name: vcardField(entity, 'fn'),
30
+ org: vcardField(entity, 'org'),
31
+ email: vcardField(entity, 'email'),
32
+ phone: vcardField(entity, 'tel'),
33
+ address: vcardField(entity, 'adr'),
34
+ };
35
+ }
36
+ // Contacts nest (ARIN hangs abuse/tech under the registrant) and repeat once
37
+ // per role (RIPE), so flatten them and merge roles by handle.
38
+ function collectContacts(entities = []) {
39
+ const contacts = [];
40
+ const byHandle = new Map();
41
+ const visit = (list) => {
42
+ for (const entity of list) {
43
+ const contact = toContact(entity);
44
+ const existing = contact.handle === null ? undefined : byHandle.get(contact.handle);
45
+ if (existing) {
46
+ for (const role of contact.roles) {
47
+ if (!existing.roles.includes(role))
48
+ existing.roles.push(role);
49
+ }
50
+ for (const key of ['kind', 'name', 'org', 'email', 'phone', 'address']) {
51
+ existing[key] ??= contact[key];
52
+ }
53
+ }
54
+ else {
55
+ contacts.push(contact);
56
+ if (contact.handle !== null)
57
+ byHandle.set(contact.handle, contact);
58
+ }
59
+ visit(entity.entities ?? []);
60
+ }
61
+ };
62
+ visit(entities);
63
+ return contacts;
64
+ }
65
+ function eventDate(network, action) {
66
+ return text(network.events?.find((e) => e.eventAction === action)?.eventDate);
67
+ }
68
+ function parseRdap(ip, network) {
69
+ if (network.objectClassName !== 'ip network') {
70
+ throw new HostInfoError('Unexpected RDAP response: not an IP network');
71
+ }
72
+ const contacts = collectContacts(network.entities);
73
+ const withRole = (role) => contacts.filter((c) => c.roles.includes(role));
74
+ // RIPE also lists maintainer objects as registrants; prefer the organization.
75
+ const registrants = withRole('registrant');
76
+ const registrant = registrants.find((c) => c.kind === 'org') ?? registrants[0];
77
+ return {
78
+ ip,
79
+ handle: text(network.handle),
80
+ name: text(network.name),
81
+ type: text(network.type),
82
+ parentHandle: text(network.parentHandle),
83
+ country: text(network.country),
84
+ startAddress: text(network.startAddress),
85
+ endAddress: text(network.endAddress),
86
+ cidrs: (network.cidr0_cidrs ?? []).flatMap((c) => {
87
+ const prefix = c.v4prefix ?? c.v6prefix;
88
+ return prefix && c.length !== undefined ? [`${prefix}/${c.length}`] : [];
89
+ }),
90
+ status: [...(network.status ?? [])],
91
+ organization: registrant?.name ?? registrant?.org ?? null,
92
+ abuseEmail: withRole('abuse').find((c) => c.email !== null)?.email ?? null,
93
+ registered: eventDate(network, 'registration'),
94
+ updated: eventDate(network, 'last changed'),
95
+ registry: text(network.port43),
96
+ contacts,
97
+ };
98
+ }
99
+ /**
100
+ * Registration data for the network an IPv4 or IPv6 address belongs to: who
101
+ * owns it, its range, and its abuse contact. Queries ARIN's RDAP service,
102
+ * which hands off to the right regional registry for non-ARIN space.
103
+ */
104
+ export async function whois(ip, options = {}) {
105
+ const { timeout = DEFAULT_TIMEOUT, signal, endpoint = DEFAULT_ENDPOINT } = options;
106
+ if (!isIP(ip)) {
107
+ throw new TypeError(`Expected an IP address, got ${JSON.stringify(ip)}`);
108
+ }
109
+ const base = new URL(endpoint);
110
+ if (!base.pathname.endsWith('/'))
111
+ base.pathname += '/';
112
+ const url = new URL(encodeURIComponent(ip), base);
113
+ const signals = [signal, timeout > 0 ? AbortSignal.timeout(timeout) : undefined].filter((s) => s !== undefined);
114
+ let response;
115
+ try {
116
+ response = await fetch(url, {
117
+ headers: { accept: 'application/rdap+json' },
118
+ signal: signals.length > 0 ? AbortSignal.any(signals) : null,
119
+ });
120
+ }
121
+ catch (cause) {
122
+ const message = cause instanceof Error ? cause.message : String(cause);
123
+ throw new HostInfoError(`RDAP request for ${ip} failed: ${message}`, { cause });
124
+ }
125
+ if (!response.ok) {
126
+ throw new HostInfoError(`RDAP lookup for ${ip} responded with HTTP ${response.status}`);
127
+ }
128
+ let body;
129
+ try {
130
+ body = await response.json();
131
+ }
132
+ catch (cause) {
133
+ throw new HostInfoError('Unexpected RDAP response: not JSON', { cause });
134
+ }
135
+ return parseRdap(ip, (body ?? {}));
136
+ }
package/llms.txt ADDED
@@ -0,0 +1,35 @@
1
+ # hostinfo
2
+
3
+ > Zero-dependency Node.js (>=22.12) library, CLI, and MCP server for IP geolocation (via the free hostip.info API, with reverse-DNS and traceroute fallbacks), reverse DNS, traceroute, and IP WHOIS (RDAP). ESM with bundled TypeScript types; `require('hostinfo')` also works.
4
+
5
+ Every function returns a Promise and accepts an `options` object with `signal` (AbortSignal) and usually `timeout` (ms, 0 disables). Network/HTTP/parse failures reject with `HostInfoError` (underlying error on `.cause`). Non-IP input rejects with `TypeError`. Unknown fields are `null`, never placeholder strings. Results are approximate: hostip.info is community-maintained.
6
+
7
+ ## API
8
+
9
+ - `lookup(ip?, { timeout=10000, signal, endpoint }) → HostInfo` — hostip.info only. IPv4 only. Omit `ip` for the caller's public IP. Private addresses return all-null fields without a request. `HostInfo = { ip, city, country, countryCode, latitude, longitude }`.
10
+ - `locate(ip, { ...lookupOptions, trace?: boolean | TraceOptions }) → Location` — preferred for "where is this IP". Tries hostip.info, then location codes in the reverse-DNS hostname, then (only if `trace` is set) the nearest traceroute hop with a location. `Location = HostInfo & { hostname, source: 'hostip'|'hostname'|'traceroute'|null, via: TraceHop|null }`. Trust `source` in that order.
11
+ - `trace(ip, { maxHops=30, probeTimeout=1000, giveUpAfter=5, timeout=60000, signal, geolocate=true, endpoint }) → TraceResult` — shells out to `traceroute`/`tracert` (must be installed). `TraceResult = { target, hops: TraceHop[], reached, stopped: 'reached'|'max-hops'|'gave-up'|'timeout', lastResponding }`; `TraceHop = { hop, ip|null, rtt|null, hostname, info: HostInfo|null, hint: LocationHint|null }`.
12
+ - `reverse(ip, { timeout=5000, signal }) → string[]` — PTR records, IPv4 or IPv6; `[]` when none.
13
+ - `whois(ip, { timeout=10000, signal, endpoint }) → WhoisInfo` — ARIN RDAP (redirects to other registries). Fields include `organization, name, handle, startAddress, endAddress, cidrs, type, country, abuseEmail, registered, updated, registry, contacts[]`.
14
+ - `hintFromHostname(hostname) → LocationHint | null` — synchronous, offline. `LocationHint = { code, city, countryCode, latitude, longitude }`.
15
+ - `isPrivateIPv4(ip) → boolean` — synchronous. TEST-NET ranges count as public.
16
+ - Legacy: `lookup(ip, (err, info) => …)` callback form still works.
17
+
18
+ ```js
19
+ import { locate, whois } from 'hostinfo';
20
+ const where = await locate('8.8.8.8'); // { city: 'Mountain View, CA', source: 'hostip', … }
21
+ const owner = await whois('8.8.8.8'); // { organization: 'Google LLC', cidrs: ['8.8.8.0/24'], … }
22
+ ```
23
+
24
+ ## CLI
25
+
26
+ `npx hostinfo [ip|host] [--trace]`, `npx hostinfo trace|reverse|whois <ip|host>`, `--json` for machine-readable output. Hostnames resolve to their first IPv4 address.
27
+
28
+ ## MCP server
29
+
30
+ `npx -y hostinfo mcp` runs a stdio MCP server with read-only tools: `locate` (`target?`, `trace?`), `traceroute` (`target`, `maxHops?`), `reverse_dns` (`target`), `whois` (`target`), `hostname_hint` (`hostname`). Tool results are the JSON-serialized return values of the functions above.
31
+
32
+ ## Docs
33
+
34
+ - [README](https://github.com/neopunisher/node-hostip#readme): full API reference and examples
35
+ - [Types](https://unpkg.com/hostinfo/dist/index.d.ts): bundled TypeScript declarations
package/package.json CHANGED
@@ -1,32 +1,61 @@
1
1
  {
2
- "name": "hostinfo",
3
- "description": "Uses the hostinfo database to geocode ip addresses",
4
- "version": "0.0.2",
5
- "tags" : ["geoip", "hostinfo", "util", "utility"],
6
- "author" : "Carter Cole <node@cartercole.com>",
7
- "maintainers":[
8
- {
9
- "name":"Carter Cole",
10
- "email":"node@cartercole.com"
11
- }
12
- ],
13
- "homepage": "http://blog.cartercole.com",
14
- "repository" : {
15
- "type" : "git",
16
- "url" : "git://github.com/neopunisher/node-hostip.git"
2
+ "name": "hostinfo",
3
+ "version": "2.0.0",
4
+ "description": "Geocode IP addresses to city, country, and coordinates with the free hostip.info API, with reverse-DNS and traceroute fallbacks, plus WHOIS and an MCP server for AI assistants. Zero dependencies.",
5
+ "keywords": [
6
+ "geoip",
7
+ "hostinfo",
8
+ "hostip",
9
+ "geocode",
10
+ "geolocation",
11
+ "ip",
12
+ "reverse-dns",
13
+ "traceroute",
14
+ "whois",
15
+ "rdap",
16
+ "mcp",
17
+ "mcp-server",
18
+ "model-context-protocol"
19
+ ],
20
+ "homepage": "https://github.com/neopunisher/node-hostip#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/neopunisher/node-hostip/issues"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/neopunisher/node-hostip.git"
27
+ },
28
+ "license": "MIT",
29
+ "author": "Carter Cole <node@cartercole.com>",
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
17
35
  },
18
- "main" : "main.js",
19
- "licenses" : [
20
- {
21
- "type": "MIT",
22
- "url": "http://www.opensource.org/licenses/mit-license.php"
23
- }
24
- ],"bugs" :
25
- { "web" : "https://github.com/neopunisher/node-hostip/issues" },
26
- "dependencies": {
27
- "xml2js": ">= 0.1.9",
28
- "request": ">= 2.1.0"
29
- },
30
- "engine": [ "node >=0.4.1" ]
36
+ "./package.json": "./package.json"
37
+ },
38
+ "bin": {
39
+ "hostinfo": "./dist/cli.js"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "llms.txt"
44
+ ],
45
+ "sideEffects": false,
46
+ "engines": {
47
+ "node": ">=22.12"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc -p tsconfig.build.json",
51
+ "typecheck": "npm run build && tsc",
52
+ "test": "npm run build && node --test \"test/**/*.test.ts\"",
53
+ "test:live": "LIVE_TEST=1 npm test",
54
+ "prepack": "npm run build"
55
+ },
56
+ "types": "./dist/index.d.ts",
57
+ "devDependencies": {
58
+ "@types/node": "^22.20.4",
59
+ "typescript": "^7.0.2"
60
+ }
31
61
  }
32
-
package/README DELETED
@@ -1 +0,0 @@
1
- This uses the free api from http://www.hostip.info/ to provide ip to city geocoding
package/main.js DELETED
@@ -1,12 +0,0 @@
1
- var xml2js = require('xml2js'),
2
- request = require('request');
3
-
4
- exports.lookup = function (ip,cb){
5
- request({uri: "http://api.hostip.info/?ip="+ip}, function (error, response, body) {
6
- var parser = new xml2js.Parser();
7
- parser.addListener('end', function(obj){
8
- cb(null,obj);
9
- });
10
- parser.parseString(body);
11
- });
12
- }