@cyanheads/whois-mcp-server 0.1.5 → 0.2.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/AGENTS.md +27 -14
- package/CLAUDE.md +27 -14
- package/Dockerfile +48 -34
- package/README.md +30 -35
- package/changelog/0.1.x/0.1.6.md +41 -0
- package/changelog/0.2.x/0.2.0.md +40 -0
- package/dist/app.d.ts +67 -26
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +7 -5
- package/dist/app.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_fqdn.d.ts +9 -3
- package/dist/mcp-server/tools/definitions/_fqdn.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_fqdn.js +10 -14
- package/dist/mcp-server/tools/definitions/_fqdn.js.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-check-availability.tool.d.ts +11 -5
- package/dist/mcp-server/tools/definitions/whois-check-availability.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-check-availability.tool.js +25 -40
- package/dist/mcp-server/tools/definitions/whois-check-availability.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-get-dns.tool.d.ts +23 -4
- package/dist/mcp-server/tools/definitions/whois-get-dns.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-get-dns.tool.js +44 -28
- package/dist/mcp-server/tools/definitions/whois-get-dns.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.d.ts +0 -5
- package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.js +58 -53
- package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.d.ts +2 -2
- package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.js +9 -5
- package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.d.ts +16 -9
- package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.js +60 -24
- package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.d.ts +16 -2
- package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.js +44 -17
- package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.js.map +1 -1
- package/dist/services/doh/doh-service.d.ts +3 -3
- package/dist/services/doh/doh-service.d.ts.map +1 -1
- package/dist/services/doh/doh-service.js +71 -24
- package/dist/services/doh/doh-service.js.map +1 -1
- package/dist/services/doh/types.d.ts +16 -0
- package/dist/services/doh/types.d.ts.map +1 -1
- package/dist/services/rdap/rdap-service.d.ts +20 -8
- package/dist/services/rdap/rdap-service.d.ts.map +1 -1
- package/dist/services/rdap/rdap-service.js +200 -145
- package/dist/services/rdap/rdap-service.js.map +1 -1
- package/dist/services/rdap/types.d.ts +32 -2
- package/dist/services/rdap/types.d.ts.map +1 -1
- package/package.json +14 -12
- package/server.json +11 -25
|
@@ -7,12 +7,18 @@ import type { Context } from '@cyanheads/mcp-ts-core';
|
|
|
7
7
|
import type { AppConfig } from '@cyanheads/mcp-ts-core/config';
|
|
8
8
|
import type { StorageService } from '@cyanheads/mcp-ts-core/storage';
|
|
9
9
|
import type { NormalizedAsn, NormalizedDomain, NormalizedIpNetwork } from './types.js';
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
valid:
|
|
10
|
+
/** Parsed lookup address; mapped IPv6 resolves through its embedded IPv4 address. */
|
|
11
|
+
type ParsedIp = {
|
|
12
|
+
valid: true;
|
|
13
|
+
base: string;
|
|
14
|
+
value: bigint;
|
|
13
15
|
isIpv6: boolean;
|
|
14
16
|
hasCidr: boolean;
|
|
15
17
|
};
|
|
18
|
+
/** Parse a complete IP/CIDR token, retaining base-address lookup semantics. */
|
|
19
|
+
export declare function validateIp(ip: string): ParsedIp | {
|
|
20
|
+
valid: false;
|
|
21
|
+
};
|
|
16
22
|
/** Build reverse PTR query name from an IPv4 address */
|
|
17
23
|
export declare function ipv4ToPtr(ip: string): string;
|
|
18
24
|
/** Build reverse PTR query name from an IPv6 address */
|
|
@@ -28,19 +34,24 @@ export declare class RdapService {
|
|
|
28
34
|
/** Find the RDAP server URL for an ASN via IANA bootstrap */
|
|
29
35
|
findAsnRdapServer(asnNum: number, ctx: Context): Promise<string | null>;
|
|
30
36
|
/** Look up a domain registration record via RDAP */
|
|
31
|
-
lookupDomain(domain: string, ctx: Context): Promise<NormalizedDomain>;
|
|
37
|
+
lookupDomain(domain: string, ctx: Context, includeRegistrar?: boolean): Promise<NormalizedDomain>;
|
|
38
|
+
/** One optional registrar exchange, with guarded redirects and a total deadline. */
|
|
39
|
+
private registrarDetail;
|
|
32
40
|
/**
|
|
33
|
-
* Check
|
|
34
|
-
*
|
|
35
|
-
* or `{ available: null }` when TLD has no RDAP coverage.
|
|
41
|
+
* Check registry record presence; a miss never establishes registration eligibility.
|
|
42
|
+
* Recognized ICANN subdomains are rejected without querying their parent.
|
|
36
43
|
*/
|
|
37
44
|
checkAvailability(domain: string, ctx: Context): Promise<{
|
|
38
|
-
available:
|
|
45
|
+
available: null;
|
|
46
|
+
registration_status: 'not_found';
|
|
47
|
+
rdap_coverage: true;
|
|
39
48
|
} | {
|
|
40
49
|
available: false;
|
|
50
|
+
registration_status: 'registered';
|
|
41
51
|
record: NormalizedDomain;
|
|
42
52
|
} | {
|
|
43
53
|
available: null;
|
|
54
|
+
registration_status: 'unknown';
|
|
44
55
|
rdap_coverage: false;
|
|
45
56
|
}>;
|
|
46
57
|
/** Look up an IP address or CIDR via RIR RDAP */
|
|
@@ -50,4 +61,5 @@ export declare class RdapService {
|
|
|
50
61
|
}
|
|
51
62
|
export declare function initRdapService(_config: AppConfig, _storage: StorageService): void;
|
|
52
63
|
export declare function getRdapService(): RdapService;
|
|
64
|
+
export {};
|
|
53
65
|
//# sourceMappingURL=rdap-service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rdap-service.d.ts","sourceRoot":"","sources":["../../../src/services/rdap/rdap-service.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"rdap-service.d.ts","sourceRoot":"","sources":["../../../src/services/rdap/rdap-service.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAO/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAIrE,OAAO,KAAK,EAEV,aAAa,EACb,gBAAgB,EAChB,mBAAmB,EAMpB,MAAM,YAAY,CAAC;AAUpB,qFAAqF;AACrF,KAAK,QAAQ,GAAG;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;AAEhG,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,GAAG;IAAE,KAAK,EAAE,KAAK,CAAA;CAAE,CAuBlE;AAuBD,wDAAwD;AACxD,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAE5C;AAsBD,wDAAwD;AACxD,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAG5C;AA0ND,qBAAa,WAAW;IACtB,OAAO,CAAC,cAAc,CAAsE;IAE5F,iDAAiD;YACnC,cAAc;IAgB5B,qEAAqE;IAC/D,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe/E;IAED,oEAAoE;IAC9D,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiDxF;IAED,6DAA6D;IACvD,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe5E;IAED,oDAAoD;IAC9C,YAAY,CAChB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,OAAO,EACZ,gBAAgB,UAAQ,GACvB,OAAO,CAAC,gBAAgB,CAAC,CAoD3B;IAED,oFAAoF;YACtE,eAAe;IAqE7B;;;OAGG;IACG,iBAAiB,CACrB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,OAAO,GACX,OAAO,CACN;QAAE,SAAS,EAAE,IAAI,CAAC;QAAC,mBAAmB,EAAE,WAAW,CAAC;QAAC,aAAa,EAAE,IAAI,CAAA;KAAE,GAC1E;QAAE,SAAS,EAAE,KAAK,CAAC;QAAC,mBAAmB,EAAE,YAAY,CAAC;QAAC,MAAM,EAAE,gBAAgB,CAAA;KAAE,GACjF;QAAE,SAAS,EAAE,IAAI,CAAC;QAAC,mBAAmB,EAAE,SAAS,CAAC;QAAC,aAAa,EAAE,KAAK,CAAA;KAAE,CAC5E,CAkDA;IAED,iDAAiD;IAC3C,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAiErE;IAED,kCAAkC;IAC5B,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAwDjE;CACF;AAMD,wBAAgB,eAAe,CAAC,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,cAAc,GAAG,IAAI,CAElF;AAED,wBAAgB,cAAc,IAAI,WAAW,CAK5C"}
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* Caches the IANA bootstrap JSON for 24 hours to avoid a bootstrap hop on every query.
|
|
4
4
|
* @module services/rdap/rdap-service
|
|
5
5
|
*/
|
|
6
|
+
import { isIP } from 'node:net';
|
|
6
7
|
import { McpError, notFound, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
7
8
|
import { fetchWithTimeout, withRetry } from '@cyanheads/mcp-ts-core/utils';
|
|
9
|
+
import { parse } from 'tldts';
|
|
8
10
|
import { getServerConfig } from '../../config/server-config.js';
|
|
9
11
|
// IANA bootstrap endpoints
|
|
10
12
|
const IANA_DNS_BOOTSTRAP = 'https://data.iana.org/rdap/dns.json';
|
|
@@ -12,75 +14,46 @@ const IANA_IPV4_BOOTSTRAP = 'https://data.iana.org/rdap/ipv4.json';
|
|
|
12
14
|
const IANA_IPV6_BOOTSTRAP = 'https://data.iana.org/rdap/ipv6.json';
|
|
13
15
|
const IANA_ASN_BOOTSTRAP = 'https://data.iana.org/rdap/asn.json';
|
|
14
16
|
const BOOTSTRAP_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
15
|
-
|
|
16
|
-
const PRIVATE_IPV4_RANGES = [
|
|
17
|
-
/^10\./,
|
|
18
|
-
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
19
|
-
/^192\.168\./,
|
|
20
|
-
/^127\./,
|
|
21
|
-
/^169\.254\./,
|
|
22
|
-
/^100\.(6[4-9]|[7-9]\d|1([01]\d|2[0-7]))\./, // CGNAT 100.64-100.127
|
|
23
|
-
/^0\./,
|
|
24
|
-
/^255\./,
|
|
25
|
-
];
|
|
26
|
-
// Private/reserved IPv6 ranges (string prefix checks)
|
|
27
|
-
const PRIVATE_IPV6_PREFIXES = [
|
|
28
|
-
'::1', // loopback
|
|
29
|
-
'fc', // ULA fc00::/7
|
|
30
|
-
'fd', // ULA fd00::/7
|
|
31
|
-
'fe80', // link-local fe80::/10
|
|
32
|
-
];
|
|
33
|
-
function isPrivateIpv4(ip) {
|
|
34
|
-
return PRIVATE_IPV4_RANGES.some((r) => r.test(ip));
|
|
35
|
-
}
|
|
36
|
-
function isPrivateIpv6(ip) {
|
|
37
|
-
const lower = ip.toLowerCase();
|
|
38
|
-
return PRIVATE_IPV6_PREFIXES.some((p) => lower.startsWith(p));
|
|
39
|
-
}
|
|
40
|
-
/** Check if an IP or CIDR base is private/reserved. */
|
|
41
|
-
function isPrivateIp(ip) {
|
|
42
|
-
const base = ip.split('/')[0] ?? '';
|
|
43
|
-
if (base.includes(':'))
|
|
44
|
-
return isPrivateIpv6(base);
|
|
45
|
-
return isPrivateIpv4(base);
|
|
46
|
-
}
|
|
47
|
-
/** Simple IPv4 validation */
|
|
48
|
-
function isValidIpv4(ip) {
|
|
49
|
-
const parts = ip.split('.');
|
|
50
|
-
if (parts.length !== 4)
|
|
51
|
-
return false;
|
|
52
|
-
return parts.every((p) => {
|
|
53
|
-
const n = parseInt(p, 10);
|
|
54
|
-
return !Number.isNaN(n) && n >= 0 && n <= 255 && String(n) === p;
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
/** Simple IPv6 validation — accepts compressed forms */
|
|
58
|
-
function isValidIpv6(ip) {
|
|
59
|
-
if (!ip.includes(':'))
|
|
60
|
-
return false;
|
|
61
|
-
if (ip.split(':').length > 8)
|
|
62
|
-
return false;
|
|
63
|
-
return /^[0-9a-fA-F:]+$/.test(ip);
|
|
64
|
-
}
|
|
65
|
-
/** Validate an IP address or CIDR notation */
|
|
17
|
+
/** Parse a complete IP/CIDR token, retaining base-address lookup semantics. */
|
|
66
18
|
export function validateIp(ip) {
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
19
|
+
const [base = '', prefix, ...extra] = ip.split('/');
|
|
20
|
+
const family = isIP(base);
|
|
21
|
+
if (!family || base.includes('%') || extra.length > 0)
|
|
22
|
+
return { valid: false };
|
|
23
|
+
if (prefix !== undefined &&
|
|
24
|
+
(!/^\d+$/.test(prefix) || Number(prefix) > (family === 6 ? 128 : 32))) {
|
|
25
|
+
return { valid: false };
|
|
74
26
|
}
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
27
|
+
const value = family === 6 ? ipv6ToBigInt(base) : ipv4ToBigInt(base);
|
|
28
|
+
const mapped = family === 6 && value >> 32n === 0xffffn;
|
|
29
|
+
const lookupValue = mapped ? value & 0xffffffffn : value;
|
|
30
|
+
const lookupBase = mapped
|
|
31
|
+
? [24n, 16n, 8n, 0n].map((shift) => Number((lookupValue >> shift) & 255n)).join('.')
|
|
32
|
+
: base;
|
|
33
|
+
return {
|
|
34
|
+
valid: true,
|
|
35
|
+
base: lookupBase,
|
|
36
|
+
value: lookupValue,
|
|
37
|
+
isIpv6: family === 6 && !mapped,
|
|
38
|
+
hasCidr: prefix !== undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Apply the server's explicit exclusions, not a blanket special-use policy. */
|
|
42
|
+
function isPrivateIp(ip) {
|
|
43
|
+
if (ip.isIpv6) {
|
|
44
|
+
return ip.value === 1n || ip.value >> 121n === 0x7en || ip.value >> 118n === 0x3fan;
|
|
80
45
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return
|
|
46
|
+
const first = Number(ip.value >> 24n);
|
|
47
|
+
const second = Number((ip.value >> 16n) & 255n);
|
|
48
|
+
return ([0, 10, 127, 255].includes(first) ||
|
|
49
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
50
|
+
(first === 169 && second === 254) ||
|
|
51
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
52
|
+
(first === 192 && second === 168));
|
|
53
|
+
}
|
|
54
|
+
/** Convert a validated IPv4 address to its numeric value. */
|
|
55
|
+
function ipv4ToBigInt(ip) {
|
|
56
|
+
return ip.split('.').reduce((value, octet) => (value << 8n) | BigInt(octet), 0n);
|
|
84
57
|
}
|
|
85
58
|
/** Build reverse PTR query name from an IPv4 address */
|
|
86
59
|
export function ipv4ToPtr(ip) {
|
|
@@ -88,6 +61,11 @@ export function ipv4ToPtr(ip) {
|
|
|
88
61
|
}
|
|
89
62
|
/** Expand a compressed IPv6 address to a full 128-bit BigInt */
|
|
90
63
|
function ipv6ToBigInt(ip) {
|
|
64
|
+
if (ip.includes('.')) {
|
|
65
|
+
const lastColon = ip.lastIndexOf(':');
|
|
66
|
+
const tail = ipv4ToBigInt(ip.slice(lastColon + 1));
|
|
67
|
+
ip = `${ip.slice(0, lastColon + 1)}${(tail >> 16n).toString(16)}:${(tail & 0xffffn).toString(16)}`;
|
|
68
|
+
}
|
|
91
69
|
let groups;
|
|
92
70
|
if (ip.includes('::')) {
|
|
93
71
|
const parts = ip.split('::');
|
|
@@ -103,22 +81,7 @@ function ipv6ToBigInt(ip) {
|
|
|
103
81
|
}
|
|
104
82
|
/** Build reverse PTR query name from an IPv6 address */
|
|
105
83
|
export function ipv6ToPtr(ip) {
|
|
106
|
-
|
|
107
|
-
// Handle :: compression
|
|
108
|
-
if (ip.includes('::')) {
|
|
109
|
-
const parts = ip.split('::');
|
|
110
|
-
const left = parts[0] ? parts[0].split(':') : [];
|
|
111
|
-
const right = parts[1] ? parts[1].split(':') : [];
|
|
112
|
-
const missing = 8 - left.length - right.length;
|
|
113
|
-
const full = [...left, ...Array(missing).fill('0'), ...right];
|
|
114
|
-
hex = full.map((g) => g.padStart(4, '0')).join('');
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
hex = ip
|
|
118
|
-
.split(':')
|
|
119
|
-
.map((g) => g.padStart(4, '0'))
|
|
120
|
-
.join('');
|
|
121
|
-
}
|
|
84
|
+
const hex = ipv6ToBigInt(ip).toString(16).padStart(32, '0');
|
|
122
85
|
return `${hex.split('').reverse().join('.')}.ip6.arpa`;
|
|
123
86
|
}
|
|
124
87
|
/** Extract top-level TLD (last label) */
|
|
@@ -134,17 +97,15 @@ function extractTwoLabelSuffix(domain) {
|
|
|
134
97
|
return labels.slice(-2).join('.');
|
|
135
98
|
}
|
|
136
99
|
/** Identify RIR from an RDAP self-link URL */
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (selfLink.includes('afrinic.net'))
|
|
147
|
-
return 'AFRINIC';
|
|
100
|
+
function rirFromSelfLink(selfLink) {
|
|
101
|
+
const url = URL.parse(selfLink);
|
|
102
|
+
if (!url)
|
|
103
|
+
return;
|
|
104
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
|
|
105
|
+
for (const rir of ['arin', 'ripe', 'apnic', 'lacnic', 'afrinic']) {
|
|
106
|
+
if (hostname === `${rir}.net` || hostname.endsWith(`.${rir}.net`))
|
|
107
|
+
return rir.toUpperCase();
|
|
108
|
+
}
|
|
148
109
|
return;
|
|
149
110
|
}
|
|
150
111
|
/** Extract vCard field value by field name */
|
|
@@ -191,6 +152,29 @@ function findEntityByRole(entities, role) {
|
|
|
191
152
|
function registrarIanaId(entity) {
|
|
192
153
|
return entity.publicIds?.find((p) => p.type === 'IANA Registrar ID')?.identifier;
|
|
193
154
|
}
|
|
155
|
+
const REDACTED_VALUES = new Set([
|
|
156
|
+
'REDACTED FOR PRIVACY',
|
|
157
|
+
'REDACTED REGISTRANT',
|
|
158
|
+
'REDACTED',
|
|
159
|
+
'DATA REDACTED',
|
|
160
|
+
'NOT DISCLOSED',
|
|
161
|
+
]);
|
|
162
|
+
/** Registrant organization and explicit, scoped privacy evidence; absence stays unknown. */
|
|
163
|
+
function registrantEvidence(raw) {
|
|
164
|
+
const registrant = findEntityByRole(raw.entities ?? [], 'registrant');
|
|
165
|
+
const org = registrant?.vcardArray ? vcardField(registrant.vcardArray[1], 'org') : undefined;
|
|
166
|
+
const placeholder = (value) => value !== undefined && REDACTED_VALUES.has(value.trim().toUpperCase());
|
|
167
|
+
const entityRedaction = registrant &&
|
|
168
|
+
(placeholder(entityName(registrant)) ||
|
|
169
|
+
placeholder(org) ||
|
|
170
|
+
(registrant.redacted?.length ?? 0) > 0 ||
|
|
171
|
+
registrant.remarks?.some((remark) => /redact|privacy/i.test([remark.title, ...(remark.description ?? [])].join(' '))));
|
|
172
|
+
const scopedRedaction = raw.redacted?.some((entry) => /registrant/i.test([entry.name?.type, entry.name?.description, entry.prePath, entry.postPath].join(' ')));
|
|
173
|
+
return {
|
|
174
|
+
...(org && !placeholder(org) ? { registrant_org: org } : {}),
|
|
175
|
+
registrant_redacted: entityRedaction || scopedRedaction ? true : null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
194
178
|
/** Normalize RDAP domain response to our output shape */
|
|
195
179
|
function normalizeDomain(raw) {
|
|
196
180
|
const events = raw.events ?? [];
|
|
@@ -202,9 +186,7 @@ function normalizeDomain(raw) {
|
|
|
202
186
|
const registrarEntity = findEntityByRole(entities, 'registrar');
|
|
203
187
|
const registrar = registrarEntity ? entityName(registrarEntity) : undefined;
|
|
204
188
|
const registrarIanaIdVal = registrarEntity ? registrarIanaId(registrarEntity) : undefined;
|
|
205
|
-
const
|
|
206
|
-
const registrantOrg = registrantEntity ? entityName(registrantEntity) : undefined;
|
|
207
|
-
const registrantRedacted = !registrantOrg;
|
|
189
|
+
const evidence = registrantEvidence(raw);
|
|
208
190
|
const rdapLastUpdated = getDate('last update of RDAP database');
|
|
209
191
|
const createdDate = getDate('registration');
|
|
210
192
|
const updatedDate = getDate('last changed');
|
|
@@ -216,8 +198,8 @@ function normalizeDomain(raw) {
|
|
|
216
198
|
rdap_coverage: true,
|
|
217
199
|
status: raw.status ?? [],
|
|
218
200
|
nameservers,
|
|
219
|
-
dnssec_signed: raw.secureDNS?.delegationSigned ??
|
|
220
|
-
|
|
201
|
+
dnssec_signed: raw.secureDNS?.delegationSigned ?? null,
|
|
202
|
+
...evidence,
|
|
221
203
|
};
|
|
222
204
|
if (raw.handle !== undefined)
|
|
223
205
|
result.handle = raw.handle;
|
|
@@ -233,8 +215,6 @@ function normalizeDomain(raw) {
|
|
|
233
215
|
result.expiry_date = expiryDate;
|
|
234
216
|
if (rdapLastUpdated !== undefined)
|
|
235
217
|
result.rdap_last_updated = rdapLastUpdated;
|
|
236
|
-
if (registrantOrg !== undefined)
|
|
237
|
-
result.registrant_org = registrantOrg;
|
|
238
218
|
return result;
|
|
239
219
|
}
|
|
240
220
|
/** Normalize RDAP IP network response */
|
|
@@ -244,27 +224,27 @@ function normalizeIpNetwork(raw, ip) {
|
|
|
244
224
|
const abuse = findEntityByRole(entities, 'abuse');
|
|
245
225
|
const orgName = registrant ? entityName(registrant) : undefined;
|
|
246
226
|
const abuseEmail = abuse ? entityEmail(abuse) : undefined;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
else if (c.v6prefix !== undefined && c.length !== undefined)
|
|
254
|
-
cidr = `${c.v6prefix}/${c.length}`;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
227
|
+
const cidrs = (raw.cidr0_cidrs ?? []).flatMap((component) => {
|
|
228
|
+
const prefix = component.v4prefix ?? component.v6prefix;
|
|
229
|
+
return prefix !== undefined && component.length !== undefined
|
|
230
|
+
? [`${prefix}/${component.length}`]
|
|
231
|
+
: [];
|
|
232
|
+
});
|
|
257
233
|
const selfLink = raw.links?.find((l) => l.rel === 'self')?.href ?? '';
|
|
258
|
-
const rdapSource =
|
|
259
|
-
const result = {
|
|
234
|
+
const rdapSource = rirFromSelfLink(selfLink);
|
|
235
|
+
const result = {
|
|
236
|
+
ip,
|
|
237
|
+
cidrs,
|
|
238
|
+
...(cidrs.length === 1 ? { cidr: cidrs.join() } : {}),
|
|
239
|
+
ptr: null,
|
|
240
|
+
ptr_status: 'not_found',
|
|
241
|
+
};
|
|
260
242
|
if (raw.handle !== undefined)
|
|
261
243
|
result.handle = raw.handle;
|
|
262
244
|
if (raw.startAddress !== undefined)
|
|
263
245
|
result.start_address = raw.startAddress;
|
|
264
246
|
if (raw.endAddress !== undefined)
|
|
265
247
|
result.end_address = raw.endAddress;
|
|
266
|
-
if (cidr !== undefined)
|
|
267
|
-
result.cidr = cidr;
|
|
268
248
|
if (raw.ipVersion !== undefined)
|
|
269
249
|
result.ip_version = raw.ipVersion;
|
|
270
250
|
if (raw.name !== undefined)
|
|
@@ -287,7 +267,7 @@ function normalizeAutnum(raw, asn) {
|
|
|
287
267
|
const registrant = findEntityByRole(entities, 'registrant');
|
|
288
268
|
const orgName = registrant ? entityName(registrant) : undefined;
|
|
289
269
|
const selfLink = raw.links?.find((l) => l.rel === 'self')?.href ?? '';
|
|
290
|
-
const rir =
|
|
270
|
+
const rir = rirFromSelfLink(selfLink);
|
|
291
271
|
const result = { asn };
|
|
292
272
|
if (raw.handle !== undefined)
|
|
293
273
|
result.handle = raw.handle;
|
|
@@ -383,7 +363,7 @@ export class RdapService {
|
|
|
383
363
|
const netParts = net.split('.').map(Number);
|
|
384
364
|
if (netParts.length !== 4 || parts.length !== 4)
|
|
385
365
|
continue;
|
|
386
|
-
const mask = ~((1 << (32 - prefixLen)) - 1);
|
|
366
|
+
const mask = prefixLen === 0 ? 0 : ~((1 << (32 - prefixLen)) - 1);
|
|
387
367
|
const [n0, n1, n2, n3] = netParts;
|
|
388
368
|
const [p0, p1, p2, p3] = parts;
|
|
389
369
|
const netNum = (n0 << 24) | (n1 << 16) | (n2 << 8) | n3;
|
|
@@ -413,7 +393,8 @@ export class RdapService {
|
|
|
413
393
|
return null;
|
|
414
394
|
}
|
|
415
395
|
/** Look up a domain registration record via RDAP */
|
|
416
|
-
async lookupDomain(domain, ctx) {
|
|
396
|
+
async lookupDomain(domain, ctx, includeRegistrar = false) {
|
|
397
|
+
ctx.signal.throwIfAborted();
|
|
417
398
|
const rdapServer = await this.findDomainRdapServer(domain, ctx);
|
|
418
399
|
if (!rdapServer) {
|
|
419
400
|
return {
|
|
@@ -421,8 +402,8 @@ export class RdapService {
|
|
|
421
402
|
rdap_coverage: false,
|
|
422
403
|
status: [],
|
|
423
404
|
nameservers: [],
|
|
424
|
-
dnssec_signed:
|
|
425
|
-
registrant_redacted:
|
|
405
|
+
dnssec_signed: null,
|
|
406
|
+
registrant_redacted: null,
|
|
426
407
|
};
|
|
427
408
|
}
|
|
428
409
|
const config = getServerConfig();
|
|
@@ -441,15 +422,19 @@ export class RdapService {
|
|
|
441
422
|
maxRetries: config.rdapMaxRetries,
|
|
442
423
|
baseDelayMs: 1000,
|
|
443
424
|
signal: ctx.signal,
|
|
444
|
-
//
|
|
425
|
+
// A missing registry object is terminal, not an eligibility statement.
|
|
445
426
|
isTransient: (err) => !isRdapNotFound(err),
|
|
446
427
|
});
|
|
447
|
-
|
|
428
|
+
const record = normalizeDomain(raw);
|
|
429
|
+
ctx.signal.throwIfAborted();
|
|
430
|
+
if (includeRegistrar)
|
|
431
|
+
record.registrar_detail = await this.registrarDetail(raw, domain, ctx);
|
|
432
|
+
return record;
|
|
448
433
|
}
|
|
449
434
|
catch (err) {
|
|
450
|
-
// RDAP 404
|
|
435
|
+
// RDAP 404 establishes record absence only.
|
|
451
436
|
if (isRdapNotFound(err)) {
|
|
452
|
-
throw notFound(`
|
|
437
|
+
throw notFound(`No RDAP domain record found for "${domain}" (404).`, {
|
|
453
438
|
reason: 'domain_not_found',
|
|
454
439
|
domain: domain.toLowerCase(),
|
|
455
440
|
});
|
|
@@ -457,15 +442,82 @@ export class RdapService {
|
|
|
457
442
|
throw err;
|
|
458
443
|
}
|
|
459
444
|
}
|
|
445
|
+
/** One optional registrar exchange, with guarded redirects and a total deadline. */
|
|
446
|
+
async registrarDetail(raw, domain, ctx) {
|
|
447
|
+
const referral = raw.links?.find((link) => link.rel === 'related' && link.type?.toLowerCase() === 'application/rdap+json' && link.href)?.href;
|
|
448
|
+
if (!referral)
|
|
449
|
+
return { status: 'no_referral' };
|
|
450
|
+
try {
|
|
451
|
+
const url = new URL(referral);
|
|
452
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
453
|
+
throw validationError('Registrar referral must use HTTPS without credentials.');
|
|
454
|
+
const signal = AbortSignal.any([
|
|
455
|
+
ctx.signal,
|
|
456
|
+
AbortSignal.timeout(getServerConfig().rdapTimeoutMs),
|
|
457
|
+
]);
|
|
458
|
+
signal.throwIfAborted();
|
|
459
|
+
const aborted = Promise.withResolvers();
|
|
460
|
+
const onAbort = () => aborted.reject(signal.reason);
|
|
461
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
462
|
+
let response;
|
|
463
|
+
try {
|
|
464
|
+
// The guard's DNS preflight precedes its fetch timer; bound that await as well.
|
|
465
|
+
response = await Promise.race([
|
|
466
|
+
fetchWithTimeout(url, getServerConfig().rdapTimeoutMs, ctx, {
|
|
467
|
+
headers: { Accept: 'application/rdap+json' },
|
|
468
|
+
signal,
|
|
469
|
+
rejectPrivateIPs: true,
|
|
470
|
+
}),
|
|
471
|
+
aborted.promise,
|
|
472
|
+
]);
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
signal.removeEventListener('abort', onAbort);
|
|
476
|
+
}
|
|
477
|
+
if (!response.headers
|
|
478
|
+
.get('content-type')
|
|
479
|
+
?.toLowerCase()
|
|
480
|
+
.split(';')[0]
|
|
481
|
+
?.trim()
|
|
482
|
+
.match(/^application\/(?:rdap\+json|json)$/))
|
|
483
|
+
throw serviceUnavailable('Registrar response is not RDAP JSON.');
|
|
484
|
+
const detail = await response.json();
|
|
485
|
+
signal.throwIfAborted();
|
|
486
|
+
if (!detail ||
|
|
487
|
+
typeof detail !== 'object' ||
|
|
488
|
+
!('objectClassName' in detail) ||
|
|
489
|
+
detail.objectClassName !== 'domain' ||
|
|
490
|
+
!('ldhName' in detail) ||
|
|
491
|
+
typeof detail.ldhName !== 'string' ||
|
|
492
|
+
detail.ldhName.toLowerCase() !== domain.toLowerCase())
|
|
493
|
+
throw serviceUnavailable('Registrar response does not contain the matching domain object.');
|
|
494
|
+
const evidence = registrantEvidence(detail);
|
|
495
|
+
return { status: 'found', source: response.url || url.href, ...evidence };
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
ctx.signal.throwIfAborted();
|
|
499
|
+
return {
|
|
500
|
+
status: 'failed',
|
|
501
|
+
source: referral,
|
|
502
|
+
source_error: error instanceof Error ? error.message : String(error),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
460
506
|
/**
|
|
461
|
-
* Check
|
|
462
|
-
*
|
|
463
|
-
* or `{ available: null }` when TLD has no RDAP coverage.
|
|
507
|
+
* Check registry record presence; a miss never establishes registration eligibility.
|
|
508
|
+
* Recognized ICANN subdomains are rejected without querying their parent.
|
|
464
509
|
*/
|
|
465
510
|
async checkAvailability(domain, ctx) {
|
|
511
|
+
const suffix = parse(domain, { allowPrivateDomains: false });
|
|
512
|
+
if (suffix.isIcann && suffix.domain && suffix.subdomain) {
|
|
513
|
+
throw validationError(`"${domain}" is a subdomain; its registrable parent is "${suffix.domain}".`, {
|
|
514
|
+
reason: 'non_registrable_domain',
|
|
515
|
+
registrable_parent: suffix.domain,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
466
518
|
const rdapServer = await this.findDomainRdapServer(domain, ctx);
|
|
467
519
|
if (!rdapServer) {
|
|
468
|
-
return { available: null, rdap_coverage: false };
|
|
520
|
+
return { available: null, registration_status: 'unknown', rdap_coverage: false };
|
|
469
521
|
}
|
|
470
522
|
const config = getServerConfig();
|
|
471
523
|
const url = `${rdapServer}/domain/${encodeURIComponent(domain.toLowerCase())}`;
|
|
@@ -483,39 +535,42 @@ export class RdapService {
|
|
|
483
535
|
maxRetries: config.rdapMaxRetries,
|
|
484
536
|
baseDelayMs: 1000,
|
|
485
537
|
signal: ctx.signal,
|
|
486
|
-
//
|
|
538
|
+
// A registry miss is terminal record-status data.
|
|
487
539
|
isTransient: (err) => !isRdapNotFound(err),
|
|
488
540
|
});
|
|
489
541
|
const record = normalizeDomain(raw);
|
|
490
|
-
return { available: false, record };
|
|
542
|
+
return { available: false, registration_status: 'registered', record };
|
|
491
543
|
}
|
|
492
544
|
catch (err) {
|
|
493
|
-
//
|
|
545
|
+
// Preserve record absence independently of registration eligibility.
|
|
494
546
|
if (isRdapNotFound(err)) {
|
|
495
|
-
return { available: true };
|
|
547
|
+
return { available: null, registration_status: 'not_found', rdap_coverage: true };
|
|
496
548
|
}
|
|
497
549
|
throw err;
|
|
498
550
|
}
|
|
499
551
|
}
|
|
500
552
|
/** Look up an IP address or CIDR via RIR RDAP */
|
|
501
553
|
async lookupIp(ip, ctx) {
|
|
502
|
-
const
|
|
503
|
-
if (!valid) {
|
|
554
|
+
const parsed = validateIp(ip);
|
|
555
|
+
if (!parsed.valid) {
|
|
504
556
|
throw validationError(`"${ip}" is not a valid IPv4, IPv6, or CIDR address.`, {
|
|
505
557
|
reason: 'invalid_ip',
|
|
506
|
-
...ctx.recoveryFor('invalid_ip'),
|
|
507
558
|
});
|
|
508
559
|
}
|
|
509
|
-
const base
|
|
510
|
-
if (isPrivateIp(
|
|
511
|
-
throw validationError(`"${base}" is
|
|
560
|
+
const { base, isIpv6 } = parsed;
|
|
561
|
+
if (isPrivateIp(parsed)) {
|
|
562
|
+
throw validationError(`"${base}" is in an address range excluded by this server's lookup policy.`, {
|
|
512
563
|
reason: 'private_range',
|
|
513
|
-
...ctx.recoveryFor('private_range'),
|
|
514
564
|
});
|
|
515
565
|
}
|
|
516
|
-
const rdapServer = await this.findIpRdapServer(
|
|
566
|
+
const rdapServer = await this.findIpRdapServer(base, isIpv6, ctx);
|
|
517
567
|
if (!rdapServer) {
|
|
518
|
-
throw
|
|
568
|
+
throw notFound('No RIR RDAP route covers this IP address in the IANA bootstrap.', {
|
|
569
|
+
ip,
|
|
570
|
+
reason: 'ip_no_rdap_route',
|
|
571
|
+
retryable: false,
|
|
572
|
+
...ctx.recoveryFor('ip_no_rdap_route'),
|
|
573
|
+
});
|
|
519
574
|
}
|
|
520
575
|
const config = getServerConfig();
|
|
521
576
|
// IPv6 addresses contain colons which must NOT be percent-encoded in the RDAP path.
|
|
@@ -553,12 +608,12 @@ export class RdapService {
|
|
|
553
608
|
}
|
|
554
609
|
/** Look up an ASN via RIR RDAP */
|
|
555
610
|
async lookupAsn(asn, ctx) {
|
|
556
|
-
const
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
611
|
+
const token = asn.trim();
|
|
612
|
+
const match = /^(?:AS\s*)?\+?(\d+)$/i.exec(token);
|
|
613
|
+
const asnNum = match ? Number(match[1]) : NaN;
|
|
614
|
+
if (!Number.isInteger(asnNum) || asnNum < 1 || asnNum > 4294967295) {
|
|
615
|
+
throw validationError(`"${asn}" is not a valid ASN. Expected a decimal integer from 1 to 4294967295, optionally prefixed with AS and a leading + (e.g., AS15169 or +15169).`, {
|
|
560
616
|
reason: 'invalid_asn',
|
|
561
|
-
...ctx.recoveryFor('invalid_asn'),
|
|
562
617
|
});
|
|
563
618
|
}
|
|
564
619
|
const rdapServer = await this.findAsnRdapServer(asnNum, ctx);
|