@malloy-publisher/server 0.0.217 → 0.0.218

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.
Files changed (28) hide show
  1. package/dist/app/api-doc.yaml +98 -0
  2. package/dist/app/assets/{EnvironmentPage-BX71Wsun.js → EnvironmentPage-DxOiCxcd.js} +1 -1
  3. package/dist/app/assets/{HomePage-Bnp4Puv4.js → HomePage-i8qAtD6x.js} +1 -1
  4. package/dist/app/assets/{MainPage-chkqUlI0.js → MainPage-B58d5pmX.js} +1 -1
  5. package/dist/app/assets/{MaterializationsPage-DG4e_wRd.js → MaterializationsPage-6OEVM543.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DbLU-ABs.js → ModelPage-8d5l7YkU.js} +1 -1
  7. package/dist/app/assets/{PackagePage-EvWf3VZ4.js → PackagePage-BVmoVsPQ.js} +1 -1
  8. package/dist/app/assets/{RouteError-POj8kImc.js → RouteError-DFX521_t.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Bkq3C1MS.js → WorkbookPage-CzucDJ-T.js} +1 -1
  10. package/dist/app/assets/{core-DiLT6QvL.es-Jn6sdy15.js → core-B95VQkAV.es-Cbn-yKG-.js} +1 -1
  11. package/dist/app/assets/{index-gjr27uMq.js → index--xJ1pzL-.js} +3 -3
  12. package/dist/app/assets/{index-bAdd7U9-.js → index-Bxkza-hz.js} +1 -1
  13. package/dist/app/assets/{index-C_AT6ZZw.js → index-DxRKma36.js} +1 -1
  14. package/dist/app/assets/{index.umd-BxzPw_1E.js → index.umd-o-yUIEtS.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  17. package/dist/server.mjs +19022 -70
  18. package/dist/sshcrypto-8m50vnmb.node +0 -0
  19. package/package.json +1 -1
  20. package/src/dto/connection.dto.ts +43 -0
  21. package/src/service/connection.ts +104 -2
  22. package/src/service/connection_config.spec.ts +116 -0
  23. package/src/service/connection_config.ts +48 -0
  24. package/src/service/model.ts +25 -3
  25. package/src/service/package.ts +21 -2
  26. package/src/service/package_worker_path.spec.ts +103 -0
  27. package/src/service/proxy.spec.ts +367 -0
  28. package/src/service/proxy.ts +233 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Generic TCP proxy layer for publisher database connections.
3
+ *
4
+ * Currently supports SSH bastion tunnels (type "ssh"). Future connection
5
+ * types (mysql, trino, …) only need a new `openProxy` branch — the caller
6
+ * interface is unchanged.
7
+ *
8
+ * A connection proxy is a normal connection capability (authorized by whoever
9
+ * configures the connection); it is intentionally NOT behind an env-flag gate,
10
+ * and is kept separate from the `publisher` HTTP multi-hop type's
11
+ * PUBLISHER_ALLOW_PROXY_CONNECTIONS gate. The trust boundary is host-key pinning
12
+ * (below), which is fail-closed.
13
+ *
14
+ * # Host-key policy
15
+ *
16
+ * Publisher is fail-closed by default: if `ssh.hostKey` is absent the tunnel
17
+ * is refused at connect time to prevent MITM. Set the environment variable
18
+ *
19
+ * PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY=true
20
+ *
21
+ * to disable this check for development/testing. Never set this in
22
+ * production.
23
+ */
24
+
25
+ import net from "net";
26
+ import { Client as SshClient } from "ssh2";
27
+ import type { ConnectConfig, SyncHostVerifier } from "ssh2";
28
+ import { components } from "../api";
29
+
30
+ type ConnectionProxy = components["schemas"]["ConnectionProxy"];
31
+
32
+ export interface ProxyEndpoint {
33
+ host: string;
34
+ port: number;
35
+ close(): Promise<void>;
36
+ }
37
+
38
+ const SSH_CONNECT_TIMEOUT_MS = 15_000;
39
+ const SSH_KEEPALIVE_INTERVAL_MS = 15_000;
40
+
41
+ /**
42
+ * Extract the base64 wire-format key from a stored hostKey, accepting any of:
43
+ * a full known_hosts line (`[markers] host keytype AAAA…`), a `keytype AAAA…`
44
+ * pair, or the bare base64 blob. SSH public-key blobs always base64-encode to a
45
+ * string starting with "AAAA" (the 4-byte length prefix of the key-type name),
46
+ * so we pick that token; otherwise fall back to the last whitespace-delimited
47
+ * token. This matches what ssh2's hostVerifier hands us (`key.toString("base64")`).
48
+ */
49
+ function extractHostKeyBase64(raw: string): string {
50
+ const tokens = raw.trim().split(/\s+/);
51
+ const blob = tokens.find((t) => /^AAAA[A-Za-z0-9+/]+={0,2}$/.test(t));
52
+ return blob ?? tokens[tokens.length - 1] ?? "";
53
+ }
54
+
55
+ /**
56
+ * Open a proxy tunnel described by `proxy` that ultimately delivers traffic to
57
+ * `target`. Returns a local `127.0.0.1:port` endpoint the DB driver should
58
+ * connect to in place of the real host/port.
59
+ */
60
+ export async function openProxy(
61
+ proxy: ConnectionProxy,
62
+ target: { host: string; port: number },
63
+ ): Promise<ProxyEndpoint> {
64
+ if (proxy.type === "ssh") {
65
+ if (!proxy.ssh) {
66
+ throw new Error(
67
+ "ConnectionProxy type is 'ssh' but the 'ssh' config object is missing.",
68
+ );
69
+ }
70
+ return openSshProxy(proxy.ssh, target);
71
+ }
72
+ throw new Error(
73
+ `Proxy type '${proxy.type}' is not supported yet. Only 'ssh' is implemented.`,
74
+ );
75
+ }
76
+
77
+ function openSshProxy(
78
+ ssh: components["schemas"]["SshProxyConfig"],
79
+ target: { host: string; port: number },
80
+ ): Promise<ProxyEndpoint> {
81
+ return new Promise((resolve, reject) => {
82
+ const client = new SshClient();
83
+ let localPort = 0;
84
+ let server: net.Server | undefined;
85
+ let settled = false;
86
+ // Track accepted local sockets so close() can force them down. Relying on
87
+ // server.close() alone waits for in-flight sockets to drain, which can
88
+ // hang teardown indefinitely if a forwarded socket lingers.
89
+ const sockets = new Set<net.Socket>();
90
+
91
+ function fail(err: Error): void {
92
+ if (settled) return;
93
+ settled = true;
94
+ server?.close();
95
+ client.end();
96
+ reject(err);
97
+ }
98
+
99
+ const connectConfig: ConnectConfig = {
100
+ host: ssh.host,
101
+ port: ssh.port ?? 22,
102
+ username: ssh.username,
103
+ privateKey: ssh.privateKey,
104
+ ...(ssh.privateKeyPass ? { passphrase: ssh.privateKeyPass } : {}),
105
+ readyTimeout: SSH_CONNECT_TIMEOUT_MS,
106
+ // Send SSH-level keepalives so an idle tunnel isn't silently reaped by
107
+ // Cloud NAT / bastion ClientAlive / stateful-firewall idle timeouts —
108
+ // which would otherwise only surface as a confusing pg error on the
109
+ // next query. 3 missed (~45s) before ssh2 considers the link dead.
110
+ keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS,
111
+ keepaliveCountMax: 3,
112
+ hostVerifier: ((key: Buffer): boolean => {
113
+ // `key` is the raw Buffer of the host's public key.
114
+ const presented = key.toString("base64");
115
+ if (ssh.hostKey) {
116
+ const expected = extractHostKeyBase64(ssh.hostKey);
117
+ if (presented !== expected) {
118
+ fail(
119
+ new Error(
120
+ `SSH host-key mismatch for ${ssh.host}: expected ${expected.slice(0, 24)}… but got ${presented.slice(0, 24)}….`,
121
+ ),
122
+ );
123
+ return false;
124
+ }
125
+ return true;
126
+ }
127
+ // No hostKey provided.
128
+ if (process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY === "true") {
129
+ return true;
130
+ }
131
+ fail(
132
+ new Error(
133
+ `SSH connection to ${ssh.host} refused: no hostKey provided and ` +
134
+ `PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY is not 'true'. ` +
135
+ `Set hostKey to the bastion's host public key (an OpenSSH ` +
136
+ `known_hosts line or its base64 blob, e.g. from ssh-keyscan).`,
137
+ ),
138
+ );
139
+ return false;
140
+ }) as SyncHostVerifier,
141
+ };
142
+
143
+ client.on("error", (err) => fail(err));
144
+
145
+ client.on("ready", () => {
146
+ server = net.createServer((socket) => {
147
+ sockets.add(socket);
148
+ socket.on("close", () => sockets.delete(socket));
149
+ // Buffer incoming data immediately so we don't lose bytes that
150
+ // arrive before the forwardOut channel is open (relevant in Bun
151
+ // where resume() doesn't re-emit already-buffered data).
152
+ const earlyData: Buffer[] = [];
153
+ socket.on("data", (chunk: Buffer) => earlyData.push(chunk));
154
+
155
+ client.forwardOut(
156
+ "127.0.0.1",
157
+ localPort,
158
+ target.host,
159
+ target.port,
160
+ (err, channel) => {
161
+ if (err) {
162
+ socket.destroy(err);
163
+ return;
164
+ }
165
+ // The local socket may have closed while forwardOut was still
166
+ // opening the channel; if so, tear the channel down instead of
167
+ // attaching it to a dead socket (which would leak the remote
168
+ // connection). The rest of this callback is synchronous, so no
169
+ // close can slip in between this check and the listeners below.
170
+ if (socket.destroyed) {
171
+ channel.destroy();
172
+ return;
173
+ }
174
+ // Flush buffered data, then switch to live forwarding.
175
+ for (const chunk of earlyData) {
176
+ channel.write(chunk);
177
+ }
178
+ earlyData.length = 0;
179
+ socket.removeAllListeners("data");
180
+ // pipe() honors backpressure (pauses the source when the
181
+ // destination's buffer is full) — unlike a raw on('data') =>
182
+ // write() which would buffer unbounded on a slow peer.
183
+ socket.pipe(channel);
184
+ channel.pipe(socket);
185
+ socket.on("error", () => channel.destroy());
186
+ channel.on("error", () => socket.destroy());
187
+ // Propagate teardown both ways so neither side is left
188
+ // half-open when the other closes (e.g. pool eviction or the
189
+ // DB dropping the connection).
190
+ socket.on("close", () => channel.destroy());
191
+ channel.on("close", () => socket.destroy());
192
+ },
193
+ );
194
+ });
195
+
196
+ server.listen(0, "127.0.0.1", () => {
197
+ const addr = server!.address();
198
+ if (!addr || typeof addr === "string") {
199
+ fail(
200
+ new Error(
201
+ "Failed to obtain local listen port for SSH proxy.",
202
+ ),
203
+ );
204
+ return;
205
+ }
206
+ localPort = addr.port;
207
+
208
+ if (settled) return;
209
+ settled = true;
210
+
211
+ resolve({
212
+ host: "127.0.0.1",
213
+ port: localPort,
214
+ close(): Promise<void> {
215
+ return new Promise((res) => {
216
+ // Force-destroy live forwarded sockets first so server.close()
217
+ // doesn't wait on them (which would hang teardown).
218
+ for (const s of sockets) s.destroy();
219
+ server!.close(() => {
220
+ client.end();
221
+ res();
222
+ });
223
+ });
224
+ },
225
+ });
226
+ });
227
+
228
+ server.on("error", (err) => fail(err));
229
+ });
230
+
231
+ client.connect(connectConfig);
232
+ });
233
+ }