@malloy-publisher/server 0.0.217 → 0.0.219
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 +114 -0
- package/dist/app/assets/{EnvironmentPage-BX71Wsun.js → EnvironmentPage-gehnjfC6.js} +1 -1
- package/dist/app/assets/{HomePage-Bnp4Puv4.js → HomePage-8LQBytE4.js} +1 -1
- package/dist/app/assets/{MainPage-chkqUlI0.js → MainPage-DDaZLJw-.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-DG4e_wRd.js → MaterializationsPage-D7P1Kp6O.js} +1 -1
- package/dist/app/assets/{ModelPage-DbLU-ABs.js → ModelPage-Do_vhxOc.js} +1 -1
- package/dist/app/assets/{PackagePage-EvWf3VZ4.js → PackagePage-Cz9fVwSG.js} +1 -1
- package/dist/app/assets/{RouteError-POj8kImc.js → RouteError-UONCloyN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Bkq3C1MS.js → WorkbookPage-Bhzqvbq_.js} +1 -1
- package/dist/app/assets/{core-DiLT6QvL.es-Jn6sdy15.js → core-BiGj7BML.es-kMHAa8tP.js} +1 -1
- package/dist/app/assets/{index-gjr27uMq.js → index-VzRbxcF7.js} +3 -3
- package/dist/app/assets/{index-C_AT6ZZw.js → index-ddq4-5hu.js} +1 -1
- package/dist/app/assets/{index-bAdd7U9-.js → index-qOQF9CXq.js} +1 -1
- package/dist/app/assets/{index.umd-BxzPw_1E.js → index.umd-B2kmxDh7.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/cpufeatures-1yrn0vtw.node +0 -0
- package/dist/server.mjs +19069 -70
- package/package.json +1 -1
- package/src/dto/connection.dto.ts +43 -0
- package/src/service/connection.spec.ts +70 -0
- package/src/service/connection.ts +149 -2
- package/src/service/connection_config.spec.ts +254 -0
- package/src/service/connection_config.ts +127 -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 +414 -0
- package/src/service/proxy.ts +248 -0
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Type } from "class-transformer";
|
|
2
2
|
import {
|
|
3
|
+
IsDefined,
|
|
3
4
|
IsEnum,
|
|
4
5
|
IsArray,
|
|
5
6
|
IsNumber,
|
|
6
7
|
IsOptional,
|
|
7
8
|
IsString,
|
|
9
|
+
ValidateIf,
|
|
8
10
|
ValidateNested,
|
|
9
11
|
} from "class-validator";
|
|
10
12
|
import "reflect-metadata";
|
|
@@ -199,6 +201,42 @@ export class DuckdbConnectionDto {
|
|
|
199
201
|
attachedDatabases?: AttachedDatabase[];
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
export class SshProxyConfigDto {
|
|
205
|
+
@IsString()
|
|
206
|
+
host!: string;
|
|
207
|
+
|
|
208
|
+
@IsOptional()
|
|
209
|
+
@IsNumber()
|
|
210
|
+
port?: number;
|
|
211
|
+
|
|
212
|
+
@IsString()
|
|
213
|
+
username!: string;
|
|
214
|
+
|
|
215
|
+
@IsString()
|
|
216
|
+
privateKey!: string;
|
|
217
|
+
|
|
218
|
+
@IsOptional()
|
|
219
|
+
@IsString()
|
|
220
|
+
privateKeyPass?: string;
|
|
221
|
+
|
|
222
|
+
@IsOptional()
|
|
223
|
+
@IsString()
|
|
224
|
+
hostKey?: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export class ConnectionProxyDto {
|
|
228
|
+
@IsEnum(["ssh"])
|
|
229
|
+
type!: "ssh";
|
|
230
|
+
|
|
231
|
+
// ssh config is required for the ssh proxy type (the only type today); keep
|
|
232
|
+
// the conditional so a future proxy type isn't forced to supply `ssh`.
|
|
233
|
+
@ValidateIf((o) => o.type === "ssh")
|
|
234
|
+
@IsDefined()
|
|
235
|
+
@ValidateNested()
|
|
236
|
+
@Type(() => SshProxyConfigDto)
|
|
237
|
+
ssh?: SshProxyConfigDto;
|
|
238
|
+
}
|
|
239
|
+
|
|
202
240
|
export class ConnectionDto implements ApiConnection {
|
|
203
241
|
@IsOptional()
|
|
204
242
|
@IsString()
|
|
@@ -256,4 +294,9 @@ export class ConnectionDto implements ApiConnection {
|
|
|
256
294
|
@ValidateNested()
|
|
257
295
|
@Type(() => DuckdbConnectionDto)
|
|
258
296
|
duckdbConnection?: DuckdbConnectionDto;
|
|
297
|
+
|
|
298
|
+
@IsOptional()
|
|
299
|
+
@ValidateNested()
|
|
300
|
+
@Type(() => ConnectionProxyDto)
|
|
301
|
+
proxy?: ConnectionProxyDto;
|
|
259
302
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
3
3
|
import fs from "fs/promises";
|
|
4
|
+
import os from "os";
|
|
4
5
|
import path from "path";
|
|
5
6
|
import sinon from "sinon";
|
|
6
7
|
import { components } from "../api";
|
|
7
8
|
import {
|
|
9
|
+
buildProxiedSslQuery,
|
|
8
10
|
createEnvironmentConnections,
|
|
9
11
|
testConnectionConfig,
|
|
10
12
|
} from "./connection";
|
|
@@ -1846,3 +1848,71 @@ describe("connection integration tests", () => {
|
|
|
1846
1848
|
);
|
|
1847
1849
|
});
|
|
1848
1850
|
});
|
|
1851
|
+
|
|
1852
|
+
describe("buildProxiedSslQuery", () => {
|
|
1853
|
+
const ORIG_CA = process.env.NODE_EXTRA_CA_CERTS;
|
|
1854
|
+
afterEach(() => {
|
|
1855
|
+
if (ORIG_CA === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
|
|
1856
|
+
else process.env.NODE_EXTRA_CA_CERTS = ORIG_CA;
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
it("defaults to no-verify when sslmode is unset (force-SSL target isn't rejected for plaintext)", () => {
|
|
1860
|
+
expect(buildProxiedSslQuery("conn")).toBe("?sslmode=no-verify");
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
it("returns no-verify for explicit no-verify", () => {
|
|
1864
|
+
expect(buildProxiedSslQuery("conn", "no-verify")).toBe(
|
|
1865
|
+
"?sslmode=no-verify",
|
|
1866
|
+
);
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
it("emits an explicit sslmode=disable, never an empty query", () => {
|
|
1870
|
+
// Regression: an empty query lets pg fall back to the deployment's PGSSLMODE
|
|
1871
|
+
// env, which would verify and fail the 127.0.0.1 hostname check.
|
|
1872
|
+
expect(buildProxiedSslQuery("conn", "disable")).toBe("?sslmode=disable");
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
it("builds a chain-verifying, hostname-skipped query for verify-ca", () => {
|
|
1876
|
+
process.env.NODE_EXTRA_CA_CERTS = "/etc/ssl/certs/rds bundle.pem";
|
|
1877
|
+
const q = buildProxiedSslQuery("conn", "verify-ca");
|
|
1878
|
+
expect(q).toContain("uselibpqcompat=true");
|
|
1879
|
+
expect(q).toContain("sslmode=verify-ca");
|
|
1880
|
+
expect(q).toContain(
|
|
1881
|
+
`sslrootcert=${encodeURIComponent("/etc/ssl/certs/rds bundle.pem")}`,
|
|
1882
|
+
);
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
it("throws for verify-ca when no CA bundle is available", () => {
|
|
1886
|
+
delete process.env.NODE_EXTRA_CA_CERTS;
|
|
1887
|
+
expect(() => buildProxiedSslQuery("conn", "verify-ca")).toThrow(
|
|
1888
|
+
/no trusted CA bundle/i,
|
|
1889
|
+
);
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
it("throws for an unsupported sslmode", () => {
|
|
1893
|
+
expect(() => buildProxiedSslQuery("conn", "require")).toThrow(
|
|
1894
|
+
/unsupported sslmode/i,
|
|
1895
|
+
);
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
it("verify-ca connectionString parses to an ssl config (pg-connection-string version guard)", async () => {
|
|
1899
|
+
// uselibpqcompat=true (pg-connection-string >= 2.7) is what turns verify-ca
|
|
1900
|
+
// into "verify chain, skip hostname". If pg is ever downgraded below that
|
|
1901
|
+
// floor, sslmode would be ignored — this guard fails loudly instead.
|
|
1902
|
+
const caPath = path.join(os.tmpdir(), `proxy-ca-${process.pid}.pem`);
|
|
1903
|
+
await fs.writeFile(
|
|
1904
|
+
caPath,
|
|
1905
|
+
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
|
|
1906
|
+
);
|
|
1907
|
+
process.env.NODE_EXTRA_CA_CERTS = caPath;
|
|
1908
|
+
try {
|
|
1909
|
+
const q = buildProxiedSslQuery("conn", "verify-ca");
|
|
1910
|
+
const { parse } = await import("pg-connection-string");
|
|
1911
|
+
const parsed = parse(`postgresql://127.0.0.1:5432/db${q}`);
|
|
1912
|
+
expect(parsed.ssl).toBeTruthy();
|
|
1913
|
+
expect(typeof parsed.ssl).toBe("object");
|
|
1914
|
+
} finally {
|
|
1915
|
+
await fs.rm(caPath, { force: true });
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
});
|
|
@@ -6,7 +6,10 @@ import "@malloydata/db-duckdb/native";
|
|
|
6
6
|
import "@malloydata/db-mysql";
|
|
7
7
|
import type { MySQLConnection } from "@malloydata/db-mysql";
|
|
8
8
|
import "@malloydata/db-postgres";
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
PooledPostgresConnection,
|
|
11
|
+
type PostgresConnection,
|
|
12
|
+
} from "@malloydata/db-postgres";
|
|
10
13
|
// Registers the "publisher" connection type (proxies SQL to a remote Publisher
|
|
11
14
|
// dataplane). No live-class branch is needed in lookupConnection — the default
|
|
12
15
|
// path resolves it through the registry like any other registered type.
|
|
@@ -41,6 +44,7 @@ import {
|
|
|
41
44
|
normalizeSnowflakePrivateKey,
|
|
42
45
|
} from "./connection_config";
|
|
43
46
|
import { CloudStorageCredentials } from "./gcs_s3_utils";
|
|
47
|
+
import { openProxy, type ProxyEndpoint } from "./proxy";
|
|
44
48
|
|
|
45
49
|
type AttachedDatabase = components["schemas"]["AttachedDatabase"];
|
|
46
50
|
type ApiConnection = components["schemas"]["Connection"];
|
|
@@ -921,6 +925,75 @@ function buildSnowflakePrivateKeyConnection(
|
|
|
921
925
|
});
|
|
922
926
|
}
|
|
923
927
|
|
|
928
|
+
// TLS mode for a proxied postgres connection, as `pg` connectionString query
|
|
929
|
+
// params. @malloydata/db-postgres exposes no ssl config, but it forwards a
|
|
930
|
+
// connectionString straight to pg — which parses sslmode. The tunnel terminates
|
|
931
|
+
// at 127.0.0.1, so pg's hostname check can't apply: `verify-ca` validates the
|
|
932
|
+
// cert chain against the trusted CA bundle (NODE_EXTRA_CA_CERTS, e.g. the baked
|
|
933
|
+
// Amazon RDS roots) WITHOUT the hostname; `no-verify` encrypts without checking;
|
|
934
|
+
// `disable` uses no TLS. Full verify-full through a tunnel needs a servername
|
|
935
|
+
// override (a raw ssl object) — awaits an upstream passthrough (malloydata/malloy#2960).
|
|
936
|
+
export function buildProxiedSslQuery(name: string, sslmode?: string): string {
|
|
937
|
+
const caBundle = process.env.NODE_EXTRA_CA_CERTS;
|
|
938
|
+
// Default: encrypt (no-verify) so a force-SSL target (the common RDS case)
|
|
939
|
+
// isn't rejected for plaintext. Operators opt up to verify-ca when the
|
|
940
|
+
// target's CA is in the trust bundle, or down to disable for non-TLS DBs.
|
|
941
|
+
const mode = sslmode ?? "no-verify";
|
|
942
|
+
switch (mode) {
|
|
943
|
+
case "disable":
|
|
944
|
+
// Explicit: with no sslmode in the connectionString, pg falls back to
|
|
945
|
+
// the PGSSLMODE env (set on the non-proxied path), which would verify
|
|
946
|
+
// and fail the 127.0.0.1 hostname check. Force plaintext explicitly.
|
|
947
|
+
return "?sslmode=disable";
|
|
948
|
+
case "no-verify":
|
|
949
|
+
return "?sslmode=no-verify";
|
|
950
|
+
case "verify-ca":
|
|
951
|
+
// Env-set backstop; validateConnectionShape does the readable-file check
|
|
952
|
+
// at config load (so a bad bundle fails fast instead of at connect time).
|
|
953
|
+
if (!caBundle) {
|
|
954
|
+
throw new Error(
|
|
955
|
+
`Connection proxy on '${name}' uses sslmode 'verify-ca' but no trusted CA bundle is available (NODE_EXTRA_CA_CERTS is unset). Add the CA bundle to the image or use sslmode 'no-verify'.`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
return `?uselibpqcompat=true&sslmode=verify-ca&sslrootcert=${encodeURIComponent(caBundle)}`;
|
|
959
|
+
default:
|
|
960
|
+
throw new Error(
|
|
961
|
+
`Connection proxy on '${name}' has unsupported sslmode '${mode}' (expected disable | no-verify | verify-ca).`,
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function buildProxiedPostgresConnection(
|
|
967
|
+
metadata: EnvironmentConnectionMetadata,
|
|
968
|
+
endpoint: ProxyEndpoint,
|
|
969
|
+
): PooledPostgresConnection {
|
|
970
|
+
const name = metadata.apiConnection.name!;
|
|
971
|
+
const pg = metadata.apiConnection.postgresConnection;
|
|
972
|
+
if (!pg) {
|
|
973
|
+
throw new Error(
|
|
974
|
+
`Proxied connection '${name}' has type 'postgres' but no postgresConnection config.`,
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
// Build a connectionString to the LOCAL tunnel endpoint carrying the TLS mode.
|
|
978
|
+
// This is NOT the tenant's connectionString (rejected in validateConnectionShape
|
|
979
|
+
// because it can't be tunneled) — we construct it ourselves and it targets
|
|
980
|
+
// 127.0.0.1:<localport>, so there's no ambiguity about where it connects.
|
|
981
|
+
const enc = encodeURIComponent;
|
|
982
|
+
const auth = pg.userName
|
|
983
|
+
? `${enc(pg.userName)}${pg.password ? `:${enc(pg.password)}` : ""}@`
|
|
984
|
+
: "";
|
|
985
|
+
const db = pg.databaseName ? `/${enc(pg.databaseName)}` : "";
|
|
986
|
+
const ssl = buildProxiedSslQuery(name, pg.sslmode);
|
|
987
|
+
const connectionString = `postgresql://${auth}${endpoint.host}:${endpoint.port}${db}${ssl}`;
|
|
988
|
+
return new PooledPostgresConnection({
|
|
989
|
+
name,
|
|
990
|
+
connectionString,
|
|
991
|
+
// Pool sizing mirrors buildSnowflakePrivateKeyConnection.
|
|
992
|
+
poolMin: 1,
|
|
993
|
+
poolMax: 20,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
924
997
|
function buildDuckLakeConnection(
|
|
925
998
|
metadata: EnvironmentConnectionMetadata,
|
|
926
999
|
entry: CoreConnectionEntry,
|
|
@@ -987,6 +1060,11 @@ export function buildEnvironmentMalloyConfig(
|
|
|
987
1060
|
const duckLakeCache = new Map<string, Promise<DuckLakeConnection>>();
|
|
988
1061
|
const snowflakeJwtCache = new Map<string, Promise<SnowflakeConnection>>();
|
|
989
1062
|
const azureDuckDBCache = new Map<string, Promise<AzureDuckDBConnection>>();
|
|
1063
|
+
const proxyConnectionCache = new Map<
|
|
1064
|
+
string,
|
|
1065
|
+
Promise<PooledPostgresConnection>
|
|
1066
|
+
>();
|
|
1067
|
+
const proxyEndpoints = new Map<string, ProxyEndpoint>();
|
|
990
1068
|
const attachPromises = new WeakMap<Connection, Promise<void>>();
|
|
991
1069
|
|
|
992
1070
|
const malloyConfig = new MalloyConfig(assembled.pojo, {
|
|
@@ -1044,6 +1122,64 @@ export function buildEnvironmentMalloyConfig(
|
|
|
1044
1122
|
return connection;
|
|
1045
1123
|
}
|
|
1046
1124
|
|
|
1125
|
+
if (metadata?.proxy) {
|
|
1126
|
+
let connectionPromise = proxyConnectionCache.get(name!);
|
|
1127
|
+
if (!connectionPromise) {
|
|
1128
|
+
const connType = metadata.apiConnection.type;
|
|
1129
|
+
if (connType !== "postgres") {
|
|
1130
|
+
throw new Error(
|
|
1131
|
+
`SSH proxy is not yet supported for connection type '${connType}'. ` +
|
|
1132
|
+
`Only 'postgres' is implemented.`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
// validateConnectionShape already enforces explicit host/port
|
|
1136
|
+
// at config load (the connectionString form can't be tunneled);
|
|
1137
|
+
// this is a defense-in-depth guard for any path that reaches
|
|
1138
|
+
// lookup without that validation.
|
|
1139
|
+
const pgConfig = metadata.apiConnection.postgresConnection;
|
|
1140
|
+
if (!pgConfig?.host || !pgConfig?.port) {
|
|
1141
|
+
throw new Error(
|
|
1142
|
+
`SSH proxy for connection '${name}' requires explicit host and ` +
|
|
1143
|
+
`port on the postgres connection; the connectionString form ` +
|
|
1144
|
+
`is not supported with a proxy.`,
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
connectionPromise = (async () => {
|
|
1148
|
+
const endpoint = await openProxy(metadata.proxy!, {
|
|
1149
|
+
host: pgConfig.host!,
|
|
1150
|
+
port: pgConfig.port!,
|
|
1151
|
+
});
|
|
1152
|
+
try {
|
|
1153
|
+
const connection = buildProxiedPostgresConnection(
|
|
1154
|
+
metadata,
|
|
1155
|
+
endpoint,
|
|
1156
|
+
);
|
|
1157
|
+
// Register only after the build succeeds: a throw between
|
|
1158
|
+
// openProxy and here would otherwise leave an orphaned
|
|
1159
|
+
// tunnel in proxyEndpoints that a retry can't reach (the
|
|
1160
|
+
// outer .catch evicts the cache but not the endpoint).
|
|
1161
|
+
proxyEndpoints.set(name!, endpoint);
|
|
1162
|
+
return connection;
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
await endpoint.close().catch(() => {});
|
|
1165
|
+
throw err;
|
|
1166
|
+
}
|
|
1167
|
+
})();
|
|
1168
|
+
// Drop a rejected build from the cache so a later lookup can
|
|
1169
|
+
// retry, rather than replaying a transient SSH failure (bastion
|
|
1170
|
+
// restart, network blip) until the environment is rebuilt.
|
|
1171
|
+
connectionPromise.catch(() => {
|
|
1172
|
+
if (
|
|
1173
|
+
proxyConnectionCache.get(name!) === connectionPromise
|
|
1174
|
+
) {
|
|
1175
|
+
proxyConnectionCache.delete(name!);
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
proxyConnectionCache.set(name!, connectionPromise);
|
|
1179
|
+
}
|
|
1180
|
+
return connectionPromise;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1047
1183
|
if (metadata?.hasSnowflakePrivateKey) {
|
|
1048
1184
|
let connectionPromise = snowflakeJwtCache.get(name!);
|
|
1049
1185
|
if (!connectionPromise) {
|
|
@@ -1086,6 +1222,7 @@ export function buildEnvironmentMalloyConfig(
|
|
|
1086
1222
|
...duckLakeCache.values(),
|
|
1087
1223
|
...snowflakeJwtCache.values(),
|
|
1088
1224
|
...azureDuckDBCache.values(),
|
|
1225
|
+
...proxyConnectionCache.values(),
|
|
1089
1226
|
];
|
|
1090
1227
|
const closeResults = await Promise.allSettled([
|
|
1091
1228
|
malloyConfig.shutdown("close"),
|
|
@@ -1094,11 +1231,21 @@ export function buildEnvironmentMalloyConfig(
|
|
|
1094
1231
|
await connection.close();
|
|
1095
1232
|
}),
|
|
1096
1233
|
]);
|
|
1234
|
+
// Close proxy tunnels only AFTER the build promises above have settled:
|
|
1235
|
+
// openProxy may still be in flight during a teardown race, and the
|
|
1236
|
+
// endpoint is registered inside that promise — capturing the map
|
|
1237
|
+
// earlier would miss it and leak the tunnel. Closing after the DB
|
|
1238
|
+
// connections also keeps the tunnel alive while they drain.
|
|
1239
|
+
const endpointResults = await Promise.allSettled(
|
|
1240
|
+
[...proxyEndpoints.values()].map((ep) => ep.close()),
|
|
1241
|
+
);
|
|
1097
1242
|
duckLakeCache.clear();
|
|
1098
1243
|
snowflakeJwtCache.clear();
|
|
1099
1244
|
azureDuckDBCache.clear();
|
|
1245
|
+
proxyConnectionCache.clear();
|
|
1246
|
+
proxyEndpoints.clear();
|
|
1100
1247
|
|
|
1101
|
-
const failures = closeResults.filter(
|
|
1248
|
+
const failures = [...closeResults, ...endpointResults].filter(
|
|
1102
1249
|
(result): result is PromiseRejectedResult =>
|
|
1103
1250
|
result.status === "rejected",
|
|
1104
1251
|
);
|
|
@@ -349,3 +349,257 @@ describe("assembleEnvironmentConnections — publisher", () => {
|
|
|
349
349
|
});
|
|
350
350
|
});
|
|
351
351
|
});
|
|
352
|
+
|
|
353
|
+
describe("SSH proxy validation", () => {
|
|
354
|
+
const validSshProxy: ApiConnection = {
|
|
355
|
+
name: "pg-via-bastion",
|
|
356
|
+
type: "postgres",
|
|
357
|
+
postgresConnection: {
|
|
358
|
+
host: "127.0.0.1",
|
|
359
|
+
port: 5432,
|
|
360
|
+
databaseName: "mydb",
|
|
361
|
+
userName: "user",
|
|
362
|
+
password: "pass",
|
|
363
|
+
},
|
|
364
|
+
proxy: {
|
|
365
|
+
type: "ssh",
|
|
366
|
+
ssh: {
|
|
367
|
+
host: "bastion.example.com",
|
|
368
|
+
username: "ec2-user",
|
|
369
|
+
privateKey:
|
|
370
|
+
"-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----",
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
it("allows a postgres+ssh-proxy connection with no env flag required", () => {
|
|
376
|
+
// The SSH proxy is a normal connection capability — deliberately NOT gated
|
|
377
|
+
// by PUBLISHER_ALLOW_PROXY_CONNECTIONS (that flag is for the `publisher`
|
|
378
|
+
// HTTP multi-hop type). Prove it assembles with the flag unset.
|
|
379
|
+
const priorFlag = process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
|
|
380
|
+
delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
|
|
381
|
+
try {
|
|
382
|
+
const { pojo } = assembleEnvironmentConnections([validSshProxy]);
|
|
383
|
+
expect(pojo.connections["pg-via-bastion"].is).toBe("postgres");
|
|
384
|
+
} finally {
|
|
385
|
+
if (priorFlag === undefined) {
|
|
386
|
+
delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
|
|
387
|
+
} else {
|
|
388
|
+
process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = priorFlag;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("rejects a non-postgres (bigquery) connection with a proxy", () => {
|
|
394
|
+
const conn: ApiConnection = {
|
|
395
|
+
name: "bq-via-bastion",
|
|
396
|
+
type: "bigquery",
|
|
397
|
+
bigqueryConnection: {
|
|
398
|
+
serviceAccountKeyJson: JSON.stringify({
|
|
399
|
+
type: "service_account",
|
|
400
|
+
project_id: "proj",
|
|
401
|
+
private_key: "key",
|
|
402
|
+
client_email: "sa@proj.iam.gserviceaccount.com",
|
|
403
|
+
}),
|
|
404
|
+
},
|
|
405
|
+
proxy: {
|
|
406
|
+
type: "ssh",
|
|
407
|
+
ssh: {
|
|
408
|
+
host: "bastion.example.com",
|
|
409
|
+
username: "ec2-user",
|
|
410
|
+
privateKey: "key",
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
415
|
+
"Connection proxy is not supported for type 'bigquery' (only 'postgres' today)",
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("rejects an ssh proxy with no ssh config object", () => {
|
|
420
|
+
const conn: ApiConnection = {
|
|
421
|
+
name: "pg-no-ssh-config",
|
|
422
|
+
type: "postgres",
|
|
423
|
+
postgresConnection: {
|
|
424
|
+
host: "127.0.0.1",
|
|
425
|
+
databaseName: "mydb",
|
|
426
|
+
},
|
|
427
|
+
proxy: {
|
|
428
|
+
type: "ssh",
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
432
|
+
"Connection proxy on 'pg-no-ssh-config' has type 'ssh' but no 'ssh' config object",
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("rejects a proxied postgres connection that also carries a connectionString", () => {
|
|
437
|
+
const conn: ApiConnection = {
|
|
438
|
+
...validSshProxy,
|
|
439
|
+
name: "pg-with-connstring",
|
|
440
|
+
postgresConnection: {
|
|
441
|
+
connectionString: "postgresql://real-db.example.com:5432/mydb",
|
|
442
|
+
host: "127.0.0.1",
|
|
443
|
+
port: 5432,
|
|
444
|
+
databaseName: "mydb",
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
// The tunnel forwards to host/port; a connectionString would be silently
|
|
448
|
+
// ignored and could point at a different database, so it's rejected.
|
|
449
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
450
|
+
"does not support the connectionString form",
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("rejects a proxied postgres connection missing explicit host/port", () => {
|
|
455
|
+
const conn: ApiConnection = {
|
|
456
|
+
...validSshProxy,
|
|
457
|
+
name: "pg-no-host-port",
|
|
458
|
+
postgresConnection: {
|
|
459
|
+
databaseName: "mydb",
|
|
460
|
+
userName: "user",
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
464
|
+
"requires explicit host and port",
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("accepts a multi-line hostKey and sslmode=no-verify", () => {
|
|
469
|
+
const conn: ApiConnection = {
|
|
470
|
+
...validSshProxy,
|
|
471
|
+
postgresConnection: {
|
|
472
|
+
...validSshProxy.postgresConnection!,
|
|
473
|
+
sslmode: "no-verify",
|
|
474
|
+
},
|
|
475
|
+
proxy: {
|
|
476
|
+
type: "ssh",
|
|
477
|
+
ssh: {
|
|
478
|
+
...validSshProxy.proxy!.ssh!,
|
|
479
|
+
hostKey:
|
|
480
|
+
"bastion ssh-ed25519 AAAAdecoy\nbastion ssh-rsa AAAAreal",
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
expect(() => assembleEnvironmentConnections([conn])).not.toThrow();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("rejects a hostKey that parses to zero keys (comment/blank only) — fail-closed at config load", () => {
|
|
488
|
+
const conn: ApiConnection = {
|
|
489
|
+
...validSshProxy,
|
|
490
|
+
proxy: {
|
|
491
|
+
type: "ssh",
|
|
492
|
+
ssh: {
|
|
493
|
+
...validSshProxy.proxy!.ssh!,
|
|
494
|
+
hostKey: "# ssh-keyscan bastion timed out\n \n",
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
499
|
+
"no usable host-key line",
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("rejects a whitespace-only hostKey (must not silently connect unpinned)", () => {
|
|
504
|
+
const conn: ApiConnection = {
|
|
505
|
+
...validSshProxy,
|
|
506
|
+
proxy: {
|
|
507
|
+
type: "ssh",
|
|
508
|
+
ssh: {
|
|
509
|
+
...validSshProxy.proxy!.ssh!,
|
|
510
|
+
hostKey: " ",
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
515
|
+
"no usable host-key line",
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("treats an empty-string hostKey as unpinned (omission-equivalent)", () => {
|
|
520
|
+
const conn: ApiConnection = {
|
|
521
|
+
...validSshProxy,
|
|
522
|
+
proxy: {
|
|
523
|
+
type: "ssh",
|
|
524
|
+
ssh: { ...validSshProxy.proxy!.ssh!, hostKey: "" },
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
expect(() => assembleEnvironmentConnections([conn])).not.toThrow();
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("rejects an unsupported sslmode", () => {
|
|
531
|
+
const conn = {
|
|
532
|
+
...validSshProxy,
|
|
533
|
+
postgresConnection: {
|
|
534
|
+
...validSshProxy.postgresConnection!,
|
|
535
|
+
sslmode: "require",
|
|
536
|
+
},
|
|
537
|
+
} as unknown as ApiConnection;
|
|
538
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
539
|
+
"unsupported sslmode 'require'",
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("rejects an empty-string sslmode at config load (not deferred to connect)", () => {
|
|
544
|
+
const conn = {
|
|
545
|
+
...validSshProxy,
|
|
546
|
+
postgresConnection: {
|
|
547
|
+
...validSshProxy.postgresConnection!,
|
|
548
|
+
sslmode: "",
|
|
549
|
+
},
|
|
550
|
+
} as unknown as ApiConnection;
|
|
551
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
552
|
+
"unsupported sslmode",
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("rejects sslmode=verify-ca when no CA bundle is available", () => {
|
|
557
|
+
const prior = process.env.NODE_EXTRA_CA_CERTS;
|
|
558
|
+
delete process.env.NODE_EXTRA_CA_CERTS;
|
|
559
|
+
try {
|
|
560
|
+
const conn: ApiConnection = {
|
|
561
|
+
...validSshProxy,
|
|
562
|
+
postgresConnection: {
|
|
563
|
+
...validSshProxy.postgresConnection!,
|
|
564
|
+
sslmode: "verify-ca",
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
568
|
+
"verify-ca",
|
|
569
|
+
);
|
|
570
|
+
} finally {
|
|
571
|
+
if (prior !== undefined) process.env.NODE_EXTRA_CA_CERTS = prior;
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("rejects sslmode set on a non-proxied connection (silent-ignore footgun)", () => {
|
|
576
|
+
const conn: ApiConnection = {
|
|
577
|
+
name: "pg-direct",
|
|
578
|
+
type: "postgres",
|
|
579
|
+
postgresConnection: {
|
|
580
|
+
host: "db.example.com",
|
|
581
|
+
port: 5432,
|
|
582
|
+
databaseName: "mydb",
|
|
583
|
+
userName: "user",
|
|
584
|
+
password: "pass",
|
|
585
|
+
sslmode: "verify-ca",
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
589
|
+
"only supported for proxied connections",
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("rejects a proxied database name with URI-reserved characters", () => {
|
|
594
|
+
const conn: ApiConnection = {
|
|
595
|
+
...validSshProxy,
|
|
596
|
+
postgresConnection: {
|
|
597
|
+
...validSshProxy.postgresConnection!,
|
|
598
|
+
databaseName: "sales@prod",
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
602
|
+
"URI-reserved characters",
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
});
|