@mark1russell7/netdiag 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +92 -2
- package/build/diagnosis-BTa-Dfwl.js +820 -0
- package/build/diagnosis-BTa-Dfwl.js.map +1 -0
- package/build/diagnosis.d.ts +7 -0
- package/build/diagnosis.js +1 -0
- package/build/index-CP1hzELI.d.ts +198 -0
- package/build/index-CP1hzELI.d.ts.map +1 -0
- package/build/index.d.ts +287 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +436 -0
- package/build/index.js.map +1 -0
- package/package.json +69 -3
- package/src/diagnosis/catalog.ts +357 -0
- package/src/diagnosis/index.ts +34 -0
- package/src/diagnosis/model.ts +322 -0
- package/src/fact.ts +87 -0
- package/src/index.ts +33 -0
- package/src/layer.ts +31 -0
- package/src/probe.ts +61 -0
- package/src/run.ts +163 -0
- package/src/scope.ts +171 -0
- package/src/target.ts +116 -0
package/src/run.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Clock } from "@mark1russell7/algebra/clock";
|
|
2
|
+
import type { Fact, FactTime, Observation } from "./fact.ts";
|
|
3
|
+
import type { Probe, ProbeContext } from "./probe.ts";
|
|
4
|
+
import { decideScope, widen, type Grant, type PortLedger, type Scope } from "./scope.ts";
|
|
5
|
+
import { canonicalHost, formatTarget, type Target } from "./target.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The outcome of one run of a probe.
|
|
9
|
+
*
|
|
10
|
+
* - `done`: the probe did its work. The facts include each network failure that it saw.
|
|
11
|
+
* - `refused`: a peer is not in the scope, so the probe did not start.
|
|
12
|
+
* - `unavailable`: the probe cannot operate on this computer.
|
|
13
|
+
* - `failed`: the probe or its parser has a fault. The raw output is present when the run ended.
|
|
14
|
+
* - `aborted`: the signal stopped the run.
|
|
15
|
+
*/
|
|
16
|
+
export type ProbeOutcome<R> =
|
|
17
|
+
| { readonly status: "done"; readonly time: FactTime; readonly raw: R; readonly facts: readonly Fact[] }
|
|
18
|
+
| { readonly status: "refused"; readonly peer: Target; readonly reason: string }
|
|
19
|
+
| { readonly status: "unavailable"; readonly reason: string }
|
|
20
|
+
| { readonly status: "failed"; readonly stage: "probe" | "run" | "parse"; readonly error: string; readonly raw?: R | undefined }
|
|
21
|
+
| { readonly status: "aborted"; readonly reason: string };
|
|
22
|
+
|
|
23
|
+
/** The wall-clock time of one monotonic time of the runner. */
|
|
24
|
+
export interface ClockOrigin {
|
|
25
|
+
readonly wall: string;
|
|
26
|
+
readonly monotonic: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The options of a runner. */
|
|
30
|
+
export interface RunnerOptions {
|
|
31
|
+
readonly scope: Scope;
|
|
32
|
+
readonly clock: Clock;
|
|
33
|
+
/** The wall-clock time of the start, as an ISO 8601 text. The default is the time of the computer. */
|
|
34
|
+
readonly wall?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A runner starts probes in one scope, and keeps the facts of the diagnosis.
|
|
39
|
+
*
|
|
40
|
+
* It gives each fact an id in sequence. It keeps the ledger of the ports of each host, for the limit of ports of the scope.
|
|
41
|
+
*/
|
|
42
|
+
export interface Runner {
|
|
43
|
+
readonly origin: ClockOrigin;
|
|
44
|
+
scope(): Scope;
|
|
45
|
+
facts(): readonly Fact[];
|
|
46
|
+
/** This method adds grants to the scope, for example the addresses of a granted name. */
|
|
47
|
+
grant(grants: readonly Grant[]): void;
|
|
48
|
+
run<I, R>(probe: Probe<I, R>, input: I, signal?: AbortSignal): Promise<ProbeOutcome<R>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function message(thrown: unknown): string {
|
|
52
|
+
if (thrown instanceof Error) return thrown.message;
|
|
53
|
+
return String(thrown);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function abortReason(signal: AbortSignal): string {
|
|
57
|
+
const reason: unknown = signal.reason;
|
|
58
|
+
return reason === undefined ? "The signal stopped the run." : message(reason);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** This function makes a runner. */
|
|
62
|
+
export function createRunner(options: RunnerOptions): Runner {
|
|
63
|
+
const { clock } = options;
|
|
64
|
+
const origin: ClockOrigin = { wall: options.wall ?? new Date().toISOString(), monotonic: clock.now() };
|
|
65
|
+
let scope = options.scope;
|
|
66
|
+
const facts: Fact[] = [];
|
|
67
|
+
let ledger: PortLedger = new Map();
|
|
68
|
+
let next = 1;
|
|
69
|
+
|
|
70
|
+
/** This function gives the ledger with the ports of the peers, or the refusal of the first peer out of the scope. */
|
|
71
|
+
function reserve(peers: readonly Target[]): PortLedger | { readonly peer: Target; readonly reason: string } {
|
|
72
|
+
const planned = new Map([...ledger].map(([host, ports]) => [host, new Set(ports)]));
|
|
73
|
+
for (const peer of peers) {
|
|
74
|
+
const decision = decideScope(scope, peer, planned);
|
|
75
|
+
if (!decision.allowed) return { peer, reason: decision.reason };
|
|
76
|
+
if (peer.port === undefined) continue;
|
|
77
|
+
const host = canonicalHost(peer.host);
|
|
78
|
+
planned.set(host, (planned.get(host) ?? new Set<number>()).add(peer.port));
|
|
79
|
+
}
|
|
80
|
+
return planned;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stamp<I, R>(probe: Probe<I, R>, observations: readonly Observation[], time: FactTime): Fact[] | string {
|
|
84
|
+
const stamped: Fact[] = [];
|
|
85
|
+
for (const observation of observations) {
|
|
86
|
+
const { kind } = observation;
|
|
87
|
+
if (!probe.kinds.some((known) => known.kind === kind.kind)) return `The probe gave the kind "${kind.kind}", which it does not declare.`;
|
|
88
|
+
if (!kind.check(observation.value)) return `The value of a "${kind.kind}" fact does not pass the check of its kind.`;
|
|
89
|
+
stamped.push({
|
|
90
|
+
id: `f${next + stamped.length}`,
|
|
91
|
+
kind: kind.kind,
|
|
92
|
+
layer: kind.layer,
|
|
93
|
+
subject: observation.subject,
|
|
94
|
+
time: observation.time ?? time,
|
|
95
|
+
source: probe.tool === undefined ? { probe: probe.id, version: probe.version } : { probe: probe.id, version: probe.version, tool: probe.tool },
|
|
96
|
+
value: observation.value,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return stamped;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function run<I, R>(probe: Probe<I, R>, input: I, signal: AbortSignal = new AbortController().signal): Promise<ProbeOutcome<R>> {
|
|
103
|
+
if (signal.aborted) return { status: "aborted", reason: abortReason(signal) };
|
|
104
|
+
let peers: readonly Target[];
|
|
105
|
+
try {
|
|
106
|
+
peers = probe.peers(input);
|
|
107
|
+
} catch (thrown) {
|
|
108
|
+
return { status: "failed", stage: "probe", error: message(thrown) };
|
|
109
|
+
}
|
|
110
|
+
if (probe.mode === "passive" && peers.length > 0) {
|
|
111
|
+
return { status: "failed", stage: "probe", error: `The passive probe "${probe.id}" gives peers: ${peers.map(formatTarget).join(", ")}.` };
|
|
112
|
+
}
|
|
113
|
+
if (probe.mode === "active" && peers.length === 0) {
|
|
114
|
+
return { status: "failed", stage: "probe", error: `The active probe "${probe.id}" gives no peer.` };
|
|
115
|
+
}
|
|
116
|
+
// The decision and the reservation of the ports happen with no await between them. Thus two runs at the same time
|
|
117
|
+
// cannot both pass the limit of ports. A run that does not start keeps its reservation: the ledger only grows.
|
|
118
|
+
const reserved = reserve(peers);
|
|
119
|
+
if ("reason" in reserved) return { status: "refused", peer: reserved.peer, reason: reserved.reason };
|
|
120
|
+
ledger = reserved;
|
|
121
|
+
const context: ProbeContext = { clock, signal };
|
|
122
|
+
let availability;
|
|
123
|
+
try {
|
|
124
|
+
availability = await probe.available(context);
|
|
125
|
+
} catch (thrown) {
|
|
126
|
+
return { status: "failed", stage: "probe", error: message(thrown) };
|
|
127
|
+
}
|
|
128
|
+
if (!availability.available) return { status: "unavailable", reason: availability.reason };
|
|
129
|
+
if (signal.aborted) return { status: "aborted", reason: abortReason(signal) };
|
|
130
|
+
const start = clock.now();
|
|
131
|
+
let raw: R;
|
|
132
|
+
try {
|
|
133
|
+
raw = await probe.run(input, context);
|
|
134
|
+
} catch (thrown) {
|
|
135
|
+
if (signal.aborted) return { status: "aborted", reason: abortReason(signal) };
|
|
136
|
+
return { status: "failed", stage: "run", error: message(thrown) };
|
|
137
|
+
}
|
|
138
|
+
const time: FactTime = { start, end: clock.now() };
|
|
139
|
+
let observations: readonly Observation[];
|
|
140
|
+
try {
|
|
141
|
+
observations = probe.parse(raw, input);
|
|
142
|
+
} catch (thrown) {
|
|
143
|
+
return { status: "failed", stage: "parse", error: message(thrown), raw };
|
|
144
|
+
}
|
|
145
|
+
const stamped = stamp(probe, observations, time);
|
|
146
|
+
if (typeof stamped === "string") return { status: "failed", stage: "parse", error: stamped, raw };
|
|
147
|
+
facts.push(...stamped);
|
|
148
|
+
next += stamped.length;
|
|
149
|
+
return { status: "done", time, raw, facts: stamped };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
origin,
|
|
154
|
+
scope: () => scope,
|
|
155
|
+
facts: () => [...facts],
|
|
156
|
+
grant(grants) {
|
|
157
|
+
const widened = widen(scope, grants);
|
|
158
|
+
if (widened.tag === "err") throw new RangeError(widened.error.join(" "));
|
|
159
|
+
scope = widened.value;
|
|
160
|
+
},
|
|
161
|
+
run,
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { BlockList, isIP } from "node:net";
|
|
2
|
+
import { err, ok, type Result } from "@mark1russell7/optional";
|
|
3
|
+
import { canonicalHost, formatTarget, type Target } from "./target.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The basis of a grant: why a target is in the scope.
|
|
7
|
+
*
|
|
8
|
+
* - `named`: the user named the target for this diagnosis.
|
|
9
|
+
* - `owned`: the user declares that the user owns the network or the host.
|
|
10
|
+
* - `configured`: the configuration of the computer of the user names it, for example the gateway or the DNS resolver.
|
|
11
|
+
* - `resolved`: a DNS answer for a granted name gave this address.
|
|
12
|
+
*/
|
|
13
|
+
export type GrantBasis = "named" | "owned" | "configured" | "resolved";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A grant puts targets in the scope.
|
|
17
|
+
*
|
|
18
|
+
* A `host` grant puts a DNS name in the scope, and with `subdomains` each name under it. A `range` grant puts the
|
|
19
|
+
* addresses of a CIDR range in the scope, for example `192.0.2.0/24` or `2001:db8::/32`.
|
|
20
|
+
*/
|
|
21
|
+
export type Grant = HostGrant | RangeGrant;
|
|
22
|
+
|
|
23
|
+
/** A grant of a DNS name, and with `subdomains` of each name under it. */
|
|
24
|
+
export interface HostGrant {
|
|
25
|
+
readonly kind: "host";
|
|
26
|
+
readonly host: string;
|
|
27
|
+
readonly subdomains?: boolean | undefined;
|
|
28
|
+
readonly basis: GrantBasis;
|
|
29
|
+
readonly note?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A grant of the addresses of a CIDR range, for example `192.0.2.0/24` or `2001:db8::/32`. */
|
|
33
|
+
export interface RangeGrant {
|
|
34
|
+
readonly kind: "range";
|
|
35
|
+
readonly cidr: string;
|
|
36
|
+
readonly basis: GrantBasis;
|
|
37
|
+
readonly note?: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The scope of a diagnosis: the grants, and the largest number of ports for each host.
|
|
42
|
+
*
|
|
43
|
+
* An active probe sends packets only to a peer in the scope. The limit of ports stops a port scan: netdiag does no
|
|
44
|
+
* vulnerability scan.
|
|
45
|
+
*/
|
|
46
|
+
export interface Scope {
|
|
47
|
+
readonly grants: readonly Grant[];
|
|
48
|
+
readonly maxPortsPerHost: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The default limit of the different ports of one host in one runner. */
|
|
52
|
+
export const DEFAULT_MAX_PORTS_PER_HOST = 8;
|
|
53
|
+
|
|
54
|
+
/** The decision of the scope for one peer of an active probe. A refusal has the reason in plain words. */
|
|
55
|
+
export type ScopeDecision =
|
|
56
|
+
| { readonly allowed: true; readonly grant: Grant }
|
|
57
|
+
| { readonly allowed: false; readonly reason: string };
|
|
58
|
+
|
|
59
|
+
/** The ports that a runner sent packets to, for each host. */
|
|
60
|
+
export type PortLedger = ReadonlyMap<string, ReadonlySet<number>>;
|
|
61
|
+
|
|
62
|
+
interface Range {
|
|
63
|
+
readonly address: string;
|
|
64
|
+
readonly prefix: number;
|
|
65
|
+
readonly family: "ipv4" | "ipv6";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseRange(cidr: string): Result<Range, string> {
|
|
69
|
+
const slash = cidr.indexOf("/");
|
|
70
|
+
const address = canonicalHost(slash === -1 ? cidr : cidr.slice(0, slash));
|
|
71
|
+
const family = isIP(address);
|
|
72
|
+
if (family === 0) return err(`"${cidr}" is not an IP address or a CIDR range.`);
|
|
73
|
+
const max = family === 4 ? 32 : 128;
|
|
74
|
+
const prefixText = slash === -1 ? String(max) : cidr.slice(slash + 1);
|
|
75
|
+
if (!/^\d{1,3}$/.test(prefixText) || Number(prefixText) > max) return err(`"${cidr}" has no prefix from 0 to ${max}.`);
|
|
76
|
+
return ok({ address, prefix: Number(prefixText), family: family === 4 ? "ipv4" : "ipv6" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** This function gives the problems of a grant, or an empty list for a correct grant. */
|
|
80
|
+
export function grantProblems(grant: Grant): string[] {
|
|
81
|
+
if (grant.kind === "range") {
|
|
82
|
+
const range = parseRange(grant.cidr);
|
|
83
|
+
return range.tag === "err" ? [range.error] : [];
|
|
84
|
+
}
|
|
85
|
+
const host = canonicalHost(grant.host);
|
|
86
|
+
if (host.length === 0) return ["A host grant has an empty host."];
|
|
87
|
+
if (isIP(host) !== 0) return [`The host grant "${grant.host}" is an IP address. Use a range grant for an address.`];
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* This function makes a scope from grants. It refuses a grant with a problem, so a typing error cannot widen the scope.
|
|
93
|
+
*
|
|
94
|
+
* The default limit of ports is `DEFAULT_MAX_PORTS_PER_HOST`.
|
|
95
|
+
*/
|
|
96
|
+
export function scopeOf(grants: readonly Grant[], options: { readonly maxPortsPerHost?: number } = {}): Result<Scope, string[]> {
|
|
97
|
+
const problems = grants.flatMap(grantProblems);
|
|
98
|
+
const maxPortsPerHost = options.maxPortsPerHost ?? DEFAULT_MAX_PORTS_PER_HOST;
|
|
99
|
+
if (!Number.isInteger(maxPortsPerHost) || maxPortsPerHost < 1) problems.push("The limit of ports for each host is not a positive integer.");
|
|
100
|
+
if (problems.length > 0) return err(problems);
|
|
101
|
+
return ok({ grants: [...grants], maxPortsPerHost });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* This function gives the grant for a target that the user names. A name gets a host grant. An IP address gets a
|
|
106
|
+
* range grant of one address.
|
|
107
|
+
*/
|
|
108
|
+
export function namedGrant(t: Target, note?: string): Grant {
|
|
109
|
+
if (t.kind === "name") return { kind: "host", host: t.host, basis: "named", note };
|
|
110
|
+
return { kind: "range", cidr: `${t.host}/${t.kind === "ipv4" ? 32 : 128}`, basis: "named", note };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** This function gives the grant of one address that a DNS answer for a granted name gave. */
|
|
114
|
+
export function resolvedGrant(name: string, address: string, factId?: string): RangeGrant {
|
|
115
|
+
const host = canonicalHost(address);
|
|
116
|
+
const family = isIP(host);
|
|
117
|
+
if (family === 0) throw new RangeError(`"${address}" is not an IP address`);
|
|
118
|
+
const note = factId === undefined ? `${canonicalHost(name)} resolved to it` : `${canonicalHost(name)} resolved to it (fact ${factId})`;
|
|
119
|
+
return { kind: "range", cidr: `${host}/${family === 4 ? 32 : 128}`, basis: "resolved", note };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** This function gives a scope with more grants. The limit of ports does not change. */
|
|
123
|
+
export function widen(scope: Scope, grants: readonly Grant[]): Result<Scope, string[]> {
|
|
124
|
+
return scopeOf([...scope.grants, ...grants], { maxPortsPerHost: scope.maxPortsPerHost });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function grantFor(scope: Scope, peer: Target): Grant | undefined {
|
|
128
|
+
const host = canonicalHost(peer.host);
|
|
129
|
+
const family = isIP(host);
|
|
130
|
+
for (const grant of scope.grants) {
|
|
131
|
+
if (grant.kind === "host") {
|
|
132
|
+
if (family !== 0) continue;
|
|
133
|
+
const granted = canonicalHost(grant.host);
|
|
134
|
+
if (host === granted || (grant.subdomains === true && host.endsWith(`.${granted}`))) return grant;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (family === 0) continue;
|
|
138
|
+
const range = parseRange(grant.cidr);
|
|
139
|
+
if (range.tag === "err" || (range.value.family === "ipv4") !== (family === 4)) continue;
|
|
140
|
+
const list = new BlockList();
|
|
141
|
+
list.addSubnet(range.value.address, range.value.prefix, range.value.family);
|
|
142
|
+
if (list.check(host, family === 4 ? "ipv4" : "ipv6")) return grant;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* This function decides if an active probe can send packets to a peer.
|
|
149
|
+
*
|
|
150
|
+
* The peer must match a grant of the scope. A new port of a host must also stay in the limit of ports for each host.
|
|
151
|
+
* The ledger has the ports that the runner sent packets to before.
|
|
152
|
+
*/
|
|
153
|
+
export function decideScope(scope: Scope, peer: Target, ledger: PortLedger = new Map()): ScopeDecision {
|
|
154
|
+
const grant = grantFor(scope, peer);
|
|
155
|
+
if (grant === undefined) {
|
|
156
|
+
return {
|
|
157
|
+
allowed: false,
|
|
158
|
+
reason: `${formatTarget(peer)} is not in the scope. Name it, or add a grant for a network that you own.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (peer.port !== undefined) {
|
|
162
|
+
const ports = ledger.get(canonicalHost(peer.host)) ?? new Set<number>();
|
|
163
|
+
if (!ports.has(peer.port) && ports.size >= scope.maxPortsPerHost) {
|
|
164
|
+
return {
|
|
165
|
+
allowed: false,
|
|
166
|
+
reason: `${formatTarget(peer)} is a new port after ${ports.size} ports of the same host. netdiag does no port scan.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { allowed: true, grant };
|
|
171
|
+
}
|
package/src/target.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { err, ok, type Result } from "@mark1russell7/optional";
|
|
3
|
+
|
|
4
|
+
/** The kind of the host of a target: a DNS name, an IPv4 address or an IPv6 address. */
|
|
5
|
+
export type HostKind = "name" | "ipv4" | "ipv6";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A host that a probe examines, and an optional port.
|
|
9
|
+
*
|
|
10
|
+
* The host is in its canonical form: lower case, with no final dot and no brackets around an IPv6 address.
|
|
11
|
+
*/
|
|
12
|
+
export interface Target {
|
|
13
|
+
readonly host: string;
|
|
14
|
+
readonly kind: HostKind;
|
|
15
|
+
readonly port?: number | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The ports of the URL schemes that a target can have. */
|
|
19
|
+
const SCHEME_PORTS: ReadonlyMap<string, number> = new Map([
|
|
20
|
+
["http:", 80],
|
|
21
|
+
["https:", 443],
|
|
22
|
+
["ws:", 80],
|
|
23
|
+
["wss:", 443],
|
|
24
|
+
["dns:", 53],
|
|
25
|
+
["tls:", 443],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** This function gives the kind of a host in its canonical form. */
|
|
29
|
+
export function hostKind(host: string): HostKind {
|
|
30
|
+
const family = isIP(host);
|
|
31
|
+
if (family === 4) return "ipv4";
|
|
32
|
+
if (family === 6) return "ipv6";
|
|
33
|
+
return "name";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** This function gives the canonical form of a host: lower case, with no final dot and no brackets. */
|
|
37
|
+
export function canonicalHost(host: string): string {
|
|
38
|
+
let text = host.trim().toLowerCase();
|
|
39
|
+
if (text.startsWith("[") && text.endsWith("]")) text = text.slice(1, -1);
|
|
40
|
+
if (text.endsWith(".")) text = text.slice(0, -1);
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** This function makes a target from a host and an optional port. */
|
|
45
|
+
export function target(host: string, port?: number): Target {
|
|
46
|
+
const canonical = canonicalHost(host);
|
|
47
|
+
return port === undefined ? { host: canonical, kind: hostKind(canonical) } : { host: canonical, kind: hostKind(canonical), port };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** This function gives the text of a target: `host`, `host:port`, or `[address]:port` for an IPv6 address. */
|
|
51
|
+
export function formatTarget(t: Target): string {
|
|
52
|
+
const host = t.kind === "ipv6" && t.port !== undefined ? `[${t.host}]` : t.host;
|
|
53
|
+
return t.port === undefined ? host : `${host}:${t.port}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** This function tells if two targets have the same host and the same port. */
|
|
57
|
+
export function sameTarget(a: Target, b: Target): boolean {
|
|
58
|
+
return a.host === b.host && a.port === b.port;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
|
|
62
|
+
|
|
63
|
+
function validName(host: string): boolean {
|
|
64
|
+
if (host.length === 0 || host.length > 253) return false;
|
|
65
|
+
return host.split(".").every((label) => LABEL.test(label) || label === "_" || /^_[a-z0-9-]{1,62}$/.test(label));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function portOf(text: string): number | undefined {
|
|
69
|
+
if (!/^\d{1,5}$/.test(text)) return undefined;
|
|
70
|
+
const port = Number(text);
|
|
71
|
+
return port >= 1 && port <= 65535 ? port : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* This function reads a target from the text that a user gives.
|
|
76
|
+
*
|
|
77
|
+
* It accepts a URL (`https://example.com/a`), a host (`example.com`) and a host with a port (`example.com:8443`).
|
|
78
|
+
* It also accepts an IPv4 address, and an IPv6 address in brackets with a port (`[2001:db8::1]:443`).
|
|
79
|
+
* A URL with a known scheme and no port gets the port of its scheme.
|
|
80
|
+
*/
|
|
81
|
+
export function parseTarget(text: string): Result<Target, string> {
|
|
82
|
+
const input = text.trim();
|
|
83
|
+
if (input.length === 0) return err("The target is empty.");
|
|
84
|
+
if (input.includes("://")) {
|
|
85
|
+
let url: URL;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(input);
|
|
88
|
+
} catch {
|
|
89
|
+
return err(`"${input}" is not a valid URL.`);
|
|
90
|
+
}
|
|
91
|
+
const host = canonicalHost(url.hostname);
|
|
92
|
+
if (host.length === 0) return err(`The URL "${input}" has no host.`);
|
|
93
|
+
const port = url.port === "" ? SCHEME_PORTS.get(url.protocol) : Number(url.port);
|
|
94
|
+
return checked(host, port, input);
|
|
95
|
+
}
|
|
96
|
+
if (isIP(input) === 6) return checked(canonicalHost(input), undefined, input);
|
|
97
|
+
const bracket = /^\[([^\]]+)\](?::(.*))?$/.exec(input);
|
|
98
|
+
if (bracket !== null) {
|
|
99
|
+
const host = bracket[1] ?? "";
|
|
100
|
+
if (isIP(host) !== 6) return err(`"${host}" is not an IPv6 address.`);
|
|
101
|
+
if (bracket[2] === undefined) return checked(canonicalHost(host), undefined, input);
|
|
102
|
+
const port = portOf(bracket[2]);
|
|
103
|
+
return port === undefined ? err(`"${bracket[2]}" is not a port from 1 to 65535.`) : checked(canonicalHost(host), port, input);
|
|
104
|
+
}
|
|
105
|
+
const colon = input.lastIndexOf(":");
|
|
106
|
+
if (colon === -1) return checked(canonicalHost(input), undefined, input);
|
|
107
|
+
const port = portOf(input.slice(colon + 1));
|
|
108
|
+
if (port === undefined) return err(`"${input.slice(colon + 1)}" is not a port from 1 to 65535.`);
|
|
109
|
+
return checked(canonicalHost(input.slice(0, colon)), port, input);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function checked(host: string, port: number | undefined, input: string): Result<Target, string> {
|
|
113
|
+
const kind = hostKind(host);
|
|
114
|
+
if (kind === "name" && !validName(host)) return err(`"${input}" has no valid host name.`);
|
|
115
|
+
return ok(port === undefined ? { host, kind } : { host, kind, port });
|
|
116
|
+
}
|