@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.
- package/dist/app/api-doc.yaml +98 -0
- package/dist/app/assets/{EnvironmentPage-BX71Wsun.js → EnvironmentPage-DxOiCxcd.js} +1 -1
- package/dist/app/assets/{HomePage-Bnp4Puv4.js → HomePage-i8qAtD6x.js} +1 -1
- package/dist/app/assets/{MainPage-chkqUlI0.js → MainPage-B58d5pmX.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-DG4e_wRd.js → MaterializationsPage-6OEVM543.js} +1 -1
- package/dist/app/assets/{ModelPage-DbLU-ABs.js → ModelPage-8d5l7YkU.js} +1 -1
- package/dist/app/assets/{PackagePage-EvWf3VZ4.js → PackagePage-BVmoVsPQ.js} +1 -1
- package/dist/app/assets/{RouteError-POj8kImc.js → RouteError-DFX521_t.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Bkq3C1MS.js → WorkbookPage-CzucDJ-T.js} +1 -1
- package/dist/app/assets/{core-DiLT6QvL.es-Jn6sdy15.js → core-B95VQkAV.es-Cbn-yKG-.js} +1 -1
- package/dist/app/assets/{index-gjr27uMq.js → index--xJ1pzL-.js} +3 -3
- package/dist/app/assets/{index-bAdd7U9-.js → index-Bxkza-hz.js} +1 -1
- package/dist/app/assets/{index-C_AT6ZZw.js → index-DxRKma36.js} +1 -1
- package/dist/app/assets/{index.umd-BxzPw_1E.js → index.umd-o-yUIEtS.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/cpufeatures-1yrn0vtw.node +0 -0
- package/dist/server.mjs +19022 -70
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +1 -1
- package/src/dto/connection.dto.ts +43 -0
- package/src/service/connection.ts +104 -2
- package/src/service/connection_config.spec.ts +116 -0
- package/src/service/connection_config.ts +48 -0
- package/src/service/model.ts +25 -3
- package/src/service/package.ts +21 -2
- package/src/service/package_worker_path.spec.ts +103 -0
- package/src/service/proxy.spec.ts +367 -0
- 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
|
+
}
|