@instafy/cli 0.2.3 → 0.3.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/README.md +4 -0
- package/dist/bind/LICENSE +661 -0
- package/dist/bind/bin/instafy-bind.mjs +542 -0
- package/dist/bind/client/bind-client.js +1219 -0
- package/dist/bind/src/apply.mjs +293 -0
- package/dist/bind/src/bound.mjs +202 -0
- package/dist/bind/src/budget.mjs +58 -0
- package/dist/bind/src/check.mjs +176 -0
- package/dist/bind/src/config.mjs +169 -0
- package/dist/bind/src/controller.mjs +244 -0
- package/dist/bind/src/detached-env.mjs +27 -0
- package/dist/bind/src/executors/agent.mjs +336 -0
- package/dist/bind/src/executors/script.mjs +163 -0
- package/dist/bind/src/handler-kit.mjs +150 -0
- package/dist/bind/src/init.mjs +238 -0
- package/dist/bind/src/jobs.mjs +316 -0
- package/dist/bind/src/net.mjs +281 -0
- package/dist/bind/src/page-fetch.mjs +43 -0
- package/dist/bind/src/preview-host.mjs +114 -0
- package/dist/bind/src/protocol.mjs +103 -0
- package/dist/bind/src/records-schema.mjs +580 -0
- package/dist/bind/src/records.mjs +161 -0
- package/dist/bind/src/save-owner.mjs +492 -0
- package/dist/bind/src/schema-lite.mjs +212 -0
- package/dist/bind/src/server.mjs +908 -0
- package/dist/bind/src/sse.mjs +119 -0
- package/dist/bind/src/starters/blank/app.js +17 -0
- package/dist/bind/src/starters/blank/index.html +22 -0
- package/dist/bind/src/starters/checklist/app.js +171 -0
- package/dist/bind/src/starters/checklist/bound.json +29 -0
- package/dist/bind/src/starters/checklist/data.json +26 -0
- package/dist/bind/src/starters/checklist/index.html +23 -0
- package/dist/bind/src/starters/compare/app.js +316 -0
- package/dist/bind/src/starters/compare/bound.json +26 -0
- package/dist/bind/src/starters/compare/data.json +170 -0
- package/dist/bind/src/starters/compare/index.html +23 -0
- package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
- package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
- package/dist/bind/src/starters/ranked/app.js +166 -0
- package/dist/bind/src/starters/ranked/bound.json +26 -0
- package/dist/bind/src/starters/ranked/data.json +142 -0
- package/dist/bind/src/starters/ranked/index.html +23 -0
- package/dist/bind/src/starters/site.js +888 -0
- package/dist/bind/src/starters/theme.css +492 -0
- package/dist/bind/src/starters/timeline/app.js +143 -0
- package/dist/bind/src/starters/timeline/data.json +41 -0
- package/dist/bind/src/starters/timeline/index.html +22 -0
- package/dist/bind/src/static.mjs +141 -0
- package/dist/bind/src/tokens.mjs +102 -0
- package/dist/bind/src/try.mjs +139 -0
- package/dist/bind/src/web.mjs +191 -0
- package/dist/cli.js +2261 -387
- package/package.json +3 -2
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// SSRF guard: URL params and handler fetches go only to public http(s) addresses unless --allow-local.
|
|
2
|
+
import dns from "node:dns";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import https from "node:https";
|
|
6
|
+
|
|
7
|
+
function ipv4ToInt(ip) {
|
|
8
|
+
return ip.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const V4_BLOCKS = [
|
|
12
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
|
|
13
|
+
["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15],
|
|
14
|
+
["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
15
|
+
].map(([base, bits]) => [ipv4ToInt(base), bits]);
|
|
16
|
+
|
|
17
|
+
function isPrivateV4(ip) {
|
|
18
|
+
const value = ipv4ToInt(ip);
|
|
19
|
+
return V4_BLOCKS.some(([base, bits]) => {
|
|
20
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
21
|
+
return (value & mask) === (base & mask);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expandV6(ip) {
|
|
26
|
+
let address = ip.toLowerCase().replace(/%.*$/, "");
|
|
27
|
+
let tail = [];
|
|
28
|
+
const v4 = address.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
29
|
+
if (v4) {
|
|
30
|
+
const n = ipv4ToInt(v4[1]);
|
|
31
|
+
tail = [(n >>> 16).toString(16), (n & 0xffff).toString(16)];
|
|
32
|
+
address = address.slice(0, -v4[1].length).replace(/:$/, "") || ":";
|
|
33
|
+
if (address === ":") address = "::";
|
|
34
|
+
}
|
|
35
|
+
const [head, rest] = address.split("::");
|
|
36
|
+
const left = head ? head.split(":").filter(Boolean) : [];
|
|
37
|
+
const right = rest !== undefined ? (rest ? rest.split(":").filter(Boolean) : []) : null;
|
|
38
|
+
const known = left.length + (right ? right.length : 0) + tail.length;
|
|
39
|
+
const middle = right === null ? [] : Array(Math.max(0, 8 - known)).fill("0");
|
|
40
|
+
return [...left, ...middle, ...(right || []), ...tail].map((h) => parseInt(h, 16) || 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPrivateV6(ip) {
|
|
44
|
+
const g = expandV6(ip);
|
|
45
|
+
if (g.length !== 8) return true;
|
|
46
|
+
const allZeroUpTo = (n) => g.slice(0, n).every((x) => x === 0);
|
|
47
|
+
if (allZeroUpTo(8)) return true; // ::
|
|
48
|
+
if (allZeroUpTo(7) && g[7] === 1) return true; // ::1
|
|
49
|
+
const embedded = () => `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
50
|
+
if (allZeroUpTo(5) && g[5] === 0xffff) return isPrivateV4(embedded()); // ::ffff:a.b.c.d
|
|
51
|
+
if (allZeroUpTo(6)) return true; // ::a.b.c.d (deprecated compatible)
|
|
52
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0)) return isPrivateV4(embedded()); // NAT64
|
|
53
|
+
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
|
|
54
|
+
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link local
|
|
55
|
+
if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site local
|
|
56
|
+
if ((g[0] & 0xff00) === 0xff00) return true; // multicast
|
|
57
|
+
if (g[0] === 0x2001 && g[1] === 0x0db8) return true; // documentation
|
|
58
|
+
if (g[0] === 0x2001 && g[1] === 0x0000) return true; // 2001::/32 Teredo (tunnels to arbitrary IPv4)
|
|
59
|
+
if (g[0] === 0x2002) return isPrivateV4(`${g[1] >> 8}.${g[1] & 255}.${g[2] >> 8}.${g[2] & 255}`); // 2002::/16 6to4
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True for loopback, private, link-local, CGNAT, multicast, reserved and documentation addresses. */
|
|
64
|
+
export function isPrivateAddress(ip) {
|
|
65
|
+
const family = net.isIP(ip);
|
|
66
|
+
if (family === 4) return isPrivateV4(ip);
|
|
67
|
+
if (family === 6) return isPrivateV6(ip);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const LOCAL_NAME_RE = /(^|\.)(localhost|local|internal|lan|home|intranet|localdomain)$/i;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check that a URL points at a public http(s) host. Resolves DNS names.
|
|
75
|
+
* Returns {ok: true, addresses} or {ok: false, reason}.
|
|
76
|
+
*/
|
|
77
|
+
export async function checkPublicUrl(raw, { allowLocal = false, lookup = dns.promises.lookup } = {}) {
|
|
78
|
+
let url;
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
return { ok: false, reason: "not a valid URL" };
|
|
83
|
+
}
|
|
84
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return { ok: false, reason: "only http(s) URLs are allowed" };
|
|
85
|
+
if (url.username || url.password) return { ok: false, reason: "URLs with credentials are not allowed" };
|
|
86
|
+
if (allowLocal) return { ok: true, addresses: [] };
|
|
87
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
88
|
+
if (net.isIP(host)) {
|
|
89
|
+
return isPrivateAddress(host) ? { ok: false, reason: `${host} is a private or local address` } : { ok: true, addresses: [host] };
|
|
90
|
+
}
|
|
91
|
+
if (LOCAL_NAME_RE.test(host) || !host.includes(".")) return { ok: false, reason: `${host} is a local host name` };
|
|
92
|
+
let addresses;
|
|
93
|
+
try {
|
|
94
|
+
addresses = (await lookup(host, { all: true, verbatim: true })).map((entry) => entry.address);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return { ok: false, reason: `${host} does not resolve (${error.code || error.message})` };
|
|
97
|
+
}
|
|
98
|
+
const blocked = addresses.find(isPrivateAddress);
|
|
99
|
+
if (blocked) return { ok: false, reason: `${host} resolves to a private address` };
|
|
100
|
+
return { ok: true, addresses };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every string in `value` that its schema marks format: uri, walking properties, additionalProperties,
|
|
105
|
+
* items and oneOf/anyOf/allOf branches. Returns [{path, value}].
|
|
106
|
+
*/
|
|
107
|
+
export function uriParams(schema, value, at = "") {
|
|
108
|
+
const out = [];
|
|
109
|
+
if (!schema || typeof schema !== "object") return out;
|
|
110
|
+
const branches = [...(schema.oneOf || []), ...(schema.anyOf || []), ...(schema.allOf || [])];
|
|
111
|
+
if (typeof value === "string" && (schema.format === "uri" || branches.some((b) => b?.format === "uri"))) {
|
|
112
|
+
out.push({ path: at || "$", value });
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
const items = schema.items && typeof schema.items === "object" ? schema.items : null;
|
|
117
|
+
if (items) value.forEach((entry, i) => out.push(...uriParams(items, entry, `${at}[${i}]`)));
|
|
118
|
+
} else if (value && typeof value === "object") {
|
|
119
|
+
const props = schema.properties || {};
|
|
120
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
121
|
+
const sub = Object.prototype.hasOwnProperty.call(props, key) ? props[key] : typeof schema.additionalProperties === "object" ? schema.additionalProperties : null;
|
|
122
|
+
if (sub) out.push(...uriParams(sub, entry, at ? `${at}.${key}` : key));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const branch of branches) if (typeof value !== "string") out.push(...uriParams(branch, value, at));
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check every param the schema marks format: uri (nested ones too): public http(s) only, and inside
|
|
131
|
+
* `allowedOrigins` when that list is given. Returns null or a reason.
|
|
132
|
+
*/
|
|
133
|
+
export async function checkUriParams(paramsSchema, params, { allowedOrigins = null, ...options } = {}) {
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
for (const { path: at, value } of uriParams(paramsSchema, params)) {
|
|
136
|
+
if (seen.has(at)) continue;
|
|
137
|
+
seen.add(at);
|
|
138
|
+
if (allowedOrigins && allowedOrigins.length && !originAllowed(value, allowedOrigins)) {
|
|
139
|
+
let origin = value;
|
|
140
|
+
try {
|
|
141
|
+
origin = new URL(value).origin;
|
|
142
|
+
} catch {
|
|
143
|
+
// reported as is
|
|
144
|
+
}
|
|
145
|
+
return `${at}: ${String(origin).slice(0, 80)} is not in allowed_origins`;
|
|
146
|
+
}
|
|
147
|
+
const result = await checkPublicUrl(value, options);
|
|
148
|
+
if (!result.ok) return `${at}: ${result.reason}`;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The origins of an intent's URL params, as allowed_origins patterns (used when an agent intent declares none). */
|
|
154
|
+
export function uriParamOrigins(paramsSchema, params) {
|
|
155
|
+
const origins = new Set();
|
|
156
|
+
for (const { value } of uriParams(paramsSchema, params)) {
|
|
157
|
+
try {
|
|
158
|
+
const url = new URL(value);
|
|
159
|
+
if (url.protocol === "https:" || url.protocol === "http:") origins.add(url.origin);
|
|
160
|
+
} catch {
|
|
161
|
+
// not a URL
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return [...origins];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A dns.lookup wrapper that refuses private addresses at connect time (covers DNS rebinding). */
|
|
168
|
+
function guardedLookup(allowLocal) {
|
|
169
|
+
return (hostname, options, callback) => {
|
|
170
|
+
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
171
|
+
if (error) return callback(error);
|
|
172
|
+
const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: options.family || 4 }];
|
|
173
|
+
if (!allowLocal) {
|
|
174
|
+
const blocked = list.find((entry) => isPrivateAddress(entry.address));
|
|
175
|
+
if (blocked) {
|
|
176
|
+
const refusal = new Error(`refusing to connect to private address for ${hostname}`);
|
|
177
|
+
refusal.code = "EPRIVATE";
|
|
178
|
+
return callback(refusal);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (options.all) return callback(null, list);
|
|
182
|
+
return callback(null, list[0].address, list[0].family);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
|
|
188
|
+
/** Response headers up to this size are read (Node's default, 16 KB, is less than some shops send). */
|
|
189
|
+
export const MAX_HEADER_BYTES = 128 * 1024;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fetch a public URL for script handlers. Redirects are followed manually and re-checked (and passed to `checkHop`);
|
|
193
|
+
* credential headers are dropped when a redirect changes origin.
|
|
194
|
+
* Resolves {ok, status, url, headers, body: Buffer, text(): Promise<string>, json(): Promise<any>}.
|
|
195
|
+
*/
|
|
196
|
+
export async function safeFetch(raw, { allowLocal = false, method = "GET", headers: initialHeaders = {}, maxBytes = 5 * 1024 * 1024, timeoutMs = 20_000, maxRedirects = 5, followRedirects = true, checkHop = null } = {}) {
|
|
197
|
+
let current = raw;
|
|
198
|
+
let headers = { ...initialHeaders };
|
|
199
|
+
let firstOrigin = null;
|
|
200
|
+
for (let hop = 0; hop <= maxRedirects; hop += 1) {
|
|
201
|
+
const check = await checkPublicUrl(current, { allowLocal });
|
|
202
|
+
if (!check.ok) throw Object.assign(new Error(`blocked: ${check.reason}`), { code: "policy" });
|
|
203
|
+
// A caller's own rule for every URL it would read, the first one and each redirect target (kit.fetch: robots.txt).
|
|
204
|
+
// It throws to refuse.
|
|
205
|
+
if (checkHop) await checkHop(current, hop);
|
|
206
|
+
const url = new URL(current);
|
|
207
|
+
firstOrigin ??= url.origin;
|
|
208
|
+
if (url.origin !== firstOrigin) {
|
|
209
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !CREDENTIAL_HEADERS.includes(name.toLowerCase())));
|
|
210
|
+
}
|
|
211
|
+
const response = await new Promise((resolve, reject) => {
|
|
212
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
213
|
+
const request = lib.request(url, {
|
|
214
|
+
method,
|
|
215
|
+
headers: { "user-agent": "instafy-bind/0.1", ...headers },
|
|
216
|
+
lookup: net.isIP(url.hostname.replace(/^\[|\]$/g, "")) ? undefined : guardedLookup(allowLocal),
|
|
217
|
+
timeout: timeoutMs,
|
|
218
|
+
maxHeaderSize: MAX_HEADER_BYTES,
|
|
219
|
+
}, (res) => {
|
|
220
|
+
const chunks = [];
|
|
221
|
+
let size = 0;
|
|
222
|
+
res.on("data", (chunk) => {
|
|
223
|
+
size += chunk.length;
|
|
224
|
+
if (size > maxBytes) {
|
|
225
|
+
request.destroy(Object.assign(new Error(`response larger than ${maxBytes} bytes`), { code: "upstream" }));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
chunks.push(chunk);
|
|
229
|
+
});
|
|
230
|
+
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
|
231
|
+
res.on("error", reject);
|
|
232
|
+
});
|
|
233
|
+
request.on("timeout", () => request.destroy(Object.assign(new Error("request timed out"), { code: "timeout" })));
|
|
234
|
+
request.on("error", (error) => {
|
|
235
|
+
// Say which site failed; a handler that does not catch it still reports something readable.
|
|
236
|
+
if (error.code === "upstream" || error.code === "timeout" || error.code === "EPRIVATE") return reject(error);
|
|
237
|
+
reject(Object.assign(new Error(`could not read ${url.hostname}: ${error.reason || error.message}`), { code: "upstream", cause: error }));
|
|
238
|
+
});
|
|
239
|
+
request.end();
|
|
240
|
+
});
|
|
241
|
+
if (followRedirects && [301, 302, 303, 307, 308].includes(response.status) && response.headers.location) {
|
|
242
|
+
current = new URL(response.headers.location, url).toString();
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
ok: response.status >= 200 && response.status < 300,
|
|
247
|
+
status: response.status,
|
|
248
|
+
url: url.toString(),
|
|
249
|
+
headers: response.headers,
|
|
250
|
+
body: response.body,
|
|
251
|
+
text: async () => response.body.toString("utf8"),
|
|
252
|
+
json: async () => JSON.parse(response.body.toString("utf8")),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
throw Object.assign(new Error("too many redirects"), { code: "upstream" });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Match an origin against bound.json allowed_origins patterns like "https://*" or "https://*.example.com". */
|
|
259
|
+
export function originAllowed(raw, patterns) {
|
|
260
|
+
if (!patterns || patterns.length === 0) return true;
|
|
261
|
+
let url;
|
|
262
|
+
try {
|
|
263
|
+
url = new URL(raw);
|
|
264
|
+
} catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return patterns.some((pattern) => {
|
|
268
|
+
const match = pattern.match(/^(https?):\/\/([^:/]+)(?::(\d+))?$/);
|
|
269
|
+
if (!match) return false;
|
|
270
|
+
const [, scheme, hostPattern, port] = match;
|
|
271
|
+
if (`${scheme}:` !== url.protocol) return false;
|
|
272
|
+
const actualPort = url.port || (scheme === "https" ? "443" : "80");
|
|
273
|
+
if (actualPort !== (port || (scheme === "https" ? "443" : "80"))) return false;
|
|
274
|
+
if (hostPattern === "*") return true;
|
|
275
|
+
if (hostPattern.startsWith("*.")) {
|
|
276
|
+
const suffix = hostPattern.slice(1).toLowerCase();
|
|
277
|
+
return url.hostname.toLowerCase().endsWith(suffix);
|
|
278
|
+
}
|
|
279
|
+
return url.hostname.toLowerCase() === hostPattern.toLowerCase();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Page reads for script handlers (kit.fetch) and for the robots.txt check: one SSRF-guarded GET (net.mjs). A site
|
|
2
|
+
// that does not answer (refused, reset, timed out, not resolvable) or answers 5xx is "unavailable" (error code
|
|
3
|
+
// "unavailable"): not a missing page, so a handler can say it could not be checked right now. 404/410 and every other
|
|
4
|
+
// answer are returned to the caller.
|
|
5
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
6
|
+
import { safeFetch } from "./net.mjs";
|
|
7
|
+
|
|
8
|
+
const UNAVAILABLE_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "EAI_AGAIN", "ENOTFOUND", "EPIPE", "timeout", "unavailable"]);
|
|
9
|
+
|
|
10
|
+
/** Did this error mean the site did not answer (as opposed to a refusal, a bad URL or a bug)? */
|
|
11
|
+
export function isUnavailable(error) {
|
|
12
|
+
if (!error || error.code === "policy" || error.code === "invalid") return false;
|
|
13
|
+
if (error.code === "unavailable") return true;
|
|
14
|
+
const code = error.code === "upstream" ? error.cause?.code : error.code;
|
|
15
|
+
return Boolean(code && UNAVAILABLE_CODES.has(code));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const unavailable = (message, extra = {}) => Object.assign(new Error(message), { code: "unavailable", ...extra });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* safeFetch's signature and answer. Throws code "unavailable" when the site did not answer or answered 5xx
|
|
22
|
+
* (`answerAnyStatus` keeps a 5xx as an answer: robots.txt reads 5xx as "do not crawl"); "policy" refusals as before.
|
|
23
|
+
*/
|
|
24
|
+
export async function pageFetch(url, options = {}) {
|
|
25
|
+
const { answerAnyStatus = false, ...rest } = options;
|
|
26
|
+
let host = "the page";
|
|
27
|
+
try {
|
|
28
|
+
host = new URL(url).host.toLowerCase();
|
|
29
|
+
} catch {
|
|
30
|
+
// safeFetch reports the bad URL
|
|
31
|
+
}
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await safeFetch(url, rest);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (!isUnavailable(error)) throw error;
|
|
37
|
+
throw unavailable(`${host} did not answer (${String(error.message).slice(0, 160)}): unavailable right now, not a missing page`, { cause: error });
|
|
38
|
+
}
|
|
39
|
+
if (!answerAnyStatus && response.status >= 500) {
|
|
40
|
+
throw unavailable(`${host} answered HTTP ${response.status}: unavailable right now, not a missing page`, { httpStatus: response.status });
|
|
41
|
+
}
|
|
42
|
+
return response;
|
|
43
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Preview mode (`serve --preview`): serve a bound site inside Studio's preview card without a viewer token.
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
//
|
|
4
|
+
// Why this is safe. `instafy preview open --port N` connects a private tunnel to 127.0.0.1:N. The tunnel broker's
|
|
5
|
+
// Traefik only routes that tunnel through a capability host `p-<26 base32 label>--<32 hex tunnel id>.<preview
|
|
6
|
+
// domain>`, behind the broker's forwardAuth, which lets through only space members holding a live per-Open session
|
|
7
|
+
// (tunnel-broker ingress.rs; runtime-controller previews/sessions.rs). Traefik keeps the Host header and the rathole
|
|
8
|
+
// hop is plain TCP, so the binder sees that capability host. The site backend holds no credential (see
|
|
9
|
+
// SITE-BACKEND-CONTRACT.md): forwardAuth on the capability host is the auth, so the binder needs no token of its own.
|
|
10
|
+
//
|
|
11
|
+
// What the binder still checks, because without a token anything that reaches the port could act:
|
|
12
|
+
// - it listens on loopback only (enforced by createBinder), so only this runtime's own processes and its tunnels
|
|
13
|
+
// reach it;
|
|
14
|
+
// - the Host header is a loopback name at the binder's port, or a capability host `p-<label>--<id>.<suffix>` where
|
|
15
|
+
// <id> is a preview `instafy preview open` recorded for this port (~/.instafy/cli-preview-state.json) AND <suffix>
|
|
16
|
+
// is exactly the preview domain (and port) the controller told the CLI that preview is viewed under (`hostSuffix`).
|
|
17
|
+
// The tunnel id alone is not a secret: every member's frame address carries it and it leaks as a Referer, so a
|
|
18
|
+
// DNS-rebinding page named `p-<any label>--<id>.attacker.example` could otherwise pose as the preview host. The
|
|
19
|
+
// attacker cannot serve a page from the real preview domain, so pinning the domain is what stops rebinding. A state
|
|
20
|
+
// entry without a hostSuffix (an older CLI) is never accepted. A public tunnel (`instafy tunnel`, Host
|
|
21
|
+
// `<uuid>.<tunnel domain>`) or any other name gets 421;
|
|
22
|
+
// - intents still need Origin == Host (same origin) and a JSON body (CORS preflight), as with a token;
|
|
23
|
+
// - frame messages (Ask Octo, location, follow mode) are taken only from the Studio origin `instafy preview open`
|
|
24
|
+
// recorded for that preview (`studioOrigin`, from the controller): the capability host sends
|
|
25
|
+
// `frame-ancestors 'self' <studio origin>` and the bind client trusts no other parent. The label is a bearer that
|
|
26
|
+
// also leaks in Origin headers, so without this any page that framed the preview URL could act as its host. A
|
|
27
|
+
// preview without a recorded Studio origin is still shown, but its page takes no host messages at all.
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
/** The capability host the broker routes private previews by (Traefik HostRegexp), plus an optional port. */
|
|
33
|
+
export const PREVIEW_HOST_RE = /^p-[a-z2-7]{26}--([0-9a-f]{32})\.([a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?)$/;
|
|
34
|
+
|
|
35
|
+
/** Where the Instafy CLI records the previews it opened (instafy-cli src/preview.ts previewStateFile). */
|
|
36
|
+
export function defaultPreviewStateFile() {
|
|
37
|
+
return path.join(os.homedir(), ".instafy", "cli-preview-state.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The tunnel id (32 lowercase hex) of a capability Host header, or null. */
|
|
41
|
+
export function previewTunnelOf(hostHeader) {
|
|
42
|
+
return previewHostParts(hostHeader)?.tunnel ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `{ tunnel, suffix }` of a capability Host header (suffix = the domain and any port after the first label), or null. */
|
|
46
|
+
export function previewHostParts(hostHeader) {
|
|
47
|
+
const match = PREVIEW_HOST_RE.exec(String(hostHeader || "").trim().toLowerCase());
|
|
48
|
+
return match ? { tunnel: match[1], suffix: match[2] } : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const normalizeSuffix = (raw) => {
|
|
52
|
+
const suffix = String(raw || "").trim().toLowerCase().replace(/^\.+/, "");
|
|
53
|
+
return /^[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/.test(suffix) ? suffix : null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const normalizeTunnelId = (raw) => {
|
|
57
|
+
const hex = String(raw || "").trim().toLowerCase().replace(/-/g, "");
|
|
58
|
+
return /^[0-9a-f]{32}$/.test(hex) ? hex : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const normalizeOrigin = (raw) => {
|
|
62
|
+
if (typeof raw !== "string") return null;
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(raw.trim());
|
|
65
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
66
|
+
if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) return null;
|
|
67
|
+
return url.origin;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The previews opened for a local port, read from the CLI's state file: a Map of tunnel id → Map of the host suffixes
|
|
75
|
+
* it is viewed under → the Studio origins that frame it (empty when unknown). Entries without a hostSuffix are left out. The file is small and is written by `instafy preview open`
|
|
76
|
+
* after the binder starts, so it is read on use (cached until it changes).
|
|
77
|
+
*/
|
|
78
|
+
export function previewTunnelReader(file = defaultPreviewStateFile()) {
|
|
79
|
+
let cacheKey = null;
|
|
80
|
+
let cached = [];
|
|
81
|
+
return function tunnelsForPort(port) {
|
|
82
|
+
let stat;
|
|
83
|
+
try {
|
|
84
|
+
stat = fs.statSync(file);
|
|
85
|
+
} catch {
|
|
86
|
+
cacheKey = null;
|
|
87
|
+
cached = [];
|
|
88
|
+
return new Map();
|
|
89
|
+
}
|
|
90
|
+
const key = `${stat.mtimeMs}:${stat.size}`;
|
|
91
|
+
if (key !== cacheKey) {
|
|
92
|
+
cacheKey = key;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
95
|
+
cached = Array.isArray(parsed?.previews) ? parsed.previews.filter((p) => p && typeof p === "object") : [];
|
|
96
|
+
} catch {
|
|
97
|
+
cached = [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const previews = new Map();
|
|
101
|
+
for (const entry of cached) {
|
|
102
|
+
if (Number(entry.localPort) !== Number(port)) continue;
|
|
103
|
+
const id = normalizeTunnelId(entry.previewId);
|
|
104
|
+
const suffix = normalizeSuffix(entry.hostSuffix);
|
|
105
|
+
if (!id || !suffix) continue;
|
|
106
|
+
if (!previews.has(id)) previews.set(id, new Map());
|
|
107
|
+
const suffixes = previews.get(id);
|
|
108
|
+
if (!suffixes.has(suffix)) suffixes.set(suffix, new Set());
|
|
109
|
+
const studio = normalizeOrigin(entry.studioOrigin);
|
|
110
|
+
if (studio) suffixes.get(suffix).add(studio);
|
|
111
|
+
}
|
|
112
|
+
return previews;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Message shapes shared by the binder and the bind client.
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export const PROTOCOL_VERSION = 2;
|
|
5
|
+
export const HOST_SOURCE = "instafy-host";
|
|
6
|
+
export const INTENT_SOURCES = ["instafy-site"];
|
|
7
|
+
export const HOST_TYPES = ["hello", "ack", "progress", "results", "done", "error", "command"];
|
|
8
|
+
export const ACK_STATUSES = ["queued", "needs_confirm", "rejected", "running"];
|
|
9
|
+
export const ERROR_CODES = ["not_found", "timeout", "policy", "upstream", "unavailable", "invalid", "cancelled", "dry_run", "internal"];
|
|
10
|
+
export const COMMAND_NAMES = ["select", "highlight", "toast", "reload"];
|
|
11
|
+
export const RESERVED_INTENTS = ["cancel"];
|
|
12
|
+
|
|
13
|
+
export const INTENT_ID_RE = /^int_[a-z0-9]{1,12}_[a-z0-9]{2,8}$/;
|
|
14
|
+
export const INTENT_NAME_RE = /^[a-z][a-z0-9_]{0,47}$/;
|
|
15
|
+
/** The records-schema ref shape without whitespace or other free text: "<collection>:<id>". */
|
|
16
|
+
export const ENVELOPE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}:[A-Za-z0-9_.:-]{1,120}$/;
|
|
17
|
+
/** ui and attr hints from the page: short tokens only (they reach logs, prompts and other viewers). */
|
|
18
|
+
export const ENVELOPE_TOKEN_RE = /^[A-Za-z0-9_.:-]{1,60}$/;
|
|
19
|
+
/** Cancel keys returned to the submitting viewer only. */
|
|
20
|
+
export const CANCEL_KEY_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
21
|
+
/** The whole POST body. */
|
|
22
|
+
export const MAX_INTENT_BYTES = 8 * 1024;
|
|
23
|
+
/** Serialised params. */
|
|
24
|
+
export const MAX_PARAMS_BYTES = 4 * 1024;
|
|
25
|
+
export const MAX_COMMAND_BYTES = 4 * 1024;
|
|
26
|
+
|
|
27
|
+
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
28
|
+
/** n uniform base36 characters (rejection sampling: bytes 252-255 are dropped, so no character is likelier). */
|
|
29
|
+
const base36 = (n) => {
|
|
30
|
+
let out = "";
|
|
31
|
+
while (out.length < n) {
|
|
32
|
+
for (const b of randomBytes(n * 2)) {
|
|
33
|
+
if (b < 252 && out.length < n) out += ALPHABET[b % 36];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** "int_" + base36(now) + "_" + 4 random base36 characters. */
|
|
40
|
+
export function newIntentId(now = Date.now()) {
|
|
41
|
+
return `int_${now.toString(36)}_${base36(4)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function newJobId(now = Date.now()) {
|
|
45
|
+
return `job_${now.toString(36)}_${base36(6)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Host -> site message with the protocol envelope. */
|
|
49
|
+
export function hostMessage(type, fields = {}) {
|
|
50
|
+
return { source: HOST_SOURCE, v: PROTOCOL_VERSION, type, ...fields, at: new Date().toISOString() };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Clip text for progress/error messages (140 characters). */
|
|
54
|
+
export function clip(text, max = 140) {
|
|
55
|
+
const value = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
56
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Structural check of an intent message before the per-intent params check.
|
|
61
|
+
* Returns null when the envelope is fine, else a short reason.
|
|
62
|
+
*/
|
|
63
|
+
export function checkIntentEnvelope(msg) {
|
|
64
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return "body must be a JSON object";
|
|
65
|
+
if (!INTENT_SOURCES.includes(msg.source)) return `source must be one of ${INTENT_SOURCES.join(", ")}`;
|
|
66
|
+
if (msg.v !== PROTOCOL_VERSION) return `v must be ${PROTOCOL_VERSION}`;
|
|
67
|
+
if (msg.type !== "intent") return 'type must be "intent"';
|
|
68
|
+
if (typeof msg.id !== "string" || !INTENT_ID_RE.test(msg.id)) return "id must look like int_<base36>_<rand>";
|
|
69
|
+
if (typeof msg.name !== "string" || !INTENT_NAME_RE.test(msg.name)) return "name must match ^[a-z][a-z0-9_]{0,47}$";
|
|
70
|
+
if (!msg.params || typeof msg.params !== "object" || Array.isArray(msg.params)) return "params must be an object";
|
|
71
|
+
if (msg.ref !== undefined && msg.ref !== null && (typeof msg.ref !== "string" || !ENVELOPE_REF_RE.test(msg.ref))) {
|
|
72
|
+
return "ref must look like <collection>:<id> (letters, digits, _ . : -)";
|
|
73
|
+
}
|
|
74
|
+
for (const key of ["ui", "attr"]) {
|
|
75
|
+
const value = msg[key];
|
|
76
|
+
if (value !== undefined && value !== null && (typeof value !== "string" || !ENVELOPE_TOKEN_RE.test(value))) {
|
|
77
|
+
return `${key} must be a short token (letters, digits, _ . : -)`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Make text safe for a terminal or a log line: C0/C1 control characters (escape sequences included) become "?",
|
|
85
|
+
* newlines stay only when `multiline` is set.
|
|
86
|
+
*/
|
|
87
|
+
export function safeText(text, { multiline = false } = {}) {
|
|
88
|
+
const value = String(text ?? "");
|
|
89
|
+
const cleaned = value.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "?");
|
|
90
|
+
return multiline ? cleaned : cleaned.replace(/\n/g, "\\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Deterministic JSON for coalescing keys (sorted object keys). */
|
|
94
|
+
export function stableStringify(value) {
|
|
95
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
96
|
+
if (value && typeof value === "object") {
|
|
97
|
+
return `{${Object.keys(value)
|
|
98
|
+
.sort()
|
|
99
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
|
100
|
+
.join(",")}}`;
|
|
101
|
+
}
|
|
102
|
+
return JSON.stringify(value);
|
|
103
|
+
}
|