@floegence/flowersec-core 0.24.0 → 0.26.0
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 +6 -0
- package/dist/browser/controlplane.d.ts +1 -1
- package/dist/browser/controlplane.js +0 -1
- package/dist/browser/index.d.ts +3 -3
- package/dist/browser/index.js +2 -2
- package/dist/client-connect/transportSecurity.d.ts +1 -3
- package/dist/client-connect/transportSecurity.js +1 -5
- package/dist/controlplane/request.d.ts +1 -0
- package/dist/controlplane/request.js +77 -2
- package/dist/defaults.d.ts +1 -0
- package/dist/defaults.js +1 -0
- package/dist/endpoint/index.js +185 -33
- package/dist/facade.d.ts +1 -1
- package/dist/facade.js +1 -1
- package/dist/node/index.d.ts +1 -1
- package/dist/node/index.js +1 -1
- package/dist/proxy/appWindow.js +93 -7
- package/dist/proxy/bootstrap.d.ts +5 -24
- package/dist/proxy/bootstrap.js +1 -11
- package/dist/proxy/constants.d.ts +1 -0
- package/dist/proxy/constants.js +1 -0
- package/dist/proxy/controllerWindow.js +219 -78
- package/dist/proxy/integration.js +0 -6
- package/dist/proxy/portStream.d.ts +2 -1
- package/dist/proxy/portStream.js +173 -74
- package/dist/proxy/preset.d.ts +0 -1
- package/dist/proxy/preset.js +0 -9
- package/dist/proxy/runtimeScope.js +2 -7
- package/dist/proxy/server.d.ts +1 -0
- package/dist/proxy/server.js +466 -113
- package/dist/proxy/windowBridgeProtocol.d.ts +4 -3
- package/dist/proxy/windowBridgeProtocol.js +1 -1
- package/package.json +1 -1
- package/dist/proxy/profiles.d.ts +0 -18
- package/dist/proxy/profiles.js +0 -71
package/README.md
CHANGED
|
@@ -27,6 +27,12 @@ Start with the [TypeScript cookbook](https://github.com/floegence/flowersec/tree
|
|
|
27
27
|
|
|
28
28
|
High-level WebSocket connections require TLS by default. Use `AllowPlaintextForLoopback` only for literal local development targets.
|
|
29
29
|
|
|
30
|
+
## Node Proxy Server
|
|
31
|
+
|
|
32
|
+
The Node entrypoint exports `serveProxySession(...)`, `serveProxyStream(...)`, and `ProxyServerOptions`. HTTP request and response bodies stream between Flowersec chunk frames and the upstream fetch while remaining bounded per request by `maxBodyBytes`. Proxy sessions admit at most `maxConcurrentStreams` HTTP/WebSocket streams, with a shared default of 64; RPC serving has its own concurrency controls. WebSocket frames remain bounded by `maxWsFrameBytes`; `maxWsQueuedBytes` additionally caps upstream-to-Yamux queued bytes per connection, while the reverse direction waits for each Node `ws` send callback.
|
|
33
|
+
|
|
34
|
+
The defaults keep one queued WebSocket frame plus its proxy header. Raise body, concurrency, or queue limits only when the deployment has matching upstream and memory capacity.
|
|
35
|
+
|
|
30
36
|
## Runtime Boundaries
|
|
31
37
|
|
|
32
38
|
TypeScript owns browser and Service Worker integration. Shared tunnel, proxy gateway, and helper binaries remain Go-owned.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { type ChannelInitGrant } from "../facade.js";
|
|
2
2
|
import type { ControlplaneBaseConfig as SharedControlplaneBaseConfig, RequestConnectArtifactInput, RequestEntryConnectArtifactInput } from "../controlplane/request.js";
|
|
3
|
-
export { ControlplaneRequestError, requestConnectArtifact, requestEntryConnectArtifact, } from "../controlplane/request.js";
|
|
4
3
|
type BaseControlplaneRequestConfig = SharedControlplaneBaseConfig & Readonly<{
|
|
5
4
|
endpointId: string;
|
|
6
5
|
payload?: Record<string, unknown>;
|
|
@@ -13,3 +12,4 @@ export type ConnectArtifactRequestConfig = RequestConnectArtifactInput;
|
|
|
13
12
|
export type EntryConnectArtifactRequestConfig = RequestEntryConnectArtifactInput;
|
|
14
13
|
export declare function requestChannelGrant(config: ControlplaneConfig): Promise<ChannelInitGrant>;
|
|
15
14
|
export declare function requestEntryChannelGrant(config: EntryControlplaneConfig): Promise<ChannelInitGrant>;
|
|
15
|
+
export {};
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { assertChannelInitGrant } from "../facade.js";
|
|
2
2
|
import { requestControlplaneJSON, } from "../controlplane/request.js";
|
|
3
|
-
export { ControlplaneRequestError, requestConnectArtifact, requestEntryConnectArtifact, } from "../controlplane/request.js";
|
|
4
3
|
function buildURL(baseUrl, path) {
|
|
5
4
|
const base = String(baseUrl ?? "").trim();
|
|
6
5
|
if (base === "")
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export type { ConnectBrowserOptions, DirectConnectBrowserOptions, TunnelConnectBrowserOptions } from "./connect.js";
|
|
2
2
|
export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
|
|
3
|
-
export {
|
|
3
|
+
export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
4
4
|
export type { TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, NetworkPlaintextPolicyOptions, } from "../client-connect/transportSecurity.js";
|
|
5
5
|
export type { ConnectArtifact, CorrelationContext, CorrelationKV, DirectClientConnectArtifact, ScopeMetadataEntry, TunnelClientConnectArtifact, } from "../connect/artifact.js";
|
|
6
6
|
export { assertConnectArtifact } from "../connect/artifact.js";
|
|
7
|
-
export type {
|
|
8
|
-
export {
|
|
7
|
+
export type { ControlplaneConfig, EntryControlplaneConfig } from "./controlplane.js";
|
|
8
|
+
export { requestChannelGrant, requestEntryChannelGrant, } from "./controlplane.js";
|
|
9
9
|
export type { BrowserReconnectConfig, DirectBrowserReconnectConfig, TunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
|
|
10
10
|
export { createBrowserReconnectConfig, createDirectBrowserReconnectConfig, createTunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
|
package/dist/browser/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
|
|
2
|
-
export {
|
|
2
|
+
export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
3
3
|
export { assertConnectArtifact } from "../connect/artifact.js";
|
|
4
|
-
export {
|
|
4
|
+
export { requestChannelGrant, requestEntryChannelGrant, } from "./controlplane.js";
|
|
5
5
|
export { createBrowserReconnectConfig, createDirectBrowserReconnectConfig, createTunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
|
|
@@ -2,8 +2,6 @@ import type { ClientPath } from "../client.js";
|
|
|
2
2
|
import { type ClientObserverLike } from "../observability/observer.js";
|
|
3
3
|
export declare const RequireTLS: "require_tls";
|
|
4
4
|
export declare const AllowPlaintextForLoopback: "allow_plaintext_for_loopback";
|
|
5
|
-
/** @deprecated Use RequireTLS, AllowPlaintextForLoopback, or createNetworkPlaintextPolicy. */
|
|
6
|
-
export declare const AllowPlaintext: "allow_plaintext";
|
|
7
5
|
export declare const PlaintextRiskAcceptance: {
|
|
8
6
|
readonly acceptPreE2ECredentialExposure: "accept_pre_e2ee_credential_exposure";
|
|
9
7
|
};
|
|
@@ -18,7 +16,7 @@ export type TransportSecurityPolicyInput = Readonly<{
|
|
|
18
16
|
host: string;
|
|
19
17
|
runtime: "browser" | "node" | "other";
|
|
20
18
|
}>;
|
|
21
|
-
export type TransportSecurityPolicyPreset = typeof RequireTLS | typeof AllowPlaintextForLoopback
|
|
19
|
+
export type TransportSecurityPolicyPreset = typeof RequireTLS | typeof AllowPlaintextForLoopback;
|
|
22
20
|
export type TransportSecurityPolicy = TransportSecurityPolicyPreset | ((input: TransportSecurityPolicyInput) => boolean | Promise<boolean>);
|
|
23
21
|
export declare function createNetworkPlaintextPolicy(options: NetworkPlaintextPolicyOptions): TransportSecurityPolicy;
|
|
24
22
|
export declare function enforceTransportSecurity(args: Readonly<{
|
|
@@ -2,8 +2,6 @@ import { FlowersecError } from "../utils/errors.js";
|
|
|
2
2
|
import { emitObserverDiagnostic } from "../observability/observer.js";
|
|
3
3
|
export const RequireTLS = "require_tls";
|
|
4
4
|
export const AllowPlaintextForLoopback = "allow_plaintext_for_loopback";
|
|
5
|
-
/** @deprecated Use RequireTLS, AllowPlaintextForLoopback, or createNetworkPlaintextPolicy. */
|
|
6
|
-
export const AllowPlaintext = "allow_plaintext";
|
|
7
5
|
export const PlaintextRiskAcceptance = {
|
|
8
6
|
acceptPreE2ECredentialExposure: "accept_pre_e2ee_credential_exposure",
|
|
9
7
|
};
|
|
@@ -35,7 +33,7 @@ export async function enforceTransportSecurity(args) {
|
|
|
35
33
|
const policy = args.policy ?? RequireTLS;
|
|
36
34
|
try {
|
|
37
35
|
if (typeof policy === "function") {
|
|
38
|
-
allowed = await policy(input);
|
|
36
|
+
allowed = await policy(input) === true;
|
|
39
37
|
}
|
|
40
38
|
else {
|
|
41
39
|
allowed = evaluatePreset(policy, target);
|
|
@@ -71,8 +69,6 @@ function evaluatePreset(policy, target) {
|
|
|
71
69
|
return target.scheme === "wss";
|
|
72
70
|
case AllowPlaintextForLoopback:
|
|
73
71
|
return target.scheme === "wss" || isLiteralLoopbackHost(target.host);
|
|
74
|
-
case AllowPlaintext:
|
|
75
|
-
return true;
|
|
76
72
|
}
|
|
77
73
|
}
|
|
78
74
|
function parseWebSocketTarget(rawUrl) {
|
|
@@ -34,6 +34,76 @@ export function buildControlplaneURL(baseUrl, path) {
|
|
|
34
34
|
return path;
|
|
35
35
|
return `${base.replace(/\/+$/, "")}${path}`;
|
|
36
36
|
}
|
|
37
|
+
function transportPolicyDenied() {
|
|
38
|
+
return new ControlplaneRequestError({
|
|
39
|
+
status: 0,
|
|
40
|
+
code: "transport_policy_denied",
|
|
41
|
+
message: "controlplane transport security policy denied URL",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function validateArtifactURL(rawUrl, allowLoopbackHTTP) {
|
|
45
|
+
let target;
|
|
46
|
+
try {
|
|
47
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(rawUrl)) {
|
|
48
|
+
target = new URL(rawUrl);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const base = globalThis.location?.href;
|
|
52
|
+
if (!base)
|
|
53
|
+
throw new Error("relative URL requires a browser location");
|
|
54
|
+
target = new URL(rawUrl, base);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw transportPolicyDenied();
|
|
59
|
+
}
|
|
60
|
+
if (target.username !== "" || target.password !== "" || target.hash !== "") {
|
|
61
|
+
throw transportPolicyDenied();
|
|
62
|
+
}
|
|
63
|
+
if (target.protocol === "https:")
|
|
64
|
+
return;
|
|
65
|
+
if (target.protocol === "http:"
|
|
66
|
+
&& allowLoopbackHTTP === true
|
|
67
|
+
&& isLiteralLoopbackHost(sourceHostname(rawUrl, target)))
|
|
68
|
+
return;
|
|
69
|
+
throw transportPolicyDenied();
|
|
70
|
+
}
|
|
71
|
+
function sourceHostname(rawUrl, target) {
|
|
72
|
+
const authority = /^(?:[A-Za-z][A-Za-z0-9+.-]*:)?\/\/([^/?#]*)/.exec(rawUrl)?.[1];
|
|
73
|
+
if (authority === undefined)
|
|
74
|
+
return target.hostname;
|
|
75
|
+
const hostPort = authority.slice(authority.lastIndexOf("@") + 1);
|
|
76
|
+
if (hostPort.startsWith("[")) {
|
|
77
|
+
const end = hostPort.indexOf("]");
|
|
78
|
+
if (end < 0 || (hostPort.length > end + 1 && !/^:\d+$/.test(hostPort.slice(end + 1)))) {
|
|
79
|
+
return hostPort;
|
|
80
|
+
}
|
|
81
|
+
return hostPort.slice(1, end);
|
|
82
|
+
}
|
|
83
|
+
const colon = hostPort.lastIndexOf(":");
|
|
84
|
+
if (colon < 0)
|
|
85
|
+
return hostPort;
|
|
86
|
+
if (hostPort.indexOf(":") !== colon || !/^\d+$/.test(hostPort.slice(colon + 1)))
|
|
87
|
+
return hostPort;
|
|
88
|
+
return hostPort.slice(0, colon);
|
|
89
|
+
}
|
|
90
|
+
function isLiteralLoopbackHost(rawHost) {
|
|
91
|
+
const host = rawHost.startsWith("[") && rawHost.endsWith("]")
|
|
92
|
+
? rawHost.slice(1, -1).toLowerCase()
|
|
93
|
+
: rawHost.toLowerCase();
|
|
94
|
+
if (host === "localhost" || host === "::1")
|
|
95
|
+
return true;
|
|
96
|
+
const parts = host.split(".");
|
|
97
|
+
if (parts.length !== 4)
|
|
98
|
+
return false;
|
|
99
|
+
const octets = parts.map((part) => {
|
|
100
|
+
if (!/^(0|[1-9]\d{0,2})$/.test(part))
|
|
101
|
+
return -1;
|
|
102
|
+
const value = Number(part);
|
|
103
|
+
return value <= 255 ? value : -1;
|
|
104
|
+
});
|
|
105
|
+
return octets.every((value) => value >= 0) && octets[0] === 127;
|
|
106
|
+
}
|
|
37
107
|
function parseMaybeJSON(bodyText) {
|
|
38
108
|
const trimmed = String(bodyText ?? "").trim();
|
|
39
109
|
if (trimmed === "")
|
|
@@ -110,6 +180,7 @@ export async function requestControlplaneJSON(url, init) {
|
|
|
110
180
|
const response = await runFetch(url, {
|
|
111
181
|
...init,
|
|
112
182
|
cache: "no-store",
|
|
183
|
+
redirect: "error",
|
|
113
184
|
});
|
|
114
185
|
let rawBody = "";
|
|
115
186
|
try {
|
|
@@ -164,7 +235,9 @@ function createJSONHeaders(input) {
|
|
|
164
235
|
return headers;
|
|
165
236
|
}
|
|
166
237
|
export async function requestConnectArtifact(config) {
|
|
167
|
-
const
|
|
238
|
+
const url = buildControlplaneURL(config.baseUrl, config.path ?? DEFAULT_CONNECT_ARTIFACT_PATH);
|
|
239
|
+
validateArtifactURL(url, config.allowLoopbackHTTP);
|
|
240
|
+
const data = (await requestControlplaneJSON(url, {
|
|
168
241
|
...(config.fetch === undefined ? {} : { fetch: config.fetch }),
|
|
169
242
|
method: "POST",
|
|
170
243
|
credentials: config.credentials ?? "omit",
|
|
@@ -183,7 +256,9 @@ export async function requestEntryConnectArtifact(config) {
|
|
|
183
256
|
throw new Error("entryTicket is required");
|
|
184
257
|
const headers = createJSONHeaders(config.headers);
|
|
185
258
|
headers.set("Authorization", `Bearer ${entryTicket}`);
|
|
186
|
-
const
|
|
259
|
+
const url = buildControlplaneURL(config.baseUrl, config.path ?? DEFAULT_ENTRY_CONNECT_ARTIFACT_PATH);
|
|
260
|
+
validateArtifactURL(url, config.allowLoopbackHTTP);
|
|
261
|
+
const data = (await requestControlplaneJSON(url, {
|
|
187
262
|
...(config.fetch === undefined ? {} : { fetch: config.fetch }),
|
|
188
263
|
method: "POST",
|
|
189
264
|
credentials: config.credentials ?? "omit",
|
package/dist/defaults.d.ts
CHANGED
package/dist/defaults.js
CHANGED
package/dist/endpoint/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Role as ControlRole, assertChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
|
|
2
2
|
import { Role as TunnelRole } from "../gen/flowersec/tunnel/v1.gen.js";
|
|
3
|
+
import { withAbortAndTimeout } from "../client-connect/common.js";
|
|
3
4
|
import { assertTunnelGrantContract, assertValidPSK, prepareChannelId } from "../client-connect/contract.js";
|
|
4
5
|
import { enforceTransportSecurity } from "../client-connect/transportSecurity.js";
|
|
5
6
|
import { SDK_DEFAULTS } from "../defaults.js";
|
|
@@ -12,7 +13,7 @@ import { isYamuxPingTimeoutError } from "../yamux/errors.js";
|
|
|
12
13
|
import { YamuxSession } from "../yamux/session.js";
|
|
13
14
|
import { RpcServer } from "../rpc/server.js";
|
|
14
15
|
import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
|
|
15
|
-
import { AbortError, FlowersecError } from "../utils/errors.js";
|
|
16
|
+
import { AbortError, FlowersecError, TimeoutError, throwIfAborted } from "../utils/errors.js";
|
|
16
17
|
import { WebSocketBinaryTransport } from "../ws-client/binaryTransport.js";
|
|
17
18
|
export class Session {
|
|
18
19
|
secure;
|
|
@@ -152,15 +153,46 @@ export class Session {
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
export async function acceptDirect(websocket, handshake, options = {}) {
|
|
156
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
|
|
157
|
+
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
158
|
+
throw new FlowersecError({
|
|
159
|
+
path: "direct",
|
|
160
|
+
stage: "validate",
|
|
161
|
+
code: "invalid_option",
|
|
162
|
+
message: "handshakeTimeoutMs must be a non-negative number",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
155
165
|
await enforceIncomingDirectTransport(options);
|
|
166
|
+
const deadline = createHandshakeDeadline(handshakeTimeoutMs);
|
|
156
167
|
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
157
|
-
return await establishSession("direct", transport, handshake, options);
|
|
168
|
+
return await establishSession("direct", transport, handshake, options, undefined, deadline);
|
|
158
169
|
}
|
|
159
170
|
export async function acceptDirectResolved(websocket, resolver, options = {}) {
|
|
171
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
|
|
172
|
+
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
173
|
+
throw new FlowersecError({
|
|
174
|
+
path: "direct",
|
|
175
|
+
stage: "validate",
|
|
176
|
+
code: "invalid_option",
|
|
177
|
+
message: "handshakeTimeoutMs must be a non-negative number",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
160
180
|
await enforceIncomingDirectTransport(options);
|
|
181
|
+
const deadline = createHandshakeDeadline(handshakeTimeoutMs);
|
|
161
182
|
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
162
|
-
|
|
183
|
+
let first;
|
|
184
|
+
try {
|
|
185
|
+
first = await runHandshakeStep(deadline, options.signal, () => transport.readBinary({
|
|
186
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
187
|
+
timeoutMs: remainingHandshakeTimeoutMs(deadline),
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
transport.close();
|
|
192
|
+
throw endpointHandshakeError("direct", error, "failed to read handshake init");
|
|
193
|
+
}
|
|
163
194
|
let init;
|
|
195
|
+
let channelId;
|
|
164
196
|
try {
|
|
165
197
|
const decoded = decodeHandshakeFrame(first, options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes);
|
|
166
198
|
if (decoded.handshakeType !== HANDSHAKE_TYPE_INIT)
|
|
@@ -168,30 +200,44 @@ export async function acceptDirectResolved(websocket, resolver, options = {}) {
|
|
|
168
200
|
init = JSON.parse(new TextDecoder().decode(decoded.payloadJsonUtf8));
|
|
169
201
|
if (init.version !== PROTOCOL_VERSION || init.role !== 1 || (init.suite !== 1 && init.suite !== 2))
|
|
170
202
|
throw new Error("invalid handshake init");
|
|
203
|
+
channelId = prepareChannelId(init.channel_id, "direct");
|
|
204
|
+
if (channelId !== init.channel_id) {
|
|
205
|
+
throw new FlowersecError({
|
|
206
|
+
path: "direct",
|
|
207
|
+
stage: "validate",
|
|
208
|
+
code: "invalid_input",
|
|
209
|
+
message: "channel_id must not have leading or trailing whitespace",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
171
212
|
}
|
|
172
213
|
catch (error) {
|
|
173
214
|
transport.close();
|
|
215
|
+
if (error instanceof FlowersecError)
|
|
216
|
+
throw error;
|
|
174
217
|
throw new FlowersecError({ path: "direct", stage: "handshake", code: "handshake_failed", message: "invalid handshake init", cause: error });
|
|
175
218
|
}
|
|
176
219
|
let credential;
|
|
177
220
|
try {
|
|
178
|
-
credential = await resolver({
|
|
179
|
-
channelId
|
|
221
|
+
credential = await runHandshakeStep(deadline, options.signal, () => resolver({
|
|
222
|
+
channelId,
|
|
180
223
|
version: init.version,
|
|
181
224
|
suite: init.suite,
|
|
182
225
|
clientFeatures: init.client_features >>> 0,
|
|
183
|
-
});
|
|
226
|
+
}));
|
|
184
227
|
}
|
|
185
228
|
catch (error) {
|
|
186
229
|
transport.close();
|
|
230
|
+
const interrupted = endpointHandshakeInterruption("direct", error);
|
|
231
|
+
if (interrupted != null)
|
|
232
|
+
throw interrupted;
|
|
187
233
|
throw new FlowersecError({ path: "direct", stage: "validate", code: "resolve_failed", message: "credential resolution failed", cause: error });
|
|
188
234
|
}
|
|
189
235
|
const replay = new PrefetchedTransport(transport, first);
|
|
190
236
|
return await establishSession("direct", replay, {
|
|
191
|
-
channelId
|
|
237
|
+
channelId,
|
|
192
238
|
suite: init.suite,
|
|
193
239
|
...credential,
|
|
194
|
-
}, options);
|
|
240
|
+
}, options, undefined, deadline);
|
|
195
241
|
}
|
|
196
242
|
export async function connectTunnel(grantInput, options) {
|
|
197
243
|
let grant;
|
|
@@ -202,6 +248,7 @@ export async function connectTunnel(grantInput, options) {
|
|
|
202
248
|
throw new FlowersecError({ path: "tunnel", stage: "validate", code: "invalid_input", message: "invalid ChannelInitGrant", cause: error });
|
|
203
249
|
}
|
|
204
250
|
assertTunnelGrantContract(grant, ControlRole.Role_server);
|
|
251
|
+
const channelId = prepareChannelId(grant.channel_id, "tunnel");
|
|
205
252
|
const tunnelUrl = grant.tunnel_url.trim();
|
|
206
253
|
if (tunnelUrl === "")
|
|
207
254
|
throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_tunnel_url", message: "missing tunnel_url" });
|
|
@@ -212,35 +259,63 @@ export async function connectTunnel(grantInput, options) {
|
|
|
212
259
|
const origin = options.origin.trim();
|
|
213
260
|
if (origin === "")
|
|
214
261
|
throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_origin", message: "missing origin" });
|
|
262
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? SDK_DEFAULTS.transport.connectTimeoutMs;
|
|
263
|
+
if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
|
|
264
|
+
throw new FlowersecError({
|
|
265
|
+
path: "tunnel",
|
|
266
|
+
stage: "validate",
|
|
267
|
+
code: "invalid_option",
|
|
268
|
+
message: "connectTimeoutMs must be a non-negative number",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
|
|
272
|
+
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
273
|
+
throw new FlowersecError({
|
|
274
|
+
path: "tunnel",
|
|
275
|
+
stage: "validate",
|
|
276
|
+
code: "invalid_option",
|
|
277
|
+
message: "handshakeTimeoutMs must be a non-negative number",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const psk = assertValidPSK(grant.e2ee_psk_b64u, "tunnel");
|
|
215
281
|
await enforceTransportSecurity({ rawUrl: tunnelUrl, path: "tunnel", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
|
|
216
282
|
const endpointInstanceId = normalizeEndpointInstanceId(options.endpointInstanceId);
|
|
217
|
-
|
|
218
|
-
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
283
|
+
let transport;
|
|
219
284
|
try {
|
|
220
|
-
|
|
285
|
+
throwIfAborted(options.signal, "connect aborted");
|
|
286
|
+
const websocket = options.wsFactory(tunnelUrl, origin);
|
|
287
|
+
transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
288
|
+
await waitForOpen(websocket, connectTimeoutMs, options.signal);
|
|
289
|
+
throwIfAborted(options.signal, "connect aborted");
|
|
221
290
|
const attach = {
|
|
222
291
|
v: 1,
|
|
223
|
-
channel_id:
|
|
292
|
+
channel_id: channelId,
|
|
224
293
|
role: TunnelRole.Role_server,
|
|
225
294
|
token: grant.token.trim(),
|
|
226
295
|
endpoint_instance_id: endpointInstanceId,
|
|
227
296
|
};
|
|
228
297
|
websocket.send(JSON.stringify(attach));
|
|
229
298
|
return await establishSession("tunnel", transport, {
|
|
230
|
-
channelId
|
|
299
|
+
channelId,
|
|
231
300
|
suite: grant.default_suite,
|
|
232
|
-
psk
|
|
301
|
+
psk,
|
|
233
302
|
initExpireAtUnixS: grant.channel_init_expire_at_unix_s,
|
|
234
303
|
}, options, endpointInstanceId);
|
|
235
304
|
}
|
|
236
305
|
catch (error) {
|
|
237
|
-
transport
|
|
306
|
+
transport?.close();
|
|
238
307
|
if (error instanceof FlowersecError)
|
|
239
308
|
throw error;
|
|
309
|
+
if (error instanceof TimeoutError) {
|
|
310
|
+
throw new FlowersecError({ path: "tunnel", stage: "connect", code: "timeout", message: "endpoint tunnel connect timed out", cause: error });
|
|
311
|
+
}
|
|
312
|
+
if (error instanceof AbortError) {
|
|
313
|
+
throw new FlowersecError({ path: "tunnel", stage: "connect", code: "canceled", message: "endpoint tunnel connect canceled", cause: error });
|
|
314
|
+
}
|
|
240
315
|
throw new FlowersecError({ path: "tunnel", stage: "connect", code: "dial_failed", message: "endpoint tunnel connect failed", cause: error });
|
|
241
316
|
}
|
|
242
317
|
}
|
|
243
|
-
async function establishSession(path, transport, handshake, options, endpointInstanceId) {
|
|
318
|
+
async function establishSession(path, transport, handshake, options, endpointInstanceId, deadline) {
|
|
244
319
|
let psk;
|
|
245
320
|
try {
|
|
246
321
|
psk = normalizePSK(handshake.psk, path);
|
|
@@ -250,7 +325,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
|
|
|
250
325
|
throw error;
|
|
251
326
|
}
|
|
252
327
|
try {
|
|
253
|
-
const
|
|
328
|
+
const startHandshake = () => serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
|
|
254
329
|
channelId: prepareChannelId(handshake.channelId, path),
|
|
255
330
|
suite: handshake.suite,
|
|
256
331
|
psk,
|
|
@@ -262,25 +337,52 @@ async function establishSession(path, transport, handshake, options, endpointIns
|
|
|
262
337
|
outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
|
|
263
338
|
maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
|
|
264
339
|
maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
|
|
265
|
-
timeoutMs:
|
|
340
|
+
timeoutMs: deadline == null
|
|
341
|
+
? options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs
|
|
342
|
+
: remainingHandshakeTimeoutMs(deadline),
|
|
266
343
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
267
344
|
});
|
|
345
|
+
const secure = deadline == null
|
|
346
|
+
? await startHandshake()
|
|
347
|
+
: await runHandshakeStep(deadline, options.signal, startHandshake);
|
|
268
348
|
try {
|
|
269
|
-
|
|
349
|
+
if (handshake.commitAuthenticated != null) {
|
|
350
|
+
if (deadline == null) {
|
|
351
|
+
await handshake.commitAuthenticated();
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
await runHandshakeStep(deadline, options.signal, handshake.commitAuthenticated);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
270
357
|
}
|
|
271
358
|
catch (error) {
|
|
272
359
|
secure.close();
|
|
360
|
+
const interrupted = endpointHandshakeInterruption(path, error);
|
|
361
|
+
if (interrupted != null)
|
|
362
|
+
throw interrupted;
|
|
273
363
|
throw new FlowersecError({ path, stage: "handshake", code: "credential_commit_failed", message: "credential commit failed", cause: error });
|
|
274
364
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
365
|
+
try {
|
|
366
|
+
throwIfAborted(options.signal, "handshake canceled");
|
|
367
|
+
if (deadline != null)
|
|
368
|
+
remainingHandshakeTimeoutMs(deadline);
|
|
369
|
+
return Session.create(path, secure, {
|
|
370
|
+
...(options.yamuxLimits === undefined ? {} : { yamuxLimits: options.yamuxLimits }),
|
|
371
|
+
...(endpointInstanceId === undefined ? {} : { endpointInstanceId }),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
secure.close();
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
279
378
|
}
|
|
280
379
|
catch (error) {
|
|
281
380
|
transport.close();
|
|
282
381
|
if (error instanceof FlowersecError)
|
|
283
382
|
throw error;
|
|
383
|
+
const interrupted = endpointHandshakeInterruption(path, error);
|
|
384
|
+
if (interrupted != null)
|
|
385
|
+
throw interrupted;
|
|
284
386
|
throw new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message: "endpoint handshake failed", cause: error });
|
|
285
387
|
}
|
|
286
388
|
finally {
|
|
@@ -352,22 +454,25 @@ function unwrapServerGrant(input) {
|
|
|
352
454
|
function webSocketTransportOptions(options) {
|
|
353
455
|
return options.webSocketLimits === undefined ? {} : { webSocketLimits: options.webSocketLimits };
|
|
354
456
|
}
|
|
355
|
-
function readOptions(options) {
|
|
356
|
-
return {
|
|
357
|
-
timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
|
|
358
|
-
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
457
|
async function enforceIncomingDirectTransport(options) {
|
|
362
458
|
const rawUrl = options.secureTransport === false ? "ws://127.0.0.1/" : "wss://127.0.0.1/";
|
|
363
459
|
await enforceTransportSecurity({ rawUrl, path: "direct", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
|
|
364
460
|
}
|
|
365
461
|
function waitForOpen(websocket, timeoutMs, signal) {
|
|
366
|
-
if (websocket.readyState === 1)
|
|
367
|
-
return Promise.resolve();
|
|
368
462
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
369
463
|
return Promise.reject(new RangeError("connectTimeoutMs must be non-negative"));
|
|
464
|
+
try {
|
|
465
|
+
throwIfAborted(signal, "connect aborted");
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
return Promise.reject(error);
|
|
469
|
+
}
|
|
470
|
+
if (websocket.readyState === 1)
|
|
471
|
+
return Promise.resolve();
|
|
472
|
+
if (websocket.readyState >= 2)
|
|
473
|
+
return Promise.reject(new Error("websocket closed before open"));
|
|
370
474
|
return new Promise((resolve, reject) => {
|
|
475
|
+
let settled = false;
|
|
371
476
|
let timer;
|
|
372
477
|
const cleanup = () => {
|
|
373
478
|
if (timer != null)
|
|
@@ -378,6 +483,9 @@ function waitForOpen(websocket, timeoutMs, signal) {
|
|
|
378
483
|
signal?.removeEventListener("abort", onAbort);
|
|
379
484
|
};
|
|
380
485
|
const finish = (error) => {
|
|
486
|
+
if (settled)
|
|
487
|
+
return;
|
|
488
|
+
settled = true;
|
|
381
489
|
cleanup();
|
|
382
490
|
if (error == null)
|
|
383
491
|
resolve();
|
|
@@ -392,14 +500,58 @@ function waitForOpen(websocket, timeoutMs, signal) {
|
|
|
392
500
|
websocket.addEventListener("error", onError);
|
|
393
501
|
websocket.addEventListener("close", onClose);
|
|
394
502
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
395
|
-
if (
|
|
396
|
-
|
|
503
|
+
if (signal?.aborted) {
|
|
504
|
+
onAbort();
|
|
505
|
+
}
|
|
506
|
+
else if (websocket.readyState === 1) {
|
|
507
|
+
onOpen();
|
|
508
|
+
}
|
|
509
|
+
else if (websocket.readyState >= 2) {
|
|
510
|
+
onClose();
|
|
511
|
+
}
|
|
512
|
+
if (!settled && timeoutMs > 0)
|
|
513
|
+
timer = setTimeout(() => finish(new TimeoutError("websocket open timeout")), timeoutMs);
|
|
397
514
|
});
|
|
398
515
|
}
|
|
399
516
|
function cleanupWaiter(waiter) {
|
|
400
517
|
if (waiter.onAbort != null)
|
|
401
518
|
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
402
519
|
}
|
|
520
|
+
function createHandshakeDeadline(timeoutMs) {
|
|
521
|
+
return timeoutMs > 0 ? { expiresAtMs: Date.now() + timeoutMs } : {};
|
|
522
|
+
}
|
|
523
|
+
function remainingHandshakeTimeoutMs(deadline) {
|
|
524
|
+
if (deadline.expiresAtMs === undefined)
|
|
525
|
+
return 0;
|
|
526
|
+
const remaining = deadline.expiresAtMs - Date.now();
|
|
527
|
+
if (remaining <= 0)
|
|
528
|
+
throw new TimeoutError("handshake timeout");
|
|
529
|
+
return remaining;
|
|
530
|
+
}
|
|
531
|
+
async function runHandshakeStep(deadline, signal, operation) {
|
|
532
|
+
throwIfAborted(signal, "handshake canceled");
|
|
533
|
+
const timeoutMs = remainingHandshakeTimeoutMs(deadline);
|
|
534
|
+
const result = await withAbortAndTimeout(Promise.resolve().then(operation), {
|
|
535
|
+
timeoutMs,
|
|
536
|
+
...(signal === undefined ? {} : { signal }),
|
|
537
|
+
});
|
|
538
|
+
throwIfAborted(signal, "handshake canceled");
|
|
539
|
+
remainingHandshakeTimeoutMs(deadline);
|
|
540
|
+
return result;
|
|
541
|
+
}
|
|
542
|
+
function endpointHandshakeInterruption(path, error) {
|
|
543
|
+
if (error instanceof TimeoutError) {
|
|
544
|
+
return new FlowersecError({ path, stage: "handshake", code: "timeout", message: "endpoint handshake timed out", cause: error });
|
|
545
|
+
}
|
|
546
|
+
if (error instanceof AbortError) {
|
|
547
|
+
return new FlowersecError({ path, stage: "handshake", code: "canceled", message: "endpoint handshake canceled", cause: error });
|
|
548
|
+
}
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
function endpointHandshakeError(path, error, message) {
|
|
552
|
+
return endpointHandshakeInterruption(path, error)
|
|
553
|
+
?? new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message, cause: error });
|
|
554
|
+
}
|
|
403
555
|
function asError(error) {
|
|
404
556
|
return error instanceof Error ? error : new Error(String(error));
|
|
405
557
|
}
|
package/dist/facade.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type { WebSocketLimits } from "./ws-client/binaryTransport.js";
|
|
|
17
17
|
export type { YamuxLimits } from "./yamux/session.js";
|
|
18
18
|
export type { FlowersecErrorCode, FlowersecPath, FlowersecStage } from "./utils/errors.js";
|
|
19
19
|
export { FlowersecError } from "./utils/errors.js";
|
|
20
|
-
export {
|
|
20
|
+
export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "./client-connect/transportSecurity.js";
|
|
21
21
|
export type { NetworkPlaintextPolicyOptions, TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, } from "./client-connect/transportSecurity.js";
|
|
22
22
|
export type { TunnelConnectOptions } from "./tunnel-client/connect.js";
|
|
23
23
|
export type { DirectConnectOptions } from "./direct-client/connect.js";
|
package/dist/facade.js
CHANGED
|
@@ -6,7 +6,7 @@ export { assertChannelInitGrant } from "./gen/flowersec/controlplane/v1.gen.js";
|
|
|
6
6
|
export { assertDirectConnectInfo } from "./gen/flowersec/direct/v1.gen.js";
|
|
7
7
|
export { assertConnectArtifact } from "./connect/artifact.js";
|
|
8
8
|
export { FlowersecError } from "./utils/errors.js";
|
|
9
|
-
export {
|
|
9
|
+
export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "./client-connect/transportSecurity.js";
|
|
10
10
|
export async function connectTunnel(grant, opts) {
|
|
11
11
|
return await connectTunnelInternal(grant, opts);
|
|
12
12
|
}
|
package/dist/node/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createNodeWsFactory } from "./wsFactory.js";
|
|
2
|
-
export {
|
|
2
|
+
export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
3
3
|
export type { TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, NetworkPlaintextPolicyOptions, } from "../client-connect/transportSecurity.js";
|
|
4
4
|
export { connectDirectNode, connectNode, connectTunnelNode } from "./connect.js";
|
|
5
5
|
export type { DirectNodeReconnectConfig, NodeReconnectConfig, TunnelNodeReconnectConfig, } from "./reconnectConfig.js";
|
package/dist/node/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createNodeWsFactory } from "./wsFactory.js";
|
|
2
|
-
export {
|
|
2
|
+
export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
3
3
|
export { connectDirectNode, connectNode, connectTunnelNode } from "./connect.js";
|
|
4
4
|
export { createDirectNodeReconnectConfig, createNodeReconnectConfig, createTunnelNodeReconnectConfig, } from "./reconnectConfig.js";
|
|
5
5
|
export * from "../endpoint/index.js";
|