harnery 0.42.0 → 0.43.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/commander.d.ts +16 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +18 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +54 -4
- package/dist/commands/tunnel.d.ts +65 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +290 -101
- package/dist/commands/web-fetch.d.ts +3 -2
- package/dist/commands/web-fetch.d.ts.map +1 -1
- package/dist/commands/web-fetch.js +25 -19
- package/dist/commands/web.d.ts +7 -1
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +9 -3
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +11 -5
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +57 -13
- package/dist/core/agents/state/names.d.ts +43 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +87 -3
- package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
- package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
- package/dist/core/hooks/cli.js +87 -18
- package/dist/core/hooks/fork-lineage.d.ts +40 -0
- package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
- package/dist/core/hooks/fork-lineage.js +62 -0
- package/dist/core/hooks/resolve/transcript.d.ts +16 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +84 -0
- package/dist/core/hooks/shell-rm.d.ts +2 -0
- package/dist/core/hooks/shell-rm.d.ts.map +1 -0
- package/dist/core/hooks/shell-rm.js +105 -0
- package/dist/core/hooks/shell-tokens.d.ts +12 -0
- package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
- package/dist/core/hooks/shell-tokens.js +97 -0
- package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
- package/dist/core/hooks/shell-waiter.js +3 -93
- package/dist/core/semantic/contract.d.ts +5 -5
- package/dist/core/storage/process-log.d.ts +12 -0
- package/dist/core/storage/process-log.d.ts.map +1 -1
- package/dist/core/storage/process-log.js +10 -2
- package/dist/lib/tunnel/allowlist.d.ts +44 -0
- package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
- package/dist/lib/tunnel/allowlist.js +204 -0
- package/dist/lib/tunnel/current-address.d.ts +41 -0
- package/dist/lib/tunnel/current-address.d.ts.map +1 -0
- package/dist/lib/tunnel/current-address.js +123 -0
- package/dist/lib/tunnel/gate.js +13 -6
- package/dist/lib/tunnel/processes.d.ts +39 -0
- package/dist/lib/tunnel/processes.d.ts.map +1 -0
- package/dist/lib/tunnel/processes.js +121 -0
- package/dist/lib/tunnel/state.d.ts +28 -3
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +98 -27
- package/package.json +2 -1
- package/src/commander.ts +32 -0
- package/src/commands/agents.ts +97 -11
- package/src/commands/tunnel.ts +394 -116
- package/src/commands/web-fetch.ts +30 -19
- package/src/commands/web.ts +9 -3
- package/src/core/agents/render/prompt-context.ts +25 -8
- package/src/core/agents/render/session-context.ts +83 -16
- package/src/core/agents/state/names.ts +117 -6
- package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
- package/src/core/hooks/cli.ts +106 -17
- package/src/core/hooks/fork-lineage.ts +82 -0
- package/src/core/hooks/resolve/transcript.ts +88 -0
- package/src/core/hooks/shell-rm.ts +103 -0
- package/src/core/hooks/shell-tokens.ts +96 -0
- package/src/core/hooks/shell-waiter.ts +4 -91
- package/src/core/storage/process-log.ts +17 -2
- package/src/lib/tunnel/allowlist.ts +208 -0
- package/src/lib/tunnel/current-address.ts +158 -0
- package/src/lib/tunnel/gate.ts +16 -8
- package/src/lib/tunnel/processes.ts +149 -0
- package/src/lib/tunnel/state.ts +123 -27
|
@@ -1,83 +1,10 @@
|
|
|
1
|
+
import { basename, shellCommandFromTool, tokenize } from "./shell-tokens.js";
|
|
1
2
|
/** Recognize inline waiters; this is not a proof of arbitrary shell termination. */
|
|
2
3
|
const REASON = "Inline shell waiter blocked: polling loops and delayed log-only checks can outlive the job " +
|
|
3
4
|
"and accumulate as background tasks. Read the existing task output now, or wait on its " +
|
|
4
5
|
"existing task handle. If polling is necessary, use a reviewed script with a hard deadline, " +
|
|
5
6
|
"nonzero timeout exit, and producer-exit/failure detection. Do not create another waiter " +
|
|
6
7
|
"to check progress or infer completion from a broad pgrep -f pattern.";
|
|
7
|
-
/** Keep quoted arguments opaque, including examples and heredoc bodies. */
|
|
8
|
-
function tokenize(source) {
|
|
9
|
-
const out = [];
|
|
10
|
-
const heredocs = [];
|
|
11
|
-
for (let i = 0; i < source.length;) {
|
|
12
|
-
const ch = source[i];
|
|
13
|
-
if (ch === "\\" && source[i + 1] === "\n") {
|
|
14
|
-
i += 2;
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
if (ch === "\n") {
|
|
18
|
-
out.push({ value: ";", quoted: false, operator: true });
|
|
19
|
-
i++;
|
|
20
|
-
for (const doc of heredocs.splice(0)) {
|
|
21
|
-
while (i < source.length) {
|
|
22
|
-
const end = source.indexOf("\n", i);
|
|
23
|
-
const stop = end === -1 ? source.length : end;
|
|
24
|
-
const line = source.slice(i, stop);
|
|
25
|
-
i = end === -1 ? source.length : end + 1;
|
|
26
|
-
if ((doc.tabs ? line.replace(/^\t+/, "") : line) === doc.end)
|
|
27
|
-
break;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
if (/\s/.test(ch)) {
|
|
33
|
-
i++;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
if (ch === "#") {
|
|
37
|
-
const end = source.indexOf("\n", i);
|
|
38
|
-
i = end === -1 ? source.length : end;
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
if (";|&(){}<>".includes(ch)) {
|
|
42
|
-
const op = source.slice(i).match(/^(?:<<-?|>>|&&|\|\||[;|&(){}<>])/)[0];
|
|
43
|
-
out.push({ value: op, quoted: false, operator: true });
|
|
44
|
-
i += op.length;
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
let value = "";
|
|
48
|
-
let quoted = false;
|
|
49
|
-
while (i < source.length && !/[\s;|&(){}<>]/.test(source[i])) {
|
|
50
|
-
const next = source[i++];
|
|
51
|
-
if (next === "'" || next === '"' || next === "`") {
|
|
52
|
-
quoted = true;
|
|
53
|
-
while (i < source.length && source[i] !== next) {
|
|
54
|
-
if (source[i] === "\\" && next !== "'" && i + 1 < source.length)
|
|
55
|
-
i++;
|
|
56
|
-
value += source[i++];
|
|
57
|
-
}
|
|
58
|
-
if (source[i] === next)
|
|
59
|
-
i++;
|
|
60
|
-
}
|
|
61
|
-
else if (next === "\\" && i < source.length) {
|
|
62
|
-
const escaped = source[i++];
|
|
63
|
-
if (escaped !== "\n")
|
|
64
|
-
value += escaped;
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
value += next;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const previous = out.at(-1)?.value;
|
|
71
|
-
if (previous === "<<" || previous === "<<-") {
|
|
72
|
-
heredocs.push({ end: value, tabs: previous === "<<-" });
|
|
73
|
-
}
|
|
74
|
-
out.push({ value, quoted });
|
|
75
|
-
}
|
|
76
|
-
return out;
|
|
77
|
-
}
|
|
78
|
-
function basename(value) {
|
|
79
|
-
return value.split(/[\\/]/).at(-1) ?? value;
|
|
80
|
-
}
|
|
81
8
|
function isWaiter(source, depth = 0) {
|
|
82
9
|
if (depth > 8)
|
|
83
10
|
return false;
|
|
@@ -151,23 +78,6 @@ function isWaiter(source, depth = 0) {
|
|
|
151
78
|
commands.every((name) => harmless.has(name)));
|
|
152
79
|
}
|
|
153
80
|
export function shellWaiterReason(toolName, toolInput) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const name = toolName.toLowerCase().replace(/^functions\./, "");
|
|
157
|
-
if (!["bash", "shell", "shell_command", "exec_command"].includes(name))
|
|
158
|
-
return null;
|
|
159
|
-
let input = toolInput;
|
|
160
|
-
if (typeof input === "string") {
|
|
161
|
-
try {
|
|
162
|
-
input = JSON.parse(input);
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (!input || typeof input !== "object")
|
|
169
|
-
return null;
|
|
170
|
-
const args = input;
|
|
171
|
-
const command = args.command ?? args.cmd;
|
|
172
|
-
return typeof command === "string" && isWaiter(command) ? REASON : null;
|
|
81
|
+
const command = shellCommandFromTool(toolName, toolInput);
|
|
82
|
+
return command !== null && isWaiter(command) ? REASON : null;
|
|
173
83
|
}
|
|
@@ -152,7 +152,7 @@ export declare const SemanticUsageReceiptV1Schema: import("@sinclair/typebox").T
|
|
|
152
152
|
scope: import("@sinclair/typebox").TLiteral<"unreported">;
|
|
153
153
|
}>]>;
|
|
154
154
|
export declare const SemanticEvidenceObservationV1Schema: import("@sinclair/typebox").TObject<{
|
|
155
|
-
kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"
|
|
155
|
+
kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"recovery" | "artifact" | "error" | "operation" | "action" | "terminal" | "task" | "progress" | "intent" | "wait" | "lifecycle" | "claim-conflict">[]>;
|
|
156
156
|
event_id: import("@sinclair/typebox").TString;
|
|
157
157
|
observed_at: import("@sinclair/typebox").TString;
|
|
158
158
|
label: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
@@ -193,7 +193,7 @@ export declare const SemanticEvidenceV1Schema: import("@sinclair/typebox").TObje
|
|
|
193
193
|
started_at: import("@sinclair/typebox").TString;
|
|
194
194
|
}>>;
|
|
195
195
|
recent: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
196
|
-
kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"
|
|
196
|
+
kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"recovery" | "artifact" | "error" | "operation" | "action" | "terminal" | "task" | "progress" | "intent" | "wait" | "lifecycle" | "claim-conflict">[]>;
|
|
197
197
|
event_id: import("@sinclair/typebox").TString;
|
|
198
198
|
observed_at: import("@sinclair/typebox").TString;
|
|
199
199
|
label: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
@@ -201,7 +201,7 @@ export declare const SemanticEvidenceV1Schema: import("@sinclair/typebox").TObje
|
|
|
201
201
|
error_class: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
202
202
|
}>>;
|
|
203
203
|
attention: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
204
|
-
kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"
|
|
204
|
+
kind: import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"recovery" | "artifact" | "error" | "operation" | "action" | "terminal" | "task" | "progress" | "intent" | "wait" | "lifecycle" | "claim-conflict">[]>;
|
|
205
205
|
event_id: import("@sinclair/typebox").TString;
|
|
206
206
|
observed_at: import("@sinclair/typebox").TString;
|
|
207
207
|
label: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
@@ -628,7 +628,7 @@ export declare const SemanticInvalidReadModelV2Schema: import("@sinclair/typebox
|
|
|
628
628
|
}>;
|
|
629
629
|
receipt: import("@sinclair/typebox").TObject<{
|
|
630
630
|
reason_code: import("@sinclair/typebox").TLiteral<"invalid_output">;
|
|
631
|
-
validation_issue_codes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"unknown" | "
|
|
631
|
+
validation_issue_codes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"unknown" | "confidence" | "schema" | "privacy" | "identity" | "basis" | "citation" | "unsupported_claim">[]>>;
|
|
632
632
|
usage: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
|
|
633
633
|
schema_version: import("@sinclair/typebox").TLiteral<1>;
|
|
634
634
|
source: import("@sinclair/typebox").TLiteral<"native">;
|
|
@@ -954,7 +954,7 @@ export declare const SemanticAgentReadModelV2Schema: import("@sinclair/typebox")
|
|
|
954
954
|
}>;
|
|
955
955
|
receipt: import("@sinclair/typebox").TObject<{
|
|
956
956
|
reason_code: import("@sinclair/typebox").TLiteral<"invalid_output">;
|
|
957
|
-
validation_issue_codes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"unknown" | "
|
|
957
|
+
validation_issue_codes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"unknown" | "confidence" | "schema" | "privacy" | "identity" | "basis" | "citation" | "unsupported_claim">[]>>;
|
|
958
958
|
usage: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
|
|
959
959
|
schema_version: import("@sinclair/typebox").TLiteral<1>;
|
|
960
960
|
source: import("@sinclair/typebox").TLiteral<"native">;
|
|
@@ -20,6 +20,16 @@ export interface RotatingProcessOptions {
|
|
|
20
20
|
env?: Readonly<Record<string, string | undefined>>;
|
|
21
21
|
max_bytes?: number;
|
|
22
22
|
backups?: number;
|
|
23
|
+
/**
|
|
24
|
+
* How to start the log wrapper, as an argv prefix that takes the JSON
|
|
25
|
+
* specification as its final argument and ends in `runProcessLogWorker`. The
|
|
26
|
+
* default runs this module's own file, which does not exist inside a
|
|
27
|
+
* single-file host bundle; such a host supplies a hidden task of its own.
|
|
28
|
+
*/
|
|
29
|
+
worker?: {
|
|
30
|
+
command: string;
|
|
31
|
+
arguments: string[];
|
|
32
|
+
};
|
|
23
33
|
}
|
|
24
34
|
/** Resolve one catalog-owned partition path, with an explicit legacy rollback. */
|
|
25
35
|
export declare function processLogDestination(options: ProcessLogDestinationOptions): string;
|
|
@@ -35,4 +45,6 @@ export declare class RotatingTextSink {
|
|
|
35
45
|
export declare function spawnRotatingProcess(options: RotatingProcessOptions): ChildProcess;
|
|
36
46
|
/** Run a short process synchronously while keeping its captured output bounded. */
|
|
37
47
|
export declare function runRotatingProcessSync(options: RotatingProcessOptions): number | null;
|
|
48
|
+
/** The wrapper's body: run the specified command and rotate its output into the log. */
|
|
49
|
+
export declare function runProcessLogWorker(raw: string | undefined): Promise<void>;
|
|
38
50
|
//# sourceMappingURL=process-log.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-log.d.ts","sourceRoot":"","sources":["../../../src/core/storage/process-log.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAoB,MAAM,oBAAoB,CAAC;AAiBzE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,4BAA4B;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"process-log.d.ts","sourceRoot":"","sources":["../../../src/core/storage/process-log.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAoB,MAAM,oBAAoB,CAAC;AAiBzE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,4BAA4B;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;CACnD;AAKD,kFAAkF;AAClF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,MAAM,CAcnF;AAED,qBAAa,gBAAgB;;gBAIf,OAAO,EAAE,uBAAuB;IAa5C,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAuB1B,eAAe,IAAI,IAAI;IAKvB,KAAK,IAAI,IAAI;IACb,KAAK,IAAI,IAAI;CAWd;AAED,6FAA6F;AAC7F,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,sBAAsB,GAAG,YAAY,CA4BlF;AAED,mFAAmF;AACnF,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,GAAG,IAAI,CAgBrF;AAED,wFAAwF;AACxF,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BhF"}
|
|
@@ -98,7 +98,14 @@ export function spawnRotatingProcess(options) {
|
|
|
98
98
|
command: options.command,
|
|
99
99
|
arguments: options.arguments ?? [],
|
|
100
100
|
});
|
|
101
|
-
|
|
101
|
+
// Run the wrapper with the Bun that is running this code: a host may carry a
|
|
102
|
+
// managed Bun that is not on PATH.
|
|
103
|
+
const bun = typeof process.versions.bun === "string" ? process.execPath : "bun";
|
|
104
|
+
const launcher = options.worker ?? {
|
|
105
|
+
command: bun,
|
|
106
|
+
arguments: [fileURLToPath(import.meta.url), "--run"],
|
|
107
|
+
};
|
|
108
|
+
return spawn(launcher.command, [...launcher.arguments, specification], {
|
|
102
109
|
detached: true,
|
|
103
110
|
stdio: "ignore",
|
|
104
111
|
env: { ...(options.env ?? process.env) },
|
|
@@ -125,7 +132,8 @@ export function runRotatingProcessSync(options) {
|
|
|
125
132
|
sink.close();
|
|
126
133
|
return result.status;
|
|
127
134
|
}
|
|
128
|
-
|
|
135
|
+
/** The wrapper's body: run the specified command and rotate its output into the log. */
|
|
136
|
+
export async function runProcessLogWorker(raw) {
|
|
129
137
|
const parsed = JSON.parse(raw ?? "null");
|
|
130
138
|
if (!parsed || typeof parsed.command !== "string" || !Array.isArray(parsed.arguments)) {
|
|
131
139
|
throw new Error("invalid process log worker specification");
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type IpFamily = 4 | 6;
|
|
2
|
+
export interface ParsedIp {
|
|
3
|
+
family: IpFamily;
|
|
4
|
+
value: bigint;
|
|
5
|
+
}
|
|
6
|
+
export interface AllowEntry extends ParsedIp {
|
|
7
|
+
/** Prefix length in bits (32 or 128 for a single address). */
|
|
8
|
+
prefix: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Parse one address. Returns null for anything that is not an IP literal. A
|
|
12
|
+
* zone id (`fe80::1%en0`) is dropped, and an IPv4-mapped IPv6 address is
|
|
13
|
+
* reported as the IPv4 address it carries.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseIp(raw: string): ParsedIp | null;
|
|
16
|
+
/**
|
|
17
|
+
* Parse an allowlist entry: `203.0.113.8`, `203.0.113.0/24`, `2601:db8::1`, or
|
|
18
|
+
* `2601:db8:1:2::/64`. Host bits below the prefix are cleared, so
|
|
19
|
+
* `203.0.113.77/24` means the network `203.0.113.0/24`. Returns null when the
|
|
20
|
+
* entry is not a valid address or its prefix length is out of range.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseAllowEntry(raw: string): AllowEntry | null;
|
|
23
|
+
/** Canonical text of an address (dotted quad, or compressed lowercase IPv6). */
|
|
24
|
+
export declare function formatIp(ip: ParsedIp): string;
|
|
25
|
+
/** Canonical text of an entry; a single address carries no `/len` suffix. */
|
|
26
|
+
export declare function formatAllowEntry(entry: AllowEntry): string;
|
|
27
|
+
/**
|
|
28
|
+
* Canonical spelling of an allowlist entry, or null when invalid. Two spellings
|
|
29
|
+
* of one address or range (`2601:DB8:0:0::1`, `2601:db8::1`) normalize equal.
|
|
30
|
+
*/
|
|
31
|
+
export declare function normalizeAllowEntry(raw: string): string | null;
|
|
32
|
+
export interface Allowlist {
|
|
33
|
+
entries: AllowEntry[];
|
|
34
|
+
/** Entries that could not be parsed; they never match anything. */
|
|
35
|
+
invalid: string[];
|
|
36
|
+
}
|
|
37
|
+
export declare function compileAllowlist(rawEntries: readonly string[]): Allowlist;
|
|
38
|
+
/** True when `ip` lies inside any entry. A missing or malformed address never matches. */
|
|
39
|
+
export declare function allowlistMatches(list: Allowlist, ip: string): boolean;
|
|
40
|
+
/** The IPv6 /64 network prefix of an address, as a canonical `…/64` entry. */
|
|
41
|
+
export declare function ipv6Slash64(address: string): string | null;
|
|
42
|
+
/** A single IPv4 address as a `/32` entry. */
|
|
43
|
+
export declare function ipv4Slash32(address: string): string | null;
|
|
44
|
+
//# sourceMappingURL=allowlist.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"allowlist.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/allowlist.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,CAAC;AAE7B,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,QAAQ,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAW,SAAQ,QAAQ;IAC1C,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;CAChB;AAyCD;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAYpD;AAQD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAmB9D;AAyBD,gFAAgF;AAChF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,QAAQ,GAAG,MAAM,CAO7C;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAG1D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAWzE;AAED,0FAA0F;AAC1F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CASrE;AAED,8EAA8E;AAC9E,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI1D;AAED,8CAA8C;AAC9C,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI1D"}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Address allowlist for the tunnel gate: exact addresses and CIDR ranges, IPv4
|
|
2
|
+
// and IPv6. Pure functions with no I/O, shared by the gate worker (matching)
|
|
3
|
+
// and the `tunnel allow` commands (validation + canonical spelling).
|
|
4
|
+
//
|
|
5
|
+
// An IPv6 phone and laptop on one Wi-Fi network have different addresses but
|
|
6
|
+
// share a /64 prefix, so a range entry such as `2601:db8:1:2::/64` admits both.
|
|
7
|
+
// An entry without a prefix length is a single address (/32 or /128).
|
|
8
|
+
//
|
|
9
|
+
// IPv4-mapped IPv6 addresses (`::ffff:203.0.113.8`) are IPv4 addresses reached
|
|
10
|
+
// through a dual-stack socket, so they parse and match as IPv4.
|
|
11
|
+
import { isIPv4, isIPv6 } from "node:net";
|
|
12
|
+
const FULL_BITS = { 4: 32, 6: 128 };
|
|
13
|
+
function parseIpv4(text) {
|
|
14
|
+
if (!isIPv4(text))
|
|
15
|
+
return null;
|
|
16
|
+
let value = 0n;
|
|
17
|
+
for (const part of text.split("."))
|
|
18
|
+
value = (value << 8n) | BigInt(Number(part));
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function parseIpv6(text) {
|
|
22
|
+
if (!isIPv6(text))
|
|
23
|
+
return null;
|
|
24
|
+
let rest = text;
|
|
25
|
+
// A trailing dotted quad (`::ffff:1.2.3.4`, `64:ff9b::1.2.3.4`) is two groups.
|
|
26
|
+
const dotted = rest.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
|
|
27
|
+
if (dotted) {
|
|
28
|
+
const v4 = parseIpv4(dotted[2]);
|
|
29
|
+
if (v4 === null)
|
|
30
|
+
return null;
|
|
31
|
+
rest = `${dotted[1]}${(v4 >> 16n).toString(16)}:${(v4 & 0xffffn).toString(16)}`;
|
|
32
|
+
}
|
|
33
|
+
const halves = rest.split("::");
|
|
34
|
+
if (halves.length > 2)
|
|
35
|
+
return null;
|
|
36
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
37
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
38
|
+
const missing = 8 - head.length - tail.length;
|
|
39
|
+
if (halves.length === 1 ? missing !== 0 : missing < 1)
|
|
40
|
+
return null;
|
|
41
|
+
const groups = [
|
|
42
|
+
...head,
|
|
43
|
+
...Array.from({ length: halves.length === 2 ? missing : 0 }, () => "0"),
|
|
44
|
+
...tail,
|
|
45
|
+
];
|
|
46
|
+
if (groups.length !== 8)
|
|
47
|
+
return null;
|
|
48
|
+
let value = 0n;
|
|
49
|
+
for (const group of groups) {
|
|
50
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group))
|
|
51
|
+
return null;
|
|
52
|
+
value = (value << 16n) | BigInt(Number.parseInt(group, 16));
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parse one address. Returns null for anything that is not an IP literal. A
|
|
58
|
+
* zone id (`fe80::1%en0`) is dropped, and an IPv4-mapped IPv6 address is
|
|
59
|
+
* reported as the IPv4 address it carries.
|
|
60
|
+
*/
|
|
61
|
+
export function parseIp(raw) {
|
|
62
|
+
let text = raw.trim();
|
|
63
|
+
if (text.startsWith("[") && text.endsWith("]"))
|
|
64
|
+
text = text.slice(1, -1);
|
|
65
|
+
const zone = text.indexOf("%");
|
|
66
|
+
if (zone !== -1)
|
|
67
|
+
text = text.slice(0, zone);
|
|
68
|
+
if (!text)
|
|
69
|
+
return null;
|
|
70
|
+
const v4 = parseIpv4(text);
|
|
71
|
+
if (v4 !== null)
|
|
72
|
+
return { family: 4, value: v4 };
|
|
73
|
+
const v6 = parseIpv6(text);
|
|
74
|
+
if (v6 === null)
|
|
75
|
+
return null;
|
|
76
|
+
if (v6 >> 32n === 0xffffn)
|
|
77
|
+
return { family: 4, value: v6 & 0xffffffffn };
|
|
78
|
+
return { family: 6, value: v6 };
|
|
79
|
+
}
|
|
80
|
+
function maskFor(family, prefix) {
|
|
81
|
+
const bits = BigInt(FULL_BITS[family]);
|
|
82
|
+
const host = bits - BigInt(prefix);
|
|
83
|
+
return ((1n << bits) - 1n) ^ ((1n << host) - 1n);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse an allowlist entry: `203.0.113.8`, `203.0.113.0/24`, `2601:db8::1`, or
|
|
87
|
+
* `2601:db8:1:2::/64`. Host bits below the prefix are cleared, so
|
|
88
|
+
* `203.0.113.77/24` means the network `203.0.113.0/24`. Returns null when the
|
|
89
|
+
* entry is not a valid address or its prefix length is out of range.
|
|
90
|
+
*/
|
|
91
|
+
export function parseAllowEntry(raw) {
|
|
92
|
+
const text = raw.trim();
|
|
93
|
+
const slash = text.indexOf("/");
|
|
94
|
+
const address = parseIp(slash === -1 ? text : text.slice(0, slash));
|
|
95
|
+
if (!address)
|
|
96
|
+
return null;
|
|
97
|
+
let prefix = FULL_BITS[address.family];
|
|
98
|
+
if (slash !== -1) {
|
|
99
|
+
const lengthText = text.slice(slash + 1);
|
|
100
|
+
if (!/^\d{1,3}$/.test(lengthText))
|
|
101
|
+
return null;
|
|
102
|
+
prefix = Number(lengthText);
|
|
103
|
+
// A prefix on an IPv4-mapped IPv6 entry counts from the IPv6 start, so
|
|
104
|
+
// `::ffff:1.2.3.0/120` is the IPv4 /24. Anything shorter is not an IPv4 range.
|
|
105
|
+
if (address.family === 4 && /:/.test(text.slice(0, slash))) {
|
|
106
|
+
if (prefix < 96 || prefix > 128)
|
|
107
|
+
return null;
|
|
108
|
+
prefix -= 96;
|
|
109
|
+
}
|
|
110
|
+
if (prefix > FULL_BITS[address.family])
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return { ...address, prefix, value: address.value & maskFor(address.family, prefix) };
|
|
114
|
+
}
|
|
115
|
+
function compress(groups) {
|
|
116
|
+
// RFC 5952: collapse the longest run of two or more zero groups (first wins).
|
|
117
|
+
let bestStart = -1;
|
|
118
|
+
let bestLen = 0;
|
|
119
|
+
for (let i = 0; i < 8;) {
|
|
120
|
+
if (groups[i] !== 0) {
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let j = i;
|
|
125
|
+
while (j < 8 && groups[j] === 0)
|
|
126
|
+
j++;
|
|
127
|
+
if (j - i > bestLen) {
|
|
128
|
+
bestStart = i;
|
|
129
|
+
bestLen = j - i;
|
|
130
|
+
}
|
|
131
|
+
i = j;
|
|
132
|
+
}
|
|
133
|
+
if (bestLen < 2)
|
|
134
|
+
return groups.map((g) => g.toString(16)).join(":");
|
|
135
|
+
const left = groups.slice(0, bestStart).map((g) => g.toString(16));
|
|
136
|
+
const right = groups.slice(bestStart + bestLen).map((g) => g.toString(16));
|
|
137
|
+
return `${left.join(":")}::${right.join(":")}`;
|
|
138
|
+
}
|
|
139
|
+
/** Canonical text of an address (dotted quad, or compressed lowercase IPv6). */
|
|
140
|
+
export function formatIp(ip) {
|
|
141
|
+
if (ip.family === 4) {
|
|
142
|
+
return [24n, 16n, 8n, 0n].map((shift) => Number((ip.value >> shift) & 0xffn)).join(".");
|
|
143
|
+
}
|
|
144
|
+
const groups = [];
|
|
145
|
+
for (let i = 7; i >= 0; i--)
|
|
146
|
+
groups.push(Number((ip.value >> BigInt(i * 16)) & 0xffffn));
|
|
147
|
+
return compress(groups);
|
|
148
|
+
}
|
|
149
|
+
/** Canonical text of an entry; a single address carries no `/len` suffix. */
|
|
150
|
+
export function formatAllowEntry(entry) {
|
|
151
|
+
const address = formatIp(entry);
|
|
152
|
+
return entry.prefix === FULL_BITS[entry.family] ? address : `${address}/${entry.prefix}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Canonical spelling of an allowlist entry, or null when invalid. Two spellings
|
|
156
|
+
* of one address or range (`2601:DB8:0:0::1`, `2601:db8::1`) normalize equal.
|
|
157
|
+
*/
|
|
158
|
+
export function normalizeAllowEntry(raw) {
|
|
159
|
+
const entry = parseAllowEntry(raw);
|
|
160
|
+
return entry ? formatAllowEntry(entry) : null;
|
|
161
|
+
}
|
|
162
|
+
export function compileAllowlist(rawEntries) {
|
|
163
|
+
const entries = [];
|
|
164
|
+
const invalid = [];
|
|
165
|
+
for (const raw of rawEntries) {
|
|
166
|
+
const text = raw.trim();
|
|
167
|
+
if (!text)
|
|
168
|
+
continue;
|
|
169
|
+
const entry = parseAllowEntry(text);
|
|
170
|
+
if (entry)
|
|
171
|
+
entries.push(entry);
|
|
172
|
+
else
|
|
173
|
+
invalid.push(text);
|
|
174
|
+
}
|
|
175
|
+
return { entries, invalid };
|
|
176
|
+
}
|
|
177
|
+
/** True when `ip` lies inside any entry. A missing or malformed address never matches. */
|
|
178
|
+
export function allowlistMatches(list, ip) {
|
|
179
|
+
const address = parseIp(ip);
|
|
180
|
+
if (!address)
|
|
181
|
+
return false;
|
|
182
|
+
for (const entry of list.entries) {
|
|
183
|
+
if (entry.family !== address.family)
|
|
184
|
+
continue;
|
|
185
|
+
const mask = maskFor(entry.family, entry.prefix);
|
|
186
|
+
if ((address.value & mask) === entry.value)
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
/** The IPv6 /64 network prefix of an address, as a canonical `…/64` entry. */
|
|
192
|
+
export function ipv6Slash64(address) {
|
|
193
|
+
const ip = parseIp(address);
|
|
194
|
+
if (!ip || ip.family !== 6)
|
|
195
|
+
return null;
|
|
196
|
+
return formatAllowEntry({ ...ip, prefix: 64, value: ip.value & maskFor(6, 64) });
|
|
197
|
+
}
|
|
198
|
+
/** A single IPv4 address as a `/32` entry. */
|
|
199
|
+
export function ipv4Slash32(address) {
|
|
200
|
+
const ip = parseIp(address);
|
|
201
|
+
if (!ip || ip.family !== 4)
|
|
202
|
+
return null;
|
|
203
|
+
return `${formatIp(ip)}/32`;
|
|
204
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { TunnelConfig } from "./state.js";
|
|
2
|
+
export declare const TRACE_URL_V4 = "https://1.1.1.1/cdn-cgi/trace";
|
|
3
|
+
export declare const TRACE_URL_V6 = "https://[2606:4700:4700::1111]/cdn-cgi/trace";
|
|
4
|
+
export interface DetectedAddresses {
|
|
5
|
+
v4?: string;
|
|
6
|
+
v6?: string;
|
|
7
|
+
/** One note per family that could not be read. */
|
|
8
|
+
failures: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface DetectOptions {
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
/** Tries per family; a transient failure on a flaky network should not drop an address. */
|
|
14
|
+
attempts?: number;
|
|
15
|
+
}
|
|
16
|
+
/** The `ip=` value of a Cloudflare trace body, or null. */
|
|
17
|
+
export declare function parseTraceIp(body: string): string | null;
|
|
18
|
+
/** Both families in parallel. A family that fails is reported, not thrown. */
|
|
19
|
+
export declare function detectPublicAddresses(options?: DetectOptions): Promise<DetectedAddresses>;
|
|
20
|
+
export interface CurrentAddressPlan {
|
|
21
|
+
config: TunnelConfig;
|
|
22
|
+
/** Entries newly present in the allowlist. */
|
|
23
|
+
added: string[];
|
|
24
|
+
/** Automatic entries from an earlier refresh that are now gone. */
|
|
25
|
+
removed: string[];
|
|
26
|
+
/** The automatic entries after this refresh (the detected, not already listed by hand). */
|
|
27
|
+
automatic: string[];
|
|
28
|
+
changed: boolean;
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fold freshly detected addresses into the allowlist.
|
|
33
|
+
*
|
|
34
|
+
* When nothing was detected the config comes back untouched with a warning:
|
|
35
|
+
* a failed lookup must never empty the list. When at least one family was
|
|
36
|
+
* detected, a family that was not is treated as absent from this network, so
|
|
37
|
+
* its old automatic entry goes. The entries for the detected families replace
|
|
38
|
+
* every previous automatic entry; manual entries are kept in place.
|
|
39
|
+
*/
|
|
40
|
+
export declare function planCurrentAddresses(config: TunnelConfig, detected: DetectedAddresses): CurrentAddressPlan;
|
|
41
|
+
//# sourceMappingURL=current-address.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"current-address.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/current-address.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,eAAO,MAAM,YAAY,kCAAkC,CAAC;AAC5D,eAAO,MAAM,YAAY,iDAAiD,CAAC;AAE3E,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2FAA2F;IAC3F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,2DAA2D;AAC3D,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGxD;AAoBD,8EAA8E;AAC9E,wBAAsB,qBAAqB,CACzC,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,iBAAiB,CAAC,CAgB5B;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,YAAY,CAAC;IACrB,8CAA8C;IAC9C,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,2FAA2F;IAC3F,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAOD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,YAAY,EACpB,QAAQ,EAAE,iBAAiB,GAC1B,kBAAkB,CAkDpB"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// This machine's public addresses as Cloudflare sees them, and the allowlist
|
|
2
|
+
// bookkeeping around them (`tunnel allow add --current`).
|
|
3
|
+
//
|
|
4
|
+
// The tunnel gate admits a visitor by the `CF-Connecting-IP` header, which is
|
|
5
|
+
// the address Cloudflare saw. Asking Cloudflare's own trace endpoint therefore
|
|
6
|
+
// returns the same address the gate will later compare, with no third party and
|
|
7
|
+
// no guess about NAT. The IPv4 and IPv6 probes use address literals, so each
|
|
8
|
+
// travels over the family it is named for and needs no DNS.
|
|
9
|
+
//
|
|
10
|
+
// Entries the refresh adds are recorded in `auto_allowed`. The next refresh
|
|
11
|
+
// replaces exactly those, so a machine that changes network does not pile up
|
|
12
|
+
// stale addresses, and an entry added by hand is never removed.
|
|
13
|
+
import { ipv4Slash32, ipv6Slash64, normalizeAllowEntry } from "./allowlist.js";
|
|
14
|
+
export const TRACE_URL_V4 = "https://1.1.1.1/cdn-cgi/trace";
|
|
15
|
+
export const TRACE_URL_V6 = "https://[2606:4700:4700::1111]/cdn-cgi/trace";
|
|
16
|
+
/** The `ip=` value of a Cloudflare trace body, or null. */
|
|
17
|
+
export function parseTraceIp(body) {
|
|
18
|
+
const m = body.match(/^ip=(\S+)\s*$/m);
|
|
19
|
+
return m ? m[1] : null;
|
|
20
|
+
}
|
|
21
|
+
async function probe(url, family, opts) {
|
|
22
|
+
let last = "no answer";
|
|
23
|
+
for (let attempt = 0; attempt < opts.attempts; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await opts.fetch(url, { signal: AbortSignal.timeout(opts.timeoutMs) });
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
throw new Error(`HTTP ${res.status}`);
|
|
28
|
+
const ip = parseTraceIp(await res.text());
|
|
29
|
+
if (!ip)
|
|
30
|
+
throw new Error("the trace had no ip= line");
|
|
31
|
+
const entry = family === 4 ? ipv4Slash32(ip) : ipv6Slash64(ip);
|
|
32
|
+
if (!entry)
|
|
33
|
+
throw new Error(`expected an IPv${family} address, got ${ip}`);
|
|
34
|
+
return ip;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
last = error instanceof Error ? error.message : String(error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error(last);
|
|
41
|
+
}
|
|
42
|
+
/** Both families in parallel. A family that fails is reported, not thrown. */
|
|
43
|
+
export async function detectPublicAddresses(options = {}) {
|
|
44
|
+
const opts = {
|
|
45
|
+
fetch: options.fetch ?? fetch,
|
|
46
|
+
timeoutMs: options.timeoutMs ?? 4_000,
|
|
47
|
+
attempts: options.attempts ?? 2,
|
|
48
|
+
};
|
|
49
|
+
const [v4, v6] = await Promise.allSettled([
|
|
50
|
+
probe(TRACE_URL_V4, 4, opts),
|
|
51
|
+
probe(TRACE_URL_V6, 6, opts),
|
|
52
|
+
]);
|
|
53
|
+
const out = { failures: [] };
|
|
54
|
+
if (v4.status === "fulfilled")
|
|
55
|
+
out.v4 = v4.value;
|
|
56
|
+
else
|
|
57
|
+
out.failures.push(`IPv4: ${v4.reason.message}`);
|
|
58
|
+
if (v6.status === "fulfilled")
|
|
59
|
+
out.v6 = v6.value;
|
|
60
|
+
else
|
|
61
|
+
out.failures.push(`IPv6: ${v6.reason.message}`);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
const sameEntry = (a, b) => (normalizeAllowEntry(a) ?? a) === (normalizeAllowEntry(b) ?? b);
|
|
65
|
+
const includesEntry = (list, entry) => list.some((e) => sameEntry(e, entry));
|
|
66
|
+
/**
|
|
67
|
+
* Fold freshly detected addresses into the allowlist.
|
|
68
|
+
*
|
|
69
|
+
* When nothing was detected the config comes back untouched with a warning:
|
|
70
|
+
* a failed lookup must never empty the list. When at least one family was
|
|
71
|
+
* detected, a family that was not is treated as absent from this network, so
|
|
72
|
+
* its old automatic entry goes. The entries for the detected families replace
|
|
73
|
+
* every previous automatic entry; manual entries are kept in place.
|
|
74
|
+
*/
|
|
75
|
+
export function planCurrentAddresses(config, detected) {
|
|
76
|
+
const previousAuto = config.auto_allowed ?? [];
|
|
77
|
+
const wanted = [];
|
|
78
|
+
if (detected.v4) {
|
|
79
|
+
const entry = ipv4Slash32(detected.v4);
|
|
80
|
+
if (entry)
|
|
81
|
+
wanted.push(entry);
|
|
82
|
+
}
|
|
83
|
+
if (detected.v6) {
|
|
84
|
+
const entry = ipv6Slash64(detected.v6);
|
|
85
|
+
if (entry)
|
|
86
|
+
wanted.push(entry);
|
|
87
|
+
}
|
|
88
|
+
if (wanted.length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
config,
|
|
91
|
+
added: [],
|
|
92
|
+
removed: [],
|
|
93
|
+
automatic: previousAuto,
|
|
94
|
+
changed: false,
|
|
95
|
+
warnings: [
|
|
96
|
+
`Could not detect this machine's public address (${detected.failures.join("; ") || "no answer"}). ` +
|
|
97
|
+
`The allowlist is unchanged${previousAuto.length ? `; the previous automatic entries stay: ${previousAuto.join(", ")}` : ""}.`,
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const manual = config.allowed_ips.filter((e) => !includesEntry(previousAuto, e));
|
|
102
|
+
// An address already listed by hand stays manual, so a later refresh keeps it.
|
|
103
|
+
const automatic = wanted.filter((e) => !includesEntry(manual, e));
|
|
104
|
+
const allowed = [...manual, ...automatic];
|
|
105
|
+
const before = config.allowed_ips;
|
|
106
|
+
const added = allowed.filter((e) => !includesEntry(before, e));
|
|
107
|
+
const removed = before.filter((e) => !includesEntry(allowed, e));
|
|
108
|
+
const warnings = [];
|
|
109
|
+
const missing = [detected.v4 ? null : "IPv4", detected.v6 ? null : "IPv6"].filter(Boolean);
|
|
110
|
+
if (missing.length > 0) {
|
|
111
|
+
warnings.push(`${missing.join(" and ")} was not detected, so this network is treated as having none` +
|
|
112
|
+
(detected.failures.length ? ` (${detected.failures.join("; ")})` : "") +
|
|
113
|
+
".");
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
config: { ...config, allowed_ips: allowed, auto_allowed: automatic },
|
|
117
|
+
added,
|
|
118
|
+
removed,
|
|
119
|
+
automatic,
|
|
120
|
+
changed: added.length > 0 || removed.length > 0,
|
|
121
|
+
warnings,
|
|
122
|
+
};
|
|
123
|
+
}
|