planche 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -13
- package/dist/index.js +1418 -1125
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,584 +8,532 @@ import { parseArgs } from "util";
|
|
|
8
8
|
|
|
9
9
|
// src/server.ts
|
|
10
10
|
import express from "express";
|
|
11
|
-
import { timingSafeEqual } from "crypto";
|
|
11
|
+
import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
12
12
|
|
|
13
13
|
// ../shared/contract.ts
|
|
14
14
|
var PROTOCOL_VERSION = 2;
|
|
15
15
|
|
|
16
|
-
// src/
|
|
17
|
-
import {
|
|
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";
|
|
18
23
|
|
|
19
|
-
// src/
|
|
20
|
-
import
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
8: "bigint",
|
|
31
|
-
9: "mediumint",
|
|
32
|
-
10: "date",
|
|
33
|
-
11: "time",
|
|
34
|
-
12: "datetime",
|
|
35
|
-
13: "year",
|
|
36
|
-
14: "newdate",
|
|
37
|
-
15: "varchar",
|
|
38
|
-
16: "bit",
|
|
39
|
-
245: "json",
|
|
40
|
-
246: "newdecimal",
|
|
41
|
-
247: "enum",
|
|
42
|
-
248: "set",
|
|
43
|
-
249: "tinyblob",
|
|
44
|
-
250: "mediumblob",
|
|
45
|
-
251: "longblob",
|
|
46
|
-
252: "blob",
|
|
47
|
-
253: "varstring",
|
|
48
|
-
254: "string",
|
|
49
|
-
255: "geometry"
|
|
50
|
-
};
|
|
51
|
-
function getTypeName(typeId) {
|
|
52
|
-
return MYSQL_TYPES[typeId] ?? "unknown";
|
|
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;
|
|
53
35
|
}
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
port: config.port,
|
|
58
|
-
user: config.user,
|
|
59
|
-
password: config.password,
|
|
60
|
-
database: config.database || void 0,
|
|
61
|
-
dateStrings: true,
|
|
62
|
-
charset: config.charset || "utf8mb4"
|
|
63
|
-
};
|
|
36
|
+
function globToRegExp(pattern) {
|
|
37
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
38
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
64
39
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
type = "mysql";
|
|
74
|
-
label;
|
|
75
|
-
/**
|
|
76
|
-
* Default schema the session is currently in, or null when unknown (after
|
|
77
|
-
* the client ran its own `USE`). Tracked so the schema is switched with a
|
|
78
|
-
* plain `USE` only when it actually changes: `changeUser()` would reset the
|
|
79
|
-
* whole session on every call, silently rolling back open transactions and
|
|
80
|
-
* dropping temporary tables and user variables.
|
|
81
|
-
*/
|
|
82
|
-
currentDatabase;
|
|
83
|
-
async query(sql2, database) {
|
|
84
|
-
if (database && database !== this.currentDatabase) {
|
|
85
|
-
await this.connection.query(`USE ${mysql.escapeId(database)}`);
|
|
86
|
-
this.currentDatabase = database;
|
|
87
|
-
}
|
|
88
|
-
const [results, fields] = await this.connection.query(sql2);
|
|
89
|
-
if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
|
|
90
|
-
if (Array.isArray(results) && fields) {
|
|
91
|
-
const fieldPackets = fields;
|
|
92
|
-
const mappedFields = fieldPackets.map((field) => ({
|
|
93
|
-
name: field.name,
|
|
94
|
-
orgName: field.orgName ?? field.name,
|
|
95
|
-
type: getTypeName(field.type ?? 253),
|
|
96
|
-
table: field.table ?? "",
|
|
97
|
-
orgTable: field.orgTable ?? "",
|
|
98
|
-
db: field.db ?? "",
|
|
99
|
-
maxLength: field.length ?? 0
|
|
100
|
-
}));
|
|
101
|
-
const records = results.map(
|
|
102
|
-
(row) => fieldPackets.map((field) => row[field.name])
|
|
103
|
-
);
|
|
104
|
-
return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
105
|
-
}
|
|
106
|
-
const header = results;
|
|
107
|
-
return {
|
|
108
|
-
fields: [],
|
|
109
|
-
records: [],
|
|
110
|
-
affectedRows: header.affectedRows ?? 0,
|
|
111
|
-
insertId: header.insertId ?? 0,
|
|
112
|
-
isResultQuery: false
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
async close() {
|
|
116
|
-
await this.connection.end();
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
var mysqlDriverFactory = {
|
|
120
|
-
async open(config) {
|
|
121
|
-
const connection = await mysql.createConnection(toConnectionOptions(config));
|
|
122
|
-
return new MySqlDriver(connection, config);
|
|
123
|
-
},
|
|
124
|
-
async test(config) {
|
|
125
|
-
const connection = await mysql.createConnection({
|
|
126
|
-
...toConnectionOptions(config),
|
|
127
|
-
database: void 0
|
|
128
|
-
});
|
|
129
|
-
await connection.end();
|
|
130
|
-
},
|
|
131
|
-
parseConfig(body) {
|
|
132
|
-
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
133
|
-
const user = typeof body.user === "string" ? body.user : "";
|
|
134
|
-
if (!host || !user) {
|
|
135
|
-
throw new Error("host and user are required");
|
|
136
|
-
}
|
|
137
|
-
return {
|
|
138
|
-
driver: "mysql",
|
|
139
|
-
host,
|
|
140
|
-
port: Number(body.port) || 3306,
|
|
141
|
-
user,
|
|
142
|
-
password: typeof body.password === "string" ? body.password : "",
|
|
143
|
-
database: typeof body.database === "string" && body.database ? body.database : void 0,
|
|
144
|
-
charset: typeof body.charset === "string" && body.charset ? body.charset : void 0
|
|
145
|
-
};
|
|
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;
|
|
146
48
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// src/drivers/postgres.ts
|
|
150
|
-
import pg from "pg";
|
|
151
|
-
var TEXT_OIDS = [
|
|
152
|
-
20,
|
|
153
|
-
// int8
|
|
154
|
-
1700,
|
|
155
|
-
// numeric
|
|
156
|
-
1082,
|
|
157
|
-
// date
|
|
158
|
-
1114,
|
|
159
|
-
// timestamp
|
|
160
|
-
1184,
|
|
161
|
-
// timestamptz
|
|
162
|
-
1083,
|
|
163
|
-
// time
|
|
164
|
-
1266,
|
|
165
|
-
// timetz
|
|
166
|
-
1186
|
|
167
|
-
// interval
|
|
168
|
-
];
|
|
169
|
-
var types = new pg.TypeOverrides();
|
|
170
|
-
for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
|
|
171
|
-
var BUILTIN_TYPES = {
|
|
172
|
-
16: "bool",
|
|
173
|
-
17: "bytea",
|
|
174
|
-
18: "char",
|
|
175
|
-
19: "name",
|
|
176
|
-
20: "int8",
|
|
177
|
-
21: "int2",
|
|
178
|
-
23: "int4",
|
|
179
|
-
25: "text",
|
|
180
|
-
26: "oid",
|
|
181
|
-
114: "json",
|
|
182
|
-
142: "xml",
|
|
183
|
-
700: "float4",
|
|
184
|
-
701: "float8",
|
|
185
|
-
1042: "bpchar",
|
|
186
|
-
1043: "varchar",
|
|
187
|
-
1082: "date",
|
|
188
|
-
1083: "time",
|
|
189
|
-
1114: "timestamp",
|
|
190
|
-
1184: "timestamptz",
|
|
191
|
-
1186: "interval",
|
|
192
|
-
1266: "timetz",
|
|
193
|
-
1700: "numeric",
|
|
194
|
-
2950: "uuid",
|
|
195
|
-
3802: "jsonb",
|
|
196
|
-
1560: "bit",
|
|
197
|
-
1562: "varbit",
|
|
198
|
-
600: "point",
|
|
199
|
-
869: "inet",
|
|
200
|
-
650: "cidr",
|
|
201
|
-
829: "macaddr",
|
|
202
|
-
1007: "_int4",
|
|
203
|
-
1009: "_text"
|
|
204
|
-
};
|
|
205
|
-
function toClientConfig(config) {
|
|
206
|
-
return {
|
|
207
|
-
host: config.host,
|
|
208
|
-
port: config.port,
|
|
209
|
-
user: config.user,
|
|
210
|
-
password: config.password,
|
|
211
|
-
database: config.database,
|
|
212
|
-
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
|
|
213
|
-
types
|
|
214
|
-
};
|
|
49
|
+
return matched;
|
|
215
50
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 [];
|
|
222
74
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
currentSchema = null;
|
|
232
|
-
typeNames = { ...BUILTIN_TYPES };
|
|
233
|
-
/**
|
|
234
|
-
* Result fields name their source only by OID (`tableID` / `columnID`);
|
|
235
|
-
* the frontend needs schema/table/column names to decide whether a grid is
|
|
236
|
-
* editable and to build UPDATEs, so they are resolved here and cached.
|
|
237
|
-
*/
|
|
238
|
-
relations = /* @__PURE__ */ new Map();
|
|
239
|
-
async loadTypeNames() {
|
|
240
|
-
try {
|
|
241
|
-
const res = await this.client.query(
|
|
242
|
-
"SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
|
|
243
|
-
);
|
|
244
|
-
for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
|
|
245
|
-
} catch {
|
|
246
|
-
}
|
|
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 [];
|
|
247
83
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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;
|
|
268
135
|
}
|
|
269
|
-
} catch {
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
async query(sql2, database) {
|
|
273
|
-
if (database && database !== this.currentSchema) {
|
|
274
|
-
await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
|
|
275
|
-
this.currentSchema = database;
|
|
276
|
-
}
|
|
277
|
-
const result = await this.client.query({ text: sql2, rowMode: "array" });
|
|
278
|
-
if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
|
|
279
|
-
const results = Array.isArray(result) ? result : [result];
|
|
280
|
-
const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
|
|
281
|
-
if (last.fields.length > 0) {
|
|
282
|
-
await this.resolveRelations(last.fields.map((f) => f.tableID));
|
|
283
|
-
const fields = last.fields.map((field) => {
|
|
284
|
-
const rel = this.relations.get(field.tableID);
|
|
285
|
-
return {
|
|
286
|
-
name: field.name,
|
|
287
|
-
orgName: rel?.columns.get(field.columnID) ?? field.name,
|
|
288
|
-
type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
|
|
289
|
-
table: rel?.table ?? "",
|
|
290
|
-
orgTable: rel?.table ?? "",
|
|
291
|
-
db: rel?.schema ?? "",
|
|
292
|
-
maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
|
|
293
|
-
};
|
|
294
|
-
});
|
|
295
|
-
return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
296
136
|
}
|
|
297
|
-
return {
|
|
298
|
-
fields: [],
|
|
299
|
-
records: [],
|
|
300
|
-
affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
|
|
301
|
-
insertId: 0,
|
|
302
|
-
isResultQuery: false
|
|
303
|
-
};
|
|
304
137
|
}
|
|
305
|
-
|
|
306
|
-
|
|
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";
|
|
307
148
|
}
|
|
149
|
+
status;
|
|
308
150
|
};
|
|
309
|
-
var
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
return driver;
|
|
316
|
-
},
|
|
317
|
-
async test(config) {
|
|
318
|
-
const client = new pg.Client(toClientConfig(config));
|
|
319
|
-
await client.connect();
|
|
320
|
-
await client.end();
|
|
321
|
-
},
|
|
322
|
-
parseConfig(body) {
|
|
323
|
-
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
324
|
-
const user = typeof body.user === "string" ? body.user : "";
|
|
325
|
-
if (!host || !user) {
|
|
326
|
-
throw new Error("host and user are required");
|
|
327
|
-
}
|
|
328
|
-
return {
|
|
329
|
-
driver: "postgres",
|
|
330
|
-
host,
|
|
331
|
-
port: Number(body.port) || 5432,
|
|
332
|
-
user,
|
|
333
|
-
password: typeof body.password === "string" ? body.password : "",
|
|
334
|
-
database: typeof body.database === "string" && body.database ? body.database : "postgres",
|
|
335
|
-
ssl: body.ssl === true,
|
|
336
|
-
sslVerify: body.sslVerify === true
|
|
337
|
-
};
|
|
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 };
|
|
338
157
|
}
|
|
339
158
|
};
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
const
|
|
348
|
-
|
|
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;
|
|
349
190
|
}
|
|
350
|
-
function
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
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;
|
|
356
198
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
});
|
|
363
209
|
}
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
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 [];
|
|
367
222
|
}
|
|
368
|
-
return resolved;
|
|
369
223
|
}
|
|
370
|
-
function
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
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";
|
|
375
245
|
}
|
|
376
|
-
return
|
|
377
|
-
readonly: config.readonly ?? false,
|
|
378
|
-
fileMustExist: !isMemory && !config.createIfMissing
|
|
379
|
-
});
|
|
246
|
+
return sawHost ? "mismatch" : "unknown";
|
|
380
247
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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);
|
|
385
261
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
label;
|
|
389
|
-
async query(sql2) {
|
|
390
|
-
let statement;
|
|
391
|
-
try {
|
|
392
|
-
statement = this.db.prepare(sql2);
|
|
393
|
-
} catch (err) {
|
|
394
|
-
if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
|
|
395
|
-
this.db.exec(sql2);
|
|
396
|
-
return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
|
|
397
|
-
}
|
|
398
|
-
throw err;
|
|
399
|
-
}
|
|
400
|
-
if (!statement.reader) {
|
|
401
|
-
const info = statement.run();
|
|
402
|
-
return {
|
|
403
|
-
fields: [],
|
|
404
|
-
records: [],
|
|
405
|
-
affectedRows: info.changes,
|
|
406
|
-
insertId: Number(info.lastInsertRowid),
|
|
407
|
-
isResultQuery: false
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
const columns = statement.columns();
|
|
411
|
-
const records = statement.raw(true).all();
|
|
412
|
-
const fields = columns.map((col, index) => {
|
|
413
|
-
const declared = normalizeDeclaredType(col.type);
|
|
414
|
-
const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
|
|
415
|
-
return {
|
|
416
|
-
name: col.name,
|
|
417
|
-
orgName: col.column ?? col.name,
|
|
418
|
-
type,
|
|
419
|
-
table: col.table ?? "",
|
|
420
|
-
orgTable: col.table ?? "",
|
|
421
|
-
db: col.database ?? "",
|
|
422
|
-
maxLength: 0
|
|
423
|
-
};
|
|
424
|
-
});
|
|
425
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
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);
|
|
426
264
|
}
|
|
427
|
-
|
|
428
|
-
|
|
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) {
|
|
429
283
|
try {
|
|
430
|
-
|
|
284
|
+
resolved.privateKey = await readFile2(file, "utf8");
|
|
285
|
+
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
286
|
+
break;
|
|
431
287
|
} catch {
|
|
432
288
|
}
|
|
433
289
|
}
|
|
434
|
-
|
|
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
|
+
}
|
|
435
296
|
}
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
+
}
|
|
441
317
|
}
|
|
442
|
-
return null;
|
|
443
318
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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)));
|
|
450
365
|
try {
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
},
|
|
456
|
-
parseConfig(body) {
|
|
457
|
-
const file = typeof body.file === "string" ? body.file.trim() : "";
|
|
458
|
-
if (!file) {
|
|
459
|
-
throw new Error("file is required");
|
|
366
|
+
client.connect(connectConfig);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
reject(describeSshError(err, ssh));
|
|
460
369
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
}
|
|
469
421
|
|
|
470
|
-
// src/
|
|
471
|
-
import
|
|
472
|
-
|
|
422
|
+
// src/tunneling.ts
|
|
423
|
+
import { randomUUID } from "crypto";
|
|
424
|
+
|
|
425
|
+
// src/drivers/mysql.ts
|
|
426
|
+
import mysql from "mysql2/promise";
|
|
427
|
+
var MYSQL_TYPES = {
|
|
428
|
+
0: "decimal",
|
|
429
|
+
1: "tinyint",
|
|
430
|
+
2: "smallint",
|
|
431
|
+
3: "int",
|
|
432
|
+
4: "float",
|
|
433
|
+
5: "double",
|
|
434
|
+
6: "null",
|
|
435
|
+
7: "timestamp",
|
|
436
|
+
8: "bigint",
|
|
437
|
+
9: "mediumint",
|
|
438
|
+
10: "date",
|
|
439
|
+
11: "time",
|
|
440
|
+
12: "datetime",
|
|
441
|
+
13: "year",
|
|
442
|
+
14: "newdate",
|
|
443
|
+
15: "varchar",
|
|
444
|
+
16: "bit",
|
|
445
|
+
245: "json",
|
|
446
|
+
246: "newdecimal",
|
|
447
|
+
247: "enum",
|
|
448
|
+
248: "set",
|
|
449
|
+
249: "tinyblob",
|
|
450
|
+
250: "mediumblob",
|
|
451
|
+
251: "longblob",
|
|
452
|
+
252: "blob",
|
|
453
|
+
253: "varstring",
|
|
454
|
+
254: "string",
|
|
455
|
+
255: "geometry"
|
|
456
|
+
};
|
|
457
|
+
function getTypeName(typeId) {
|
|
458
|
+
return MYSQL_TYPES[typeId] ?? "unknown";
|
|
459
|
+
}
|
|
460
|
+
function toConnectionOptions(config) {
|
|
473
461
|
return {
|
|
474
|
-
|
|
462
|
+
host: config.host,
|
|
475
463
|
port: config.port,
|
|
476
464
|
user: config.user,
|
|
477
465
|
password: config.password,
|
|
478
|
-
database: config.database,
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
encrypt: config.ssl === true,
|
|
483
|
-
trustServerCertificate: true,
|
|
484
|
-
// Keep date/time values as sent by the server rather than shifting to UTC.
|
|
485
|
-
useUTC: false,
|
|
486
|
-
enableArithAbort: true
|
|
487
|
-
},
|
|
488
|
-
requestTimeout: 0
|
|
466
|
+
database: config.database || void 0,
|
|
467
|
+
dateStrings: true,
|
|
468
|
+
charset: config.charset || "utf8mb4",
|
|
469
|
+
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0
|
|
489
470
|
};
|
|
490
471
|
}
|
|
491
|
-
var
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
function normalizeValue(value) {
|
|
498
|
-
if (value instanceof Date) return formatDate(value);
|
|
499
|
-
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
500
|
-
return value;
|
|
501
|
-
}
|
|
502
|
-
var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
|
|
503
|
-
var MssqlDriver = class {
|
|
504
|
-
constructor(pool, config) {
|
|
505
|
-
this.pool = pool;
|
|
506
|
-
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
472
|
+
var USE_STATEMENT = /^\s*USE\s/i;
|
|
473
|
+
var MySqlDriver = class {
|
|
474
|
+
constructor(connection, config) {
|
|
475
|
+
this.connection = connection;
|
|
476
|
+
this.label = `${config.user}@${config.host}:${config.port}`;
|
|
477
|
+
this.currentDatabase = config.database ?? null;
|
|
507
478
|
}
|
|
508
|
-
|
|
509
|
-
type = "
|
|
479
|
+
connection;
|
|
480
|
+
type = "mysql";
|
|
510
481
|
label;
|
|
511
|
-
/** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
|
|
512
|
-
sources = /* @__PURE__ */ new Map();
|
|
513
482
|
/**
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
483
|
+
* Default schema the session is currently in, or null when unknown (after
|
|
484
|
+
* the client ran its own `USE`). Tracked so the schema is switched with a
|
|
485
|
+
* plain `USE` only when it actually changes: `changeUser()` would reset the
|
|
486
|
+
* whole session on every call, silently rolling back open transactions and
|
|
487
|
+
* dropping temporary tables and user variables.
|
|
517
488
|
*/
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const req = this.pool.request();
|
|
524
|
-
req.input("sql", sql.NVarChar(sql.MAX), text);
|
|
525
|
-
const res = await req.query(
|
|
526
|
-
"SELECT name, source_schema, source_table, source_column, system_type_name FROM sys.dm_exec_describe_first_result_set(@sql, NULL, 1) WHERE is_hidden = 0 ORDER BY column_ordinal"
|
|
527
|
-
);
|
|
528
|
-
result = res.recordset.map((r) => ({
|
|
529
|
-
schema: r.source_schema ?? "",
|
|
530
|
-
table: r.source_table ?? "",
|
|
531
|
-
column: r.source_column ?? "",
|
|
532
|
-
type: (r.system_type_name ?? "").toLowerCase()
|
|
533
|
-
}));
|
|
534
|
-
} catch {
|
|
535
|
-
result = null;
|
|
489
|
+
currentDatabase;
|
|
490
|
+
async query(sql2, database) {
|
|
491
|
+
if (database && database !== this.currentDatabase) {
|
|
492
|
+
await this.connection.query(`USE ${mysql.escapeId(database)}`);
|
|
493
|
+
this.currentDatabase = database;
|
|
536
494
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return {
|
|
555
|
-
name: col.name,
|
|
556
|
-
orgName: src?.column || col.name,
|
|
557
|
-
type: src?.type || typeName.toLowerCase(),
|
|
558
|
-
table: src?.table ?? "",
|
|
559
|
-
orgTable: src?.table ?? "",
|
|
560
|
-
db: src?.schema ?? "",
|
|
561
|
-
maxLength: typeof col.length === "number" ? col.length : 0
|
|
562
|
-
};
|
|
563
|
-
});
|
|
564
|
-
const records = last.map((row) => row.map(normalizeValue));
|
|
565
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
495
|
+
const [results, fields] = await this.connection.query(sql2);
|
|
496
|
+
if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
|
|
497
|
+
if (Array.isArray(results) && fields) {
|
|
498
|
+
const fieldPackets = fields;
|
|
499
|
+
const mappedFields = fieldPackets.map((field) => ({
|
|
500
|
+
name: field.name,
|
|
501
|
+
orgName: field.orgName ?? field.name,
|
|
502
|
+
type: getTypeName(field.type ?? 253),
|
|
503
|
+
table: field.table ?? "",
|
|
504
|
+
orgTable: field.orgTable ?? "",
|
|
505
|
+
db: field.db ?? "",
|
|
506
|
+
maxLength: field.length ?? 0
|
|
507
|
+
}));
|
|
508
|
+
const records = results.map(
|
|
509
|
+
(row) => fieldPackets.map((field) => row[field.name])
|
|
510
|
+
);
|
|
511
|
+
return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
566
512
|
}
|
|
513
|
+
const header = results;
|
|
567
514
|
return {
|
|
568
515
|
fields: [],
|
|
569
516
|
records: [],
|
|
570
|
-
affectedRows:
|
|
571
|
-
insertId: 0,
|
|
517
|
+
affectedRows: header.affectedRows ?? 0,
|
|
518
|
+
insertId: header.insertId ?? 0,
|
|
572
519
|
isResultQuery: false
|
|
573
520
|
};
|
|
574
521
|
}
|
|
575
522
|
async close() {
|
|
576
|
-
await this.
|
|
523
|
+
await this.connection.end();
|
|
577
524
|
}
|
|
578
525
|
};
|
|
579
|
-
var
|
|
526
|
+
var mysqlDriverFactory = {
|
|
580
527
|
async open(config) {
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
return new MssqlDriver(pool, config);
|
|
528
|
+
const connection = await mysql.createConnection(toConnectionOptions(config));
|
|
529
|
+
return new MySqlDriver(connection, config);
|
|
584
530
|
},
|
|
585
531
|
async test(config) {
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
532
|
+
const connection = await mysql.createConnection({
|
|
533
|
+
...toConnectionOptions(config),
|
|
534
|
+
database: void 0
|
|
535
|
+
});
|
|
536
|
+
await connection.end();
|
|
589
537
|
},
|
|
590
538
|
parseConfig(body) {
|
|
591
539
|
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
@@ -594,146 +542,191 @@ var mssqlDriverFactory = {
|
|
|
594
542
|
throw new Error("host and user are required");
|
|
595
543
|
}
|
|
596
544
|
return {
|
|
597
|
-
driver: "
|
|
545
|
+
driver: "mysql",
|
|
598
546
|
host,
|
|
599
|
-
port: Number(body.port) ||
|
|
547
|
+
port: Number(body.port) || 3306,
|
|
600
548
|
user,
|
|
601
549
|
password: typeof body.password === "string" ? body.password : "",
|
|
602
|
-
database: typeof body.database === "string" && body.database ? body.database :
|
|
603
|
-
|
|
550
|
+
database: typeof body.database === "string" && body.database ? body.database : void 0,
|
|
551
|
+
charset: typeof body.charset === "string" && body.charset ? body.charset : void 0,
|
|
552
|
+
ssl: body.ssl === true,
|
|
553
|
+
sslVerify: body.sslVerify === true
|
|
604
554
|
};
|
|
605
555
|
}
|
|
606
556
|
};
|
|
607
557
|
|
|
608
|
-
// src/drivers/
|
|
609
|
-
import
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
558
|
+
// src/drivers/postgres.ts
|
|
559
|
+
import pg from "pg";
|
|
560
|
+
var TEXT_OIDS = [
|
|
561
|
+
20,
|
|
562
|
+
// int8
|
|
563
|
+
1700,
|
|
564
|
+
// numeric
|
|
565
|
+
1082,
|
|
566
|
+
// date
|
|
567
|
+
1114,
|
|
568
|
+
// timestamp
|
|
569
|
+
1184,
|
|
570
|
+
// timestamptz
|
|
571
|
+
1083,
|
|
572
|
+
// time
|
|
573
|
+
1266,
|
|
574
|
+
// timetz
|
|
575
|
+
1186
|
|
576
|
+
// interval
|
|
627
577
|
];
|
|
628
|
-
var
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
578
|
+
var types = new pg.TypeOverrides();
|
|
579
|
+
for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
|
|
580
|
+
var BUILTIN_TYPES = {
|
|
581
|
+
16: "bool",
|
|
582
|
+
17: "bytea",
|
|
583
|
+
18: "char",
|
|
584
|
+
19: "name",
|
|
585
|
+
20: "int8",
|
|
586
|
+
21: "int2",
|
|
587
|
+
23: "int4",
|
|
588
|
+
25: "text",
|
|
589
|
+
26: "oid",
|
|
590
|
+
114: "json",
|
|
591
|
+
142: "xml",
|
|
592
|
+
700: "float4",
|
|
593
|
+
701: "float8",
|
|
594
|
+
1042: "bpchar",
|
|
595
|
+
1043: "varchar",
|
|
596
|
+
1082: "date",
|
|
597
|
+
1083: "time",
|
|
598
|
+
1114: "timestamp",
|
|
599
|
+
1184: "timestamptz",
|
|
600
|
+
1186: "interval",
|
|
601
|
+
1266: "timetz",
|
|
602
|
+
1700: "numeric",
|
|
603
|
+
2950: "uuid",
|
|
604
|
+
3802: "jsonb",
|
|
605
|
+
1560: "bit",
|
|
606
|
+
1562: "varbit",
|
|
607
|
+
600: "point",
|
|
608
|
+
869: "inet",
|
|
609
|
+
650: "cidr",
|
|
610
|
+
829: "macaddr",
|
|
611
|
+
1007: "_int4",
|
|
612
|
+
1009: "_text"
|
|
613
|
+
};
|
|
614
|
+
function toClientConfig(config) {
|
|
615
|
+
return {
|
|
616
|
+
host: config.host,
|
|
617
|
+
port: config.port,
|
|
618
|
+
user: config.user,
|
|
619
|
+
password: config.password,
|
|
620
|
+
database: config.database,
|
|
621
|
+
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
|
|
622
|
+
types
|
|
623
|
+
};
|
|
645
624
|
}
|
|
646
|
-
var
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
this.
|
|
625
|
+
var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
|
|
626
|
+
var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
627
|
+
var PostgresDriver = class {
|
|
628
|
+
constructor(client, config) {
|
|
629
|
+
this.client = client;
|
|
630
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
651
631
|
}
|
|
652
|
-
|
|
653
|
-
type = "
|
|
632
|
+
client;
|
|
633
|
+
type = "postgres";
|
|
654
634
|
label;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
635
|
+
/**
|
|
636
|
+
* Schema currently first in `search_path`, or null when unknown (after the
|
|
637
|
+
* client set it itself). The app's "database" for a Postgres connection is
|
|
638
|
+
* a schema of the bound database, switched with `SET search_path`.
|
|
639
|
+
*/
|
|
640
|
+
currentSchema = null;
|
|
641
|
+
typeNames = { ...BUILTIN_TYPES };
|
|
642
|
+
/**
|
|
643
|
+
* Result fields name their source only by OID (`tableID` / `columnID`);
|
|
644
|
+
* the frontend needs schema/table/column names to decide whether a grid is
|
|
645
|
+
* editable and to build UPDATEs, so they are resolved here and cached.
|
|
646
|
+
*/
|
|
647
|
+
relations = /* @__PURE__ */ new Map();
|
|
648
|
+
async loadTypeNames() {
|
|
649
|
+
try {
|
|
650
|
+
const res = await this.client.query(
|
|
651
|
+
"SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
|
|
652
|
+
);
|
|
653
|
+
for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
|
|
654
|
+
} catch {
|
|
655
|
+
}
|
|
661
656
|
}
|
|
662
|
-
async
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
if (cached !== void 0) return cached;
|
|
666
|
-
let out = null;
|
|
657
|
+
async resolveRelations(tableIds) {
|
|
658
|
+
const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
|
|
659
|
+
if (missing.length === 0) return;
|
|
667
660
|
try {
|
|
668
|
-
const res = await this.
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
661
|
+
const res = await this.client.query(
|
|
662
|
+
`SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
|
|
663
|
+
FROM pg_class c
|
|
664
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
665
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
666
|
+
WHERE c.oid = ANY($1::oid[])`,
|
|
667
|
+
[missing]
|
|
672
668
|
);
|
|
673
|
-
|
|
669
|
+
for (const row of res.rows) {
|
|
670
|
+
const oid = Number(row.oid);
|
|
671
|
+
let rel = this.relations.get(oid);
|
|
672
|
+
if (!rel) {
|
|
673
|
+
rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
|
|
674
|
+
this.relations.set(oid, rel);
|
|
675
|
+
}
|
|
676
|
+
rel.columns.set(Number(row.attnum), row.attname);
|
|
677
|
+
}
|
|
674
678
|
} catch {
|
|
675
|
-
out = null;
|
|
676
679
|
}
|
|
677
|
-
if (this.columns.size > 200) this.columns.clear();
|
|
678
|
-
this.columns.set(key, out);
|
|
679
|
-
return out;
|
|
680
680
|
}
|
|
681
681
|
async query(sql2, database) {
|
|
682
682
|
if (database && database !== this.currentSchema) {
|
|
683
|
-
await this.
|
|
683
|
+
await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
|
|
684
684
|
this.currentSchema = database;
|
|
685
685
|
}
|
|
686
|
-
const
|
|
687
|
-
if (
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if (END_TX.test(text)) this.manualTx = false;
|
|
695
|
-
if (res.metaData && res.metaData.length > 0) {
|
|
696
|
-
const source = singleSourceTable(text);
|
|
697
|
-
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
698
|
-
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
699
|
-
const fields = res.metaData.map((col) => {
|
|
700
|
-
const fromTable = known?.has(col.name) ?? false;
|
|
686
|
+
const result = await this.client.query({ text: sql2, rowMode: "array" });
|
|
687
|
+
if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
|
|
688
|
+
const results = Array.isArray(result) ? result : [result];
|
|
689
|
+
const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
|
|
690
|
+
if (last.fields.length > 0) {
|
|
691
|
+
await this.resolveRelations(last.fields.map((f) => f.tableID));
|
|
692
|
+
const fields = last.fields.map((field) => {
|
|
693
|
+
const rel = this.relations.get(field.tableID);
|
|
701
694
|
return {
|
|
702
|
-
name:
|
|
703
|
-
orgName:
|
|
704
|
-
type:
|
|
705
|
-
table:
|
|
706
|
-
orgTable:
|
|
707
|
-
db:
|
|
708
|
-
maxLength:
|
|
695
|
+
name: field.name,
|
|
696
|
+
orgName: rel?.columns.get(field.columnID) ?? field.name,
|
|
697
|
+
type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
|
|
698
|
+
table: rel?.table ?? "",
|
|
699
|
+
orgTable: rel?.table ?? "",
|
|
700
|
+
db: rel?.schema ?? "",
|
|
701
|
+
maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
|
|
709
702
|
};
|
|
710
703
|
});
|
|
711
|
-
|
|
712
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
704
|
+
return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
713
705
|
}
|
|
714
|
-
return {
|
|
706
|
+
return {
|
|
707
|
+
fields: [],
|
|
708
|
+
records: [],
|
|
709
|
+
affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
|
|
710
|
+
insertId: 0,
|
|
711
|
+
isResultQuery: false
|
|
712
|
+
};
|
|
715
713
|
}
|
|
716
714
|
async close() {
|
|
717
|
-
await this.
|
|
715
|
+
await this.client.end();
|
|
718
716
|
}
|
|
719
717
|
};
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
var oracleDriverFactory = {
|
|
728
|
-
async open(config) {
|
|
729
|
-
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
730
|
-
const driver = new OracleDriver(conn, config);
|
|
731
|
-
await driver.init();
|
|
718
|
+
var postgresDriverFactory = {
|
|
719
|
+
async open(config) {
|
|
720
|
+
const client = new pg.Client(toClientConfig(config));
|
|
721
|
+
await client.connect();
|
|
722
|
+
const driver = new PostgresDriver(client, config);
|
|
723
|
+
await driver.loadTypeNames();
|
|
732
724
|
return driver;
|
|
733
725
|
},
|
|
734
726
|
async test(config) {
|
|
735
|
-
const
|
|
736
|
-
await
|
|
727
|
+
const client = new pg.Client(toClientConfig(config));
|
|
728
|
+
await client.connect();
|
|
729
|
+
await client.end();
|
|
737
730
|
},
|
|
738
731
|
parseConfig(body) {
|
|
739
732
|
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
@@ -742,525 +735,694 @@ var oracleDriverFactory = {
|
|
|
742
735
|
throw new Error("host and user are required");
|
|
743
736
|
}
|
|
744
737
|
return {
|
|
745
|
-
driver: "
|
|
738
|
+
driver: "postgres",
|
|
746
739
|
host,
|
|
747
|
-
port: Number(body.port) ||
|
|
740
|
+
port: Number(body.port) || 5432,
|
|
748
741
|
user,
|
|
749
742
|
password: typeof body.password === "string" ? body.password : "",
|
|
750
|
-
|
|
743
|
+
database: typeof body.database === "string" && body.database ? body.database : "postgres",
|
|
744
|
+
ssl: body.ssl === true,
|
|
745
|
+
sslVerify: body.sslVerify === true
|
|
751
746
|
};
|
|
752
747
|
}
|
|
753
748
|
};
|
|
754
749
|
|
|
755
|
-
// src/drivers/
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
status;
|
|
763
|
-
};
|
|
764
|
-
var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
|
|
765
|
-
var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
766
|
-
var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
767
|
-
function singleSourceTable2(sql2) {
|
|
768
|
-
const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
769
|
-
if (!m) return null;
|
|
770
|
-
const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
|
|
771
|
-
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
772
|
-
const raw = m[1];
|
|
773
|
-
const first = raw[0];
|
|
774
|
-
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
775
|
-
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
776
|
-
if (first === "[") return raw.slice(1, -1);
|
|
777
|
-
return raw;
|
|
778
|
-
}
|
|
779
|
-
function normalizeDeclaredType2(declared) {
|
|
780
|
-
if (typeof declared !== "string") return null;
|
|
750
|
+
// src/drivers/sqlite.ts
|
|
751
|
+
import Database from "better-sqlite3";
|
|
752
|
+
import { existsSync, realpathSync } from "fs";
|
|
753
|
+
import { homedir as homedir3 } from "os";
|
|
754
|
+
import { basename as basename3, dirname as dirname2, join as join3, relative, resolve, sep } from "path";
|
|
755
|
+
function normalizeDeclaredType(declared) {
|
|
756
|
+
if (!declared) return null;
|
|
781
757
|
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
782
758
|
return base || null;
|
|
783
759
|
}
|
|
784
|
-
function
|
|
760
|
+
function inferTypeFromValue(value) {
|
|
785
761
|
if (value === null || value === void 0) return "null";
|
|
786
762
|
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
787
|
-
if (
|
|
763
|
+
if (typeof value === "bigint") return "bigint";
|
|
764
|
+
if (Buffer.isBuffer(value)) return "blob";
|
|
788
765
|
return "text";
|
|
789
766
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
767
|
+
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome2(process.env.PLANCHE_SQLITE_DIR)) : null;
|
|
768
|
+
var DEFAULT_ROOT = homedir3();
|
|
769
|
+
var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
|
|
770
|
+
var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
|
|
771
|
+
var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
|
|
772
|
+
function expandHome2(path) {
|
|
773
|
+
return path === "~" || path.startsWith("~/") ? resolve(homedir3(), path.slice(2)) : path;
|
|
796
774
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
headers;
|
|
802
|
-
/** Declared column types per table, for result-source resolution. */
|
|
803
|
-
columns = /* @__PURE__ */ new Map();
|
|
804
|
-
constructor(config) {
|
|
805
|
-
this.label = `d1:${config.databaseId}`;
|
|
806
|
-
this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
|
|
807
|
-
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
808
|
-
}
|
|
809
|
-
async raw(sql2) {
|
|
810
|
-
let res;
|
|
811
|
-
try {
|
|
812
|
-
res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }) });
|
|
813
|
-
} catch (err) {
|
|
814
|
-
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
815
|
-
}
|
|
816
|
-
let body = null;
|
|
817
|
-
try {
|
|
818
|
-
body = await res.json();
|
|
819
|
-
} catch {
|
|
820
|
-
}
|
|
821
|
-
if (!res.ok || !body?.success) {
|
|
822
|
-
const message = body?.errors?.map((e) => e.message).join("; ") || `Cloudflare API responded ${res.status}`;
|
|
823
|
-
throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
|
|
824
|
-
}
|
|
825
|
-
const result = body.result?.[0];
|
|
826
|
-
if (!result) throw new D1Error("Cloudflare API returned no result");
|
|
827
|
-
return result;
|
|
828
|
-
}
|
|
829
|
-
async tableColumns(table) {
|
|
830
|
-
const cached = this.columns.get(table);
|
|
831
|
-
if (cached !== void 0) return cached;
|
|
832
|
-
let out = null;
|
|
775
|
+
function realish(file) {
|
|
776
|
+
let probe = file;
|
|
777
|
+
const rest = [];
|
|
778
|
+
for (; ; ) {
|
|
833
779
|
try {
|
|
834
|
-
|
|
835
|
-
const cols = info.results?.columns ?? [];
|
|
836
|
-
const name = cols.indexOf("name");
|
|
837
|
-
const type = cols.indexOf("type");
|
|
838
|
-
const rows = info.results?.rows ?? [];
|
|
839
|
-
if (rows.length > 0 && name >= 0) {
|
|
840
|
-
out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
|
|
841
|
-
}
|
|
780
|
+
return rest.length ? join3(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
|
|
842
781
|
} catch {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
return out;
|
|
848
|
-
}
|
|
849
|
-
async query(sql2) {
|
|
850
|
-
if (DATABASE_LIST.test(sql2)) {
|
|
851
|
-
return {
|
|
852
|
-
fields: [
|
|
853
|
-
{ name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
854
|
-
{ name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
855
|
-
{ name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
|
|
856
|
-
],
|
|
857
|
-
records: [[0, "main", ""]],
|
|
858
|
-
affectedRows: 0,
|
|
859
|
-
insertId: 0,
|
|
860
|
-
isResultQuery: true
|
|
861
|
-
};
|
|
862
|
-
}
|
|
863
|
-
const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
|
|
864
|
-
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
|
|
865
|
-
throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
|
|
866
|
-
}
|
|
867
|
-
if (this.isDdl(text)) this.columns.clear();
|
|
868
|
-
const result = await this.raw(text);
|
|
869
|
-
const columns = result.results?.columns ?? [];
|
|
870
|
-
const records = result.results?.rows ?? [];
|
|
871
|
-
if (columns.length === 0 && !RESULT_KIND.test(text)) {
|
|
872
|
-
return {
|
|
873
|
-
fields: [],
|
|
874
|
-
records: [],
|
|
875
|
-
affectedRows: result.meta?.changes ?? 0,
|
|
876
|
-
insertId: result.meta?.last_row_id ?? 0,
|
|
877
|
-
isResultQuery: false
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
881
|
-
const known = source ? await this.tableColumns(source) : null;
|
|
882
|
-
const fields = columns.map((name, index) => {
|
|
883
|
-
const fromTable = known?.has(name) ?? false;
|
|
884
|
-
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
885
|
-
return {
|
|
886
|
-
name,
|
|
887
|
-
orgName: name,
|
|
888
|
-
type,
|
|
889
|
-
table: fromTable && source ? source : "",
|
|
890
|
-
orgTable: fromTable && source ? source : "",
|
|
891
|
-
db: fromTable ? "main" : "",
|
|
892
|
-
maxLength: 0
|
|
893
|
-
};
|
|
894
|
-
});
|
|
895
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
896
|
-
}
|
|
897
|
-
isDdl(sql2) {
|
|
898
|
-
return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
|
|
899
|
-
}
|
|
900
|
-
async close() {
|
|
901
|
-
this.columns.clear();
|
|
902
|
-
}
|
|
903
|
-
};
|
|
904
|
-
var d1DriverFactory = {
|
|
905
|
-
async open(config) {
|
|
906
|
-
const driver = new D1Driver(config);
|
|
907
|
-
await driver.raw("SELECT 1");
|
|
908
|
-
return driver;
|
|
909
|
-
},
|
|
910
|
-
async test(config) {
|
|
911
|
-
await new D1Driver(config).raw("SELECT 1");
|
|
912
|
-
},
|
|
913
|
-
parseConfig(body) {
|
|
914
|
-
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
915
|
-
const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
|
|
916
|
-
const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
|
|
917
|
-
if (!accountId || !databaseId || !apiToken) {
|
|
918
|
-
throw new Error("accountId, databaseId and apiToken are required");
|
|
782
|
+
const parent = dirname2(probe);
|
|
783
|
+
if (parent === probe) return file;
|
|
784
|
+
rest.unshift(basename3(probe));
|
|
785
|
+
probe = parent;
|
|
919
786
|
}
|
|
920
|
-
return { driver: "d1", accountId, databaseId, apiToken };
|
|
921
787
|
}
|
|
922
|
-
};
|
|
923
|
-
|
|
924
|
-
// src/ssh-tunnel.ts
|
|
925
|
-
import { createServer } from "net";
|
|
926
|
-
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
927
|
-
import { createHash } from "crypto";
|
|
928
|
-
import { homedir as homedir2 } from "os";
|
|
929
|
-
import { join as join2 } from "path";
|
|
930
|
-
import { Client } from "ssh2";
|
|
931
|
-
|
|
932
|
-
// src/ssh-config.ts
|
|
933
|
-
import { readFile } from "fs/promises";
|
|
934
|
-
import { homedir } from "os";
|
|
935
|
-
import { dirname, isAbsolute as isAbsolute2, join } from "path";
|
|
936
|
-
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
937
|
-
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
|
|
938
|
-
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
939
|
-
(f) => join(homedir(), ".ssh", f)
|
|
940
|
-
);
|
|
941
|
-
function expandHome(path) {
|
|
942
|
-
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
943
788
|
}
|
|
944
|
-
function
|
|
945
|
-
|
|
946
|
-
return new RegExp(`^${escaped}$`, "i");
|
|
789
|
+
function isInside(path, root) {
|
|
790
|
+
return path === root || path.startsWith(root + sep);
|
|
947
791
|
}
|
|
948
|
-
function
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
if (!
|
|
954
|
-
|
|
955
|
-
|
|
792
|
+
function resolveFile(file, forCreate = false) {
|
|
793
|
+
if (file === ":memory:") return file;
|
|
794
|
+
if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
|
|
795
|
+
const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome2(file)));
|
|
796
|
+
if (SQLITE_DIR) {
|
|
797
|
+
if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
|
|
798
|
+
} else {
|
|
799
|
+
const root = realish(DEFAULT_ROOT);
|
|
800
|
+
if (!isInside(resolved, root)) throw new Error("SQLite files outside your home directory need PLANCHE_SQLITE_DIR on the agent");
|
|
801
|
+
const parts = relative(root, resolved).split(sep);
|
|
802
|
+
if (parts.some((part) => part.startsWith("."))) throw new Error("SQLite files in hidden directories need PLANCHE_SQLITE_DIR on the agent");
|
|
803
|
+
if (DENIED_UNDER_HOME.some((d) => d.toLowerCase() === parts[0].toLowerCase())) throw new Error(`SQLite files under ~/${parts[0]} need PLANCHE_SQLITE_DIR on the agent`);
|
|
804
|
+
}
|
|
805
|
+
if (forCreate && !DB_EXTENSIONS.test(resolved)) {
|
|
806
|
+
throw new Error("A new SQLite file must end with .db, .db3, .s3db, .sl3, .sqlite or .sqlite3");
|
|
956
807
|
}
|
|
957
|
-
return
|
|
958
|
-
}
|
|
959
|
-
function tokenize(line) {
|
|
960
|
-
const tokens = [];
|
|
961
|
-
const re = /"([^"]*)"|(\S+)/g;
|
|
962
|
-
let m;
|
|
963
|
-
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
964
|
-
return tokens;
|
|
965
|
-
}
|
|
966
|
-
function parseLine(line) {
|
|
967
|
-
const trimmed = line.trim();
|
|
968
|
-
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
969
|
-
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
970
|
-
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
971
|
-
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
808
|
+
return resolved;
|
|
972
809
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
} catch {
|
|
979
|
-
return [];
|
|
810
|
+
function openDatabase(config) {
|
|
811
|
+
const file = resolveFile(config.file, config.createIfMissing === true);
|
|
812
|
+
const isMemory = file === ":memory:";
|
|
813
|
+
if (!isMemory && !config.createIfMissing && !existsSync(file)) {
|
|
814
|
+
throw new Error(`SQLite file not found: ${config.file}`);
|
|
980
815
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
816
|
+
const db = new Database(file, {
|
|
817
|
+
readonly: config.readonly ?? false,
|
|
818
|
+
fileMustExist: !isMemory && !config.createIfMissing
|
|
819
|
+
});
|
|
820
|
+
db.pragma("trusted_schema = OFF");
|
|
821
|
+
if (config.readonly) db.pragma("query_only = ON");
|
|
822
|
+
return db;
|
|
823
|
+
}
|
|
824
|
+
function stripLiteralsAndComments(sql2) {
|
|
825
|
+
let out = "";
|
|
826
|
+
let i = 0;
|
|
827
|
+
while (i < sql2.length) {
|
|
828
|
+
const c = sql2[i];
|
|
829
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
830
|
+
let j = i + 1;
|
|
831
|
+
while (j < sql2.length) {
|
|
832
|
+
if (sql2[j] === c) {
|
|
833
|
+
if (sql2[j + 1] === c) j += 2;
|
|
834
|
+
else {
|
|
835
|
+
j++;
|
|
836
|
+
break;
|
|
1002
837
|
}
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1005
|
-
if (!blockActive) continue;
|
|
1006
|
-
const value = d.args[0];
|
|
1007
|
-
if (value === void 0) continue;
|
|
1008
|
-
switch (d.keyword) {
|
|
1009
|
-
case "hostname":
|
|
1010
|
-
result.hostName ??= value.replace(/%h/g, host);
|
|
1011
|
-
break;
|
|
1012
|
-
case "port":
|
|
1013
|
-
result.port ??= Number(value) || void 0;
|
|
1014
|
-
break;
|
|
1015
|
-
case "user":
|
|
1016
|
-
result.user ??= value;
|
|
1017
|
-
break;
|
|
1018
|
-
case "identityfile":
|
|
1019
|
-
for (const f of d.args) result.identityFiles.push(expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host)));
|
|
1020
|
-
break;
|
|
1021
|
-
case "identityagent":
|
|
1022
|
-
result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
|
|
1023
|
-
break;
|
|
1024
|
-
case "proxyjump":
|
|
1025
|
-
result.proxyJump ??= value;
|
|
1026
|
-
break;
|
|
838
|
+
} else j++;
|
|
1027
839
|
}
|
|
840
|
+
out += c + c;
|
|
841
|
+
i = j;
|
|
842
|
+
continue;
|
|
1028
843
|
}
|
|
844
|
+
if (c === "[") {
|
|
845
|
+
const j = sql2.indexOf("]", i);
|
|
846
|
+
i = j < 0 ? sql2.length : j + 1;
|
|
847
|
+
out += "[]";
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (c === "-" && sql2[i + 1] === "-") {
|
|
851
|
+
const j = sql2.indexOf("\n", i);
|
|
852
|
+
i = j < 0 ? sql2.length : j;
|
|
853
|
+
out += " ";
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (c === "/" && sql2[i + 1] === "*") {
|
|
857
|
+
const j = sql2.indexOf("*/", i + 2);
|
|
858
|
+
i = j < 0 ? sql2.length : j + 2;
|
|
859
|
+
out += " ";
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
out += c;
|
|
863
|
+
i++;
|
|
1029
864
|
}
|
|
1030
|
-
|
|
1031
|
-
return result;
|
|
865
|
+
return out;
|
|
1032
866
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
867
|
+
function assertNoFileStatements(sql2) {
|
|
868
|
+
const text = stripLiteralsAndComments(sql2);
|
|
869
|
+
if (!ALLOW_ATTACH && /\bATTACH\b/i.test(text)) {
|
|
870
|
+
throw new Error("ATTACH DATABASE is disabled on the agent (set PLANCHE_SQLITE_ATTACH=1 to allow it)");
|
|
871
|
+
}
|
|
872
|
+
if (/\bVACUUM\b[^;]*\bINTO\b/i.test(text)) {
|
|
873
|
+
throw new Error("VACUUM INTO is not available through the agent");
|
|
874
|
+
}
|
|
875
|
+
if (/\bPRAGMA\s+temp_store_directory\b/i.test(text)) {
|
|
876
|
+
throw new Error("PRAGMA temp_store_directory is not available through the agent");
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
var SqliteDriver = class {
|
|
880
|
+
constructor(db, config) {
|
|
881
|
+
this.db = db;
|
|
882
|
+
this.label = config.file;
|
|
883
|
+
}
|
|
884
|
+
db;
|
|
885
|
+
type = "sqlite";
|
|
886
|
+
label;
|
|
887
|
+
async query(sql2) {
|
|
888
|
+
assertNoFileStatements(sql2);
|
|
889
|
+
let statement;
|
|
890
|
+
try {
|
|
891
|
+
statement = this.db.prepare(sql2);
|
|
892
|
+
} catch (err) {
|
|
893
|
+
if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
|
|
894
|
+
this.db.exec(sql2);
|
|
895
|
+
return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
|
|
896
|
+
}
|
|
897
|
+
throw err;
|
|
898
|
+
}
|
|
899
|
+
if (!statement.reader) {
|
|
900
|
+
const info = statement.run();
|
|
901
|
+
return {
|
|
902
|
+
fields: [],
|
|
903
|
+
records: [],
|
|
904
|
+
affectedRows: info.changes,
|
|
905
|
+
insertId: Number(info.lastInsertRowid),
|
|
906
|
+
isResultQuery: false
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
const columns = statement.columns();
|
|
910
|
+
const records = statement.raw(true).all();
|
|
911
|
+
const fields = columns.map((col, index) => {
|
|
912
|
+
const declared = normalizeDeclaredType(col.type);
|
|
913
|
+
const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
|
|
914
|
+
return {
|
|
915
|
+
name: col.name,
|
|
916
|
+
orgName: col.column ?? col.name,
|
|
917
|
+
type,
|
|
918
|
+
table: col.table ?? "",
|
|
919
|
+
orgTable: col.table ?? "",
|
|
920
|
+
db: col.database ?? "",
|
|
921
|
+
maxLength: 0
|
|
922
|
+
};
|
|
923
|
+
});
|
|
924
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
925
|
+
}
|
|
926
|
+
async close() {
|
|
927
|
+
if (this.db.inTransaction) {
|
|
928
|
+
try {
|
|
929
|
+
this.db.exec("ROLLBACK");
|
|
930
|
+
} catch {
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
this.db.close();
|
|
1040
934
|
}
|
|
1041
|
-
status;
|
|
1042
935
|
};
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
function parseSshConfig(value) {
|
|
1048
|
-
if (value === void 0 || value === null) return void 0;
|
|
1049
|
-
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
1050
|
-
const body = value;
|
|
1051
|
-
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1052
|
-
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
1053
|
-
const auth = body.auth;
|
|
1054
|
-
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
1055
|
-
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
1056
|
-
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
936
|
+
function firstNonNull(records, index) {
|
|
937
|
+
for (const row of records) {
|
|
938
|
+
const value = row[index];
|
|
939
|
+
if (value !== null && value !== void 0) return value;
|
|
1057
940
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
var sqliteDriverFactory = {
|
|
944
|
+
async open(config) {
|
|
945
|
+
return new SqliteDriver(openDatabase(config), config);
|
|
946
|
+
},
|
|
947
|
+
async test(config) {
|
|
948
|
+
const db = openDatabase(config);
|
|
949
|
+
try {
|
|
950
|
+
db.prepare("SELECT 1").get();
|
|
951
|
+
} finally {
|
|
952
|
+
db.close();
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
parseConfig(body) {
|
|
956
|
+
const file = typeof body.file === "string" ? body.file.trim() : "";
|
|
957
|
+
if (!file) {
|
|
958
|
+
throw new Error("file is required");
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
driver: "sqlite",
|
|
962
|
+
file,
|
|
963
|
+
createIfMissing: body.createIfMissing === true,
|
|
964
|
+
readonly: body.readonly === true
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
// src/drivers/mssql.ts
|
|
970
|
+
import sql from "mssql";
|
|
971
|
+
function toPoolConfig(config) {
|
|
972
|
+
return {
|
|
973
|
+
server: config.host,
|
|
974
|
+
port: config.port,
|
|
975
|
+
user: config.user,
|
|
976
|
+
password: config.password,
|
|
977
|
+
database: config.database,
|
|
978
|
+
// One physical connection: manual transactions and USE need session affinity.
|
|
979
|
+
pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
|
|
980
|
+
options: {
|
|
981
|
+
encrypt: config.ssl === true,
|
|
982
|
+
trustServerCertificate: config.sslVerify !== true,
|
|
983
|
+
// Keep date/time values as sent by the server rather than shifting to UTC.
|
|
984
|
+
useUTC: false,
|
|
985
|
+
enableArithAbort: true
|
|
986
|
+
},
|
|
987
|
+
requestTimeout: 0
|
|
1067
988
|
};
|
|
1068
|
-
return config;
|
|
1069
989
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
990
|
+
var pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
991
|
+
function formatDate(d) {
|
|
992
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
993
|
+
const ms = d.getMilliseconds();
|
|
994
|
+
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
|
|
1073
995
|
}
|
|
1074
|
-
function
|
|
1075
|
-
|
|
1076
|
-
|
|
996
|
+
function normalizeValue(value) {
|
|
997
|
+
if (value instanceof Date) return formatDate(value);
|
|
998
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
999
|
+
return value;
|
|
1077
1000
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1001
|
+
var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
|
|
1002
|
+
var MssqlDriver = class {
|
|
1003
|
+
constructor(pool, config) {
|
|
1004
|
+
this.pool = pool;
|
|
1005
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
1006
|
+
}
|
|
1007
|
+
pool;
|
|
1008
|
+
type = "mssql";
|
|
1009
|
+
label;
|
|
1010
|
+
/** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
|
|
1011
|
+
sources = /* @__PURE__ */ new Map();
|
|
1012
|
+
/**
|
|
1013
|
+
* The `mssql` driver does not report which table a result column comes
|
|
1014
|
+
* from; ask the server to describe the statement instead so the grid can
|
|
1015
|
+
* decide whether it is editable and address rows by primary key.
|
|
1016
|
+
*/
|
|
1017
|
+
async describe(text) {
|
|
1018
|
+
const cached = this.sources.get(text);
|
|
1019
|
+
if (cached !== void 0) return cached;
|
|
1020
|
+
let result = null;
|
|
1021
|
+
try {
|
|
1022
|
+
const req = this.pool.request();
|
|
1023
|
+
req.input("sql", sql.NVarChar(sql.MAX), text);
|
|
1024
|
+
const res = await req.query(
|
|
1025
|
+
"SELECT name, source_schema, source_table, source_column, system_type_name FROM sys.dm_exec_describe_first_result_set(@sql, NULL, 1) WHERE is_hidden = 0 ORDER BY column_ordinal"
|
|
1026
|
+
);
|
|
1027
|
+
result = res.recordset.map((r) => ({
|
|
1028
|
+
schema: r.source_schema ?? "",
|
|
1029
|
+
table: r.source_table ?? "",
|
|
1030
|
+
column: r.source_column ?? "",
|
|
1031
|
+
type: (r.system_type_name ?? "").toLowerCase()
|
|
1032
|
+
}));
|
|
1033
|
+
} catch {
|
|
1034
|
+
result = null;
|
|
1035
|
+
}
|
|
1036
|
+
if (this.sources.size > 200) this.sources.clear();
|
|
1037
|
+
this.sources.set(text, result);
|
|
1038
|
+
return result;
|
|
1039
|
+
}
|
|
1040
|
+
async query(text) {
|
|
1041
|
+
const req = this.pool.request();
|
|
1042
|
+
req.arrayRowMode = true;
|
|
1043
|
+
const res = await req.query(text);
|
|
1044
|
+
const sets = res.recordsets;
|
|
1045
|
+
const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
|
|
1046
|
+
const columns = last?.columns;
|
|
1047
|
+
if (last && columns) {
|
|
1048
|
+
const meta = Object.values(columns).sort((a, b) => a.index - b.index);
|
|
1049
|
+
const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
|
|
1050
|
+
const fields = meta.map((col, i) => {
|
|
1051
|
+
const src = sources && sources.length === meta.length ? sources[i] : void 0;
|
|
1052
|
+
const typeName = col.type?.declaration ?? "";
|
|
1053
|
+
return {
|
|
1054
|
+
name: col.name,
|
|
1055
|
+
orgName: src?.column || col.name,
|
|
1056
|
+
type: src?.type || typeName.toLowerCase(),
|
|
1057
|
+
table: src?.table ?? "",
|
|
1058
|
+
orgTable: src?.table ?? "",
|
|
1059
|
+
db: src?.schema ?? "",
|
|
1060
|
+
maxLength: typeof col.length === "number" ? col.length : 0
|
|
1061
|
+
};
|
|
1062
|
+
});
|
|
1063
|
+
const records = last.map((row) => row.map(normalizeValue));
|
|
1064
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1065
|
+
}
|
|
1066
|
+
return {
|
|
1067
|
+
fields: [],
|
|
1068
|
+
records: [],
|
|
1069
|
+
affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
|
|
1070
|
+
insertId: 0,
|
|
1071
|
+
isResultQuery: false
|
|
1072
|
+
};
|
|
1083
1073
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1074
|
+
async close() {
|
|
1075
|
+
await this.pool.close();
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
var mssqlDriverFactory = {
|
|
1079
|
+
async open(config) {
|
|
1080
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
1081
|
+
await pool.connect();
|
|
1082
|
+
return new MssqlDriver(pool, config);
|
|
1083
|
+
},
|
|
1084
|
+
async test(config) {
|
|
1085
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
1086
|
+
await pool.connect();
|
|
1087
|
+
await pool.close();
|
|
1088
|
+
},
|
|
1089
|
+
parseConfig(body) {
|
|
1090
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1091
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
1092
|
+
if (!host || !user) {
|
|
1093
|
+
throw new Error("host and user are required");
|
|
1094
|
+
}
|
|
1095
|
+
return {
|
|
1096
|
+
driver: "mssql",
|
|
1097
|
+
host,
|
|
1098
|
+
port: Number(body.port) || 1433,
|
|
1099
|
+
user,
|
|
1100
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
1101
|
+
database: typeof body.database === "string" && body.database ? body.database : "master",
|
|
1102
|
+
ssl: body.ssl === true,
|
|
1103
|
+
sslVerify: body.sslVerify === true
|
|
1104
|
+
};
|
|
1100
1105
|
}
|
|
1101
|
-
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
// src/drivers/oracle.ts
|
|
1109
|
+
import oracledb from "oracledb";
|
|
1110
|
+
oracledb.autoCommit = true;
|
|
1111
|
+
oracledb.fetchAsString = [oracledb.CLOB];
|
|
1112
|
+
var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
|
|
1113
|
+
var pad2 = (n, width = 2) => String(n).padStart(width, "0");
|
|
1114
|
+
function formatDate2(d) {
|
|
1115
|
+
const ms = d.getMilliseconds();
|
|
1116
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
|
|
1102
1117
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
`, { mode: 384 });
|
|
1118
|
+
function normalizeValue2(value) {
|
|
1119
|
+
if (value instanceof Date) return formatDate2(value);
|
|
1120
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
1121
|
+
return value;
|
|
1108
1122
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1123
|
+
var SESSION_FORMATS = [
|
|
1124
|
+
"ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
|
|
1125
|
+
"ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
|
|
1126
|
+
"ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
|
|
1127
|
+
];
|
|
1128
|
+
var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
1129
|
+
var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
|
|
1130
|
+
function normalizeStatement(sql2) {
|
|
1131
|
+
const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
|
|
1132
|
+
if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
|
1133
|
+
return trimmed.replace(/;\s*$/, "");
|
|
1111
1134
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1135
|
+
var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
|
|
1136
|
+
var START_TX = /^\s*SET\s+TRANSACTION\b/i;
|
|
1137
|
+
var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
|
|
1138
|
+
function singleSourceTable(sql2) {
|
|
1139
|
+
const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
|
|
1140
|
+
if (!m) return null;
|
|
1141
|
+
const rest = m[3].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|FETCH|OFFSET|UNION|MINUS|INTERSECT|FOR\s+UPDATE)\b[\s\S]*$/i, "");
|
|
1142
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
1143
|
+
const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
|
|
1144
|
+
return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
|
|
1145
|
+
}
|
|
1146
|
+
var OracleDriver = class {
|
|
1147
|
+
constructor(conn, config) {
|
|
1148
|
+
this.conn = conn;
|
|
1149
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
|
|
1150
|
+
this.currentSchema = config.user.toUpperCase();
|
|
1116
1151
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
400
|
|
1127
|
-
);
|
|
1152
|
+
conn;
|
|
1153
|
+
type = "oracle";
|
|
1154
|
+
label;
|
|
1155
|
+
currentSchema;
|
|
1156
|
+
manualTx = false;
|
|
1157
|
+
/** Column names per `OWNER.TABLE`, for result-source resolution. */
|
|
1158
|
+
columns = /* @__PURE__ */ new Map();
|
|
1159
|
+
async init() {
|
|
1160
|
+
for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
|
|
1128
1161
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1162
|
+
async tableColumns(schema, table) {
|
|
1163
|
+
const key = `${schema}.${table}`;
|
|
1164
|
+
const cached = this.columns.get(key);
|
|
1165
|
+
if (cached !== void 0) return cached;
|
|
1166
|
+
let out = null;
|
|
1167
|
+
try {
|
|
1168
|
+
const res = await this.conn.execute(
|
|
1169
|
+
"SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
|
|
1170
|
+
{ o: schema, t: table },
|
|
1171
|
+
{ outFormat: oracledb.OUT_FORMAT_ARRAY }
|
|
1172
|
+
);
|
|
1173
|
+
out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
|
|
1174
|
+
} catch {
|
|
1175
|
+
out = null;
|
|
1132
1176
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1177
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
1178
|
+
this.columns.set(key, out);
|
|
1179
|
+
return out;
|
|
1180
|
+
}
|
|
1181
|
+
async query(sql2, database) {
|
|
1182
|
+
if (database && database !== this.currentSchema) {
|
|
1183
|
+
await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
|
|
1184
|
+
this.currentSchema = database;
|
|
1141
1185
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1186
|
+
const text = normalizeStatement(sql2);
|
|
1187
|
+
if (START_TX.test(text)) this.manualTx = true;
|
|
1188
|
+
const res = await this.conn.execute(text, [], {
|
|
1189
|
+
outFormat: oracledb.OUT_FORMAT_ARRAY,
|
|
1190
|
+
fetchTypeHandler,
|
|
1191
|
+
autoCommit: !this.manualTx
|
|
1192
|
+
});
|
|
1193
|
+
if (SET_SCHEMA.test(text)) this.currentSchema = null;
|
|
1194
|
+
if (END_TX.test(text)) this.manualTx = false;
|
|
1195
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
1196
|
+
const source = singleSourceTable(text);
|
|
1197
|
+
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
1198
|
+
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
1199
|
+
const fields = res.metaData.map((col) => {
|
|
1200
|
+
const fromTable = known?.has(col.name) ?? false;
|
|
1201
|
+
return {
|
|
1202
|
+
name: col.name,
|
|
1203
|
+
orgName: col.name,
|
|
1204
|
+
type: (col.dbTypeName ?? "").toLowerCase(),
|
|
1205
|
+
table: fromTable ? source.table : "",
|
|
1206
|
+
orgTable: fromTable ? source.table : "",
|
|
1207
|
+
db: fromTable ? schema : "",
|
|
1208
|
+
maxLength: col.byteSize ?? 0
|
|
1209
|
+
};
|
|
1210
|
+
});
|
|
1211
|
+
const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
|
|
1212
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1147
1213
|
}
|
|
1214
|
+
return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
|
|
1148
1215
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1216
|
+
async close() {
|
|
1217
|
+
await this.conn.close();
|
|
1151
1218
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
username: ssh.user,
|
|
1159
|
-
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
1160
|
-
keepaliveInterval: 3e4
|
|
1219
|
+
};
|
|
1220
|
+
function connectionAttributes(config) {
|
|
1221
|
+
return {
|
|
1222
|
+
user: config.user,
|
|
1223
|
+
password: config.password,
|
|
1224
|
+
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
1161
1225
|
};
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1226
|
+
}
|
|
1227
|
+
var oracleDriverFactory = {
|
|
1228
|
+
async open(config) {
|
|
1229
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
1230
|
+
const driver = new OracleDriver(conn, config);
|
|
1231
|
+
await driver.init();
|
|
1232
|
+
return driver;
|
|
1233
|
+
},
|
|
1234
|
+
async test(config) {
|
|
1235
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
1236
|
+
await conn.close();
|
|
1237
|
+
},
|
|
1238
|
+
parseConfig(body) {
|
|
1239
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1240
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
1241
|
+
if (!host || !user) {
|
|
1242
|
+
throw new Error("host and user are required");
|
|
1171
1243
|
}
|
|
1244
|
+
if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
|
|
1245
|
+
const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
|
|
1246
|
+
if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
|
|
1247
|
+
return {
|
|
1248
|
+
driver: "oracle",
|
|
1249
|
+
host,
|
|
1250
|
+
port: Number(body.port) || 1521,
|
|
1251
|
+
user,
|
|
1252
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
1253
|
+
serviceName
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
// src/drivers/d1.ts
|
|
1259
|
+
var API = "https://api.cloudflare.com/client/v4";
|
|
1260
|
+
var D1Error = class extends Error {
|
|
1261
|
+
constructor(message, status) {
|
|
1262
|
+
super(message);
|
|
1263
|
+
this.status = status;
|
|
1172
1264
|
}
|
|
1265
|
+
status;
|
|
1266
|
+
};
|
|
1267
|
+
var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
|
|
1268
|
+
var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
1269
|
+
var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
1270
|
+
function singleSourceTable2(sql2) {
|
|
1271
|
+
const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
1272
|
+
if (!m) return null;
|
|
1273
|
+
const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
|
|
1274
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
1275
|
+
const raw = m[1];
|
|
1276
|
+
const first = raw[0];
|
|
1277
|
+
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
1278
|
+
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
1279
|
+
if (first === "[") return raw.slice(1, -1);
|
|
1280
|
+
return raw;
|
|
1281
|
+
}
|
|
1282
|
+
function normalizeDeclaredType2(declared) {
|
|
1283
|
+
if (typeof declared !== "string") return null;
|
|
1284
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
1285
|
+
return base || null;
|
|
1173
1286
|
}
|
|
1174
|
-
function
|
|
1175
|
-
|
|
1176
|
-
if (
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1287
|
+
function inferTypeFromValue2(value) {
|
|
1288
|
+
if (value === null || value === void 0) return "null";
|
|
1289
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
1290
|
+
if (Array.isArray(value)) return "blob";
|
|
1291
|
+
return "text";
|
|
1292
|
+
}
|
|
1293
|
+
function firstNonNull2(records, index) {
|
|
1294
|
+
for (const row of records) {
|
|
1295
|
+
const value = row[index];
|
|
1296
|
+
if (value !== null && value !== void 0) return value;
|
|
1184
1297
|
}
|
|
1298
|
+
return null;
|
|
1185
1299
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1201
|
-
if (result === "unknown") {
|
|
1202
|
-
console.log(`[ssh] Trusting new host key for ${ssh.host}:${ssh.port} (${fingerprint(key)}), saved to ${PLANCHE_KNOWN_HOSTS}`);
|
|
1203
|
-
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
1204
|
-
}
|
|
1205
|
-
verified(true);
|
|
1206
|
-
});
|
|
1207
|
-
};
|
|
1208
|
-
await new Promise((resolve2, reject) => {
|
|
1209
|
-
client.once("ready", resolve2);
|
|
1210
|
-
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
1300
|
+
var D1Driver = class {
|
|
1301
|
+
type = "d1";
|
|
1302
|
+
label;
|
|
1303
|
+
url;
|
|
1304
|
+
headers;
|
|
1305
|
+
/** Declared column types per table, for result-source resolution. */
|
|
1306
|
+
columns = /* @__PURE__ */ new Map();
|
|
1307
|
+
constructor(config) {
|
|
1308
|
+
this.label = `d1:${config.databaseId}`;
|
|
1309
|
+
this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
|
|
1310
|
+
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
1311
|
+
}
|
|
1312
|
+
async raw(sql2) {
|
|
1313
|
+
let res;
|
|
1211
1314
|
try {
|
|
1212
|
-
|
|
1315
|
+
res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
|
|
1213
1316
|
} catch (err) {
|
|
1214
|
-
|
|
1317
|
+
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
1215
1318
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1319
|
+
let body = null;
|
|
1320
|
+
try {
|
|
1321
|
+
body = await res.json();
|
|
1322
|
+
} catch {
|
|
1323
|
+
}
|
|
1324
|
+
if (!res.ok || !body?.success) {
|
|
1325
|
+
const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
|
|
1326
|
+
throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
|
|
1327
|
+
}
|
|
1328
|
+
const result = body.result?.[0];
|
|
1329
|
+
if (!result) throw new D1Error("Cloudflare API returned no result");
|
|
1330
|
+
return result;
|
|
1331
|
+
}
|
|
1332
|
+
async tableColumns(table) {
|
|
1333
|
+
const cached = this.columns.get(table);
|
|
1334
|
+
if (cached !== void 0) return cached;
|
|
1335
|
+
let out = null;
|
|
1336
|
+
try {
|
|
1337
|
+
const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
1338
|
+
const cols = info.results?.columns ?? [];
|
|
1339
|
+
const name = cols.indexOf("name");
|
|
1340
|
+
const type = cols.indexOf("type");
|
|
1341
|
+
const rows = info.results?.rows ?? [];
|
|
1342
|
+
if (rows.length > 0 && name >= 0) {
|
|
1343
|
+
out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
|
|
1230
1344
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1345
|
+
} catch {
|
|
1346
|
+
out = null;
|
|
1347
|
+
}
|
|
1348
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
1349
|
+
this.columns.set(table, out);
|
|
1350
|
+
return out;
|
|
1351
|
+
}
|
|
1352
|
+
async query(sql2) {
|
|
1353
|
+
if (DATABASE_LIST.test(sql2)) {
|
|
1354
|
+
return {
|
|
1355
|
+
fields: [
|
|
1356
|
+
{ name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
1357
|
+
{ name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
1358
|
+
{ name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
|
|
1359
|
+
],
|
|
1360
|
+
records: [[0, "main", ""]],
|
|
1361
|
+
affectedRows: 0,
|
|
1362
|
+
insertId: 0,
|
|
1363
|
+
isResultQuery: true
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
|
|
1367
|
+
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
|
|
1368
|
+
throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
|
|
1369
|
+
}
|
|
1370
|
+
if (this.isDdl(text)) this.columns.clear();
|
|
1371
|
+
const result = await this.raw(text);
|
|
1372
|
+
const columns = result.results?.columns ?? [];
|
|
1373
|
+
const records = result.results?.rows ?? [];
|
|
1374
|
+
if (columns.length === 0 && !RESULT_KIND.test(text)) {
|
|
1375
|
+
return {
|
|
1376
|
+
fields: [],
|
|
1377
|
+
records: [],
|
|
1378
|
+
affectedRows: result.meta?.changes ?? 0,
|
|
1379
|
+
insertId: result.meta?.last_row_id ?? 0,
|
|
1380
|
+
isResultQuery: false
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
1384
|
+
const known = source ? await this.tableColumns(source) : null;
|
|
1385
|
+
const fields = columns.map((name, index) => {
|
|
1386
|
+
const fromTable = known?.has(name) ?? false;
|
|
1387
|
+
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
1388
|
+
return {
|
|
1389
|
+
name,
|
|
1390
|
+
orgName: name,
|
|
1391
|
+
type,
|
|
1392
|
+
table: fromTable && source ? source : "",
|
|
1393
|
+
orgTable: fromTable && source ? source : "",
|
|
1394
|
+
db: fromTable ? "main" : "",
|
|
1395
|
+
maxLength: 0
|
|
1396
|
+
};
|
|
1252
1397
|
});
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1398
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1399
|
+
}
|
|
1400
|
+
isDdl(sql2) {
|
|
1401
|
+
return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
|
|
1402
|
+
}
|
|
1403
|
+
async close() {
|
|
1404
|
+
this.columns.clear();
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
var d1DriverFactory = {
|
|
1408
|
+
async open(config) {
|
|
1409
|
+
const driver = new D1Driver(config);
|
|
1410
|
+
await driver.raw("SELECT 1");
|
|
1411
|
+
return driver;
|
|
1412
|
+
},
|
|
1413
|
+
async test(config) {
|
|
1414
|
+
await new D1Driver(config).raw("SELECT 1");
|
|
1415
|
+
},
|
|
1416
|
+
parseConfig(body) {
|
|
1417
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
1418
|
+
const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
|
|
1419
|
+
const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
|
|
1420
|
+
if (!accountId || !databaseId || !apiToken) {
|
|
1421
|
+
throw new Error("accountId, databaseId and apiToken are required");
|
|
1422
|
+
}
|
|
1423
|
+
return { driver: "d1", accountId, databaseId, apiToken };
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1264
1426
|
|
|
1265
1427
|
// src/drivers/index.ts
|
|
1266
1428
|
var factories = {
|
|
@@ -1360,6 +1522,7 @@ function testDirect(config) {
|
|
|
1360
1522
|
|
|
1361
1523
|
// src/tunneling.ts
|
|
1362
1524
|
var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
|
|
1525
|
+
var MAX_CONNECTIONS = Number(process.env.PLANCHE_MAX_CONNECTIONS) || 64;
|
|
1363
1526
|
var ConnectionManager = class {
|
|
1364
1527
|
connections = /* @__PURE__ */ new Map();
|
|
1365
1528
|
lastUsed = /* @__PURE__ */ new Map();
|
|
@@ -1381,6 +1544,9 @@ var ConnectionManager = class {
|
|
|
1381
1544
|
* Open a new connection and return a unique connection ID.
|
|
1382
1545
|
*/
|
|
1383
1546
|
async connect(config) {
|
|
1547
|
+
if (this.connections.size >= MAX_CONNECTIONS) {
|
|
1548
|
+
throw new Error(`Too many open connections (${MAX_CONNECTIONS}); disconnect some first`);
|
|
1549
|
+
}
|
|
1384
1550
|
const driver = await openDriver(config);
|
|
1385
1551
|
const connectionId = randomUUID();
|
|
1386
1552
|
this.connections.set(connectionId, driver);
|
|
@@ -1453,16 +1619,54 @@ function errorStatus(err) {
|
|
|
1453
1619
|
const status = err?.status;
|
|
1454
1620
|
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
1455
1621
|
}
|
|
1456
|
-
function fail(res, status, message) {
|
|
1457
|
-
res.status(status).json({ success: false, message });
|
|
1622
|
+
function fail(res, status, message, extra = {}) {
|
|
1623
|
+
res.status(status).json({ success: false, message, ...extra });
|
|
1624
|
+
}
|
|
1625
|
+
function errorExtra(err) {
|
|
1626
|
+
if (err instanceof HostKeyError) {
|
|
1627
|
+
const body = { code: "host-key-unknown", hostKey: err.hostKey };
|
|
1628
|
+
return body;
|
|
1629
|
+
}
|
|
1630
|
+
return {};
|
|
1458
1631
|
}
|
|
1459
1632
|
var errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
1460
|
-
function
|
|
1461
|
-
|
|
1462
|
-
const
|
|
1463
|
-
|
|
1464
|
-
|
|
1633
|
+
function secretEquals(given, expected) {
|
|
1634
|
+
const a = createHash2("sha256").update(given).digest();
|
|
1635
|
+
const b = createHash2("sha256").update(expected).digest();
|
|
1636
|
+
return timingSafeEqual2(a, b);
|
|
1637
|
+
}
|
|
1638
|
+
function bearerOf(header) {
|
|
1639
|
+
return header?.startsWith("Bearer ") ? header.slice(7) : null;
|
|
1465
1640
|
}
|
|
1641
|
+
var AuthThrottle = class {
|
|
1642
|
+
constructor(max = 10, windowMs = 6e4, lockMs = 3e4) {
|
|
1643
|
+
this.max = max;
|
|
1644
|
+
this.windowMs = windowMs;
|
|
1645
|
+
this.lockMs = lockMs;
|
|
1646
|
+
}
|
|
1647
|
+
max;
|
|
1648
|
+
windowMs;
|
|
1649
|
+
lockMs;
|
|
1650
|
+
failures = /* @__PURE__ */ new Map();
|
|
1651
|
+
lockedUntil = /* @__PURE__ */ new Map();
|
|
1652
|
+
/** Keyed by peer address so one local process cannot lock the browser out. */
|
|
1653
|
+
locked(peer) {
|
|
1654
|
+
return Date.now() < (this.lockedUntil.get(peer) ?? 0);
|
|
1655
|
+
}
|
|
1656
|
+
fail(peer) {
|
|
1657
|
+
const now = Date.now();
|
|
1658
|
+
const recent = (this.failures.get(peer) ?? []).filter((t) => now - t < this.windowMs);
|
|
1659
|
+
recent.push(now);
|
|
1660
|
+
if (recent.length >= this.max) {
|
|
1661
|
+
this.lockedUntil.set(peer, now + this.lockMs);
|
|
1662
|
+
this.failures.delete(peer);
|
|
1663
|
+
console.warn(`[agent] Too many bad tokens from ${peer}; refusing its authentication for ${this.lockMs / 1e3}s`);
|
|
1664
|
+
} else {
|
|
1665
|
+
this.failures.set(peer, recent);
|
|
1666
|
+
}
|
|
1667
|
+
if (this.failures.size > 1e3) this.failures.clear();
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1466
1670
|
function accessControl(allowed) {
|
|
1467
1671
|
return (req, res, next) => {
|
|
1468
1672
|
const origin = req.headers.origin;
|
|
@@ -1496,6 +1700,22 @@ function createAgent(options) {
|
|
|
1496
1700
|
const app = express();
|
|
1497
1701
|
const manager = new ConnectionManager();
|
|
1498
1702
|
const allowedOrigins = new Set(options.allowedOrigins.map((o) => o.replace(/\/+$/, "")));
|
|
1703
|
+
const allowedHosts = options.allowedHosts ? new Set(options.allowedHosts.map((h) => h.toLowerCase())) : null;
|
|
1704
|
+
const throttle = new AuthThrottle();
|
|
1705
|
+
let pairing = options.pairingCode;
|
|
1706
|
+
app.set("env", "production");
|
|
1707
|
+
app.disable("x-powered-by");
|
|
1708
|
+
app.disable("etag");
|
|
1709
|
+
const peerOf = (req) => req.socket.remoteAddress ?? "unknown";
|
|
1710
|
+
const authorized = (req) => {
|
|
1711
|
+
const peer = peerOf(req);
|
|
1712
|
+
if (throttle.locked(peer)) return false;
|
|
1713
|
+
const given = bearerOf(req.headers.authorization);
|
|
1714
|
+
if (given === null) return false;
|
|
1715
|
+
const ok = secretEquals(given, options.authToken);
|
|
1716
|
+
if (!ok) throttle.fail(peer);
|
|
1717
|
+
return ok;
|
|
1718
|
+
};
|
|
1499
1719
|
if (options.logRequests) {
|
|
1500
1720
|
app.use((req, _res, next) => {
|
|
1501
1721
|
const hasAuth = !!req.headers.authorization;
|
|
@@ -1503,19 +1723,19 @@ function createAgent(options) {
|
|
|
1503
1723
|
next();
|
|
1504
1724
|
});
|
|
1505
1725
|
}
|
|
1506
|
-
app.use(accessControl(allowedOrigins));
|
|
1507
|
-
app.use(express.json({ limit: options.bodyLimit }));
|
|
1508
1726
|
app.use((req, res, next) => {
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1727
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1728
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1729
|
+
if (allowedHosts) {
|
|
1730
|
+
const host = req.headers.host?.toLowerCase();
|
|
1731
|
+
if (!host || !allowedHosts.has(host)) {
|
|
1732
|
+
fail(res, 403, "Invalid Host header");
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1516
1735
|
}
|
|
1517
1736
|
next();
|
|
1518
1737
|
});
|
|
1738
|
+
app.use(accessControl(allowedOrigins));
|
|
1519
1739
|
app.get("/health", (req, res) => {
|
|
1520
1740
|
const body = {
|
|
1521
1741
|
success: true,
|
|
@@ -1526,10 +1746,41 @@ function createAgent(options) {
|
|
|
1526
1746
|
uptime: process.uptime()
|
|
1527
1747
|
};
|
|
1528
1748
|
if (req.headers.authorization !== void 0) {
|
|
1529
|
-
|
|
1749
|
+
if (throttle.locked(peerOf(req))) {
|
|
1750
|
+
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
body.authorized = authorized(req);
|
|
1754
|
+
}
|
|
1755
|
+
res.json(body);
|
|
1756
|
+
});
|
|
1757
|
+
app.post("/pair", express.json({ limit: "1kb" }), (req, res) => {
|
|
1758
|
+
if (throttle.locked(peerOf(req))) {
|
|
1759
|
+
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
const code = typeof req.body?.code === "string" ? req.body.code : "";
|
|
1763
|
+
if (!pairing || Date.now() > pairing.expiresAt || !code || !secretEquals(code, pairing.code)) {
|
|
1764
|
+
throttle.fail(peerOf(req));
|
|
1765
|
+
fail(res, 401, "Pairing code is invalid or has expired; restart the agent for a new link");
|
|
1766
|
+
return;
|
|
1530
1767
|
}
|
|
1768
|
+
pairing = void 0;
|
|
1769
|
+
const body = { success: true, token: options.authToken };
|
|
1531
1770
|
res.json(body);
|
|
1532
1771
|
});
|
|
1772
|
+
app.use((req, res, next) => {
|
|
1773
|
+
if (throttle.locked(peerOf(req))) {
|
|
1774
|
+
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (!authorized(req)) {
|
|
1778
|
+
fail(res, 401, "Unauthorized: invalid or missing auth token");
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
next();
|
|
1782
|
+
});
|
|
1783
|
+
app.use(express.json({ limit: options.bodyLimit }));
|
|
1533
1784
|
app.post("/connect", async (req, res) => {
|
|
1534
1785
|
try {
|
|
1535
1786
|
const config = parseDriverConfig(req.body ?? {});
|
|
@@ -1538,12 +1789,12 @@ function createAgent(options) {
|
|
|
1538
1789
|
} catch (err) {
|
|
1539
1790
|
const message = errorMessage(err);
|
|
1540
1791
|
console.error("[connect] Error:", message);
|
|
1541
|
-
fail(res, errorStatus(err), message);
|
|
1792
|
+
fail(res, errorStatus(err), message, errorExtra(err));
|
|
1542
1793
|
}
|
|
1543
1794
|
});
|
|
1544
1795
|
app.post("/query", async (req, res) => {
|
|
1545
1796
|
try {
|
|
1546
|
-
const { connectionId, sql: sql2, database } = req.body;
|
|
1797
|
+
const { connectionId, sql: sql2, database } = req.body ?? {};
|
|
1547
1798
|
if (typeof connectionId !== "string" || typeof sql2 !== "string" || !connectionId || !sql2) {
|
|
1548
1799
|
fail(res, 400, "connectionId and sql are required");
|
|
1549
1800
|
return;
|
|
@@ -1553,7 +1804,7 @@ function createAgent(options) {
|
|
|
1553
1804
|
return;
|
|
1554
1805
|
}
|
|
1555
1806
|
const start = Date.now();
|
|
1556
|
-
const result = await manager.query(connectionId, sql2, database);
|
|
1807
|
+
const result = await manager.query(connectionId, sql2, typeof database === "string" ? database : void 0);
|
|
1557
1808
|
const execTime = Date.now() - start;
|
|
1558
1809
|
res.json({
|
|
1559
1810
|
success: true,
|
|
@@ -1572,7 +1823,7 @@ function createAgent(options) {
|
|
|
1572
1823
|
});
|
|
1573
1824
|
app.post("/disconnect", async (req, res) => {
|
|
1574
1825
|
try {
|
|
1575
|
-
const { connectionId } = req.body;
|
|
1826
|
+
const { connectionId } = req.body ?? {};
|
|
1576
1827
|
if (typeof connectionId !== "string" || !connectionId) {
|
|
1577
1828
|
fail(res, 400, "connectionId is required");
|
|
1578
1829
|
return;
|
|
@@ -1596,9 +1847,17 @@ function createAgent(options) {
|
|
|
1596
1847
|
} catch (err) {
|
|
1597
1848
|
const message = errorMessage(err);
|
|
1598
1849
|
console.error("[test-connection] Error:", message);
|
|
1599
|
-
fail(res, errorStatus(err), message);
|
|
1850
|
+
fail(res, errorStatus(err), message, errorExtra(err));
|
|
1600
1851
|
}
|
|
1601
1852
|
});
|
|
1853
|
+
app.use((_req, res) => fail(res, 404, "Not found"));
|
|
1854
|
+
app.use((err, _req, res, _next) => {
|
|
1855
|
+
const e = err;
|
|
1856
|
+
const status = e?.type === "entity.too.large" ? 413 : typeof e?.status === "number" && e.status >= 400 && e.status < 600 ? e.status : 400;
|
|
1857
|
+
console.error("[agent] Request error:", e?.message ?? String(err));
|
|
1858
|
+
if (res.headersSent) return;
|
|
1859
|
+
fail(res, status, status === 413 ? "Request body too large" : status === 400 ? "Malformed request" : "Request failed");
|
|
1860
|
+
});
|
|
1602
1861
|
return { app, manager };
|
|
1603
1862
|
}
|
|
1604
1863
|
|
|
@@ -1623,6 +1882,7 @@ Options:
|
|
|
1623
1882
|
--app <url> Web app to open (default: ${DEFAULT_APP_URL}, env PLANCHE_APP_URL)
|
|
1624
1883
|
--origin <url> Extra browser origin allowed to use the agent (repeatable,
|
|
1625
1884
|
env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
|
|
1885
|
+
--allow-remote Required with a non-loopback --addr (see README: no TLS)
|
|
1626
1886
|
--no-open Do not open the browser
|
|
1627
1887
|
--verbose Log every request
|
|
1628
1888
|
-v, --version Print the version
|
|
@@ -1634,15 +1894,21 @@ function splitList(value) {
|
|
|
1634
1894
|
}
|
|
1635
1895
|
function originOf(url) {
|
|
1636
1896
|
try {
|
|
1637
|
-
|
|
1897
|
+
const parsed = new URL(url);
|
|
1898
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
1899
|
+
return parsed.origin === "null" ? null : parsed.origin;
|
|
1638
1900
|
} catch {
|
|
1639
1901
|
return null;
|
|
1640
1902
|
}
|
|
1641
1903
|
}
|
|
1904
|
+
var LOOPBACK_ADDRS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
1905
|
+
var MIN_TOKEN_LENGTH = 16;
|
|
1642
1906
|
function openInBrowser(url) {
|
|
1643
|
-
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["
|
|
1907
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
1644
1908
|
try {
|
|
1645
|
-
|
|
1909
|
+
const env = { ...process.env };
|
|
1910
|
+
delete env.PLANCHE_AUTH_TOKEN;
|
|
1911
|
+
spawn(cmd, args, { stdio: "ignore", detached: true, env }).on("error", () => void 0).unref();
|
|
1646
1912
|
} catch {
|
|
1647
1913
|
}
|
|
1648
1914
|
}
|
|
@@ -1664,6 +1930,7 @@ function main() {
|
|
|
1664
1930
|
origin: { type: "string", multiple: true },
|
|
1665
1931
|
open: { type: "boolean" },
|
|
1666
1932
|
verbose: { type: "boolean" },
|
|
1933
|
+
"allow-remote": { type: "boolean" },
|
|
1667
1934
|
version: { type: "boolean", short: "v" },
|
|
1668
1935
|
help: { type: "boolean", short: "h" }
|
|
1669
1936
|
},
|
|
@@ -1693,37 +1960,61 @@ function main() {
|
|
|
1693
1960
|
const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
|
|
1694
1961
|
const appOrigin = originOf(appUrl);
|
|
1695
1962
|
if (!appOrigin) {
|
|
1696
|
-
console.error(`Invalid --app URL: ${appUrl}`);
|
|
1963
|
+
console.error(`Invalid --app URL (http or https only): ${appUrl}`);
|
|
1964
|
+
process.exit(2);
|
|
1965
|
+
}
|
|
1966
|
+
const loopback = LOOPBACK_ADDRS.has(addr);
|
|
1967
|
+
if (!loopback) {
|
|
1968
|
+
if (!flags["allow-remote"]) {
|
|
1969
|
+
console.error(`Refusing to bind to ${addr}: the agent speaks plain HTTP, so database passwords and SSH keys would cross the network unencrypted.`);
|
|
1970
|
+
console.error("Put it behind a TLS reverse proxy or an SSH port forward, then pass --allow-remote together with --token (or PLANCHE_AUTH_TOKEN).");
|
|
1971
|
+
process.exit(2);
|
|
1972
|
+
}
|
|
1973
|
+
if (!flags.token && !process.env.PLANCHE_AUTH_TOKEN) {
|
|
1974
|
+
console.error("--allow-remote needs an explicit --token / PLANCHE_AUTH_TOKEN.");
|
|
1975
|
+
process.exit(2);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
if ((flags.token || process.env.PLANCHE_AUTH_TOKEN) && authToken.length < MIN_TOKEN_LENGTH) {
|
|
1979
|
+
console.error(`The auth token must be at least ${MIN_TOKEN_LENGTH} characters (got ${authToken.length}). Generate one with: openssl rand -base64 24`);
|
|
1697
1980
|
process.exit(2);
|
|
1698
1981
|
}
|
|
1699
|
-
const
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1982
|
+
const extraOrigins = [...flags.origin ?? [], ...splitList(process.env.PLANCHE_ALLOWED_ORIGINS)];
|
|
1983
|
+
for (const o of extraOrigins) {
|
|
1984
|
+
if (originOf(o) !== o.replace(/\/+$/, "")) {
|
|
1985
|
+
console.error(`Invalid --origin (expected scheme://host[:port]): ${o}`);
|
|
1986
|
+
process.exit(2);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
|
|
1990
|
+
const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
|
|
1991
|
+
const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
|
|
1992
|
+
const listenAddr = addr.replace(/^\[(.*)\]$/, "$1");
|
|
1993
|
+
const agentUrl = `http://${listenAddr.includes(":") ? `[${listenAddr}]` : listenAddr}:${port}`;
|
|
1705
1994
|
const { app, manager } = createAgent({
|
|
1706
1995
|
authToken,
|
|
1707
1996
|
allowedOrigins,
|
|
1708
1997
|
bodyLimit,
|
|
1709
1998
|
version: VERSION,
|
|
1710
|
-
logRequests: flags.verbose ?? false
|
|
1999
|
+
logRequests: flags.verbose ?? false,
|
|
2000
|
+
// Browsers omit a default port from `Host`, so the bare names are allowed too.
|
|
2001
|
+
allowedHosts: loopback ? ["127.0.0.1", "localhost", "[::1]", `127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
|
|
2002
|
+
pairingCode
|
|
1711
2003
|
});
|
|
1712
|
-
const
|
|
1713
|
-
const
|
|
1714
|
-
const server = app.listen(port, addr, () => {
|
|
2004
|
+
const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
|
|
2005
|
+
const server = app.listen(port, listenAddr, () => {
|
|
1715
2006
|
const line = "-".repeat(72);
|
|
1716
2007
|
console.log(line);
|
|
1717
2008
|
console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
|
|
1718
2009
|
console.log(line);
|
|
1719
|
-
console.log(`Open the app with:
|
|
2010
|
+
console.log(`Open the app with (link valid for 10 minutes, single use):
|
|
1720
2011
|
|
|
1721
2012
|
${pairUrl}
|
|
1722
2013
|
`);
|
|
1723
|
-
console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token: ${authToken}`);
|
|
2014
|
+
console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token (for manual setup): ${authToken}`);
|
|
1724
2015
|
console.log(`Allowed origins: ${allowedOrigins.join(", ")}`);
|
|
1725
|
-
if (!
|
|
1726
|
-
console.
|
|
2016
|
+
if (!loopback) {
|
|
2017
|
+
console.error(`WARNING: bound to ${addr} without TLS \u2014 anyone who can reach this port and knows the token can use your databases, and anyone on the path can read the traffic.`);
|
|
1727
2018
|
}
|
|
1728
2019
|
console.log(line);
|
|
1729
2020
|
if (shouldOpen) openInBrowser(pairUrl);
|
|
@@ -1743,5 +2034,7 @@ function main() {
|
|
|
1743
2034
|
};
|
|
1744
2035
|
process.on("SIGINT", shutdown);
|
|
1745
2036
|
process.on("SIGTERM", shutdown);
|
|
2037
|
+
process.on("uncaughtException", (err) => console.error("[agent] Uncaught exception:", err instanceof Error ? err.message : String(err)));
|
|
2038
|
+
process.on("unhandledRejection", (err) => console.error("[agent] Unhandled rejection:", err instanceof Error ? err.message : String(err)));
|
|
1746
2039
|
}
|
|
1747
2040
|
main();
|