hostinfo 1.0.0 → 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/locate.js ADDED
@@ -0,0 +1,59 @@
1
+ import { hintFromHostname } from './hints.js';
2
+ import { lookup } from './lookup.js';
3
+ import { reverse } from './reverse.js';
4
+ import { trace } from './trace.js';
5
+ function fromHint(info, hint) {
6
+ return {
7
+ ip: info.ip,
8
+ city: hint.city,
9
+ // hostip.info's country name only applies if it agrees with the hint.
10
+ country: info.countryCode === hint.countryCode ? info.country : null,
11
+ countryCode: hint.countryCode,
12
+ latitude: hint.latitude,
13
+ longitude: hint.longitude,
14
+ };
15
+ }
16
+ /**
17
+ * Best-effort geolocation for an IPv4 address. Asks hostip.info first, then
18
+ * falls back to location codes in the target's reverse DNS name (e.g.
19
+ * "…lax01.example.net"), and optionally to a traceroute. Check `source` to see
20
+ * how much to trust the answer.
21
+ */
22
+ export async function locate(ip, options = {}) {
23
+ const { trace: traceOption = false, ...lookupOptions } = options;
24
+ const { signal } = lookupOptions;
25
+ const [info, hostnames] = await Promise.all([
26
+ lookup(ip, lookupOptions),
27
+ // lookup() validates the address; a failed reverse lookup just means no hint.
28
+ reverse(ip, signal ? { signal } : {}).catch(() => []),
29
+ ]);
30
+ const hostname = hostnames[0] ?? null;
31
+ if (info.city !== null) {
32
+ return { ...info, hostname, source: 'hostip', via: null };
33
+ }
34
+ const hint = hostnames.map(hintFromHostname).find((h) => h !== null);
35
+ if (hint) {
36
+ return { ...fromHint(info, hint), hostname, source: 'hostname', via: null };
37
+ }
38
+ if (traceOption !== false) {
39
+ const traceOptions = {
40
+ ...(signal && { signal }),
41
+ ...(lookupOptions.endpoint !== undefined && { endpoint: lookupOptions.endpoint }),
42
+ ...(traceOption === true ? {} : traceOption),
43
+ geolocate: true,
44
+ };
45
+ const { hops } = await trace(ip, traceOptions);
46
+ // Walk back from the target: the nearest located router is the best proxy.
47
+ for (const hop of hops.toReversed()) {
48
+ if (hop.ip === null || hop.ip === ip)
49
+ continue;
50
+ if (hop.info?.city != null) {
51
+ return { ...hop.info, ip: info.ip, hostname, source: 'traceroute', via: hop };
52
+ }
53
+ if (hop.hint) {
54
+ return { ...fromHint(info, hop.hint), hostname, source: 'traceroute', via: hop };
55
+ }
56
+ }
57
+ }
58
+ return { ...info, hostname, source: null, via: null };
59
+ }
@@ -0,0 +1,37 @@
1
+ export interface LookupOptions {
2
+ /**
3
+ * Milliseconds before the request is aborted. Set to 0 to disable.
4
+ * Default: 10000.
5
+ */
6
+ timeout?: number;
7
+ /** Abort the request yourself. Combined with `timeout`; whichever fires first wins. */
8
+ signal?: AbortSignal;
9
+ /** Alternate API endpoint. Default: "https://api.hostip.info/". */
10
+ endpoint?: string | URL;
11
+ }
12
+ export interface HostInfo {
13
+ /** The IP address the API answered for. */
14
+ ip: string | null;
15
+ /** City name, or null when unknown or a private address. */
16
+ city: string | null;
17
+ /** Country name (upper case), or null when unknown. */
18
+ country: string | null;
19
+ /** ISO-ish two-letter country code, or null when unknown. */
20
+ countryCode: string | null;
21
+ latitude: number | null;
22
+ longitude: number | null;
23
+ }
24
+ export type LookupCallback = (error: Error | null, result?: HostInfo) => void;
25
+ export declare class HostInfoError extends Error {
26
+ name: 'HostInfoError';
27
+ }
28
+ /**
29
+ * Geocode an IPv4 address with the free hostip.info API. Omit `ip` to look up
30
+ * the caller's own public address. Returns a Promise unless a callback is
31
+ * given.
32
+ */
33
+ export declare function lookup(ip?: string | null, options?: LookupOptions): Promise<HostInfo>;
34
+ export declare function lookup(options: LookupOptions): Promise<HostInfo>;
35
+ export declare function lookup(callback: LookupCallback): void;
36
+ export declare function lookup(ip: string | null | undefined, callback: LookupCallback): void;
37
+ export declare function lookup(ip: string | null | undefined, options: LookupOptions | undefined, callback: LookupCallback): void;
package/dist/lookup.js ADDED
@@ -0,0 +1,116 @@
1
+ import { isIPv4 } from 'node:net';
2
+ import { isPrivateIPv4 } from './private.js';
3
+ const DEFAULT_ENDPOINT = 'https://api.hostip.info/';
4
+ const DEFAULT_TIMEOUT = 10_000;
5
+ export class HostInfoError extends Error {
6
+ name = 'HostInfoError';
7
+ }
8
+ const XML_ENTITIES = {
9
+ '&amp;': '&',
10
+ '&lt;': '<',
11
+ '&gt;': '>',
12
+ '&quot;': '"',
13
+ '&apos;': "'",
14
+ };
15
+ function unescapeXml(text) {
16
+ return text.replace(/&(?:amp|lt|gt|quot|apos);|&#(\d+);|&#x([0-9a-fA-F]+);/g, (match, dec, hex) => {
17
+ if (dec)
18
+ return String.fromCodePoint(Number(dec));
19
+ if (hex)
20
+ return String.fromCodePoint(Number.parseInt(hex, 16));
21
+ return XML_ENTITIES[match] ?? match;
22
+ });
23
+ }
24
+ function extractField(xml, tag) {
25
+ const match = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml);
26
+ if (!match)
27
+ return null;
28
+ const value = unescapeXml(match[1].trim());
29
+ return value === '' ? null : value;
30
+ }
31
+ // The API reports unknowns as placeholders like "(Unknown city)" or
32
+ // "(Private Address)", and an unknown country code as "XX"; normalize those
33
+ // to null.
34
+ function known(value) {
35
+ return value?.startsWith('(') || value === 'XX' ? null : value;
36
+ }
37
+ function parseHostipXml(xml) {
38
+ const inner = /<Hostip>([\s\S]*?)<\/Hostip>/.exec(xml)?.[1];
39
+ if (inner === undefined) {
40
+ throw new HostInfoError('Unexpected response from hostip.info: missing <Hostip> element');
41
+ }
42
+ let latitude = null;
43
+ let longitude = null;
44
+ const coordinates = extractField(inner, 'gml:coordinates');
45
+ if (coordinates !== null) {
46
+ // hostip.info returns "longitude,latitude"
47
+ const [lng, lat] = coordinates.split(',').map(Number);
48
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
49
+ latitude = lat;
50
+ longitude = lng;
51
+ }
52
+ }
53
+ return {
54
+ ip: extractField(inner, 'ip'),
55
+ city: known(extractField(inner, 'gml:name')),
56
+ country: known(extractField(inner, 'countryName')),
57
+ countryCode: known(extractField(inner, 'countryAbbrev')),
58
+ latitude,
59
+ longitude,
60
+ };
61
+ }
62
+ async function lookupAsync(ip, options) {
63
+ const { timeout = DEFAULT_TIMEOUT, signal, endpoint = DEFAULT_ENDPOINT } = options;
64
+ // hostip.info only knows IPv4 and answers anything else with a misleading
65
+ // "(Private Address)", so reject bad input up front.
66
+ if (ip !== undefined && !isIPv4(ip)) {
67
+ throw new TypeError(`Expected an IPv4 address, got ${JSON.stringify(ip)}`);
68
+ }
69
+ // hostip.info can't place these, so skip the round trip.
70
+ if (ip !== undefined && isPrivateIPv4(ip)) {
71
+ return { ip, city: null, country: null, countryCode: null, latitude: null, longitude: null };
72
+ }
73
+ const url = new URL(endpoint);
74
+ if (ip !== undefined)
75
+ url.searchParams.set('ip', ip);
76
+ const signals = [signal, timeout > 0 ? AbortSignal.timeout(timeout) : undefined].filter((s) => s !== undefined);
77
+ let response;
78
+ try {
79
+ response = await fetch(url, {
80
+ headers: { accept: 'text/xml' },
81
+ signal: signals.length > 0 ? AbortSignal.any(signals) : null,
82
+ });
83
+ }
84
+ catch (cause) {
85
+ const message = cause instanceof Error ? cause.message : String(cause);
86
+ throw new HostInfoError(`Request to hostip.info failed: ${message}`, { cause });
87
+ }
88
+ if (!response.ok) {
89
+ throw new HostInfoError(`hostip.info responded with HTTP ${response.status}`);
90
+ }
91
+ // The API serves ISO-8859-1, which response.text() would mangle.
92
+ const xml = new TextDecoder('iso-8859-1').decode(await response.arrayBuffer());
93
+ return parseHostipXml(xml);
94
+ }
95
+ export function lookup(ip, options, callback) {
96
+ if (typeof ip === 'function') {
97
+ callback = ip;
98
+ ip = undefined;
99
+ options = undefined;
100
+ }
101
+ else if (typeof options === 'function') {
102
+ callback = options;
103
+ options = undefined;
104
+ }
105
+ if (typeof ip === 'object' && ip !== null) {
106
+ options = ip;
107
+ ip = undefined;
108
+ }
109
+ const promise = lookupAsync(ip ?? undefined, options ?? {});
110
+ if (typeof callback !== 'function')
111
+ return promise;
112
+ const cb = callback;
113
+ // Call back outside the promise chain so an exception thrown by the
114
+ // callback surfaces as-is instead of becoming an unhandled rejection.
115
+ promise.then((result) => process.nextTick(cb, null, result), (error) => process.nextTick(cb, error));
116
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ type Id = string | number;
2
+ export type McpResponse = {
3
+ jsonrpc: '2.0';
4
+ id: Id | null;
5
+ result: unknown;
6
+ } | {
7
+ jsonrpc: '2.0';
8
+ id: Id | null;
9
+ error: {
10
+ code: number;
11
+ message: string;
12
+ };
13
+ };
14
+ export interface McpSession {
15
+ /** Handle one parsed JSON-RPC message. Resolves to the response, or null for notifications. */
16
+ handle(message: unknown): Promise<McpResponse | null>;
17
+ /** Abort every in-flight tool call. */
18
+ close(): void;
19
+ }
20
+ export declare function createMcpSession(): McpSession;
21
+ export interface ServeMcpOptions {
22
+ input?: NodeJS.ReadableStream;
23
+ output?: NodeJS.WritableStream;
24
+ }
25
+ /** Serve MCP over newline-delimited JSON on stdin/stdout until the input ends. */
26
+ export declare function serveMcp(options?: ServeMcpOptions): Promise<void>;
27
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,243 @@
1
+ // A zero-dependency Model Context Protocol server over stdio, so AI assistants
2
+ // can call locate/trace/reverse/whois as tools. Messages are newline-delimited
3
+ // JSON-RPC 2.0; see https://modelcontextprotocol.io/specification.
4
+ import { createRequire } from 'node:module';
5
+ import { createInterface } from 'node:readline';
6
+ import { hintFromHostname } from './hints.js';
7
+ import { locate } from './locate.js';
8
+ import { lookup } from './lookup.js';
9
+ import { toIP, toIPv4 } from './resolve.js';
10
+ import { reverse } from './reverse.js';
11
+ import { trace } from './trace.js';
12
+ import { whois } from './whois.js';
13
+ // Newest first. We only use features common to all of them.
14
+ const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
15
+ const { version } = createRequire(import.meta.url)('../package.json');
16
+ const TARGET = {
17
+ type: 'string',
18
+ description: 'IPv4 address or hostname (hostnames are resolved to their first IPv4 address).',
19
+ };
20
+ function str(args, key) {
21
+ const value = args[key];
22
+ if (value === undefined)
23
+ return undefined;
24
+ if (typeof value !== 'string' || value.trim() === '') {
25
+ throw new TypeError(`"${key}" must be a non-empty string`);
26
+ }
27
+ return value.trim();
28
+ }
29
+ function required(args, key) {
30
+ const value = str(args, key);
31
+ if (value === undefined)
32
+ throw new TypeError(`"${key}" is required`);
33
+ return value;
34
+ }
35
+ const TOOLS = [
36
+ {
37
+ name: 'locate',
38
+ title: 'Geolocate an IP address',
39
+ description: 'Find the city, country, and coordinates of an IPv4 address or hostname. Asks hostip.info first, ' +
40
+ 'then falls back to location codes in the reverse DNS name, and optionally to a traceroute. ' +
41
+ 'The `source` field says where the answer came from ("hostip" is most reliable, "traceroute" least). ' +
42
+ 'Omit `target` to locate the machine running this server.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ target: TARGET,
47
+ trace: {
48
+ type: 'boolean',
49
+ description: 'If nothing else finds a city, traceroute to the target and use the nearest hop with a location. Slow (up to a minute).',
50
+ },
51
+ },
52
+ },
53
+ async run(args, signal) {
54
+ const target = str(args, 'target');
55
+ const ip = target === undefined ? (await lookup({ signal })).ip : await toIPv4(target);
56
+ if (ip === null)
57
+ throw new Error('hostip.info did not report a public IP for this machine');
58
+ return locate(ip, { signal, ...(args['trace'] === true && { trace: { signal } }) });
59
+ },
60
+ },
61
+ {
62
+ name: 'traceroute',
63
+ title: 'Traceroute',
64
+ description: 'Run a traceroute to an IPv4 address or hostname and report every hop with its reverse DNS name and ' +
65
+ 'location. Useful for seeing where traffic goes or where it stops. Takes seconds to a minute.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ target: TARGET,
70
+ maxHops: { type: 'integer', minimum: 1, maximum: 64, description: 'Highest TTL to probe. Default 30.' },
71
+ },
72
+ required: ['target'],
73
+ },
74
+ async run(args, signal) {
75
+ const ip = await toIPv4(required(args, 'target'));
76
+ const maxHops = args['maxHops'];
77
+ if (maxHops !== undefined && (!Number.isInteger(maxHops) || maxHops < 1)) {
78
+ throw new TypeError('"maxHops" must be a positive integer');
79
+ }
80
+ return trace(ip, { signal, ...(maxHops !== undefined && { maxHops: maxHops }) });
81
+ },
82
+ },
83
+ {
84
+ name: 'reverse_dns',
85
+ title: 'Reverse DNS',
86
+ description: 'Look up the hostnames (PTR records) for an IPv4 or IPv6 address. Returns an empty list if there are none.',
87
+ inputSchema: {
88
+ type: 'object',
89
+ properties: { target: { type: 'string', description: 'IPv4/IPv6 address or hostname.' } },
90
+ required: ['target'],
91
+ },
92
+ async run(args, signal) {
93
+ return reverse(await toIP(required(args, 'target')), { signal });
94
+ },
95
+ },
96
+ {
97
+ name: 'whois',
98
+ title: 'IP WHOIS (RDAP)',
99
+ description: 'Who owns the network an IP address belongs to: organization, address range and CIDRs, allocation type, ' +
100
+ 'abuse contact, and registration dates, from the regional internet registry via RDAP.',
101
+ inputSchema: {
102
+ type: 'object',
103
+ properties: { target: { type: 'string', description: 'IPv4/IPv6 address or hostname.' } },
104
+ required: ['target'],
105
+ },
106
+ async run(args, signal) {
107
+ return whois(await toIP(required(args, 'target')), { signal });
108
+ },
109
+ },
110
+ {
111
+ name: 'hostname_hint',
112
+ title: 'Guess location from a hostname',
113
+ description: 'Guess a location from the airport, CLLI, or city code that network operators embed in router and ' +
114
+ 'server hostnames (e.g. "ae-5.r21.lsanca07.us.bb.gin.ntt.net" is Los Angeles). Offline; returns null if no code is recognized.',
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: { hostname: { type: 'string', description: 'A DNS hostname.' } },
118
+ required: ['hostname'],
119
+ },
120
+ async run(args) {
121
+ return hintFromHostname(required(args, 'hostname'));
122
+ },
123
+ },
124
+ ];
125
+ function errorResponse(id, code, message) {
126
+ return { jsonrpc: '2.0', id, error: { code, message } };
127
+ }
128
+ export function createMcpSession() {
129
+ const inFlight = new Map();
130
+ async function callTool(id, params) {
131
+ const tool = TOOLS.find((t) => t.name === params['name']);
132
+ if (!tool)
133
+ throw Object.assign(new Error(`Unknown tool: ${String(params['name'])}`), { code: -32602 });
134
+ const args = (params['arguments'] ?? {});
135
+ const controller = new AbortController();
136
+ inFlight.set(id, controller);
137
+ try {
138
+ const result = await tool.run(args, controller.signal);
139
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], isError: false };
140
+ }
141
+ catch (error) {
142
+ // Tool failures go back to the model as results so it can react to them.
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ return { content: [{ type: 'text', text: message }], isError: true };
145
+ }
146
+ finally {
147
+ inFlight.delete(id);
148
+ }
149
+ }
150
+ async function handle(raw) {
151
+ const message = (raw ?? {});
152
+ const isRequest = message.id !== undefined && message.id !== null;
153
+ const id = isRequest ? message.id : null;
154
+ if (message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
155
+ return errorResponse(id, -32600, 'Invalid Request');
156
+ }
157
+ const params = message.params ?? {};
158
+ if (!isRequest) {
159
+ if (message.method === 'notifications/cancelled') {
160
+ inFlight.get(params['requestId'])?.abort(new Error('Cancelled by client'));
161
+ }
162
+ return null;
163
+ }
164
+ try {
165
+ switch (message.method) {
166
+ case 'initialize': {
167
+ const requested = params['protocolVersion'];
168
+ const protocolVersion = typeof requested === 'string' && PROTOCOL_VERSIONS.includes(requested)
169
+ ? requested
170
+ : PROTOCOL_VERSIONS[0];
171
+ return {
172
+ jsonrpc: '2.0',
173
+ id,
174
+ result: {
175
+ protocolVersion,
176
+ capabilities: { tools: {} },
177
+ serverInfo: { name: 'hostinfo', title: 'hostinfo', version },
178
+ instructions: 'IP geolocation and network lookups. Use `locate` for "where is this IP/host", `whois` for ' +
179
+ '"who owns it", `reverse_dns` for its hostnames, and `traceroute` for the network path. ' +
180
+ 'Locations are approximate; check `source` on locate results.',
181
+ },
182
+ };
183
+ }
184
+ case 'ping':
185
+ return { jsonrpc: '2.0', id, result: {} };
186
+ case 'tools/list':
187
+ return {
188
+ jsonrpc: '2.0',
189
+ id,
190
+ result: {
191
+ tools: TOOLS.map(({ run: _run, ...tool }) => ({
192
+ ...tool,
193
+ annotations: { readOnlyHint: true, openWorldHint: tool.name !== 'hostname_hint' },
194
+ })),
195
+ },
196
+ };
197
+ case 'tools/call':
198
+ return { jsonrpc: '2.0', id, result: await callTool(id, params) };
199
+ default:
200
+ return errorResponse(id, -32601, `Method not found: ${message.method}`);
201
+ }
202
+ }
203
+ catch (error) {
204
+ const code = error.code;
205
+ return errorResponse(id, typeof code === 'number' ? code : -32603, error instanceof Error ? error.message : String(error));
206
+ }
207
+ }
208
+ return {
209
+ handle,
210
+ close() {
211
+ for (const controller of inFlight.values())
212
+ controller.abort(new Error('Server closed'));
213
+ },
214
+ };
215
+ }
216
+ /** Serve MCP over newline-delimited JSON on stdin/stdout until the input ends. */
217
+ export async function serveMcp(options = {}) {
218
+ const { input = process.stdin, output = process.stdout } = options;
219
+ const session = createMcpSession();
220
+ const pending = new Set();
221
+ const send = (response) => {
222
+ if (response)
223
+ output.write(`${JSON.stringify(response)}\n`);
224
+ };
225
+ for await (const line of createInterface({ input, crlfDelay: Infinity })) {
226
+ if (line.trim() === '')
227
+ continue;
228
+ let message;
229
+ try {
230
+ message = JSON.parse(line);
231
+ }
232
+ catch {
233
+ send(errorResponse(null, -32700, 'Parse error'));
234
+ continue;
235
+ }
236
+ // Handle requests concurrently so a slow traceroute doesn't block pings or cancellation.
237
+ const task = session.handle(message).then(send);
238
+ pending.add(task);
239
+ void task.finally(() => pending.delete(task));
240
+ }
241
+ session.close();
242
+ await Promise.allSettled(pending);
243
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * True for IPv4 addresses that aren't publicly routable: loopback,
3
+ * link-local, RFC 1918, CGNAT, multicast, benchmarking, and reserved space.
4
+ */
5
+ export declare function isPrivateIPv4(ip: string): boolean;
@@ -0,0 +1,24 @@
1
+ import { BlockList, isIPv4 } from 'node:net';
2
+ // Addresses that never identify a place on the public internet, so there is
3
+ // nothing to geolocate. The TEST-NET documentation ranges are left out: they
4
+ // don't appear in real traffic, and examples and tests use them as stand-ins
5
+ // for public addresses.
6
+ const PRIVATE = new BlockList();
7
+ PRIVATE.addSubnet('0.0.0.0', 8); // "this network"
8
+ PRIVATE.addSubnet('10.0.0.0', 8);
9
+ PRIVATE.addSubnet('100.64.0.0', 10); // carrier-grade NAT
10
+ PRIVATE.addSubnet('127.0.0.0', 8);
11
+ PRIVATE.addSubnet('169.254.0.0', 16);
12
+ PRIVATE.addSubnet('172.16.0.0', 12);
13
+ PRIVATE.addSubnet('192.0.0.0', 24); // IETF protocol assignments
14
+ PRIVATE.addSubnet('192.168.0.0', 16);
15
+ PRIVATE.addSubnet('198.18.0.0', 15); // benchmarking
16
+ PRIVATE.addSubnet('224.0.0.0', 4); // multicast
17
+ PRIVATE.addSubnet('240.0.0.0', 4); // reserved, including 255.255.255.255
18
+ /**
19
+ * True for IPv4 addresses that aren't publicly routable: loopback,
20
+ * link-local, RFC 1918, CGNAT, multicast, benchmarking, and reserved space.
21
+ */
22
+ export function isPrivateIPv4(ip) {
23
+ return isIPv4(ip) && PRIVATE.check(ip, 'ipv4');
24
+ }
@@ -0,0 +1,4 @@
1
+ /** An IPv4 address as-is, or the first IPv4 address a hostname resolves to. */
2
+ export declare function toIPv4(target: string): Promise<string>;
3
+ /** An IPv4 or IPv6 address as-is, or the first IPv4 address a hostname resolves to. */
4
+ export declare function toIP(target: string): Promise<string>;
@@ -0,0 +1,13 @@
1
+ import dns from 'node:dns';
2
+ import { isIP, isIPv4 } from 'node:net';
3
+ /** An IPv4 address as-is, or the first IPv4 address a hostname resolves to. */
4
+ export async function toIPv4(target) {
5
+ if (isIPv4(target))
6
+ return target;
7
+ const { address } = await dns.promises.lookup(target, { family: 4 });
8
+ return address;
9
+ }
10
+ /** An IPv4 or IPv6 address as-is, or the first IPv4 address a hostname resolves to. */
11
+ export async function toIP(target) {
12
+ return isIP(target) ? target : toIPv4(target);
13
+ }
@@ -0,0 +1,11 @@
1
+ export interface ReverseOptions {
2
+ /** Milliseconds before the DNS query gives up. Set to 0 for the system default. Default: 5000. */
3
+ timeout?: number;
4
+ /** Cancel the query yourself. */
5
+ signal?: AbortSignal;
6
+ }
7
+ /**
8
+ * Reverse DNS: the hostnames (PTR records) registered for an IPv4 or IPv6
9
+ * address. Resolves to an empty array when there are none.
10
+ */
11
+ export declare function reverse(ip: string, options?: ReverseOptions): Promise<string[]>;
@@ -0,0 +1,34 @@
1
+ import dns from 'node:dns';
2
+ import { isIP } from 'node:net';
3
+ import { HostInfoError } from './lookup.js';
4
+ // Answers that just mean "this address has no PTR record".
5
+ const NO_RECORD = new Set(['ENOTFOUND', 'ENODATA', 'ESERVFAIL', 'ENONAME']);
6
+ /**
7
+ * Reverse DNS: the hostnames (PTR records) registered for an IPv4 or IPv6
8
+ * address. Resolves to an empty array when there are none.
9
+ */
10
+ export async function reverse(ip, options = {}) {
11
+ const { timeout = 5000, signal } = options;
12
+ if (!isIP(ip)) {
13
+ throw new TypeError(`Expected an IP address, got ${JSON.stringify(ip)}`);
14
+ }
15
+ signal?.throwIfAborted();
16
+ const resolver = new dns.promises.Resolver({ timeout: timeout > 0 ? timeout : -1, tries: 1 });
17
+ const cancel = () => resolver.cancel();
18
+ signal?.addEventListener('abort', cancel, { once: true });
19
+ try {
20
+ return await resolver.reverse(ip);
21
+ }
22
+ catch (cause) {
23
+ const code = cause.code;
24
+ if (code !== undefined && NO_RECORD.has(code))
25
+ return [];
26
+ if (signal?.aborted)
27
+ throw signal.reason;
28
+ const message = cause instanceof Error ? cause.message : String(cause);
29
+ throw new HostInfoError(`Reverse DNS lookup for ${ip} failed: ${message}`, { cause });
30
+ }
31
+ finally {
32
+ signal?.removeEventListener('abort', cancel);
33
+ }
34
+ }
@@ -0,0 +1,56 @@
1
+ import { type LocationHint } from './hints.ts';
2
+ import { type HostInfo } from './lookup.ts';
3
+ export interface TraceOptions {
4
+ /** Highest TTL to probe. Default: 30. */
5
+ maxHops?: number;
6
+ /** Milliseconds to wait for each probe (rounded up to whole seconds on macOS/Linux). Default: 1000. */
7
+ probeTimeout?: number;
8
+ /**
9
+ * Stop after this many hops in a row that don't answer — past that point
10
+ * the trail has usually gone cold for good. Set to 0 to always run to
11
+ * `maxHops`. Default: 5.
12
+ */
13
+ giveUpAfter?: number;
14
+ /** Milliseconds before the traceroute is cut short (the hops so far are still returned). 0 disables. Default: 60000. */
15
+ timeout?: number;
16
+ /** Abort the trace; the promise rejects with the signal's reason. */
17
+ signal?: AbortSignal;
18
+ /** Look up each hop's hostname and location. Default: true. */
19
+ geolocate?: boolean;
20
+ /** Alternate hostip.info endpoint for hop lookups. */
21
+ endpoint?: string | URL;
22
+ }
23
+ export interface TraceHop {
24
+ /** TTL of the probe, starting at 1. */
25
+ hop: number;
26
+ /** Address that answered, or null if the probe timed out ("*"). */
27
+ ip: string | null;
28
+ /** Round-trip time of the first answer in milliseconds. */
29
+ rtt: number | null;
30
+ /** Reverse DNS name, when `geolocate` is on and one exists. */
31
+ hostname: string | null;
32
+ /** hostip.info's answer for this hop; null for private, silent, or failed lookups. */
33
+ info: HostInfo | null;
34
+ /** Location guessed from the hostname; see `hintFromHostname`. */
35
+ hint: LocationHint | null;
36
+ }
37
+ export interface TraceResult {
38
+ target: string;
39
+ hops: TraceHop[];
40
+ /** Whether the target itself answered. */
41
+ reached: boolean;
42
+ /**
43
+ * Why the trace ended: the target answered, `maxHops` ran out, too many
44
+ * silent hops in a row (`giveUpAfter`), or the overall `timeout` fired.
45
+ */
46
+ stopped: 'reached' | 'max-hops' | 'gave-up' | 'timeout';
47
+ /** The furthest hop that answered — where the trail goes cold when the target isn't reached. */
48
+ lastResponding: TraceHop | null;
49
+ }
50
+ /**
51
+ * Run the system traceroute (`traceroute` on macOS/Linux, `tracert` on
52
+ * Windows) to an IPv4 address and report each hop, optionally with its
53
+ * hostname and location. When the target never answers, `lastResponding` is
54
+ * the furthest router that did — often the edge of the target's network.
55
+ */
56
+ export declare function trace(ip: string, options?: TraceOptions): Promise<TraceResult>;