@floegence/flowersec-core 0.19.11 → 0.20.1
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 +7 -7
- package/dist/browser/reconnectConfig.d.ts +8 -36
- package/dist/browser/reconnectConfig.js +13 -70
- package/dist/client-connect/connectCore.d.ts +14 -5
- package/dist/client-connect/connectCore.js +130 -38
- package/dist/client-connect/transportSecurity.js +13 -15
- package/dist/client.d.ts +2 -0
- package/dist/e2ee/handshake.d.ts +4 -0
- package/dist/e2ee/handshake.js +2 -0
- package/dist/e2ee/secureChannel.d.ts +4 -0
- package/dist/e2ee/secureChannel.js +18 -9
- package/dist/facade.d.ts +3 -0
- package/dist/node/reconnectConfig.d.ts +8 -34
- package/dist/node/reconnectConfig.js +13 -71
- package/dist/node/wsFactory.js +3 -0
- package/dist/observability/observer.d.ts +5 -2
- package/dist/observability/observer.js +3 -0
- package/dist/proxy/integration.d.ts +2 -0
- package/dist/proxy/integration.js +6 -0
- package/dist/proxy/runtime.d.ts +4 -0
- package/dist/proxy/runtime.js +145 -3
- package/dist/proxy/runtimeScope.d.ts +4 -0
- package/dist/reconnect/artifactControlplane.d.ts +9 -6
- package/dist/reconnect/artifactControlplane.js +28 -27
- package/dist/reconnect/index.d.ts +2 -0
- package/dist/reconnect/index.js +7 -0
- package/dist/rpc/server.d.ts +29 -4
- package/dist/rpc/server.js +142 -34
- package/dist/tunnel-client/connect.js +6 -6
- package/dist/utils/errors.d.ts +1 -1
- package/dist/ws-client/binaryTransport.d.ts +20 -5
- package/dist/ws-client/binaryTransport.js +110 -8
- package/dist/yamux/byteReader.d.ts +1 -0
- package/dist/yamux/byteReader.js +26 -0
- package/dist/yamux/errors.d.ts +7 -0
- package/dist/yamux/errors.js +15 -0
- package/dist/yamux/session.d.ts +31 -1
- package/dist/yamux/session.js +191 -12
- package/dist/yamux/stream.d.ts +10 -3
- package/dist/yamux/stream.js +61 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,8 +30,9 @@ client.close();
|
|
|
30
30
|
Node.js:
|
|
31
31
|
|
|
32
32
|
```ts
|
|
33
|
-
import { connectNode, createNodeReconnectConfig
|
|
33
|
+
import { connectNode, createNodeReconnectConfig } from "@floegence/flowersec-core/node";
|
|
34
34
|
import { requestConnectArtifact } from "@floegence/flowersec-core/controlplane";
|
|
35
|
+
import { createControlplaneArtifactSource } from "@floegence/flowersec-core/reconnect";
|
|
35
36
|
|
|
36
37
|
const artifact = await requestConnectArtifact({
|
|
37
38
|
baseUrl: "https://your-app.example/api/flowersec",
|
|
@@ -40,26 +41,25 @@ const artifact = await requestConnectArtifact({
|
|
|
40
41
|
|
|
41
42
|
const client = await connectNode(artifact, {
|
|
42
43
|
origin: "https://your-app.example",
|
|
43
|
-
transportSecurityPolicy: RequireTLS,
|
|
44
44
|
});
|
|
45
45
|
await client.ping();
|
|
46
|
+
const rttMs = await client.probeLiveness();
|
|
46
47
|
client.close();
|
|
47
48
|
|
|
48
49
|
const reconnectConfig = createNodeReconnectConfig({
|
|
49
|
-
|
|
50
|
+
source: createControlplaneArtifactSource({
|
|
50
51
|
baseUrl: "https://your-app.example/api/flowersec",
|
|
51
52
|
endpointId: "env_demo",
|
|
52
|
-
},
|
|
53
|
+
}),
|
|
53
54
|
connect: {
|
|
54
55
|
origin: "https://your-app.example",
|
|
55
|
-
transportSecurityPolicy: RequireTLS,
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
Browser `requestConnectArtifact(...)`, `requestEntryConnectArtifact(...)`, and `ControlplaneRequestError` remain available from `@floegence/flowersec-core/browser` as stable aliases.
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
High-level connects use `RequireTLS` by default. `AllowPlaintextForLoopback` permits only literal loopback
|
|
63
63
|
targets without DNS resolution; `AllowPlaintext` is an explicit acceptance of pre-E2EE metadata exposure.
|
|
64
64
|
|
|
65
65
|
## Docs
|
|
@@ -69,4 +69,4 @@ targets without DNS resolution; `AllowPlaintext` is an explicit acceptance of pr
|
|
|
69
69
|
- API surface contract: `docs/API_SURFACE.md`
|
|
70
70
|
- Controlplane artifact fetch: `docs/CONTROLPLANE_ARTIFACT_FETCH.md`
|
|
71
71
|
- Error model: `docs/ERROR_MODEL.md`
|
|
72
|
-
- Migration guide: `docs/
|
|
72
|
+
- Migration guide: `docs/V0_20_MIGRATION.md`
|
|
@@ -1,43 +1,15 @@
|
|
|
1
|
-
import type { ConnectArtifact } from "../connect/artifact.js";
|
|
2
|
-
import type { ChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
|
|
3
|
-
import type { DirectConnectInfo } from "../gen/flowersec/direct/v1.gen.js";
|
|
4
1
|
import type { ClientObserverLike } from "../observability/observer.js";
|
|
5
2
|
import type { AutoReconnectConfig, ConnectConfig as ReconnectConnectConfig } from "../reconnect/index.js";
|
|
6
|
-
import { type
|
|
7
|
-
import type { RequestConnectArtifactInput, RequestEntryConnectArtifactInput } from "../controlplane/index.js";
|
|
3
|
+
import { type ArtifactSource } from "../reconnect/artifactControlplane.js";
|
|
8
4
|
import type { DirectConnectBrowserOptions, TunnelConnectBrowserOptions } from "./connect.js";
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
export type BrowserReconnectConfig = Readonly<{
|
|
6
|
+
source: ArtifactSource;
|
|
7
|
+
connect?: Omit<TunnelConnectBrowserOptions, "observer" | "signal"> | Omit<DirectConnectBrowserOptions, "observer" | "signal">;
|
|
11
8
|
observer?: ClientObserverLike;
|
|
12
9
|
autoReconnect?: AutoReconnectConfig;
|
|
13
10
|
}>;
|
|
14
|
-
type
|
|
15
|
-
type
|
|
16
|
-
type ArtifactAwareTunnelReconnectConfig = ArtifactAwareReconnectConfig & Readonly<{
|
|
17
|
-
artifact?: ConnectArtifact;
|
|
18
|
-
getArtifact?: (args: ArtifactFactoryArgs) => Promise<ConnectArtifact>;
|
|
19
|
-
artifactControlplane?: RequestConnectArtifactInput | RequestEntryConnectArtifactInput;
|
|
20
|
-
}>;
|
|
21
|
-
type ArtifactAwareDirectReconnectConfig = ArtifactAwareReconnectConfig & Readonly<{
|
|
22
|
-
artifact?: ConnectArtifact;
|
|
23
|
-
getArtifact?: (args: ArtifactFactoryArgs) => Promise<ConnectArtifact>;
|
|
24
|
-
artifactControlplane?: RequestConnectArtifactInput | RequestEntryConnectArtifactInput;
|
|
25
|
-
}>;
|
|
26
|
-
export type TunnelBrowserReconnectConfig = SharedReconnectOptions & ArtifactAwareTunnelReconnectConfig & Readonly<{
|
|
27
|
-
mode?: "tunnel";
|
|
28
|
-
connect?: TunnelReconnectConnectOptions;
|
|
29
|
-
grant?: ChannelInitGrant;
|
|
30
|
-
getGrant?: () => Promise<ChannelInitGrant>;
|
|
31
|
-
controlplane?: ControlplaneConfig;
|
|
32
|
-
}>;
|
|
33
|
-
export type DirectBrowserReconnectConfig = SharedReconnectOptions & ArtifactAwareDirectReconnectConfig & Readonly<{
|
|
34
|
-
mode: "direct";
|
|
35
|
-
connect?: DirectReconnectConnectOptions;
|
|
36
|
-
directInfo?: DirectConnectInfo;
|
|
37
|
-
getDirectInfo?: () => Promise<DirectConnectInfo>;
|
|
38
|
-
}>;
|
|
39
|
-
export type BrowserReconnectConfig = TunnelBrowserReconnectConfig | DirectBrowserReconnectConfig;
|
|
40
|
-
export declare function createTunnelBrowserReconnectConfig(config: TunnelBrowserReconnectConfig): ReconnectConnectConfig;
|
|
41
|
-
export declare function createDirectBrowserReconnectConfig(config: DirectBrowserReconnectConfig): ReconnectConnectConfig;
|
|
11
|
+
export type TunnelBrowserReconnectConfig = BrowserReconnectConfig;
|
|
12
|
+
export type DirectBrowserReconnectConfig = BrowserReconnectConfig;
|
|
42
13
|
export declare function createBrowserReconnectConfig(config: BrowserReconnectConfig): ReconnectConnectConfig;
|
|
43
|
-
export
|
|
14
|
+
export declare const createTunnelBrowserReconnectConfig: typeof createBrowserReconnectConfig;
|
|
15
|
+
export declare const createDirectBrowserReconnectConfig: typeof createBrowserReconnectConfig;
|
|
@@ -1,77 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { connectBrowser
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if (config.controlplane)
|
|
10
|
-
return await requestChannelGrant(config.controlplane);
|
|
11
|
-
throw new Error("Tunnel reconnect config requires `getGrant`, `grant`, or `controlplane`");
|
|
12
|
-
}
|
|
13
|
-
async function resolveDirectInfo(config) {
|
|
14
|
-
if (config.getDirectInfo)
|
|
15
|
-
return await config.getDirectInfo();
|
|
16
|
-
if (config.directInfo)
|
|
17
|
-
return config.directInfo;
|
|
18
|
-
throw new Error("Direct reconnect config requires `getDirectInfo` or `directInfo`");
|
|
19
|
-
}
|
|
20
|
-
export function createTunnelBrowserReconnectConfig(config) {
|
|
21
|
-
let traceId = config.artifact?.correlation?.trace_id;
|
|
22
|
-
return {
|
|
23
|
-
...(config.observer === undefined ? {} : { observer: config.observer }),
|
|
24
|
-
...(config.autoReconnect === undefined ? {} : { autoReconnect: config.autoReconnect }),
|
|
25
|
-
connectOnce: async ({ signal, observer }) => {
|
|
26
|
-
if (config.getArtifact || config.artifact || config.artifactControlplane) {
|
|
27
|
-
const artifact = await resolveConnectArtifact(config, traceId, signal);
|
|
28
|
-
if (artifact.transport !== "tunnel") {
|
|
29
|
-
throw new Error("Tunnel reconnect config requires a tunnel ConnectArtifact");
|
|
30
|
-
}
|
|
31
|
-
traceId = updateTraceId(traceId, artifact);
|
|
32
|
-
return await connectBrowser(artifact, {
|
|
33
|
-
...(config.connect ?? {}),
|
|
34
|
-
signal,
|
|
35
|
-
observer,
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
return await connectTunnelBrowser(await resolveTunnelGrant(config), {
|
|
39
|
-
...(config.connect ?? {}),
|
|
40
|
-
signal,
|
|
41
|
-
observer,
|
|
42
|
-
});
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
export function createDirectBrowserReconnectConfig(config) {
|
|
47
|
-
let traceId = config.artifact?.correlation?.trace_id;
|
|
1
|
+
import { createArtifactResolver, updateTraceId } from "../reconnect/artifactControlplane.js";
|
|
2
|
+
import { connectBrowser } from "./connect.js";
|
|
3
|
+
export function createBrowserReconnectConfig(config) {
|
|
4
|
+
if (config.source.kind === "once" && config.autoReconnect?.enabled) {
|
|
5
|
+
throw new Error("automatic reconnect requires a refreshable artifact source");
|
|
6
|
+
}
|
|
7
|
+
let traceId = config.source.kind === "once" ? config.source.artifact.correlation?.trace_id : undefined;
|
|
8
|
+
const acquire = createArtifactResolver(config.source);
|
|
48
9
|
return {
|
|
49
10
|
...(config.observer === undefined ? {} : { observer: config.observer }),
|
|
50
11
|
...(config.autoReconnect === undefined ? {} : { autoReconnect: config.autoReconnect }),
|
|
51
12
|
connectOnce: async ({ signal, observer }) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
throw new Error("Direct reconnect config requires a direct ConnectArtifact");
|
|
56
|
-
}
|
|
57
|
-
traceId = updateTraceId(traceId, artifact);
|
|
58
|
-
return await connectBrowser(artifact, {
|
|
59
|
-
...(config.connect ?? {}),
|
|
60
|
-
signal,
|
|
61
|
-
observer,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
return await connectDirectBrowser(await resolveDirectInfo(config), {
|
|
65
|
-
...(config.connect ?? {}),
|
|
66
|
-
signal,
|
|
67
|
-
observer,
|
|
68
|
-
});
|
|
13
|
+
const artifact = await acquire({ ...(traceId === undefined ? {} : { traceId }), signal });
|
|
14
|
+
traceId = updateTraceId(traceId, artifact);
|
|
15
|
+
return await connectBrowser(artifact, { ...(config.connect ?? {}), signal, observer });
|
|
69
16
|
},
|
|
70
17
|
};
|
|
71
18
|
}
|
|
72
|
-
export
|
|
73
|
-
|
|
74
|
-
return createDirectBrowserReconnectConfig(config);
|
|
75
|
-
}
|
|
76
|
-
return createTunnelBrowserReconnectConfig(config);
|
|
77
|
-
}
|
|
19
|
+
export const createTunnelBrowserReconnectConfig = createBrowserReconnectConfig;
|
|
20
|
+
export const createDirectBrowserReconnectConfig = createBrowserReconnectConfig;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { type YamuxLimits } from "../yamux/session.js";
|
|
1
2
|
import { type ClientObserverLike } from "../observability/observer.js";
|
|
2
|
-
import { type WebSocketLike } from "../ws-client/binaryTransport.js";
|
|
3
|
+
import { type WebSocketLike, type WebSocketLimits } from "../ws-client/binaryTransport.js";
|
|
3
4
|
import type { ClientInternal } from "../client.js";
|
|
4
5
|
import type { ConnectScopeResolverMap } from "../connect/internalNormalize.js";
|
|
5
6
|
import { type TransportSecurityPolicy } from "./transportSecurity.js";
|
|
7
|
+
export type LivenessOptions = Readonly<{
|
|
8
|
+
intervalMs?: number;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}>;
|
|
6
11
|
export type ConnectOptionsBase = Readonly<{
|
|
7
12
|
/** Explicit Origin value (required). In browsers this must match window.location.origin. */
|
|
8
13
|
origin: string;
|
|
@@ -20,8 +25,12 @@ export type ConnectOptionsBase = Readonly<{
|
|
|
20
25
|
maxRecordBytes?: number;
|
|
21
26
|
/** Maximum buffered plaintext bytes in the secure channel (0 uses default). */
|
|
22
27
|
maxBufferedBytes?: number;
|
|
23
|
-
/**
|
|
24
|
-
|
|
28
|
+
/** Preferred plaintext bytes per outbound encrypted record (default 64 KiB). */
|
|
29
|
+
outboundRecordChunkBytes?: number;
|
|
30
|
+
/** WebSocket inbound and outbound queue limits. */
|
|
31
|
+
webSocketLimits?: Partial<WebSocketLimits>;
|
|
32
|
+
/** Yamux stream, frame, and receive-memory limits. */
|
|
33
|
+
yamuxLimits?: Partial<YamuxLimits>;
|
|
25
34
|
/** Optional factory for creating the WebSocket instance. */
|
|
26
35
|
wsFactory?: (url: string, origin: string) => WebSocketLike;
|
|
27
36
|
/** Optional observer for client metrics. */
|
|
@@ -32,8 +41,8 @@ export type ConnectOptionsBase = Readonly<{
|
|
|
32
41
|
scopeResolvers?: ConnectScopeResolverMap;
|
|
33
42
|
/** Experimental migration switch for optional scope failures. */
|
|
34
43
|
relaxedOptionalScopeValidation?: boolean;
|
|
35
|
-
/**
|
|
36
|
-
|
|
44
|
+
/** Acknowledged Yamux liveness checks, or false to disable automatic checks. */
|
|
45
|
+
liveness?: false | LivenessOptions;
|
|
37
46
|
}>;
|
|
38
47
|
export type ConnectCoreArgs = Readonly<{
|
|
39
48
|
path: "tunnel" | "direct";
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { clientHandshake } from "../e2ee/handshake.js";
|
|
2
2
|
import { ByteReader } from "../yamux/byteReader.js";
|
|
3
3
|
import { YamuxSession } from "../yamux/session.js";
|
|
4
|
+
import { DEFAULT_YAMUX_LIMITS } from "../yamux/session.js";
|
|
4
5
|
import { RpcClient } from "../rpc/client.js";
|
|
5
6
|
import { writeStreamHello } from "../streamhello/streamHello.js";
|
|
6
|
-
import { normalizeObserver, nowSeconds } from "../observability/observer.js";
|
|
7
|
+
import { emitObserverDiagnostic, normalizeObserver, nowSeconds } from "../observability/observer.js";
|
|
7
8
|
import { base64urlDecode } from "../utils/base64url.js";
|
|
8
9
|
import { AbortError, FlowersecError, throwIfAborted } from "../utils/errors.js";
|
|
9
|
-
import { WebSocketBinaryTransport, WsCloseError } from "../ws-client/binaryTransport.js";
|
|
10
|
+
import { DEFAULT_WEB_SOCKET_LIMITS, WebSocketBinaryTransport, WsCloseError } from "../ws-client/binaryTransport.js";
|
|
10
11
|
import { OriginMismatchError, WsFactoryRequiredError, classifyConnectError, classifyHandshakeError, createWebSocket, waitOpen, withAbortAndTimeout, } from "./common.js";
|
|
11
12
|
import { prepareChannelId } from "./contract.js";
|
|
12
13
|
import { isTunnelAttachCloseReason } from "./tunnelAttachCloseReason.js";
|
|
13
14
|
import { enforceTransportSecurity } from "./transportSecurity.js";
|
|
15
|
+
import { maxPlaintextBytes } from "../e2ee/record.js";
|
|
16
|
+
import { isYamuxResourceExhaustedError } from "../yamux/errors.js";
|
|
14
17
|
export async function connectCore(args) {
|
|
15
18
|
const observer = normalizeObserver(args.opts.observer, { path: args.path });
|
|
16
19
|
const signal = args.opts.signal;
|
|
@@ -32,6 +35,9 @@ export async function connectCore(args) {
|
|
|
32
35
|
if (origin === "") {
|
|
33
36
|
throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_origin", message: "missing origin" });
|
|
34
37
|
}
|
|
38
|
+
const invalidOption = (message) => {
|
|
39
|
+
throw new FlowersecError({ path: args.path, stage: "validate", code: "invalid_option", message });
|
|
40
|
+
};
|
|
35
41
|
if (args.path === "tunnel" && args.attach == null) {
|
|
36
42
|
throw new FlowersecError({
|
|
37
43
|
path: args.path,
|
|
@@ -45,15 +51,6 @@ export async function connectCore(args) {
|
|
|
45
51
|
const code = args.path === "tunnel" ? "missing_tunnel_url" : "missing_ws_url";
|
|
46
52
|
throw new FlowersecError({ path: args.path, stage: "validate", code, message: "missing websocket url" });
|
|
47
53
|
}
|
|
48
|
-
await enforceTransportSecurity({
|
|
49
|
-
rawUrl: wsUrl,
|
|
50
|
-
path: args.path,
|
|
51
|
-
...(args.opts.transportSecurityPolicy === undefined ? {} : { policy: args.opts.transportSecurityPolicy }),
|
|
52
|
-
...(args.opts.observer === undefined ? {} : { observer: args.opts.observer }),
|
|
53
|
-
});
|
|
54
|
-
const invalidOption = (message) => {
|
|
55
|
-
throw new FlowersecError({ path: args.path, stage: "validate", code: "invalid_option", message });
|
|
56
|
-
};
|
|
57
54
|
const connectTimeoutMs = args.opts.connectTimeoutMs ?? 10_000;
|
|
58
55
|
if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
|
|
59
56
|
invalidOption("connectTimeoutMs must be a non-negative number");
|
|
@@ -62,10 +59,6 @@ export async function connectCore(args) {
|
|
|
62
59
|
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
63
60
|
invalidOption("handshakeTimeoutMs must be a non-negative number");
|
|
64
61
|
}
|
|
65
|
-
const keepaliveIntervalMs = args.opts.keepaliveIntervalMs ?? 0;
|
|
66
|
-
if (!Number.isFinite(keepaliveIntervalMs) || keepaliveIntervalMs < 0) {
|
|
67
|
-
invalidOption("keepaliveIntervalMs must be a non-negative number");
|
|
68
|
-
}
|
|
69
62
|
const clientFeatures = args.opts.clientFeatures ?? 0;
|
|
70
63
|
if (!Number.isSafeInteger(clientFeatures) || clientFeatures < 0 || clientFeatures > 0xffffffff) {
|
|
71
64
|
invalidOption("clientFeatures must be a uint32");
|
|
@@ -82,10 +75,22 @@ export async function connectCore(args) {
|
|
|
82
75
|
if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes < 0) {
|
|
83
76
|
invalidOption("maxBufferedBytes must be a non-negative integer");
|
|
84
77
|
}
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
const effectiveMaxRecordBytes = maxRecordBytes > 0 ? maxRecordBytes : (1 << 20);
|
|
79
|
+
const outboundRecordChunkBytes = args.opts.outboundRecordChunkBytes ?? 64 * 1024;
|
|
80
|
+
if (!Number.isSafeInteger(outboundRecordChunkBytes) || outboundRecordChunkBytes <= 0 || outboundRecordChunkBytes > maxPlaintextBytes(effectiveMaxRecordBytes)) {
|
|
81
|
+
invalidOption("outboundRecordChunkBytes must be a positive integer within maxRecordBytes");
|
|
88
82
|
}
|
|
83
|
+
validateLimitObject(args.opts.webSocketLimits, "webSocketLimits", invalidOption);
|
|
84
|
+
validateLimitObject(args.opts.yamuxLimits, "yamuxLimits", invalidOption);
|
|
85
|
+
validateWebSocketLimitRelationships(args.opts.webSocketLimits, invalidOption);
|
|
86
|
+
validateYamuxLimitRelationships(args.opts.yamuxLimits, invalidOption);
|
|
87
|
+
const liveness = normalizeLiveness(args.opts.liveness, invalidOption);
|
|
88
|
+
await enforceTransportSecurity({
|
|
89
|
+
rawUrl: wsUrl,
|
|
90
|
+
path: args.path,
|
|
91
|
+
...(args.opts.transportSecurityPolicy === undefined ? {} : { policy: args.opts.transportSecurityPolicy }),
|
|
92
|
+
...(args.opts.observer === undefined ? {} : { observer: args.opts.observer }),
|
|
93
|
+
});
|
|
89
94
|
const channelId = prepareChannelId(args.channelId, args.path);
|
|
90
95
|
let psk;
|
|
91
96
|
try {
|
|
@@ -118,7 +123,7 @@ export async function connectCore(args) {
|
|
|
118
123
|
// Install close/error/message listeners before waiting for "open" to avoid a gap where a peer close
|
|
119
124
|
// (for example a tunnel attach rejection with a reason token) can be missed and misclassified as a handshake timeout.
|
|
120
125
|
const transport = new WebSocketBinaryTransport(ws, {
|
|
121
|
-
...(
|
|
126
|
+
...(args.opts.webSocketLimits === undefined ? {} : { webSocketLimits: args.opts.webSocketLimits }),
|
|
122
127
|
observer,
|
|
123
128
|
});
|
|
124
129
|
try {
|
|
@@ -174,7 +179,8 @@ export async function connectCore(args) {
|
|
|
174
179
|
psk,
|
|
175
180
|
clientFeatures,
|
|
176
181
|
maxHandshakePayload: maxHandshakePayload > 0 ? maxHandshakePayload : 8 * 1024,
|
|
177
|
-
maxRecordBytes:
|
|
182
|
+
maxRecordBytes: effectiveMaxRecordBytes,
|
|
183
|
+
outboundRecordChunkBytes,
|
|
178
184
|
...(maxBufferedBytes > 0 ? { maxBufferedBytes } : {}),
|
|
179
185
|
timeoutMs: handshakeTimeoutMs,
|
|
180
186
|
...(signal !== undefined ? { signal } : {}),
|
|
@@ -214,7 +220,20 @@ export async function connectCore(args) {
|
|
|
214
220
|
write: (b) => secure.write(b),
|
|
215
221
|
close: () => secure.close(),
|
|
216
222
|
};
|
|
217
|
-
const mux = new YamuxSession(conn, {
|
|
223
|
+
const mux = new YamuxSession(conn, {
|
|
224
|
+
client: true,
|
|
225
|
+
...(args.opts.yamuxLimits === undefined ? {} : { limits: args.opts.yamuxLimits }),
|
|
226
|
+
onDiagnostic: (event) => emitObserverDiagnostic(args.opts.observer, {
|
|
227
|
+
path: args.path,
|
|
228
|
+
stage: "yamux",
|
|
229
|
+
code_domain: "event",
|
|
230
|
+
code: event.code,
|
|
231
|
+
result: "fail",
|
|
232
|
+
resource: event.resource,
|
|
233
|
+
current: event.current,
|
|
234
|
+
limit: event.limit,
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
218
237
|
let rpcStream;
|
|
219
238
|
try {
|
|
220
239
|
rpcStream = await mux.openStream();
|
|
@@ -256,16 +275,39 @@ export async function connectCore(args) {
|
|
|
256
275
|
throw new FlowersecError({ path: args.path, stage: "secure", code: "ping_failed", message: "ping failed", cause: e });
|
|
257
276
|
}
|
|
258
277
|
};
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
const probeLiveness = async () => {
|
|
279
|
+
try {
|
|
280
|
+
return await mux.probeLiveness(liveness.timeoutMs);
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
if (String(e?.message).includes("ping timeout")) {
|
|
284
|
+
emitObserverDiagnostic(args.opts.observer, { path: args.path, stage: "yamux", code_domain: "event", code: "liveness_timeout", result: "fail" });
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
rpc.close();
|
|
288
|
+
}
|
|
289
|
+
catch { /* ignore */ }
|
|
290
|
+
try {
|
|
291
|
+
mux.close();
|
|
292
|
+
}
|
|
293
|
+
catch { /* ignore */ }
|
|
294
|
+
try {
|
|
295
|
+
secure.close();
|
|
296
|
+
}
|
|
297
|
+
catch { /* ignore */ }
|
|
298
|
+
throw new FlowersecError({ path: args.path, stage: "yamux", code: "ping_failed", message: "liveness probe failed", cause: e });
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
let livenessTimer;
|
|
302
|
+
let livenessInFlight = false;
|
|
303
|
+
const stopLiveness = () => {
|
|
304
|
+
if (livenessTimer === undefined)
|
|
263
305
|
return;
|
|
264
|
-
clearInterval(
|
|
265
|
-
|
|
306
|
+
clearInterval(livenessTimer);
|
|
307
|
+
livenessTimer = undefined;
|
|
266
308
|
};
|
|
267
309
|
const closeAll = () => {
|
|
268
|
-
|
|
310
|
+
stopLiveness();
|
|
269
311
|
try {
|
|
270
312
|
rpc.close();
|
|
271
313
|
}
|
|
@@ -285,20 +327,20 @@ export async function connectCore(args) {
|
|
|
285
327
|
// ignore
|
|
286
328
|
}
|
|
287
329
|
};
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
if (
|
|
330
|
+
if (liveness.intervalMs > 0) {
|
|
331
|
+
livenessTimer = setInterval(() => {
|
|
332
|
+
if (livenessInFlight)
|
|
291
333
|
return;
|
|
292
|
-
|
|
293
|
-
|
|
334
|
+
livenessInFlight = true;
|
|
335
|
+
probeLiveness()
|
|
294
336
|
.catch(() => {
|
|
295
|
-
|
|
337
|
+
stopLiveness();
|
|
296
338
|
})
|
|
297
339
|
.finally(() => {
|
|
298
|
-
|
|
340
|
+
livenessInFlight = false;
|
|
299
341
|
});
|
|
300
|
-
},
|
|
301
|
-
|
|
342
|
+
}, liveness.intervalMs);
|
|
343
|
+
livenessTimer?.unref?.();
|
|
302
344
|
}
|
|
303
345
|
return {
|
|
304
346
|
path: args.path,
|
|
@@ -307,6 +349,7 @@ export async function connectCore(args) {
|
|
|
307
349
|
mux,
|
|
308
350
|
rpc,
|
|
309
351
|
ping,
|
|
352
|
+
probeLiveness,
|
|
310
353
|
openStream: async (kind, opts = {}) => {
|
|
311
354
|
if (kind == null || kind === "")
|
|
312
355
|
throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
|
|
@@ -334,7 +377,8 @@ export async function connectCore(args) {
|
|
|
334
377
|
s = await mux.openStream();
|
|
335
378
|
}
|
|
336
379
|
catch (e) {
|
|
337
|
-
|
|
380
|
+
const exhausted = isYamuxResourceExhaustedError(e);
|
|
381
|
+
throw new FlowersecError({ path: args.path, stage: "yamux", code: exhausted ? "resource_exhausted" : "open_stream_failed", message: exhausted ? "yamux stream limit reached" : "open stream failed", cause: e });
|
|
338
382
|
}
|
|
339
383
|
if (signal != null) {
|
|
340
384
|
abortListener = () => {
|
|
@@ -423,3 +467,51 @@ export async function connectCore(args) {
|
|
|
423
467
|
throw e;
|
|
424
468
|
}
|
|
425
469
|
}
|
|
470
|
+
function validateLimitObject(input, name, invalid) {
|
|
471
|
+
if (input === undefined)
|
|
472
|
+
return;
|
|
473
|
+
for (const [key, value] of Object.entries(input)) {
|
|
474
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0))
|
|
475
|
+
invalid(`${name}.${key} must be a positive integer`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function normalizeLiveness(input, invalid) {
|
|
479
|
+
if (input === false || input === undefined)
|
|
480
|
+
return { intervalMs: 0, timeoutMs: 10_000 };
|
|
481
|
+
const intervalMs = input.intervalMs ?? 0;
|
|
482
|
+
const timeoutMs = input.timeoutMs ?? (intervalMs > 0 ? Math.min(10_000, intervalMs) : 10_000);
|
|
483
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 0)
|
|
484
|
+
invalid("liveness.intervalMs must be a non-negative number");
|
|
485
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
486
|
+
invalid("liveness.timeoutMs must be a positive number");
|
|
487
|
+
return { intervalMs, timeoutMs };
|
|
488
|
+
}
|
|
489
|
+
function validateWebSocketLimitRelationships(input, invalid) {
|
|
490
|
+
if (input === undefined)
|
|
491
|
+
return;
|
|
492
|
+
const low = input.outboundLowWatermarkBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundLowWatermarkBytes;
|
|
493
|
+
const high = input.outboundHighWatermarkBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundHighWatermarkBytes;
|
|
494
|
+
const hard = input.outboundHardLimitBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundHardLimitBytes;
|
|
495
|
+
if (low > high || high > hard)
|
|
496
|
+
invalid("webSocketLimits outbound watermarks must satisfy low <= high <= hard");
|
|
497
|
+
}
|
|
498
|
+
function validateYamuxLimitRelationships(input, invalid) {
|
|
499
|
+
if (input === undefined)
|
|
500
|
+
return;
|
|
501
|
+
const active = input.maxActiveStreams ?? DEFAULT_YAMUX_LIMITS.maxActiveStreams;
|
|
502
|
+
const inbound = input.maxInboundStreams ?? DEFAULT_YAMUX_LIMITS.maxInboundStreams;
|
|
503
|
+
const frame = input.maxFrameBytes ?? DEFAULT_YAMUX_LIMITS.maxFrameBytes;
|
|
504
|
+
const outbound = input.preferredOutboundFrameBytes ?? Math.min(DEFAULT_YAMUX_LIMITS.preferredOutboundFrameBytes, frame);
|
|
505
|
+
const streamReceive = input.maxStreamReceiveBytes ?? DEFAULT_YAMUX_LIMITS.maxStreamReceiveBytes;
|
|
506
|
+
const sessionReceive = input.maxSessionReceiveBytes ?? DEFAULT_YAMUX_LIMITS.maxSessionReceiveBytes;
|
|
507
|
+
if (inbound > active)
|
|
508
|
+
invalid("yamuxLimits.maxInboundStreams must not exceed maxActiveStreams");
|
|
509
|
+
if (outbound > frame)
|
|
510
|
+
invalid("yamuxLimits.preferredOutboundFrameBytes must not exceed maxFrameBytes");
|
|
511
|
+
if (frame > streamReceive)
|
|
512
|
+
invalid("yamuxLimits.maxFrameBytes must not exceed maxStreamReceiveBytes");
|
|
513
|
+
if (streamReceive < DEFAULT_YAMUX_LIMITS.maxStreamReceiveBytes)
|
|
514
|
+
invalid("yamuxLimits.maxStreamReceiveBytes must cover the 256 KiB initial stream window");
|
|
515
|
+
if (streamReceive > sessionReceive)
|
|
516
|
+
invalid("yamuxLimits.maxStreamReceiveBytes must not exceed maxSessionReceiveBytes");
|
|
517
|
+
}
|
|
@@ -17,25 +17,14 @@ export async function enforceTransportSecurity(args) {
|
|
|
17
17
|
host: target.host,
|
|
18
18
|
runtime: detectRuntime(),
|
|
19
19
|
};
|
|
20
|
-
if (args.policy === undefined) {
|
|
21
|
-
if (target.scheme === "ws") {
|
|
22
|
-
emitObserverDiagnostic(args.observer, {
|
|
23
|
-
path: args.path,
|
|
24
|
-
stage: "validate",
|
|
25
|
-
code_domain: "event",
|
|
26
|
-
code: "plaintext_transport",
|
|
27
|
-
result: "skip",
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
20
|
let allowed = false;
|
|
21
|
+
const policy = args.policy ?? RequireTLS;
|
|
33
22
|
try {
|
|
34
|
-
if (typeof
|
|
35
|
-
allowed = await
|
|
23
|
+
if (typeof policy === "function") {
|
|
24
|
+
allowed = await policy(input);
|
|
36
25
|
}
|
|
37
26
|
else {
|
|
38
|
-
allowed = evaluatePreset(
|
|
27
|
+
allowed = evaluatePreset(policy, target);
|
|
39
28
|
}
|
|
40
29
|
}
|
|
41
30
|
catch (cause) {
|
|
@@ -43,6 +32,15 @@ export async function enforceTransportSecurity(args) {
|
|
|
43
32
|
}
|
|
44
33
|
if (!allowed)
|
|
45
34
|
throw denied(args.path);
|
|
35
|
+
if (target.scheme === "ws") {
|
|
36
|
+
emitObserverDiagnostic(args.observer, {
|
|
37
|
+
path: args.path,
|
|
38
|
+
stage: "transport",
|
|
39
|
+
code_domain: "event",
|
|
40
|
+
code: "plaintext_transport",
|
|
41
|
+
result: "skip",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
46
44
|
}
|
|
47
45
|
function denied(path, cause) {
|
|
48
46
|
return new FlowersecError({
|
package/dist/client.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type Client = Readonly<{
|
|
|
11
11
|
signal?: AbortSignal;
|
|
12
12
|
}>) => Promise<YamuxStream>;
|
|
13
13
|
ping: () => Promise<void>;
|
|
14
|
+
/** Performs an acknowledged Yamux round trip and returns RTT in milliseconds. */
|
|
15
|
+
probeLiveness: () => Promise<number>;
|
|
14
16
|
close: () => void;
|
|
15
17
|
}>;
|
|
16
18
|
export type ClientInternal = Client & Readonly<{
|
package/dist/e2ee/handshake.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export type HandshakeClientOptions = Readonly<{
|
|
|
14
14
|
maxHandshakePayload: number;
|
|
15
15
|
/** Maximum record size for encrypted frames after handshake. */
|
|
16
16
|
maxRecordBytes: number;
|
|
17
|
+
/** Preferred plaintext bytes per outbound record. */
|
|
18
|
+
outboundRecordChunkBytes?: number;
|
|
17
19
|
/** Maximum buffered plaintext bytes for the secure channel. */
|
|
18
20
|
maxBufferedBytes?: number;
|
|
19
21
|
/** Optional AbortSignal to cancel the handshake. */
|
|
@@ -38,6 +40,8 @@ export type HandshakeServerOptions = Readonly<{
|
|
|
38
40
|
maxHandshakePayload: number;
|
|
39
41
|
/** Maximum record size for encrypted frames after handshake. */
|
|
40
42
|
maxRecordBytes: number;
|
|
43
|
+
/** Preferred plaintext bytes per outbound record. */
|
|
44
|
+
outboundRecordChunkBytes?: number;
|
|
41
45
|
/** Maximum buffered plaintext bytes for the secure channel. */
|
|
42
46
|
maxBufferedBytes?: number;
|
|
43
47
|
/** Optional AbortSignal to cancel the handshake. */
|
package/dist/e2ee/handshake.js
CHANGED
|
@@ -159,6 +159,7 @@ export async function clientHandshake(transport, opts) {
|
|
|
159
159
|
return new SecureChannel({
|
|
160
160
|
transport,
|
|
161
161
|
maxRecordBytes: opts.maxRecordBytes,
|
|
162
|
+
...(opts.outboundRecordChunkBytes !== undefined ? { outboundRecordChunkBytes: opts.outboundRecordChunkBytes } : {}),
|
|
162
163
|
...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
|
|
163
164
|
sendKey: keys.c2sKey,
|
|
164
165
|
recvKey: keys.s2cKey,
|
|
@@ -316,6 +317,7 @@ export async function serverHandshake(transport, cache, opts) {
|
|
|
316
317
|
return new SecureChannel({
|
|
317
318
|
transport,
|
|
318
319
|
maxRecordBytes: opts.maxRecordBytes,
|
|
320
|
+
...(opts.outboundRecordChunkBytes !== undefined ? { outboundRecordChunkBytes: opts.outboundRecordChunkBytes } : {}),
|
|
319
321
|
...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
|
|
320
322
|
sendKey: keys.s2cKey,
|
|
321
323
|
recvKey: keys.c2sKey,
|
|
@@ -19,6 +19,8 @@ export type BinaryTransport = {
|
|
|
19
19
|
export type SecureChannelOptions = Readonly<{
|
|
20
20
|
/** Maximum encoded record size (header + ciphertext). */
|
|
21
21
|
maxRecordBytes: number;
|
|
22
|
+
/** Preferred plaintext bytes per outbound record. */
|
|
23
|
+
outboundRecordChunkBytes?: number;
|
|
22
24
|
/** Maximum queued plaintext bytes before backpressure/errors. */
|
|
23
25
|
maxBufferedBytes?: number;
|
|
24
26
|
}>;
|
|
@@ -26,6 +28,7 @@ type Direction = 1 | 2;
|
|
|
26
28
|
export declare class SecureChannel {
|
|
27
29
|
private readonly transport;
|
|
28
30
|
private readonly maxRecordBytes;
|
|
31
|
+
private readonly outboundRecordChunkBytes;
|
|
29
32
|
private readonly maxBufferedBytes;
|
|
30
33
|
private sendKey;
|
|
31
34
|
private recvKey;
|
|
@@ -52,6 +55,7 @@ export declare class SecureChannel {
|
|
|
52
55
|
constructor(args: {
|
|
53
56
|
transport: BinaryTransport;
|
|
54
57
|
maxRecordBytes: number;
|
|
58
|
+
outboundRecordChunkBytes?: number;
|
|
55
59
|
maxBufferedBytes?: number;
|
|
56
60
|
sendKey: Uint8Array;
|
|
57
61
|
recvKey: Uint8Array;
|