@johnhenry/sitefile 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -0
- package/README.md +58 -20
- 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/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
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/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;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type SourceSpan } from '../errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Explicit network settings for everything sitefile fetches (IMPORT over http(s), Git, registries).
|
|
4
|
+
* Nothing here is read from the environment implicitly: `HTTP(S)_PROXY` stays ignored, and the CLI
|
|
5
|
+
* reads `SITEFILE_PROXY` and friends through {@link networkFromEnv}. Embedders pass this in code
|
|
6
|
+
* (`FetchPolicy.network`, `RegistryOptions.network`).
|
|
7
|
+
*/
|
|
8
|
+
export interface NetworkSettings {
|
|
9
|
+
/** `http://[user:password@]host[:port]`: a CONNECT tunnel for https targets, absolute-form requests for http. */
|
|
10
|
+
readonly proxy?: string | undefined;
|
|
11
|
+
/** Hosts reached directly, never through the proxy (`NO_PROXY` syntax, see {@link hostMatches}). */
|
|
12
|
+
readonly noProxy?: readonly string[] | undefined;
|
|
13
|
+
/** When non-empty, only hosts matching one of these patterns may be contacted. */
|
|
14
|
+
readonly allowHosts?: readonly string[] | undefined;
|
|
15
|
+
/** Hosts never contacted; a deny rule beats an allow rule. */
|
|
16
|
+
readonly denyHosts?: readonly string[] | undefined;
|
|
17
|
+
/** Time limit for establishing the TCP connection (to the target, or to the proxy), ms. */
|
|
18
|
+
readonly connectTimeoutMs?: number | undefined;
|
|
19
|
+
/** Longest wait for data once connected (idle time), ms. */
|
|
20
|
+
readonly readTimeoutMs?: number | undefined;
|
|
21
|
+
}
|
|
22
|
+
/** A configured HTTP proxy, parsed. */
|
|
23
|
+
export interface ProxyConfig {
|
|
24
|
+
readonly host: string;
|
|
25
|
+
readonly port: number;
|
|
26
|
+
/** `Basic …` when the URL carries credentials. */
|
|
27
|
+
readonly authorization?: string | undefined;
|
|
28
|
+
/** The proxy URL without credentials, for messages. */
|
|
29
|
+
readonly shown: string;
|
|
30
|
+
}
|
|
31
|
+
/** Split a list given as one string (commas and/or whitespace). */
|
|
32
|
+
export declare function splitList(raw: string | undefined): string[];
|
|
33
|
+
/** Parse a proxy URL, or throw E_USAGE. Credentials never appear in the message. */
|
|
34
|
+
export declare function parseProxy(raw: string): ProxyConfig;
|
|
35
|
+
/**
|
|
36
|
+
* True when `host` (as in a URL, IPv6 without brackets) on `port` matches `pattern`:
|
|
37
|
+
* `*` every host
|
|
38
|
+
* `example.com` example.com and every subdomain (`.example.com` and `*.example.com` too)
|
|
39
|
+
* `10.0.0.0/8`, `fd00::/8` IP-literal hosts in that network; `192.0.2.1`, `::1` that address
|
|
40
|
+
* any of these + `:port` only that port (`[::1]:8080` for IPv6)
|
|
41
|
+
* Patterns match the host as written in the URL: a name pattern never matches an IP literal and an
|
|
42
|
+
* IP pattern never matches a name, whatever it resolves to (the private-address rule covers that).
|
|
43
|
+
*/
|
|
44
|
+
export declare function hostMatches(host: string, port: number, pattern: string): boolean;
|
|
45
|
+
/** Default port of an http(s) URL. */
|
|
46
|
+
export declare function urlPort(url: URL): number;
|
|
47
|
+
/**
|
|
48
|
+
* Apply the allow/deny host rules to one hop (every redirect, token realm and blob CDN hop included).
|
|
49
|
+
* Throws E_HOST_BLOCKED naming the rule; a deny rule beats an allow rule.
|
|
50
|
+
*/
|
|
51
|
+
export declare function checkHostRules(url: URL, network: NetworkSettings | undefined, shown: string, span?: SourceSpan): void;
|
|
52
|
+
/** The proxy to use for `url`, or undefined for a direct connection (no proxy, or a no-proxy match). */
|
|
53
|
+
export declare function proxyFor(url: URL, network: NetworkSettings | undefined): ProxyConfig | undefined;
|
|
54
|
+
/** A usage problem in `network` (bad proxy URL, host pattern or timeout), or undefined. */
|
|
55
|
+
export declare function networkProblem(network: NetworkSettings): string | undefined;
|
|
56
|
+
/** Parse `SITEFILE_FETCH_TIMEOUT`: `<ms>` (connect and read) or `connect=<ms>,read=<ms>` (either part optional). */
|
|
57
|
+
export declare function parseFetchTimeout(raw: string): {
|
|
58
|
+
connectTimeoutMs?: number;
|
|
59
|
+
readTimeoutMs?: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Network settings from `SITEFILE_PROXY`, `SITEFILE_NO_PROXY`, `SITEFILE_ALLOW_HOSTS`,
|
|
63
|
+
* `SITEFILE_DENY_HOSTS` (comma- or space-separated) and `SITEFILE_FETCH_TIMEOUT`. Empty values are unset.
|
|
64
|
+
* `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` are never read.
|
|
65
|
+
*/
|
|
66
|
+
export declare function networkFromEnv(env?: NodeJS.ProcessEnv): NetworkSettings;
|
|
67
|
+
/** `over` on top of `base`: the proxy and timeouts are replaced, the host lists are joined (duplicates dropped). */
|
|
68
|
+
export declare function mergeNetwork(base: NetworkSettings, over: NetworkSettings): NetworkSettings;
|
|
69
|
+
/** True when any setting is present. */
|
|
70
|
+
export declare function hasNetworkSettings(n: NetworkSettings | undefined): boolean;
|