planche 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -16
- package/dist/index.js +399 -4
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -39,8 +39,6 @@ npx planche [options]
|
|
|
39
39
|
-p, --port <n> Port to listen on (default: 8888)
|
|
40
40
|
--addr <host> Address to bind (default: 127.0.0.1)
|
|
41
41
|
--token <str> Auth token (default: random on each start)
|
|
42
|
-
--app <url> Web app to open (default: https://planche.dev)
|
|
43
|
-
--origin <url> Extra browser origin allowed to use the agent (repeatable)
|
|
44
42
|
--no-open Print the pairing link without opening a browser
|
|
45
43
|
--verbose Log every request
|
|
46
44
|
-h, --help Show help
|
|
@@ -53,10 +51,9 @@ Every option can also be set through an environment variable:
|
|
|
53
51
|
| `PLANCHE_PORT` | `--port` | |
|
|
54
52
|
| `PLANCHE_ADDR` | `--addr` | |
|
|
55
53
|
| `PLANCHE_AUTH_TOKEN` | `--token` | Fixed token; the pairing link stays valid across restarts |
|
|
56
|
-
| `PLANCHE_APP_URL` | `--app` | Use a self-hosted build of the app |
|
|
57
|
-
| `PLANCHE_ALLOWED_ORIGINS` | `--origin` | Comma-separated list |
|
|
58
54
|
| `PLANCHE_BODY_LIMIT` | | Max request body, default `50mb` (large SQL scripts) |
|
|
59
55
|
| `PLANCHE_SQLITE_DIR` | | Only SQLite files inside this directory may be opened |
|
|
56
|
+
| `PLANCHE_SSH_CONFIG` | `~/.ssh/config` | SSH client config used to resolve jump-host aliases; `none` disables reading it and `~/.ssh/id_*` |
|
|
60
57
|
|
|
61
58
|
### Examples
|
|
62
59
|
|
|
@@ -72,12 +69,6 @@ Run on another port and print the link only:
|
|
|
72
69
|
npx planche --port 9000 --no-open
|
|
73
70
|
```
|
|
74
71
|
|
|
75
|
-
Use with the app served from your own host:
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
npx planche --app https://planche.example.com
|
|
79
|
-
```
|
|
80
|
-
|
|
81
72
|
Restrict SQLite access to one folder:
|
|
82
73
|
|
|
83
74
|
```bash
|
|
@@ -99,19 +90,35 @@ database connections open and forwards queries with the matching driver
|
|
|
99
90
|
- **SQLite** — opens a file on your disk by path. Set `PLANCHE_SQLITE_DIR` to
|
|
100
91
|
limit which files can be opened. (Small SQLite files can also be opened
|
|
101
92
|
directly in the browser without the agent.)
|
|
93
|
+
- **SSH tunnel** — MySQL and PostgreSQL profiles can go through an SSH jump
|
|
94
|
+
host (password, private key, or the local `ssh-agent`). The agent opens the
|
|
95
|
+
tunnel with `ssh2` on a loopback port and points the driver at it. The host
|
|
96
|
+
may be a `Host` alias from `~/.ssh/config`: `HostName`, `Port`, `User` and
|
|
97
|
+
`IdentityFile` are applied like the `ssh` command does (`ProxyJump` is not
|
|
98
|
+
supported), and with no key given `~/.ssh/id_*` are tried. Unknown
|
|
99
|
+
host keys are trusted on first use and recorded in `~/.planche/known_hosts`
|
|
100
|
+
(entries in `~/.ssh/known_hosts` are honoured too); a changed key refuses the
|
|
101
|
+
connection until the stale entry is removed.
|
|
102
102
|
|
|
103
103
|
## Security
|
|
104
104
|
|
|
105
105
|
- Every request except `GET /health` requires the bearer token.
|
|
106
|
-
- Browser requests are accepted only from the Planche app origin
|
|
107
|
-
dev
|
|
108
|
-
|
|
106
|
+
- Browser requests are accepted only from the Planche app origin
|
|
107
|
+
(`https://planche.dev`). Any other `Origin` is refused with `403`, even
|
|
108
|
+
with a valid token.
|
|
109
109
|
- The agent binds to `127.0.0.1` by default, so only the browser on the same
|
|
110
110
|
machine can reach it. Binding to another address (`--addr 0.0.0.0`) exposes
|
|
111
111
|
your databases to the network — only do that behind a tunnel or firewall you
|
|
112
112
|
trust.
|
|
113
113
|
- The agent never stores credentials. They are passed per connection by the
|
|
114
|
-
app
|
|
114
|
+
app (including SSH passwords and private key contents) and live in memory
|
|
115
|
+
only while the connection is open. The only thing written to disk is the
|
|
116
|
+
SSH host key list in `~/.planche/known_hosts`.
|
|
117
|
+
- For SSH tunnels the agent reads `~/.ssh/config` and, when the profile has no
|
|
118
|
+
key, the `IdentityFile` it names or `~/.ssh/id_*` — exactly what the `ssh`
|
|
119
|
+
command would do, and the key never leaves the agent. Set
|
|
120
|
+
`PLANCHE_SSH_CONFIG=none` if you would rather the agent only used what the
|
|
121
|
+
app sends.
|
|
115
122
|
|
|
116
123
|
## Troubleshooting
|
|
117
124
|
|
|
@@ -120,8 +127,8 @@ database connections open and forwards queries with the matching driver
|
|
|
120
127
|
browser extensions that block `localhost` requests can also cause this.
|
|
121
128
|
- **"Agent restarted, re-pair"** — the agent generated a new token. Open the
|
|
122
129
|
new link it printed, or start it with a fixed `--token`.
|
|
123
|
-
- **`403 Forbidden`** — the request
|
|
124
|
-
|
|
130
|
+
- **`403 Forbidden`** — the request did not come from `https://planche.dev`.
|
|
131
|
+
Open the app from the pairing link the agent printed.
|
|
125
132
|
- **Wrong Node version** — the SQLite driver ships prebuilt binaries for
|
|
126
133
|
Node 22+. Check with `node -v`, or run with `npx -p node@22 planche`.
|
|
127
134
|
|
package/dist/index.js
CHANGED
|
@@ -466,6 +466,347 @@ var sqliteDriverFactory = {
|
|
|
466
466
|
}
|
|
467
467
|
};
|
|
468
468
|
|
|
469
|
+
// src/ssh-tunnel.ts
|
|
470
|
+
import { createServer } from "net";
|
|
471
|
+
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
472
|
+
import { createHash } from "crypto";
|
|
473
|
+
import { homedir as homedir2 } from "os";
|
|
474
|
+
import { join as join2 } from "path";
|
|
475
|
+
import { Client } from "ssh2";
|
|
476
|
+
|
|
477
|
+
// src/ssh-config.ts
|
|
478
|
+
import { readFile } from "fs/promises";
|
|
479
|
+
import { homedir } from "os";
|
|
480
|
+
import { dirname, isAbsolute as isAbsolute2, join } from "path";
|
|
481
|
+
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
482
|
+
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
|
|
483
|
+
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
484
|
+
(f) => join(homedir(), ".ssh", f)
|
|
485
|
+
);
|
|
486
|
+
function expandHome(path) {
|
|
487
|
+
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
488
|
+
}
|
|
489
|
+
function globToRegExp(pattern) {
|
|
490
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
491
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
492
|
+
}
|
|
493
|
+
function hostPatternMatches(patterns, host) {
|
|
494
|
+
let matched = false;
|
|
495
|
+
for (const raw of patterns) {
|
|
496
|
+
const negated = raw.startsWith("!");
|
|
497
|
+
const pattern = negated ? raw.slice(1) : raw;
|
|
498
|
+
if (!globToRegExp(pattern).test(host)) continue;
|
|
499
|
+
if (negated) return false;
|
|
500
|
+
matched = true;
|
|
501
|
+
}
|
|
502
|
+
return matched;
|
|
503
|
+
}
|
|
504
|
+
function tokenize(line) {
|
|
505
|
+
const tokens = [];
|
|
506
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
507
|
+
let m;
|
|
508
|
+
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
509
|
+
return tokens;
|
|
510
|
+
}
|
|
511
|
+
function parseLine(line) {
|
|
512
|
+
const trimmed = line.trim();
|
|
513
|
+
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
514
|
+
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
515
|
+
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
516
|
+
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
517
|
+
}
|
|
518
|
+
async function loadLines(file, seen) {
|
|
519
|
+
if (seen.has(file)) return [];
|
|
520
|
+
seen.add(file);
|
|
521
|
+
try {
|
|
522
|
+
return (await readFile(file, "utf8")).split(/\r?\n/);
|
|
523
|
+
} catch {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
528
|
+
const result = { identityFiles: [] };
|
|
529
|
+
if (!file) return result;
|
|
530
|
+
const seen = /* @__PURE__ */ new Set();
|
|
531
|
+
async function walk(path, active) {
|
|
532
|
+
let blockActive = active;
|
|
533
|
+
for (const line of await loadLines(path, seen)) {
|
|
534
|
+
const d = parseLine(line);
|
|
535
|
+
if (!d) continue;
|
|
536
|
+
switch (d.keyword) {
|
|
537
|
+
case "host":
|
|
538
|
+
blockActive = hostPatternMatches(d.args, host);
|
|
539
|
+
continue;
|
|
540
|
+
case "match":
|
|
541
|
+
blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
|
|
542
|
+
continue;
|
|
543
|
+
case "include":
|
|
544
|
+
for (const inc of d.args) {
|
|
545
|
+
const target = expandHome(inc);
|
|
546
|
+
await walk(isAbsolute2(target) ? target : join(dirname(file), target), blockActive);
|
|
547
|
+
}
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (!blockActive) continue;
|
|
551
|
+
const value = d.args[0];
|
|
552
|
+
if (value === void 0) continue;
|
|
553
|
+
switch (d.keyword) {
|
|
554
|
+
case "hostname":
|
|
555
|
+
result.hostName ??= value.replace(/%h/g, host);
|
|
556
|
+
break;
|
|
557
|
+
case "port":
|
|
558
|
+
result.port ??= Number(value) || void 0;
|
|
559
|
+
break;
|
|
560
|
+
case "user":
|
|
561
|
+
result.user ??= value;
|
|
562
|
+
break;
|
|
563
|
+
case "identityfile":
|
|
564
|
+
for (const f of d.args) result.identityFiles.push(expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host)));
|
|
565
|
+
break;
|
|
566
|
+
case "identityagent":
|
|
567
|
+
result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
|
|
568
|
+
break;
|
|
569
|
+
case "proxyjump":
|
|
570
|
+
result.proxyJump ??= value;
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
await walk(file, true);
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/ssh-tunnel.ts
|
|
580
|
+
var TunnelError = class extends Error {
|
|
581
|
+
constructor(message, status) {
|
|
582
|
+
super(message);
|
|
583
|
+
this.status = status;
|
|
584
|
+
this.name = "TunnelError";
|
|
585
|
+
}
|
|
586
|
+
status;
|
|
587
|
+
};
|
|
588
|
+
var CONNECT_TIMEOUT_MS = 2e4;
|
|
589
|
+
var PLANCHE_DIR = join2(homedir2(), ".planche");
|
|
590
|
+
var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
|
|
591
|
+
var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
|
|
592
|
+
function parseSshConfig(value) {
|
|
593
|
+
if (value === void 0 || value === null) return void 0;
|
|
594
|
+
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
595
|
+
const body = value;
|
|
596
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
597
|
+
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
598
|
+
const auth = body.auth;
|
|
599
|
+
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
600
|
+
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
601
|
+
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
602
|
+
}
|
|
603
|
+
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
604
|
+
const config = {
|
|
605
|
+
host,
|
|
606
|
+
port: Number(body.port) || 22,
|
|
607
|
+
user,
|
|
608
|
+
auth,
|
|
609
|
+
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
610
|
+
privateKey: auth === "key" ? str(body.privateKey) : void 0,
|
|
611
|
+
passphrase: auth === "key" ? str(body.passphrase) : void 0
|
|
612
|
+
};
|
|
613
|
+
return config;
|
|
614
|
+
}
|
|
615
|
+
function keyType(blob) {
|
|
616
|
+
const len = blob.readUInt32BE(0);
|
|
617
|
+
return blob.subarray(4, 4 + len).toString("ascii");
|
|
618
|
+
}
|
|
619
|
+
function hostPatterns(host, port) {
|
|
620
|
+
const h = host.toLowerCase();
|
|
621
|
+
return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
|
|
622
|
+
}
|
|
623
|
+
async function readKnownHosts(file) {
|
|
624
|
+
try {
|
|
625
|
+
return (await readFile2(file, "utf8")).split(/\r?\n/);
|
|
626
|
+
} catch {
|
|
627
|
+
return [];
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async function lookupHostKey(host, port, blob) {
|
|
631
|
+
const patterns = hostPatterns(host, port);
|
|
632
|
+
const type = keyType(blob);
|
|
633
|
+
const encoded = blob.toString("base64");
|
|
634
|
+
let sawType = false;
|
|
635
|
+
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
636
|
+
for (const raw of lines) {
|
|
637
|
+
const line = raw.trim();
|
|
638
|
+
if (!line || line.startsWith("#") || line.startsWith("@") || line.startsWith("|")) continue;
|
|
639
|
+
const [hosts, lineType, lineKey] = line.split(/\s+/);
|
|
640
|
+
if (!hosts || !lineType || !lineKey) continue;
|
|
641
|
+
if (!hosts.toLowerCase().split(",").some((h) => patterns.includes(h))) continue;
|
|
642
|
+
if (lineType !== type) continue;
|
|
643
|
+
if (lineKey === encoded) return "match";
|
|
644
|
+
sawType = true;
|
|
645
|
+
}
|
|
646
|
+
return sawType ? "mismatch" : "unknown";
|
|
647
|
+
}
|
|
648
|
+
async function rememberHostKey(host, port, blob) {
|
|
649
|
+
await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
|
|
650
|
+
const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
|
|
651
|
+
await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
|
|
652
|
+
`, { mode: 384 });
|
|
653
|
+
}
|
|
654
|
+
function fingerprint(blob) {
|
|
655
|
+
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
|
656
|
+
}
|
|
657
|
+
async function resolveConfig(ssh) {
|
|
658
|
+
const cfg = await resolveSshHost(ssh.host);
|
|
659
|
+
if (cfg.proxyJump) {
|
|
660
|
+
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
661
|
+
}
|
|
662
|
+
const resolved = {
|
|
663
|
+
...ssh,
|
|
664
|
+
host: cfg.hostName ?? ssh.host,
|
|
665
|
+
port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
|
|
666
|
+
user: ssh.user || cfg.user || ""
|
|
667
|
+
};
|
|
668
|
+
if (!resolved.user) {
|
|
669
|
+
throw new TunnelError(
|
|
670
|
+
SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
|
|
671
|
+
400
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (resolved.auth === "key" && !resolved.privateKey) {
|
|
675
|
+
if (SSH_CONFIG_DISABLED) {
|
|
676
|
+
throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
|
|
677
|
+
}
|
|
678
|
+
const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
|
|
679
|
+
for (const file of candidates) {
|
|
680
|
+
try {
|
|
681
|
+
resolved.privateKey = await readFile2(file, "utf8");
|
|
682
|
+
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
683
|
+
break;
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (!resolved.privateKey) {
|
|
688
|
+
throw new TunnelError(
|
|
689
|
+
`No private key given and none readable for ${ssh.host} (tried ${candidates.join(", ")})`,
|
|
690
|
+
400
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (resolved.auth === "agent" && cfg.identityAgent && !process.env.SSH_AUTH_SOCK) {
|
|
695
|
+
process.env.SSH_AUTH_SOCK = cfg.identityAgent;
|
|
696
|
+
}
|
|
697
|
+
return resolved;
|
|
698
|
+
}
|
|
699
|
+
function toConnectConfig(ssh) {
|
|
700
|
+
const base = {
|
|
701
|
+
host: ssh.host,
|
|
702
|
+
port: ssh.port,
|
|
703
|
+
username: ssh.user,
|
|
704
|
+
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
705
|
+
keepaliveInterval: 3e4
|
|
706
|
+
};
|
|
707
|
+
switch (ssh.auth) {
|
|
708
|
+
case "password":
|
|
709
|
+
return { ...base, password: ssh.password ?? "", tryKeyboard: true };
|
|
710
|
+
case "key":
|
|
711
|
+
return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
|
|
712
|
+
case "agent": {
|
|
713
|
+
const agent = process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "pageant" : void 0);
|
|
714
|
+
if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
|
|
715
|
+
return { ...base, agent };
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function describeSshError(err, ssh) {
|
|
720
|
+
const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
|
|
721
|
+
if (err instanceof TunnelError) return err;
|
|
722
|
+
switch (err.level) {
|
|
723
|
+
case "client-authentication":
|
|
724
|
+
return new TunnelError(`SSH authentication failed for ${target}`, 401);
|
|
725
|
+
case "client-timeout":
|
|
726
|
+
return new TunnelError(`SSH connection to ${target} timed out`, 400);
|
|
727
|
+
default:
|
|
728
|
+
return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async function openSshTunnel(requested, target) {
|
|
732
|
+
const ssh = await resolveConfig(requested);
|
|
733
|
+
const client = new Client();
|
|
734
|
+
const connectConfig = toConnectConfig(ssh);
|
|
735
|
+
let verifyError;
|
|
736
|
+
connectConfig.hostVerifier = (key, verified) => {
|
|
737
|
+
void lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
|
|
738
|
+
if (result === "mismatch") {
|
|
739
|
+
verifyError = new TunnelError(
|
|
740
|
+
`Host key for ${ssh.host}:${ssh.port} has changed (${fingerprint(key)}). Remove the old entry from ~/.ssh/known_hosts or ${PLANCHE_KNOWN_HOSTS} if this is expected.`,
|
|
741
|
+
400
|
|
742
|
+
);
|
|
743
|
+
verified(false);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (result === "unknown") {
|
|
747
|
+
console.log(`[ssh] Trusting new host key for ${ssh.host}:${ssh.port} (${fingerprint(key)}), saved to ${PLANCHE_KNOWN_HOSTS}`);
|
|
748
|
+
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
749
|
+
}
|
|
750
|
+
verified(true);
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
await new Promise((resolve2, reject) => {
|
|
754
|
+
client.once("ready", resolve2);
|
|
755
|
+
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
756
|
+
try {
|
|
757
|
+
client.connect(connectConfig);
|
|
758
|
+
} catch (err) {
|
|
759
|
+
reject(describeSshError(err, ssh));
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
763
|
+
let forwardError;
|
|
764
|
+
const server = createServer((socket) => {
|
|
765
|
+
sockets.add(socket);
|
|
766
|
+
socket.once("close", () => sockets.delete(socket));
|
|
767
|
+
client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
768
|
+
if (err) {
|
|
769
|
+
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
770
|
+
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?";
|
|
771
|
+
forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
|
|
772
|
+
console.error(`[ssh] ${forwardError}`);
|
|
773
|
+
socket.destroy();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
socket.pipe(stream).pipe(socket);
|
|
777
|
+
stream.once("close", () => socket.destroy());
|
|
778
|
+
socket.once("close", () => stream.destroy());
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
await new Promise((resolve2, reject) => {
|
|
782
|
+
server.once("error", reject);
|
|
783
|
+
server.listen(0, "127.0.0.1", () => resolve2());
|
|
784
|
+
}).catch((err) => {
|
|
785
|
+
client.end();
|
|
786
|
+
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
787
|
+
});
|
|
788
|
+
const { port } = server.address();
|
|
789
|
+
let closed = false;
|
|
790
|
+
const close = () => new Promise((resolve2) => {
|
|
791
|
+
if (closed) return resolve2();
|
|
792
|
+
closed = true;
|
|
793
|
+
for (const s of sockets) s.destroy();
|
|
794
|
+
server.close(() => {
|
|
795
|
+
client.end();
|
|
796
|
+
resolve2();
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
client.once("close", () => void close());
|
|
800
|
+
return {
|
|
801
|
+
host: "127.0.0.1",
|
|
802
|
+
port,
|
|
803
|
+
get forwardError() {
|
|
804
|
+
return forwardError;
|
|
805
|
+
},
|
|
806
|
+
close
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
469
810
|
// src/drivers/index.ts
|
|
470
811
|
var factories = {
|
|
471
812
|
mysql: mysqlDriverFactory,
|
|
@@ -480,9 +821,53 @@ function parseDriverConfig(body) {
|
|
|
480
821
|
if (!isDriverType(driver)) {
|
|
481
822
|
throw new Error(`Unsupported driver: ${String(driver)}`);
|
|
482
823
|
}
|
|
483
|
-
|
|
824
|
+
const config = factories[driver].parseConfig(body);
|
|
825
|
+
if (config.driver === "sqlite") return config;
|
|
826
|
+
const ssh = parseSshConfig(body.ssh);
|
|
827
|
+
return ssh ? { ...config, ssh } : config;
|
|
828
|
+
}
|
|
829
|
+
async function withTunnel(config, run) {
|
|
830
|
+
if (config.driver === "sqlite" || !config.ssh) return run(config, void 0);
|
|
831
|
+
const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
|
|
832
|
+
try {
|
|
833
|
+
return await run({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
|
|
834
|
+
} catch (err) {
|
|
835
|
+
await tunnel.close();
|
|
836
|
+
throw tunnel.forwardError ? new TunnelError(tunnel.forwardError, 400) : err;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function tunneled(driver, tunnel, label) {
|
|
840
|
+
return {
|
|
841
|
+
type: driver.type,
|
|
842
|
+
label,
|
|
843
|
+
query: (sql, database) => driver.query(sql, database),
|
|
844
|
+
async close() {
|
|
845
|
+
try {
|
|
846
|
+
await driver.close();
|
|
847
|
+
} finally {
|
|
848
|
+
await tunnel.close();
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
};
|
|
484
852
|
}
|
|
485
853
|
function openDriver(config) {
|
|
854
|
+
return withTunnel(config, async (direct, tunnel) => {
|
|
855
|
+
const driver = await openDirect(direct);
|
|
856
|
+
if (!tunnel || config.driver === "sqlite" || !config.ssh) return driver;
|
|
857
|
+
const label = `${config.user}@${config.host}:${config.port} via ssh ${config.ssh.user}@${config.ssh.host}:${config.ssh.port}`;
|
|
858
|
+
return tunneled(driver, tunnel, label);
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
function testDriver(config) {
|
|
862
|
+
return withTunnel(config, async (direct, tunnel) => {
|
|
863
|
+
try {
|
|
864
|
+
await testDirect(direct);
|
|
865
|
+
} finally {
|
|
866
|
+
await tunnel?.close();
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
function openDirect(config) {
|
|
486
871
|
switch (config.driver) {
|
|
487
872
|
case "mysql":
|
|
488
873
|
return factories.mysql.open(config);
|
|
@@ -492,7 +877,7 @@ function openDriver(config) {
|
|
|
492
877
|
return factories.sqlite.open(config);
|
|
493
878
|
}
|
|
494
879
|
}
|
|
495
|
-
function
|
|
880
|
+
function testDirect(config) {
|
|
496
881
|
switch (config.driver) {
|
|
497
882
|
case "mysql":
|
|
498
883
|
return factories.mysql.test(config);
|
|
@@ -594,6 +979,10 @@ var ConnectionManager = class {
|
|
|
594
979
|
};
|
|
595
980
|
|
|
596
981
|
// src/server.ts
|
|
982
|
+
function errorStatus(err) {
|
|
983
|
+
const status = err?.status;
|
|
984
|
+
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
985
|
+
}
|
|
597
986
|
function fail(res, status, message) {
|
|
598
987
|
res.status(status).json({ success: false, message });
|
|
599
988
|
}
|
|
@@ -679,7 +1068,7 @@ function createAgent(options) {
|
|
|
679
1068
|
} catch (err) {
|
|
680
1069
|
const message = errorMessage(err);
|
|
681
1070
|
console.error("[connect] Error:", message);
|
|
682
|
-
fail(res,
|
|
1071
|
+
fail(res, errorStatus(err), message);
|
|
683
1072
|
}
|
|
684
1073
|
});
|
|
685
1074
|
app.post("/query", async (req, res) => {
|
|
@@ -737,7 +1126,7 @@ function createAgent(options) {
|
|
|
737
1126
|
} catch (err) {
|
|
738
1127
|
const message = errorMessage(err);
|
|
739
1128
|
console.error("[test-connection] Error:", message);
|
|
740
|
-
fail(res,
|
|
1129
|
+
fail(res, errorStatus(err), message);
|
|
741
1130
|
}
|
|
742
1131
|
});
|
|
743
1132
|
return { app, manager };
|
|
@@ -788,6 +1177,12 @@ function openInBrowser(url) {
|
|
|
788
1177
|
}
|
|
789
1178
|
}
|
|
790
1179
|
function main() {
|
|
1180
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
1181
|
+
if (major < 22) {
|
|
1182
|
+
console.error(`planche needs Node.js 22 or newer (you are running ${process.version}).`);
|
|
1183
|
+
console.error("Try `nvm use 22` or `npx -p node@22 planche`.");
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
}
|
|
791
1186
|
let values;
|
|
792
1187
|
try {
|
|
793
1188
|
values = parseArgs({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planche",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite databases from your machine.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://planche.dev",
|
|
@@ -43,13 +43,15 @@
|
|
|
43
43
|
"better-sqlite3": "^12.11.1",
|
|
44
44
|
"express": "^5.1.0",
|
|
45
45
|
"mysql2": "^3.14.1",
|
|
46
|
-
"pg": "^8.23.0"
|
|
46
|
+
"pg": "^8.23.0",
|
|
47
|
+
"ssh2": "^1.17.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"@types/better-sqlite3": "^9.6.0",
|
|
50
51
|
"@types/express": "^5.0.0",
|
|
51
52
|
"@types/node": "^24.0.0",
|
|
52
53
|
"@types/pg": "^8.23.1",
|
|
54
|
+
"@types/ssh2": "^1.15.5",
|
|
53
55
|
"tsup": "^8.5.0",
|
|
54
56
|
"tsx": "^4.19.0",
|
|
55
57
|
"typescript": "^5.9.0"
|