@zhin.js/plugin-nettools 0.0.0-stage → 0.1.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +70 -2
  3. package/commands/dns/[name]/[[type]]/index.js +18 -0
  4. package/commands/dns/[name]/[[type]]/index.ts +18 -0
  5. package/commands/domain-price/[suffix]/[[order]]/index.js +19 -0
  6. package/commands/domain-price/[suffix]/[[order]]/index.ts +19 -0
  7. package/commands/ping/[host]/index.js +14 -0
  8. package/commands/ping/[host]/index.ts +14 -0
  9. package/commands/whois/[domain]/index.js +14 -0
  10. package/commands/whois/[domain]/index.ts +14 -0
  11. package/lib/address-policy.d.ts +44 -0
  12. package/lib/address-policy.js +388 -0
  13. package/lib/address-policy.js.map +1 -0
  14. package/lib/config.d.ts +44 -0
  15. package/lib/config.js +38 -0
  16. package/lib/config.js.map +1 -0
  17. package/lib/cooldown.d.ts +29 -0
  18. package/lib/cooldown.js +80 -0
  19. package/lib/cooldown.js.map +1 -0
  20. package/lib/dns.d.ts +52 -0
  21. package/lib/dns.js +191 -0
  22. package/lib/dns.js.map +1 -0
  23. package/lib/domain-price.d.ts +28 -0
  24. package/lib/domain-price.js +165 -0
  25. package/lib/domain-price.js.map +1 -0
  26. package/lib/errors.d.ts +23 -0
  27. package/lib/errors.js +30 -0
  28. package/lib/errors.js.map +1 -0
  29. package/lib/http.d.ts +50 -0
  30. package/lib/http.js +242 -0
  31. package/lib/http.js.map +1 -0
  32. package/lib/index.d.ts +12 -0
  33. package/lib/index.js +13 -0
  34. package/lib/index.js.map +1 -0
  35. package/lib/ping.d.ts +45 -0
  36. package/lib/ping.js +195 -0
  37. package/lib/ping.js.map +1 -0
  38. package/lib/runtime.d.ts +3 -0
  39. package/lib/runtime.js +4 -0
  40. package/lib/runtime.js.map +1 -0
  41. package/lib/service.d.ts +18 -0
  42. package/lib/service.js +96 -0
  43. package/lib/service.js.map +1 -0
  44. package/lib/target-guard.d.ts +24 -0
  45. package/lib/target-guard.js +59 -0
  46. package/lib/target-guard.js.map +1 -0
  47. package/lib/whois.d.ts +31 -0
  48. package/lib/whois.js +345 -0
  49. package/lib/whois.js.map +1 -0
  50. package/package.json +105 -3
  51. package/plugin.js +14 -0
  52. package/schema.json +62 -0
  53. package/skills/nettools/SKILL.md +44 -0
  54. package/skills/nettools/tools/dns_lookup/index.js +20 -0
  55. package/skills/nettools/tools/domain_price/index.js +19 -0
  56. package/skills/nettools/tools/net_ping/index.js +16 -0
  57. package/skills/nettools/tools/whois_lookup/index.js +16 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # @zhin.js/plugin-nettools
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 17e1cae: Add `@zhin.js/plugin-nettools`: multi-region `ping` (check-host.net), `whois` (RDAP with rdap.org and WHOIS fallbacks), `dns` over HTTPS (Cloudflare → AliDNS → Google) and `domain-price` comparison (nazhumi.com), plus Agent tools `net_ping`, `whois_lookup`, `dns_lookup` and `domain_price`. Targets are canonicalised and refused when private, loopback, link-local, reserved, multicast or cloud-metadata (including hostnames resolving there); every request has a timeout, retry with exponential backoff and jitter, Retry-After handling and a per-user cooldown.
8
+ - Updated dependencies [1a11207]
9
+ - Updated dependencies [9943df6]
10
+ - @zhin.js/tool@1.1.8
11
+ - @zhin.js/skill@1.1.8
package/README.md CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # @zhin.js/plugin-nettools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Network tools for Zhin.js: multi-region ping, RDAP whois, DNS-over-HTTPS lookups and domain price comparison. Platform-agnostic, keyless (every upstream API is public), with per-user cooldown, an SSRF guard and resilient HTTP.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm exec zhin install @zhin.js/plugin-nettools
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Run the installation command from your application directory. It installs the package and mounts the `nettools` instance in 主配置 `plugins` 映射. The corresponding `zhin.config.yml` node is:
14
+
15
+ ```yaml
16
+ configVersion: 2
17
+ plugins:
18
+ nettools: "@zhin.js/plugin-nettools"
19
+ nettools: {}
20
+ ```
21
+
22
+ ### Commands
23
+
24
+ | Command | Description | Upstream |
25
+ |---------|-------------|----------|
26
+ | `ping <host>` | Ping a public host from several regions; shows average/min/max latency and packet loss per node | [check-host.net](https://check-host.net/about/api) |
27
+ | `whois <domain>` | Registrar, creation/expiry/update dates, status, name servers, DNSSEC | RDAP via the [IANA bootstrap](https://data.iana.org/rdap/dns.json), fallback [rdap.org](https://rdap.org), then the [60s API](https://60s.viki.moe) WHOIS (covers `.cn` and other TLDs without RDAP; registrant personal data is not shown) |
28
+ | `dns <name> [type]` | DNS records; `type` is one of `A AAAA CNAME MX TXT NS SOA CAA SRV PTR DS DNSKEY` (default `A`). `dns 1.1.1.1 PTR` does a reverse lookup | Cloudflare DoH, fallback AliDNS DoH, then Google DoH |
29
+ | `domain-price <suffix> [register\|renew\|transfer]` (alias `域名比价`) | Cheapest registrars for a suffix (`com`, `.cn`, `.co.uk`, or a full domain); Chinese kinds `注册 / 续费 / 转入` also work | [nazhumi.com](https://www.nazhumi.com) |
30
+
31
+ ### Agent tools
32
+
33
+ Skill `nettools` exposes `net_ping`, `whois_lookup`, `dns_lookup` and `domain_price`, sharing the same validation, cooldown and resilience as the commands.
34
+
35
+ ## Configuration
36
+
37
+ All fields are optional; defaults are conservative because every upstream is a free, shared service. Out-of-range values are clamped.
38
+
39
+ | Field | Default | Range | Meaning |
40
+ |-------|---------|-------|---------|
41
+ | `cooldownSeconds` | `10` | 0–3600 | Per-user cooldown shared by all commands/tools (0 disables). Spent after input validation, so typos are free |
42
+ | `requestTimeoutMs` | `8000` | 1000–30000 | Timeout for each HTTP attempt |
43
+ | `maxRetries` | `2` | 0–5 | Retries for transient failures |
44
+ | `pingMaxNodes` | `4` | 1–10 | check-host.net probe nodes |
45
+ | `pingPollIntervalMs` | `2000` | 500–10000 | Wait between result polls |
46
+ | `pingMaxPolls` | `5` | 1–10 | Poll budget; unfinished nodes are reported as "no result" |
47
+ | `dohProviders` | `["cloudflare", "alidns", "google"]` | — | DoH providers in fallback order |
48
+ | `priceListLimit` | `8` | 1–20 | Registrars shown per comparison |
49
+
50
+ ```yaml
51
+ nettools:
52
+ cooldownSeconds: 30
53
+ pingMaxNodes: 6
54
+ ```
55
+
56
+ ## Security: target policy (SSRF guard)
57
+
58
+ User input is canonicalised with the WHATWG URL host parser (the parser `fetch` uses), so odd spellings such as `2130706433`, `0x7f.1`, `017700000001`, `127.1` or `[::ffff:127.0.0.1]` are checked in their real form. URLs, ports, paths, user-info, zone ids and percent-encoding are refused outright.
59
+
60
+ Refused targets:
61
+
62
+ - IPv4: `0.0.0.0/8`, private `10/8`, `172.16/12`, `192.168/16`, CGNAT `100.64/10`, loopback `127/8`, link-local `169.254/16`, IETF/documentation/benchmark ranges, multicast `224/4`, reserved `240/4`;
63
+ - IPv6: `::`, `::1`, ULA `fc00::/7`, link-local `fe80::/10`, site-local `fec0::/10`, multicast `ff00::/8`, documentation, Teredo, anything outside global unicast `2000::/3`; IPv4-mapped, NAT64 and 6to4 addresses are unwrapped and judged by their embedded IPv4;
64
+ - cloud metadata: `169.254.169.254`, `169.254.170.2`, `100.100.100.200`, `168.63.129.16`, `fd00:ec2::254`, `metadata.google.internal`, …;
65
+ - names: single-label hosts, `localhost`, and internal/reserved suffixes (`.local`, `.internal`, `.lan`, `.home.arpa`, `.test`, `.invalid`, …).
66
+
67
+ For `ping`, hostnames are also resolved (A + AAAA over DoH) and refused if **any** address is blocked; resolution failure fails closed. The probe itself runs on check-host.net, so this process never connects to the target.
68
+
69
+ ## Resilience
70
+
71
+ Every external request has a per-attempt timeout, retries network errors, timeouts, 408/425/429/5xx with exponential backoff plus jitter, honours `Retry-After` up to 10 s (longer waits fail fast with a rate-limit message), caps response bodies at 1 MiB and parses JSON defensively. DoH falls back Cloudflare → AliDNS → Google (only one retry per provider while another remains, to bound latency); whois falls back registry RDAP → rdap.org → 60s WHOIS (the IANA bootstrap is cached for 24 h, stale copies are reused if IANA is down); a failed ping poll does not discard a running check. Failures are reported as short, specific messages.
@@ -0,0 +1,18 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from 'zhin.js/command';
3
+ import { commandCallerKey } from "../../../../lib/cooldown.js";
4
+ import { nettoolsServiceToken } from "../../../../lib/runtime.js";
5
+ export default defineCommand({
6
+ description: 'DNS 查询(DoH),类型可选 A/AAAA/CNAME/MX/TXT/NS/SOA/CAA/SRV/PTR/DS/DNSKEY',
7
+ params: {
8
+ name: { type: 'string', description: '域名;PTR 查询时可填 IP' },
9
+ type: { type: 'string', description: '记录类型,默认 A' },
10
+ },
11
+ async execute({ params, input, use, signal }) {
12
+ const service = use(nettoolsServiceToken);
13
+ const name = String(params.name ?? '');
14
+ const type = params.type == null ? undefined : String(params.type);
15
+ const callerKey = commandCallerKey(input);
16
+ return service.run(() => service.dns(name, type, callerKey, signal));
17
+ },
18
+ });
@@ -0,0 +1,18 @@
1
+ import { defineCommand } from 'zhin.js/command';
2
+ import { commandCallerKey } from '../../../../src/cooldown.js';
3
+ import { nettoolsServiceToken } from '../../../../src/runtime.js';
4
+
5
+ export default defineCommand({
6
+ description: 'DNS 查询(DoH),类型可选 A/AAAA/CNAME/MX/TXT/NS/SOA/CAA/SRV/PTR/DS/DNSKEY',
7
+ params: {
8
+ name: { type: 'string', description: '域名;PTR 查询时可填 IP' },
9
+ type: { type: 'string', description: '记录类型,默认 A' },
10
+ },
11
+ async execute({ params, input, use, signal }) {
12
+ const service = use(nettoolsServiceToken);
13
+ const name = String(params.name ?? '');
14
+ const type = params.type == null ? undefined : String(params.type);
15
+ const callerKey = commandCallerKey(input);
16
+ return service.run(() => service.dns(name, type, callerKey, signal));
17
+ },
18
+ });
@@ -0,0 +1,19 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from 'zhin.js/command';
3
+ import { commandCallerKey } from "../../../../lib/cooldown.js";
4
+ import { nettoolsServiceToken } from "../../../../lib/runtime.js";
5
+ export default defineCommand({
6
+ description: '域名注册/续费/转入价格比价(nazhumi.com)',
7
+ alias: ['域名比价'],
8
+ params: {
9
+ suffix: { type: 'string', description: '域名后缀(com、.cn、.co.uk)或完整域名' },
10
+ order: { type: 'string', description: '注册(register) / 续费(renew) / 转入(transfer),默认注册' },
11
+ },
12
+ async execute({ params, input, use, signal }) {
13
+ const service = use(nettoolsServiceToken);
14
+ const suffix = String(params.suffix ?? '');
15
+ const order = params.order == null ? undefined : String(params.order);
16
+ const callerKey = commandCallerKey(input);
17
+ return service.run(() => service.domainPrice(suffix, order, callerKey, signal));
18
+ },
19
+ });
@@ -0,0 +1,19 @@
1
+ import { defineCommand } from 'zhin.js/command';
2
+ import { commandCallerKey } from '../../../../src/cooldown.js';
3
+ import { nettoolsServiceToken } from '../../../../src/runtime.js';
4
+
5
+ export default defineCommand({
6
+ description: '域名注册/续费/转入价格比价(nazhumi.com)',
7
+ alias: ['域名比价'],
8
+ params: {
9
+ suffix: { type: 'string', description: '域名后缀(com、.cn、.co.uk)或完整域名' },
10
+ order: { type: 'string', description: '注册(register) / 续费(renew) / 转入(transfer),默认注册' },
11
+ },
12
+ async execute({ params, input, use, signal }) {
13
+ const service = use(nettoolsServiceToken);
14
+ const suffix = String(params.suffix ?? '');
15
+ const order = params.order == null ? undefined : String(params.order);
16
+ const callerKey = commandCallerKey(input);
17
+ return service.run(() => service.domainPrice(suffix, order, callerKey, signal));
18
+ },
19
+ });
@@ -0,0 +1,14 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from 'zhin.js/command';
3
+ import { commandCallerKey } from "../../../lib/cooldown.js";
4
+ import { nettoolsServiceToken } from "../../../lib/runtime.js";
5
+ export default defineCommand({
6
+ description: '从多个地区 Ping 一个公网主机(check-host.net)',
7
+ params: { host: { type: 'string', description: '域名或公网 IP' } },
8
+ async execute({ params, input, use, signal }) {
9
+ const service = use(nettoolsServiceToken);
10
+ const host = String(params.host ?? '');
11
+ const callerKey = commandCallerKey(input);
12
+ return service.run(() => service.ping(host, callerKey, signal));
13
+ },
14
+ });
@@ -0,0 +1,14 @@
1
+ import { defineCommand } from 'zhin.js/command';
2
+ import { commandCallerKey } from '../../../src/cooldown.js';
3
+ import { nettoolsServiceToken } from '../../../src/runtime.js';
4
+
5
+ export default defineCommand({
6
+ description: '从多个地区 Ping 一个公网主机(check-host.net)',
7
+ params: { host: { type: 'string', description: '域名或公网 IP' } },
8
+ async execute({ params, input, use, signal }) {
9
+ const service = use(nettoolsServiceToken);
10
+ const host = String(params.host ?? '');
11
+ const callerKey = commandCallerKey(input);
12
+ return service.run(() => service.ping(host, callerKey, signal));
13
+ },
14
+ });
@@ -0,0 +1,14 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from 'zhin.js/command';
3
+ import { commandCallerKey } from "../../../lib/cooldown.js";
4
+ import { nettoolsServiceToken } from "../../../lib/runtime.js";
5
+ export default defineCommand({
6
+ description: '查询域名注册信息(RDAP / WHOIS)',
7
+ params: { domain: { type: 'string', description: '要查询的域名' } },
8
+ async execute({ params, input, use, signal }) {
9
+ const service = use(nettoolsServiceToken);
10
+ const domain = String(params.domain ?? '');
11
+ const callerKey = commandCallerKey(input);
12
+ return service.run(() => service.whois(domain, callerKey, signal));
13
+ },
14
+ });
@@ -0,0 +1,14 @@
1
+ import { defineCommand } from 'zhin.js/command';
2
+ import { commandCallerKey } from '../../../src/cooldown.js';
3
+ import { nettoolsServiceToken } from '../../../src/runtime.js';
4
+
5
+ export default defineCommand({
6
+ description: '查询域名注册信息(RDAP / WHOIS)',
7
+ params: { domain: { type: 'string', description: '要查询的域名' } },
8
+ async execute({ params, input, use, signal }) {
9
+ const service = use(nettoolsServiceToken);
10
+ const domain = String(params.domain ?? '');
11
+ const callerKey = commandCallerKey(input);
12
+ return service.run(() => service.whois(domain, callerKey, signal));
13
+ },
14
+ });
@@ -0,0 +1,44 @@
1
+ export type ParsedHost = {
2
+ readonly kind: 'ipv4';
3
+ readonly address: string;
4
+ readonly bytes: Uint8Array;
5
+ } | {
6
+ readonly kind: 'ipv6';
7
+ readonly address: string;
8
+ readonly bytes: Uint8Array;
9
+ } | {
10
+ readonly kind: 'domain';
11
+ readonly name: string;
12
+ };
13
+ export type AddressVerdict = {
14
+ readonly blocked: false;
15
+ } | {
16
+ readonly blocked: true;
17
+ readonly reason: string;
18
+ };
19
+ export interface ParseHostOptions {
20
+ /** Allow `_` in labels (needed for DNS names such as `_dmarc.example.com`). */
21
+ readonly allowUnderscore?: boolean;
22
+ /** Minimum number of labels for a domain (default 2, i.e. a full domain). */
23
+ readonly minLabels?: number;
24
+ }
25
+ /**
26
+ * Parse and canonicalise one user-supplied host (domain, IPv4 or IPv6).
27
+ * Throws {@link NettoolsError} (`invalid_input`) for anything that is not a
28
+ * bare host: URLs, ports, paths, user-info, zone ids and percent-encoding are
29
+ * all refused rather than "helpfully" stripped.
30
+ */
31
+ export declare function parseHostInput(input: string, options?: ParseHostOptions): ParsedHost;
32
+ /** Classify a parsed host without any network access. Domains are checked by name only. */
33
+ export declare function classifyParsedHost(host: ParsedHost): AddressVerdict;
34
+ /** Classify a literal address string (any spelling the URL parser understands). */
35
+ export declare function classifyAddress(address: string): AddressVerdict;
36
+ export declare function classifyHostname(name: string): AddressVerdict;
37
+ /** Dotted-quad IPv4 to 4 bytes. Callers must pass a canonical dotted quad. */
38
+ export declare function ipv4ToBytes(address: string): Uint8Array;
39
+ /** Textual IPv6 (with optional `::` and dotted IPv4 tail) to 16 bytes. */
40
+ export declare function ipv6ToBytes(address: string): Uint8Array;
41
+ /** Build the PTR query name for an IP (`in-addr.arpa` / `ip6.arpa`). */
42
+ export declare function reverseLookupName(host: ParsedHost): string;
43
+ /** Human label for a parsed host. */
44
+ export declare function displayHost(host: ParsedHost): string;
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Target address policy (SSRF guard).
3
+ *
4
+ * Network tools probe hosts that users type into chat. Even though the probes
5
+ * themselves run on third-party services (check-host.net, public DoH, RDAP),
6
+ * we refuse anything that points at private, loopback, link-local, reserved or
7
+ * cloud-metadata space so the bot can never be used to map internal networks
8
+ * or poke metadata endpoints through a relay.
9
+ *
10
+ * Design:
11
+ * 1. Canonicalise first. The input is fed through the WHATWG URL host parser,
12
+ * the same parser browsers and Node's fetch use. It turns odd IPv4 spellings
13
+ * (`2130706433`, `0x7f.1`, `017700000001`, `127.1`) into dotted quads,
14
+ * normalises IPv6 and converts IDN to punycode. We then classify the
15
+ * canonical form, so "what we checked" is exactly "what we send".
16
+ * 2. Classify by bytes. IPv4 and IPv6 are compared as raw bytes against
17
+ * prefix tables. Embedded IPv4 (IPv4-mapped, NAT64, 6to4) is unwrapped and
18
+ * classified as IPv4.
19
+ * 3. Allow-list IPv6. Only global unicast (2000::/3) is accepted, minus the
20
+ * documentation / Teredo / special ranges listed below.
21
+ * 4. Names. Single-label names, internal suffixes (`.local`, `.internal`, ...)
22
+ * and well-known metadata hostnames are refused before any lookup.
23
+ * Hostnames are additionally resolved (see target-guard.ts) and refused
24
+ * when any A/AAAA record is blocked.
25
+ */
26
+ import { isIPv4, isIPv6 } from 'node:net';
27
+ import { NettoolsError } from './errors.js';
28
+ const ALLOWED = Object.freeze({ blocked: false });
29
+ function blocked(reason) {
30
+ return Object.freeze({ blocked: true, reason });
31
+ }
32
+ const CLOUD_METADATA_REASON = '云服务元数据地址';
33
+ /** Exact IPv4 metadata endpoints, checked before the range table for a clearer message. */
34
+ const CLOUD_METADATA_IPV4 = new Set([
35
+ '169.254.169.254', // AWS / GCP / Azure / Oracle / OpenStack instance metadata
36
+ '169.254.170.2', // AWS ECS task metadata
37
+ '169.254.169.123', // AWS Time Sync
38
+ '100.100.100.200', // Alibaba Cloud metadata
39
+ '168.63.129.16', // Azure WireServer (public range, still host-internal)
40
+ ]);
41
+ /** Exact IPv6 metadata endpoints. */
42
+ const CLOUD_METADATA_IPV6 = ['fd00:ec2::254', 'fd00:ec2::23'];
43
+ const BLOCKED_IPV4_RANGES = [
44
+ ipv4Rule('0.0.0.0', 8, '未指定 / “本网络”地址'),
45
+ ipv4Rule('10.0.0.0', 8, '私有地址 (RFC 1918)'),
46
+ ipv4Rule('100.64.0.0', 10, '运营商级 NAT 地址 (CGNAT, RFC 6598)'),
47
+ ipv4Rule('127.0.0.0', 8, '回环地址'),
48
+ ipv4Rule('169.254.0.0', 16, '链路本地地址'),
49
+ ipv4Rule('172.16.0.0', 12, '私有地址 (RFC 1918)'),
50
+ ipv4Rule('192.0.0.0', 24, 'IETF 协议保留地址'),
51
+ ipv4Rule('192.0.2.0', 24, '文档示例地址'),
52
+ ipv4Rule('192.88.99.0', 24, '已废弃的 6to4 中继地址'),
53
+ ipv4Rule('192.168.0.0', 16, '私有地址 (RFC 1918)'),
54
+ ipv4Rule('198.18.0.0', 15, '基准测试保留地址'),
55
+ ipv4Rule('198.51.100.0', 24, '文档示例地址'),
56
+ ipv4Rule('203.0.113.0', 24, '文档示例地址'),
57
+ ipv4Rule('224.0.0.0', 4, '组播地址'),
58
+ ipv4Rule('240.0.0.0', 4, '保留地址(含广播地址)'),
59
+ ];
60
+ /** Special IPv6 ranges inside 2000::/3 that are still refused. */
61
+ const BLOCKED_GLOBAL_IPV6_RANGES = [
62
+ ipv6Rule('2001::', 32, 'Teredo 隧道地址'),
63
+ ipv6Rule('2001:2::', 48, '基准测试保留地址'),
64
+ ipv6Rule('2001:10::', 28, 'ORCHID 保留地址'),
65
+ ipv6Rule('2001:db8::', 32, '文档示例地址'),
66
+ ipv6Rule('3fff::', 20, '文档示例地址'),
67
+ ];
68
+ const BLOCKED_HOSTNAMES = new Set([
69
+ 'localhost',
70
+ 'metadata',
71
+ 'metadata.google.internal',
72
+ 'metadata.goog',
73
+ 'instance-data',
74
+ 'instance-data.ec2.internal',
75
+ ]);
76
+ /** Suffixes that only make sense inside a private network or are reserved (RFC 2606 / 6761 / 6762). */
77
+ const BLOCKED_SUFFIXES = [
78
+ 'localhost',
79
+ 'local',
80
+ 'localdomain',
81
+ 'internal',
82
+ 'intranet',
83
+ 'private',
84
+ 'corp',
85
+ 'home',
86
+ 'lan',
87
+ 'home.arpa',
88
+ 'arpa',
89
+ 'test',
90
+ 'invalid',
91
+ 'example',
92
+ 'onion',
93
+ ];
94
+ const MAX_HOSTNAME_LENGTH = 253;
95
+ const MAX_LABEL_LENGTH = 63;
96
+ /* ------------------------------------------------------------------ */
97
+ /* Parsing */
98
+ /* ------------------------------------------------------------------ */
99
+ /**
100
+ * Parse and canonicalise one user-supplied host (domain, IPv4 or IPv6).
101
+ * Throws {@link NettoolsError} (`invalid_input`) for anything that is not a
102
+ * bare host: URLs, ports, paths, user-info, zone ids and percent-encoding are
103
+ * all refused rather than "helpfully" stripped.
104
+ */
105
+ export function parseHostInput(input, options = {}) {
106
+ const trimmed = input.trim();
107
+ if (trimmed.length === 0) {
108
+ throw invalid('请提供主机名或 IP 地址');
109
+ }
110
+ if (trimmed.length > MAX_HOSTNAME_LENGTH + 2) {
111
+ throw invalid('主机名过长');
112
+ }
113
+ if (/[\s/\\@?#%]/u.test(trimmed)) {
114
+ throw invalid('只接受裸主机名或 IP,不要带协议、端口、路径或特殊字符');
115
+ }
116
+ const bracketed = trimmed.startsWith('[') && trimmed.endsWith(']');
117
+ const ipv6Candidate = bracketed ? trimmed.slice(1, -1) : trimmed;
118
+ if (bracketed || trimmed.includes(':')) {
119
+ if (!isIPv6(ipv6Candidate)) {
120
+ throw invalid('不是有效的 IPv6 地址(如需端口请去掉端口)');
121
+ }
122
+ const canonical = canonicalizeWithUrlParser(`[${ipv6Candidate}]`);
123
+ const address = canonical.slice(1, -1);
124
+ return Object.freeze({ kind: 'ipv6', address, bytes: ipv6ToBytes(address) });
125
+ }
126
+ const canonical = canonicalizeWithUrlParser(trimmed);
127
+ if (isIPv4(canonical)) {
128
+ return Object.freeze({ kind: 'ipv4', address: canonical, bytes: ipv4ToBytes(canonical) });
129
+ }
130
+ const name = canonical.endsWith('.') ? canonical.slice(0, -1) : canonical;
131
+ validateDomainSyntax(name, options);
132
+ return Object.freeze({ kind: 'domain', name });
133
+ }
134
+ /**
135
+ * Run the WHATWG host parser by assigning to `URL#hostname` on a placeholder
136
+ * URL. An invalid host leaves the placeholder untouched, which we detect.
137
+ */
138
+ function canonicalizeWithUrlParser(host) {
139
+ const placeholder = 'placeholder.invalid';
140
+ const probe = new URL(`http://${placeholder}/`);
141
+ probe.hostname = host;
142
+ const result = probe.hostname;
143
+ const unchanged = result === placeholder && host.toLowerCase() !== placeholder;
144
+ if (unchanged || result.length === 0) {
145
+ throw invalid('不是有效的主机名或 IP 地址');
146
+ }
147
+ return result;
148
+ }
149
+ function validateDomainSyntax(name, options) {
150
+ if (name.length === 0 || name.length > MAX_HOSTNAME_LENGTH) {
151
+ throw invalid('主机名长度不合法');
152
+ }
153
+ const labels = name.split('.');
154
+ if (labels.length < (options.minLabels ?? 2)) {
155
+ throw invalid('请提供完整域名(例如 example.com)');
156
+ }
157
+ const labelPattern = options.allowUnderscore
158
+ ? /^[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?$/u
159
+ : /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u;
160
+ for (const label of labels) {
161
+ if (label.length === 0 || label.length > MAX_LABEL_LENGTH || !labelPattern.test(label)) {
162
+ throw invalid(`域名标签不合法:${label || '(空)'}`);
163
+ }
164
+ }
165
+ const topLevel = labels[labels.length - 1] ?? '';
166
+ if (/^\d+$/u.test(topLevel)) {
167
+ throw invalid('顶级域不能是纯数字');
168
+ }
169
+ }
170
+ function invalid(message) {
171
+ return new NettoolsError('invalid_input', message);
172
+ }
173
+ /* ------------------------------------------------------------------ */
174
+ /* Classification */
175
+ /* ------------------------------------------------------------------ */
176
+ /** Classify a parsed host without any network access. Domains are checked by name only. */
177
+ export function classifyParsedHost(host) {
178
+ if (host.kind === 'domain') {
179
+ return classifyHostname(host.name);
180
+ }
181
+ if (host.kind === 'ipv4') {
182
+ return classifyIpv4Bytes(host.bytes);
183
+ }
184
+ return classifyIpv6Bytes(host.bytes);
185
+ }
186
+ /** Classify a literal address string (any spelling the URL parser understands). */
187
+ export function classifyAddress(address) {
188
+ const parsed = parseHostInput(address);
189
+ if (parsed.kind === 'domain') {
190
+ throw invalid(`不是 IP 地址:${address}`);
191
+ }
192
+ return classifyParsedHost(parsed);
193
+ }
194
+ export function classifyHostname(name) {
195
+ const normalized = name.toLowerCase().replace(/\.$/u, '');
196
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
197
+ return normalized.startsWith('localhost')
198
+ ? blocked('本机主机名')
199
+ : blocked(CLOUD_METADATA_REASON);
200
+ }
201
+ for (const suffix of BLOCKED_SUFFIXES) {
202
+ if (normalized === suffix || normalized.endsWith(`.${suffix}`)) {
203
+ return blocked(`内网或保留域名后缀 .${suffix}`);
204
+ }
205
+ }
206
+ return ALLOWED;
207
+ }
208
+ function classifyIpv4Bytes(bytes) {
209
+ const dotted = Array.from(bytes).join('.');
210
+ if (CLOUD_METADATA_IPV4.has(dotted)) {
211
+ return blocked(CLOUD_METADATA_REASON);
212
+ }
213
+ for (const rule of BLOCKED_IPV4_RANGES) {
214
+ if (matchesPrefix(bytes, rule.prefix, rule.bits)) {
215
+ return blocked(rule.reason);
216
+ }
217
+ }
218
+ return ALLOWED;
219
+ }
220
+ const IPV4_MAPPED_PREFIX = ipv6ToBytes('::ffff:0:0');
221
+ const NAT64_WELL_KNOWN_PREFIX = ipv6ToBytes('64:ff9b::');
222
+ const NAT64_LOCAL_PREFIX = ipv6ToBytes('64:ff9b:1::');
223
+ const SIX_TO_FOUR_PREFIX = ipv6ToBytes('2002::');
224
+ const GLOBAL_UNICAST_PREFIX = ipv6ToBytes('2000::');
225
+ const UNIQUE_LOCAL_PREFIX = ipv6ToBytes('fc00::');
226
+ const LINK_LOCAL_PREFIX = ipv6ToBytes('fe80::');
227
+ const SITE_LOCAL_PREFIX = ipv6ToBytes('fec0::');
228
+ const MULTICAST_PREFIX = ipv6ToBytes('ff00::');
229
+ const CLOUD_METADATA_IPV6_BYTES = CLOUD_METADATA_IPV6.map((address) => ipv6ToBytes(address));
230
+ function classifyIpv6Bytes(bytes) {
231
+ if (bytes.every((byte) => byte === 0)) {
232
+ return blocked('未指定地址 (::)');
233
+ }
234
+ const isLoopback = bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1;
235
+ if (isLoopback) {
236
+ return blocked('回环地址 (::1)');
237
+ }
238
+ for (const metadata of CLOUD_METADATA_IPV6_BYTES) {
239
+ if (matchesPrefix(bytes, metadata, 128)) {
240
+ return blocked(CLOUD_METADATA_REASON);
241
+ }
242
+ }
243
+ if (matchesPrefix(bytes, IPV4_MAPPED_PREFIX, 96)) {
244
+ return classifyEmbeddedIpv4(bytes.slice(12, 16), 'IPv4 映射地址');
245
+ }
246
+ if (matchesPrefix(bytes, NAT64_WELL_KNOWN_PREFIX, 96)) {
247
+ return classifyEmbeddedIpv4(bytes.slice(12, 16), 'NAT64 地址');
248
+ }
249
+ if (matchesPrefix(bytes, NAT64_LOCAL_PREFIX, 48)) {
250
+ return blocked('本地 NAT64 地址');
251
+ }
252
+ if (matchesPrefix(bytes, SIX_TO_FOUR_PREFIX, 16)) {
253
+ return classifyEmbeddedIpv4(bytes.slice(2, 6), '6to4 地址');
254
+ }
255
+ if (matchesPrefix(bytes, UNIQUE_LOCAL_PREFIX, 7)) {
256
+ return blocked('唯一本地地址 (ULA, fc00::/7)');
257
+ }
258
+ if (matchesPrefix(bytes, LINK_LOCAL_PREFIX, 10)) {
259
+ return blocked('链路本地地址 (fe80::/10)');
260
+ }
261
+ if (matchesPrefix(bytes, SITE_LOCAL_PREFIX, 10)) {
262
+ return blocked('已废弃的站点本地地址 (fec0::/10)');
263
+ }
264
+ if (matchesPrefix(bytes, MULTICAST_PREFIX, 8)) {
265
+ return blocked('组播地址 (ff00::/8)');
266
+ }
267
+ if (!matchesPrefix(bytes, GLOBAL_UNICAST_PREFIX, 3)) {
268
+ return blocked('非全球单播 IPv6 地址');
269
+ }
270
+ for (const rule of BLOCKED_GLOBAL_IPV6_RANGES) {
271
+ if (matchesPrefix(bytes, rule.prefix, rule.bits)) {
272
+ return blocked(rule.reason);
273
+ }
274
+ }
275
+ return ALLOWED;
276
+ }
277
+ function classifyEmbeddedIpv4(embedded, wrapper) {
278
+ const verdict = classifyIpv4Bytes(embedded);
279
+ if (!verdict.blocked) {
280
+ return verdict;
281
+ }
282
+ const dotted = Array.from(embedded).join('.');
283
+ return blocked(`${wrapper}指向 ${dotted}(${verdict.reason})`);
284
+ }
285
+ /* ------------------------------------------------------------------ */
286
+ /* Byte helpers */
287
+ /* ------------------------------------------------------------------ */
288
+ function matchesPrefix(bytes, prefix, bits) {
289
+ if (bytes.length !== prefix.length) {
290
+ return false;
291
+ }
292
+ const fullBytes = Math.floor(bits / 8);
293
+ for (let index = 0; index < fullBytes; index += 1) {
294
+ if (bytes[index] !== prefix[index]) {
295
+ return false;
296
+ }
297
+ }
298
+ const remainingBits = bits % 8;
299
+ if (remainingBits === 0) {
300
+ return true;
301
+ }
302
+ const mask = (0xff << (8 - remainingBits)) & 0xff;
303
+ return ((bytes[fullBytes] ?? 0) & mask) === ((prefix[fullBytes] ?? 0) & mask);
304
+ }
305
+ /** Dotted-quad IPv4 to 4 bytes. Callers must pass a canonical dotted quad. */
306
+ export function ipv4ToBytes(address) {
307
+ const parts = address.split('.').map((part) => Number(part));
308
+ const valid = parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255);
309
+ if (!valid) {
310
+ throw invalid(`不是有效的 IPv4 地址:${address}`);
311
+ }
312
+ return Uint8Array.from(parts);
313
+ }
314
+ /** Textual IPv6 (with optional `::` and dotted IPv4 tail) to 16 bytes. */
315
+ export function ipv6ToBytes(address) {
316
+ let text = address.toLowerCase();
317
+ const groups = [];
318
+ const lastColon = text.lastIndexOf(':');
319
+ const tail = text.slice(lastColon + 1);
320
+ const tailGroups = [];
321
+ if (tail.includes('.')) {
322
+ const v4 = ipv4ToBytes(tail);
323
+ tailGroups.push(((v4[0] ?? 0) << 8) | (v4[1] ?? 0));
324
+ tailGroups.push(((v4[2] ?? 0) << 8) | (v4[3] ?? 0));
325
+ text = text.slice(0, lastColon + 1) + '0:0';
326
+ }
327
+ const halves = text.split('::');
328
+ if (halves.length > 2) {
329
+ throw invalid(`不是有效的 IPv6 地址:${address}`);
330
+ }
331
+ const head = parseGroups(halves[0] ?? '');
332
+ const rest = halves.length === 2 ? parseGroups(halves[1] ?? '') : [];
333
+ const missing = 8 - head.length - rest.length;
334
+ if (halves.length === 2 ? missing < 1 : missing !== 0) {
335
+ throw invalid(`不是有效的 IPv6 地址:${address}`);
336
+ }
337
+ groups.push(...head);
338
+ for (let index = 0; index < missing && halves.length === 2; index += 1) {
339
+ groups.push(0);
340
+ }
341
+ groups.push(...rest);
342
+ if (tailGroups.length === 2) {
343
+ groups.splice(6, 2, ...tailGroups);
344
+ }
345
+ const bytes = new Uint8Array(16);
346
+ groups.forEach((group, index) => {
347
+ bytes[index * 2] = (group >> 8) & 0xff;
348
+ bytes[index * 2 + 1] = group & 0xff;
349
+ });
350
+ return bytes;
351
+ }
352
+ function parseGroups(text) {
353
+ if (text.length === 0) {
354
+ return [];
355
+ }
356
+ return text.split(':').map((group) => {
357
+ if (!/^[0-9a-f]{1,4}$/u.test(group)) {
358
+ throw invalid(`IPv6 分组不合法:${group}`);
359
+ }
360
+ return Number.parseInt(group, 16);
361
+ });
362
+ }
363
+ function ipv4Rule(base, bits, reason) {
364
+ return Object.freeze({ prefix: ipv4ToBytes(base), bits, reason });
365
+ }
366
+ function ipv6Rule(base, bits, reason) {
367
+ return Object.freeze({ prefix: ipv6ToBytes(base), bits, reason });
368
+ }
369
+ /** Build the PTR query name for an IP (`in-addr.arpa` / `ip6.arpa`). */
370
+ export function reverseLookupName(host) {
371
+ if (host.kind === 'ipv4') {
372
+ return `${Array.from(host.bytes).reverse().join('.')}.in-addr.arpa`;
373
+ }
374
+ if (host.kind === 'ipv6') {
375
+ const nibbles = [];
376
+ for (const byte of host.bytes) {
377
+ nibbles.push((byte >> 4).toString(16));
378
+ nibbles.push((byte & 0x0f).toString(16));
379
+ }
380
+ return `${nibbles.reverse().join('.')}.ip6.arpa`;
381
+ }
382
+ throw invalid('反向解析需要 IP 地址');
383
+ }
384
+ /** Human label for a parsed host. */
385
+ export function displayHost(host) {
386
+ return host.kind === 'domain' ? host.name : host.address;
387
+ }
388
+ //# sourceMappingURL=address-policy.js.map