@johnhenry/sitefile 0.0.0 → 0.0.2
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/CHANGELOG.md +115 -1
- package/README.md +62 -24
- package/lib/src/checklinks.js +2 -0
- package/lib/src/cli/build-image.js +2 -2
- package/lib/src/cli/cache.js +15 -5
- package/lib/src/cli/images.js +3 -3
- package/lib/src/cli/index.d.ts +1 -1
- package/lib/src/cli/index.js +1 -1
- package/lib/src/cli/network.d.ts +34 -0
- package/lib/src/cli/network.js +56 -0
- package/lib/src/cli/outdated.js +5 -0
- package/lib/src/cli/registry.js +6 -1
- package/lib/src/cli/servers.js +23 -7
- package/lib/src/cli/vendor.js +5 -0
- package/lib/src/cli.js +44 -8
- package/lib/src/errors.d.ts +25 -3
- package/lib/src/errors.js +9 -0
- package/lib/src/executor.d.ts +7 -0
- package/lib/src/executor.js +18 -10
- package/lib/src/export-formats.d.ts +10 -3
- package/lib/src/export-formats.js +17 -6
- package/lib/src/index.d.ts +2 -1
- package/lib/src/index.js +2 -1
- package/lib/src/language/ast.d.ts +9 -2
- package/lib/src/outdated.js +1 -1
- package/lib/src/planner.d.ts +1 -1
- package/lib/src/planner.js +2 -2
- package/lib/src/plugins/cache.d.ts +29 -3
- package/lib/src/plugins/cache.js +104 -5
- package/lib/src/plugins/host.js +3 -2
- package/lib/src/registry.d.ts +6 -0
- package/lib/src/registry.js +38 -19
- package/lib/src/resources/https.d.ts +35 -1
- package/lib/src/resources/https.js +175 -18
- package/lib/src/resources/network.d.ts +70 -0
- package/lib/src/resources/network.js +304 -0
- package/lib/src/resources/types.d.ts +6 -0
- package/lib/src/secrets.js +1 -1
- package/lib/src/serve-rules.js +2 -1
- package/lib/src/servers.d.ts +14 -0
- package/lib/src/servers.js +89 -3
- package/lib/src/vendor.js +1 -1
- package/lib/src/wbn.d.ts +21 -0
- package/lib/src/wbn.js +111 -17
- package/package.json +2 -1
|
@@ -2,15 +2,27 @@ import type { PluginResult } from './host.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Content-addressed cache of TRANSFORM results (see docs/PLUGINS.md, "Caching").
|
|
4
4
|
*
|
|
5
|
-
* <dir>/entries/<key>.json manifest: which blobs make up the result
|
|
5
|
+
* <dir>/entries/<key>.json manifest: which blobs make up the result, and the
|
|
6
|
+
* PLUGIN or TOOL that produced it ("plugin")
|
|
6
7
|
* <dir>/blobs/<hex> result bytes, named by their SHA-256
|
|
7
8
|
*
|
|
8
9
|
* The key is the SHA-256 of a canonical JSON document describing everything the
|
|
9
10
|
* result can depend on. Reads verify every blob's digest and size; a damaged
|
|
10
11
|
* entry is removed and counts as a miss, so the next put rewrites it.
|
|
12
|
+
* "plugin" is optional (entries written before it was recorded have none and are
|
|
13
|
+
* still read) and not part of the key.
|
|
11
14
|
*/
|
|
12
15
|
/** Bump to invalidate every existing entry when the key or entry format changes. */
|
|
13
16
|
export declare const CACHE_VERSION = 1;
|
|
17
|
+
/** What one plugin's entries hold (`TransformCache.usageByPlugin`). */
|
|
18
|
+
export interface PluginCacheUsage {
|
|
19
|
+
/** The PLUGIN or TOOL name; undefined for entries that record none. */
|
|
20
|
+
readonly plugin: string | undefined;
|
|
21
|
+
readonly entries: number;
|
|
22
|
+
/** Distinct blobs these entries reference (a blob shared with another plugin's entry counts for both). */
|
|
23
|
+
readonly blobs: number;
|
|
24
|
+
readonly bytes: number;
|
|
25
|
+
}
|
|
14
26
|
/** What an entry records: the result kind and `[path, blob hex, size]` per file (a `file` result has one, with path ''). */
|
|
15
27
|
export interface CacheEntry {
|
|
16
28
|
readonly kind: 'file' | 'files';
|
|
@@ -48,9 +60,11 @@ export declare class TransformCache {
|
|
|
48
60
|
constructor(dir: string, remote?: RemoteCache | undefined);
|
|
49
61
|
private entryPath;
|
|
50
62
|
private blobPath;
|
|
51
|
-
|
|
63
|
+
/** `plugin`: the TRANSFORM's PLUGIN or TOOL, recorded on an entry fetched from the remote cache. */
|
|
64
|
+
get(key: string, plugin?: string): Promise<PluginResult | undefined>;
|
|
52
65
|
private load;
|
|
53
|
-
|
|
66
|
+
/** `plugin`: the PLUGIN or TOOL that produced the result (see `usageByPlugin`, `prunePlugin`). */
|
|
67
|
+
put(key: string, result: PluginResult, plugin?: string): Promise<void>;
|
|
54
68
|
private writeAtomic;
|
|
55
69
|
info(): Promise<CacheInfo>;
|
|
56
70
|
/**
|
|
@@ -62,6 +76,18 @@ export declare class TransformCache {
|
|
|
62
76
|
blobs: number;
|
|
63
77
|
bytes: number;
|
|
64
78
|
}>;
|
|
79
|
+
/**
|
|
80
|
+
* Remove the entries `plugin` (a PLUGIN or TOOL name) wrote that were not used for `olderThanMs`
|
|
81
|
+
* (0: all of them), then the blobs they used that no remaining entry references. Entries of other
|
|
82
|
+
* plugins, entries that record no plugin, and blobs no entry references yet are kept.
|
|
83
|
+
*/
|
|
84
|
+
prunePlugin(plugin: string, olderThanMs?: number, now?: number): Promise<{
|
|
85
|
+
entries: number;
|
|
86
|
+
blobs: number;
|
|
87
|
+
bytes: number;
|
|
88
|
+
}>;
|
|
89
|
+
/** Entries, blobs and bytes per plugin, sorted by name (entries that record none first). Reads no blob. */
|
|
90
|
+
usageByPlugin(): Promise<PluginCacheUsage[]>;
|
|
65
91
|
}
|
|
66
92
|
/** Digest of every regular file under `dir` (a plugin can read all of them, so any may change its output). */
|
|
67
93
|
export declare function digestFolder(dir: string, limits?: {
|
package/lib/src/plugins/cache.js
CHANGED
|
@@ -5,15 +5,21 @@ import { sha256 } from '../digest.js';
|
|
|
5
5
|
/**
|
|
6
6
|
* Content-addressed cache of TRANSFORM results (see docs/PLUGINS.md, "Caching").
|
|
7
7
|
*
|
|
8
|
-
* <dir>/entries/<key>.json manifest: which blobs make up the result
|
|
8
|
+
* <dir>/entries/<key>.json manifest: which blobs make up the result, and the
|
|
9
|
+
* PLUGIN or TOOL that produced it ("plugin")
|
|
9
10
|
* <dir>/blobs/<hex> result bytes, named by their SHA-256
|
|
10
11
|
*
|
|
11
12
|
* The key is the SHA-256 of a canonical JSON document describing everything the
|
|
12
13
|
* result can depend on. Reads verify every blob's digest and size; a damaged
|
|
13
14
|
* entry is removed and counts as a miss, so the next put rewrites it.
|
|
15
|
+
* "plugin" is optional (entries written before it was recorded have none and are
|
|
16
|
+
* still read) and not part of the key.
|
|
14
17
|
*/
|
|
15
18
|
/** Bump to invalidate every existing entry when the key or entry format changes. */
|
|
16
19
|
export const CACHE_VERSION = 1;
|
|
20
|
+
function manifestOf(version, kind, files, plugin) {
|
|
21
|
+
return plugin === undefined ? { version, kind, files } : { version, kind, files, plugin };
|
|
22
|
+
}
|
|
17
23
|
/** JSON with sorted object keys, so equal values always serialize equally. */
|
|
18
24
|
export function canonicalJson(value) {
|
|
19
25
|
const norm = (v) => {
|
|
@@ -51,7 +57,8 @@ export class TransformCache {
|
|
|
51
57
|
blobPath(hex) {
|
|
52
58
|
return path.join(this.dir, 'blobs', hex);
|
|
53
59
|
}
|
|
54
|
-
|
|
60
|
+
/** `plugin`: the TRANSFORM's PLUGIN or TOOL, recorded on an entry fetched from the remote cache. */
|
|
61
|
+
async get(key, plugin) {
|
|
55
62
|
const file = this.entryPath(key);
|
|
56
63
|
let raw;
|
|
57
64
|
try {
|
|
@@ -63,7 +70,7 @@ export class TransformCache {
|
|
|
63
70
|
this.stats.misses++;
|
|
64
71
|
return undefined;
|
|
65
72
|
}
|
|
66
|
-
const m =
|
|
73
|
+
const m = manifestOf(CACHE_VERSION, fetched.kind, fetched.files, plugin);
|
|
67
74
|
raw = JSON.stringify(m);
|
|
68
75
|
await this.writeAtomic(file, raw, false).catch(() => { });
|
|
69
76
|
}
|
|
@@ -112,7 +119,8 @@ export class TransformCache {
|
|
|
112
119
|
const result = m.kind === 'file' ? { kind: 'file', bytes: loaded[0][1] } : { kind: 'files', files: loaded };
|
|
113
120
|
return { result, entry: { kind: m.kind, files: m.files.map((f) => [f[0], f[1], f[2]]) } };
|
|
114
121
|
}
|
|
115
|
-
|
|
122
|
+
/** `plugin`: the PLUGIN or TOOL that produced the result (see `usageByPlugin`, `prunePlugin`). */
|
|
123
|
+
async put(key, result, plugin) {
|
|
116
124
|
try {
|
|
117
125
|
const pairs = result.kind === 'file' ? [['', result.bytes]] : result.files;
|
|
118
126
|
const files = [];
|
|
@@ -121,7 +129,7 @@ export class TransformCache {
|
|
|
121
129
|
await this.writeAtomic(this.blobPath(hex), bytes, true);
|
|
122
130
|
files.push([p, hex, bytes.byteLength]);
|
|
123
131
|
}
|
|
124
|
-
const manifest =
|
|
132
|
+
const manifest = manifestOf(CACHE_VERSION, result.kind, files, plugin);
|
|
125
133
|
await this.writeAtomic(this.entryPath(key), JSON.stringify(manifest), false);
|
|
126
134
|
this.stats.writes++;
|
|
127
135
|
this.remote?.used(key, { kind: result.kind, files });
|
|
@@ -198,6 +206,97 @@ export class TransformCache {
|
|
|
198
206
|
}
|
|
199
207
|
return { entries: removedEntries, blobs: removedBlobs, bytes };
|
|
200
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Remove the entries `plugin` (a PLUGIN or TOOL name) wrote that were not used for `olderThanMs`
|
|
211
|
+
* (0: all of them), then the blobs they used that no remaining entry references. Entries of other
|
|
212
|
+
* plugins, entries that record no plugin, and blobs no entry references yet are kept.
|
|
213
|
+
*/
|
|
214
|
+
async prunePlugin(plugin, olderThanMs = 0, now = Date.now()) {
|
|
215
|
+
let removedEntries = 0;
|
|
216
|
+
const live = new Set();
|
|
217
|
+
const removed = new Set();
|
|
218
|
+
for (const n of await readdir(path.join(this.dir, 'entries')).catch(() => [])) {
|
|
219
|
+
const file = path.join(this.dir, 'entries', n);
|
|
220
|
+
const st = await stat(file).catch(() => undefined);
|
|
221
|
+
if (!st)
|
|
222
|
+
continue;
|
|
223
|
+
let m;
|
|
224
|
+
try {
|
|
225
|
+
m = JSON.parse(await readFile(file, 'utf8'));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const refs = Array.isArray(m?.files) ? m.files.map((f) => f[1]) : [];
|
|
231
|
+
const old = olderThanMs === 0 || now - st.mtimeMs >= olderThanMs;
|
|
232
|
+
if (old && m.plugin === plugin) {
|
|
233
|
+
await rm(file, { force: true });
|
|
234
|
+
removedEntries++;
|
|
235
|
+
for (const r of refs)
|
|
236
|
+
removed.add(r);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
for (const r of refs)
|
|
240
|
+
live.add(r);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let removedBlobs = 0;
|
|
244
|
+
let bytes = 0;
|
|
245
|
+
for (const n of removed) {
|
|
246
|
+
if (live.has(n))
|
|
247
|
+
continue;
|
|
248
|
+
const file = path.join(this.dir, 'blobs', n);
|
|
249
|
+
const st = await stat(file).catch(() => undefined);
|
|
250
|
+
if (!st?.isFile())
|
|
251
|
+
continue;
|
|
252
|
+
await rm(file, { force: true });
|
|
253
|
+
removedBlobs++;
|
|
254
|
+
bytes += st.size;
|
|
255
|
+
}
|
|
256
|
+
return { entries: removedEntries, blobs: removedBlobs, bytes };
|
|
257
|
+
}
|
|
258
|
+
/** Entries, blobs and bytes per plugin, sorted by name (entries that record none first). Reads no blob. */
|
|
259
|
+
async usageByPlugin() {
|
|
260
|
+
const by = new Map();
|
|
261
|
+
for (const n of await readdir(path.join(this.dir, 'entries')).catch(() => [])) {
|
|
262
|
+
if (!n.endsWith('.json'))
|
|
263
|
+
continue;
|
|
264
|
+
let m;
|
|
265
|
+
try {
|
|
266
|
+
m = JSON.parse(await readFile(path.join(this.dir, 'entries', n), 'utf8'));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!Array.isArray(m?.files))
|
|
272
|
+
continue;
|
|
273
|
+
const name = typeof m.plugin === 'string' ? m.plugin : undefined;
|
|
274
|
+
const slot = by.get(name) ?? { entries: 0, blobs: new Set() };
|
|
275
|
+
slot.entries++;
|
|
276
|
+
for (const f of m.files)
|
|
277
|
+
if (typeof f?.[1] === 'string')
|
|
278
|
+
slot.blobs.add(f[1]);
|
|
279
|
+
by.set(name, slot);
|
|
280
|
+
}
|
|
281
|
+
// (Array.prototype.sort puts undefined last without asking the comparator.)
|
|
282
|
+
const named = [...by.keys()].filter((k) => k !== undefined).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
283
|
+
const names = by.has(undefined) ? [undefined, ...named] : named;
|
|
284
|
+
const out = [];
|
|
285
|
+
for (const plugin of names) {
|
|
286
|
+
const slot = by.get(plugin);
|
|
287
|
+
let blobs = 0;
|
|
288
|
+
let bytes = 0;
|
|
289
|
+
for (const hex of slot.blobs) {
|
|
290
|
+
const st = await stat(this.blobPath(hex)).catch(() => undefined);
|
|
291
|
+
if (st?.isFile()) {
|
|
292
|
+
blobs++;
|
|
293
|
+
bytes += st.size;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
out.push({ plugin, entries: slot.entries, blobs, bytes });
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
201
300
|
}
|
|
202
301
|
/** Digest of every regular file under `dir` (a plugin can read all of them, so any may change its output). */
|
|
203
302
|
export async function digestFolder(dir, limits = { files: 2000, bytes: 128 * 1024 * 1024 }) {
|
package/lib/src/plugins/host.js
CHANGED
|
@@ -12,8 +12,9 @@ const MARK = '\u0000SITEFILE:';
|
|
|
12
12
|
const RUNNER = String.raw `
|
|
13
13
|
const MARK = '\u0000SITEFILE:';
|
|
14
14
|
const send = (msg) => process.stdout.write(MARK + JSON.stringify(msg) + '\n');
|
|
15
|
-
// Plugin console output goes to
|
|
16
|
-
|
|
15
|
+
// Plugin console output goes to stdout as unmarked lines, so it reaches the host before
|
|
16
|
+
// the result that follows it (stderr is another pipe, with no order against stdout).
|
|
17
|
+
for (const k of ['log', 'info', 'debug']) console[k] = (...a) => process.stdout.write(a.map(String).join(' ').split('\n').filter((l) => !l.startsWith(MARK)).join('\n') + '\n');
|
|
17
18
|
const toB64 = (v) => Buffer.from(typeof v === 'string' ? Buffer.from(v, 'utf8') : v).toString('base64');
|
|
18
19
|
const isBytes = (v) => typeof v === 'string' || v instanceof Uint8Array;
|
|
19
20
|
let transform;
|
package/lib/src/registry.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Readable } from 'node:stream';
|
|
2
2
|
import { type Descriptor } from './oci.js';
|
|
3
3
|
import type { Credentials } from './registry-auth.js';
|
|
4
|
+
import { type NetworkSettings } from './resources/network.js';
|
|
4
5
|
/**
|
|
5
6
|
* Client for the OCI distribution API (distribution-spec v1.1): `/v2/` probe, Bearer-token and Basic
|
|
6
7
|
* auth, manifest and blob GET/HEAD, monolithic blob upload, cross-repository mount, manifest PUT.
|
|
@@ -40,6 +41,11 @@ export interface RegistryOptions {
|
|
|
40
41
|
readonly limits?: Partial<RegistryLimits> | undefined;
|
|
41
42
|
/** Extra PEM CA certificate(s) (tests / private CAs). */
|
|
42
43
|
readonly ca?: string | Buffer | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Proxy, host rules and timeouts (see {@link NetworkSettings}); the host rules apply to every
|
|
46
|
+
* request, the registry's own included. `readTimeoutMs` replaces `limits.timeoutMs`.
|
|
47
|
+
*/
|
|
48
|
+
readonly network?: NetworkSettings | undefined;
|
|
43
49
|
}
|
|
44
50
|
export interface Body {
|
|
45
51
|
readonly length: number;
|
package/lib/src/registry.js
CHANGED
|
@@ -7,7 +7,8 @@ import { sha256 } from './digest.js';
|
|
|
7
7
|
import { SitefileError, isSitefileError } from './errors.js';
|
|
8
8
|
import { MEDIA } from './oci.js';
|
|
9
9
|
import { registryApiHost } from './ref.js';
|
|
10
|
-
import { createGuardedLookup, redactUrl } from './resources/https.js';
|
|
10
|
+
import { armTimeouts, createGuardedLookup, redactUrl, routeRequest } from './resources/https.js';
|
|
11
|
+
import { checkHostRules } from './resources/network.js';
|
|
11
12
|
import { isPublicAddress } from './resources/netpolicy.js';
|
|
12
13
|
import { DEFAULT_FETCH_POLICY } from './resources/types.js';
|
|
13
14
|
export const DEFAULT_REGISTRY_LIMITS = {
|
|
@@ -97,7 +98,8 @@ export class RegistryClient {
|
|
|
97
98
|
this.registry = registry;
|
|
98
99
|
this.options = options;
|
|
99
100
|
this.host = registryApiHost(registry);
|
|
100
|
-
|
|
101
|
+
const read = options.network?.readTimeoutMs;
|
|
102
|
+
this.limits = { ...DEFAULT_REGISTRY_LIMITS, ...options.limits, ...(read ? { timeoutMs: read } : {}) };
|
|
101
103
|
this.loopback = isLoopbackHost(hostnameOf(this.host));
|
|
102
104
|
this.insecure = this.loopback || (options.insecureRegistries ?? []).some((h) => h.toLowerCase() === this.host.toLowerCase() || h.toLowerCase() === registry.toLowerCase());
|
|
103
105
|
}
|
|
@@ -120,31 +122,48 @@ export class RegistryClient {
|
|
|
120
122
|
throw new SitefileError('E_SSRF_BLOCKED', `${this.registry}: refusing to connect to non-public address ${host} (${redactUrl(u)}); use --allow-private-network to permit it`);
|
|
121
123
|
}
|
|
122
124
|
}
|
|
123
|
-
sendOnce(method, url, headers, body, timeoutMs) {
|
|
125
|
+
async sendOnce(method, url, headers, body, timeoutMs) {
|
|
126
|
+
const shown = redactUrl(url);
|
|
127
|
+
const network = this.options.network;
|
|
128
|
+
checkHostRules(url, network, shown);
|
|
129
|
+
const same = this.sameOrigin(url);
|
|
130
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
131
|
+
const loopbackPair = this.loopback && isLoopbackHost(host);
|
|
132
|
+
const policy = { ...DEFAULT_FETCH_POLICY, allowPrivateNetwork: same || loopbackPair || !!this.options.allowPrivateNetwork, network };
|
|
133
|
+
const h = { 'accept-encoding': 'identity', 'user-agent': 'sitefile/0.1', ...headers };
|
|
134
|
+
if (body)
|
|
135
|
+
h['content-length'] = String(body.length);
|
|
136
|
+
const o = { method, headers: h, lookup: createGuardedLookup(policy), agent: false, timeout: timeoutMs };
|
|
137
|
+
if (url.protocol === 'https:' && this.options.ca !== undefined) {
|
|
138
|
+
const get = tls.getCACertificates;
|
|
139
|
+
o.ca = [...(get ? get('default') : tls.rootCertificates), this.options.ca];
|
|
140
|
+
}
|
|
141
|
+
// The idle timeout is the request's own (`timeout`); only the connect timeout comes from `network`.
|
|
142
|
+
const connectOnly = network?.connectTimeoutMs ? { connectTimeoutMs: network.connectTimeoutMs } : undefined;
|
|
143
|
+
const connectTimeout = () => new SitefileError('E_FETCH_TIMEOUT', `${method} ${shown}: connecting timed out after ${network?.connectTimeoutMs} ms (connect timeout)`);
|
|
144
|
+
const idleTimeout = () => new SitefileError('E_FETCH_TIMEOUT', `${method} ${shown} timed out after ${timeoutMs} ms`);
|
|
145
|
+
const route = await routeRequest(url, o, policy, {
|
|
146
|
+
shown,
|
|
147
|
+
fail: (e) => (e.detail.endsWith('(connect timeout)') ? connectTimeout() : idleTimeout()),
|
|
148
|
+
}).catch((e) => {
|
|
149
|
+
// Proxy failures are registry transport failures (an insecure registry then falls back to http).
|
|
150
|
+
if (isSitefileError(e))
|
|
151
|
+
throw e.code === 'E_FETCH' ? new SitefileError('E_REGISTRY', e.detail, undefined, { cause: e }) : e;
|
|
152
|
+
throw new SitefileError('E_REGISTRY', `${method} ${shown} failed: ${e.message}`, undefined, { cause: e });
|
|
153
|
+
});
|
|
124
154
|
return new Promise((resolve, reject) => {
|
|
125
|
-
const same = this.sameOrigin(url);
|
|
126
|
-
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
127
|
-
const loopbackPair = this.loopback && isLoopbackHost(host);
|
|
128
|
-
const policy = { ...DEFAULT_FETCH_POLICY, allowPrivateNetwork: same || loopbackPair || !!this.options.allowPrivateNetwork };
|
|
129
|
-
const h = { 'accept-encoding': 'identity', 'user-agent': 'sitefile/0.1', ...headers };
|
|
130
|
-
if (body)
|
|
131
|
-
h['content-length'] = String(body.length);
|
|
132
|
-
const o = { method, headers: h, lookup: createGuardedLookup(policy), agent: false, timeout: timeoutMs };
|
|
133
|
-
if (url.protocol === 'https:' && this.options.ca !== undefined) {
|
|
134
|
-
const get = tls.getCACertificates;
|
|
135
|
-
o.ca = [...(get ? get('default') : tls.rootCertificates), this.options.ca];
|
|
136
|
-
}
|
|
137
155
|
let req;
|
|
138
156
|
try {
|
|
139
|
-
req =
|
|
157
|
+
req = route.url ? route.fn(route.url, route.options) : route.fn(route.options);
|
|
140
158
|
}
|
|
141
159
|
catch (e) {
|
|
142
|
-
reject(new SitefileError('E_REGISTRY', `cannot request ${
|
|
160
|
+
reject(new SitefileError('E_REGISTRY', `cannot request ${shown}: ${e.message}`, undefined, { cause: e }));
|
|
143
161
|
return;
|
|
144
162
|
}
|
|
145
|
-
req.
|
|
163
|
+
armTimeouts(req, connectOnly, route.proxied, () => req.destroy(connectTimeout()));
|
|
164
|
+
req.on('timeout', () => req.destroy(idleTimeout()));
|
|
146
165
|
req.on('response', resolve);
|
|
147
|
-
req.on('error', (e) => reject(unwrap(e) ?? new SitefileError('E_REGISTRY', `${method} ${
|
|
166
|
+
req.on('error', (e) => reject(unwrap(e) ?? new SitefileError('E_REGISTRY', `${method} ${shown} failed: ${e.message}`, undefined, { cause: e })));
|
|
148
167
|
if (!body) {
|
|
149
168
|
req.end();
|
|
150
169
|
return;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { request as httpRequest, type ClientRequest, type RequestOptions } from 'node:http';
|
|
1
2
|
import { type LookupFunction } from 'node:net';
|
|
2
|
-
import { type SourceSpan } from '../errors.js';
|
|
3
|
+
import { SitefileError, type SourceSpan } from '../errors.js';
|
|
3
4
|
import type { FetchPolicy } from './types.js';
|
|
5
|
+
import { type NetworkSettings } from './network.js';
|
|
4
6
|
export interface FetchResult {
|
|
5
7
|
readonly bytes: Uint8Array;
|
|
6
8
|
/** URL of the final hop after redirects. */
|
|
@@ -38,3 +40,35 @@ export declare function createGuardedLookup(policy: FetchPolicy, span?: SourceSp
|
|
|
38
40
|
* whole fetch.
|
|
39
41
|
*/
|
|
40
42
|
export declare function fetchRemote(url: string, policy: FetchPolicy, span?: SourceSpan, request?: FetchRequest): Promise<FetchResult>;
|
|
43
|
+
/** How to send one request: directly, or through the configured proxy. */
|
|
44
|
+
export interface Route {
|
|
45
|
+
readonly fn: typeof httpRequest;
|
|
46
|
+
/** The URL to pass to `fn`; undefined when `options` carry the proxy's host, port and absolute path. */
|
|
47
|
+
readonly url?: URL | undefined;
|
|
48
|
+
readonly options: RequestOptions;
|
|
49
|
+
/** True when the connection is to a proxy (the connect timeout then covers the tunnel). */
|
|
50
|
+
readonly proxied: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface RouteContext {
|
|
53
|
+
readonly signal?: AbortSignal | undefined;
|
|
54
|
+
readonly span?: SourceSpan | undefined;
|
|
55
|
+
/** The hop's URL as shown in messages. */
|
|
56
|
+
readonly shown: string;
|
|
57
|
+
/** Records a timeout error before the request is destroyed with it. */
|
|
58
|
+
readonly fail?: ((e: SitefileError) => SitefileError) | undefined;
|
|
59
|
+
}
|
|
60
|
+
/** E_FETCH_TIMEOUT for a connect or read (idle) timeout. */
|
|
61
|
+
export declare function hopTimeout(kind: 'connect' | 'read', shown: string, network: NetworkSettings | undefined, span?: SourceSpan): SitefileError;
|
|
62
|
+
/**
|
|
63
|
+
* Apply `network`'s connect timeout (until the TCP connection is up) and read timeout (socket idle time
|
|
64
|
+
* once connected) to a request; `onTimeout` destroys it. For a tunnelled request the socket is already
|
|
65
|
+
* connected (the tunnel had the connect timeout), so only the read timeout applies.
|
|
66
|
+
*/
|
|
67
|
+
export declare function armTimeouts(req: ClientRequest, network: NetworkSettings | undefined, _proxied: boolean, onTimeout: (kind: 'connect' | 'read') => void): void;
|
|
68
|
+
/**
|
|
69
|
+
* Route one request: directly (with the guarded lookup in `options`), or through `policy.network.proxy`
|
|
70
|
+
* unless the host is in its no-proxy list. Through a proxy, the target host is still resolved here and
|
|
71
|
+
* refused if it is non-public (unless `allowPrivateNetwork`), so a proxy never widens the policy; the
|
|
72
|
+
* proxy itself is named by the user and may be on a private address.
|
|
73
|
+
*/
|
|
74
|
+
export declare function routeRequest(url: URL, options: RequestOptions, policy: FetchPolicy, ctx: RouteContext): Promise<Route>;
|
|
@@ -5,6 +5,7 @@ import * as tls from 'node:tls';
|
|
|
5
5
|
import { isIP } from 'node:net';
|
|
6
6
|
import { SitefileError, isSitefileError } from '../errors.js';
|
|
7
7
|
import { isPublicAddress } from './netpolicy.js';
|
|
8
|
+
import { checkHostRules, proxyFor } from './network.js';
|
|
8
9
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
9
10
|
const USER_AGENT = 'sitefile/0.1';
|
|
10
11
|
/** A URL rendered without any userinfo, safe for error messages. */
|
|
@@ -37,6 +38,7 @@ export function validateHop(url, policy, span) {
|
|
|
37
38
|
const host = stripBrackets(url.hostname);
|
|
38
39
|
if (host === '')
|
|
39
40
|
throw new SitefileError('E_PROTOCOL', `URL ${redactUrl(url)} has no host`, span);
|
|
41
|
+
checkHostRules(url, policy.network, redactUrl(url), span);
|
|
40
42
|
if (isIP(host) !== 0 && !policy.allowPrivateNetwork && !isPublicAddress(host)) {
|
|
41
43
|
throw new SitefileError('E_SSRF_BLOCKED', `refusing to connect to non-public address ${host} (${redactUrl(url)}); use --allow-private-network to permit it`, span);
|
|
42
44
|
}
|
|
@@ -184,6 +186,8 @@ export async function fetchRemote(url, policy, span, request = {}) {
|
|
|
184
186
|
throw e;
|
|
185
187
|
if (controller.signal.aborted)
|
|
186
188
|
throw timeoutError();
|
|
189
|
+
if (state.failure)
|
|
190
|
+
throw state.failure;
|
|
187
191
|
throw new SitefileError('E_FETCH', `error reading ${redactUrl(current)}: ${e.message}`, span, {
|
|
188
192
|
cause: e,
|
|
189
193
|
});
|
|
@@ -203,32 +207,47 @@ export async function fetchRemote(url, policy, span, request = {}) {
|
|
|
203
207
|
state.req?.destroy();
|
|
204
208
|
}
|
|
205
209
|
}
|
|
206
|
-
function openHop(url, policy, signal, state, span, timeoutError, method, extra, body) {
|
|
210
|
+
async function openHop(url, policy, signal, state, span, timeoutError, method, extra, body) {
|
|
211
|
+
const options = {
|
|
212
|
+
method,
|
|
213
|
+
headers: { 'accept-encoding': 'identity', 'user-agent': USER_AGENT, accept: '*/*', ...extra, ...(body ? { 'content-length': String(body.byteLength) } : {}) },
|
|
214
|
+
lookup: createGuardedLookup(policy, span),
|
|
215
|
+
agent: false,
|
|
216
|
+
signal,
|
|
217
|
+
};
|
|
218
|
+
if (url.protocol === 'https:' && policy.ca !== undefined) {
|
|
219
|
+
// Additive: setting `ca` replaces Node's whole default store, so start from
|
|
220
|
+
// that store (bundled roots plus NODE_EXTRA_CA_CERTS / --use-system-ca),
|
|
221
|
+
// not just the bundled roots, and add the extra CA(s).
|
|
222
|
+
options.ca = [...defaultCaCertificates(), policy.ca];
|
|
223
|
+
}
|
|
224
|
+
const shown = redactUrl(url);
|
|
225
|
+
const fail = (e) => {
|
|
226
|
+
state.failure = e;
|
|
227
|
+
return e;
|
|
228
|
+
};
|
|
229
|
+
let route;
|
|
230
|
+
try {
|
|
231
|
+
route = await routeRequest(url, options, policy, { signal, span, shown, fail });
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
if (signal.aborted)
|
|
235
|
+
throw timeoutError();
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
207
238
|
return new Promise((resolve, reject) => {
|
|
208
|
-
const options = {
|
|
209
|
-
method,
|
|
210
|
-
headers: { 'accept-encoding': 'identity', 'user-agent': USER_AGENT, accept: '*/*', ...extra, ...(body ? { 'content-length': String(body.byteLength) } : {}) },
|
|
211
|
-
lookup: createGuardedLookup(policy, span),
|
|
212
|
-
agent: false,
|
|
213
|
-
signal,
|
|
214
|
-
};
|
|
215
|
-
if (url.protocol === 'https:' && policy.ca !== undefined) {
|
|
216
|
-
// Additive: setting `ca` replaces Node's whole default store, so start from
|
|
217
|
-
// that store (bundled roots plus NODE_EXTRA_CA_CERTS / --use-system-ca),
|
|
218
|
-
// not just the bundled roots, and add the extra CA(s).
|
|
219
|
-
options.ca = [...defaultCaCertificates(), policy.ca];
|
|
220
|
-
}
|
|
221
|
-
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
222
239
|
let req;
|
|
223
240
|
try {
|
|
224
|
-
req =
|
|
241
|
+
req = route.url ? route.fn(route.url, route.options) : route.fn(route.options);
|
|
225
242
|
}
|
|
226
243
|
catch (e) {
|
|
227
|
-
reject(new SitefileError('E_FETCH', `cannot request ${
|
|
244
|
+
reject(new SitefileError('E_FETCH', `cannot request ${shown}: ${e.message}`, span, { cause: e }));
|
|
228
245
|
return;
|
|
229
246
|
}
|
|
230
247
|
state.req = req;
|
|
231
248
|
state.res = undefined;
|
|
249
|
+
state.failure = undefined;
|
|
250
|
+
armTimeouts(req, policy.network, route.proxied, (kind) => req.destroy(fail(hopTimeout(kind, shown, policy.network, span))));
|
|
232
251
|
req.on('response', (res) => {
|
|
233
252
|
state.res = res;
|
|
234
253
|
resolve(res);
|
|
@@ -243,11 +262,149 @@ function openHop(url, policy, signal, state, span, timeoutError, method, extra,
|
|
|
243
262
|
reject(inner);
|
|
244
263
|
return;
|
|
245
264
|
}
|
|
246
|
-
reject(new SitefileError('E_FETCH', `fetching ${
|
|
265
|
+
reject(new SitefileError('E_FETCH', `fetching ${shown} failed: ${e.message}`, span, { cause: e }));
|
|
247
266
|
});
|
|
248
267
|
req.end(body);
|
|
249
268
|
});
|
|
250
269
|
}
|
|
270
|
+
/** E_FETCH_TIMEOUT for a connect or read (idle) timeout. */
|
|
271
|
+
export function hopTimeout(kind, shown, network, span) {
|
|
272
|
+
return kind === 'connect'
|
|
273
|
+
? new SitefileError('E_FETCH_TIMEOUT', `fetching ${shown}: connecting timed out after ${network?.connectTimeoutMs} ms (connect timeout)`, span)
|
|
274
|
+
: new SitefileError('E_FETCH_TIMEOUT', `fetching ${shown}: no data for ${network?.readTimeoutMs} ms (read timeout)`, span);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Apply `network`'s connect timeout (until the TCP connection is up) and read timeout (socket idle time
|
|
278
|
+
* once connected) to a request; `onTimeout` destroys it. For a tunnelled request the socket is already
|
|
279
|
+
* connected (the tunnel had the connect timeout), so only the read timeout applies.
|
|
280
|
+
*/
|
|
281
|
+
export function armTimeouts(req, network, _proxied, onTimeout) {
|
|
282
|
+
const connectMs = network?.connectTimeoutMs;
|
|
283
|
+
if (connectMs) {
|
|
284
|
+
const t = setTimeout(() => onTimeout('connect'), connectMs);
|
|
285
|
+
t.unref?.();
|
|
286
|
+
const clear = () => clearTimeout(t);
|
|
287
|
+
req.once('socket', (socket) => {
|
|
288
|
+
if (socket.connecting)
|
|
289
|
+
socket.once('connect', clear);
|
|
290
|
+
else
|
|
291
|
+
clear();
|
|
292
|
+
});
|
|
293
|
+
req.once('close', clear);
|
|
294
|
+
req.once('error', clear);
|
|
295
|
+
}
|
|
296
|
+
const readMs = network?.readTimeoutMs;
|
|
297
|
+
if (readMs)
|
|
298
|
+
req.setTimeout(readMs, () => onTimeout('read'));
|
|
299
|
+
}
|
|
300
|
+
/** Resolve `hostname` and apply the private-address rule, as a direct connection would at connect time. */
|
|
301
|
+
function checkResolved(hostname, policy, span) {
|
|
302
|
+
return new Promise((resolve, reject) => {
|
|
303
|
+
createGuardedLookup(policy, span)(hostname, { all: true }, (err) => {
|
|
304
|
+
if (!err) {
|
|
305
|
+
resolve();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (isSitefileError(err)) {
|
|
309
|
+
reject(err);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
reject(new SitefileError('E_FETCH', `cannot resolve ${hostname} to check it against the private-network rule before using the proxy: ${err.code ?? err.message}`, span));
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Route one request: directly (with the guarded lookup in `options`), or through `policy.network.proxy`
|
|
318
|
+
* unless the host is in its no-proxy list. Through a proxy, the target host is still resolved here and
|
|
319
|
+
* refused if it is non-public (unless `allowPrivateNetwork`), so a proxy never widens the policy; the
|
|
320
|
+
* proxy itself is named by the user and may be on a private address.
|
|
321
|
+
*/
|
|
322
|
+
export async function routeRequest(url, options, policy, ctx) {
|
|
323
|
+
const https = url.protocol === 'https:';
|
|
324
|
+
const proxy = proxyFor(url, policy.network);
|
|
325
|
+
if (!proxy)
|
|
326
|
+
return { fn: https ? httpsRequest : httpRequest, url, options, proxied: false };
|
|
327
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
328
|
+
if (!policy.allowPrivateNetwork && isIP(host) === 0)
|
|
329
|
+
await checkResolved(host, policy, ctx.span);
|
|
330
|
+
const port = url.port !== '' ? Number(url.port) : https ? 443 : 80;
|
|
331
|
+
const { lookup: _lookup, agent: _agent, ...rest } = options;
|
|
332
|
+
if (!https) {
|
|
333
|
+
const target = new URL(url.href);
|
|
334
|
+
target.hash = '';
|
|
335
|
+
return {
|
|
336
|
+
fn: httpRequest,
|
|
337
|
+
options: {
|
|
338
|
+
...rest,
|
|
339
|
+
host: proxy.host,
|
|
340
|
+
port: proxy.port,
|
|
341
|
+
path: target.href,
|
|
342
|
+
agent: false,
|
|
343
|
+
headers: { ...options.headers, host: url.host, ...(proxy.authorization ? { 'proxy-authorization': proxy.authorization } : {}) },
|
|
344
|
+
},
|
|
345
|
+
proxied: true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const socket = await openTunnel(proxy, host, port, policy.network, ctx);
|
|
349
|
+
const ca = options.ca;
|
|
350
|
+
return {
|
|
351
|
+
fn: httpsRequest,
|
|
352
|
+
url,
|
|
353
|
+
options: {
|
|
354
|
+
...rest,
|
|
355
|
+
createConnection: () => tls.connect({ socket, host, ...(isIP(host) === 0 ? { servername: host } : {}), ...(ca !== undefined ? { ca } : {}), ALPNProtocols: ['http/1.1'] }),
|
|
356
|
+
},
|
|
357
|
+
proxied: true,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/** A CONNECT tunnel to `host:port` through `proxy`. */
|
|
361
|
+
function openTunnel(proxy, host, port, network, ctx) {
|
|
362
|
+
const authority = `${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
let req;
|
|
365
|
+
try {
|
|
366
|
+
req = httpRequest({
|
|
367
|
+
host: proxy.host,
|
|
368
|
+
port: proxy.port,
|
|
369
|
+
method: 'CONNECT',
|
|
370
|
+
path: authority,
|
|
371
|
+
agent: false,
|
|
372
|
+
headers: { host: authority, 'user-agent': USER_AGENT, ...(proxy.authorization ? { 'proxy-authorization': proxy.authorization } : {}) },
|
|
373
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
reject(new SitefileError('E_FETCH', `fetching ${ctx.shown} failed: proxy ${proxy.shown}: ${e.message}`, ctx.span, { cause: e }));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const record = ctx.fail ?? ((e) => e);
|
|
381
|
+
armTimeouts(req, network, true, (kind) => req.destroy(record(hopTimeout(kind, ctx.shown, network, ctx.span))));
|
|
382
|
+
const refused = (status) => status === 407
|
|
383
|
+
? new SitefileError('E_FETCH', `proxy ${proxy.shown} requires authentication (HTTP 407)`, ctx.span)
|
|
384
|
+
: new SitefileError('E_FETCH', `proxy ${proxy.shown} refused to connect to ${authority}`, ctx.span);
|
|
385
|
+
req.on('connect', (res, socket, head) => {
|
|
386
|
+
if (res.statusCode !== 200) {
|
|
387
|
+
socket.destroy();
|
|
388
|
+
reject(refused(res.statusCode));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
socket.setTimeout(0);
|
|
392
|
+
socket.removeAllListeners('timeout');
|
|
393
|
+
if (head.length)
|
|
394
|
+
socket.unshift(head);
|
|
395
|
+
resolve(socket);
|
|
396
|
+
});
|
|
397
|
+
req.on('response', (res) => {
|
|
398
|
+
res.resume();
|
|
399
|
+
reject(refused(res.statusCode));
|
|
400
|
+
});
|
|
401
|
+
req.on('error', (e) => {
|
|
402
|
+
const inner = findSitefileError(e);
|
|
403
|
+
reject(inner ?? new SitefileError('E_FETCH', `fetching ${ctx.shown} failed: proxy ${proxy.shown}: ${e.message}`, ctx.span, { cause: e }));
|
|
404
|
+
});
|
|
405
|
+
req.end();
|
|
406
|
+
});
|
|
407
|
+
}
|
|
251
408
|
/** Node's default CA store; `tls.getCACertificates` exists from Node 22.15/23.5. */
|
|
252
409
|
function defaultCaCertificates() {
|
|
253
410
|
const get = tls.getCACertificates;
|