@fluid-app/exigo-connection-sdk 0.1.14 → 0.1.15
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 +42 -5
- package/dist/egress-proxy.d.mts +24 -0
- package/dist/egress-proxy.mjs +53 -0
- package/dist/rest.d.mts +2 -22
- package/dist/rest.mjs +3 -39
- package/dist/sql-tunnel.mjs +172 -0
- package/dist/sql.d.mts +12 -3
- package/dist/sql.mjs +45 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,9 +52,9 @@ export const resolveExigoCredentials = defineExigoCredentials(
|
|
|
52
52
|
database: c.exigoDbName,
|
|
53
53
|
username: c.exigoDbUsername,
|
|
54
54
|
password: c.exigoDbPassword,
|
|
55
|
-
// Required, no default
|
|
56
|
-
//
|
|
57
|
-
//
|
|
55
|
+
// Required, no default. true skips server certificate verification,
|
|
56
|
+
// which Exigo's replicas need: they present a certificate that does
|
|
57
|
+
// not verify against the host (e.g. SQL Server's self-signed one).
|
|
58
58
|
trustServerCertificate: true,
|
|
59
59
|
},
|
|
60
60
|
};
|
|
@@ -154,8 +154,7 @@ const exigo = createExigoRestClient(rest, {
|
|
|
154
154
|
reach Exigo, 503 when it could not verify the token or is at capacity.
|
|
155
155
|
- Clients built with the same proxy URL and token share one agent, so a
|
|
156
156
|
client per request still reuses open tunnels.
|
|
157
|
-
-
|
|
158
|
-
cannot use a proxy.
|
|
157
|
+
- SQL goes through the same proxy; see [SQL through the egress proxy](#through-fluids-egress-proxy).
|
|
159
158
|
|
|
160
159
|
## 3. SQL
|
|
161
160
|
|
|
@@ -211,6 +210,44 @@ export default { serverExternalPackages: ["mssql"] };
|
|
|
211
210
|
export const runtime = "nodejs";
|
|
212
211
|
```
|
|
213
212
|
|
|
213
|
+
### Through Fluid's egress proxy
|
|
214
|
+
|
|
215
|
+
The same proxy as for REST, for the same reason: Exigo allowlists the replica
|
|
216
|
+
by source IP. `mssql` ignores proxy settings, so the pool hands tedious a
|
|
217
|
+
connector that opens a CONNECT tunnel to the replica's host and port; TDS,
|
|
218
|
+
including its TLS handshake, then runs through the tunnel unchanged. No extra
|
|
219
|
+
package is needed.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
const pool = await getExigoPool(creds, {
|
|
223
|
+
egressProxy: process.env.FLUID_EGRESS_PROXY_URL
|
|
224
|
+
? {
|
|
225
|
+
url: process.env.FLUID_EGRESS_PROXY_URL,
|
|
226
|
+
token: installation.authenticationToken, // dit_…
|
|
227
|
+
}
|
|
228
|
+
: undefined,
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- The proxy must allow the replica's host and port (1433), and Exigo must
|
|
233
|
+
allowlist the proxy's IP on the SQL side.
|
|
234
|
+
- The proxy and token are part of the pool's identity: a call with a different
|
|
235
|
+
one gets its own pool.
|
|
236
|
+
- Encryption is required through the proxy. `encrypt: false` is refused
|
|
237
|
+
(`ExigoCredentialsError`). And because SQL Server negotiates TLS (tedious
|
|
238
|
+
skips it, sending the login with the password merely obfuscated, when the
|
|
239
|
+
server's PRELOGIN answer does not offer encryption), the connector reads that
|
|
240
|
+
answer and hangs up before the login unless encryption is `ON` or `REQ`,
|
|
241
|
+
throwing `ExigoConnectionError`. Direct connections are not checked this way.
|
|
242
|
+
- Refusals throw `ExigoProxyError` with `source: "sql"`; a proxy that cannot be
|
|
243
|
+
reached throws `ExigoConnectionError` naming it.
|
|
244
|
+
- **Known gap:** the connection is encrypted, but with
|
|
245
|
+
`trustServerCertificate: true` nothing proves the other end of the TLS is
|
|
246
|
+
Exigo. The proxy reads nothing inside the tunnel, yet anything between the
|
|
247
|
+
proxy and Exigo that impersonates the replica could read the login. Direct
|
|
248
|
+
connections carry the same gap. Closing it needs a certificate on Exigo's
|
|
249
|
+
side that can be verified.
|
|
250
|
+
|
|
214
251
|
## Errors
|
|
215
252
|
|
|
216
253
|
Every error extends `ExigoError`. None of them carries a password or an
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/egress-proxy.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Fluid's egress proxy: a CONNECT-only forward proxy with a static outbound IP
|
|
4
|
+
* that Exigo tenants allowlist, for apps on hosts whose egress IPs are not
|
|
5
|
+
* Fluid's (Mist apps on Vercel). It tunnels bytes without reading them; what
|
|
6
|
+
* keeps the Exigo credentials from it is the TLS inside the tunnel.
|
|
7
|
+
*/
|
|
8
|
+
interface ExigoEgressProxy {
|
|
9
|
+
/**
|
|
10
|
+
* The proxy URL, which Fluid provides to a droplet-linked Mist app as
|
|
11
|
+
* `FLUID_EGRESS_PROXY_URL`. Must be https: the CONNECT request carries the
|
|
12
|
+
* token outside the tunnel. Plain http is accepted only for a loopback host,
|
|
13
|
+
* such as a proxy run locally.
|
|
14
|
+
*/
|
|
15
|
+
readonly url: string;
|
|
16
|
+
/**
|
|
17
|
+
* The droplet installation token (`dit_…`, the `authentication_token` of the
|
|
18
|
+
* install webhook) the proxy authenticates the app with. A `Bearer ` prefix
|
|
19
|
+
* is optional.
|
|
20
|
+
*/
|
|
21
|
+
readonly token: string;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { ExigoEgressProxy };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ExigoCredentialsError } from "./errors.mjs";
|
|
2
|
+
//#region src/egress-proxy.ts
|
|
3
|
+
/**
|
|
4
|
+
* Fluid's egress proxy, as both clients use it: what an app passes, how it is
|
|
5
|
+
* checked, and what a refusal means. No Node-only APIs, so `./rest` can import
|
|
6
|
+
* it; the SQL tunnel itself lives in `./sql-tunnel`.
|
|
7
|
+
*/
|
|
8
|
+
const LOOPBACK_HOSTS = new Set([
|
|
9
|
+
"localhost",
|
|
10
|
+
"127.0.0.1",
|
|
11
|
+
"[::1]"
|
|
12
|
+
]);
|
|
13
|
+
/** Same rule as the REST base URL: the CONNECT request carries the token in clear. */
|
|
14
|
+
function normalizeUrl(proxyUrl) {
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(proxyUrl.trim());
|
|
18
|
+
} catch {
|
|
19
|
+
throw new ExigoCredentialsError("Exigo egressProxy.url is not a valid URL.", ["egressProxy.url"]);
|
|
20
|
+
}
|
|
21
|
+
const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
22
|
+
if (url.protocol !== "https:" && !loopbackHttp) throw new ExigoCredentialsError("Exigo egressProxy.url must be an https URL; the CONNECT request carries the token.", ["egressProxy.url"]);
|
|
23
|
+
if (url.username || url.password) throw new ExigoCredentialsError("Exigo egressProxy.url must not carry credentials; pass the token instead.", ["egressProxy.url"]);
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
function normalizeToken(token) {
|
|
27
|
+
const bare = token.replace(/^\s*Bearer(?:\s+|$)/i, "").trim();
|
|
28
|
+
if (bare === "" || /\s/.test(bare)) throw new ExigoCredentialsError("Exigo egressProxy.token must be the droplet installation token (dit_…).", ["egressProxy.token"]);
|
|
29
|
+
return bare;
|
|
30
|
+
}
|
|
31
|
+
/** Throws `ExigoCredentialsError` for a URL or token that cannot work. */
|
|
32
|
+
function normalizeEgressProxy(proxy) {
|
|
33
|
+
const url = normalizeUrl(proxy.url);
|
|
34
|
+
return {
|
|
35
|
+
url: url.origin,
|
|
36
|
+
host: url.host,
|
|
37
|
+
token: normalizeToken(proxy.token)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** What each answer to CONNECT means, and what to check. */
|
|
41
|
+
function describeProxyRefusal(status, target, proxyHost) {
|
|
42
|
+
const prefix = `Fluid's egress proxy at ${proxyHost} refused the tunnel to ${target} (HTTP ${status})`;
|
|
43
|
+
switch (status) {
|
|
44
|
+
case 407: return `${prefix}: it did not accept the token. Pass the droplet installation token (dit_…) as egressProxy.token.`;
|
|
45
|
+
case 403: return `${prefix}: this droplet may not egress through it, or ${target} is not on its allowlist.`;
|
|
46
|
+
case 502: return `${prefix}: it could not reach Exigo.`;
|
|
47
|
+
case 503: return `${prefix}: it could not verify the token with Fluid or is at capacity. Retry.`;
|
|
48
|
+
case 504: return `${prefix}: its connection to Exigo timed out. Retry.`;
|
|
49
|
+
default: return `${prefix}.`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { LOOPBACK_HOSTS, describeProxyRefusal, normalizeEgressProxy };
|
package/dist/rest.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ExigoRestCredentials } from "./credentials.mjs";
|
|
2
2
|
import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoProxyError, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
|
|
3
|
+
import { ExigoEgressProxy } from "./egress-proxy.mjs";
|
|
3
4
|
|
|
4
5
|
//#region src/rest.d.ts
|
|
5
6
|
declare const EXIGO_API_VERSION = "3.0";
|
|
@@ -21,27 +22,6 @@ interface ExigoRequestOptions {
|
|
|
21
22
|
}
|
|
22
23
|
type ExigoMethodOptions = Omit<ExigoRequestOptions, "body">;
|
|
23
24
|
type ExigoBodyMethodOptions = ExigoRequestOptions;
|
|
24
|
-
/**
|
|
25
|
-
* Fluid's egress proxy: a CONNECT-only forward proxy with a static outbound IP
|
|
26
|
-
* that Exigo tenants allowlist, for apps on hosts whose egress IPs are not
|
|
27
|
-
* Fluid's (Mist apps on Vercel). TLS stays end to end between the app and
|
|
28
|
-
* Exigo; the proxy never sees the Exigo credentials.
|
|
29
|
-
*/
|
|
30
|
-
interface ExigoEgressProxy {
|
|
31
|
-
/**
|
|
32
|
-
* The proxy URL, which Fluid provides to a droplet-linked Mist app as
|
|
33
|
-
* `FLUID_EGRESS_PROXY_URL`. Must be https: the CONNECT request carries the
|
|
34
|
-
* token outside the tunnel. Plain http is accepted only for a loopback host,
|
|
35
|
-
* such as a proxy run locally.
|
|
36
|
-
*/
|
|
37
|
-
readonly url: string;
|
|
38
|
-
/**
|
|
39
|
-
* The droplet installation token (`dit_…`, the `authentication_token` of the
|
|
40
|
-
* install webhook) the proxy authenticates the app with. A `Bearer ` prefix
|
|
41
|
-
* is optional.
|
|
42
|
-
*/
|
|
43
|
-
readonly token: string;
|
|
44
|
-
}
|
|
45
25
|
interface ExigoRestClientOptions {
|
|
46
26
|
/** Per-request timeout. Defaults to {@link DEFAULT_EXIGO_TIMEOUT_MS}. */
|
|
47
27
|
readonly timeoutMs?: number;
|
|
@@ -94,4 +74,4 @@ declare function normalizeExigoBaseUrl(baseUrl: string): string;
|
|
|
94
74
|
*/
|
|
95
75
|
declare function createExigoRestClient(credentials: ExigoRestCredentials | null | undefined, options?: ExigoRestClientOptions): ExigoRestClient;
|
|
96
76
|
//#endregion
|
|
97
|
-
export { DEFAULT_EXIGO_TIMEOUT_MS, EXIGO_API_VERSION, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoBodyMethodOptions, ExigoConnectionError, ExigoCredentialsError, ExigoEgressProxy, ExigoError, ExigoErrorOptions, ExigoHttpMethod, ExigoMethodOptions, ExigoProxyError, ExigoQueryValue, ExigoRequestOptions, ExigoRestClient, ExigoRestClientOptions, type ExigoRestCredentials, ExigoSource, ExigoTimeoutError, createExigoRestClient, normalizeExigoBaseUrl };
|
|
77
|
+
export { DEFAULT_EXIGO_TIMEOUT_MS, EXIGO_API_VERSION, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoBodyMethodOptions, ExigoConnectionError, ExigoCredentialsError, type ExigoEgressProxy, ExigoError, ExigoErrorOptions, ExigoHttpMethod, ExigoMethodOptions, ExigoProxyError, ExigoQueryValue, ExigoRequestOptions, ExigoRestClient, ExigoRestClientOptions, type ExigoRestCredentials, ExigoSource, ExigoTimeoutError, createExigoRestClient, normalizeExigoBaseUrl };
|
package/dist/rest.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoProxyError, ExigoTimeoutError } from "./errors.mjs";
|
|
2
2
|
import { assertExigoRestCredentials } from "./credentials.mjs";
|
|
3
|
+
import { LOOPBACK_HOSTS, describeProxyRefusal, normalizeEgressProxy } from "./egress-proxy.mjs";
|
|
3
4
|
//#region src/rest.ts
|
|
4
5
|
/**
|
|
5
6
|
* `@fluid-app/exigo-connection-sdk/rest`: one way to call Exigo's REST API.
|
|
@@ -18,11 +19,6 @@ const EXIGO_API_VERSION = "3.0";
|
|
|
18
19
|
const DEFAULT_EXIGO_TIMEOUT_MS = 15e3;
|
|
19
20
|
const BODY_SNIPPET_LENGTH = 500;
|
|
20
21
|
const VERSION_SEGMENT = /^\d+\.\d+$/;
|
|
21
|
-
const LOOPBACK_HOSTS = new Set([
|
|
22
|
-
"localhost",
|
|
23
|
-
"127.0.0.1",
|
|
24
|
-
"[::1]"
|
|
25
|
-
]);
|
|
26
22
|
/**
|
|
27
23
|
* `https://acme-api.exigo.com`, `…/3.0` and `…/3.0/` all become
|
|
28
24
|
* `https://acme-api.exigo.com/3.0`. A base URL ending in any other version
|
|
@@ -137,24 +133,6 @@ function validTimeout(timeoutMs) {
|
|
|
137
133
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) throw new TypeError(`Exigo timeoutMs must be a positive number up to ${MAX_TIMEOUT_MS}, got ${timeoutMs}.`);
|
|
138
134
|
return timeoutMs;
|
|
139
135
|
}
|
|
140
|
-
/** Same rule as the base URL: the CONNECT request carries the token in clear. */
|
|
141
|
-
function normalizeEgressProxyUrl(proxyUrl) {
|
|
142
|
-
let url;
|
|
143
|
-
try {
|
|
144
|
-
url = new URL(proxyUrl.trim());
|
|
145
|
-
} catch {
|
|
146
|
-
throw new ExigoCredentialsError("Exigo egressProxy.url is not a valid URL.", ["egressProxy.url"]);
|
|
147
|
-
}
|
|
148
|
-
const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
149
|
-
if (url.protocol !== "https:" && !loopbackHttp) throw new ExigoCredentialsError("Exigo egressProxy.url must be an https URL; the CONNECT request carries the token.", ["egressProxy.url"]);
|
|
150
|
-
if (url.username || url.password) throw new ExigoCredentialsError("Exigo egressProxy.url must not carry credentials; pass the token instead.", ["egressProxy.url"]);
|
|
151
|
-
return url.origin;
|
|
152
|
-
}
|
|
153
|
-
function normalizeEgressProxyToken(token) {
|
|
154
|
-
const bare = token.replace(/^\s*Bearer(?:\s+|$)/i, "").trim();
|
|
155
|
-
if (bare === "" || /\s/.test(bare)) throw new ExigoCredentialsError("Exigo egressProxy.token must be the droplet installation token (dit_…).", ["egressProxy.token"]);
|
|
156
|
-
return bare;
|
|
157
|
-
}
|
|
158
136
|
/**
|
|
159
137
|
* One proxy agent per proxy URL and token, shared by every client that uses
|
|
160
138
|
* them, so a droplet building a client per request still reuses its tunnels.
|
|
@@ -195,17 +173,6 @@ function proxyRefusalStatus(error) {
|
|
|
195
173
|
}
|
|
196
174
|
return null;
|
|
197
175
|
}
|
|
198
|
-
function describeProxyRefusal(status, host, proxyHost) {
|
|
199
|
-
const prefix = `Fluid's egress proxy at ${proxyHost} refused the tunnel to ${host} (HTTP ${status})`;
|
|
200
|
-
switch (status) {
|
|
201
|
-
case 407: return `${prefix}: it did not accept the token. Pass the droplet installation token (dit_…) as egressProxy.token.`;
|
|
202
|
-
case 403: return `${prefix}: this droplet may not egress through it, or ${host} is not on its allowlist.`;
|
|
203
|
-
case 502: return `${prefix}: it could not reach Exigo.`;
|
|
204
|
-
case 503: return `${prefix}: it could not verify the token with Fluid or is at capacity. Retry.`;
|
|
205
|
-
case 504: return `${prefix}: its connection to Exigo timed out. Retry.`;
|
|
206
|
-
default: return `${prefix}.`;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
176
|
/**
|
|
210
177
|
* Creates a client for one set of REST credentials.
|
|
211
178
|
*
|
|
@@ -219,11 +186,8 @@ function createExigoRestClient(credentials, options = {}) {
|
|
|
219
186
|
const baseUrl = normalizeExigoBaseUrl(credentials.baseUrl);
|
|
220
187
|
const host = new URL(baseUrl).host;
|
|
221
188
|
const authorization = authorizationHeader(credentials);
|
|
222
|
-
const egressProxy = options.egressProxy ?
|
|
223
|
-
|
|
224
|
-
token: normalizeEgressProxyToken(options.egressProxy.token)
|
|
225
|
-
} : null;
|
|
226
|
-
const proxyHost = egressProxy ? new URL(egressProxy.url).host : null;
|
|
189
|
+
const egressProxy = options.egressProxy ? normalizeEgressProxy(options.egressProxy) : null;
|
|
190
|
+
const proxyHost = egressProxy?.host ?? null;
|
|
227
191
|
const secrets = [
|
|
228
192
|
credentials.password,
|
|
229
193
|
authorization.replace(/^Basic /, ""),
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { ExigoConnectionError, ExigoProxyError } from "./errors.mjs";
|
|
2
|
+
import { describeProxyRefusal } from "./egress-proxy.mjs";
|
|
3
|
+
import * as net from "node:net";
|
|
4
|
+
import * as tls from "node:tls";
|
|
5
|
+
//#region src/sql-tunnel.ts
|
|
6
|
+
/**
|
|
7
|
+
* Opens SQL Server connections through Fluid's egress proxy.
|
|
8
|
+
*
|
|
9
|
+
* tedious, which `mssql` drives, ignores proxy settings but accepts a
|
|
10
|
+
* `connector` that hands it an already-connected socket. This one opens a
|
|
11
|
+
* CONNECT tunnel to the server's host and port and returns the socket once the
|
|
12
|
+
* proxy answers 200; tedious then speaks TDS through it exactly as it would on
|
|
13
|
+
* a direct connection, including the TLS handshake, which the proxy cannot
|
|
14
|
+
* read. Redirects from the server (`routingData`) are tunnelled too, because
|
|
15
|
+
* tedious calls the connector with whatever host and port it is dialling.
|
|
16
|
+
*
|
|
17
|
+
* With `encrypt: true` tedious only starts TLS when the server's PRELOGIN
|
|
18
|
+
* answer offers it; a server, or anything impersonating one, that answers
|
|
19
|
+
* NOT_SUP or OFF would get the login with its password merely obfuscated. The
|
|
20
|
+
* connector sees the raw bytes first, so it reads that answer and hangs up
|
|
21
|
+
* before tedious can send the login unless encryption is ON or REQ.
|
|
22
|
+
*/
|
|
23
|
+
/** A proxy's response head is a few hundred bytes; past this it is not one. */
|
|
24
|
+
const MAX_RESPONSE_HEAD_BYTES = 16 * 1024;
|
|
25
|
+
function authority(host, port) {
|
|
26
|
+
return net.isIPv6(host) ? `[${host}]:${port}` : `${host}:${port}`;
|
|
27
|
+
}
|
|
28
|
+
function dialProxy(proxyUrl) {
|
|
29
|
+
const port = Number(proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80));
|
|
30
|
+
const host = proxyUrl.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
31
|
+
if (proxyUrl.protocol !== "https:") return net.connect({
|
|
32
|
+
host,
|
|
33
|
+
port
|
|
34
|
+
});
|
|
35
|
+
return tls.connect({
|
|
36
|
+
host,
|
|
37
|
+
port,
|
|
38
|
+
servername: net.isIP(host) ? void 0 : host
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolves with the proxy's status once the response head has arrived, and
|
|
43
|
+
* any bytes after it, which belong to the tunnel.
|
|
44
|
+
*/
|
|
45
|
+
function readResponseHead(socket, ready, request, signal) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
let buffered = Buffer.alloc(0);
|
|
48
|
+
const cleanup = () => {
|
|
49
|
+
socket.off(ready, onReady);
|
|
50
|
+
socket.off("data", onData);
|
|
51
|
+
socket.off("error", onError);
|
|
52
|
+
socket.off("close", onClose);
|
|
53
|
+
signal.removeEventListener("abort", onAbort);
|
|
54
|
+
};
|
|
55
|
+
const fail = (error) => {
|
|
56
|
+
cleanup();
|
|
57
|
+
socket.destroy();
|
|
58
|
+
reject(error);
|
|
59
|
+
};
|
|
60
|
+
const onReady = () => {
|
|
61
|
+
socket.write(request);
|
|
62
|
+
};
|
|
63
|
+
const onData = (chunk) => {
|
|
64
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
65
|
+
const end = buffered.indexOf("\r\n\r\n");
|
|
66
|
+
if (end === -1) {
|
|
67
|
+
if (buffered.length > MAX_RESPONSE_HEAD_BYTES) fail(/* @__PURE__ */ new Error("the proxy's response head is too long"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const statusLine = buffered.subarray(0, buffered.indexOf("\r\n")).toString("latin1");
|
|
71
|
+
const match = /^HTTP\/1\.[01] (\d{3})\b/.exec(statusLine);
|
|
72
|
+
if (!match) {
|
|
73
|
+
fail(/* @__PURE__ */ new Error("the proxy did not answer with HTTP"));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
cleanup();
|
|
77
|
+
socket.pause();
|
|
78
|
+
resolve({
|
|
79
|
+
status: Number(match[1]),
|
|
80
|
+
rest: buffered.subarray(end + 4)
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
const onError = (error) => fail(error);
|
|
84
|
+
const onClose = () => fail(/* @__PURE__ */ new Error("the proxy closed the connection before answering"));
|
|
85
|
+
const onAbort = () => fail(signal.reason);
|
|
86
|
+
if (signal.aborted) {
|
|
87
|
+
fail(signal.reason);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
socket.once(ready, onReady);
|
|
91
|
+
socket.on("data", onData);
|
|
92
|
+
socket.on("error", onError);
|
|
93
|
+
socket.on("close", onClose);
|
|
94
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** A tedious connector that dials every SQL connection through the proxy. */
|
|
98
|
+
function egressProxyConnector(proxy) {
|
|
99
|
+
const proxyUrl = new URL(proxy.url);
|
|
100
|
+
return async ({ host, port }, _lookup, signal) => {
|
|
101
|
+
const target = authority(host, port);
|
|
102
|
+
const socket = dialProxy(proxyUrl);
|
|
103
|
+
const request = `CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\nProxy-Authorization: Bearer ${proxy.token}\r\n\r
|
|
104
|
+
`;
|
|
105
|
+
let head;
|
|
106
|
+
try {
|
|
107
|
+
head = await readResponseHead(socket, proxyUrl.protocol === "https:" ? "secureConnect" : "connect", request, signal);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (signal.aborted) throw error;
|
|
110
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
111
|
+
throw new ExigoConnectionError(`Could not reach Fluid's egress proxy at ${proxy.host}: ${reason}`, "sql", { cause: error });
|
|
112
|
+
}
|
|
113
|
+
if (head.status !== 200) {
|
|
114
|
+
socket.destroy();
|
|
115
|
+
throw new ExigoProxyError(describeProxyRefusal(head.status, target, proxy.host), "sql", { status: head.status });
|
|
116
|
+
}
|
|
117
|
+
refuseUnencryptedPrelogin(socket, target);
|
|
118
|
+
if (head.rest.length > 0) socket.unshift(head.rest);
|
|
119
|
+
return socket;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const TDS_HEADER_LENGTH = 8;
|
|
123
|
+
const TDS_TABULAR_RESULT = 4;
|
|
124
|
+
const PRELOGIN_ENCRYPTION = 1;
|
|
125
|
+
const PRELOGIN_TERMINATOR = 255;
|
|
126
|
+
const ENCRYPTION_NAMES = {
|
|
127
|
+
0: "OFF",
|
|
128
|
+
1: "ON",
|
|
129
|
+
2: "NOT_SUP",
|
|
130
|
+
3: "REQ"
|
|
131
|
+
};
|
|
132
|
+
const ENCRYPTED = new Set([1, 3]);
|
|
133
|
+
/** A PRELOGIN answer is tens of bytes; past this it is not one. */
|
|
134
|
+
const MAX_PRELOGIN_BYTES = 4 * 1024;
|
|
135
|
+
/** The ENCRYPTION option of a PRELOGIN answer, or null if there is none. */
|
|
136
|
+
function preloginEncryption(packet) {
|
|
137
|
+
if (packet.length < TDS_HEADER_LENGTH || packet[0] !== TDS_TABULAR_RESULT) return null;
|
|
138
|
+
const payload = packet.subarray(TDS_HEADER_LENGTH);
|
|
139
|
+
for (let index = 0; index + 5 <= payload.length; index += 5) {
|
|
140
|
+
const type = payload[index];
|
|
141
|
+
if (type === PRELOGIN_TERMINATOR) return null;
|
|
142
|
+
if (type !== PRELOGIN_ENCRYPTION) continue;
|
|
143
|
+
const offset = payload.readUInt16BE(index + 1);
|
|
144
|
+
if (payload.readUInt16BE(index + 3) < 1 || offset >= payload.length) return null;
|
|
145
|
+
return payload[offset] ?? null;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
function refuseUnencryptedPrelogin(socket, target) {
|
|
150
|
+
let buffered = Buffer.alloc(0);
|
|
151
|
+
const refuse = (detail) => {
|
|
152
|
+
socket.destroy(new ExigoConnectionError(`Exigo's SQL Server at ${target} did not agree to encrypt the connection (${detail}); the login was not sent.`, "sql"));
|
|
153
|
+
};
|
|
154
|
+
const onData = (chunk) => {
|
|
155
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
156
|
+
if (buffered.length < TDS_HEADER_LENGTH) return;
|
|
157
|
+
const length = buffered.readUInt16BE(2);
|
|
158
|
+
if (length < TDS_HEADER_LENGTH || length > MAX_PRELOGIN_BYTES) {
|
|
159
|
+
socket.off("data", onData);
|
|
160
|
+
refuse("its first answer is not a PRELOGIN response");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (buffered.length < length) return;
|
|
164
|
+
socket.off("data", onData);
|
|
165
|
+
const encryption = preloginEncryption(buffered.subarray(0, length));
|
|
166
|
+
if (encryption === null) refuse("its PRELOGIN response carries no encryption option");
|
|
167
|
+
else if (!ENCRYPTED.has(encryption)) refuse(`PRELOGIN encryption ${ENCRYPTION_NAMES[encryption] ?? encryption}`);
|
|
168
|
+
};
|
|
169
|
+
socket.on("data", onData);
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
export { egressProxyConnector };
|
package/dist/sql.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ExigoSqlCredentials } from "./credentials.mjs";
|
|
2
2
|
import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoProxyError, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
|
|
3
|
+
import { ExigoEgressProxy } from "./egress-proxy.mjs";
|
|
3
4
|
import { ConnectionPool, ConnectionPool as ConnectionPool$1 } from "mssql";
|
|
4
5
|
|
|
5
6
|
//#region src/sql.d.ts
|
|
@@ -24,6 +25,14 @@ interface ExigoPoolOptions {
|
|
|
24
25
|
* dropped. Defaults to a `console.warn`; the pool replaces the connection.
|
|
25
26
|
*/
|
|
26
27
|
readonly onError?: (error: ExigoError | Error) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Dials every connection through Fluid's egress proxy, for an app whose
|
|
30
|
+
* outbound IP Exigo does not allowlist. Leave it unset to connect directly,
|
|
31
|
+
* as in local development. Part of the pool's identity: a call with a
|
|
32
|
+
* different proxy or token gets its own pool. Requires `encrypt`, so the
|
|
33
|
+
* login never crosses the proxy in clear.
|
|
34
|
+
*/
|
|
35
|
+
readonly egressProxy?: ExigoEgressProxy;
|
|
27
36
|
}
|
|
28
37
|
/**
|
|
29
38
|
* Turns an `mssql` failure into the package's typed errors where Exigo's
|
|
@@ -35,8 +44,8 @@ declare function translateExigoSqlError(error: unknown): unknown;
|
|
|
35
44
|
* The open pool for these credentials, opening it on first use.
|
|
36
45
|
*
|
|
37
46
|
* Pools are keyed by host, port, database and username, and within that by
|
|
38
|
-
* password and
|
|
39
|
-
* failed connect is not cached, so the next call tries again. A rotated
|
|
47
|
+
* password, TLS settings and egress proxy. Concurrent first calls share one
|
|
48
|
+
* connect. A failed connect is not cached, so the next call tries again. A rotated
|
|
40
49
|
* password opens its own pool without closing the previous one, which is
|
|
41
50
|
* closed once no call has used it for five minutes. A pool closed directly is
|
|
42
51
|
* replaced on the next call.
|
|
@@ -50,4 +59,4 @@ declare function closeExigoPool(credentials: Pick<ExigoSqlCredentials, "host" |
|
|
|
50
59
|
/** Closes every pool this package opened; for shutdown hooks and tests. */
|
|
51
60
|
declare function closeAllExigoPools(): Promise<void>;
|
|
52
61
|
//#endregion
|
|
53
|
-
export { type ConnectionPool, EXIGO_SQL_DEFAULTS, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoPoolOptions, ExigoProxyError, ExigoSource, type ExigoSqlCredentials, ExigoTimeoutError, closeAllExigoPools, closeExigoPool, getExigoPool, translateExigoSqlError };
|
|
62
|
+
export { type ConnectionPool, EXIGO_SQL_DEFAULTS, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, type ExigoEgressProxy, ExigoError, ExigoErrorOptions, ExigoPoolOptions, ExigoProxyError, ExigoSource, type ExigoSqlCredentials, ExigoTimeoutError, closeAllExigoPools, closeExigoPool, getExigoPool, translateExigoSqlError };
|
package/dist/sql.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoProxyError, ExigoTimeoutError } from "./errors.mjs";
|
|
2
2
|
import { assertExigoSqlCredentials } from "./credentials.mjs";
|
|
3
|
+
import { normalizeEgressProxy } from "./egress-proxy.mjs";
|
|
4
|
+
import { egressProxyConnector } from "./sql-tunnel.mjs";
|
|
3
5
|
import { createHash } from "node:crypto";
|
|
4
6
|
//#region src/sql.ts
|
|
5
7
|
/**
|
|
@@ -15,6 +17,9 @@ import { createHash } from "node:crypto";
|
|
|
15
17
|
* the first time a pool opens, so an app that only imports `./rest` never
|
|
16
18
|
* loads it. In Next, list `mssql` in `serverExternalPackages` and set
|
|
17
19
|
* `export const runtime = "nodejs"` on routes that query.
|
|
20
|
+
*
|
|
21
|
+
* With `egressProxy`, every connection is dialled through Fluid's egress
|
|
22
|
+
* proxy (see `./sql-tunnel`).
|
|
18
23
|
*/
|
|
19
24
|
const EXIGO_SQL_DEFAULTS = {
|
|
20
25
|
port: 1433,
|
|
@@ -24,9 +29,9 @@ const EXIGO_SQL_DEFAULTS = {
|
|
|
24
29
|
requestTimeoutMs: 3e4
|
|
25
30
|
};
|
|
26
31
|
/**
|
|
27
|
-
* Each database holds one pool per credential fingerprint (password
|
|
28
|
-
* settings). A rotation therefore never closes a pool
|
|
29
|
-
* using: requests that resolved the old password keep the old pool, new ones
|
|
32
|
+
* Each database holds one pool per credential fingerprint (password, TLS
|
|
33
|
+
* settings and egress proxy). A rotation therefore never closes a pool
|
|
34
|
+
* another request is using: requests that resolved the old password keep the old pool, new ones
|
|
30
35
|
* get the new pool, and a rollback finds its pool still there. A pool that
|
|
31
36
|
* is not its database's most recently used one, and that no request has asked
|
|
32
37
|
* for in this long, is closed and forgotten. The sweep runs on every call and,
|
|
@@ -61,14 +66,15 @@ function poolKey(credentials) {
|
|
|
61
66
|
credentials.username
|
|
62
67
|
]);
|
|
63
68
|
}
|
|
64
|
-
function fingerprint(credentials) {
|
|
69
|
+
function fingerprint(credentials, proxy) {
|
|
65
70
|
return createHash("sha256").update(JSON.stringify([
|
|
66
71
|
credentials.password,
|
|
67
72
|
credentials.encrypt ?? true,
|
|
68
|
-
credentials.trustServerCertificate
|
|
73
|
+
credentials.trustServerCertificate,
|
|
74
|
+
proxy ? [proxy.url, proxy.token] : null
|
|
69
75
|
])).digest("hex");
|
|
70
76
|
}
|
|
71
|
-
function toMssqlConfig(credentials, options) {
|
|
77
|
+
function toMssqlConfig(credentials, options, proxy) {
|
|
72
78
|
return {
|
|
73
79
|
server: credentials.host.trim(),
|
|
74
80
|
port: credentials.port ?? EXIGO_SQL_DEFAULTS.port,
|
|
@@ -85,7 +91,8 @@ function toMssqlConfig(credentials, options) {
|
|
|
85
91
|
options: {
|
|
86
92
|
encrypt: credentials.encrypt ?? true,
|
|
87
93
|
trustServerCertificate: credentials.trustServerCertificate,
|
|
88
|
-
enableArithAbort: true
|
|
94
|
+
enableArithAbort: true,
|
|
95
|
+
...proxy ? { connector: egressProxyConnector(proxy) } : {}
|
|
89
96
|
}
|
|
90
97
|
};
|
|
91
98
|
}
|
|
@@ -129,7 +136,8 @@ function errorChain(error) {
|
|
|
129
136
|
* such as a syntax error in a droplet's query, is returned unchanged.
|
|
130
137
|
*/
|
|
131
138
|
function translateExigoSqlError(error) {
|
|
132
|
-
|
|
139
|
+
const own = ownErrorIn(error);
|
|
140
|
+
if (own !== null) return own;
|
|
133
141
|
const { codes, text } = errorChain(error);
|
|
134
142
|
const code = error !== null && typeof error === "object" ? error.code : void 0;
|
|
135
143
|
const name = error instanceof Error ? error.name : "";
|
|
@@ -145,8 +153,29 @@ function translateExigoSqlError(error) {
|
|
|
145
153
|
if (name === "ConnectionError" || code === "ESOCKET" || code === "ECONNCLOSED") return new ExigoConnectionError(`Could not connect to Exigo's SQL Server: ${raw}`, "sql", options);
|
|
146
154
|
return error;
|
|
147
155
|
}
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
/** The first `ExigoError` inside mssql's and tedious's wrapping, if any. */
|
|
157
|
+
function ownErrorIn(error) {
|
|
158
|
+
const seen = /* @__PURE__ */ new Set();
|
|
159
|
+
const visit = (value, depth) => {
|
|
160
|
+
if (value === null || typeof value !== "object" || seen.has(value) || depth > 5) return null;
|
|
161
|
+
seen.add(value);
|
|
162
|
+
if (value instanceof ExigoError) return value;
|
|
163
|
+
const record = value;
|
|
164
|
+
const inner = [
|
|
165
|
+
record.originalError,
|
|
166
|
+
record.cause,
|
|
167
|
+
...Array.isArray(record.errors) ? record.errors : []
|
|
168
|
+
];
|
|
169
|
+
for (const candidate of inner) {
|
|
170
|
+
const found = visit(candidate, depth + 1);
|
|
171
|
+
if (found !== null) return found;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
};
|
|
175
|
+
return visit(error, 0);
|
|
176
|
+
}
|
|
177
|
+
async function openPool(credentials, options, proxy) {
|
|
178
|
+
const pool = new (await (loadMssql())).ConnectionPool(toMssqlConfig(credentials, options, proxy));
|
|
150
179
|
const onError = options.onError ?? ((error) => console.warn(`[@fluid-app/exigo-connection-sdk] ${error.message}`));
|
|
151
180
|
pool.on("error", (error) => {
|
|
152
181
|
const translated = translateExigoSqlError(error);
|
|
@@ -166,8 +195,8 @@ function isUsable(pool) {
|
|
|
166
195
|
* The open pool for these credentials, opening it on first use.
|
|
167
196
|
*
|
|
168
197
|
* Pools are keyed by host, port, database and username, and within that by
|
|
169
|
-
* password and
|
|
170
|
-
* failed connect is not cached, so the next call tries again. A rotated
|
|
198
|
+
* password, TLS settings and egress proxy. Concurrent first calls share one
|
|
199
|
+
* connect. A failed connect is not cached, so the next call tries again. A rotated
|
|
171
200
|
* password opens its own pool without closing the previous one, which is
|
|
172
201
|
* closed once no call has used it for five minutes. A pool closed directly is
|
|
173
202
|
* replaced on the next call.
|
|
@@ -175,9 +204,11 @@ function isUsable(pool) {
|
|
|
175
204
|
async function getExigoPool(credentials, options = {}) {
|
|
176
205
|
if (!credentials) throw new ExigoCredentialsError("No Exigo SQL credentials were provided.", ["sql"]);
|
|
177
206
|
assertExigoSqlCredentials(credentials);
|
|
207
|
+
const proxy = options.egressProxy ? normalizeEgressProxy(options.egressProxy) : null;
|
|
208
|
+
if (proxy && credentials.encrypt === false) throw new ExigoCredentialsError("Exigo sql.encrypt cannot be false with egressProxy; the login would cross the proxy unencrypted.", ["sql.encrypt"]);
|
|
178
209
|
const pools = cache();
|
|
179
210
|
const identity = poolKey(credentials);
|
|
180
|
-
const current = fingerprint(credentials);
|
|
211
|
+
const current = fingerprint(credentials, proxy);
|
|
181
212
|
const group = () => {
|
|
182
213
|
let entries = pools.get(identity);
|
|
183
214
|
if (entries === void 0) {
|
|
@@ -202,7 +233,7 @@ async function getExigoPool(credentials, options = {}) {
|
|
|
202
233
|
}
|
|
203
234
|
}
|
|
204
235
|
const entry = {
|
|
205
|
-
pool: openPool(credentials, options),
|
|
236
|
+
pool: openPool(credentials, options, proxy),
|
|
206
237
|
lastUsed: Date.now()
|
|
207
238
|
};
|
|
208
239
|
const entries = group();
|
package/package.json
CHANGED