planche 0.5.2 → 0.6.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 +39 -0
- package/app/_headers +19 -0
- package/app/assets/AboutDialog-HsaLhYrq.js +1 -0
- package/app/assets/ConnectDialog-DAYnaSzA.js +1 -0
- package/app/assets/CopyDatabaseDialog-DCDkX1Zt.js +1 -0
- package/app/assets/CopyTableDialog-BylicT1d.js +1 -0
- package/app/assets/CreateDatabaseDialog-i7_uV-wh.js +1 -0
- package/app/assets/DataDiffDialog-B4SeJXry.js +1 -0
- package/app/assets/DownloadToCSVDialog-jX0lEP8B.js +3 -0
- package/app/assets/EditSchemaDialog-BVOHVgBR.js +1 -0
- package/app/assets/FindReplaceDialog-B0rD26fg.js +1 -0
- package/app/assets/FlushDialog-CNu4QuYo.js +1 -0
- package/app/assets/LinkColumnDialog-BwUBtMiy.js +1 -0
- package/app/assets/LocalHistoryDialog-DUlsyn8i.js +1 -0
- package/app/assets/ProcessListDialog-Chv324I7.js +1 -0
- package/app/assets/QuickCommandPalette-BG5A7y3V.js +1 -0
- package/app/assets/RowFormDialog-C5agNu3l.js +2 -0
- package/app/assets/SafetySettingsDialog-iaN6H-EP.js +1 -0
- package/app/assets/SchemaDiffDialog-D7re7sup.js +7 -0
- package/app/assets/SchemaToHTMLDialog-Biq3ELkl.js +27 -0
- package/app/assets/StatusDialog-D8CcfFkP.js +1 -0
- package/app/assets/TermsDialog-DbHxb0ld.js +1 -0
- package/app/assets/UserAddDialog-B_fz-cXy.js +1 -0
- package/app/assets/UserManagerDialog-ku7exEzu.js +1 -0
- package/app/assets/VariablesDialog-BP2RcCBK.js +1 -0
- package/app/assets/abap-08VXUWAP.js +1 -0
- package/app/assets/apex-BWPQTe0t.js +1 -0
- package/app/assets/app-storage-B6v72zLH.js +1 -0
- package/app/assets/azcli-Bc_sGQ0U.js +1 -0
- package/app/assets/bat-i0X4ZdIN.js +1 -0
- package/app/assets/bicep-B5-_aFwp.js +2 -0
- package/app/assets/button-DkRlMkru.js +1 -0
- package/app/assets/cameligo-DMUM7wLl.js +1 -0
- package/app/assets/chevron-up-Dy_mz-03.js +1 -0
- package/app/assets/clojure-Cm7r79vr.js +1 -0
- package/app/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/app/assets/coffee-Ba7i2nA0.js +1 -0
- package/app/assets/connection-store-CJ_al8qv.js +1 -0
- package/app/assets/cpp-C7h46wYY.js +1 -0
- package/app/assets/csharp-BKxtCVv1.js +1 -0
- package/app/assets/csp-bTuwJoIa.js +1 -0
- package/app/assets/css-DIMkf-bt.js +3 -0
- package/app/assets/css.worker-Dsm2X3j_.js +89 -0
- package/app/assets/cssMode-BK7_e-YI.js +1 -0
- package/app/assets/cypher-CVaqCwHa.js +1 -0
- package/app/assets/dart-onAF5SnQ.js +1 -0
- package/app/assets/database-BIhkgl2O.js +1 -0
- package/app/assets/dbms-Cq4imB7l.js +353 -0
- package/app/assets/de-B_f2X0cM.js +2 -0
- package/app/assets/dialog-BtwqThq6.js +1 -0
- package/app/assets/dist-BV6Hwye5.js +41 -0
- package/app/assets/dist-C2pydad-.js +1 -0
- package/app/assets/dist-C3MLFgG1.js +1 -0
- package/app/assets/dist-Roudme38.js +3 -0
- package/app/assets/dockerfile-DZFCIeNp.js +1 -0
- package/app/assets/ecl-D05T4iGw.js +1 -0
- package/app/assets/editor-Br_kD0ds.css +1 -0
- package/app/assets/editor.api2-JBjomUWM.js +872 -0
- package/app/assets/editor.worker-BboMRUer.js +26 -0
- package/app/assets/elixir-6RTg0lbw.js +1 -0
- package/app/assets/engine-DBTzC3si.js +1 -0
- package/app/assets/file-name-CnYBZPf9.js +1 -0
- package/app/assets/flow9-C5_-GSwl.js +1 -0
- package/app/assets/fr-DigYoMZT.js +2 -0
- package/app/assets/freemarker2-Ca8y5qEc.js +3 -0
- package/app/assets/fsharp-C8Ef5oNN.js +1 -0
- package/app/assets/go-C-y9NEjX.js +1 -0
- package/app/assets/graphql-fmXr3nnJ.js +1 -0
- package/app/assets/handlebars-CMoDOM1Y.js +1 -0
- package/app/assets/hcl-CpzslTdj.js +1 -0
- package/app/assets/html-BOS8Hfbf.js +1 -0
- package/app/assets/html.worker-fD12ABR7.js +502 -0
- package/app/assets/htmlMode-CRjI81Qd.js +1 -0
- package/app/assets/i18n-C7R6hKCp.js +2 -0
- package/app/assets/ini-sBoK_t0W.js +1 -0
- package/app/assets/ja-CPMeuUuT.js +2 -0
- package/app/assets/java-BEtHBSE6.js +1 -0
- package/app/assets/javascript-2ntxzhMa.js +1 -0
- package/app/assets/json.worker-B2CIyALT.js +58 -0
- package/app/assets/jsonMode-C8yGgOEo.js +7 -0
- package/app/assets/julia-Bri6UV-V.js +1 -0
- package/app/assets/ko-IAtUoTg5.js +2 -0
- package/app/assets/kotlin-BOotOW0E.js +1 -0
- package/app/assets/less-B9JPFI3C.js +2 -0
- package/app/assets/lexon-CfSJPG6W.js +1 -0
- package/app/assets/liquid-DThFAxbS.js +1 -0
- package/app/assets/lspLanguageFeatures-DQW0rv3P.js +4 -0
- package/app/assets/lua-CsQS60Ue.js +1 -0
- package/app/assets/m3-D-oSqn_W.js +1 -0
- package/app/assets/main-DI8DxG3x.css +2 -0
- package/app/assets/main-NJl8KiiX.js +73 -0
- package/app/assets/markdown-Cimd5fb3.js +1 -0
- package/app/assets/mdx-CI71gDmg.js +1 -0
- package/app/assets/mips-CIPQ_RoX.js +1 -0
- package/app/assets/msdax-DauUninz.js +1 -0
- package/app/assets/mysql-SOo6toE5.js +1 -0
- package/app/assets/objective-c-FvmIjYaQ.js +1 -0
- package/app/assets/pascal-DrH0SRf2.js +1 -0
- package/app/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/app/assets/perl-oz_6vUea.js +1 -0
- package/app/assets/pgsql-DTj74zXo.js +1 -0
- package/app/assets/php-nr791fC2.js +1 -0
- package/app/assets/pla-CopQ2nXW.js +1 -0
- package/app/assets/postiats-43DmfD33.js +1 -0
- package/app/assets/powerquery-D3hlyOfw.js +1 -0
- package/app/assets/powershell-DmHpPYUd.js +1 -0
- package/app/assets/preload-helper-Czpn1I53.js +1 -0
- package/app/assets/protobuf-C531GsRP.js +2 -0
- package/app/assets/pug-Z5eAx3Zn.js +1 -0
- package/app/assets/python-Ck7s8L2p.js +1 -0
- package/app/assets/qsharp-DkqhCAOL.js +1 -0
- package/app/assets/query-execution-CR2mkIdH.js +7 -0
- package/app/assets/query-store-DlzOIWw1.js +1 -0
- package/app/assets/r-BwWrilGY.js +1 -0
- package/app/assets/razor-DiN-9kkz.js +1 -0
- package/app/assets/redis-ClamHrr6.js +1 -0
- package/app/assets/redshift-DT7zqm-g.js +1 -0
- package/app/assets/restructuredtext-BYgofb2h.js +1 -0
- package/app/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/app/assets/ruby-DezsRK8O.js +1 -0
- package/app/assets/rust-DdL9SqIa.js +1 -0
- package/app/assets/sb-CcwsVR0C.js +1 -0
- package/app/assets/scala-DHpiXF5c.js +1 -0
- package/app/assets/schema-nodes-n_QZJb21.js +1 -0
- package/app/assets/scheme-BeGwcela.js +1 -0
- package/app/assets/scss-gp-XZpBa.js +3 -0
- package/app/assets/select-CqYxAwnu.js +1 -0
- package/app/assets/settings-store-DxKFgC4Z.js +1 -0
- package/app/assets/shell-CC2rA5mh.js +1 -0
- package/app/assets/solidity-BEEn4gHE.js +1 -0
- package/app/assets/sophia-CRfGWb83.js +1 -0
- package/app/assets/sparql-D_Lu-MrJ.js +1 -0
- package/app/assets/sql-NEE52Syq.js +1 -0
- package/app/assets/sqlite3-BVKGSWc-.wasm +0 -0
- package/app/assets/sqlite3-opfs-async-proxy-D_xnb1D8.js +876 -0
- package/app/assets/sqlite3-worker1-CVKz6JuG.js +43 -0
- package/app/assets/st-DbInun42.js +1 -0
- package/app/assets/storage-worker-DEIYmjYK.js +43 -0
- package/app/assets/swift-Bxkupp3x.js +1 -0
- package/app/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/app/assets/table-copy-2y2RNth1.js +1 -0
- package/app/assets/table-definition-Bs5VSn0X.js +1 -0
- package/app/assets/tcl-DISqw1ZD.js +1 -0
- package/app/assets/ts.worker-BwG70EDZ.js +67734 -0
- package/app/assets/tsMode-Dzctobqp.js +11 -0
- package/app/assets/twig-De2hgUGE.js +1 -0
- package/app/assets/typescript-Ww5oHWDp.js +1 -0
- package/app/assets/typespec-B8J7ngcE.js +1 -0
- package/app/assets/ui-store-BPY9VfQP.js +1 -0
- package/app/assets/use-engine-F2Y_trPz.js +1 -0
- package/app/assets/user-store-D5dzuD-E.js +1 -0
- package/app/assets/vb-DV3o63ZY.js +1 -0
- package/app/assets/wgsl-DpFanUEy.js +298 -0
- package/app/assets/xml-CT99FsSc.js +1 -0
- package/app/assets/yaml-DA78fpmA.js +1 -0
- package/app/assets/zh-DCKqmOQA.js +2 -0
- package/app/cloudflare-d1-client/index.html +111 -0
- package/app/de/cloudflare-d1-client/index.html +111 -0
- package/app/de/index.html +123 -0
- package/app/de/mariadb-client/index.html +111 -0
- package/app/de/mysql-client/index.html +112 -0
- package/app/de/neon-client/index.html +111 -0
- package/app/de/oracle-client/index.html +111 -0
- package/app/de/postgresql-client/index.html +111 -0
- package/app/de/sql-server-client/index.html +111 -0
- package/app/de/sqlite-viewer/index.html +112 -0
- package/app/de/supabase-client/index.html +111 -0
- package/app/favicon.svg +1 -0
- package/app/fr/cloudflare-d1-client/index.html +111 -0
- package/app/fr/index.html +123 -0
- package/app/fr/mariadb-client/index.html +111 -0
- package/app/fr/mysql-client/index.html +112 -0
- package/app/fr/neon-client/index.html +111 -0
- package/app/fr/oracle-client/index.html +111 -0
- package/app/fr/postgresql-client/index.html +111 -0
- package/app/fr/sql-server-client/index.html +111 -0
- package/app/fr/sqlite-viewer/index.html +112 -0
- package/app/fr/supabase-client/index.html +111 -0
- package/app/icons.svg +24 -0
- package/app/index.html +123 -0
- package/app/ja/cloudflare-d1-client/index.html +111 -0
- package/app/ja/index.html +123 -0
- package/app/ja/mariadb-client/index.html +111 -0
- package/app/ja/mysql-client/index.html +112 -0
- package/app/ja/neon-client/index.html +111 -0
- package/app/ja/oracle-client/index.html +111 -0
- package/app/ja/postgresql-client/index.html +111 -0
- package/app/ja/sql-server-client/index.html +111 -0
- package/app/ja/sqlite-viewer/index.html +112 -0
- package/app/ja/supabase-client/index.html +111 -0
- package/app/ko/cloudflare-d1-client/index.html +111 -0
- package/app/ko/index.html +123 -0
- package/app/ko/mariadb-client/index.html +111 -0
- package/app/ko/mysql-client/index.html +112 -0
- package/app/ko/neon-client/index.html +111 -0
- package/app/ko/oracle-client/index.html +111 -0
- package/app/ko/postgresql-client/index.html +111 -0
- package/app/ko/sql-server-client/index.html +111 -0
- package/app/ko/sqlite-viewer/index.html +112 -0
- package/app/ko/supabase-client/index.html +111 -0
- package/app/landing.css +25 -0
- package/app/mariadb-client/index.html +111 -0
- package/app/mysql-client/index.html +112 -0
- package/app/neon-client/index.html +111 -0
- package/app/og-image.png +0 -0
- package/app/oracle-client/index.html +111 -0
- package/app/postgresql-client/index.html +111 -0
- package/app/robots.txt +3 -0
- package/app/sample-shop.db +0 -0
- package/app/sitemap.xml +603 -0
- package/app/sql-server-client/index.html +111 -0
- package/app/sqlite-viewer/index.html +112 -0
- package/app/supabase-client/index.html +111 -0
- package/app/zh/cloudflare-d1-client/index.html +111 -0
- package/app/zh/index.html +123 -0
- package/app/zh/mariadb-client/index.html +111 -0
- package/app/zh/mysql-client/index.html +112 -0
- package/app/zh/neon-client/index.html +111 -0
- package/app/zh/oracle-client/index.html +111 -0
- package/app/zh/postgresql-client/index.html +111 -0
- package/app/zh/sql-server-client/index.html +111 -0
- package/app/zh/sqlite-viewer/index.html +112 -0
- package/app/zh/supabase-client/index.html +111 -0
- package/dist/index.js +990 -659
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -6,421 +6,9 @@ import { createRequire } from "module";
|
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
7
|
import { parseArgs } from "util";
|
|
8
8
|
|
|
9
|
-
// src/
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
// ../shared/contract.ts
|
|
14
|
-
var PROTOCOL_VERSION = 2;
|
|
15
|
-
|
|
16
|
-
// src/ssh-tunnel.ts
|
|
17
|
-
import { createServer } from "net";
|
|
18
|
-
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
19
|
-
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
20
|
-
import { homedir as homedir2 } from "os";
|
|
21
|
-
import { basename as basename2, join as join2 } from "path";
|
|
22
|
-
import { Client } from "ssh2";
|
|
23
|
-
|
|
24
|
-
// src/ssh-config.ts
|
|
25
|
-
import { readFile, readdir } from "fs/promises";
|
|
26
|
-
import { homedir } from "os";
|
|
27
|
-
import { dirname, isAbsolute, join, basename } from "path";
|
|
28
|
-
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
29
|
-
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
|
|
30
|
-
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
31
|
-
(f) => join(homedir(), ".ssh", f)
|
|
32
|
-
);
|
|
33
|
-
function expandHome(path) {
|
|
34
|
-
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
35
|
-
}
|
|
36
|
-
function globToRegExp(pattern) {
|
|
37
|
-
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
38
|
-
return new RegExp(`^${escaped}$`, "i");
|
|
39
|
-
}
|
|
40
|
-
function hostPatternMatches(patterns, host) {
|
|
41
|
-
let matched = false;
|
|
42
|
-
for (const raw of patterns) {
|
|
43
|
-
const negated = raw.startsWith("!");
|
|
44
|
-
const pattern = negated ? raw.slice(1) : raw;
|
|
45
|
-
if (!globToRegExp(pattern).test(host)) continue;
|
|
46
|
-
if (negated) return false;
|
|
47
|
-
matched = true;
|
|
48
|
-
}
|
|
49
|
-
return matched;
|
|
50
|
-
}
|
|
51
|
-
function tokenize(line) {
|
|
52
|
-
const tokens = [];
|
|
53
|
-
const re = /"([^"]*)"|(\S+)/g;
|
|
54
|
-
let m;
|
|
55
|
-
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
56
|
-
return tokens;
|
|
57
|
-
}
|
|
58
|
-
function parseLine(line) {
|
|
59
|
-
const trimmed = line.trim();
|
|
60
|
-
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
61
|
-
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
62
|
-
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
63
|
-
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
64
|
-
}
|
|
65
|
-
async function expandGlob(path) {
|
|
66
|
-
const base = basename(path);
|
|
67
|
-
if (!/[*?]/.test(base)) return [path];
|
|
68
|
-
const dir = dirname(path);
|
|
69
|
-
try {
|
|
70
|
-
const re = globToRegExp(base);
|
|
71
|
-
return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join(dir, name));
|
|
72
|
-
} catch {
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
async function loadLines(file, seen) {
|
|
77
|
-
if (seen.has(file)) return [];
|
|
78
|
-
seen.add(file);
|
|
79
|
-
try {
|
|
80
|
-
return (await readFile(file, "utf8")).split(/\r?\n/);
|
|
81
|
-
} catch {
|
|
82
|
-
return [];
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
86
|
-
const result = { identityFiles: [] };
|
|
87
|
-
if (!file) return result;
|
|
88
|
-
const seen = /* @__PURE__ */ new Set();
|
|
89
|
-
async function walk(path, active) {
|
|
90
|
-
let blockActive = active;
|
|
91
|
-
for (const line of await loadLines(path, seen)) {
|
|
92
|
-
const d = parseLine(line);
|
|
93
|
-
if (!d) continue;
|
|
94
|
-
switch (d.keyword) {
|
|
95
|
-
case "host":
|
|
96
|
-
blockActive = hostPatternMatches(d.args, host);
|
|
97
|
-
continue;
|
|
98
|
-
case "match":
|
|
99
|
-
blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
|
|
100
|
-
continue;
|
|
101
|
-
case "include":
|
|
102
|
-
for (const inc of d.args) {
|
|
103
|
-
const target = expandHome(inc);
|
|
104
|
-
const abs = isAbsolute(target) ? target : join(homedir(), ".ssh", target);
|
|
105
|
-
for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
|
|
106
|
-
}
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (!blockActive) continue;
|
|
110
|
-
const value = d.args[0];
|
|
111
|
-
if (value === void 0) continue;
|
|
112
|
-
switch (d.keyword) {
|
|
113
|
-
case "hostname":
|
|
114
|
-
result.hostName ??= value.replace(/%h/g, host);
|
|
115
|
-
break;
|
|
116
|
-
case "port":
|
|
117
|
-
result.port ??= Number(value) || void 0;
|
|
118
|
-
break;
|
|
119
|
-
case "user":
|
|
120
|
-
result.user ??= value;
|
|
121
|
-
break;
|
|
122
|
-
case "identityfile":
|
|
123
|
-
for (const f of d.args) {
|
|
124
|
-
const expanded = expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host));
|
|
125
|
-
if (expanded.split(/[\\/]/).includes("..")) continue;
|
|
126
|
-
result.identityFiles.push(expanded);
|
|
127
|
-
}
|
|
128
|
-
break;
|
|
129
|
-
case "identityagent":
|
|
130
|
-
result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
|
|
131
|
-
break;
|
|
132
|
-
case "proxyjump":
|
|
133
|
-
result.proxyJump ??= value;
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
await walk(file, true);
|
|
139
|
-
return result;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// src/ssh-tunnel.ts
|
|
143
|
-
var TunnelError = class extends Error {
|
|
144
|
-
constructor(message, status) {
|
|
145
|
-
super(message);
|
|
146
|
-
this.status = status;
|
|
147
|
-
this.name = "TunnelError";
|
|
148
|
-
}
|
|
149
|
-
status;
|
|
150
|
-
};
|
|
151
|
-
var HostKeyError = class extends TunnelError {
|
|
152
|
-
hostKey;
|
|
153
|
-
constructor(host, port, type, fingerprint2) {
|
|
154
|
-
super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
|
|
155
|
-
this.name = "HostKeyError";
|
|
156
|
-
this.hostKey = { host, port, type, fingerprint: fingerprint2 };
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
var CONNECT_TIMEOUT_MS = 2e4;
|
|
160
|
-
var PLANCHE_DIR = join2(homedir2(), ".planche");
|
|
161
|
-
var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
|
|
162
|
-
var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
|
|
163
|
-
function parseSshConfig(value) {
|
|
164
|
-
if (value === void 0 || value === null) return void 0;
|
|
165
|
-
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
166
|
-
const body = value;
|
|
167
|
-
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
168
|
-
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
169
|
-
const auth = body.auth;
|
|
170
|
-
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
171
|
-
if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
|
|
172
|
-
if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
|
|
173
|
-
const port = Math.trunc(Number(body.port)) || 22;
|
|
174
|
-
if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
|
|
175
|
-
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
176
|
-
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
177
|
-
}
|
|
178
|
-
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
179
|
-
const config = {
|
|
180
|
-
host,
|
|
181
|
-
port,
|
|
182
|
-
user,
|
|
183
|
-
auth,
|
|
184
|
-
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
185
|
-
privateKey: auth === "key" ? str(body.privateKey) : void 0,
|
|
186
|
-
passphrase: auth === "key" ? str(body.passphrase) : void 0,
|
|
187
|
-
acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
|
|
188
|
-
};
|
|
189
|
-
return config;
|
|
190
|
-
}
|
|
191
|
-
function keyType(blob) {
|
|
192
|
-
if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
|
|
193
|
-
const len = blob.readUInt32BE(0);
|
|
194
|
-
if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
|
|
195
|
-
const type = blob.subarray(4, 4 + len).toString("ascii");
|
|
196
|
-
if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
|
|
197
|
-
return type;
|
|
198
|
-
}
|
|
199
|
-
function knownHostsEntryMatches(field, patterns) {
|
|
200
|
-
if (field.startsWith("|1|")) {
|
|
201
|
-
const [, , salt, hash] = field.split("|");
|
|
202
|
-
if (!salt || !hash) return false;
|
|
203
|
-
const key = Buffer.from(salt, "base64");
|
|
204
|
-
const want = Buffer.from(hash, "base64");
|
|
205
|
-
return patterns.some((p) => {
|
|
206
|
-
const got = createHmac("sha1", key).update(p).digest();
|
|
207
|
-
return got.length === want.length && timingSafeEqual(got, want);
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
const entries = field.toLowerCase().split(",");
|
|
211
|
-
return patterns.some((p) => hostPatternMatches(entries, p));
|
|
212
|
-
}
|
|
213
|
-
function hostPatterns(host, port) {
|
|
214
|
-
const h = host.toLowerCase();
|
|
215
|
-
return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
|
|
216
|
-
}
|
|
217
|
-
async function readKnownHosts(file) {
|
|
218
|
-
try {
|
|
219
|
-
return (await readFile2(file, "utf8")).split(/\r?\n/);
|
|
220
|
-
} catch {
|
|
221
|
-
return [];
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
async function lookupHostKey(host, port, blob) {
|
|
225
|
-
const patterns = hostPatterns(host, port);
|
|
226
|
-
const type = keyType(blob);
|
|
227
|
-
const encoded = blob.toString("base64");
|
|
228
|
-
let sawHost = false;
|
|
229
|
-
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
230
|
-
for (const raw of lines) {
|
|
231
|
-
const line = raw.trim();
|
|
232
|
-
if (!line || line.startsWith("#")) continue;
|
|
233
|
-
const fields = line.split(/\s+/);
|
|
234
|
-
const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
|
|
235
|
-
const [hosts, lineType, lineKey] = fields;
|
|
236
|
-
if (!hosts || !lineType || !lineKey) continue;
|
|
237
|
-
if (!knownHostsEntryMatches(hosts, patterns)) continue;
|
|
238
|
-
if (marker === "@revoked") {
|
|
239
|
-
if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
if (marker === "@cert-authority") continue;
|
|
243
|
-
sawHost = true;
|
|
244
|
-
if (lineType === type && lineKey === encoded) return "match";
|
|
245
|
-
}
|
|
246
|
-
return sawHost ? "mismatch" : "unknown";
|
|
247
|
-
}
|
|
248
|
-
async function rememberHostKey(host, port, blob) {
|
|
249
|
-
await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
|
|
250
|
-
const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
|
|
251
|
-
await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
|
|
252
|
-
`, { mode: 384 });
|
|
253
|
-
}
|
|
254
|
-
function fingerprint(blob) {
|
|
255
|
-
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
|
256
|
-
}
|
|
257
|
-
async function resolveConfig(ssh) {
|
|
258
|
-
const cfg = await resolveSshHost(ssh.host);
|
|
259
|
-
if (cfg.proxyJump) {
|
|
260
|
-
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
261
|
-
}
|
|
262
|
-
if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
|
|
263
|
-
throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
|
|
264
|
-
}
|
|
265
|
-
const resolved = {
|
|
266
|
-
...ssh,
|
|
267
|
-
host: cfg.hostName ?? ssh.host,
|
|
268
|
-
port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
|
|
269
|
-
user: ssh.user || cfg.user || ""
|
|
270
|
-
};
|
|
271
|
-
if (!resolved.user) {
|
|
272
|
-
throw new TunnelError(
|
|
273
|
-
SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
|
|
274
|
-
400
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
if (resolved.auth === "key" && !resolved.privateKey) {
|
|
278
|
-
if (SSH_CONFIG_DISABLED) {
|
|
279
|
-
throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
|
|
280
|
-
}
|
|
281
|
-
const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
|
|
282
|
-
for (const file of candidates) {
|
|
283
|
-
try {
|
|
284
|
-
resolved.privateKey = await readFile2(file, "utf8");
|
|
285
|
-
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
286
|
-
break;
|
|
287
|
-
} catch {
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (!resolved.privateKey) {
|
|
291
|
-
throw new TunnelError(
|
|
292
|
-
`No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename2(f)).join(", ")} in ~/.ssh)`,
|
|
293
|
-
400
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return { ...resolved, identityAgent: cfg.identityAgent };
|
|
298
|
-
}
|
|
299
|
-
function toConnectConfig(ssh) {
|
|
300
|
-
const base = {
|
|
301
|
-
host: ssh.host,
|
|
302
|
-
port: ssh.port,
|
|
303
|
-
username: ssh.user,
|
|
304
|
-
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
305
|
-
keepaliveInterval: 3e4
|
|
306
|
-
};
|
|
307
|
-
switch (ssh.auth) {
|
|
308
|
-
case "password":
|
|
309
|
-
return { ...base, password: ssh.password ?? "", tryKeyboard: true };
|
|
310
|
-
case "key":
|
|
311
|
-
return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
|
|
312
|
-
case "agent": {
|
|
313
|
-
const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
|
|
314
|
-
if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
|
|
315
|
-
return { ...base, agent };
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
function describeSshError(err, ssh) {
|
|
320
|
-
const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
|
|
321
|
-
if (err instanceof TunnelError) return err;
|
|
322
|
-
switch (err.level) {
|
|
323
|
-
case "client-authentication":
|
|
324
|
-
return new TunnelError(`SSH authentication failed for ${target}`, 401);
|
|
325
|
-
case "client-timeout":
|
|
326
|
-
return new TunnelError(`SSH connection to ${target} timed out`, 400);
|
|
327
|
-
default:
|
|
328
|
-
return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
async function openSshTunnel(requested, target) {
|
|
332
|
-
const ssh = await resolveConfig(requested);
|
|
333
|
-
const client = new Client();
|
|
334
|
-
const connectConfig = toConnectConfig(ssh);
|
|
335
|
-
let verifyError;
|
|
336
|
-
connectConfig.hostVerifier = (key, verified) => {
|
|
337
|
-
lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
|
|
338
|
-
const fp = fingerprint(key);
|
|
339
|
-
if (result === "mismatch") {
|
|
340
|
-
verifyError = new TunnelError(
|
|
341
|
-
`Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
|
|
342
|
-
400
|
|
343
|
-
);
|
|
344
|
-
verified(false);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (result === "unknown") {
|
|
348
|
-
if (ssh.acceptHostKey !== fp) {
|
|
349
|
-
verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
|
|
350
|
-
verified(false);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
|
|
354
|
-
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
355
|
-
}
|
|
356
|
-
verified(true);
|
|
357
|
-
}).catch((err) => {
|
|
358
|
-
verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
359
|
-
verified(false);
|
|
360
|
-
});
|
|
361
|
-
};
|
|
362
|
-
await new Promise((resolve2, reject) => {
|
|
363
|
-
client.once("ready", resolve2);
|
|
364
|
-
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
365
|
-
try {
|
|
366
|
-
client.connect(connectConfig);
|
|
367
|
-
} catch (err) {
|
|
368
|
-
reject(describeSshError(err, ssh));
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
|
|
372
|
-
const sockets = /* @__PURE__ */ new Set();
|
|
373
|
-
let forwardError;
|
|
374
|
-
const server = createServer((socket) => {
|
|
375
|
-
sockets.add(socket);
|
|
376
|
-
socket.once("close", () => sockets.delete(socket));
|
|
377
|
-
socket.on("error", () => socket.destroy());
|
|
378
|
-
client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
379
|
-
if (err) {
|
|
380
|
-
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
381
|
-
const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
|
|
382
|
-
forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
|
|
383
|
-
console.error(`[ssh] ${forwardError}`);
|
|
384
|
-
socket.destroy();
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
stream.on("error", () => socket.destroy());
|
|
388
|
-
socket.pipe(stream).pipe(socket);
|
|
389
|
-
stream.once("close", () => socket.destroy());
|
|
390
|
-
socket.once("close", () => stream.destroy());
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
await new Promise((resolve2, reject) => {
|
|
394
|
-
server.once("error", reject);
|
|
395
|
-
server.listen(0, "127.0.0.1", () => resolve2());
|
|
396
|
-
}).catch((err) => {
|
|
397
|
-
client.end();
|
|
398
|
-
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
399
|
-
});
|
|
400
|
-
const { port } = server.address();
|
|
401
|
-
let closed = false;
|
|
402
|
-
const close = () => new Promise((resolve2) => {
|
|
403
|
-
if (closed) return resolve2();
|
|
404
|
-
closed = true;
|
|
405
|
-
for (const s of sockets) s.destroy();
|
|
406
|
-
server.close(() => {
|
|
407
|
-
client.end();
|
|
408
|
-
resolve2();
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
client.once("close", () => void close());
|
|
412
|
-
return {
|
|
413
|
-
host: "127.0.0.1",
|
|
414
|
-
port,
|
|
415
|
-
get forwardError() {
|
|
416
|
-
return forwardError;
|
|
417
|
-
},
|
|
418
|
-
close
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// src/tunneling.ts
|
|
423
|
-
import { randomUUID } from "crypto";
|
|
9
|
+
// src/gateway.ts
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { resolve as resolve2 } from "path";
|
|
424
12
|
|
|
425
13
|
// src/drivers/mysql.ts
|
|
426
14
|
import mysql from "mysql2/promise";
|
|
@@ -519,6 +107,9 @@ var MySqlDriver = class {
|
|
|
519
107
|
isResultQuery: false
|
|
520
108
|
};
|
|
521
109
|
}
|
|
110
|
+
async setReadOnly() {
|
|
111
|
+
await this.connection.query("SET SESSION TRANSACTION READ ONLY");
|
|
112
|
+
}
|
|
522
113
|
async close() {
|
|
523
114
|
await this.connection.end();
|
|
524
115
|
}
|
|
@@ -526,6 +117,7 @@ var MySqlDriver = class {
|
|
|
526
117
|
var mysqlDriverFactory = {
|
|
527
118
|
async open(config) {
|
|
528
119
|
const connection = await mysql.createConnection(toConnectionOptions(config));
|
|
120
|
+
connection.on("error", (err) => console.error("[mysql] Connection error:", err.message));
|
|
529
121
|
return new MySqlDriver(connection, config);
|
|
530
122
|
},
|
|
531
123
|
async test(config) {
|
|
@@ -711,6 +303,9 @@ var PostgresDriver = class {
|
|
|
711
303
|
isResultQuery: false
|
|
712
304
|
};
|
|
713
305
|
}
|
|
306
|
+
async setReadOnly() {
|
|
307
|
+
await this.client.query("SET default_transaction_read_only = on");
|
|
308
|
+
}
|
|
714
309
|
async close() {
|
|
715
310
|
await this.client.end();
|
|
716
311
|
}
|
|
@@ -719,6 +314,7 @@ var postgresDriverFactory = {
|
|
|
719
314
|
async open(config) {
|
|
720
315
|
const client = new pg.Client(toClientConfig(config));
|
|
721
316
|
await client.connect();
|
|
317
|
+
client.on("error", (err) => console.error("[postgres] Connection error:", err.message));
|
|
722
318
|
const driver = new PostgresDriver(client, config);
|
|
723
319
|
await driver.loadTypeNames();
|
|
724
320
|
return driver;
|
|
@@ -750,8 +346,8 @@ var postgresDriverFactory = {
|
|
|
750
346
|
// src/drivers/sqlite.ts
|
|
751
347
|
import Database from "better-sqlite3";
|
|
752
348
|
import { existsSync, realpathSync } from "fs";
|
|
753
|
-
import { homedir
|
|
754
|
-
import { basename
|
|
349
|
+
import { homedir } from "os";
|
|
350
|
+
import { basename, dirname, join, relative, resolve, sep } from "path";
|
|
755
351
|
function normalizeDeclaredType(declared) {
|
|
756
352
|
if (!declared) return null;
|
|
757
353
|
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
@@ -764,24 +360,24 @@ function inferTypeFromValue(value) {
|
|
|
764
360
|
if (Buffer.isBuffer(value)) return "blob";
|
|
765
361
|
return "text";
|
|
766
362
|
}
|
|
767
|
-
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(
|
|
768
|
-
var DEFAULT_ROOT =
|
|
363
|
+
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome(process.env.PLANCHE_SQLITE_DIR)) : null;
|
|
364
|
+
var DEFAULT_ROOT = homedir();
|
|
769
365
|
var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
|
|
770
366
|
var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
|
|
771
367
|
var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
|
|
772
|
-
function
|
|
773
|
-
return path === "~" || path.startsWith("~/") ? resolve(
|
|
368
|
+
function expandHome(path) {
|
|
369
|
+
return path === "~" || path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path;
|
|
774
370
|
}
|
|
775
371
|
function realish(file) {
|
|
776
372
|
let probe = file;
|
|
777
373
|
const rest = [];
|
|
778
374
|
for (; ; ) {
|
|
779
375
|
try {
|
|
780
|
-
return rest.length ?
|
|
376
|
+
return rest.length ? join(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
|
|
781
377
|
} catch {
|
|
782
|
-
const parent =
|
|
378
|
+
const parent = dirname(probe);
|
|
783
379
|
if (parent === probe) return file;
|
|
784
|
-
rest.unshift(
|
|
380
|
+
rest.unshift(basename(probe));
|
|
785
381
|
probe = parent;
|
|
786
382
|
}
|
|
787
383
|
}
|
|
@@ -792,7 +388,7 @@ function isInside(path, root) {
|
|
|
792
388
|
function resolveFile(file, forCreate = false) {
|
|
793
389
|
if (file === ":memory:") return file;
|
|
794
390
|
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(),
|
|
391
|
+
const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome(file)));
|
|
796
392
|
if (SQLITE_DIR) {
|
|
797
393
|
if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
|
|
798
394
|
} else {
|
|
@@ -923,6 +519,9 @@ var SqliteDriver = class {
|
|
|
923
519
|
});
|
|
924
520
|
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
925
521
|
}
|
|
522
|
+
async setReadOnly() {
|
|
523
|
+
this.db.pragma("query_only = 1");
|
|
524
|
+
}
|
|
926
525
|
async close() {
|
|
927
526
|
if (this.db.inTransaction) {
|
|
928
527
|
try {
|
|
@@ -1149,280 +748,686 @@ var OracleDriver = class {
|
|
|
1149
748
|
this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
|
|
1150
749
|
this.currentSchema = config.user.toUpperCase();
|
|
1151
750
|
}
|
|
1152
|
-
conn;
|
|
1153
|
-
type = "oracle";
|
|
751
|
+
conn;
|
|
752
|
+
type = "oracle";
|
|
753
|
+
label;
|
|
754
|
+
currentSchema;
|
|
755
|
+
manualTx = false;
|
|
756
|
+
/** Column names per `OWNER.TABLE`, for result-source resolution. */
|
|
757
|
+
columns = /* @__PURE__ */ new Map();
|
|
758
|
+
async init() {
|
|
759
|
+
for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
|
|
760
|
+
}
|
|
761
|
+
async tableColumns(schema, table) {
|
|
762
|
+
const key = `${schema}.${table}`;
|
|
763
|
+
const cached = this.columns.get(key);
|
|
764
|
+
if (cached !== void 0) return cached;
|
|
765
|
+
let out = null;
|
|
766
|
+
try {
|
|
767
|
+
const res = await this.conn.execute(
|
|
768
|
+
"SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
|
|
769
|
+
{ o: schema, t: table },
|
|
770
|
+
{ outFormat: oracledb.OUT_FORMAT_ARRAY }
|
|
771
|
+
);
|
|
772
|
+
out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
|
|
773
|
+
} catch {
|
|
774
|
+
out = null;
|
|
775
|
+
}
|
|
776
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
777
|
+
this.columns.set(key, out);
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
async query(sql2, database) {
|
|
781
|
+
if (database && database !== this.currentSchema) {
|
|
782
|
+
await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
|
|
783
|
+
this.currentSchema = database;
|
|
784
|
+
}
|
|
785
|
+
const text = normalizeStatement(sql2);
|
|
786
|
+
if (START_TX.test(text)) this.manualTx = true;
|
|
787
|
+
const res = await this.conn.execute(text, [], {
|
|
788
|
+
outFormat: oracledb.OUT_FORMAT_ARRAY,
|
|
789
|
+
fetchTypeHandler,
|
|
790
|
+
autoCommit: !this.manualTx
|
|
791
|
+
});
|
|
792
|
+
if (SET_SCHEMA.test(text)) this.currentSchema = null;
|
|
793
|
+
if (END_TX.test(text)) this.manualTx = false;
|
|
794
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
795
|
+
const source = singleSourceTable(text);
|
|
796
|
+
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
797
|
+
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
798
|
+
const fields = res.metaData.map((col) => {
|
|
799
|
+
const fromTable = known?.has(col.name) ?? false;
|
|
800
|
+
return {
|
|
801
|
+
name: col.name,
|
|
802
|
+
orgName: col.name,
|
|
803
|
+
type: (col.dbTypeName ?? "").toLowerCase(),
|
|
804
|
+
table: fromTable ? source.table : "",
|
|
805
|
+
orgTable: fromTable ? source.table : "",
|
|
806
|
+
db: fromTable ? schema : "",
|
|
807
|
+
maxLength: col.byteSize ?? 0
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
|
|
811
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
812
|
+
}
|
|
813
|
+
return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
|
|
814
|
+
}
|
|
815
|
+
async close() {
|
|
816
|
+
await this.conn.close();
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
function connectionAttributes(config) {
|
|
820
|
+
return {
|
|
821
|
+
user: config.user,
|
|
822
|
+
password: config.password,
|
|
823
|
+
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
var oracleDriverFactory = {
|
|
827
|
+
async open(config) {
|
|
828
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
829
|
+
const driver = new OracleDriver(conn, config);
|
|
830
|
+
await driver.init();
|
|
831
|
+
return driver;
|
|
832
|
+
},
|
|
833
|
+
async test(config) {
|
|
834
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
835
|
+
await conn.close();
|
|
836
|
+
},
|
|
837
|
+
parseConfig(body) {
|
|
838
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
839
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
840
|
+
if (!host || !user) {
|
|
841
|
+
throw new Error("host and user are required");
|
|
842
|
+
}
|
|
843
|
+
if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
|
|
844
|
+
const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
|
|
845
|
+
if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
|
|
846
|
+
return {
|
|
847
|
+
driver: "oracle",
|
|
848
|
+
host,
|
|
849
|
+
port: Number(body.port) || 1521,
|
|
850
|
+
user,
|
|
851
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
852
|
+
serviceName
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
// src/drivers/d1.ts
|
|
858
|
+
var API = "https://api.cloudflare.com/client/v4";
|
|
859
|
+
var D1Error = class extends Error {
|
|
860
|
+
constructor(message, status) {
|
|
861
|
+
super(message);
|
|
862
|
+
this.status = status;
|
|
863
|
+
}
|
|
864
|
+
status;
|
|
865
|
+
};
|
|
866
|
+
var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
|
|
867
|
+
var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
868
|
+
var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
869
|
+
function singleSourceTable2(sql2) {
|
|
870
|
+
const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
871
|
+
if (!m) return null;
|
|
872
|
+
const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
|
|
873
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
874
|
+
const raw = m[1];
|
|
875
|
+
const first = raw[0];
|
|
876
|
+
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
877
|
+
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
878
|
+
if (first === "[") return raw.slice(1, -1);
|
|
879
|
+
return raw;
|
|
880
|
+
}
|
|
881
|
+
function normalizeDeclaredType2(declared) {
|
|
882
|
+
if (typeof declared !== "string") return null;
|
|
883
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
884
|
+
return base || null;
|
|
885
|
+
}
|
|
886
|
+
function inferTypeFromValue2(value) {
|
|
887
|
+
if (value === null || value === void 0) return "null";
|
|
888
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
889
|
+
if (Array.isArray(value)) return "blob";
|
|
890
|
+
return "text";
|
|
891
|
+
}
|
|
892
|
+
function firstNonNull2(records, index) {
|
|
893
|
+
for (const row of records) {
|
|
894
|
+
const value = row[index];
|
|
895
|
+
if (value !== null && value !== void 0) return value;
|
|
896
|
+
}
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
var D1Driver = class {
|
|
900
|
+
type = "d1";
|
|
1154
901
|
label;
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
/**
|
|
902
|
+
url;
|
|
903
|
+
headers;
|
|
904
|
+
/** Declared column types per table, for result-source resolution. */
|
|
1158
905
|
columns = /* @__PURE__ */ new Map();
|
|
1159
|
-
|
|
1160
|
-
|
|
906
|
+
constructor(config) {
|
|
907
|
+
this.label = `d1:${config.databaseId}`;
|
|
908
|
+
this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
|
|
909
|
+
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
1161
910
|
}
|
|
1162
|
-
async
|
|
1163
|
-
|
|
1164
|
-
|
|
911
|
+
async raw(sql2) {
|
|
912
|
+
let res;
|
|
913
|
+
try {
|
|
914
|
+
res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
|
|
915
|
+
} catch (err) {
|
|
916
|
+
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
917
|
+
}
|
|
918
|
+
let body = null;
|
|
919
|
+
try {
|
|
920
|
+
body = await res.json();
|
|
921
|
+
} catch {
|
|
922
|
+
}
|
|
923
|
+
if (!res.ok || !body?.success) {
|
|
924
|
+
const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
|
|
925
|
+
throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
|
|
926
|
+
}
|
|
927
|
+
const result = body.result?.[0];
|
|
928
|
+
if (!result) throw new D1Error("Cloudflare API returned no result");
|
|
929
|
+
return result;
|
|
930
|
+
}
|
|
931
|
+
async tableColumns(table) {
|
|
932
|
+
const cached = this.columns.get(table);
|
|
1165
933
|
if (cached !== void 0) return cached;
|
|
1166
934
|
let out = null;
|
|
1167
935
|
try {
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
936
|
+
const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
937
|
+
const cols = info.results?.columns ?? [];
|
|
938
|
+
const name = cols.indexOf("name");
|
|
939
|
+
const type = cols.indexOf("type");
|
|
940
|
+
const rows = info.results?.rows ?? [];
|
|
941
|
+
if (rows.length > 0 && name >= 0) {
|
|
942
|
+
out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
|
|
943
|
+
}
|
|
1174
944
|
} catch {
|
|
1175
945
|
out = null;
|
|
1176
946
|
}
|
|
1177
947
|
if (this.columns.size > 200) this.columns.clear();
|
|
1178
|
-
this.columns.set(
|
|
948
|
+
this.columns.set(table, out);
|
|
1179
949
|
return out;
|
|
1180
950
|
}
|
|
1181
|
-
async query(sql2
|
|
1182
|
-
if (
|
|
1183
|
-
|
|
1184
|
-
|
|
951
|
+
async query(sql2) {
|
|
952
|
+
if (DATABASE_LIST.test(sql2)) {
|
|
953
|
+
return {
|
|
954
|
+
fields: [
|
|
955
|
+
{ name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
956
|
+
{ name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
957
|
+
{ name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
|
|
958
|
+
],
|
|
959
|
+
records: [[0, "main", ""]],
|
|
960
|
+
affectedRows: 0,
|
|
961
|
+
insertId: 0,
|
|
962
|
+
isResultQuery: true
|
|
963
|
+
};
|
|
1185
964
|
}
|
|
1186
|
-
const text =
|
|
1187
|
-
if (
|
|
1188
|
-
|
|
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 };
|
|
965
|
+
const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
|
|
966
|
+
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
|
|
967
|
+
throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
|
|
1213
968
|
}
|
|
1214
|
-
|
|
969
|
+
if (this.isDdl(text)) this.columns.clear();
|
|
970
|
+
const result = await this.raw(text);
|
|
971
|
+
const columns = result.results?.columns ?? [];
|
|
972
|
+
const records = result.results?.rows ?? [];
|
|
973
|
+
if (columns.length === 0 && !RESULT_KIND.test(text)) {
|
|
974
|
+
return {
|
|
975
|
+
fields: [],
|
|
976
|
+
records: [],
|
|
977
|
+
affectedRows: result.meta?.changes ?? 0,
|
|
978
|
+
insertId: result.meta?.last_row_id ?? 0,
|
|
979
|
+
isResultQuery: false
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
983
|
+
const known = source ? await this.tableColumns(source) : null;
|
|
984
|
+
const fields = columns.map((name, index) => {
|
|
985
|
+
const fromTable = known?.has(name) ?? false;
|
|
986
|
+
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
987
|
+
return {
|
|
988
|
+
name,
|
|
989
|
+
orgName: name,
|
|
990
|
+
type,
|
|
991
|
+
table: fromTable && source ? source : "",
|
|
992
|
+
orgTable: fromTable && source ? source : "",
|
|
993
|
+
db: fromTable ? "main" : "",
|
|
994
|
+
maxLength: 0
|
|
995
|
+
};
|
|
996
|
+
});
|
|
997
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
998
|
+
}
|
|
999
|
+
isDdl(sql2) {
|
|
1000
|
+
return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
|
|
1215
1001
|
}
|
|
1216
1002
|
async close() {
|
|
1217
|
-
|
|
1003
|
+
this.columns.clear();
|
|
1218
1004
|
}
|
|
1219
1005
|
};
|
|
1220
|
-
|
|
1221
|
-
return {
|
|
1222
|
-
user: config.user,
|
|
1223
|
-
password: config.password,
|
|
1224
|
-
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
1225
|
-
};
|
|
1226
|
-
}
|
|
1227
|
-
var oracleDriverFactory = {
|
|
1006
|
+
var d1DriverFactory = {
|
|
1228
1007
|
async open(config) {
|
|
1229
|
-
const
|
|
1230
|
-
|
|
1231
|
-
await driver.init();
|
|
1008
|
+
const driver = new D1Driver(config);
|
|
1009
|
+
await driver.raw("SELECT 1");
|
|
1232
1010
|
return driver;
|
|
1233
1011
|
},
|
|
1234
1012
|
async test(config) {
|
|
1235
|
-
|
|
1236
|
-
await conn.close();
|
|
1013
|
+
await new D1Driver(config).raw("SELECT 1");
|
|
1237
1014
|
},
|
|
1238
1015
|
parseConfig(body) {
|
|
1239
|
-
const
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1242
|
-
|
|
1016
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
1017
|
+
const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
|
|
1018
|
+
const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
|
|
1019
|
+
if (!accountId || !databaseId || !apiToken) {
|
|
1020
|
+
throw new Error("accountId, databaseId and apiToken are required");
|
|
1021
|
+
}
|
|
1022
|
+
return { driver: "d1", accountId, databaseId, apiToken };
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
// src/ssh-tunnel.ts
|
|
1027
|
+
import { createServer } from "net";
|
|
1028
|
+
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
1029
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
1030
|
+
import { homedir as homedir3 } from "os";
|
|
1031
|
+
import { basename as basename3, join as join3 } from "path";
|
|
1032
|
+
import { Client } from "ssh2";
|
|
1033
|
+
|
|
1034
|
+
// src/ssh-config.ts
|
|
1035
|
+
import { readFile, readdir } from "fs/promises";
|
|
1036
|
+
import { homedir as homedir2 } from "os";
|
|
1037
|
+
import { dirname as dirname2, isAbsolute, join as join2, basename as basename2 } from "path";
|
|
1038
|
+
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
1039
|
+
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join2(homedir2(), ".ssh", "config");
|
|
1040
|
+
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
1041
|
+
(f) => join2(homedir2(), ".ssh", f)
|
|
1042
|
+
);
|
|
1043
|
+
function expandHome2(path) {
|
|
1044
|
+
return path === "~" || path.startsWith("~/") ? join2(homedir2(), path.slice(1)) : path;
|
|
1045
|
+
}
|
|
1046
|
+
function globToRegExp(pattern) {
|
|
1047
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
1048
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
1049
|
+
}
|
|
1050
|
+
function hostPatternMatches(patterns, host) {
|
|
1051
|
+
let matched = false;
|
|
1052
|
+
for (const raw of patterns) {
|
|
1053
|
+
const negated = raw.startsWith("!");
|
|
1054
|
+
const pattern = negated ? raw.slice(1) : raw;
|
|
1055
|
+
if (!globToRegExp(pattern).test(host)) continue;
|
|
1056
|
+
if (negated) return false;
|
|
1057
|
+
matched = true;
|
|
1058
|
+
}
|
|
1059
|
+
return matched;
|
|
1060
|
+
}
|
|
1061
|
+
function tokenize(line) {
|
|
1062
|
+
const tokens = [];
|
|
1063
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
1064
|
+
let m;
|
|
1065
|
+
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
1066
|
+
return tokens;
|
|
1067
|
+
}
|
|
1068
|
+
function parseLine(line) {
|
|
1069
|
+
const trimmed = line.trim();
|
|
1070
|
+
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
1071
|
+
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
1072
|
+
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
1073
|
+
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
1074
|
+
}
|
|
1075
|
+
async function expandGlob(path) {
|
|
1076
|
+
const base = basename2(path);
|
|
1077
|
+
if (!/[*?]/.test(base)) return [path];
|
|
1078
|
+
const dir = dirname2(path);
|
|
1079
|
+
try {
|
|
1080
|
+
const re = globToRegExp(base);
|
|
1081
|
+
return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join2(dir, name));
|
|
1082
|
+
} catch {
|
|
1083
|
+
return [];
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function loadLines(file, seen) {
|
|
1087
|
+
if (seen.has(file)) return [];
|
|
1088
|
+
seen.add(file);
|
|
1089
|
+
try {
|
|
1090
|
+
return (await readFile(file, "utf8")).split(/\r?\n/);
|
|
1091
|
+
} catch {
|
|
1092
|
+
return [];
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
1096
|
+
const result = { identityFiles: [] };
|
|
1097
|
+
if (!file) return result;
|
|
1098
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1099
|
+
async function walk(path, active) {
|
|
1100
|
+
let blockActive = active;
|
|
1101
|
+
for (const line of await loadLines(path, seen)) {
|
|
1102
|
+
const d = parseLine(line);
|
|
1103
|
+
if (!d) continue;
|
|
1104
|
+
switch (d.keyword) {
|
|
1105
|
+
case "host":
|
|
1106
|
+
blockActive = hostPatternMatches(d.args, host);
|
|
1107
|
+
continue;
|
|
1108
|
+
case "match":
|
|
1109
|
+
blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
|
|
1110
|
+
continue;
|
|
1111
|
+
case "include":
|
|
1112
|
+
for (const inc of d.args) {
|
|
1113
|
+
const target = expandHome2(inc);
|
|
1114
|
+
const abs = isAbsolute(target) ? target : join2(homedir2(), ".ssh", target);
|
|
1115
|
+
for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
|
|
1116
|
+
}
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (!blockActive) continue;
|
|
1120
|
+
const value = d.args[0];
|
|
1121
|
+
if (value === void 0) continue;
|
|
1122
|
+
switch (d.keyword) {
|
|
1123
|
+
case "hostname":
|
|
1124
|
+
result.hostName ??= value.replace(/%h/g, host);
|
|
1125
|
+
break;
|
|
1126
|
+
case "port":
|
|
1127
|
+
result.port ??= Number(value) || void 0;
|
|
1128
|
+
break;
|
|
1129
|
+
case "user":
|
|
1130
|
+
result.user ??= value;
|
|
1131
|
+
break;
|
|
1132
|
+
case "identityfile":
|
|
1133
|
+
for (const f of d.args) {
|
|
1134
|
+
const expanded = expandHome2(f.replace(/%d/g, homedir2()).replace(/%h/g, host));
|
|
1135
|
+
if (expanded.split(/[\\/]/).includes("..")) continue;
|
|
1136
|
+
result.identityFiles.push(expanded);
|
|
1137
|
+
}
|
|
1138
|
+
break;
|
|
1139
|
+
case "identityagent":
|
|
1140
|
+
result.identityAgent ??= value === "none" ? void 0 : expandHome2(value.replace(/%d/g, homedir2()));
|
|
1141
|
+
break;
|
|
1142
|
+
case "proxyjump":
|
|
1143
|
+
result.proxyJump ??= value;
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1243
1146
|
}
|
|
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
1147
|
}
|
|
1256
|
-
|
|
1148
|
+
await walk(file, true);
|
|
1149
|
+
return result;
|
|
1150
|
+
}
|
|
1257
1151
|
|
|
1258
|
-
// src/
|
|
1259
|
-
var
|
|
1260
|
-
var D1Error = class extends Error {
|
|
1152
|
+
// src/ssh-tunnel.ts
|
|
1153
|
+
var TunnelError = class extends Error {
|
|
1261
1154
|
constructor(message, status) {
|
|
1262
1155
|
super(message);
|
|
1263
1156
|
this.status = status;
|
|
1157
|
+
this.name = "TunnelError";
|
|
1264
1158
|
}
|
|
1265
1159
|
status;
|
|
1266
1160
|
};
|
|
1267
|
-
var
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
return
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1161
|
+
var HostKeyError = class extends TunnelError {
|
|
1162
|
+
hostKey;
|
|
1163
|
+
constructor(host, port, type, fingerprint2) {
|
|
1164
|
+
super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
|
|
1165
|
+
this.name = "HostKeyError";
|
|
1166
|
+
this.hostKey = { host, port, type, fingerprint: fingerprint2 };
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
var CONNECT_TIMEOUT_MS = 2e4;
|
|
1170
|
+
var PLANCHE_DIR = join3(homedir3(), ".planche");
|
|
1171
|
+
var PLANCHE_KNOWN_HOSTS = join3(PLANCHE_DIR, "known_hosts");
|
|
1172
|
+
var OPENSSH_KNOWN_HOSTS = join3(homedir3(), ".ssh", "known_hosts");
|
|
1173
|
+
function parseSshConfig(value) {
|
|
1174
|
+
if (value === void 0 || value === null) return void 0;
|
|
1175
|
+
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
1176
|
+
const body = value;
|
|
1177
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1178
|
+
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
1179
|
+
const auth = body.auth;
|
|
1180
|
+
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
1181
|
+
if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
|
|
1182
|
+
if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
|
|
1183
|
+
const port = Math.trunc(Number(body.port)) || 22;
|
|
1184
|
+
if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
|
|
1185
|
+
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
1186
|
+
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
1187
|
+
}
|
|
1188
|
+
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
1189
|
+
const config = {
|
|
1190
|
+
host,
|
|
1191
|
+
port,
|
|
1192
|
+
user,
|
|
1193
|
+
auth,
|
|
1194
|
+
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
1195
|
+
privateKey: auth === "key" ? str(body.privateKey) : void 0,
|
|
1196
|
+
passphrase: auth === "key" ? str(body.passphrase) : void 0,
|
|
1197
|
+
acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
|
|
1198
|
+
};
|
|
1199
|
+
return config;
|
|
1286
1200
|
}
|
|
1287
|
-
function
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1290
|
-
if (
|
|
1291
|
-
|
|
1201
|
+
function keyType(blob) {
|
|
1202
|
+
if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
|
|
1203
|
+
const len = blob.readUInt32BE(0);
|
|
1204
|
+
if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
|
|
1205
|
+
const type = blob.subarray(4, 4 + len).toString("ascii");
|
|
1206
|
+
if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
|
|
1207
|
+
return type;
|
|
1292
1208
|
}
|
|
1293
|
-
function
|
|
1294
|
-
|
|
1295
|
-
const
|
|
1296
|
-
if (
|
|
1209
|
+
function knownHostsEntryMatches(field, patterns) {
|
|
1210
|
+
if (field.startsWith("|1|")) {
|
|
1211
|
+
const [, , salt, hash] = field.split("|");
|
|
1212
|
+
if (!salt || !hash) return false;
|
|
1213
|
+
const key = Buffer.from(salt, "base64");
|
|
1214
|
+
const want = Buffer.from(hash, "base64");
|
|
1215
|
+
return patterns.some((p) => {
|
|
1216
|
+
const got = createHmac("sha1", key).update(p).digest();
|
|
1217
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
1218
|
+
});
|
|
1297
1219
|
}
|
|
1298
|
-
|
|
1220
|
+
const entries = field.toLowerCase().split(",");
|
|
1221
|
+
return patterns.some((p) => hostPatternMatches(entries, p));
|
|
1299
1222
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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" };
|
|
1223
|
+
function hostPatterns(host, port) {
|
|
1224
|
+
const h = host.toLowerCase();
|
|
1225
|
+
return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
|
|
1226
|
+
}
|
|
1227
|
+
async function readKnownHosts(file) {
|
|
1228
|
+
try {
|
|
1229
|
+
return (await readFile2(file, "utf8")).split(/\r?\n/);
|
|
1230
|
+
} catch {
|
|
1231
|
+
return [];
|
|
1311
1232
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1233
|
+
}
|
|
1234
|
+
async function lookupHostKey(host, port, blob) {
|
|
1235
|
+
const patterns = hostPatterns(host, port);
|
|
1236
|
+
const type = keyType(blob);
|
|
1237
|
+
const encoded = blob.toString("base64");
|
|
1238
|
+
let sawHost = false;
|
|
1239
|
+
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
1240
|
+
for (const raw of lines) {
|
|
1241
|
+
const line = raw.trim();
|
|
1242
|
+
if (!line || line.startsWith("#")) continue;
|
|
1243
|
+
const fields = line.split(/\s+/);
|
|
1244
|
+
const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
|
|
1245
|
+
const [hosts, lineType, lineKey] = fields;
|
|
1246
|
+
if (!hosts || !lineType || !lineKey) continue;
|
|
1247
|
+
if (!knownHostsEntryMatches(hosts, patterns)) continue;
|
|
1248
|
+
if (marker === "@revoked") {
|
|
1249
|
+
if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
|
|
1250
|
+
continue;
|
|
1327
1251
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
return
|
|
1252
|
+
if (marker === "@cert-authority") continue;
|
|
1253
|
+
sawHost = true;
|
|
1254
|
+
if (lineType === type && lineKey === encoded) return "match";
|
|
1331
1255
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
}
|
|
1348
|
-
if (this.columns.size > 200) this.columns.clear();
|
|
1349
|
-
this.columns.set(table, out);
|
|
1350
|
-
return out;
|
|
1256
|
+
return sawHost ? "mismatch" : "unknown";
|
|
1257
|
+
}
|
|
1258
|
+
async function rememberHostKey(host, port, blob) {
|
|
1259
|
+
await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
|
|
1260
|
+
const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
|
|
1261
|
+
await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
|
|
1262
|
+
`, { mode: 384 });
|
|
1263
|
+
}
|
|
1264
|
+
function fingerprint(blob) {
|
|
1265
|
+
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
|
1266
|
+
}
|
|
1267
|
+
async function resolveConfig(ssh) {
|
|
1268
|
+
const cfg = await resolveSshHost(ssh.host);
|
|
1269
|
+
if (cfg.proxyJump) {
|
|
1270
|
+
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
1351
1271
|
}
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1272
|
+
if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
|
|
1273
|
+
throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
|
|
1274
|
+
}
|
|
1275
|
+
const resolved = {
|
|
1276
|
+
...ssh,
|
|
1277
|
+
host: cfg.hostName ?? ssh.host,
|
|
1278
|
+
port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
|
|
1279
|
+
user: ssh.user || cfg.user || ""
|
|
1280
|
+
};
|
|
1281
|
+
if (!resolved.user) {
|
|
1282
|
+
throw new TunnelError(
|
|
1283
|
+
SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
|
|
1284
|
+
400
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
if (resolved.auth === "key" && !resolved.privateKey) {
|
|
1288
|
+
if (SSH_CONFIG_DISABLED) {
|
|
1289
|
+
throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
|
|
1365
1290
|
}
|
|
1366
|
-
const
|
|
1367
|
-
|
|
1368
|
-
|
|
1291
|
+
const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
|
|
1292
|
+
for (const file of candidates) {
|
|
1293
|
+
try {
|
|
1294
|
+
resolved.privateKey = await readFile2(file, "utf8");
|
|
1295
|
+
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
1296
|
+
break;
|
|
1297
|
+
} catch {
|
|
1298
|
+
}
|
|
1369
1299
|
}
|
|
1370
|
-
if (
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
return {
|
|
1376
|
-
fields: [],
|
|
1377
|
-
records: [],
|
|
1378
|
-
affectedRows: result.meta?.changes ?? 0,
|
|
1379
|
-
insertId: result.meta?.last_row_id ?? 0,
|
|
1380
|
-
isResultQuery: false
|
|
1381
|
-
};
|
|
1300
|
+
if (!resolved.privateKey) {
|
|
1301
|
+
throw new TunnelError(
|
|
1302
|
+
`No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename3(f)).join(", ")} in ~/.ssh)`,
|
|
1303
|
+
400
|
|
1304
|
+
);
|
|
1382
1305
|
}
|
|
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
|
-
};
|
|
1397
|
-
});
|
|
1398
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1399
1306
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1307
|
+
return { ...resolved, identityAgent: cfg.identityAgent };
|
|
1308
|
+
}
|
|
1309
|
+
function toConnectConfig(ssh) {
|
|
1310
|
+
const base = {
|
|
1311
|
+
host: ssh.host,
|
|
1312
|
+
port: ssh.port,
|
|
1313
|
+
username: ssh.user,
|
|
1314
|
+
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
1315
|
+
keepaliveInterval: 3e4
|
|
1316
|
+
};
|
|
1317
|
+
switch (ssh.auth) {
|
|
1318
|
+
case "password":
|
|
1319
|
+
return { ...base, password: ssh.password ?? "", tryKeyboard: true };
|
|
1320
|
+
case "key":
|
|
1321
|
+
return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
|
|
1322
|
+
case "agent": {
|
|
1323
|
+
const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
|
|
1324
|
+
if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
|
|
1325
|
+
return { ...base, agent };
|
|
1326
|
+
}
|
|
1402
1327
|
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1328
|
+
}
|
|
1329
|
+
function describeSshError(err, ssh) {
|
|
1330
|
+
const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
|
|
1331
|
+
if (err instanceof TunnelError) return err;
|
|
1332
|
+
switch (err.level) {
|
|
1333
|
+
case "client-authentication":
|
|
1334
|
+
return new TunnelError(`SSH authentication failed for ${target}`, 401);
|
|
1335
|
+
case "client-timeout":
|
|
1336
|
+
return new TunnelError(`SSH connection to ${target} timed out`, 400);
|
|
1337
|
+
default:
|
|
1338
|
+
return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
|
|
1405
1339
|
}
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1340
|
+
}
|
|
1341
|
+
async function openSshTunnel(requested, target) {
|
|
1342
|
+
const ssh = await resolveConfig(requested);
|
|
1343
|
+
const client = new Client();
|
|
1344
|
+
const connectConfig = toConnectConfig(ssh);
|
|
1345
|
+
let verifyError;
|
|
1346
|
+
connectConfig.hostVerifier = (key, verified) => {
|
|
1347
|
+
lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
|
|
1348
|
+
const fp = fingerprint(key);
|
|
1349
|
+
if (result === "mismatch") {
|
|
1350
|
+
verifyError = new TunnelError(
|
|
1351
|
+
`Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
|
|
1352
|
+
400
|
|
1353
|
+
);
|
|
1354
|
+
verified(false);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (result === "unknown") {
|
|
1358
|
+
if (ssh.acceptHostKey !== fp) {
|
|
1359
|
+
verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
|
|
1360
|
+
verified(false);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
|
|
1364
|
+
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
1365
|
+
}
|
|
1366
|
+
verified(true);
|
|
1367
|
+
}).catch((err) => {
|
|
1368
|
+
verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
1369
|
+
verified(false);
|
|
1370
|
+
});
|
|
1371
|
+
};
|
|
1372
|
+
await new Promise((resolve3, reject) => {
|
|
1373
|
+
client.once("ready", resolve3);
|
|
1374
|
+
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
1375
|
+
try {
|
|
1376
|
+
client.connect(connectConfig);
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
reject(describeSshError(err, ssh));
|
|
1422
1379
|
}
|
|
1423
|
-
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1380
|
+
});
|
|
1381
|
+
client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
|
|
1382
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1383
|
+
let forwardError;
|
|
1384
|
+
const server = createServer((socket) => {
|
|
1385
|
+
sockets.add(socket);
|
|
1386
|
+
socket.once("close", () => sockets.delete(socket));
|
|
1387
|
+
socket.on("error", () => socket.destroy());
|
|
1388
|
+
client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
1389
|
+
if (err) {
|
|
1390
|
+
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
1391
|
+
const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
|
|
1392
|
+
forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
|
|
1393
|
+
console.error(`[ssh] ${forwardError}`);
|
|
1394
|
+
socket.destroy();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
stream.on("error", () => socket.destroy());
|
|
1398
|
+
socket.pipe(stream).pipe(socket);
|
|
1399
|
+
stream.once("close", () => socket.destroy());
|
|
1400
|
+
socket.once("close", () => stream.destroy());
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
await new Promise((resolve3, reject) => {
|
|
1404
|
+
server.once("error", reject);
|
|
1405
|
+
server.listen(0, "127.0.0.1", () => resolve3());
|
|
1406
|
+
}).catch((err) => {
|
|
1407
|
+
client.end();
|
|
1408
|
+
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
1409
|
+
});
|
|
1410
|
+
const { port } = server.address();
|
|
1411
|
+
let closed = false;
|
|
1412
|
+
const close = () => new Promise((resolve3) => {
|
|
1413
|
+
if (closed) return resolve3();
|
|
1414
|
+
closed = true;
|
|
1415
|
+
for (const s of sockets) s.destroy();
|
|
1416
|
+
server.close(() => {
|
|
1417
|
+
client.end();
|
|
1418
|
+
resolve3();
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
client.once("close", () => void close());
|
|
1422
|
+
return {
|
|
1423
|
+
host: "127.0.0.1",
|
|
1424
|
+
port,
|
|
1425
|
+
get forwardError() {
|
|
1426
|
+
return forwardError;
|
|
1427
|
+
},
|
|
1428
|
+
close
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1426
1431
|
|
|
1427
1432
|
// src/drivers/index.ts
|
|
1428
1433
|
var factories = {
|
|
@@ -1461,6 +1466,7 @@ function tunneled(driver, tunnel, label) {
|
|
|
1461
1466
|
type: driver.type,
|
|
1462
1467
|
label,
|
|
1463
1468
|
query: (sql2, database) => driver.query(sql2, database),
|
|
1469
|
+
setReadOnly: driver.setReadOnly ? () => driver.setReadOnly() : void 0,
|
|
1464
1470
|
async close() {
|
|
1465
1471
|
try {
|
|
1466
1472
|
await driver.close();
|
|
@@ -1520,12 +1526,221 @@ function testDirect(config) {
|
|
|
1520
1526
|
}
|
|
1521
1527
|
}
|
|
1522
1528
|
|
|
1529
|
+
// src/gateway.ts
|
|
1530
|
+
function loadGatewayConfig(file) {
|
|
1531
|
+
const path = resolve2(file);
|
|
1532
|
+
let raw;
|
|
1533
|
+
try {
|
|
1534
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
throw new Error(`Cannot read gateway config ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1537
|
+
}
|
|
1538
|
+
const data = raw;
|
|
1539
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.profiles) || data.profiles.length === 0) {
|
|
1540
|
+
throw new Error(`Gateway config ${path} needs a non-empty "profiles" array`);
|
|
1541
|
+
}
|
|
1542
|
+
const names = /* @__PURE__ */ new Set();
|
|
1543
|
+
const profiles = data.profiles.map((entry, i) => {
|
|
1544
|
+
const e = entry ?? {};
|
|
1545
|
+
const name = typeof e.name === "string" ? e.name.trim() : "";
|
|
1546
|
+
if (!name) throw new Error(`Gateway profile #${i + 1} has no "name"`);
|
|
1547
|
+
if (names.has(name)) throw new Error(`Gateway profile name is not unique: ${name}`);
|
|
1548
|
+
names.add(name);
|
|
1549
|
+
const { name: _n, readOnly, ...driver } = e;
|
|
1550
|
+
void _n;
|
|
1551
|
+
let config;
|
|
1552
|
+
try {
|
|
1553
|
+
config = parseDriverConfig(driver);
|
|
1554
|
+
} catch (err) {
|
|
1555
|
+
throw new Error(`Gateway profile "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
1556
|
+
}
|
|
1557
|
+
return { name, readOnly: readOnly !== false, config };
|
|
1558
|
+
});
|
|
1559
|
+
return { name: typeof data.name === "string" && data.name.trim() ? data.name.trim() : "Planche gateway", profiles };
|
|
1560
|
+
}
|
|
1561
|
+
function describeProfile(entry) {
|
|
1562
|
+
const c = entry.config;
|
|
1563
|
+
const database = "database" in c && typeof c.database === "string" ? c.database : c.driver === "oracle" ? c.serviceName : void 0;
|
|
1564
|
+
return { name: entry.name, driver: c.driver, readOnly: entry.readOnly, ...database ? { database } : {} };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/index.ts
|
|
1568
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1569
|
+
import { join as join5 } from "path";
|
|
1570
|
+
import { fileURLToPath } from "url";
|
|
1571
|
+
|
|
1572
|
+
// src/server.ts
|
|
1573
|
+
import express from "express";
|
|
1574
|
+
import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1575
|
+
|
|
1576
|
+
// ../shared/contract.ts
|
|
1577
|
+
var PROTOCOL_VERSION = 2;
|
|
1578
|
+
|
|
1579
|
+
// src/tunneling.ts
|
|
1580
|
+
import { randomUUID } from "crypto";
|
|
1581
|
+
|
|
1582
|
+
// src/drivers/connection-lost.ts
|
|
1583
|
+
var ConnectionLostError = class extends Error {
|
|
1584
|
+
status = 404;
|
|
1585
|
+
constructor(message) {
|
|
1586
|
+
super(message);
|
|
1587
|
+
this.name = "ConnectionLostError";
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
var LOST_CODES = /* @__PURE__ */ new Set([
|
|
1591
|
+
// mysql2
|
|
1592
|
+
"PROTOCOL_CONNECTION_LOST",
|
|
1593
|
+
"PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR",
|
|
1594
|
+
"PROTOCOL_ENQUEUE_AFTER_QUIT",
|
|
1595
|
+
"PROTOCOL_ENQUEUE_AFTER_DESTROY",
|
|
1596
|
+
// node sockets (pg, mssql/tedious surface these directly)
|
|
1597
|
+
"ECONNRESET",
|
|
1598
|
+
"EPIPE",
|
|
1599
|
+
"ETIMEDOUT",
|
|
1600
|
+
"ECONNABORTED",
|
|
1601
|
+
"ENETUNREACH",
|
|
1602
|
+
"EHOSTUNREACH",
|
|
1603
|
+
"ENETDOWN",
|
|
1604
|
+
// mssql
|
|
1605
|
+
"ESOCKET",
|
|
1606
|
+
"ECLOSE",
|
|
1607
|
+
"ENOTOPEN",
|
|
1608
|
+
// pg: admin_shutdown / crash_shutdown / cannot_connect_now
|
|
1609
|
+
"57P01",
|
|
1610
|
+
"57P02",
|
|
1611
|
+
"57P03"
|
|
1612
|
+
]);
|
|
1613
|
+
var LOST_MESSAGES = [
|
|
1614
|
+
/connection is in closed state/i,
|
|
1615
|
+
// mysql2 "Can't add new command when connection is in closed state"
|
|
1616
|
+
/connection lost/i,
|
|
1617
|
+
/connection terminated/i,
|
|
1618
|
+
// pg
|
|
1619
|
+
/client has encountered a connection error/i,
|
|
1620
|
+
// pg
|
|
1621
|
+
/client was closed and is not queryable/i,
|
|
1622
|
+
// pg
|
|
1623
|
+
/connection is closed/i,
|
|
1624
|
+
// mssql
|
|
1625
|
+
/socket hang up/i,
|
|
1626
|
+
/ORA-0311[34]\b/,
|
|
1627
|
+
/ORA-01012\b/,
|
|
1628
|
+
/ORA-12541\b/,
|
|
1629
|
+
/ORA-03135\b/,
|
|
1630
|
+
/NJS-500\b/,
|
|
1631
|
+
/NJS-521\b/
|
|
1632
|
+
// oracle
|
|
1633
|
+
];
|
|
1634
|
+
function isConnectionLost(err) {
|
|
1635
|
+
if (!err || typeof err !== "object") return false;
|
|
1636
|
+
const e = err;
|
|
1637
|
+
if (typeof e.code === "string" && LOST_CODES.has(e.code)) return true;
|
|
1638
|
+
if (e.fatal === true) return true;
|
|
1639
|
+
if (typeof e.errorNum === "number" && [3113, 3114, 1012, 12541, 3135].includes(e.errorNum)) return true;
|
|
1640
|
+
const message = typeof e.message === "string" ? e.message : "";
|
|
1641
|
+
if (LOST_MESSAGES.some((re) => re.test(message))) return true;
|
|
1642
|
+
return e.originalError !== void 0 && e.originalError !== err && isConnectionLost(e.originalError);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// src/read-only.ts
|
|
1646
|
+
var ReadOnlyViolation = class extends Error {
|
|
1647
|
+
status = 403;
|
|
1648
|
+
constructor(message) {
|
|
1649
|
+
super(message);
|
|
1650
|
+
this.name = "ReadOnlyViolation";
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
var READ_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "VALUES", "TABLE", "PRAGMA"]);
|
|
1654
|
+
function splitStatements(sql2) {
|
|
1655
|
+
const out = [];
|
|
1656
|
+
let cur = "";
|
|
1657
|
+
let i = 0;
|
|
1658
|
+
const n = sql2.length;
|
|
1659
|
+
while (i < n) {
|
|
1660
|
+
const ch = sql2[i];
|
|
1661
|
+
const next = sql2[i + 1];
|
|
1662
|
+
if (ch === "-" && next === "-") {
|
|
1663
|
+
while (i < n && sql2[i] !== "\n") i++;
|
|
1664
|
+
cur += " ";
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
if (ch === "#") {
|
|
1668
|
+
while (i < n && sql2[i] !== "\n") i++;
|
|
1669
|
+
cur += " ";
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
if (ch === "/" && next === "*") {
|
|
1673
|
+
i += 2;
|
|
1674
|
+
while (i < n && !(sql2[i] === "*" && sql2[i + 1] === "/")) i++;
|
|
1675
|
+
i += 2;
|
|
1676
|
+
cur += " ";
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
|
|
1680
|
+
const close = ch === "[" ? "]" : ch;
|
|
1681
|
+
i++;
|
|
1682
|
+
while (i < n) {
|
|
1683
|
+
if (sql2[i] === "\\" && close === "'") {
|
|
1684
|
+
i += 2;
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
if (sql2[i] === close) {
|
|
1688
|
+
if (sql2[i + 1] === close && close !== "]") {
|
|
1689
|
+
i += 2;
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1694
|
+
i++;
|
|
1695
|
+
}
|
|
1696
|
+
i++;
|
|
1697
|
+
cur += ch === "[" ? " x " : " 'x' ";
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
if (ch === "$" && next === "$") {
|
|
1701
|
+
const end = sql2.indexOf("$$", i + 2);
|
|
1702
|
+
i = end === -1 ? n : end + 2;
|
|
1703
|
+
cur += " 'x' ";
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
if (ch === ";") {
|
|
1707
|
+
out.push(cur);
|
|
1708
|
+
cur = "";
|
|
1709
|
+
i++;
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
cur += ch;
|
|
1713
|
+
i++;
|
|
1714
|
+
}
|
|
1715
|
+
out.push(cur);
|
|
1716
|
+
return out.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1717
|
+
}
|
|
1718
|
+
function assertReadOnlyStatement(sql2) {
|
|
1719
|
+
const statements = splitStatements(sql2);
|
|
1720
|
+
if (statements.length === 0) return;
|
|
1721
|
+
if (statements.length > 1) throw new ReadOnlyViolation("Read-only profile: one statement per request");
|
|
1722
|
+
const stmt = statements[0];
|
|
1723
|
+
const first = /^[A-Za-z]+/.exec(stmt)?.[0].toUpperCase() ?? "";
|
|
1724
|
+
if (!READ_KEYWORDS.has(first)) throw new ReadOnlyViolation(`Read-only profile: ${first || "this statement"} is not allowed`);
|
|
1725
|
+
if (first === "WITH") {
|
|
1726
|
+
const words = stmt.toUpperCase().match(/\b(INSERT|UPDATE|DELETE|MERGE|REPLACE)\b/);
|
|
1727
|
+
if (words) throw new ReadOnlyViolation(`Read-only profile: ${words[1]} inside WITH is not allowed`);
|
|
1728
|
+
}
|
|
1729
|
+
if (first === "PRAGMA" && /[=(]/.test(stmt)) throw new ReadOnlyViolation("Read-only profile: PRAGMA may only read");
|
|
1730
|
+
if (/\bINTO\s+(OUTFILE|DUMPFILE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: INTO OUTFILE is not allowed");
|
|
1731
|
+
if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
|
|
1732
|
+
if (/\bLOCK\s+IN\s+SHARE\s+MODE\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
|
|
1733
|
+
if (first === "SELECT" && /\bINTO\b/i.test(stmt) && !/\bINTO\s+@/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: SELECT INTO is not allowed");
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1523
1736
|
// src/tunneling.ts
|
|
1524
1737
|
var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
|
|
1525
1738
|
var MAX_CONNECTIONS = Number(process.env.PLANCHE_MAX_CONNECTIONS) || 64;
|
|
1526
1739
|
var ConnectionManager = class {
|
|
1527
1740
|
connections = /* @__PURE__ */ new Map();
|
|
1528
1741
|
lastUsed = /* @__PURE__ */ new Map();
|
|
1742
|
+
/** Connections opened for a read-only gateway profile: writes are refused before reaching the driver. */
|
|
1743
|
+
readOnly = /* @__PURE__ */ new Set();
|
|
1529
1744
|
constructor() {
|
|
1530
1745
|
const timer = setInterval(() => void this.closeIdle(), 6e4);
|
|
1531
1746
|
timer.unref();
|
|
@@ -1543,14 +1758,23 @@ var ConnectionManager = class {
|
|
|
1543
1758
|
/**
|
|
1544
1759
|
* Open a new connection and return a unique connection ID.
|
|
1545
1760
|
*/
|
|
1546
|
-
async connect(config) {
|
|
1761
|
+
async connect(config, options = {}) {
|
|
1547
1762
|
if (this.connections.size >= MAX_CONNECTIONS) {
|
|
1548
1763
|
throw new Error(`Too many open connections (${MAX_CONNECTIONS}); disconnect some first`);
|
|
1549
1764
|
}
|
|
1550
1765
|
const driver = await openDriver(config);
|
|
1766
|
+
if (options.readOnly && driver.setReadOnly) {
|
|
1767
|
+
try {
|
|
1768
|
+
await driver.setReadOnly();
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
await driver.close().catch(() => void 0);
|
|
1771
|
+
throw err;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1551
1774
|
const connectionId = randomUUID();
|
|
1552
1775
|
this.connections.set(connectionId, driver);
|
|
1553
1776
|
this.lastUsed.set(connectionId, Date.now());
|
|
1777
|
+
if (options.readOnly) this.readOnly.add(connectionId);
|
|
1554
1778
|
console.log(`[ConnectionManager] New ${driver.type} connection: ${connectionId} -> ${driver.label}`);
|
|
1555
1779
|
return connectionId;
|
|
1556
1780
|
}
|
|
@@ -1567,7 +1791,18 @@ var ConnectionManager = class {
|
|
|
1567
1791
|
throw new Error(`Connection not found: ${connectionId}`);
|
|
1568
1792
|
}
|
|
1569
1793
|
this.lastUsed.set(connectionId, Date.now());
|
|
1570
|
-
|
|
1794
|
+
if (this.readOnly.has(connectionId)) assertReadOnlyStatement(sql2);
|
|
1795
|
+
try {
|
|
1796
|
+
return await driver.query(sql2, database);
|
|
1797
|
+
} catch (err) {
|
|
1798
|
+
if (!isConnectionLost(err)) throw err;
|
|
1799
|
+
console.log(`[ConnectionManager] Connection lost, evicting: ${connectionId}`);
|
|
1800
|
+
this.connections.delete(connectionId);
|
|
1801
|
+
this.lastUsed.delete(connectionId);
|
|
1802
|
+
this.readOnly.delete(connectionId);
|
|
1803
|
+
await driver.close().catch(() => void 0);
|
|
1804
|
+
throw new ConnectionLostError(`Connection lost: ${err instanceof Error ? err.message : String(err)}`);
|
|
1805
|
+
}
|
|
1571
1806
|
}
|
|
1572
1807
|
/**
|
|
1573
1808
|
* Close and remove a connection.
|
|
@@ -1580,6 +1815,7 @@ var ConnectionManager = class {
|
|
|
1580
1815
|
await driver.close();
|
|
1581
1816
|
this.connections.delete(connectionId);
|
|
1582
1817
|
this.lastUsed.delete(connectionId);
|
|
1818
|
+
this.readOnly.delete(connectionId);
|
|
1583
1819
|
console.log(`[ConnectionManager] Disconnected: ${connectionId}`);
|
|
1584
1820
|
}
|
|
1585
1821
|
/**
|
|
@@ -1611,10 +1847,13 @@ var ConnectionManager = class {
|
|
|
1611
1847
|
await Promise.all(promises);
|
|
1612
1848
|
this.connections.clear();
|
|
1613
1849
|
this.lastUsed.clear();
|
|
1850
|
+
this.readOnly.clear();
|
|
1614
1851
|
}
|
|
1615
1852
|
};
|
|
1616
1853
|
|
|
1617
1854
|
// src/server.ts
|
|
1855
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
1856
|
+
import { join as join4 } from "path";
|
|
1618
1857
|
function errorStatus(err) {
|
|
1619
1858
|
const status = err?.status;
|
|
1620
1859
|
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
@@ -1736,6 +1975,7 @@ function createAgent(options) {
|
|
|
1736
1975
|
next();
|
|
1737
1976
|
});
|
|
1738
1977
|
app.use(accessControl(allowedOrigins));
|
|
1978
|
+
if (options.appDir) serveApp(app, options.appDir);
|
|
1739
1979
|
app.get("/health", (req, res) => {
|
|
1740
1980
|
const body = {
|
|
1741
1981
|
success: true,
|
|
@@ -1745,6 +1985,7 @@ function createAgent(options) {
|
|
|
1745
1985
|
activeConnections: manager.activeConnections,
|
|
1746
1986
|
uptime: process.uptime()
|
|
1747
1987
|
};
|
|
1988
|
+
if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
|
|
1748
1989
|
if (req.headers.authorization !== void 0) {
|
|
1749
1990
|
if (throttle.locked(peerOf(req))) {
|
|
1750
1991
|
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
@@ -1781,10 +2022,28 @@ function createAgent(options) {
|
|
|
1781
2022
|
next();
|
|
1782
2023
|
});
|
|
1783
2024
|
app.use(express.json({ limit: options.bodyLimit }));
|
|
2025
|
+
app.get("/profiles", (_req, res) => {
|
|
2026
|
+
if (!options.gateway) {
|
|
2027
|
+
fail(res, 404, "Not a gateway");
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const body = { success: true, gateway: options.gateway.name, profiles: options.gateway.profiles.map(describeProfile) };
|
|
2031
|
+
res.json(body);
|
|
2032
|
+
});
|
|
2033
|
+
const resolveConnect = (body) => {
|
|
2034
|
+
const b = body ?? {};
|
|
2035
|
+
if (options.gateway) {
|
|
2036
|
+
if (typeof b.profile !== "string") throw Object.assign(new Error("Gateway mode: choose a profile (POST { profile })"), { status: 403 });
|
|
2037
|
+
const entry = options.gateway.profiles.find((p) => p.name === b.profile);
|
|
2038
|
+
if (!entry) throw Object.assign(new Error(`Unknown gateway profile: ${b.profile}`), { status: 404 });
|
|
2039
|
+
return { config: entry.config, readOnly: entry.readOnly };
|
|
2040
|
+
}
|
|
2041
|
+
return { config: parseDriverConfig(b), readOnly: false };
|
|
2042
|
+
};
|
|
1784
2043
|
app.post("/connect", async (req, res) => {
|
|
1785
2044
|
try {
|
|
1786
|
-
const config =
|
|
1787
|
-
const connectionId = await manager.connect(config);
|
|
2045
|
+
const { config, readOnly } = resolveConnect(req.body);
|
|
2046
|
+
const connectionId = await manager.connect(config, { readOnly });
|
|
1788
2047
|
res.json({ success: true, connectionId, message: "Connection Successful" });
|
|
1789
2048
|
} catch (err) {
|
|
1790
2049
|
const message = errorMessage(err);
|
|
@@ -1818,6 +2077,10 @@ function createAgent(options) {
|
|
|
1818
2077
|
} catch (err) {
|
|
1819
2078
|
const message = errorMessage(err);
|
|
1820
2079
|
console.error("[query] Error:", message);
|
|
2080
|
+
if (err instanceof ConnectionLostError) {
|
|
2081
|
+
fail(res, 404, message);
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
1821
2084
|
fail(res, 422, message);
|
|
1822
2085
|
}
|
|
1823
2086
|
});
|
|
@@ -1842,7 +2105,7 @@ function createAgent(options) {
|
|
|
1842
2105
|
});
|
|
1843
2106
|
app.post("/test-connection", async (req, res) => {
|
|
1844
2107
|
try {
|
|
1845
|
-
await manager.testConnection(
|
|
2108
|
+
await manager.testConnection(resolveConnect(req.body).config);
|
|
1846
2109
|
res.json({ success: true, message: "Connection test successful" });
|
|
1847
2110
|
} catch (err) {
|
|
1848
2111
|
const message = errorMessage(err);
|
|
@@ -1860,6 +2123,45 @@ function createAgent(options) {
|
|
|
1860
2123
|
});
|
|
1861
2124
|
return { app, manager };
|
|
1862
2125
|
}
|
|
2126
|
+
function serveApp(app, appDir) {
|
|
2127
|
+
const csp = [
|
|
2128
|
+
"default-src 'self'",
|
|
2129
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
2130
|
+
"connect-src 'self'",
|
|
2131
|
+
"img-src 'self' data: blob:",
|
|
2132
|
+
"style-src 'self' 'unsafe-inline'",
|
|
2133
|
+
"font-src 'self' data:",
|
|
2134
|
+
"worker-src 'self' blob:",
|
|
2135
|
+
"child-src 'self' blob:",
|
|
2136
|
+
"object-src 'none'",
|
|
2137
|
+
"base-uri 'none'",
|
|
2138
|
+
"form-action 'none'",
|
|
2139
|
+
"frame-ancestors 'none'"
|
|
2140
|
+
].join("; ");
|
|
2141
|
+
const indexFile = join4(appDir, "index.html");
|
|
2142
|
+
const indexHtml = existsSync2(indexFile) ? readFileSync2(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />') : null;
|
|
2143
|
+
const headers = (res) => {
|
|
2144
|
+
res.setHeader("Content-Security-Policy", csp);
|
|
2145
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
2146
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
2147
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
2148
|
+
};
|
|
2149
|
+
app.get(["/", "/index.html"], (_req, res) => {
|
|
2150
|
+
if (!indexHtml) {
|
|
2151
|
+
fail(res, 404, "App not bundled");
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
headers(res);
|
|
2155
|
+
res.type("html").send(indexHtml);
|
|
2156
|
+
});
|
|
2157
|
+
app.use(express.static(appDir, {
|
|
2158
|
+
index: false,
|
|
2159
|
+
setHeaders: (res, path) => {
|
|
2160
|
+
if (path.endsWith(".html")) headers(res);
|
|
2161
|
+
if (/\/assets\//.test(path)) res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
2162
|
+
}
|
|
2163
|
+
}));
|
|
2164
|
+
}
|
|
1863
2165
|
|
|
1864
2166
|
// src/index.ts
|
|
1865
2167
|
var { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
@@ -1884,6 +2186,12 @@ Options:
|
|
|
1884
2186
|
--origin <url> Extra browser origin allowed to use the agent (repeatable,
|
|
1885
2187
|
env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
|
|
1886
2188
|
--allow-remote Required with a non-loopback --addr (see README: no TLS)
|
|
2189
|
+
--gateway <file> Gateway mode: serve named read-only profiles from a JSON
|
|
2190
|
+
config instead of accepting credentials (env PLANCHE_GATEWAY)
|
|
2191
|
+
--public-url <u> URL users open when the agent serves the app itself
|
|
2192
|
+
(behind your TLS proxy; env PLANCHE_PUBLIC_URL)
|
|
2193
|
+
--no-serve-app Do not serve the bundled web app at / (served by default
|
|
2194
|
+
in gateway mode)
|
|
1887
2195
|
--no-open Do not open the browser
|
|
1888
2196
|
--verbose Log every request
|
|
1889
2197
|
-v, --version Print the version
|
|
@@ -1933,6 +2241,9 @@ function main() {
|
|
|
1933
2241
|
verbose: { type: "boolean" },
|
|
1934
2242
|
"allow-remote": { type: "boolean" },
|
|
1935
2243
|
"print-token": { type: "boolean" },
|
|
2244
|
+
gateway: { type: "string" },
|
|
2245
|
+
"public-url": { type: "string" },
|
|
2246
|
+
"serve-app": { type: "boolean" },
|
|
1936
2247
|
version: { type: "boolean", short: "v" },
|
|
1937
2248
|
help: { type: "boolean", short: "h" }
|
|
1938
2249
|
},
|
|
@@ -1957,7 +2268,24 @@ function main() {
|
|
|
1957
2268
|
const addr = flags.addr ?? process.env.PLANCHE_ADDR ?? "127.0.0.1";
|
|
1958
2269
|
const tokenFromEnv = !flags.token && !!process.env.PLANCHE_AUTH_TOKEN;
|
|
1959
2270
|
const authToken = flags.token ?? process.env.PLANCHE_AUTH_TOKEN ?? randomUUID2();
|
|
1960
|
-
const
|
|
2271
|
+
const gatewayFile = flags.gateway ?? process.env.PLANCHE_GATEWAY;
|
|
2272
|
+
let gateway;
|
|
2273
|
+
if (gatewayFile) {
|
|
2274
|
+
try {
|
|
2275
|
+
gateway = loadGatewayConfig(gatewayFile);
|
|
2276
|
+
} catch (err) {
|
|
2277
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2278
|
+
process.exit(2);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
const appDir = fileURLToPath(new URL("../app/", import.meta.url));
|
|
2282
|
+
const serveApp2 = flags["serve-app"] ?? Boolean(gateway);
|
|
2283
|
+
if (serveApp2 && !existsSync3(join5(appDir, "index.html"))) {
|
|
2284
|
+
console.error(`The web app is not bundled with this build (${appDir}); run without --serve-app or use the npm package.`);
|
|
2285
|
+
process.exit(2);
|
|
2286
|
+
}
|
|
2287
|
+
const publicUrl = (flags["public-url"] ?? process.env.PLANCHE_PUBLIC_URL ?? "").replace(/\/+$/, "");
|
|
2288
|
+
const appUrl = (serveApp2 ? publicUrl || null : null) ?? (flags.app ?? process.env.PLANCHE_APP_URL ?? DEFAULT_APP_URL).replace(/\/+$/, "");
|
|
1961
2289
|
const shouldOpen = flags.open ?? true;
|
|
1962
2290
|
const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
|
|
1963
2291
|
const appOrigin = originOf(appUrl);
|
|
@@ -1990,6 +2318,7 @@ function main() {
|
|
|
1990
2318
|
}
|
|
1991
2319
|
const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
|
|
1992
2320
|
const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
|
|
2321
|
+
if (serveApp2) allowedOrigins.push(`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`);
|
|
1993
2322
|
const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
|
|
1994
2323
|
const listenAddr = addr.replace(/^\[(.*)\]$/, "$1");
|
|
1995
2324
|
const agentUrl = `http://${listenAddr.includes(":") ? `[${listenAddr}]` : listenAddr}:${port}`;
|
|
@@ -2001,9 +2330,11 @@ function main() {
|
|
|
2001
2330
|
logRequests: flags.verbose ?? false,
|
|
2002
2331
|
// Browsers omit a default port from `Host`, so the bare names are allowed too.
|
|
2003
2332
|
allowedHosts: loopback ? ["127.0.0.1", "localhost", "[::1]", `127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
|
|
2004
|
-
pairingCode
|
|
2333
|
+
pairingCode,
|
|
2334
|
+
gateway,
|
|
2335
|
+
appDir: serveApp2 ? appDir : void 0
|
|
2005
2336
|
});
|
|
2006
|
-
const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
|
|
2337
|
+
const pairUrl = serveApp2 ? `${publicUrl || agentUrl}/#pair=${encodeURIComponent(pairingCode.code)}` : `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
|
|
2007
2338
|
const server = app.listen(port, listenAddr, () => {
|
|
2008
2339
|
const line = "-".repeat(72);
|
|
2009
2340
|
console.log(line);
|