planche 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +148 -58
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ npx planche [options]
|
|
|
40
40
|
-p, --port <n> Port to listen on (default: 8888)
|
|
41
41
|
--addr <host> Address to bind (default: 127.0.0.1)
|
|
42
42
|
--token <str> Auth token (default: random on each start)
|
|
43
|
+
--print-token Print the token (hidden by default; the pairing link is enough)
|
|
43
44
|
--no-open Print the pairing link without opening a browser
|
|
44
45
|
--verbose Log every request
|
|
45
46
|
-h, --help Show help
|
package/dist/index.js
CHANGED
|
@@ -120,7 +120,11 @@ async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
|
120
120
|
result.user ??= value;
|
|
121
121
|
break;
|
|
122
122
|
case "identityfile":
|
|
123
|
-
for (const f of d.args)
|
|
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
|
+
}
|
|
124
128
|
break;
|
|
125
129
|
case "identityagent":
|
|
126
130
|
result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
|
|
@@ -164,14 +168,17 @@ function parseSshConfig(value) {
|
|
|
164
168
|
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
165
169
|
const auth = body.auth;
|
|
166
170
|
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
167
|
-
if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 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);
|
|
168
175
|
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
169
176
|
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
170
177
|
}
|
|
171
178
|
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
172
179
|
const config = {
|
|
173
180
|
host,
|
|
174
|
-
port
|
|
181
|
+
port,
|
|
175
182
|
user,
|
|
176
183
|
auth,
|
|
177
184
|
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
@@ -185,7 +192,9 @@ function keyType(blob) {
|
|
|
185
192
|
if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
|
|
186
193
|
const len = blob.readUInt32BE(0);
|
|
187
194
|
if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
|
|
188
|
-
|
|
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;
|
|
189
198
|
}
|
|
190
199
|
function knownHostsEntryMatches(field, patterns) {
|
|
191
200
|
if (field.startsWith("|1|")) {
|
|
@@ -216,19 +225,25 @@ async function lookupHostKey(host, port, blob) {
|
|
|
216
225
|
const patterns = hostPatterns(host, port);
|
|
217
226
|
const type = keyType(blob);
|
|
218
227
|
const encoded = blob.toString("base64");
|
|
219
|
-
let
|
|
228
|
+
let sawHost = false;
|
|
220
229
|
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
221
230
|
for (const raw of lines) {
|
|
222
231
|
const line = raw.trim();
|
|
223
|
-
if (!line || line.startsWith("#")
|
|
224
|
-
const
|
|
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;
|
|
225
236
|
if (!hosts || !lineType || !lineKey) continue;
|
|
226
237
|
if (!knownHostsEntryMatches(hosts, patterns)) continue;
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
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";
|
|
230
245
|
}
|
|
231
|
-
return
|
|
246
|
+
return sawHost ? "mismatch" : "unknown";
|
|
232
247
|
}
|
|
233
248
|
async function rememberHostKey(host, port, blob) {
|
|
234
249
|
await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
|
|
@@ -244,6 +259,9 @@ async function resolveConfig(ssh) {
|
|
|
244
259
|
if (cfg.proxyJump) {
|
|
245
260
|
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
246
261
|
}
|
|
262
|
+
if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
|
|
263
|
+
throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
|
|
264
|
+
}
|
|
247
265
|
const resolved = {
|
|
248
266
|
...ssh,
|
|
249
267
|
host: cfg.hostName ?? ssh.host,
|
|
@@ -350,11 +368,13 @@ async function openSshTunnel(requested, target) {
|
|
|
350
368
|
reject(describeSshError(err, ssh));
|
|
351
369
|
}
|
|
352
370
|
});
|
|
371
|
+
client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
|
|
353
372
|
const sockets = /* @__PURE__ */ new Set();
|
|
354
373
|
let forwardError;
|
|
355
374
|
const server = createServer((socket) => {
|
|
356
375
|
sockets.add(socket);
|
|
357
376
|
socket.once("close", () => sockets.delete(socket));
|
|
377
|
+
socket.on("error", () => socket.destroy());
|
|
358
378
|
client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
359
379
|
if (err) {
|
|
360
380
|
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
@@ -364,6 +384,7 @@ async function openSshTunnel(requested, target) {
|
|
|
364
384
|
socket.destroy();
|
|
365
385
|
return;
|
|
366
386
|
}
|
|
387
|
+
stream.on("error", () => socket.destroy());
|
|
367
388
|
socket.pipe(stream).pipe(socket);
|
|
368
389
|
stream.once("close", () => socket.destroy());
|
|
369
390
|
socket.once("close", () => stream.destroy());
|
|
@@ -444,7 +465,8 @@ function toConnectionOptions(config) {
|
|
|
444
465
|
password: config.password,
|
|
445
466
|
database: config.database || void 0,
|
|
446
467
|
dateStrings: true,
|
|
447
|
-
charset: config.charset || "utf8mb4"
|
|
468
|
+
charset: config.charset || "utf8mb4",
|
|
469
|
+
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0
|
|
448
470
|
};
|
|
449
471
|
}
|
|
450
472
|
var USE_STATEMENT = /^\s*USE\s/i;
|
|
@@ -526,7 +548,9 @@ var mysqlDriverFactory = {
|
|
|
526
548
|
user,
|
|
527
549
|
password: typeof body.password === "string" ? body.password : "",
|
|
528
550
|
database: typeof body.database === "string" && body.database ? body.database : void 0,
|
|
529
|
-
charset: typeof body.charset === "string" && body.charset ? body.charset : void 0
|
|
551
|
+
charset: typeof body.charset === "string" && body.charset ? body.charset : void 0,
|
|
552
|
+
ssl: body.ssl === true,
|
|
553
|
+
sslVerify: body.sslVerify === true
|
|
530
554
|
};
|
|
531
555
|
}
|
|
532
556
|
};
|
|
@@ -743,7 +767,6 @@ function inferTypeFromValue(value) {
|
|
|
743
767
|
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome2(process.env.PLANCHE_SQLITE_DIR)) : null;
|
|
744
768
|
var DEFAULT_ROOT = homedir3();
|
|
745
769
|
var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
|
|
746
|
-
var ALLOW_URI = process.env.PLANCHE_SQLITE_URI === "1";
|
|
747
770
|
var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
|
|
748
771
|
var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
|
|
749
772
|
function expandHome2(path) {
|
|
@@ -768,10 +791,7 @@ function isInside(path, root) {
|
|
|
768
791
|
}
|
|
769
792
|
function resolveFile(file, forCreate = false) {
|
|
770
793
|
if (file === ":memory:") return file;
|
|
771
|
-
if (file.startsWith("file:"))
|
|
772
|
-
if (!ALLOW_URI || SQLITE_DIR) throw new Error("SQLite URI filenames are disabled (set PLANCHE_SQLITE_URI=1 on the agent to allow them)");
|
|
773
|
-
return file;
|
|
774
|
-
}
|
|
794
|
+
if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
|
|
775
795
|
const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome2(file)));
|
|
776
796
|
if (SQLITE_DIR) {
|
|
777
797
|
if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
|
|
@@ -780,10 +800,10 @@ function resolveFile(file, forCreate = false) {
|
|
|
780
800
|
if (!isInside(resolved, root)) throw new Error("SQLite files outside your home directory need PLANCHE_SQLITE_DIR on the agent");
|
|
781
801
|
const parts = relative(root, resolved).split(sep);
|
|
782
802
|
if (parts.some((part) => part.startsWith("."))) throw new Error("SQLite files in hidden directories need PLANCHE_SQLITE_DIR on the agent");
|
|
783
|
-
if (DENIED_UNDER_HOME.
|
|
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`);
|
|
784
804
|
}
|
|
785
805
|
if (forCreate && !DB_EXTENSIONS.test(resolved)) {
|
|
786
|
-
throw new Error("A new SQLite file must end with .db, .sqlite or .sqlite3");
|
|
806
|
+
throw new Error("A new SQLite file must end with .db, .db3, .s3db, .sl3, .sqlite or .sqlite3");
|
|
787
807
|
}
|
|
788
808
|
return resolved;
|
|
789
809
|
}
|
|
@@ -798,16 +818,63 @@ function openDatabase(config) {
|
|
|
798
818
|
fileMustExist: !isMemory && !config.createIfMissing
|
|
799
819
|
});
|
|
800
820
|
db.pragma("trusted_schema = OFF");
|
|
821
|
+
if (config.readonly) db.pragma("query_only = ON");
|
|
801
822
|
return db;
|
|
802
823
|
}
|
|
803
|
-
var ATTACH = /\bATTACH\b/i;
|
|
804
824
|
function stripLiteralsAndComments(sql2) {
|
|
805
|
-
|
|
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;
|
|
837
|
+
}
|
|
838
|
+
} else j++;
|
|
839
|
+
}
|
|
840
|
+
out += c + c;
|
|
841
|
+
i = j;
|
|
842
|
+
continue;
|
|
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++;
|
|
864
|
+
}
|
|
865
|
+
return out;
|
|
806
866
|
}
|
|
807
|
-
function
|
|
808
|
-
|
|
867
|
+
function assertNoFileStatements(sql2) {
|
|
868
|
+
const text = stripLiteralsAndComments(sql2);
|
|
869
|
+
if (!ALLOW_ATTACH && /\bATTACH\b/i.test(text)) {
|
|
809
870
|
throw new Error("ATTACH DATABASE is disabled on the agent (set PLANCHE_SQLITE_ATTACH=1 to allow it)");
|
|
810
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
|
+
}
|
|
811
878
|
}
|
|
812
879
|
var SqliteDriver = class {
|
|
813
880
|
constructor(db, config) {
|
|
@@ -818,7 +885,7 @@ var SqliteDriver = class {
|
|
|
818
885
|
type = "sqlite";
|
|
819
886
|
label;
|
|
820
887
|
async query(sql2) {
|
|
821
|
-
|
|
888
|
+
assertNoFileStatements(sql2);
|
|
822
889
|
let statement;
|
|
823
890
|
try {
|
|
824
891
|
statement = this.db.prepare(sql2);
|
|
@@ -912,7 +979,7 @@ function toPoolConfig(config) {
|
|
|
912
979
|
pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
|
|
913
980
|
options: {
|
|
914
981
|
encrypt: config.ssl === true,
|
|
915
|
-
trustServerCertificate: true,
|
|
982
|
+
trustServerCertificate: config.sslVerify !== true,
|
|
916
983
|
// Keep date/time values as sent by the server rather than shifting to UTC.
|
|
917
984
|
useUTC: false,
|
|
918
985
|
enableArithAbort: true
|
|
@@ -1032,7 +1099,8 @@ var mssqlDriverFactory = {
|
|
|
1032
1099
|
user,
|
|
1033
1100
|
password: typeof body.password === "string" ? body.password : "",
|
|
1034
1101
|
database: typeof body.database === "string" && body.database ? body.database : "master",
|
|
1035
|
-
ssl: body.ssl === true
|
|
1102
|
+
ssl: body.ssl === true,
|
|
1103
|
+
sslVerify: body.sslVerify === true
|
|
1036
1104
|
};
|
|
1037
1105
|
}
|
|
1038
1106
|
};
|
|
@@ -1173,13 +1241,16 @@ var oracleDriverFactory = {
|
|
|
1173
1241
|
if (!host || !user) {
|
|
1174
1242
|
throw new Error("host and user are required");
|
|
1175
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, . _ $ # -");
|
|
1176
1247
|
return {
|
|
1177
1248
|
driver: "oracle",
|
|
1178
1249
|
host,
|
|
1179
1250
|
port: Number(body.port) || 1521,
|
|
1180
1251
|
user,
|
|
1181
1252
|
password: typeof body.password === "string" ? body.password : "",
|
|
1182
|
-
serviceName
|
|
1253
|
+
serviceName
|
|
1183
1254
|
};
|
|
1184
1255
|
}
|
|
1185
1256
|
};
|
|
@@ -1241,7 +1312,7 @@ var D1Driver = class {
|
|
|
1241
1312
|
async raw(sql2) {
|
|
1242
1313
|
let res;
|
|
1243
1314
|
try {
|
|
1244
|
-
res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }) });
|
|
1315
|
+
res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
|
|
1245
1316
|
} catch (err) {
|
|
1246
1317
|
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
1247
1318
|
}
|
|
@@ -1451,6 +1522,7 @@ function testDirect(config) {
|
|
|
1451
1522
|
|
|
1452
1523
|
// src/tunneling.ts
|
|
1453
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;
|
|
1454
1526
|
var ConnectionManager = class {
|
|
1455
1527
|
connections = /* @__PURE__ */ new Map();
|
|
1456
1528
|
lastUsed = /* @__PURE__ */ new Map();
|
|
@@ -1472,6 +1544,9 @@ var ConnectionManager = class {
|
|
|
1472
1544
|
* Open a new connection and return a unique connection ID.
|
|
1473
1545
|
*/
|
|
1474
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
|
+
}
|
|
1475
1550
|
const driver = await openDriver(config);
|
|
1476
1551
|
const connectionId = randomUUID();
|
|
1477
1552
|
this.connections.set(connectionId, driver);
|
|
@@ -1572,20 +1647,24 @@ var AuthThrottle = class {
|
|
|
1572
1647
|
max;
|
|
1573
1648
|
windowMs;
|
|
1574
1649
|
lockMs;
|
|
1575
|
-
failures =
|
|
1576
|
-
lockedUntil =
|
|
1577
|
-
|
|
1578
|
-
|
|
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);
|
|
1579
1655
|
}
|
|
1580
|
-
fail() {
|
|
1656
|
+
fail(peer) {
|
|
1581
1657
|
const now = Date.now();
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
if (
|
|
1585
|
-
this.lockedUntil
|
|
1586
|
-
this.failures
|
|
1587
|
-
console.warn(`[agent] Too many bad tokens; refusing authentication for ${this.lockMs / 1e3}s`);
|
|
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);
|
|
1588
1666
|
}
|
|
1667
|
+
if (this.failures.size > 1e3) this.failures.clear();
|
|
1589
1668
|
}
|
|
1590
1669
|
};
|
|
1591
1670
|
function accessControl(allowed) {
|
|
@@ -1627,12 +1706,14 @@ function createAgent(options) {
|
|
|
1627
1706
|
app.set("env", "production");
|
|
1628
1707
|
app.disable("x-powered-by");
|
|
1629
1708
|
app.disable("etag");
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1632
|
-
const
|
|
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);
|
|
1633
1714
|
if (given === null) return false;
|
|
1634
1715
|
const ok = secretEquals(given, options.authToken);
|
|
1635
|
-
if (!ok) throttle.fail();
|
|
1716
|
+
if (!ok) throttle.fail(peer);
|
|
1636
1717
|
return ok;
|
|
1637
1718
|
};
|
|
1638
1719
|
if (options.logRequests) {
|
|
@@ -1665,22 +1746,22 @@ function createAgent(options) {
|
|
|
1665
1746
|
uptime: process.uptime()
|
|
1666
1747
|
};
|
|
1667
1748
|
if (req.headers.authorization !== void 0) {
|
|
1668
|
-
if (throttle.locked()) {
|
|
1749
|
+
if (throttle.locked(peerOf(req))) {
|
|
1669
1750
|
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1670
1751
|
return;
|
|
1671
1752
|
}
|
|
1672
|
-
body.authorized = authorized(req
|
|
1753
|
+
body.authorized = authorized(req);
|
|
1673
1754
|
}
|
|
1674
1755
|
res.json(body);
|
|
1675
1756
|
});
|
|
1676
1757
|
app.post("/pair", express.json({ limit: "1kb" }), (req, res) => {
|
|
1677
|
-
if (throttle.locked()) {
|
|
1758
|
+
if (throttle.locked(peerOf(req))) {
|
|
1678
1759
|
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1679
1760
|
return;
|
|
1680
1761
|
}
|
|
1681
1762
|
const code = typeof req.body?.code === "string" ? req.body.code : "";
|
|
1682
1763
|
if (!pairing || Date.now() > pairing.expiresAt || !code || !secretEquals(code, pairing.code)) {
|
|
1683
|
-
throttle.fail();
|
|
1764
|
+
throttle.fail(peerOf(req));
|
|
1684
1765
|
fail(res, 401, "Pairing code is invalid or has expired; restart the agent for a new link");
|
|
1685
1766
|
return;
|
|
1686
1767
|
}
|
|
@@ -1689,11 +1770,11 @@ function createAgent(options) {
|
|
|
1689
1770
|
res.json(body);
|
|
1690
1771
|
});
|
|
1691
1772
|
app.use((req, res, next) => {
|
|
1692
|
-
if (throttle.locked()) {
|
|
1773
|
+
if (throttle.locked(peerOf(req))) {
|
|
1693
1774
|
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1694
1775
|
return;
|
|
1695
1776
|
}
|
|
1696
|
-
if (!authorized(req
|
|
1777
|
+
if (!authorized(req)) {
|
|
1697
1778
|
fail(res, 401, "Unauthorized: invalid or missing auth token");
|
|
1698
1779
|
return;
|
|
1699
1780
|
}
|
|
@@ -1713,7 +1794,7 @@ function createAgent(options) {
|
|
|
1713
1794
|
});
|
|
1714
1795
|
app.post("/query", async (req, res) => {
|
|
1715
1796
|
try {
|
|
1716
|
-
const { connectionId, sql: sql2, database } = req.body;
|
|
1797
|
+
const { connectionId, sql: sql2, database } = req.body ?? {};
|
|
1717
1798
|
if (typeof connectionId !== "string" || typeof sql2 !== "string" || !connectionId || !sql2) {
|
|
1718
1799
|
fail(res, 400, "connectionId and sql are required");
|
|
1719
1800
|
return;
|
|
@@ -1723,7 +1804,7 @@ function createAgent(options) {
|
|
|
1723
1804
|
return;
|
|
1724
1805
|
}
|
|
1725
1806
|
const start = Date.now();
|
|
1726
|
-
const result = await manager.query(connectionId, sql2, database);
|
|
1807
|
+
const result = await manager.query(connectionId, sql2, typeof database === "string" ? database : void 0);
|
|
1727
1808
|
const execTime = Date.now() - start;
|
|
1728
1809
|
res.json({
|
|
1729
1810
|
success: true,
|
|
@@ -1742,7 +1823,7 @@ function createAgent(options) {
|
|
|
1742
1823
|
});
|
|
1743
1824
|
app.post("/disconnect", async (req, res) => {
|
|
1744
1825
|
try {
|
|
1745
|
-
const { connectionId } = req.body;
|
|
1826
|
+
const { connectionId } = req.body ?? {};
|
|
1746
1827
|
if (typeof connectionId !== "string" || !connectionId) {
|
|
1747
1828
|
fail(res, 400, "connectionId is required");
|
|
1748
1829
|
return;
|
|
@@ -1798,6 +1879,7 @@ Options:
|
|
|
1798
1879
|
-p, --port <n> Port to listen on (default: 8888, env PLANCHE_PORT)
|
|
1799
1880
|
--addr <host> Address to bind (default: 127.0.0.1, env PLANCHE_ADDR)
|
|
1800
1881
|
--token <str> Auth token (default: random, env PLANCHE_AUTH_TOKEN)
|
|
1882
|
+
--print-token Print the token (for manual setup); hidden by default
|
|
1801
1883
|
--app <url> Web app to open (default: ${DEFAULT_APP_URL}, env PLANCHE_APP_URL)
|
|
1802
1884
|
--origin <url> Extra browser origin allowed to use the agent (repeatable,
|
|
1803
1885
|
env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
|
|
@@ -1825,7 +1907,9 @@ var MIN_TOKEN_LENGTH = 16;
|
|
|
1825
1907
|
function openInBrowser(url) {
|
|
1826
1908
|
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
1827
1909
|
try {
|
|
1828
|
-
|
|
1910
|
+
const env = { ...process.env };
|
|
1911
|
+
delete env.PLANCHE_AUTH_TOKEN;
|
|
1912
|
+
spawn(cmd, args, { stdio: "ignore", detached: true, env }).on("error", () => void 0).unref();
|
|
1829
1913
|
} catch {
|
|
1830
1914
|
}
|
|
1831
1915
|
}
|
|
@@ -1848,6 +1932,7 @@ function main() {
|
|
|
1848
1932
|
open: { type: "boolean" },
|
|
1849
1933
|
verbose: { type: "boolean" },
|
|
1850
1934
|
"allow-remote": { type: "boolean" },
|
|
1935
|
+
"print-token": { type: "boolean" },
|
|
1851
1936
|
version: { type: "boolean", short: "v" },
|
|
1852
1937
|
help: { type: "boolean", short: "h" }
|
|
1853
1938
|
},
|
|
@@ -1906,18 +1991,20 @@ function main() {
|
|
|
1906
1991
|
const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
|
|
1907
1992
|
const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
|
|
1908
1993
|
const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
|
|
1909
|
-
const
|
|
1994
|
+
const listenAddr = addr.replace(/^\[(.*)\]$/, "$1");
|
|
1995
|
+
const agentUrl = `http://${listenAddr.includes(":") ? `[${listenAddr}]` : listenAddr}:${port}`;
|
|
1910
1996
|
const { app, manager } = createAgent({
|
|
1911
1997
|
authToken,
|
|
1912
1998
|
allowedOrigins,
|
|
1913
1999
|
bodyLimit,
|
|
1914
2000
|
version: VERSION,
|
|
1915
2001
|
logRequests: flags.verbose ?? false,
|
|
1916
|
-
|
|
2002
|
+
// Browsers omit a default port from `Host`, so the bare names are allowed too.
|
|
2003
|
+
allowedHosts: loopback ? ["127.0.0.1", "localhost", "[::1]", `127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
|
|
1917
2004
|
pairingCode
|
|
1918
2005
|
});
|
|
1919
2006
|
const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
|
|
1920
|
-
const server = app.listen(port,
|
|
2007
|
+
const server = app.listen(port, listenAddr, () => {
|
|
1921
2008
|
const line = "-".repeat(72);
|
|
1922
2009
|
console.log(line);
|
|
1923
2010
|
console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
|
|
@@ -1926,7 +2013,8 @@ function main() {
|
|
|
1926
2013
|
|
|
1927
2014
|
${pairUrl}
|
|
1928
2015
|
`);
|
|
1929
|
-
|
|
2016
|
+
if (flags["print-token"]) console.log(`Auth token: ${authToken}`);
|
|
2017
|
+
else console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : "Auth token: hidden \u2014 run with --print-token to show it for manual setup");
|
|
1930
2018
|
console.log(`Allowed origins: ${allowedOrigins.join(", ")}`);
|
|
1931
2019
|
if (!loopback) {
|
|
1932
2020
|
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.`);
|
|
@@ -1949,5 +2037,7 @@ function main() {
|
|
|
1949
2037
|
};
|
|
1950
2038
|
process.on("SIGINT", shutdown);
|
|
1951
2039
|
process.on("SIGTERM", shutdown);
|
|
2040
|
+
process.on("uncaughtException", (err) => console.error("[agent] Uncaught exception:", err instanceof Error ? err.message : String(err)));
|
|
2041
|
+
process.on("unhandledRejection", (err) => console.error("[agent] Unhandled rejection:", err instanceof Error ? err.message : String(err)));
|
|
1952
2042
|
}
|
|
1953
2043
|
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planche",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite/SQL Server/Oracle/Neon/Supabase/Cloudflare D1 databases from your machine.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://planche.dev",
|