@floegence/flowersec-core 0.19.10 → 0.20.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 +8 -3
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +1 -0
- package/dist/browser/reconnectConfig.d.ts +8 -36
- package/dist/browser/reconnectConfig.js +13 -70
- package/dist/client-connect/connectCore.d.ts +17 -5
- package/dist/client-connect/connectCore.js +131 -32
- package/dist/client-connect/transportSecurity.d.ts +19 -0
- package/dist/client-connect/transportSecurity.js +114 -0
- 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 +5 -0
- package/dist/facade.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +1 -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/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
|
@@ -32,6 +32,7 @@ Node.js:
|
|
|
32
32
|
```ts
|
|
33
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",
|
|
@@ -42,13 +43,14 @@ const client = await connectNode(artifact, {
|
|
|
42
43
|
origin: "https://your-app.example",
|
|
43
44
|
});
|
|
44
45
|
await client.ping();
|
|
46
|
+
const rttMs = await client.probeLiveness();
|
|
45
47
|
client.close();
|
|
46
48
|
|
|
47
49
|
const reconnectConfig = createNodeReconnectConfig({
|
|
48
|
-
|
|
50
|
+
source: createControlplaneArtifactSource({
|
|
49
51
|
baseUrl: "https://your-app.example/api/flowersec",
|
|
50
52
|
endpointId: "env_demo",
|
|
51
|
-
},
|
|
53
|
+
}),
|
|
52
54
|
connect: {
|
|
53
55
|
origin: "https://your-app.example",
|
|
54
56
|
},
|
|
@@ -57,6 +59,9 @@ const reconnectConfig = createNodeReconnectConfig({
|
|
|
57
59
|
|
|
58
60
|
Browser `requestConnectArtifact(...)`, `requestEntryConnectArtifact(...)`, and `ControlplaneRequestError` remain available from `@floegence/flowersec-core/browser` as stable aliases.
|
|
59
61
|
|
|
62
|
+
High-level connects use `RequireTLS` by default. `AllowPlaintextForLoopback` permits only literal loopback
|
|
63
|
+
targets without DNS resolution; `AllowPlaintext` is an explicit acceptance of pre-E2EE metadata exposure.
|
|
64
|
+
|
|
60
65
|
## Docs
|
|
61
66
|
|
|
62
67
|
- Frontend quickstart: `docs/FRONTEND_QUICKSTART.md`
|
|
@@ -64,4 +69,4 @@ Browser `requestConnectArtifact(...)`, `requestEntryConnectArtifact(...)`, and `
|
|
|
64
69
|
- API surface contract: `docs/API_SURFACE.md`
|
|
65
70
|
- Controlplane artifact fetch: `docs/CONTROLPLANE_ARTIFACT_FETCH.md`
|
|
66
71
|
- Error model: `docs/ERROR_MODEL.md`
|
|
67
|
-
- Migration guide: `docs/
|
|
72
|
+
- Migration guide: `docs/V0_20_MIGRATION.md`
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type { ConnectBrowserOptions, DirectConnectBrowserOptions, TunnelConnectBrowserOptions } from "./connect.js";
|
|
2
2
|
export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
|
|
3
|
+
export { AllowPlaintext, AllowPlaintextForLoopback, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
4
|
+
export type { TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, } from "../client-connect/transportSecurity.js";
|
|
3
5
|
export type { ConnectArtifact, CorrelationContext, CorrelationKV, DirectClientConnectArtifact, ScopeMetadataEntry, TunnelClientConnectArtifact, } from "../connect/artifact.js";
|
|
4
6
|
export { assertConnectArtifact } from "../connect/artifact.js";
|
|
5
7
|
export type { ConnectArtifactRequestConfig, ControlplaneConfig, EntryConnectArtifactRequestConfig, EntryControlplaneConfig, } from "./controlplane.js";
|
package/dist/browser/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
|
|
2
|
+
export { AllowPlaintext, AllowPlaintextForLoopback, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
2
3
|
export { assertConnectArtifact } from "../connect/artifact.js";
|
|
3
4
|
export { ControlplaneRequestError, requestChannelGrant, requestConnectArtifact, requestEntryChannelGrant, requestEntryConnectArtifact, } from "./controlplane.js";
|
|
4
5
|
export { createBrowserReconnectConfig, createDirectBrowserReconnectConfig, createTunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
|
|
@@ -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,7 +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";
|
|
6
|
+
import { type TransportSecurityPolicy } from "./transportSecurity.js";
|
|
7
|
+
export type LivenessOptions = Readonly<{
|
|
8
|
+
intervalMs?: number;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}>;
|
|
5
11
|
export type ConnectOptionsBase = Readonly<{
|
|
6
12
|
/** Explicit Origin value (required). In browsers this must match window.location.origin. */
|
|
7
13
|
origin: string;
|
|
@@ -19,18 +25,24 @@ export type ConnectOptionsBase = Readonly<{
|
|
|
19
25
|
maxRecordBytes?: number;
|
|
20
26
|
/** Maximum buffered plaintext bytes in the secure channel (0 uses default). */
|
|
21
27
|
maxBufferedBytes?: number;
|
|
22
|
-
/**
|
|
23
|
-
|
|
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>;
|
|
24
34
|
/** Optional factory for creating the WebSocket instance. */
|
|
25
35
|
wsFactory?: (url: string, origin: string) => WebSocketLike;
|
|
26
36
|
/** Optional observer for client metrics. */
|
|
27
37
|
observer?: ClientObserverLike;
|
|
38
|
+
/** Policy evaluated before any WebSocket network activity. */
|
|
39
|
+
transportSecurityPolicy?: TransportSecurityPolicy;
|
|
28
40
|
/** Experimental scope validators keyed by scope name. */
|
|
29
41
|
scopeResolvers?: ConnectScopeResolverMap;
|
|
30
42
|
/** Experimental migration switch for optional scope failures. */
|
|
31
43
|
relaxedOptionalScopeValidation?: boolean;
|
|
32
|
-
/**
|
|
33
|
-
|
|
44
|
+
/** Acknowledged Yamux liveness checks, or false to disable automatic checks. */
|
|
45
|
+
liveness?: false | LivenessOptions;
|
|
34
46
|
}>;
|
|
35
47
|
export type ConnectCoreArgs = Readonly<{
|
|
36
48
|
path: "tunnel" | "direct";
|
|
@@ -1,15 +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";
|
|
14
|
+
import { enforceTransportSecurity } from "./transportSecurity.js";
|
|
15
|
+
import { maxPlaintextBytes } from "../e2ee/record.js";
|
|
16
|
+
import { isYamuxResourceExhaustedError } from "../yamux/errors.js";
|
|
13
17
|
export async function connectCore(args) {
|
|
14
18
|
const observer = normalizeObserver(args.opts.observer, { path: args.path });
|
|
15
19
|
const signal = args.opts.signal;
|
|
@@ -31,6 +35,9 @@ export async function connectCore(args) {
|
|
|
31
35
|
if (origin === "") {
|
|
32
36
|
throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_origin", message: "missing origin" });
|
|
33
37
|
}
|
|
38
|
+
const invalidOption = (message) => {
|
|
39
|
+
throw new FlowersecError({ path: args.path, stage: "validate", code: "invalid_option", message });
|
|
40
|
+
};
|
|
34
41
|
if (args.path === "tunnel" && args.attach == null) {
|
|
35
42
|
throw new FlowersecError({
|
|
36
43
|
path: args.path,
|
|
@@ -44,9 +51,6 @@ export async function connectCore(args) {
|
|
|
44
51
|
const code = args.path === "tunnel" ? "missing_tunnel_url" : "missing_ws_url";
|
|
45
52
|
throw new FlowersecError({ path: args.path, stage: "validate", code, message: "missing websocket url" });
|
|
46
53
|
}
|
|
47
|
-
const invalidOption = (message) => {
|
|
48
|
-
throw new FlowersecError({ path: args.path, stage: "validate", code: "invalid_option", message });
|
|
49
|
-
};
|
|
50
54
|
const connectTimeoutMs = args.opts.connectTimeoutMs ?? 10_000;
|
|
51
55
|
if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
|
|
52
56
|
invalidOption("connectTimeoutMs must be a non-negative number");
|
|
@@ -55,10 +59,6 @@ export async function connectCore(args) {
|
|
|
55
59
|
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
56
60
|
invalidOption("handshakeTimeoutMs must be a non-negative number");
|
|
57
61
|
}
|
|
58
|
-
const keepaliveIntervalMs = args.opts.keepaliveIntervalMs ?? 0;
|
|
59
|
-
if (!Number.isFinite(keepaliveIntervalMs) || keepaliveIntervalMs < 0) {
|
|
60
|
-
invalidOption("keepaliveIntervalMs must be a non-negative number");
|
|
61
|
-
}
|
|
62
62
|
const clientFeatures = args.opts.clientFeatures ?? 0;
|
|
63
63
|
if (!Number.isSafeInteger(clientFeatures) || clientFeatures < 0 || clientFeatures > 0xffffffff) {
|
|
64
64
|
invalidOption("clientFeatures must be a uint32");
|
|
@@ -75,10 +75,22 @@ export async function connectCore(args) {
|
|
|
75
75
|
if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes < 0) {
|
|
76
76
|
invalidOption("maxBufferedBytes must be a non-negative integer");
|
|
77
77
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
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");
|
|
81
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
|
+
});
|
|
82
94
|
const channelId = prepareChannelId(args.channelId, args.path);
|
|
83
95
|
let psk;
|
|
84
96
|
try {
|
|
@@ -111,7 +123,7 @@ export async function connectCore(args) {
|
|
|
111
123
|
// Install close/error/message listeners before waiting for "open" to avoid a gap where a peer close
|
|
112
124
|
// (for example a tunnel attach rejection with a reason token) can be missed and misclassified as a handshake timeout.
|
|
113
125
|
const transport = new WebSocketBinaryTransport(ws, {
|
|
114
|
-
...(
|
|
126
|
+
...(args.opts.webSocketLimits === undefined ? {} : { webSocketLimits: args.opts.webSocketLimits }),
|
|
115
127
|
observer,
|
|
116
128
|
});
|
|
117
129
|
try {
|
|
@@ -167,7 +179,8 @@ export async function connectCore(args) {
|
|
|
167
179
|
psk,
|
|
168
180
|
clientFeatures,
|
|
169
181
|
maxHandshakePayload: maxHandshakePayload > 0 ? maxHandshakePayload : 8 * 1024,
|
|
170
|
-
maxRecordBytes:
|
|
182
|
+
maxRecordBytes: effectiveMaxRecordBytes,
|
|
183
|
+
outboundRecordChunkBytes,
|
|
171
184
|
...(maxBufferedBytes > 0 ? { maxBufferedBytes } : {}),
|
|
172
185
|
timeoutMs: handshakeTimeoutMs,
|
|
173
186
|
...(signal !== undefined ? { signal } : {}),
|
|
@@ -207,7 +220,20 @@ export async function connectCore(args) {
|
|
|
207
220
|
write: (b) => secure.write(b),
|
|
208
221
|
close: () => secure.close(),
|
|
209
222
|
};
|
|
210
|
-
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
|
+
});
|
|
211
237
|
let rpcStream;
|
|
212
238
|
try {
|
|
213
239
|
rpcStream = await mux.openStream();
|
|
@@ -249,16 +275,39 @@ export async function connectCore(args) {
|
|
|
249
275
|
throw new FlowersecError({ path: args.path, stage: "secure", code: "ping_failed", message: "ping failed", cause: e });
|
|
250
276
|
}
|
|
251
277
|
};
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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)
|
|
256
305
|
return;
|
|
257
|
-
clearInterval(
|
|
258
|
-
|
|
306
|
+
clearInterval(livenessTimer);
|
|
307
|
+
livenessTimer = undefined;
|
|
259
308
|
};
|
|
260
309
|
const closeAll = () => {
|
|
261
|
-
|
|
310
|
+
stopLiveness();
|
|
262
311
|
try {
|
|
263
312
|
rpc.close();
|
|
264
313
|
}
|
|
@@ -278,20 +327,20 @@ export async function connectCore(args) {
|
|
|
278
327
|
// ignore
|
|
279
328
|
}
|
|
280
329
|
};
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
if (
|
|
330
|
+
if (liveness.intervalMs > 0) {
|
|
331
|
+
livenessTimer = setInterval(() => {
|
|
332
|
+
if (livenessInFlight)
|
|
284
333
|
return;
|
|
285
|
-
|
|
286
|
-
|
|
334
|
+
livenessInFlight = true;
|
|
335
|
+
probeLiveness()
|
|
287
336
|
.catch(() => {
|
|
288
|
-
|
|
337
|
+
stopLiveness();
|
|
289
338
|
})
|
|
290
339
|
.finally(() => {
|
|
291
|
-
|
|
340
|
+
livenessInFlight = false;
|
|
292
341
|
});
|
|
293
|
-
},
|
|
294
|
-
|
|
342
|
+
}, liveness.intervalMs);
|
|
343
|
+
livenessTimer?.unref?.();
|
|
295
344
|
}
|
|
296
345
|
return {
|
|
297
346
|
path: args.path,
|
|
@@ -300,6 +349,7 @@ export async function connectCore(args) {
|
|
|
300
349
|
mux,
|
|
301
350
|
rpc,
|
|
302
351
|
ping,
|
|
352
|
+
probeLiveness,
|
|
303
353
|
openStream: async (kind, opts = {}) => {
|
|
304
354
|
if (kind == null || kind === "")
|
|
305
355
|
throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
|
|
@@ -327,7 +377,8 @@ export async function connectCore(args) {
|
|
|
327
377
|
s = await mux.openStream();
|
|
328
378
|
}
|
|
329
379
|
catch (e) {
|
|
330
|
-
|
|
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 });
|
|
331
382
|
}
|
|
332
383
|
if (signal != null) {
|
|
333
384
|
abortListener = () => {
|
|
@@ -416,3 +467,51 @@ export async function connectCore(args) {
|
|
|
416
467
|
throw e;
|
|
417
468
|
}
|
|
418
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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ClientPath } from "../client.js";
|
|
2
|
+
import { type ClientObserverLike } from "../observability/observer.js";
|
|
3
|
+
export declare const RequireTLS: "require_tls";
|
|
4
|
+
export declare const AllowPlaintextForLoopback: "allow_plaintext_for_loopback";
|
|
5
|
+
export declare const AllowPlaintext: "allow_plaintext";
|
|
6
|
+
export type TransportSecurityPolicyInput = Readonly<{
|
|
7
|
+
path: ClientPath;
|
|
8
|
+
scheme: "ws" | "wss";
|
|
9
|
+
host: string;
|
|
10
|
+
runtime: "browser" | "node" | "other";
|
|
11
|
+
}>;
|
|
12
|
+
export type TransportSecurityPolicyPreset = typeof RequireTLS | typeof AllowPlaintextForLoopback | typeof AllowPlaintext;
|
|
13
|
+
export type TransportSecurityPolicy = TransportSecurityPolicyPreset | ((input: TransportSecurityPolicyInput) => boolean | Promise<boolean>);
|
|
14
|
+
export declare function enforceTransportSecurity(args: Readonly<{
|
|
15
|
+
rawUrl: string;
|
|
16
|
+
path: ClientPath;
|
|
17
|
+
policy?: TransportSecurityPolicy;
|
|
18
|
+
observer?: ClientObserverLike;
|
|
19
|
+
}>): Promise<void>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { FlowersecError } from "../utils/errors.js";
|
|
2
|
+
import { emitObserverDiagnostic } from "../observability/observer.js";
|
|
3
|
+
export const RequireTLS = "require_tls";
|
|
4
|
+
export const AllowPlaintextForLoopback = "allow_plaintext_for_loopback";
|
|
5
|
+
export const AllowPlaintext = "allow_plaintext";
|
|
6
|
+
export async function enforceTransportSecurity(args) {
|
|
7
|
+
let target;
|
|
8
|
+
try {
|
|
9
|
+
target = parseWebSocketTarget(args.rawUrl);
|
|
10
|
+
}
|
|
11
|
+
catch (cause) {
|
|
12
|
+
throw denied(args.path, cause);
|
|
13
|
+
}
|
|
14
|
+
const input = {
|
|
15
|
+
path: args.path,
|
|
16
|
+
scheme: target.scheme,
|
|
17
|
+
host: target.host,
|
|
18
|
+
runtime: detectRuntime(),
|
|
19
|
+
};
|
|
20
|
+
let allowed = false;
|
|
21
|
+
const policy = args.policy ?? RequireTLS;
|
|
22
|
+
try {
|
|
23
|
+
if (typeof policy === "function") {
|
|
24
|
+
allowed = await policy(input);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
allowed = evaluatePreset(policy, target);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (cause) {
|
|
31
|
+
throw denied(args.path, cause);
|
|
32
|
+
}
|
|
33
|
+
if (!allowed)
|
|
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
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function denied(path, cause) {
|
|
46
|
+
return new FlowersecError({
|
|
47
|
+
path,
|
|
48
|
+
stage: "validate",
|
|
49
|
+
code: "transport_policy_denied",
|
|
50
|
+
message: "transport security policy denied websocket URL",
|
|
51
|
+
...(cause === undefined ? {} : { cause }),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function evaluatePreset(policy, target) {
|
|
55
|
+
switch (policy) {
|
|
56
|
+
case RequireTLS:
|
|
57
|
+
return target.scheme === "wss";
|
|
58
|
+
case AllowPlaintextForLoopback:
|
|
59
|
+
return target.scheme === "wss" || isLiteralLoopbackHost(target.host);
|
|
60
|
+
case AllowPlaintext:
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function parseWebSocketTarget(rawUrl) {
|
|
65
|
+
const raw = rawUrl.trim();
|
|
66
|
+
const match = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)(?:[/?#]|$)/.exec(raw);
|
|
67
|
+
const scheme = match?.[1]?.toLowerCase();
|
|
68
|
+
const authority = match?.[2] ?? "";
|
|
69
|
+
if ((scheme !== "ws" && scheme !== "wss") || authority === "" || authority.includes("@")) {
|
|
70
|
+
throw new Error("invalid websocket URL");
|
|
71
|
+
}
|
|
72
|
+
let host;
|
|
73
|
+
if (authority.startsWith("[")) {
|
|
74
|
+
const end = authority.indexOf("]");
|
|
75
|
+
if (end <= 1 || (authority.slice(end + 1) !== "" && !/^:\d+$/.test(authority.slice(end + 1)))) {
|
|
76
|
+
throw new Error("invalid websocket URL");
|
|
77
|
+
}
|
|
78
|
+
host = authority.slice(1, end).toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const pieces = authority.split(":");
|
|
82
|
+
if (pieces.length > 2 || (pieces.length === 2 && !/^\d+$/.test(pieces[1] ?? ""))) {
|
|
83
|
+
throw new Error("invalid websocket URL");
|
|
84
|
+
}
|
|
85
|
+
host = (pieces[0] ?? "").toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
if (host === "")
|
|
88
|
+
throw new Error("invalid websocket URL");
|
|
89
|
+
return { scheme, host };
|
|
90
|
+
}
|
|
91
|
+
function isLiteralLoopbackHost(host) {
|
|
92
|
+
if (host === "localhost" || host === "::1")
|
|
93
|
+
return true;
|
|
94
|
+
const parts = host.split(".");
|
|
95
|
+
if (parts.length !== 4)
|
|
96
|
+
return false;
|
|
97
|
+
const octets = [];
|
|
98
|
+
for (const part of parts) {
|
|
99
|
+
if (!/^(0|[1-9]\d{0,2})$/.test(part))
|
|
100
|
+
return false;
|
|
101
|
+
const value = Number(part);
|
|
102
|
+
if (value > 255)
|
|
103
|
+
return false;
|
|
104
|
+
octets.push(value);
|
|
105
|
+
}
|
|
106
|
+
return octets[0] === 127;
|
|
107
|
+
}
|
|
108
|
+
function detectRuntime() {
|
|
109
|
+
if (typeof window !== "undefined" && typeof window.document !== "undefined")
|
|
110
|
+
return "browser";
|
|
111
|
+
if (typeof process !== "undefined" && process.versions?.node != null)
|
|
112
|
+
return "node";
|
|
113
|
+
return "other";
|
|
114
|
+
}
|
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<{
|