planche 0.5.3 → 0.7.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 +47 -0
- package/dist/index.js +940 -658
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -6,421 +6,9 @@ import { createRequire } from "module";
|
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
7
|
import { parseArgs } from "util";
|
|
8
8
|
|
|
9
|
-
// src/
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
// ../shared/contract.ts
|
|
14
|
-
var PROTOCOL_VERSION = 2;
|
|
15
|
-
|
|
16
|
-
// src/ssh-tunnel.ts
|
|
17
|
-
import { createServer } from "net";
|
|
18
|
-
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
19
|
-
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
20
|
-
import { homedir as homedir2 } from "os";
|
|
21
|
-
import { basename as basename2, join as join2 } from "path";
|
|
22
|
-
import { Client } from "ssh2";
|
|
23
|
-
|
|
24
|
-
// src/ssh-config.ts
|
|
25
|
-
import { readFile, readdir } from "fs/promises";
|
|
26
|
-
import { homedir } from "os";
|
|
27
|
-
import { dirname, isAbsolute, join, basename } from "path";
|
|
28
|
-
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
29
|
-
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
|
|
30
|
-
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
31
|
-
(f) => join(homedir(), ".ssh", f)
|
|
32
|
-
);
|
|
33
|
-
function expandHome(path) {
|
|
34
|
-
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
35
|
-
}
|
|
36
|
-
function globToRegExp(pattern) {
|
|
37
|
-
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
38
|
-
return new RegExp(`^${escaped}$`, "i");
|
|
39
|
-
}
|
|
40
|
-
function hostPatternMatches(patterns, host) {
|
|
41
|
-
let matched = false;
|
|
42
|
-
for (const raw of patterns) {
|
|
43
|
-
const negated = raw.startsWith("!");
|
|
44
|
-
const pattern = negated ? raw.slice(1) : raw;
|
|
45
|
-
if (!globToRegExp(pattern).test(host)) continue;
|
|
46
|
-
if (negated) return false;
|
|
47
|
-
matched = true;
|
|
48
|
-
}
|
|
49
|
-
return matched;
|
|
50
|
-
}
|
|
51
|
-
function tokenize(line) {
|
|
52
|
-
const tokens = [];
|
|
53
|
-
const re = /"([^"]*)"|(\S+)/g;
|
|
54
|
-
let m;
|
|
55
|
-
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
56
|
-
return tokens;
|
|
57
|
-
}
|
|
58
|
-
function parseLine(line) {
|
|
59
|
-
const trimmed = line.trim();
|
|
60
|
-
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
61
|
-
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
62
|
-
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
63
|
-
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
64
|
-
}
|
|
65
|
-
async function expandGlob(path) {
|
|
66
|
-
const base = basename(path);
|
|
67
|
-
if (!/[*?]/.test(base)) return [path];
|
|
68
|
-
const dir = dirname(path);
|
|
69
|
-
try {
|
|
70
|
-
const re = globToRegExp(base);
|
|
71
|
-
return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join(dir, name));
|
|
72
|
-
} catch {
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
async function loadLines(file, seen) {
|
|
77
|
-
if (seen.has(file)) return [];
|
|
78
|
-
seen.add(file);
|
|
79
|
-
try {
|
|
80
|
-
return (await readFile(file, "utf8")).split(/\r?\n/);
|
|
81
|
-
} catch {
|
|
82
|
-
return [];
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
86
|
-
const result = { identityFiles: [] };
|
|
87
|
-
if (!file) return result;
|
|
88
|
-
const seen = /* @__PURE__ */ new Set();
|
|
89
|
-
async function walk(path, active) {
|
|
90
|
-
let blockActive = active;
|
|
91
|
-
for (const line of await loadLines(path, seen)) {
|
|
92
|
-
const d = parseLine(line);
|
|
93
|
-
if (!d) continue;
|
|
94
|
-
switch (d.keyword) {
|
|
95
|
-
case "host":
|
|
96
|
-
blockActive = hostPatternMatches(d.args, host);
|
|
97
|
-
continue;
|
|
98
|
-
case "match":
|
|
99
|
-
blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
|
|
100
|
-
continue;
|
|
101
|
-
case "include":
|
|
102
|
-
for (const inc of d.args) {
|
|
103
|
-
const target = expandHome(inc);
|
|
104
|
-
const abs = isAbsolute(target) ? target : join(homedir(), ".ssh", target);
|
|
105
|
-
for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
|
|
106
|
-
}
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (!blockActive) continue;
|
|
110
|
-
const value = d.args[0];
|
|
111
|
-
if (value === void 0) continue;
|
|
112
|
-
switch (d.keyword) {
|
|
113
|
-
case "hostname":
|
|
114
|
-
result.hostName ??= value.replace(/%h/g, host);
|
|
115
|
-
break;
|
|
116
|
-
case "port":
|
|
117
|
-
result.port ??= Number(value) || void 0;
|
|
118
|
-
break;
|
|
119
|
-
case "user":
|
|
120
|
-
result.user ??= value;
|
|
121
|
-
break;
|
|
122
|
-
case "identityfile":
|
|
123
|
-
for (const f of d.args) {
|
|
124
|
-
const expanded = expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host));
|
|
125
|
-
if (expanded.split(/[\\/]/).includes("..")) continue;
|
|
126
|
-
result.identityFiles.push(expanded);
|
|
127
|
-
}
|
|
128
|
-
break;
|
|
129
|
-
case "identityagent":
|
|
130
|
-
result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
|
|
131
|
-
break;
|
|
132
|
-
case "proxyjump":
|
|
133
|
-
result.proxyJump ??= value;
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
await walk(file, true);
|
|
139
|
-
return result;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// src/ssh-tunnel.ts
|
|
143
|
-
var TunnelError = class extends Error {
|
|
144
|
-
constructor(message, status) {
|
|
145
|
-
super(message);
|
|
146
|
-
this.status = status;
|
|
147
|
-
this.name = "TunnelError";
|
|
148
|
-
}
|
|
149
|
-
status;
|
|
150
|
-
};
|
|
151
|
-
var HostKeyError = class extends TunnelError {
|
|
152
|
-
hostKey;
|
|
153
|
-
constructor(host, port, type, fingerprint2) {
|
|
154
|
-
super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
|
|
155
|
-
this.name = "HostKeyError";
|
|
156
|
-
this.hostKey = { host, port, type, fingerprint: fingerprint2 };
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
var CONNECT_TIMEOUT_MS = 2e4;
|
|
160
|
-
var PLANCHE_DIR = join2(homedir2(), ".planche");
|
|
161
|
-
var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
|
|
162
|
-
var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
|
|
163
|
-
function parseSshConfig(value) {
|
|
164
|
-
if (value === void 0 || value === null) return void 0;
|
|
165
|
-
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
166
|
-
const body = value;
|
|
167
|
-
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
168
|
-
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
169
|
-
const auth = body.auth;
|
|
170
|
-
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
171
|
-
if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
|
|
172
|
-
if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
|
|
173
|
-
const port = Math.trunc(Number(body.port)) || 22;
|
|
174
|
-
if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
|
|
175
|
-
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
176
|
-
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
177
|
-
}
|
|
178
|
-
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
179
|
-
const config = {
|
|
180
|
-
host,
|
|
181
|
-
port,
|
|
182
|
-
user,
|
|
183
|
-
auth,
|
|
184
|
-
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
185
|
-
privateKey: auth === "key" ? str(body.privateKey) : void 0,
|
|
186
|
-
passphrase: auth === "key" ? str(body.passphrase) : void 0,
|
|
187
|
-
acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
|
|
188
|
-
};
|
|
189
|
-
return config;
|
|
190
|
-
}
|
|
191
|
-
function keyType(blob) {
|
|
192
|
-
if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
|
|
193
|
-
const len = blob.readUInt32BE(0);
|
|
194
|
-
if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
|
|
195
|
-
const type = blob.subarray(4, 4 + len).toString("ascii");
|
|
196
|
-
if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
|
|
197
|
-
return type;
|
|
198
|
-
}
|
|
199
|
-
function knownHostsEntryMatches(field, patterns) {
|
|
200
|
-
if (field.startsWith("|1|")) {
|
|
201
|
-
const [, , salt, hash] = field.split("|");
|
|
202
|
-
if (!salt || !hash) return false;
|
|
203
|
-
const key = Buffer.from(salt, "base64");
|
|
204
|
-
const want = Buffer.from(hash, "base64");
|
|
205
|
-
return patterns.some((p) => {
|
|
206
|
-
const got = createHmac("sha1", key).update(p).digest();
|
|
207
|
-
return got.length === want.length && timingSafeEqual(got, want);
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
const entries = field.toLowerCase().split(",");
|
|
211
|
-
return patterns.some((p) => hostPatternMatches(entries, p));
|
|
212
|
-
}
|
|
213
|
-
function hostPatterns(host, port) {
|
|
214
|
-
const h = host.toLowerCase();
|
|
215
|
-
return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
|
|
216
|
-
}
|
|
217
|
-
async function readKnownHosts(file) {
|
|
218
|
-
try {
|
|
219
|
-
return (await readFile2(file, "utf8")).split(/\r?\n/);
|
|
220
|
-
} catch {
|
|
221
|
-
return [];
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
async function lookupHostKey(host, port, blob) {
|
|
225
|
-
const patterns = hostPatterns(host, port);
|
|
226
|
-
const type = keyType(blob);
|
|
227
|
-
const encoded = blob.toString("base64");
|
|
228
|
-
let sawHost = false;
|
|
229
|
-
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
230
|
-
for (const raw of lines) {
|
|
231
|
-
const line = raw.trim();
|
|
232
|
-
if (!line || line.startsWith("#")) continue;
|
|
233
|
-
const fields = line.split(/\s+/);
|
|
234
|
-
const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
|
|
235
|
-
const [hosts, lineType, lineKey] = fields;
|
|
236
|
-
if (!hosts || !lineType || !lineKey) continue;
|
|
237
|
-
if (!knownHostsEntryMatches(hosts, patterns)) continue;
|
|
238
|
-
if (marker === "@revoked") {
|
|
239
|
-
if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
if (marker === "@cert-authority") continue;
|
|
243
|
-
sawHost = true;
|
|
244
|
-
if (lineType === type && lineKey === encoded) return "match";
|
|
245
|
-
}
|
|
246
|
-
return sawHost ? "mismatch" : "unknown";
|
|
247
|
-
}
|
|
248
|
-
async function rememberHostKey(host, port, blob) {
|
|
249
|
-
await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
|
|
250
|
-
const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
|
|
251
|
-
await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
|
|
252
|
-
`, { mode: 384 });
|
|
253
|
-
}
|
|
254
|
-
function fingerprint(blob) {
|
|
255
|
-
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
|
256
|
-
}
|
|
257
|
-
async function resolveConfig(ssh) {
|
|
258
|
-
const cfg = await resolveSshHost(ssh.host);
|
|
259
|
-
if (cfg.proxyJump) {
|
|
260
|
-
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
261
|
-
}
|
|
262
|
-
if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
|
|
263
|
-
throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
|
|
264
|
-
}
|
|
265
|
-
const resolved = {
|
|
266
|
-
...ssh,
|
|
267
|
-
host: cfg.hostName ?? ssh.host,
|
|
268
|
-
port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
|
|
269
|
-
user: ssh.user || cfg.user || ""
|
|
270
|
-
};
|
|
271
|
-
if (!resolved.user) {
|
|
272
|
-
throw new TunnelError(
|
|
273
|
-
SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
|
|
274
|
-
400
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
if (resolved.auth === "key" && !resolved.privateKey) {
|
|
278
|
-
if (SSH_CONFIG_DISABLED) {
|
|
279
|
-
throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
|
|
280
|
-
}
|
|
281
|
-
const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
|
|
282
|
-
for (const file of candidates) {
|
|
283
|
-
try {
|
|
284
|
-
resolved.privateKey = await readFile2(file, "utf8");
|
|
285
|
-
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
286
|
-
break;
|
|
287
|
-
} catch {
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (!resolved.privateKey) {
|
|
291
|
-
throw new TunnelError(
|
|
292
|
-
`No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename2(f)).join(", ")} in ~/.ssh)`,
|
|
293
|
-
400
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return { ...resolved, identityAgent: cfg.identityAgent };
|
|
298
|
-
}
|
|
299
|
-
function toConnectConfig(ssh) {
|
|
300
|
-
const base = {
|
|
301
|
-
host: ssh.host,
|
|
302
|
-
port: ssh.port,
|
|
303
|
-
username: ssh.user,
|
|
304
|
-
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
305
|
-
keepaliveInterval: 3e4
|
|
306
|
-
};
|
|
307
|
-
switch (ssh.auth) {
|
|
308
|
-
case "password":
|
|
309
|
-
return { ...base, password: ssh.password ?? "", tryKeyboard: true };
|
|
310
|
-
case "key":
|
|
311
|
-
return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
|
|
312
|
-
case "agent": {
|
|
313
|
-
const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
|
|
314
|
-
if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
|
|
315
|
-
return { ...base, agent };
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
function describeSshError(err, ssh) {
|
|
320
|
-
const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
|
|
321
|
-
if (err instanceof TunnelError) return err;
|
|
322
|
-
switch (err.level) {
|
|
323
|
-
case "client-authentication":
|
|
324
|
-
return new TunnelError(`SSH authentication failed for ${target}`, 401);
|
|
325
|
-
case "client-timeout":
|
|
326
|
-
return new TunnelError(`SSH connection to ${target} timed out`, 400);
|
|
327
|
-
default:
|
|
328
|
-
return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
async function openSshTunnel(requested, target) {
|
|
332
|
-
const ssh = await resolveConfig(requested);
|
|
333
|
-
const client = new Client();
|
|
334
|
-
const connectConfig = toConnectConfig(ssh);
|
|
335
|
-
let verifyError;
|
|
336
|
-
connectConfig.hostVerifier = (key, verified) => {
|
|
337
|
-
lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
|
|
338
|
-
const fp = fingerprint(key);
|
|
339
|
-
if (result === "mismatch") {
|
|
340
|
-
verifyError = new TunnelError(
|
|
341
|
-
`Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
|
|
342
|
-
400
|
|
343
|
-
);
|
|
344
|
-
verified(false);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (result === "unknown") {
|
|
348
|
-
if (ssh.acceptHostKey !== fp) {
|
|
349
|
-
verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
|
|
350
|
-
verified(false);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
|
|
354
|
-
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
355
|
-
}
|
|
356
|
-
verified(true);
|
|
357
|
-
}).catch((err) => {
|
|
358
|
-
verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
359
|
-
verified(false);
|
|
360
|
-
});
|
|
361
|
-
};
|
|
362
|
-
await new Promise((resolve2, reject) => {
|
|
363
|
-
client.once("ready", resolve2);
|
|
364
|
-
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
365
|
-
try {
|
|
366
|
-
client.connect(connectConfig);
|
|
367
|
-
} catch (err) {
|
|
368
|
-
reject(describeSshError(err, ssh));
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
|
|
372
|
-
const sockets = /* @__PURE__ */ new Set();
|
|
373
|
-
let forwardError;
|
|
374
|
-
const server = createServer((socket) => {
|
|
375
|
-
sockets.add(socket);
|
|
376
|
-
socket.once("close", () => sockets.delete(socket));
|
|
377
|
-
socket.on("error", () => socket.destroy());
|
|
378
|
-
client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
379
|
-
if (err) {
|
|
380
|
-
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
381
|
-
const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
|
|
382
|
-
forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
|
|
383
|
-
console.error(`[ssh] ${forwardError}`);
|
|
384
|
-
socket.destroy();
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
stream.on("error", () => socket.destroy());
|
|
388
|
-
socket.pipe(stream).pipe(socket);
|
|
389
|
-
stream.once("close", () => socket.destroy());
|
|
390
|
-
socket.once("close", () => stream.destroy());
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
await new Promise((resolve2, reject) => {
|
|
394
|
-
server.once("error", reject);
|
|
395
|
-
server.listen(0, "127.0.0.1", () => resolve2());
|
|
396
|
-
}).catch((err) => {
|
|
397
|
-
client.end();
|
|
398
|
-
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
399
|
-
});
|
|
400
|
-
const { port } = server.address();
|
|
401
|
-
let closed = false;
|
|
402
|
-
const close = () => new Promise((resolve2) => {
|
|
403
|
-
if (closed) return resolve2();
|
|
404
|
-
closed = true;
|
|
405
|
-
for (const s of sockets) s.destroy();
|
|
406
|
-
server.close(() => {
|
|
407
|
-
client.end();
|
|
408
|
-
resolve2();
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
client.once("close", () => void close());
|
|
412
|
-
return {
|
|
413
|
-
host: "127.0.0.1",
|
|
414
|
-
port,
|
|
415
|
-
get forwardError() {
|
|
416
|
-
return forwardError;
|
|
417
|
-
},
|
|
418
|
-
close
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// src/tunneling.ts
|
|
423
|
-
import { randomUUID } from "crypto";
|
|
9
|
+
// src/gateway.ts
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { resolve as resolve2 } from "path";
|
|
424
12
|
|
|
425
13
|
// src/drivers/mysql.ts
|
|
426
14
|
import mysql from "mysql2/promise";
|
|
@@ -519,6 +107,9 @@ var MySqlDriver = class {
|
|
|
519
107
|
isResultQuery: false
|
|
520
108
|
};
|
|
521
109
|
}
|
|
110
|
+
async setReadOnly() {
|
|
111
|
+
await this.connection.query("SET SESSION TRANSACTION READ ONLY");
|
|
112
|
+
}
|
|
522
113
|
async close() {
|
|
523
114
|
await this.connection.end();
|
|
524
115
|
}
|
|
@@ -712,6 +303,9 @@ var PostgresDriver = class {
|
|
|
712
303
|
isResultQuery: false
|
|
713
304
|
};
|
|
714
305
|
}
|
|
306
|
+
async setReadOnly() {
|
|
307
|
+
await this.client.query("SET default_transaction_read_only = on");
|
|
308
|
+
}
|
|
715
309
|
async close() {
|
|
716
310
|
await this.client.end();
|
|
717
311
|
}
|
|
@@ -752,8 +346,8 @@ var postgresDriverFactory = {
|
|
|
752
346
|
// src/drivers/sqlite.ts
|
|
753
347
|
import Database from "better-sqlite3";
|
|
754
348
|
import { existsSync, realpathSync } from "fs";
|
|
755
|
-
import { homedir
|
|
756
|
-
import { basename
|
|
349
|
+
import { homedir } from "os";
|
|
350
|
+
import { basename, dirname, join, relative, resolve, sep } from "path";
|
|
757
351
|
function normalizeDeclaredType(declared) {
|
|
758
352
|
if (!declared) return null;
|
|
759
353
|
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
@@ -766,24 +360,24 @@ function inferTypeFromValue(value) {
|
|
|
766
360
|
if (Buffer.isBuffer(value)) return "blob";
|
|
767
361
|
return "text";
|
|
768
362
|
}
|
|
769
|
-
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(
|
|
770
|
-
var DEFAULT_ROOT =
|
|
363
|
+
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome(process.env.PLANCHE_SQLITE_DIR)) : null;
|
|
364
|
+
var DEFAULT_ROOT = homedir();
|
|
771
365
|
var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
|
|
772
366
|
var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
|
|
773
367
|
var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
|
|
774
|
-
function
|
|
775
|
-
return path === "~" || path.startsWith("~/") ? resolve(
|
|
368
|
+
function expandHome(path) {
|
|
369
|
+
return path === "~" || path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path;
|
|
776
370
|
}
|
|
777
371
|
function realish(file) {
|
|
778
372
|
let probe = file;
|
|
779
373
|
const rest = [];
|
|
780
374
|
for (; ; ) {
|
|
781
375
|
try {
|
|
782
|
-
return rest.length ?
|
|
376
|
+
return rest.length ? join(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
|
|
783
377
|
} catch {
|
|
784
|
-
const parent =
|
|
378
|
+
const parent = dirname(probe);
|
|
785
379
|
if (parent === probe) return file;
|
|
786
|
-
rest.unshift(
|
|
380
|
+
rest.unshift(basename(probe));
|
|
787
381
|
probe = parent;
|
|
788
382
|
}
|
|
789
383
|
}
|
|
@@ -794,7 +388,7 @@ function isInside(path, root) {
|
|
|
794
388
|
function resolveFile(file, forCreate = false) {
|
|
795
389
|
if (file === ":memory:") return file;
|
|
796
390
|
if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
|
|
797
|
-
const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(),
|
|
391
|
+
const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome(file)));
|
|
798
392
|
if (SQLITE_DIR) {
|
|
799
393
|
if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
|
|
800
394
|
} else {
|
|
@@ -925,6 +519,9 @@ var SqliteDriver = class {
|
|
|
925
519
|
});
|
|
926
520
|
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
927
521
|
}
|
|
522
|
+
async setReadOnly() {
|
|
523
|
+
this.db.pragma("query_only = 1");
|
|
524
|
+
}
|
|
928
525
|
async close() {
|
|
929
526
|
if (this.db.inTransaction) {
|
|
930
527
|
try {
|
|
@@ -1151,280 +748,686 @@ var OracleDriver = class {
|
|
|
1151
748
|
this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
|
|
1152
749
|
this.currentSchema = config.user.toUpperCase();
|
|
1153
750
|
}
|
|
1154
|
-
conn;
|
|
1155
|
-
type = "oracle";
|
|
751
|
+
conn;
|
|
752
|
+
type = "oracle";
|
|
753
|
+
label;
|
|
754
|
+
currentSchema;
|
|
755
|
+
manualTx = false;
|
|
756
|
+
/** Column names per `OWNER.TABLE`, for result-source resolution. */
|
|
757
|
+
columns = /* @__PURE__ */ new Map();
|
|
758
|
+
async init() {
|
|
759
|
+
for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
|
|
760
|
+
}
|
|
761
|
+
async tableColumns(schema, table) {
|
|
762
|
+
const key = `${schema}.${table}`;
|
|
763
|
+
const cached = this.columns.get(key);
|
|
764
|
+
if (cached !== void 0) return cached;
|
|
765
|
+
let out = null;
|
|
766
|
+
try {
|
|
767
|
+
const res = await this.conn.execute(
|
|
768
|
+
"SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
|
|
769
|
+
{ o: schema, t: table },
|
|
770
|
+
{ outFormat: oracledb.OUT_FORMAT_ARRAY }
|
|
771
|
+
);
|
|
772
|
+
out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
|
|
773
|
+
} catch {
|
|
774
|
+
out = null;
|
|
775
|
+
}
|
|
776
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
777
|
+
this.columns.set(key, out);
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
async query(sql2, database) {
|
|
781
|
+
if (database && database !== this.currentSchema) {
|
|
782
|
+
await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
|
|
783
|
+
this.currentSchema = database;
|
|
784
|
+
}
|
|
785
|
+
const text = normalizeStatement(sql2);
|
|
786
|
+
if (START_TX.test(text)) this.manualTx = true;
|
|
787
|
+
const res = await this.conn.execute(text, [], {
|
|
788
|
+
outFormat: oracledb.OUT_FORMAT_ARRAY,
|
|
789
|
+
fetchTypeHandler,
|
|
790
|
+
autoCommit: !this.manualTx
|
|
791
|
+
});
|
|
792
|
+
if (SET_SCHEMA.test(text)) this.currentSchema = null;
|
|
793
|
+
if (END_TX.test(text)) this.manualTx = false;
|
|
794
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
795
|
+
const source = singleSourceTable(text);
|
|
796
|
+
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
797
|
+
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
798
|
+
const fields = res.metaData.map((col) => {
|
|
799
|
+
const fromTable = known?.has(col.name) ?? false;
|
|
800
|
+
return {
|
|
801
|
+
name: col.name,
|
|
802
|
+
orgName: col.name,
|
|
803
|
+
type: (col.dbTypeName ?? "").toLowerCase(),
|
|
804
|
+
table: fromTable ? source.table : "",
|
|
805
|
+
orgTable: fromTable ? source.table : "",
|
|
806
|
+
db: fromTable ? schema : "",
|
|
807
|
+
maxLength: col.byteSize ?? 0
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
|
|
811
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
812
|
+
}
|
|
813
|
+
return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
|
|
814
|
+
}
|
|
815
|
+
async close() {
|
|
816
|
+
await this.conn.close();
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
function connectionAttributes(config) {
|
|
820
|
+
return {
|
|
821
|
+
user: config.user,
|
|
822
|
+
password: config.password,
|
|
823
|
+
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
var oracleDriverFactory = {
|
|
827
|
+
async open(config) {
|
|
828
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
829
|
+
const driver = new OracleDriver(conn, config);
|
|
830
|
+
await driver.init();
|
|
831
|
+
return driver;
|
|
832
|
+
},
|
|
833
|
+
async test(config) {
|
|
834
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
835
|
+
await conn.close();
|
|
836
|
+
},
|
|
837
|
+
parseConfig(body) {
|
|
838
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
839
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
840
|
+
if (!host || !user) {
|
|
841
|
+
throw new Error("host and user are required");
|
|
842
|
+
}
|
|
843
|
+
if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
|
|
844
|
+
const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
|
|
845
|
+
if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
|
|
846
|
+
return {
|
|
847
|
+
driver: "oracle",
|
|
848
|
+
host,
|
|
849
|
+
port: Number(body.port) || 1521,
|
|
850
|
+
user,
|
|
851
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
852
|
+
serviceName
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
// src/drivers/d1.ts
|
|
858
|
+
var API = "https://api.cloudflare.com/client/v4";
|
|
859
|
+
var D1Error = class extends Error {
|
|
860
|
+
constructor(message, status) {
|
|
861
|
+
super(message);
|
|
862
|
+
this.status = status;
|
|
863
|
+
}
|
|
864
|
+
status;
|
|
865
|
+
};
|
|
866
|
+
var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
|
|
867
|
+
var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
868
|
+
var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
869
|
+
function singleSourceTable2(sql2) {
|
|
870
|
+
const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
871
|
+
if (!m) return null;
|
|
872
|
+
const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
|
|
873
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
874
|
+
const raw = m[1];
|
|
875
|
+
const first = raw[0];
|
|
876
|
+
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
877
|
+
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
878
|
+
if (first === "[") return raw.slice(1, -1);
|
|
879
|
+
return raw;
|
|
880
|
+
}
|
|
881
|
+
function normalizeDeclaredType2(declared) {
|
|
882
|
+
if (typeof declared !== "string") return null;
|
|
883
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
884
|
+
return base || null;
|
|
885
|
+
}
|
|
886
|
+
function inferTypeFromValue2(value) {
|
|
887
|
+
if (value === null || value === void 0) return "null";
|
|
888
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
889
|
+
if (Array.isArray(value)) return "blob";
|
|
890
|
+
return "text";
|
|
891
|
+
}
|
|
892
|
+
function firstNonNull2(records, index) {
|
|
893
|
+
for (const row of records) {
|
|
894
|
+
const value = row[index];
|
|
895
|
+
if (value !== null && value !== void 0) return value;
|
|
896
|
+
}
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
var D1Driver = class {
|
|
900
|
+
type = "d1";
|
|
1156
901
|
label;
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
/**
|
|
902
|
+
url;
|
|
903
|
+
headers;
|
|
904
|
+
/** Declared column types per table, for result-source resolution. */
|
|
1160
905
|
columns = /* @__PURE__ */ new Map();
|
|
1161
|
-
|
|
1162
|
-
|
|
906
|
+
constructor(config) {
|
|
907
|
+
this.label = `d1:${config.databaseId}`;
|
|
908
|
+
this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
|
|
909
|
+
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
1163
910
|
}
|
|
1164
|
-
async
|
|
1165
|
-
|
|
1166
|
-
|
|
911
|
+
async raw(sql2) {
|
|
912
|
+
let res;
|
|
913
|
+
try {
|
|
914
|
+
res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
|
|
915
|
+
} catch (err) {
|
|
916
|
+
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
917
|
+
}
|
|
918
|
+
let body = null;
|
|
919
|
+
try {
|
|
920
|
+
body = await res.json();
|
|
921
|
+
} catch {
|
|
922
|
+
}
|
|
923
|
+
if (!res.ok || !body?.success) {
|
|
924
|
+
const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
|
|
925
|
+
throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
|
|
926
|
+
}
|
|
927
|
+
const result = body.result?.[0];
|
|
928
|
+
if (!result) throw new D1Error("Cloudflare API returned no result");
|
|
929
|
+
return result;
|
|
930
|
+
}
|
|
931
|
+
async tableColumns(table) {
|
|
932
|
+
const cached = this.columns.get(table);
|
|
1167
933
|
if (cached !== void 0) return cached;
|
|
1168
934
|
let out = null;
|
|
1169
935
|
try {
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
936
|
+
const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
937
|
+
const cols = info.results?.columns ?? [];
|
|
938
|
+
const name = cols.indexOf("name");
|
|
939
|
+
const type = cols.indexOf("type");
|
|
940
|
+
const rows = info.results?.rows ?? [];
|
|
941
|
+
if (rows.length > 0 && name >= 0) {
|
|
942
|
+
out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
|
|
943
|
+
}
|
|
1176
944
|
} catch {
|
|
1177
945
|
out = null;
|
|
1178
946
|
}
|
|
1179
947
|
if (this.columns.size > 200) this.columns.clear();
|
|
1180
|
-
this.columns.set(
|
|
948
|
+
this.columns.set(table, out);
|
|
1181
949
|
return out;
|
|
1182
950
|
}
|
|
1183
|
-
async query(sql2
|
|
1184
|
-
if (
|
|
1185
|
-
|
|
1186
|
-
|
|
951
|
+
async query(sql2) {
|
|
952
|
+
if (DATABASE_LIST.test(sql2)) {
|
|
953
|
+
return {
|
|
954
|
+
fields: [
|
|
955
|
+
{ name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
956
|
+
{ name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
957
|
+
{ name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
|
|
958
|
+
],
|
|
959
|
+
records: [[0, "main", ""]],
|
|
960
|
+
affectedRows: 0,
|
|
961
|
+
insertId: 0,
|
|
962
|
+
isResultQuery: true
|
|
963
|
+
};
|
|
1187
964
|
}
|
|
1188
|
-
const text =
|
|
1189
|
-
if (
|
|
1190
|
-
|
|
1191
|
-
outFormat: oracledb.OUT_FORMAT_ARRAY,
|
|
1192
|
-
fetchTypeHandler,
|
|
1193
|
-
autoCommit: !this.manualTx
|
|
1194
|
-
});
|
|
1195
|
-
if (SET_SCHEMA.test(text)) this.currentSchema = null;
|
|
1196
|
-
if (END_TX.test(text)) this.manualTx = false;
|
|
1197
|
-
if (res.metaData && res.metaData.length > 0) {
|
|
1198
|
-
const source = singleSourceTable(text);
|
|
1199
|
-
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
1200
|
-
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
1201
|
-
const fields = res.metaData.map((col) => {
|
|
1202
|
-
const fromTable = known?.has(col.name) ?? false;
|
|
1203
|
-
return {
|
|
1204
|
-
name: col.name,
|
|
1205
|
-
orgName: col.name,
|
|
1206
|
-
type: (col.dbTypeName ?? "").toLowerCase(),
|
|
1207
|
-
table: fromTable ? source.table : "",
|
|
1208
|
-
orgTable: fromTable ? source.table : "",
|
|
1209
|
-
db: fromTable ? schema : "",
|
|
1210
|
-
maxLength: col.byteSize ?? 0
|
|
1211
|
-
};
|
|
1212
|
-
});
|
|
1213
|
-
const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
|
|
1214
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
965
|
+
const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
|
|
966
|
+
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
|
|
967
|
+
throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
|
|
1215
968
|
}
|
|
1216
|
-
|
|
969
|
+
if (this.isDdl(text)) this.columns.clear();
|
|
970
|
+
const result = await this.raw(text);
|
|
971
|
+
const columns = result.results?.columns ?? [];
|
|
972
|
+
const records = result.results?.rows ?? [];
|
|
973
|
+
if (columns.length === 0 && !RESULT_KIND.test(text)) {
|
|
974
|
+
return {
|
|
975
|
+
fields: [],
|
|
976
|
+
records: [],
|
|
977
|
+
affectedRows: result.meta?.changes ?? 0,
|
|
978
|
+
insertId: result.meta?.last_row_id ?? 0,
|
|
979
|
+
isResultQuery: false
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
983
|
+
const known = source ? await this.tableColumns(source) : null;
|
|
984
|
+
const fields = columns.map((name, index) => {
|
|
985
|
+
const fromTable = known?.has(name) ?? false;
|
|
986
|
+
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
987
|
+
return {
|
|
988
|
+
name,
|
|
989
|
+
orgName: name,
|
|
990
|
+
type,
|
|
991
|
+
table: fromTable && source ? source : "",
|
|
992
|
+
orgTable: fromTable && source ? source : "",
|
|
993
|
+
db: fromTable ? "main" : "",
|
|
994
|
+
maxLength: 0
|
|
995
|
+
};
|
|
996
|
+
});
|
|
997
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
998
|
+
}
|
|
999
|
+
isDdl(sql2) {
|
|
1000
|
+
return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
|
|
1217
1001
|
}
|
|
1218
1002
|
async close() {
|
|
1219
|
-
|
|
1003
|
+
this.columns.clear();
|
|
1220
1004
|
}
|
|
1221
1005
|
};
|
|
1222
|
-
|
|
1223
|
-
return {
|
|
1224
|
-
user: config.user,
|
|
1225
|
-
password: config.password,
|
|
1226
|
-
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
1227
|
-
};
|
|
1228
|
-
}
|
|
1229
|
-
var oracleDriverFactory = {
|
|
1006
|
+
var d1DriverFactory = {
|
|
1230
1007
|
async open(config) {
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
await driver.init();
|
|
1008
|
+
const driver = new D1Driver(config);
|
|
1009
|
+
await driver.raw("SELECT 1");
|
|
1234
1010
|
return driver;
|
|
1235
1011
|
},
|
|
1236
1012
|
async test(config) {
|
|
1237
|
-
|
|
1238
|
-
await conn.close();
|
|
1013
|
+
await new D1Driver(config).raw("SELECT 1");
|
|
1239
1014
|
},
|
|
1240
1015
|
parseConfig(body) {
|
|
1241
|
-
const
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1244
|
-
|
|
1016
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
1017
|
+
const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
|
|
1018
|
+
const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
|
|
1019
|
+
if (!accountId || !databaseId || !apiToken) {
|
|
1020
|
+
throw new Error("accountId, databaseId and apiToken are required");
|
|
1021
|
+
}
|
|
1022
|
+
return { driver: "d1", accountId, databaseId, apiToken };
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
// src/ssh-tunnel.ts
|
|
1027
|
+
import { createServer } from "net";
|
|
1028
|
+
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
1029
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
1030
|
+
import { homedir as homedir3 } from "os";
|
|
1031
|
+
import { basename as basename3, join as join3 } from "path";
|
|
1032
|
+
import { Client } from "ssh2";
|
|
1033
|
+
|
|
1034
|
+
// src/ssh-config.ts
|
|
1035
|
+
import { readFile, readdir } from "fs/promises";
|
|
1036
|
+
import { homedir as homedir2 } from "os";
|
|
1037
|
+
import { dirname as dirname2, isAbsolute, join as join2, basename as basename2 } from "path";
|
|
1038
|
+
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
1039
|
+
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join2(homedir2(), ".ssh", "config");
|
|
1040
|
+
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
1041
|
+
(f) => join2(homedir2(), ".ssh", f)
|
|
1042
|
+
);
|
|
1043
|
+
function expandHome2(path) {
|
|
1044
|
+
return path === "~" || path.startsWith("~/") ? join2(homedir2(), path.slice(1)) : path;
|
|
1045
|
+
}
|
|
1046
|
+
function globToRegExp(pattern) {
|
|
1047
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
1048
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
1049
|
+
}
|
|
1050
|
+
function hostPatternMatches(patterns, host) {
|
|
1051
|
+
let matched = false;
|
|
1052
|
+
for (const raw of patterns) {
|
|
1053
|
+
const negated = raw.startsWith("!");
|
|
1054
|
+
const pattern = negated ? raw.slice(1) : raw;
|
|
1055
|
+
if (!globToRegExp(pattern).test(host)) continue;
|
|
1056
|
+
if (negated) return false;
|
|
1057
|
+
matched = true;
|
|
1058
|
+
}
|
|
1059
|
+
return matched;
|
|
1060
|
+
}
|
|
1061
|
+
function tokenize(line) {
|
|
1062
|
+
const tokens = [];
|
|
1063
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
1064
|
+
let m;
|
|
1065
|
+
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
1066
|
+
return tokens;
|
|
1067
|
+
}
|
|
1068
|
+
function parseLine(line) {
|
|
1069
|
+
const trimmed = line.trim();
|
|
1070
|
+
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
1071
|
+
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
1072
|
+
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
1073
|
+
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
1074
|
+
}
|
|
1075
|
+
async function expandGlob(path) {
|
|
1076
|
+
const base = basename2(path);
|
|
1077
|
+
if (!/[*?]/.test(base)) return [path];
|
|
1078
|
+
const dir = dirname2(path);
|
|
1079
|
+
try {
|
|
1080
|
+
const re = globToRegExp(base);
|
|
1081
|
+
return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join2(dir, name));
|
|
1082
|
+
} catch {
|
|
1083
|
+
return [];
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function loadLines(file, seen) {
|
|
1087
|
+
if (seen.has(file)) return [];
|
|
1088
|
+
seen.add(file);
|
|
1089
|
+
try {
|
|
1090
|
+
return (await readFile(file, "utf8")).split(/\r?\n/);
|
|
1091
|
+
} catch {
|
|
1092
|
+
return [];
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
1096
|
+
const result = { identityFiles: [] };
|
|
1097
|
+
if (!file) return result;
|
|
1098
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1099
|
+
async function walk(path, active) {
|
|
1100
|
+
let blockActive = active;
|
|
1101
|
+
for (const line of await loadLines(path, seen)) {
|
|
1102
|
+
const d = parseLine(line);
|
|
1103
|
+
if (!d) continue;
|
|
1104
|
+
switch (d.keyword) {
|
|
1105
|
+
case "host":
|
|
1106
|
+
blockActive = hostPatternMatches(d.args, host);
|
|
1107
|
+
continue;
|
|
1108
|
+
case "match":
|
|
1109
|
+
blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
|
|
1110
|
+
continue;
|
|
1111
|
+
case "include":
|
|
1112
|
+
for (const inc of d.args) {
|
|
1113
|
+
const target = expandHome2(inc);
|
|
1114
|
+
const abs = isAbsolute(target) ? target : join2(homedir2(), ".ssh", target);
|
|
1115
|
+
for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
|
|
1116
|
+
}
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (!blockActive) continue;
|
|
1120
|
+
const value = d.args[0];
|
|
1121
|
+
if (value === void 0) continue;
|
|
1122
|
+
switch (d.keyword) {
|
|
1123
|
+
case "hostname":
|
|
1124
|
+
result.hostName ??= value.replace(/%h/g, host);
|
|
1125
|
+
break;
|
|
1126
|
+
case "port":
|
|
1127
|
+
result.port ??= Number(value) || void 0;
|
|
1128
|
+
break;
|
|
1129
|
+
case "user":
|
|
1130
|
+
result.user ??= value;
|
|
1131
|
+
break;
|
|
1132
|
+
case "identityfile":
|
|
1133
|
+
for (const f of d.args) {
|
|
1134
|
+
const expanded = expandHome2(f.replace(/%d/g, homedir2()).replace(/%h/g, host));
|
|
1135
|
+
if (expanded.split(/[\\/]/).includes("..")) continue;
|
|
1136
|
+
result.identityFiles.push(expanded);
|
|
1137
|
+
}
|
|
1138
|
+
break;
|
|
1139
|
+
case "identityagent":
|
|
1140
|
+
result.identityAgent ??= value === "none" ? void 0 : expandHome2(value.replace(/%d/g, homedir2()));
|
|
1141
|
+
break;
|
|
1142
|
+
case "proxyjump":
|
|
1143
|
+
result.proxyJump ??= value;
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1245
1146
|
}
|
|
1246
|
-
if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
|
|
1247
|
-
const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
|
|
1248
|
-
if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
|
|
1249
|
-
return {
|
|
1250
|
-
driver: "oracle",
|
|
1251
|
-
host,
|
|
1252
|
-
port: Number(body.port) || 1521,
|
|
1253
|
-
user,
|
|
1254
|
-
password: typeof body.password === "string" ? body.password : "",
|
|
1255
|
-
serviceName
|
|
1256
|
-
};
|
|
1257
1147
|
}
|
|
1258
|
-
|
|
1148
|
+
await walk(file, true);
|
|
1149
|
+
return result;
|
|
1150
|
+
}
|
|
1259
1151
|
|
|
1260
|
-
// src/
|
|
1261
|
-
var
|
|
1262
|
-
var D1Error = class extends Error {
|
|
1152
|
+
// src/ssh-tunnel.ts
|
|
1153
|
+
var TunnelError = class extends Error {
|
|
1263
1154
|
constructor(message, status) {
|
|
1264
1155
|
super(message);
|
|
1265
1156
|
this.status = status;
|
|
1157
|
+
this.name = "TunnelError";
|
|
1266
1158
|
}
|
|
1267
1159
|
status;
|
|
1268
1160
|
};
|
|
1269
|
-
var
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
return
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1161
|
+
var HostKeyError = class extends TunnelError {
|
|
1162
|
+
hostKey;
|
|
1163
|
+
constructor(host, port, type, fingerprint2) {
|
|
1164
|
+
super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
|
|
1165
|
+
this.name = "HostKeyError";
|
|
1166
|
+
this.hostKey = { host, port, type, fingerprint: fingerprint2 };
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
var CONNECT_TIMEOUT_MS = 2e4;
|
|
1170
|
+
var PLANCHE_DIR = join3(homedir3(), ".planche");
|
|
1171
|
+
var PLANCHE_KNOWN_HOSTS = join3(PLANCHE_DIR, "known_hosts");
|
|
1172
|
+
var OPENSSH_KNOWN_HOSTS = join3(homedir3(), ".ssh", "known_hosts");
|
|
1173
|
+
function parseSshConfig(value) {
|
|
1174
|
+
if (value === void 0 || value === null) return void 0;
|
|
1175
|
+
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
1176
|
+
const body = value;
|
|
1177
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1178
|
+
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
1179
|
+
const auth = body.auth;
|
|
1180
|
+
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
1181
|
+
if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
|
|
1182
|
+
if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
|
|
1183
|
+
const port = Math.trunc(Number(body.port)) || 22;
|
|
1184
|
+
if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
|
|
1185
|
+
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
1186
|
+
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
1187
|
+
}
|
|
1188
|
+
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
1189
|
+
const config = {
|
|
1190
|
+
host,
|
|
1191
|
+
port,
|
|
1192
|
+
user,
|
|
1193
|
+
auth,
|
|
1194
|
+
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
1195
|
+
privateKey: auth === "key" ? str(body.privateKey) : void 0,
|
|
1196
|
+
passphrase: auth === "key" ? str(body.passphrase) : void 0,
|
|
1197
|
+
acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
|
|
1198
|
+
};
|
|
1199
|
+
return config;
|
|
1288
1200
|
}
|
|
1289
|
-
function
|
|
1290
|
-
if (
|
|
1291
|
-
|
|
1292
|
-
if (
|
|
1293
|
-
|
|
1201
|
+
function keyType(blob) {
|
|
1202
|
+
if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
|
|
1203
|
+
const len = blob.readUInt32BE(0);
|
|
1204
|
+
if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
|
|
1205
|
+
const type = blob.subarray(4, 4 + len).toString("ascii");
|
|
1206
|
+
if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
|
|
1207
|
+
return type;
|
|
1294
1208
|
}
|
|
1295
|
-
function
|
|
1296
|
-
|
|
1297
|
-
const
|
|
1298
|
-
if (
|
|
1209
|
+
function knownHostsEntryMatches(field, patterns) {
|
|
1210
|
+
if (field.startsWith("|1|")) {
|
|
1211
|
+
const [, , salt, hash] = field.split("|");
|
|
1212
|
+
if (!salt || !hash) return false;
|
|
1213
|
+
const key = Buffer.from(salt, "base64");
|
|
1214
|
+
const want = Buffer.from(hash, "base64");
|
|
1215
|
+
return patterns.some((p) => {
|
|
1216
|
+
const got = createHmac("sha1", key).update(p).digest();
|
|
1217
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
1218
|
+
});
|
|
1299
1219
|
}
|
|
1300
|
-
|
|
1220
|
+
const entries = field.toLowerCase().split(",");
|
|
1221
|
+
return patterns.some((p) => hostPatternMatches(entries, p));
|
|
1301
1222
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
|
|
1312
|
-
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
1223
|
+
function hostPatterns(host, port) {
|
|
1224
|
+
const h = host.toLowerCase();
|
|
1225
|
+
return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
|
|
1226
|
+
}
|
|
1227
|
+
async function readKnownHosts(file) {
|
|
1228
|
+
try {
|
|
1229
|
+
return (await readFile2(file, "utf8")).split(/\r?\n/);
|
|
1230
|
+
} catch {
|
|
1231
|
+
return [];
|
|
1313
1232
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1233
|
+
}
|
|
1234
|
+
async function lookupHostKey(host, port, blob) {
|
|
1235
|
+
const patterns = hostPatterns(host, port);
|
|
1236
|
+
const type = keyType(blob);
|
|
1237
|
+
const encoded = blob.toString("base64");
|
|
1238
|
+
let sawHost = false;
|
|
1239
|
+
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
1240
|
+
for (const raw of lines) {
|
|
1241
|
+
const line = raw.trim();
|
|
1242
|
+
if (!line || line.startsWith("#")) continue;
|
|
1243
|
+
const fields = line.split(/\s+/);
|
|
1244
|
+
const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
|
|
1245
|
+
const [hosts, lineType, lineKey] = fields;
|
|
1246
|
+
if (!hosts || !lineType || !lineKey) continue;
|
|
1247
|
+
if (!knownHostsEntryMatches(hosts, patterns)) continue;
|
|
1248
|
+
if (marker === "@revoked") {
|
|
1249
|
+
if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
|
|
1250
|
+
continue;
|
|
1329
1251
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
return
|
|
1252
|
+
if (marker === "@cert-authority") continue;
|
|
1253
|
+
sawHost = true;
|
|
1254
|
+
if (lineType === type && lineKey === encoded) return "match";
|
|
1333
1255
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
}
|
|
1350
|
-
if (this.columns.size > 200) this.columns.clear();
|
|
1351
|
-
this.columns.set(table, out);
|
|
1352
|
-
return out;
|
|
1256
|
+
return sawHost ? "mismatch" : "unknown";
|
|
1257
|
+
}
|
|
1258
|
+
async function rememberHostKey(host, port, blob) {
|
|
1259
|
+
await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
|
|
1260
|
+
const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
|
|
1261
|
+
await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
|
|
1262
|
+
`, { mode: 384 });
|
|
1263
|
+
}
|
|
1264
|
+
function fingerprint(blob) {
|
|
1265
|
+
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
|
1266
|
+
}
|
|
1267
|
+
async function resolveConfig(ssh) {
|
|
1268
|
+
const cfg = await resolveSshHost(ssh.host);
|
|
1269
|
+
if (cfg.proxyJump) {
|
|
1270
|
+
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
1353
1271
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1272
|
+
if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
|
|
1273
|
+
throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
|
|
1274
|
+
}
|
|
1275
|
+
const resolved = {
|
|
1276
|
+
...ssh,
|
|
1277
|
+
host: cfg.hostName ?? ssh.host,
|
|
1278
|
+
port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
|
|
1279
|
+
user: ssh.user || cfg.user || ""
|
|
1280
|
+
};
|
|
1281
|
+
if (!resolved.user) {
|
|
1282
|
+
throw new TunnelError(
|
|
1283
|
+
SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
|
|
1284
|
+
400
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
if (resolved.auth === "key" && !resolved.privateKey) {
|
|
1288
|
+
if (SSH_CONFIG_DISABLED) {
|
|
1289
|
+
throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
|
|
1367
1290
|
}
|
|
1368
|
-
const
|
|
1369
|
-
|
|
1370
|
-
|
|
1291
|
+
const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
|
|
1292
|
+
for (const file of candidates) {
|
|
1293
|
+
try {
|
|
1294
|
+
resolved.privateKey = await readFile2(file, "utf8");
|
|
1295
|
+
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
1296
|
+
break;
|
|
1297
|
+
} catch {
|
|
1298
|
+
}
|
|
1371
1299
|
}
|
|
1372
|
-
if (
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
return {
|
|
1378
|
-
fields: [],
|
|
1379
|
-
records: [],
|
|
1380
|
-
affectedRows: result.meta?.changes ?? 0,
|
|
1381
|
-
insertId: result.meta?.last_row_id ?? 0,
|
|
1382
|
-
isResultQuery: false
|
|
1383
|
-
};
|
|
1300
|
+
if (!resolved.privateKey) {
|
|
1301
|
+
throw new TunnelError(
|
|
1302
|
+
`No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename3(f)).join(", ")} in ~/.ssh)`,
|
|
1303
|
+
400
|
|
1304
|
+
);
|
|
1384
1305
|
}
|
|
1385
|
-
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
1386
|
-
const known = source ? await this.tableColumns(source) : null;
|
|
1387
|
-
const fields = columns.map((name, index) => {
|
|
1388
|
-
const fromTable = known?.has(name) ?? false;
|
|
1389
|
-
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
1390
|
-
return {
|
|
1391
|
-
name,
|
|
1392
|
-
orgName: name,
|
|
1393
|
-
type,
|
|
1394
|
-
table: fromTable && source ? source : "",
|
|
1395
|
-
orgTable: fromTable && source ? source : "",
|
|
1396
|
-
db: fromTable ? "main" : "",
|
|
1397
|
-
maxLength: 0
|
|
1398
|
-
};
|
|
1399
|
-
});
|
|
1400
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1401
1306
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1307
|
+
return { ...resolved, identityAgent: cfg.identityAgent };
|
|
1308
|
+
}
|
|
1309
|
+
function toConnectConfig(ssh) {
|
|
1310
|
+
const base = {
|
|
1311
|
+
host: ssh.host,
|
|
1312
|
+
port: ssh.port,
|
|
1313
|
+
username: ssh.user,
|
|
1314
|
+
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
1315
|
+
keepaliveInterval: 3e4
|
|
1316
|
+
};
|
|
1317
|
+
switch (ssh.auth) {
|
|
1318
|
+
case "password":
|
|
1319
|
+
return { ...base, password: ssh.password ?? "", tryKeyboard: true };
|
|
1320
|
+
case "key":
|
|
1321
|
+
return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
|
|
1322
|
+
case "agent": {
|
|
1323
|
+
const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
|
|
1324
|
+
if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
|
|
1325
|
+
return { ...base, agent };
|
|
1326
|
+
}
|
|
1404
1327
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1328
|
+
}
|
|
1329
|
+
function describeSshError(err, ssh) {
|
|
1330
|
+
const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
|
|
1331
|
+
if (err instanceof TunnelError) return err;
|
|
1332
|
+
switch (err.level) {
|
|
1333
|
+
case "client-authentication":
|
|
1334
|
+
return new TunnelError(`SSH authentication failed for ${target}`, 401);
|
|
1335
|
+
case "client-timeout":
|
|
1336
|
+
return new TunnelError(`SSH connection to ${target} timed out`, 400);
|
|
1337
|
+
default:
|
|
1338
|
+
return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
|
|
1407
1339
|
}
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1340
|
+
}
|
|
1341
|
+
async function openSshTunnel(requested, target) {
|
|
1342
|
+
const ssh = await resolveConfig(requested);
|
|
1343
|
+
const client = new Client();
|
|
1344
|
+
const connectConfig = toConnectConfig(ssh);
|
|
1345
|
+
let verifyError;
|
|
1346
|
+
connectConfig.hostVerifier = (key, verified) => {
|
|
1347
|
+
lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
|
|
1348
|
+
const fp = fingerprint(key);
|
|
1349
|
+
if (result === "mismatch") {
|
|
1350
|
+
verifyError = new TunnelError(
|
|
1351
|
+
`Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
|
|
1352
|
+
400
|
|
1353
|
+
);
|
|
1354
|
+
verified(false);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (result === "unknown") {
|
|
1358
|
+
if (ssh.acceptHostKey !== fp) {
|
|
1359
|
+
verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
|
|
1360
|
+
verified(false);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
|
|
1364
|
+
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
1365
|
+
}
|
|
1366
|
+
verified(true);
|
|
1367
|
+
}).catch((err) => {
|
|
1368
|
+
verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
1369
|
+
verified(false);
|
|
1370
|
+
});
|
|
1371
|
+
};
|
|
1372
|
+
await new Promise((resolve4, reject) => {
|
|
1373
|
+
client.once("ready", resolve4);
|
|
1374
|
+
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
1375
|
+
try {
|
|
1376
|
+
client.connect(connectConfig);
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
reject(describeSshError(err, ssh));
|
|
1424
1379
|
}
|
|
1425
|
-
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1380
|
+
});
|
|
1381
|
+
client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
|
|
1382
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1383
|
+
let forwardError;
|
|
1384
|
+
const server = createServer((socket) => {
|
|
1385
|
+
sockets.add(socket);
|
|
1386
|
+
socket.once("close", () => sockets.delete(socket));
|
|
1387
|
+
socket.on("error", () => socket.destroy());
|
|
1388
|
+
client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
1389
|
+
if (err) {
|
|
1390
|
+
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
1391
|
+
const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
|
|
1392
|
+
forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
|
|
1393
|
+
console.error(`[ssh] ${forwardError}`);
|
|
1394
|
+
socket.destroy();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
stream.on("error", () => socket.destroy());
|
|
1398
|
+
socket.pipe(stream).pipe(socket);
|
|
1399
|
+
stream.once("close", () => socket.destroy());
|
|
1400
|
+
socket.once("close", () => stream.destroy());
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
await new Promise((resolve4, reject) => {
|
|
1404
|
+
server.once("error", reject);
|
|
1405
|
+
server.listen(0, "127.0.0.1", () => resolve4());
|
|
1406
|
+
}).catch((err) => {
|
|
1407
|
+
client.end();
|
|
1408
|
+
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
1409
|
+
});
|
|
1410
|
+
const { port } = server.address();
|
|
1411
|
+
let closed = false;
|
|
1412
|
+
const close = () => new Promise((resolve4) => {
|
|
1413
|
+
if (closed) return resolve4();
|
|
1414
|
+
closed = true;
|
|
1415
|
+
for (const s of sockets) s.destroy();
|
|
1416
|
+
server.close(() => {
|
|
1417
|
+
client.end();
|
|
1418
|
+
resolve4();
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
client.once("close", () => void close());
|
|
1422
|
+
return {
|
|
1423
|
+
host: "127.0.0.1",
|
|
1424
|
+
port,
|
|
1425
|
+
get forwardError() {
|
|
1426
|
+
return forwardError;
|
|
1427
|
+
},
|
|
1428
|
+
close
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1428
1431
|
|
|
1429
1432
|
// src/drivers/index.ts
|
|
1430
1433
|
var factories = {
|
|
@@ -1463,6 +1466,7 @@ function tunneled(driver, tunnel, label) {
|
|
|
1463
1466
|
type: driver.type,
|
|
1464
1467
|
label,
|
|
1465
1468
|
query: (sql2, database) => driver.query(sql2, database),
|
|
1469
|
+
setReadOnly: driver.setReadOnly ? () => driver.setReadOnly() : void 0,
|
|
1466
1470
|
async close() {
|
|
1467
1471
|
try {
|
|
1468
1472
|
await driver.close();
|
|
@@ -1522,6 +1526,58 @@ function testDirect(config) {
|
|
|
1522
1526
|
}
|
|
1523
1527
|
}
|
|
1524
1528
|
|
|
1529
|
+
// src/gateway.ts
|
|
1530
|
+
function loadGatewayConfig(file) {
|
|
1531
|
+
const path = resolve2(file);
|
|
1532
|
+
let raw;
|
|
1533
|
+
try {
|
|
1534
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
throw new Error(`Cannot read gateway config ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1537
|
+
}
|
|
1538
|
+
const data = raw;
|
|
1539
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.profiles) || data.profiles.length === 0) {
|
|
1540
|
+
throw new Error(`Gateway config ${path} needs a non-empty "profiles" array`);
|
|
1541
|
+
}
|
|
1542
|
+
const names = /* @__PURE__ */ new Set();
|
|
1543
|
+
const profiles = data.profiles.map((entry, i) => {
|
|
1544
|
+
const e = entry ?? {};
|
|
1545
|
+
const name = typeof e.name === "string" ? e.name.trim() : "";
|
|
1546
|
+
if (!name) throw new Error(`Gateway profile #${i + 1} has no "name"`);
|
|
1547
|
+
if (names.has(name)) throw new Error(`Gateway profile name is not unique: ${name}`);
|
|
1548
|
+
names.add(name);
|
|
1549
|
+
const { name: _n, readOnly, ...driver } = e;
|
|
1550
|
+
void _n;
|
|
1551
|
+
let config;
|
|
1552
|
+
try {
|
|
1553
|
+
config = parseDriverConfig(driver);
|
|
1554
|
+
} catch (err) {
|
|
1555
|
+
throw new Error(`Gateway profile "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
1556
|
+
}
|
|
1557
|
+
return { name, readOnly: readOnly !== false, config };
|
|
1558
|
+
});
|
|
1559
|
+
return { name: typeof data.name === "string" && data.name.trim() ? data.name.trim() : "Planche gateway", profiles };
|
|
1560
|
+
}
|
|
1561
|
+
function describeProfile(entry) {
|
|
1562
|
+
const c = entry.config;
|
|
1563
|
+
const database = "database" in c && typeof c.database === "string" ? c.database : c.driver === "oracle" ? c.serviceName : void 0;
|
|
1564
|
+
return { name: entry.name, driver: c.driver, readOnly: entry.readOnly, ...database ? { database } : {} };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/index.ts
|
|
1568
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1569
|
+
import { dirname as dirname3, join as join5, resolve as resolve3 } from "path";
|
|
1570
|
+
|
|
1571
|
+
// src/server.ts
|
|
1572
|
+
import express from "express";
|
|
1573
|
+
import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1574
|
+
|
|
1575
|
+
// ../shared/contract.ts
|
|
1576
|
+
var PROTOCOL_VERSION = 2;
|
|
1577
|
+
|
|
1578
|
+
// src/tunneling.ts
|
|
1579
|
+
import { randomUUID } from "crypto";
|
|
1580
|
+
|
|
1525
1581
|
// src/drivers/connection-lost.ts
|
|
1526
1582
|
var ConnectionLostError = class extends Error {
|
|
1527
1583
|
status = 404;
|
|
@@ -1585,12 +1641,105 @@ function isConnectionLost(err) {
|
|
|
1585
1641
|
return e.originalError !== void 0 && e.originalError !== err && isConnectionLost(e.originalError);
|
|
1586
1642
|
}
|
|
1587
1643
|
|
|
1644
|
+
// src/read-only.ts
|
|
1645
|
+
var ReadOnlyViolation = class extends Error {
|
|
1646
|
+
status = 403;
|
|
1647
|
+
constructor(message) {
|
|
1648
|
+
super(message);
|
|
1649
|
+
this.name = "ReadOnlyViolation";
|
|
1650
|
+
}
|
|
1651
|
+
};
|
|
1652
|
+
var READ_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "VALUES", "TABLE", "PRAGMA"]);
|
|
1653
|
+
function splitStatements(sql2) {
|
|
1654
|
+
const out = [];
|
|
1655
|
+
let cur = "";
|
|
1656
|
+
let i = 0;
|
|
1657
|
+
const n = sql2.length;
|
|
1658
|
+
while (i < n) {
|
|
1659
|
+
const ch = sql2[i];
|
|
1660
|
+
const next = sql2[i + 1];
|
|
1661
|
+
if (ch === "-" && next === "-") {
|
|
1662
|
+
while (i < n && sql2[i] !== "\n") i++;
|
|
1663
|
+
cur += " ";
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
if (ch === "#") {
|
|
1667
|
+
while (i < n && sql2[i] !== "\n") i++;
|
|
1668
|
+
cur += " ";
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
if (ch === "/" && next === "*") {
|
|
1672
|
+
i += 2;
|
|
1673
|
+
while (i < n && !(sql2[i] === "*" && sql2[i + 1] === "/")) i++;
|
|
1674
|
+
i += 2;
|
|
1675
|
+
cur += " ";
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
|
|
1679
|
+
const close = ch === "[" ? "]" : ch;
|
|
1680
|
+
i++;
|
|
1681
|
+
while (i < n) {
|
|
1682
|
+
if (sql2[i] === "\\" && close === "'") {
|
|
1683
|
+
i += 2;
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
if (sql2[i] === close) {
|
|
1687
|
+
if (sql2[i + 1] === close && close !== "]") {
|
|
1688
|
+
i += 2;
|
|
1689
|
+
continue;
|
|
1690
|
+
}
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
i++;
|
|
1694
|
+
}
|
|
1695
|
+
i++;
|
|
1696
|
+
cur += ch === "[" ? " x " : " 'x' ";
|
|
1697
|
+
continue;
|
|
1698
|
+
}
|
|
1699
|
+
if (ch === "$" && next === "$") {
|
|
1700
|
+
const end = sql2.indexOf("$$", i + 2);
|
|
1701
|
+
i = end === -1 ? n : end + 2;
|
|
1702
|
+
cur += " 'x' ";
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
if (ch === ";") {
|
|
1706
|
+
out.push(cur);
|
|
1707
|
+
cur = "";
|
|
1708
|
+
i++;
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
cur += ch;
|
|
1712
|
+
i++;
|
|
1713
|
+
}
|
|
1714
|
+
out.push(cur);
|
|
1715
|
+
return out.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1716
|
+
}
|
|
1717
|
+
function assertReadOnlyStatement(sql2) {
|
|
1718
|
+
const statements = splitStatements(sql2);
|
|
1719
|
+
if (statements.length === 0) return;
|
|
1720
|
+
if (statements.length > 1) throw new ReadOnlyViolation("Read-only profile: one statement per request");
|
|
1721
|
+
const stmt = statements[0];
|
|
1722
|
+
const first = /^[A-Za-z]+/.exec(stmt)?.[0].toUpperCase() ?? "";
|
|
1723
|
+
if (!READ_KEYWORDS.has(first)) throw new ReadOnlyViolation(`Read-only profile: ${first || "this statement"} is not allowed`);
|
|
1724
|
+
if (first === "WITH") {
|
|
1725
|
+
const words = stmt.toUpperCase().match(/\b(INSERT|UPDATE|DELETE|MERGE|REPLACE)\b/);
|
|
1726
|
+
if (words) throw new ReadOnlyViolation(`Read-only profile: ${words[1]} inside WITH is not allowed`);
|
|
1727
|
+
}
|
|
1728
|
+
if (first === "PRAGMA" && /[=(]/.test(stmt)) throw new ReadOnlyViolation("Read-only profile: PRAGMA may only read");
|
|
1729
|
+
if (/\bINTO\s+(OUTFILE|DUMPFILE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: INTO OUTFILE is not allowed");
|
|
1730
|
+
if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
|
|
1731
|
+
if (/\bLOCK\s+IN\s+SHARE\s+MODE\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
|
|
1732
|
+
if (first === "SELECT" && /\bINTO\b/i.test(stmt) && !/\bINTO\s+@/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: SELECT INTO is not allowed");
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1588
1735
|
// src/tunneling.ts
|
|
1589
1736
|
var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
|
|
1590
1737
|
var MAX_CONNECTIONS = Number(process.env.PLANCHE_MAX_CONNECTIONS) || 64;
|
|
1591
1738
|
var ConnectionManager = class {
|
|
1592
1739
|
connections = /* @__PURE__ */ new Map();
|
|
1593
1740
|
lastUsed = /* @__PURE__ */ new Map();
|
|
1741
|
+
/** Connections opened for a read-only gateway profile: writes are refused before reaching the driver. */
|
|
1742
|
+
readOnly = /* @__PURE__ */ new Set();
|
|
1594
1743
|
constructor() {
|
|
1595
1744
|
const timer = setInterval(() => void this.closeIdle(), 6e4);
|
|
1596
1745
|
timer.unref();
|
|
@@ -1608,14 +1757,23 @@ var ConnectionManager = class {
|
|
|
1608
1757
|
/**
|
|
1609
1758
|
* Open a new connection and return a unique connection ID.
|
|
1610
1759
|
*/
|
|
1611
|
-
async connect(config) {
|
|
1760
|
+
async connect(config, options = {}) {
|
|
1612
1761
|
if (this.connections.size >= MAX_CONNECTIONS) {
|
|
1613
1762
|
throw new Error(`Too many open connections (${MAX_CONNECTIONS}); disconnect some first`);
|
|
1614
1763
|
}
|
|
1615
1764
|
const driver = await openDriver(config);
|
|
1765
|
+
if (options.readOnly && driver.setReadOnly) {
|
|
1766
|
+
try {
|
|
1767
|
+
await driver.setReadOnly();
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
await driver.close().catch(() => void 0);
|
|
1770
|
+
throw err;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1616
1773
|
const connectionId = randomUUID();
|
|
1617
1774
|
this.connections.set(connectionId, driver);
|
|
1618
1775
|
this.lastUsed.set(connectionId, Date.now());
|
|
1776
|
+
if (options.readOnly) this.readOnly.add(connectionId);
|
|
1619
1777
|
console.log(`[ConnectionManager] New ${driver.type} connection: ${connectionId} -> ${driver.label}`);
|
|
1620
1778
|
return connectionId;
|
|
1621
1779
|
}
|
|
@@ -1632,6 +1790,7 @@ var ConnectionManager = class {
|
|
|
1632
1790
|
throw new Error(`Connection not found: ${connectionId}`);
|
|
1633
1791
|
}
|
|
1634
1792
|
this.lastUsed.set(connectionId, Date.now());
|
|
1793
|
+
if (this.readOnly.has(connectionId)) assertReadOnlyStatement(sql2);
|
|
1635
1794
|
try {
|
|
1636
1795
|
return await driver.query(sql2, database);
|
|
1637
1796
|
} catch (err) {
|
|
@@ -1639,6 +1798,7 @@ var ConnectionManager = class {
|
|
|
1639
1798
|
console.log(`[ConnectionManager] Connection lost, evicting: ${connectionId}`);
|
|
1640
1799
|
this.connections.delete(connectionId);
|
|
1641
1800
|
this.lastUsed.delete(connectionId);
|
|
1801
|
+
this.readOnly.delete(connectionId);
|
|
1642
1802
|
await driver.close().catch(() => void 0);
|
|
1643
1803
|
throw new ConnectionLostError(`Connection lost: ${err instanceof Error ? err.message : String(err)}`);
|
|
1644
1804
|
}
|
|
@@ -1654,6 +1814,7 @@ var ConnectionManager = class {
|
|
|
1654
1814
|
await driver.close();
|
|
1655
1815
|
this.connections.delete(connectionId);
|
|
1656
1816
|
this.lastUsed.delete(connectionId);
|
|
1817
|
+
this.readOnly.delete(connectionId);
|
|
1657
1818
|
console.log(`[ConnectionManager] Disconnected: ${connectionId}`);
|
|
1658
1819
|
}
|
|
1659
1820
|
/**
|
|
@@ -1685,10 +1846,13 @@ var ConnectionManager = class {
|
|
|
1685
1846
|
await Promise.all(promises);
|
|
1686
1847
|
this.connections.clear();
|
|
1687
1848
|
this.lastUsed.clear();
|
|
1849
|
+
this.readOnly.clear();
|
|
1688
1850
|
}
|
|
1689
1851
|
};
|
|
1690
1852
|
|
|
1691
1853
|
// src/server.ts
|
|
1854
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
1855
|
+
import { join as join4 } from "path";
|
|
1692
1856
|
function errorStatus(err) {
|
|
1693
1857
|
const status = err?.status;
|
|
1694
1858
|
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
@@ -1810,6 +1974,7 @@ function createAgent(options) {
|
|
|
1810
1974
|
next();
|
|
1811
1975
|
});
|
|
1812
1976
|
app.use(accessControl(allowedOrigins));
|
|
1977
|
+
if (options.appDir) serveApp(app, options.appDir);
|
|
1813
1978
|
app.get("/health", (req, res) => {
|
|
1814
1979
|
const body = {
|
|
1815
1980
|
success: true,
|
|
@@ -1819,6 +1984,7 @@ function createAgent(options) {
|
|
|
1819
1984
|
activeConnections: manager.activeConnections,
|
|
1820
1985
|
uptime: process.uptime()
|
|
1821
1986
|
};
|
|
1987
|
+
if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
|
|
1822
1988
|
if (req.headers.authorization !== void 0) {
|
|
1823
1989
|
if (throttle.locked(peerOf(req))) {
|
|
1824
1990
|
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
@@ -1855,10 +2021,28 @@ function createAgent(options) {
|
|
|
1855
2021
|
next();
|
|
1856
2022
|
});
|
|
1857
2023
|
app.use(express.json({ limit: options.bodyLimit }));
|
|
2024
|
+
app.get("/profiles", (_req, res) => {
|
|
2025
|
+
if (!options.gateway) {
|
|
2026
|
+
fail(res, 404, "Not a gateway");
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
const body = { success: true, gateway: options.gateway.name, profiles: options.gateway.profiles.map(describeProfile) };
|
|
2030
|
+
res.json(body);
|
|
2031
|
+
});
|
|
2032
|
+
const resolveConnect = (body) => {
|
|
2033
|
+
const b = body ?? {};
|
|
2034
|
+
if (options.gateway) {
|
|
2035
|
+
if (typeof b.profile !== "string") throw Object.assign(new Error("Gateway mode: choose a profile (POST { profile })"), { status: 403 });
|
|
2036
|
+
const entry = options.gateway.profiles.find((p) => p.name === b.profile);
|
|
2037
|
+
if (!entry) throw Object.assign(new Error(`Unknown gateway profile: ${b.profile}`), { status: 404 });
|
|
2038
|
+
return { config: entry.config, readOnly: entry.readOnly };
|
|
2039
|
+
}
|
|
2040
|
+
return { config: parseDriverConfig(b), readOnly: false };
|
|
2041
|
+
};
|
|
1858
2042
|
app.post("/connect", async (req, res) => {
|
|
1859
2043
|
try {
|
|
1860
|
-
const config =
|
|
1861
|
-
const connectionId = await manager.connect(config);
|
|
2044
|
+
const { config, readOnly } = resolveConnect(req.body);
|
|
2045
|
+
const connectionId = await manager.connect(config, { readOnly });
|
|
1862
2046
|
res.json({ success: true, connectionId, message: "Connection Successful" });
|
|
1863
2047
|
} catch (err) {
|
|
1864
2048
|
const message = errorMessage(err);
|
|
@@ -1920,7 +2104,7 @@ function createAgent(options) {
|
|
|
1920
2104
|
});
|
|
1921
2105
|
app.post("/test-connection", async (req, res) => {
|
|
1922
2106
|
try {
|
|
1923
|
-
await manager.testConnection(
|
|
2107
|
+
await manager.testConnection(resolveConnect(req.body).config);
|
|
1924
2108
|
res.json({ success: true, message: "Connection test successful" });
|
|
1925
2109
|
} catch (err) {
|
|
1926
2110
|
const message = errorMessage(err);
|
|
@@ -1938,6 +2122,45 @@ function createAgent(options) {
|
|
|
1938
2122
|
});
|
|
1939
2123
|
return { app, manager };
|
|
1940
2124
|
}
|
|
2125
|
+
function serveApp(app, appDir) {
|
|
2126
|
+
const csp = [
|
|
2127
|
+
"default-src 'self'",
|
|
2128
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
2129
|
+
"connect-src 'self'",
|
|
2130
|
+
"img-src 'self' data: blob:",
|
|
2131
|
+
"style-src 'self' 'unsafe-inline'",
|
|
2132
|
+
"font-src 'self' data:",
|
|
2133
|
+
"worker-src 'self' blob:",
|
|
2134
|
+
"child-src 'self' blob:",
|
|
2135
|
+
"object-src 'none'",
|
|
2136
|
+
"base-uri 'none'",
|
|
2137
|
+
"form-action 'none'",
|
|
2138
|
+
"frame-ancestors 'none'"
|
|
2139
|
+
].join("; ");
|
|
2140
|
+
const indexFile = join4(appDir, "index.html");
|
|
2141
|
+
const indexHtml = existsSync2(indexFile) ? readFileSync2(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />') : null;
|
|
2142
|
+
const headers = (res) => {
|
|
2143
|
+
res.setHeader("Content-Security-Policy", csp);
|
|
2144
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
2145
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
2146
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
2147
|
+
};
|
|
2148
|
+
app.get(["/", "/index.html"], (_req, res) => {
|
|
2149
|
+
if (!indexHtml) {
|
|
2150
|
+
fail(res, 404, "App not bundled");
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
headers(res);
|
|
2154
|
+
res.type("html").send(indexHtml);
|
|
2155
|
+
});
|
|
2156
|
+
app.use(express.static(appDir, {
|
|
2157
|
+
index: false,
|
|
2158
|
+
setHeaders: (res, path) => {
|
|
2159
|
+
if (path.endsWith(".html")) headers(res);
|
|
2160
|
+
if (/\/assets\//.test(path)) res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
2161
|
+
}
|
|
2162
|
+
}));
|
|
2163
|
+
}
|
|
1941
2164
|
|
|
1942
2165
|
// src/index.ts
|
|
1943
2166
|
var { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
@@ -1948,6 +2171,38 @@ var DEV_ORIGINS = [
|
|
|
1948
2171
|
"http://localhost:4173",
|
|
1949
2172
|
"http://127.0.0.1:4173"
|
|
1950
2173
|
];
|
|
2174
|
+
function resolveAppDir(explicit) {
|
|
2175
|
+
if (explicit) {
|
|
2176
|
+
const dir = resolve3(explicit);
|
|
2177
|
+
if (!existsSync3(join5(dir, "index.html"))) {
|
|
2178
|
+
console.error(`--app-dir has no index.html: ${dir}`);
|
|
2179
|
+
process.exit(2);
|
|
2180
|
+
}
|
|
2181
|
+
return dir;
|
|
2182
|
+
}
|
|
2183
|
+
try {
|
|
2184
|
+
const require2 = createRequire(import.meta.url);
|
|
2185
|
+
const pkgPath = require2.resolve("planche-app/package.json");
|
|
2186
|
+
const dir = join5(dirname3(pkgPath), "dist");
|
|
2187
|
+
if (existsSync3(join5(dir, "index.html"))) {
|
|
2188
|
+
const appVersion = require2(pkgPath).version;
|
|
2189
|
+
if (appVersion && appVersion !== VERSION) {
|
|
2190
|
+
console.warn(`WARNING: planche-app ${appVersion} does not match this agent (${VERSION}). Install matching versions if the app reports the agent as outdated.`);
|
|
2191
|
+
}
|
|
2192
|
+
return dir;
|
|
2193
|
+
}
|
|
2194
|
+
} catch {
|
|
2195
|
+
}
|
|
2196
|
+
console.error("Serving the web app needs the planche-app package (it holds the built UI).");
|
|
2197
|
+
console.error("");
|
|
2198
|
+
console.error(" npm i -g planche planche-app");
|
|
2199
|
+
console.error("or, without installing:");
|
|
2200
|
+
console.error(" npx --package planche --package planche-app planche --gateway <file>");
|
|
2201
|
+
console.error("");
|
|
2202
|
+
console.error("Already have a build? Point at it with --app-dir <dir>.");
|
|
2203
|
+
console.error("Only need the agent? Drop --gateway/--serve-app and use the app on planche.dev.");
|
|
2204
|
+
process.exit(2);
|
|
2205
|
+
}
|
|
1951
2206
|
function usage() {
|
|
1952
2207
|
return `planche ${VERSION} \u2014 local agent for the Planche SQL client
|
|
1953
2208
|
|
|
@@ -1962,6 +2217,13 @@ Options:
|
|
|
1962
2217
|
--origin <url> Extra browser origin allowed to use the agent (repeatable,
|
|
1963
2218
|
env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
|
|
1964
2219
|
--allow-remote Required with a non-loopback --addr (see README: no TLS)
|
|
2220
|
+
--gateway <file> Gateway mode: serve named read-only profiles from a JSON
|
|
2221
|
+
config instead of accepting credentials (env PLANCHE_GATEWAY)
|
|
2222
|
+
--public-url <u> URL users open when the agent serves the app itself
|
|
2223
|
+
(behind your TLS proxy; env PLANCHE_PUBLIC_URL)
|
|
2224
|
+
--no-serve-app Do not serve the web app at / (served by default in
|
|
2225
|
+
gateway mode; needs the planche-app package)
|
|
2226
|
+
--app-dir <dir> Serve this build of the web app instead of planche-app
|
|
1965
2227
|
--no-open Do not open the browser
|
|
1966
2228
|
--verbose Log every request
|
|
1967
2229
|
-v, --version Print the version
|
|
@@ -2011,6 +2273,10 @@ function main() {
|
|
|
2011
2273
|
verbose: { type: "boolean" },
|
|
2012
2274
|
"allow-remote": { type: "boolean" },
|
|
2013
2275
|
"print-token": { type: "boolean" },
|
|
2276
|
+
gateway: { type: "string" },
|
|
2277
|
+
"public-url": { type: "string" },
|
|
2278
|
+
"serve-app": { type: "boolean" },
|
|
2279
|
+
"app-dir": { type: "string" },
|
|
2014
2280
|
version: { type: "boolean", short: "v" },
|
|
2015
2281
|
help: { type: "boolean", short: "h" }
|
|
2016
2282
|
},
|
|
@@ -2035,7 +2301,20 @@ function main() {
|
|
|
2035
2301
|
const addr = flags.addr ?? process.env.PLANCHE_ADDR ?? "127.0.0.1";
|
|
2036
2302
|
const tokenFromEnv = !flags.token && !!process.env.PLANCHE_AUTH_TOKEN;
|
|
2037
2303
|
const authToken = flags.token ?? process.env.PLANCHE_AUTH_TOKEN ?? randomUUID2();
|
|
2038
|
-
const
|
|
2304
|
+
const gatewayFile = flags.gateway ?? process.env.PLANCHE_GATEWAY;
|
|
2305
|
+
let gateway;
|
|
2306
|
+
if (gatewayFile) {
|
|
2307
|
+
try {
|
|
2308
|
+
gateway = loadGatewayConfig(gatewayFile);
|
|
2309
|
+
} catch (err) {
|
|
2310
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2311
|
+
process.exit(2);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
const serveApp2 = flags["serve-app"] ?? Boolean(gateway);
|
|
2315
|
+
const appDir = serveApp2 ? resolveAppDir(flags["app-dir"] ?? process.env.PLANCHE_APP_DIR) : void 0;
|
|
2316
|
+
const publicUrl = (flags["public-url"] ?? process.env.PLANCHE_PUBLIC_URL ?? "").replace(/\/+$/, "");
|
|
2317
|
+
const appUrl = (serveApp2 ? publicUrl || null : null) ?? (flags.app ?? process.env.PLANCHE_APP_URL ?? DEFAULT_APP_URL).replace(/\/+$/, "");
|
|
2039
2318
|
const shouldOpen = flags.open ?? true;
|
|
2040
2319
|
const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
|
|
2041
2320
|
const appOrigin = originOf(appUrl);
|
|
@@ -2068,6 +2347,7 @@ function main() {
|
|
|
2068
2347
|
}
|
|
2069
2348
|
const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
|
|
2070
2349
|
const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
|
|
2350
|
+
if (serveApp2) allowedOrigins.push(`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`);
|
|
2071
2351
|
const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
|
|
2072
2352
|
const listenAddr = addr.replace(/^\[(.*)\]$/, "$1");
|
|
2073
2353
|
const agentUrl = `http://${listenAddr.includes(":") ? `[${listenAddr}]` : listenAddr}:${port}`;
|
|
@@ -2079,9 +2359,11 @@ function main() {
|
|
|
2079
2359
|
logRequests: flags.verbose ?? false,
|
|
2080
2360
|
// Browsers omit a default port from `Host`, so the bare names are allowed too.
|
|
2081
2361
|
allowedHosts: loopback ? ["127.0.0.1", "localhost", "[::1]", `127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
|
|
2082
|
-
pairingCode
|
|
2362
|
+
pairingCode,
|
|
2363
|
+
gateway,
|
|
2364
|
+
appDir
|
|
2083
2365
|
});
|
|
2084
|
-
const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
|
|
2366
|
+
const pairUrl = serveApp2 ? `${publicUrl || agentUrl}/#pair=${encodeURIComponent(pairingCode.code)}` : `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
|
|
2085
2367
|
const server = app.listen(port, listenAddr, () => {
|
|
2086
2368
|
const line = "-".repeat(72);
|
|
2087
2369
|
console.log(line);
|