@malloy-publisher/server 0.0.216 → 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 +116 -0
- package/dist/app/assets/{EnvironmentPage-CuO6sty5.js → EnvironmentPage-DxOiCxcd.js} +1 -1
- package/dist/app/assets/{HomePage-u3vxROIF.js → HomePage-i8qAtD6x.js} +1 -1
- package/dist/app/assets/{MainPage-Bt2sYLbr.js → MainPage-B58d5pmX.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-B1rj61zs.js → MaterializationsPage-6OEVM543.js} +1 -1
- package/dist/app/assets/{ModelPage-BpvONvSR.js → ModelPage-8d5l7YkU.js} +1 -1
- package/dist/app/assets/{PackagePage-DHNcwboW.js → PackagePage-BVmoVsPQ.js} +1 -1
- package/dist/app/assets/{RouteError-D1vhLJvr.js → RouteError-DFX521_t.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CZmud-yI.js → WorkbookPage-CzucDJ-T.js} +1 -1
- package/dist/app/assets/{core-XtBSnW2U.es-DE88sVsZ.js → core-B95VQkAV.es-Cbn-yKG-.js} +1 -1
- package/dist/app/assets/{index-BlnQtDZj.js → index--xJ1pzL-.js} +73 -73
- package/dist/app/assets/{index-CYf2akGH.js → index-Bxkza-hz.js} +1 -1
- package/dist/app/assets/{index-BQdOB6m9.js → index-DxRKma36.js} +1 -1
- package/dist/app/assets/{index.umd-BdQ0R4hx.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 +19050 -75
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +1 -1
- package/src/dto/connection.dto.ts +43 -0
- package/src/server.ts +55 -1
- package/src/service/build_plan.spec.ts +21 -0
- package/src/service/build_plan.ts +14 -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
- package/tests/fixtures/html-pages-test/public/barehtml.html +4 -0
- package/tests/fixtures/html-pages-test/public/bodymeta.html +8 -0
- package/tests/fixtures/html-pages-test/public/fitcomment.html +11 -0
- package/tests/fixtures/html-pages-test/public/nohead.html +8 -0
- package/tests/fixtures/html-pages-test/public/notfit.html +13 -0
- package/tests/fixtures/html-pages-test/public/slides.html +12 -0
- package/tests/fixtures/html-pages-test/public/unterminated.html +10 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +63 -1
package/src/service/package.ts
CHANGED
|
@@ -40,6 +40,7 @@ type ApiDatabase = components["schemas"]["Database"];
|
|
|
40
40
|
type ApiModel = components["schemas"]["Model"];
|
|
41
41
|
type ApiNotebook = components["schemas"]["Notebook"];
|
|
42
42
|
export type ApiPackage = components["schemas"]["Package"];
|
|
43
|
+
type ApiPackageWarning = NonNullable<ApiPackage["warnings"]>[number];
|
|
43
44
|
type ApiColumn = components["schemas"]["Column"];
|
|
44
45
|
type ApiTableDescription = components["schemas"]["TableDescription"];
|
|
45
46
|
// A thunk lets callers pass a live reference to the *current* environment
|
|
@@ -77,6 +78,12 @@ export class Package {
|
|
|
77
78
|
// no persist source. Surfaced read-only on getPackageMetadata() so a caller
|
|
78
79
|
// can derive build instructions without a separate plan round-trip.
|
|
79
80
|
private buildPlan: BuildPlan | null = null;
|
|
81
|
+
// Non-fatal render-tag findings aggregated across the package's models (each
|
|
82
|
+
// tagged with its model path), surfaced read-only on
|
|
83
|
+
// getPackageMetadata().warnings. Refreshed on load and reload. A bad render
|
|
84
|
+
// tag does not fail the load (see Model.validateRenderTags); this is the
|
|
85
|
+
// response-level signal that a tag is misconfigured.
|
|
86
|
+
private renderTagWarnings: ApiPackageWarning[] = [];
|
|
80
87
|
private static meter = publisherMeter();
|
|
81
88
|
private static packageLoadHistogram = this.meter.createHistogram(
|
|
82
89
|
"malloy_package_load_duration",
|
|
@@ -350,6 +357,7 @@ export class Package {
|
|
|
350
357
|
// placeholders instead; that branch goes through a different
|
|
351
358
|
// hydration path.)
|
|
352
359
|
const models = new Map<string, Model>();
|
|
360
|
+
const renderTagWarnings: ApiPackageWarning[] = [];
|
|
353
361
|
for (const sm of outcome.models) {
|
|
354
362
|
if (sm.compilationError) {
|
|
355
363
|
const err = Model.deserializeCompilationError(sm.compilationError);
|
|
@@ -371,7 +379,10 @@ export class Package {
|
|
|
371
379
|
// Validate renderer tags on the main thread (the renderer is too heavy
|
|
372
380
|
// to load inside the pure-CPU package-load worker). A misconfigured tag
|
|
373
381
|
// is logged as a warning naming the target; it does not fail the load.
|
|
374
|
-
|
|
382
|
+
// The findings also ride the package response as non-fatal `warnings`.
|
|
383
|
+
for (const w of await model.validateRenderTags()) {
|
|
384
|
+
renderTagWarnings.push({ model: sm.modelPath, ...w });
|
|
385
|
+
}
|
|
375
386
|
// Reject unquoted `#@ persist name=` annotations the same way: an
|
|
376
387
|
// unquoted name is dropped from the build plan, so the source would
|
|
377
388
|
// publish but never materialize. Scan the raw `.malloy` source (the
|
|
@@ -406,6 +417,7 @@ export class Package {
|
|
|
406
417
|
models,
|
|
407
418
|
malloyConfig,
|
|
408
419
|
);
|
|
420
|
+
pkg.renderTagWarnings = renderTagWarnings;
|
|
409
421
|
|
|
410
422
|
// Compute the persist build plan off the live (unbound) models, before the
|
|
411
423
|
// caller binds any configured manifest, so the surfaced plan reflects the
|
|
@@ -484,6 +496,9 @@ export class Package {
|
|
|
484
496
|
if (warnings.length > 0) {
|
|
485
497
|
metadata.exploresWarnings = warnings;
|
|
486
498
|
}
|
|
499
|
+
if (this.renderTagWarnings.length > 0) {
|
|
500
|
+
metadata.warnings = [...this.renderTagWarnings];
|
|
501
|
+
}
|
|
487
502
|
return metadata;
|
|
488
503
|
}
|
|
489
504
|
|
|
@@ -704,6 +719,7 @@ export class Package {
|
|
|
704
719
|
}
|
|
705
720
|
|
|
706
721
|
const nextModels = new Map<string, Model>();
|
|
722
|
+
const renderTagWarnings: ApiPackageWarning[] = [];
|
|
707
723
|
for (const sm of outcome.models) {
|
|
708
724
|
if (sm.compilationError) {
|
|
709
725
|
const err = Model.deserializeCompilationError(sm.compilationError);
|
|
@@ -735,7 +751,9 @@ export class Package {
|
|
|
735
751
|
// unexpected internal failure is recorded as this model's
|
|
736
752
|
// compilationError rather than aborting the whole reload.
|
|
737
753
|
try {
|
|
738
|
-
await model.validateRenderTags()
|
|
754
|
+
for (const w of await model.validateRenderTags()) {
|
|
755
|
+
renderTagWarnings.push({ model: sm.modelPath, ...w });
|
|
756
|
+
}
|
|
739
757
|
nextModels.set(sm.modelPath, model);
|
|
740
758
|
} catch (renderErr) {
|
|
741
759
|
const err =
|
|
@@ -760,6 +778,7 @@ export class Package {
|
|
|
760
778
|
}
|
|
761
779
|
}
|
|
762
780
|
this.models = nextModels;
|
|
781
|
+
this.renderTagWarnings = renderTagWarnings;
|
|
763
782
|
// A reload re-reads publisher.json in the worker; pick up any change to
|
|
764
783
|
// the explore set and query-boundary mode so listModels()/the gate
|
|
765
784
|
// reflect edited explores without a full Package.create.
|
|
@@ -316,6 +316,17 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
316
316
|
m.includes("nums -> card"),
|
|
317
317
|
),
|
|
318
318
|
).toBe(true);
|
|
319
|
+
// The same finding rides the package response (non-fatal) so the
|
|
320
|
+
// control plane / UI can surface it without scraping logs.
|
|
321
|
+
const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
|
|
322
|
+
expect(
|
|
323
|
+
responseWarnings.some(
|
|
324
|
+
(w) =>
|
|
325
|
+
w.model === "bad_render.malloy" &&
|
|
326
|
+
w.target === "nums -> card" &&
|
|
327
|
+
w.severity === "error",
|
|
328
|
+
),
|
|
329
|
+
).toBe(true);
|
|
319
330
|
} finally {
|
|
320
331
|
warnSpy.mockRestore();
|
|
321
332
|
await duckdb.close();
|
|
@@ -337,6 +348,8 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
337
348
|
try {
|
|
338
349
|
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
339
350
|
expect(pkg.getModelPaths()).toEqual(["good_render.malloy"]);
|
|
351
|
+
// A clean package omits the warnings field entirely.
|
|
352
|
+
expect(pkg.getPackageMetadata().warnings).toBeUndefined();
|
|
340
353
|
} finally {
|
|
341
354
|
await duckdb.close();
|
|
342
355
|
}
|
|
@@ -449,6 +462,14 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
449
462
|
expect(
|
|
450
463
|
warnings.some((m) => m.includes("Invalid renderer configuration")),
|
|
451
464
|
).toBe(true);
|
|
465
|
+
// The notebook finding also rides the package response (not just logs).
|
|
466
|
+
const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
|
|
467
|
+
expect(
|
|
468
|
+
responseWarnings.some(
|
|
469
|
+
(w) =>
|
|
470
|
+
w.model === "bad_render.malloynb" && w.severity === "error",
|
|
471
|
+
),
|
|
472
|
+
).toBe(true);
|
|
452
473
|
} finally {
|
|
453
474
|
warnSpy.mockRestore();
|
|
454
475
|
await duckdb.close();
|
|
@@ -499,12 +520,94 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
499
520
|
expect(
|
|
500
521
|
warnings.some((m) => m.includes("Invalid renderer configuration")),
|
|
501
522
|
).toBe(true);
|
|
523
|
+
// Reload refreshes the response-level warnings too.
|
|
524
|
+
const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
|
|
525
|
+
expect(responseWarnings.some((w) => w.model === "m.malloy")).toBe(
|
|
526
|
+
true,
|
|
527
|
+
);
|
|
502
528
|
} finally {
|
|
503
529
|
warnSpy.mockRestore();
|
|
504
530
|
await duckdb.close();
|
|
505
531
|
}
|
|
506
532
|
});
|
|
507
533
|
|
|
534
|
+
it("clears render-tag warnings on reload when a bad tag is fixed (bad -> clean)", async () => {
|
|
535
|
+
writeManifest();
|
|
536
|
+
// Start BAD so the package loads with a warning.
|
|
537
|
+
fs.writeFileSync(
|
|
538
|
+
path.join(tempDir, "m.malloy"),
|
|
539
|
+
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
540
|
+
measure: total is a.sum()
|
|
541
|
+
# big_value { sparkline=trend }
|
|
542
|
+
view: card is {
|
|
543
|
+
aggregate: total
|
|
544
|
+
nest:
|
|
545
|
+
# line_chart { size=spark }
|
|
546
|
+
trend is { group_by: a; aggregate: total }
|
|
547
|
+
}
|
|
548
|
+
}`,
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
552
|
+
try {
|
|
553
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
554
|
+
expect(
|
|
555
|
+
(pkg.getPackageMetadata().warnings ?? []).some(
|
|
556
|
+
(w) => w.model === "m.malloy",
|
|
557
|
+
),
|
|
558
|
+
).toBe(true);
|
|
559
|
+
|
|
560
|
+
// Fix the tag, then reload: renderTagWarnings is rebuilt from scratch,
|
|
561
|
+
// so the stale finding must disappear (field omitted when empty).
|
|
562
|
+
fs.writeFileSync(
|
|
563
|
+
path.join(tempDir, "m.malloy"),
|
|
564
|
+
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
565
|
+
measure: total is a.sum()
|
|
566
|
+
# bar_chart
|
|
567
|
+
view: chart is { group_by: a; aggregate: total }
|
|
568
|
+
}`,
|
|
569
|
+
);
|
|
570
|
+
await pkg.reloadAllModels({});
|
|
571
|
+
|
|
572
|
+
expect(pkg.getPackageMetadata().warnings).toBeUndefined();
|
|
573
|
+
} finally {
|
|
574
|
+
await duckdb.close();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("aggregates render-tag warnings from multiple models, each tagged with its own path", async () => {
|
|
579
|
+
writeManifest();
|
|
580
|
+
const badModel = (view: string) =>
|
|
581
|
+
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
582
|
+
measure: total is a.sum()
|
|
583
|
+
# big_value { sparkline=trend }
|
|
584
|
+
view: ${view} is {
|
|
585
|
+
aggregate: total
|
|
586
|
+
nest:
|
|
587
|
+
# line_chart { size=spark }
|
|
588
|
+
trend is { group_by: a; aggregate: total }
|
|
589
|
+
}
|
|
590
|
+
}`;
|
|
591
|
+
fs.writeFileSync(path.join(tempDir, "a.malloy"), badModel("card_a"));
|
|
592
|
+
fs.writeFileSync(path.join(tempDir, "b.malloy"), badModel("card_b"));
|
|
593
|
+
|
|
594
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
595
|
+
try {
|
|
596
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
597
|
+
const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
|
|
598
|
+
const models = new Set(responseWarnings.map((w) => w.model));
|
|
599
|
+
expect(models.has("a.malloy")).toBe(true);
|
|
600
|
+
expect(models.has("b.malloy")).toBe(true);
|
|
601
|
+
expect(models.size).toBe(2);
|
|
602
|
+
// The message field is carried through, not just model/target/severity.
|
|
603
|
+
expect(
|
|
604
|
+
responseWarnings.every((w) => (w.message ?? "").length > 0),
|
|
605
|
+
).toBe(true);
|
|
606
|
+
} finally {
|
|
607
|
+
await duckdb.close();
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
508
611
|
// NB: kept last in this describe — swapping the singleton for a
|
|
509
612
|
// pre-shutdown pool also tears down the shared `pool` (the swap
|
|
510
613
|
// implementation shuts down the outgoing singleton). Subsequent
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the SSH proxy layer.
|
|
3
|
+
*
|
|
4
|
+
* These tests are hermetic: they stand up an in-process ssh2 server that
|
|
5
|
+
* forwards to a plain TCP echo server, call openProxy(), connect a client
|
|
6
|
+
* to the returned local endpoint, and assert that bytes round-trip through
|
|
7
|
+
* the tunnel.
|
|
8
|
+
*
|
|
9
|
+
* No external network access is required.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import net from "net";
|
|
13
|
+
import { generateKeyPairSync } from "crypto";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
15
|
+
import {
|
|
16
|
+
Server as SshServer,
|
|
17
|
+
utils as sshUtils,
|
|
18
|
+
type Connection as SshServerConnection,
|
|
19
|
+
} from "ssh2";
|
|
20
|
+
import { openProxy } from "./proxy";
|
|
21
|
+
|
|
22
|
+
// ── Key material generated once for the test suite ────────────────────────────
|
|
23
|
+
|
|
24
|
+
// ssh2's server requires PEM private keys it can parse. These are ephemeral,
|
|
25
|
+
// test-only keys; RSA-2048 (not 1024) so static analysis doesn't flag them.
|
|
26
|
+
const hostKeys = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
27
|
+
const hostPrivatePem = hostKeys.privateKey
|
|
28
|
+
.export({ type: "pkcs1", format: "pem" })
|
|
29
|
+
.toString();
|
|
30
|
+
|
|
31
|
+
const clientKeys = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
32
|
+
const clientPrivatePem = clientKeys.privateKey
|
|
33
|
+
.export({ type: "pkcs1", format: "pem" })
|
|
34
|
+
.toString();
|
|
35
|
+
// Helpers ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function startEchoServer(): Promise<{
|
|
38
|
+
port: number;
|
|
39
|
+
close: () => Promise<void>;
|
|
40
|
+
}> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
// Track accepted sockets so close() can force them down. server.close()
|
|
43
|
+
// alone only stops accepting and then waits for open connections to end;
|
|
44
|
+
// a lingering forwarded socket would otherwise hang teardown (and the
|
|
45
|
+
// afterEach hook) until the test timeout, especially under --serial CI.
|
|
46
|
+
const sockets = new Set<net.Socket>();
|
|
47
|
+
const server = net.createServer((socket) => {
|
|
48
|
+
sockets.add(socket);
|
|
49
|
+
socket.on("close", () => sockets.delete(socket));
|
|
50
|
+
socket.pipe(socket);
|
|
51
|
+
});
|
|
52
|
+
server.on("error", reject);
|
|
53
|
+
server.listen(0, "127.0.0.1", () => {
|
|
54
|
+
const addr = server.address() as net.AddressInfo;
|
|
55
|
+
resolve({
|
|
56
|
+
port: addr.port,
|
|
57
|
+
close: () =>
|
|
58
|
+
new Promise((res, rej) => {
|
|
59
|
+
for (const s of sockets) s.destroy();
|
|
60
|
+
server.close((err) => (err ? rej(err) : res()));
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Start a minimal in-process SSH server that accepts the test client key
|
|
69
|
+
* and fulfils tcpip-forward (forwardOut) requests.
|
|
70
|
+
*/
|
|
71
|
+
function startSshServer(opts: {
|
|
72
|
+
rejectAuth?: boolean;
|
|
73
|
+
rejectForward?: boolean;
|
|
74
|
+
}): Promise<{
|
|
75
|
+
port: number;
|
|
76
|
+
hostKeyBase64: string;
|
|
77
|
+
close: () => Promise<void>;
|
|
78
|
+
}> {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
// Track live client connections and forwarded destination sockets so
|
|
81
|
+
// close() can force them down; otherwise a lingering forwarded socket
|
|
82
|
+
// keeps sshd.close() (and the afterEach hook) hanging until the test
|
|
83
|
+
// timeout, which is what made the tunnel tests flake under --serial CI.
|
|
84
|
+
const clients = new Set<SshServerConnection>();
|
|
85
|
+
const destSockets = new Set<net.Socket>();
|
|
86
|
+
const sshd = new SshServer(
|
|
87
|
+
{ hostKeys: [hostPrivatePem] },
|
|
88
|
+
(client: SshServerConnection) => {
|
|
89
|
+
clients.add(client);
|
|
90
|
+
client.on("close", () => clients.delete(client));
|
|
91
|
+
client.on("authentication", (ctx) => {
|
|
92
|
+
if (opts.rejectAuth) {
|
|
93
|
+
ctx.reject();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Accept publickey auth for our test client key.
|
|
97
|
+
if (ctx.method === "publickey" && !opts.rejectAuth) {
|
|
98
|
+
ctx.accept();
|
|
99
|
+
} else {
|
|
100
|
+
ctx.reject();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
client.on("ready", () => {
|
|
105
|
+
client.on("tcpip", (accept, _reject, info) => {
|
|
106
|
+
if (opts.rejectForward) {
|
|
107
|
+
_reject();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const channel = accept();
|
|
111
|
+
// Connect to the real destination and pipe.
|
|
112
|
+
const dest = net.createConnection(
|
|
113
|
+
{ host: info.destIP, port: info.destPort },
|
|
114
|
+
() => {
|
|
115
|
+
channel.pipe(dest).pipe(channel);
|
|
116
|
+
channel.on("error", () => dest.destroy());
|
|
117
|
+
dest.on("error", () => channel.destroy());
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
destSockets.add(dest);
|
|
121
|
+
dest.on("close", () => destSockets.delete(dest));
|
|
122
|
+
dest.on("error", () => channel.destroy());
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
client.on("error", () => {
|
|
127
|
+
/* swallow per-client errors in tests */
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
sshd.on("error", reject);
|
|
133
|
+
sshd.listen(0, "127.0.0.1", () => {
|
|
134
|
+
const addr = sshd.address() as net.AddressInfo;
|
|
135
|
+
|
|
136
|
+
// Capture the server's host key fingerprint (base64 of the raw DER) by
|
|
137
|
+
// parsing the pem via the ssh2 util so the format matches what
|
|
138
|
+
// hostVerifier receives.
|
|
139
|
+
const parsed = sshUtils.parseKey(hostPrivatePem);
|
|
140
|
+
const hostKeyBase64 = (parsed as { getPublicSSH(): Buffer })
|
|
141
|
+
.getPublicSSH()
|
|
142
|
+
.toString("base64");
|
|
143
|
+
|
|
144
|
+
resolve({
|
|
145
|
+
port: addr.port,
|
|
146
|
+
hostKeyBase64,
|
|
147
|
+
close: () =>
|
|
148
|
+
new Promise((res) => {
|
|
149
|
+
for (const s of destSockets) s.destroy();
|
|
150
|
+
for (const c of clients) c.end();
|
|
151
|
+
sshd.close(() => res());
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function connectAndSend(port: number, message: string): Promise<string> {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
|
|
161
|
+
socket.write(message);
|
|
162
|
+
});
|
|
163
|
+
const chunks: Buffer[] = [];
|
|
164
|
+
socket.on("data", (chunk) => {
|
|
165
|
+
chunks.push(chunk);
|
|
166
|
+
if (Buffer.concat(chunks).toString().includes(message)) {
|
|
167
|
+
socket.destroy();
|
|
168
|
+
resolve(Buffer.concat(chunks).toString());
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
socket.on("error", reject);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Best-effort, time-bounded teardown. Destroying every ssh2/socket handle is
|
|
176
|
+
// reliable locally but a lingering graceful close can still stall on some CI
|
|
177
|
+
// platforms (Bun + ssh2 on Linux/Windows), which would hang the afterEach hook
|
|
178
|
+
// until the 100s test timeout. The assertions have already run by teardown, so
|
|
179
|
+
// cap each close and move on rather than block.
|
|
180
|
+
function closeQuietly(close: () => Promise<void>, ms = 3000): Promise<void> {
|
|
181
|
+
return Promise.race([
|
|
182
|
+
close().catch(() => {}),
|
|
183
|
+
new Promise<void>((resolve) => {
|
|
184
|
+
const timer = setTimeout(resolve, ms);
|
|
185
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
186
|
+
}),
|
|
187
|
+
]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe("openProxy — SSH tunnel", () => {
|
|
193
|
+
let echoServer: { port: number; close: () => Promise<void> };
|
|
194
|
+
let sshServer: {
|
|
195
|
+
port: number;
|
|
196
|
+
hostKeyBase64: string;
|
|
197
|
+
close: () => Promise<void>;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
beforeEach(async () => {
|
|
201
|
+
echoServer = await startEchoServer();
|
|
202
|
+
sshServer = await startSshServer({});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
afterEach(async () => {
|
|
206
|
+
await closeQuietly(() => echoServer.close());
|
|
207
|
+
await closeQuietly(() => sshServer.close());
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("round-trips bytes through the tunnel", async () => {
|
|
211
|
+
const ep = await openProxy(
|
|
212
|
+
{
|
|
213
|
+
type: "ssh",
|
|
214
|
+
ssh: {
|
|
215
|
+
host: "127.0.0.1",
|
|
216
|
+
port: sshServer.port,
|
|
217
|
+
username: "testuser",
|
|
218
|
+
privateKey: clientPrivatePem,
|
|
219
|
+
hostKey: sshServer.hostKeyBase64,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{ host: "127.0.0.1", port: echoServer.port },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const reply = await connectAndSend(ep.port, "hello-proxy");
|
|
227
|
+
expect(reply).toContain("hello-proxy");
|
|
228
|
+
} finally {
|
|
229
|
+
await closeQuietly(() => ep.close());
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("accepts a hostKey given as a full known_hosts line", async () => {
|
|
234
|
+
// Users typically paste `ssh-keyscan` output, e.g.
|
|
235
|
+
// `bastion.example.com ssh-rsa AAAA…` — the verifier must extract the blob.
|
|
236
|
+
const knownHostsLine = `bastion.example.com ssh-rsa ${sshServer.hostKeyBase64}`;
|
|
237
|
+
const ep = await openProxy(
|
|
238
|
+
{
|
|
239
|
+
type: "ssh",
|
|
240
|
+
ssh: {
|
|
241
|
+
host: "127.0.0.1",
|
|
242
|
+
port: sshServer.port,
|
|
243
|
+
username: "testuser",
|
|
244
|
+
privateKey: clientPrivatePem,
|
|
245
|
+
hostKey: knownHostsLine,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{ host: "127.0.0.1", port: echoServer.port },
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const reply = await connectAndSend(ep.port, "known-hosts-line");
|
|
253
|
+
expect(reply).toContain("known-hosts-line");
|
|
254
|
+
} finally {
|
|
255
|
+
await closeQuietly(() => ep.close());
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("accepts a hashed (|1|) known_hosts line — the hash only obscures the hostname", async () => {
|
|
260
|
+
// `ssh-keyscan -H` output hashes the hostname (`|1|salt|hash`) but leaves
|
|
261
|
+
// the key blob in the clear; the verifier extracts the blob token and never
|
|
262
|
+
// matches hostnames, so hashed lines work the same as unhashed ones.
|
|
263
|
+
const hashedLine = `|1|dNQdBg9dg8u7Vw8vq3B0uZ3example=|kZ2exampleHashValue0000000= ssh-rsa ${sshServer.hostKeyBase64}`;
|
|
264
|
+
const ep = await openProxy(
|
|
265
|
+
{
|
|
266
|
+
type: "ssh",
|
|
267
|
+
ssh: {
|
|
268
|
+
host: "127.0.0.1",
|
|
269
|
+
port: sshServer.port,
|
|
270
|
+
username: "testuser",
|
|
271
|
+
privateKey: clientPrivatePem,
|
|
272
|
+
hostKey: hashedLine,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{ host: "127.0.0.1", port: echoServer.port },
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const reply = await connectAndSend(ep.port, "hashed-line");
|
|
280
|
+
expect(reply).toContain("hashed-line");
|
|
281
|
+
} finally {
|
|
282
|
+
await closeQuietly(() => ep.close());
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("rejects when hostKey does not match", async () => {
|
|
287
|
+
const wrongKey = Buffer.alloc(32, 0xff).toString("base64");
|
|
288
|
+
|
|
289
|
+
await expect(
|
|
290
|
+
openProxy(
|
|
291
|
+
{
|
|
292
|
+
type: "ssh",
|
|
293
|
+
ssh: {
|
|
294
|
+
host: "127.0.0.1",
|
|
295
|
+
port: sshServer.port,
|
|
296
|
+
username: "testuser",
|
|
297
|
+
privateKey: clientPrivatePem,
|
|
298
|
+
hostKey: wrongKey,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
{ host: "127.0.0.1", port: echoServer.port },
|
|
302
|
+
),
|
|
303
|
+
).rejects.toThrow(/host-key mismatch/i);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("rejects when hostKey is absent and opt-out env is not set", async () => {
|
|
307
|
+
const orig = process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
|
|
308
|
+
delete process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
await expect(
|
|
312
|
+
openProxy(
|
|
313
|
+
{
|
|
314
|
+
type: "ssh",
|
|
315
|
+
ssh: {
|
|
316
|
+
host: "127.0.0.1",
|
|
317
|
+
port: sshServer.port,
|
|
318
|
+
username: "testuser",
|
|
319
|
+
privateKey: clientPrivatePem,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
{ host: "127.0.0.1", port: echoServer.port },
|
|
323
|
+
),
|
|
324
|
+
).rejects.toThrow(/no hostKey provided/i);
|
|
325
|
+
} finally {
|
|
326
|
+
if (orig !== undefined) {
|
|
327
|
+
process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY = orig;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("connects when hostKey is absent and opt-out env is set", async () => {
|
|
333
|
+
process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY = "true";
|
|
334
|
+
try {
|
|
335
|
+
const ep = await openProxy(
|
|
336
|
+
{
|
|
337
|
+
type: "ssh",
|
|
338
|
+
ssh: {
|
|
339
|
+
host: "127.0.0.1",
|
|
340
|
+
port: sshServer.port,
|
|
341
|
+
username: "testuser",
|
|
342
|
+
privateKey: clientPrivatePem,
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
{ host: "127.0.0.1", port: echoServer.port },
|
|
346
|
+
);
|
|
347
|
+
try {
|
|
348
|
+
const reply = await connectAndSend(ep.port, "allow-unknown");
|
|
349
|
+
expect(reply).toContain("allow-unknown");
|
|
350
|
+
} finally {
|
|
351
|
+
await closeQuietly(() => ep.close());
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
delete process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("rejects for unsupported proxy type", async () => {
|
|
359
|
+
await expect(
|
|
360
|
+
openProxy(
|
|
361
|
+
// @ts-expect-error — intentionally passing an unsupported type
|
|
362
|
+
{ type: "unsupported" },
|
|
363
|
+
{ host: "127.0.0.1", port: echoServer.port },
|
|
364
|
+
),
|
|
365
|
+
).rejects.toThrow(/not supported/i);
|
|
366
|
+
});
|
|
367
|
+
});
|