@blockcast/mmt-container 1.0.1 → 2.0.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 +9 -7
- package/dist/browser-runtime.js +6931 -6685
- package/dist/browser-runtime.js.map +4 -4
- package/dist/carp-catalog.d.ts +7 -7
- package/dist/carp-catalog.d.ts.map +1 -1
- package/dist/carp-catalog.js +65 -230
- package/dist/carp-catalog.js.map +3 -3
- package/dist/catalog-fec-params.js.map +1 -1
- package/dist/catalog-network-source.js.map +1 -1
- package/dist/delivery-catalog.js.map +1 -1
- package/dist/delta-fallback-config.js.map +1 -1
- package/dist/fec-manager.d.ts +25 -1
- package/dist/fec-manager.d.ts.map +1 -1
- package/dist/fec-manager.js +208 -15
- package/dist/fec-manager.js.map +2 -2
- package/dist/fec-state.js.map +1 -1
- package/dist/fec-worker-setup.js.map +1 -1
- package/dist/held-layer-admission.d.ts +8 -0
- package/dist/held-layer-admission.d.ts.map +1 -1
- package/dist/held-layer-admission.js +22 -1
- package/dist/held-layer-admission.js.map +2 -2
- package/dist/iso-block-locator.js.map +1 -1
- package/dist/mfu-reassembler.js.map +1 -1
- package/dist/mmtp-container-parser.js.map +1 -1
- package/dist/mmtp-container-parser.no-modules.js +127 -322
- package/dist/mmtp-container-parser.no-modules.js.map +4 -4
- package/dist/mmtp-decode-pipeline.js.map +1 -1
- package/dist/mmtp-fec-session.d.ts.map +1 -1
- package/dist/mmtp-fec-session.js +61 -15
- package/dist/mmtp-fec-session.js.map +3 -3
- package/dist/mmtp-router.d.ts +11 -6
- package/dist/mmtp-router.d.ts.map +1 -1
- package/dist/mmtp-router.js +47 -10
- package/dist/mmtp-router.js.map +3 -3
- package/dist/multicast-auth-catalog.d.ts +8 -8
- package/dist/multicast-auth-catalog.d.ts.map +1 -1
- package/dist/multicast-auth-catalog.js +171 -75
- package/dist/multicast-auth-catalog.js.map +4 -4
- package/dist/multicast-input.d.ts +5 -5
- package/dist/multicast-input.d.ts.map +1 -1
- package/dist/multicast-input.js +126 -49
- package/dist/multicast-input.js.map +3 -3
- package/dist/multicast-manager.d.ts +11 -4
- package/dist/multicast-manager.d.ts.map +1 -1
- package/dist/multicast-manager.js +7922 -7700
- package/dist/multicast-manager.js.map +4 -4
- package/dist/multicast-transport.d.ts +8 -0
- package/dist/multicast-transport.d.ts.map +1 -1
- package/dist/multicast-transport.js +27 -3
- package/dist/multicast-transport.js.map +3 -3
- package/dist/multicast-unwrap.js +2 -2
- package/dist/multicast-unwrap.js.map +2 -2
- package/dist/no-modules.js +81 -278
- package/dist/no-modules.js.map +4 -4
- package/dist/repair-fetch.js.map +1 -1
- package/dist/signaling.js.map +1 -1
- package/dist/timestamp-codec.js.map +1 -1
- package/dist/wasm-mfu-reassembler.js.map +1 -1
- package/dist/wasm-mmtp-container-parser.js.map +1 -1
- package/package.json +5 -5
- package/schemas/catalog.schema.json +15 -7
- package/src/carp-catalog.ts +14 -13
- package/src/fec-manager.ts +89 -14
- package/src/held-layer-admission.ts +38 -1
- package/src/mmtp-fec-session.ts +20 -12
- package/src/mmtp-router.ts +65 -16
- package/src/multicast-auth-catalog.ts +43 -36
- package/src/multicast-input.ts +28 -8
- package/src/multicast-manager.ts +38 -34
- package/src/multicast-transport.ts +31 -3
- package/test-vectors/bc-provenance-v2.json +614 -175
- package/test-vectors/catalog/README.md +24 -8
- package/test-vectors/catalog/negative/multicast-auth-asm-missing-sender-address.json +0 -2
- package/test-vectors/catalog/negative/{multicast-auth-manifest-port-equals-port.json → multicast-auth-draft01-manifest-port.json} +1 -1
- package/test-vectors/catalog/negative/multicast-auth-duplicate-first-epoch.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-duplicate-kid.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-endpoint-auth-without-object-auth.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-endpoint-missing-bandwidth.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-manifest-timer-out-of-range.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-sender-address-on-ssm.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-shared-sg-port.json +73 -0
- package/test-vectors/catalog/negative/multicast-auth-version-1.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-version-3.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-version-missing.json +0 -1
- package/test-vectors/catalog/negative/multicast-endpoint-address-family-mismatch.json +55 -0
- package/test-vectors/catalog/negative/multicast-endpoint-address-host-name.json +55 -0
- package/test-vectors/catalog/negative/multicast-endpoint-address-leading-zero.json +55 -0
- package/test-vectors/catalog/negative/multicast-endpoint-address-ls32-leading-zero.json +55 -0
- package/test-vectors/catalog/negative/multicast-endpoint-address-mapped-family-mismatch.json +55 -0
- package/test-vectors/catalog/negative/multicast-endpoint-address-zone-id.json +55 -0
- package/test-vectors/catalog/positive/multicast-auth-asm-sender.json +0 -2
- package/test-vectors/catalog/{negative/multicast-auth-max-auth-delay-above-max.json → positive/multicast-auth-max-auth-delay-above-max-clamped.json} +0 -1
- package/test-vectors/catalog/{negative/multicast-auth-max-auth-delay-below-min.json → positive/multicast-auth-max-auth-delay-below-min-clamped.json} +0 -1
- package/test-vectors/catalog/positive/multicast-auth-max-auth-delay-string.json +55 -0
- package/test-vectors/catalog/positive/multicast-auth-rotation.json +0 -1
- package/test-vectors/catalog/positive/multicast-draft01-7-2-6-example.json +0 -1
- package/test-vectors/catalog/section-codes.json +8 -3
- package/test-vectors/catalog-multicast-auth-v1.json +0 -1
- package/test-vectors/catalog-validation.json +3 -3
- package/test-vectors/generate-bc-provenance-v2.mjs +94 -14
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `multicast.auth` and an endpoint's `auth`: bc-provenance version 2, per
|
|
3
|
-
* draft-ramadan-moq-multicast-
|
|
4
|
-
*
|
|
3
|
+
* draft-ramadan-moq-multicast-02 7.2.6 at Blockcast/moqcast-draft@5f49172.
|
|
4
|
+
* Records ride in band on the flow's own `port` (7.2.3); draft -01's separate
|
|
5
|
+
* record port is gone.
|
|
5
6
|
*
|
|
6
7
|
* The block lives in the `multicast` object, never the root: the root dotted
|
|
7
8
|
* key `"multicast.auth"` was draft -00's (libmmt called its `tracks[]` shape
|
|
@@ -15,7 +16,13 @@
|
|
|
15
16
|
* the transport receivers; this module decodes what they admit.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
BCP_DEFAULT_MANIFEST_TIMER_MS,
|
|
21
|
+
BCP_DEFAULT_MAX_AUTH_DELAY_MS,
|
|
22
|
+
canonicalIpAddress,
|
|
23
|
+
endpointAuthTiming,
|
|
24
|
+
multicastCatalogIssues,
|
|
25
|
+
} from "@blockcast/mmt-base";
|
|
19
26
|
import { bcProvenanceV2Ctx, type BcProvenanceV2Params, type BcpV2Key } from "@blockcast/mmt-manifest-verify";
|
|
20
27
|
|
|
21
28
|
export type MulticastAuthScheme = "bc-provenance";
|
|
@@ -24,8 +31,8 @@ export type MulticastAuthSig = "ed25519";
|
|
|
24
31
|
const RAW_ED25519_KEY_LENGTH = 32;
|
|
25
32
|
|
|
26
33
|
/** Default `maxAuthDelayMs` and `manifestTimerMs` of an endpoint's `auth`. */
|
|
27
|
-
export const DEFAULT_MAX_AUTH_DELAY_MS =
|
|
28
|
-
export const DEFAULT_MANIFEST_TIMER_MS =
|
|
34
|
+
export const DEFAULT_MAX_AUTH_DELAY_MS = BCP_DEFAULT_MAX_AUTH_DELAY_MS;
|
|
35
|
+
export const DEFAULT_MANIFEST_TIMER_MS = BCP_DEFAULT_MANIFEST_TIMER_MS;
|
|
29
36
|
|
|
30
37
|
const BASE64URL_ALPHABET =
|
|
31
38
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
@@ -118,15 +125,13 @@ export interface MulticastAuthConfig {
|
|
|
118
125
|
|
|
119
126
|
/** An endpoint's `auth` (7.2.6). A protected endpoint also declares `bandwidth`. */
|
|
120
127
|
export interface EndpointAuthConfig {
|
|
121
|
-
/** Destination port of the record datagrams on the endpoint's `(S, G)`. */
|
|
122
|
-
manifestPort: number;
|
|
123
128
|
/** u32 chosen per channel generation. */
|
|
124
129
|
streamId: number;
|
|
125
130
|
/** `S` of a protected ASM endpoint; never on SSM. */
|
|
126
131
|
senderAddress?: string;
|
|
127
|
-
/** 50..1000, default 200. */
|
|
132
|
+
/** 50..1000, default 200; a receiver clamps one outside the range. */
|
|
128
133
|
maxAuthDelayMs?: number;
|
|
129
|
-
/** At least 1, default 20. */
|
|
134
|
+
/** At least 1, default 20; a receiver ignores the endpoint below 1. */
|
|
130
135
|
manifestTimerMs?: number;
|
|
131
136
|
/** MoQ track mirroring the records. */
|
|
132
137
|
manifestTrack?: string;
|
|
@@ -142,12 +147,13 @@ export function isMulticastAuthConfig(obj: unknown): obj is MulticastAuthConfig
|
|
|
142
147
|
export interface ResolvedMulticastAuthFlow {
|
|
143
148
|
/** Index of the endpoint in `multicast.endpoints`. */
|
|
144
149
|
endpointIndex: number;
|
|
145
|
-
/** `S`: `sourceAddress` on SSM, `auth.senderAddress` on ASM. */
|
|
150
|
+
/** `S`: `sourceAddress` on SSM, `auth.senderAddress` on ASM; canonical (`canonicalIpAddress`). */
|
|
146
151
|
source: string;
|
|
152
|
+
/** Canonical (`canonicalIpAddress`). */
|
|
147
153
|
groupAddress: string;
|
|
154
|
+
/** The flow's port; its records ride in band on it (7.2.3). */
|
|
148
155
|
port: number;
|
|
149
156
|
bandwidth: number;
|
|
150
|
-
manifestPort: number;
|
|
151
157
|
streamId: number;
|
|
152
158
|
maxAuthDelayMs: number;
|
|
153
159
|
manifestTimerMs: number;
|
|
@@ -221,15 +227,13 @@ export function resolveMulticastAuthCatalog(
|
|
|
221
227
|
}
|
|
222
228
|
flows.push({
|
|
223
229
|
endpointIndex,
|
|
224
|
-
source: (endpoint["sourceAddress"] ?? flowAuth.senderAddress)
|
|
225
|
-
groupAddress: endpoint["groupAddress"]
|
|
230
|
+
source: canonicalIpAddress(endpoint["sourceAddress"] ?? flowAuth.senderAddress)!,
|
|
231
|
+
groupAddress: canonicalIpAddress(endpoint["groupAddress"])!,
|
|
226
232
|
port: endpoint["port"] as number,
|
|
227
233
|
bandwidth: endpoint["bandwidth"] as number,
|
|
228
|
-
manifestPort: flowAuth.manifestPort,
|
|
229
234
|
streamId: flowAuth.streamId,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
...(flowAuth.manifestTrack === undefined ? {} : { manifestTrack: flowAuth.manifestTrack }),
|
|
235
|
+
// Clamped, or the defaults where absent (7.2.6).
|
|
236
|
+
...endpointAuthTiming(flowAuth),
|
|
233
237
|
tracks,
|
|
234
238
|
...(ctx === undefined ? {} : { ctx }),
|
|
235
239
|
});
|
|
@@ -249,46 +253,49 @@ function positiveInteger(value: unknown): number {
|
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
/**
|
|
252
|
-
* `params_hash` inputs (7.2.4): the catalog entry of the
|
|
253
|
-
* lowest-`packetId` media track or, for a repair-only endpoint, of
|
|
254
|
-
* its lowest-`packetId` repair track repairs (`
|
|
255
|
-
* `fec
|
|
256
|
-
* resolve to an integer tick count
|
|
256
|
+
* `params_hash` inputs (multicast-02 7.2.4): the catalog entry of the
|
|
257
|
+
* endpoint's lowest-`packetId` media track or, for a repair-only endpoint, of
|
|
258
|
+
* the source its lowest-`packetId` repair track repairs (its `source`, else
|
|
259
|
+
* the track one of whose `fec` instances names it as `repairTrack`). A
|
|
260
|
+
* timescale or group duration that does not resolve to an integer tick count
|
|
261
|
+
* is `0`. An instance naming the reserved scheme "none" (fec-core-00 15.2)
|
|
262
|
+
* has no params_hash, so its endpoint is ignored like one with no entry.
|
|
257
263
|
*/
|
|
258
264
|
function ctxParams(
|
|
259
265
|
catalogTracks: Array<Record<string, unknown>>,
|
|
260
266
|
listed: Array<{ name: string; packetId: number }>,
|
|
261
267
|
): BcProvenanceV2Params | undefined {
|
|
262
268
|
const byName = new Map(catalogTracks.map((track) => [track["name"], track]));
|
|
269
|
+
const instancesOf = (track: Record<string, unknown>): Array<Record<string, unknown>> =>
|
|
270
|
+
Array.isArray(track["fec"]) ? (track["fec"] as unknown[]).filter((f): f is Record<string, unknown> => typeof f === "object" && f !== null) : [];
|
|
263
271
|
const ordered = [...listed].sort((a, b) => a.packetId - b.packetId).map(({ name }) => byName.get(name));
|
|
264
272
|
let entry = ordered.find((track) => track !== undefined && track["packaging"] !== "fec-repair");
|
|
265
273
|
if (entry === undefined) {
|
|
266
274
|
const repair = ordered.find((track) => track !== undefined);
|
|
267
275
|
if (repair === undefined) return undefined;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
: catalogTracks.find((track) => (track["fec"] as Record<string, unknown> | undefined)?.["repairTrack"] === repair["name"]);
|
|
276
|
+
entry = typeof repair["source"] === "string"
|
|
277
|
+
? byName.get(repair["source"])
|
|
278
|
+
: catalogTracks.find((track) => instancesOf(track).some((f) => f["repairTrack"] === repair["name"]));
|
|
272
279
|
if (entry === undefined) return undefined;
|
|
273
280
|
}
|
|
281
|
+
const instances = instancesOf(entry);
|
|
282
|
+
if (instances.some((f) => f["scheme"] === "none")) return undefined;
|
|
274
283
|
const timescale = positiveInteger(entry["timescale"]);
|
|
275
284
|
const ms = positiveInteger(entry["groupDurationMs"]);
|
|
276
285
|
const groupDurationTicks = positiveInteger(entry["groupDurationTicks"]) ||
|
|
277
286
|
positiveInteger(timescale && (ms * timescale) % 1000 === 0 ? (ms * timescale) / 1000 : 0);
|
|
278
|
-
const fec = entry["fec"] as Record<string, unknown> | undefined;
|
|
279
287
|
return {
|
|
280
288
|
codec: typeof entry["codec"] === "string" ? entry["codec"] : "",
|
|
281
289
|
timescale,
|
|
282
290
|
groupDurationTicks,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}),
|
|
291
|
+
fec: instances.map((f) => ({
|
|
292
|
+
instanceId: typeof f["instanceId"] === "number" ? f["instanceId"] : 0,
|
|
293
|
+
scheme: typeof f["scheme"] === "string" ? f["scheme"] : "",
|
|
294
|
+
sourceSymbols: positiveInteger(f["sourceSymbols"]),
|
|
295
|
+
repairSymbols: positiveInteger(f["repairSymbols"]),
|
|
296
|
+
symbolSize: positiveInteger(f["symbolSize"]),
|
|
297
|
+
interleaveDepthMs: positiveInteger(f["interleaveDepthMs"]),
|
|
298
|
+
})),
|
|
292
299
|
};
|
|
293
300
|
}
|
|
294
301
|
|
package/src/multicast-input.ts
CHANGED
|
@@ -36,10 +36,13 @@ declare const process: { env: Record<string, string | undefined> } | undefined;
|
|
|
36
36
|
* per-endpoint `relayAddress` input is LIFTED into the AMT entry.
|
|
37
37
|
* - Native "ssm" | "asm" requires no `networkSource` entry: a native join
|
|
38
38
|
* describes no alternate bearer (BLO-37926).
|
|
39
|
-
* - `auth` (catalog) and endpoint `auth` follow draft -
|
|
39
|
+
* - `auth` (catalog) and endpoint `auth` follow draft -02 7.2.6 through the
|
|
40
40
|
* shared `multicastCatalogIssues` of `@blockcast/mmt-base`: all or
|
|
41
|
-
* nothing, `senderAddress` on ASM only, bandwidth required,
|
|
42
|
-
*
|
|
41
|
+
* nothing, `senderAddress` on ASM only, bandwidth required, addresses
|
|
42
|
+
* RFC 3986 IP literals, and one endpoint per `(S, G, port)` (the flow's
|
|
43
|
+
* records ride in band on its `port`, 7.2.3). A receiver clamps a
|
|
44
|
+
* `maxAuthDelayMs` outside 50..1000; this emitter never writes one, nor a
|
|
45
|
+
* non-integer timer or a non-string `manifestTrack`.
|
|
43
46
|
* - groupAddress is a valid IPv4 multicast group (224.0.0.0/4)
|
|
44
47
|
* or IPv6 multicast group (ff00::/8)
|
|
45
48
|
* - SSM groupAddress MUST be in 232.0.0.0/8 (IPv4) or ff3x::/32 (IPv6),
|
|
@@ -105,7 +108,7 @@ export interface MulticastEndpointInput {
|
|
|
105
108
|
readonly relayAddress?: string;
|
|
106
109
|
/** Aggregate bandwidth in bits per second. RECOMMENDED per draft §4.1; REQUIRED with `auth`. */
|
|
107
110
|
readonly bandwidth?: number;
|
|
108
|
-
/** Protected-flow parameters (draft -
|
|
111
|
+
/** Protected-flow parameters (draft -02 7.2.6); REQUIRED exactly when `MulticastInput.auth` is set. */
|
|
109
112
|
readonly auth?: EndpointAuthConfig;
|
|
110
113
|
}
|
|
111
114
|
|
|
@@ -148,13 +151,13 @@ export type MulticastInputErrorKind =
|
|
|
148
151
|
| "ssm_requires_source"
|
|
149
152
|
/** Input `protocol` "asm" with a `sourceAddress`, which selects SSM. */
|
|
150
153
|
| "protocol_source_mismatch"
|
|
151
|
-
/** `MulticastInput.auth` fails draft -
|
|
154
|
+
/** `MulticastInput.auth` fails draft -02 7.2.6 (version, keys, a repeated kid or firstEpoch). */
|
|
152
155
|
| "invalid_multicast_auth"
|
|
153
156
|
/**
|
|
154
|
-
* An endpoint's `auth` fails draft -
|
|
157
|
+
* An endpoint's `auth` fails draft -02 7.2.6: present without
|
|
155
158
|
* `MulticastInput.auth` or absent with it, `senderAddress` on SSM or
|
|
156
|
-
* missing on ASM, a timer out of range
|
|
157
|
-
* `
|
|
159
|
+
* missing on ASM, a timer out of range or not an integer, a non-string
|
|
160
|
+
* `manifestTrack`, or no `bandwidth`.
|
|
158
161
|
*/
|
|
159
162
|
| "invalid_endpoint_auth"
|
|
160
163
|
/**
|
|
@@ -390,6 +393,23 @@ export function multicastFromConfig(input: MulticastInput): MulticastCatalogConf
|
|
|
390
393
|
const where = issue.endpointIndex === undefined ? "" : `endpoint[${issue.endpointIndex}] `;
|
|
391
394
|
throw new MulticastInputError(kind, issue.endpointIndex ?? null, `${where}${issue.path.replace("$.multicast.", "")}: ${issue.message}`);
|
|
392
395
|
}
|
|
396
|
+
// What a receiver clamps or reads as absent (7.2.6) a publisher still never emits.
|
|
397
|
+
endpoints.forEach((ep, idx) => {
|
|
398
|
+
const auth = (ep as { auth?: Record<string, unknown> }).auth;
|
|
399
|
+
if (auth === undefined) return;
|
|
400
|
+
const delay = auth["maxAuthDelayMs"];
|
|
401
|
+
const timer = auth["manifestTimerMs"];
|
|
402
|
+
const track = auth["manifestTrack"];
|
|
403
|
+
const bad =
|
|
404
|
+
delay !== undefined && !(Number.isSafeInteger(delay) && (delay as number) >= 50 && (delay as number) <= 1000)
|
|
405
|
+
? "maxAuthDelayMs must be an integer from 50 to 1000"
|
|
406
|
+
: timer !== undefined && !(Number.isSafeInteger(timer) && (timer as number) >= 1)
|
|
407
|
+
? "manifestTimerMs must be an integer of at least 1"
|
|
408
|
+
: track !== undefined && !(typeof track === "string" && track.length > 0)
|
|
409
|
+
? "manifestTrack must be a non-empty string"
|
|
410
|
+
: undefined;
|
|
411
|
+
if (bad) throw new MulticastInputError("invalid_endpoint_auth", idx, `endpoint[${idx}] auth: ${bad}`);
|
|
412
|
+
});
|
|
393
413
|
|
|
394
414
|
if (input.enabled !== undefined) out.enabled = input.enabled;
|
|
395
415
|
if (input.version !== undefined) out.version = input.version;
|
package/src/multicast-manager.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - MulticastTransport — SSM join/socket/watchdog
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { parseFecBlockProfile, resolveFecBindings, type FecBlockProfile, type FecSourceBinding } from "@blockcast/mmt-base";
|
|
11
|
+
import { canonicalIpAddress, parseFecBlockProfile, resolveFecBindings, sameIpAddress, type FecBlockProfile, type FecSourceBinding } from "@blockcast/mmt-base";
|
|
12
12
|
import {
|
|
13
13
|
FECManager,
|
|
14
14
|
MIN_INTERLEAVE_DEPTH_MS,
|
|
@@ -117,10 +117,11 @@ export type MulticastRepairSubscribe = (
|
|
|
117
117
|
) => (() => void) | Promise<() => void>;
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* A protected endpoint's join (draft -
|
|
120
|
+
* A protected endpoint's join (draft -02 7.2.7): the keys and flow
|
|
121
121
|
* `resolveMulticastAuthCatalog` resolved for it. The join registers the flow
|
|
122
|
-
* with the router
|
|
123
|
-
* the
|
|
122
|
+
* with the router; its records ride in band on the media `port` (7.2.3) and
|
|
123
|
+
* the router splits them off, so every media datagram is admitted by a record
|
|
124
|
+
* before it is read. No second subscription is opened.
|
|
124
125
|
*/
|
|
125
126
|
export interface MulticastProvenanceJoin {
|
|
126
127
|
auth: Pick<ResolvedMulticastAuthConfig, "keys" | "minEpoch">;
|
|
@@ -1269,8 +1270,8 @@ export class MulticastManager {
|
|
|
1269
1270
|
readonly #router: MmtpRouter;
|
|
1270
1271
|
#hostMfuTimeoutMs: number | null;
|
|
1271
1272
|
readonly #transport: MulticastTransport<MmtpIngressContext>;
|
|
1272
|
-
/** The protected flow of the current join
|
|
1273
|
-
#provenanceJoin: { flow: string
|
|
1273
|
+
/** The protected flow of the current join. */
|
|
1274
|
+
#provenanceJoin: { flow: string } | null = null;
|
|
1274
1275
|
#onFailback: ((reason: MulticastFailbackReason) => void) | null = null;
|
|
1275
1276
|
#failbackHooked = false;
|
|
1276
1277
|
/** Live joinRepairEndpoints() releases → the repair owners they registered,
|
|
@@ -2127,10 +2128,11 @@ export class MulticastManager {
|
|
|
2127
2128
|
|
|
2128
2129
|
async join(config: MulticastEndpointConfig): Promise<boolean> {
|
|
2129
2130
|
const provenanceFlow = config.provenance?.flow;
|
|
2131
|
+
// Addresses compare as IP addresses, never as strings (multicast-02 7.2.6).
|
|
2130
2132
|
if (
|
|
2131
2133
|
provenanceFlow !== undefined &&
|
|
2132
|
-
((config.sourceAddress ?? config.senderAddress
|
|
2133
|
-
config.groupAddress
|
|
2134
|
+
(!sameIpAddress(config.sourceAddress ?? config.senderAddress, provenanceFlow.source) ||
|
|
2135
|
+
!sameIpAddress(config.groupAddress, provenanceFlow.groupAddress) ||
|
|
2134
2136
|
config.port !== provenanceFlow.port)
|
|
2135
2137
|
) {
|
|
2136
2138
|
throw new RangeError(
|
|
@@ -2210,28 +2212,26 @@ export class MulticastManager {
|
|
|
2210
2212
|
amtDiscovery: config.amtDiscovery,
|
|
2211
2213
|
packetIds: config.packetIds,
|
|
2212
2214
|
tracks: catalogTracks?.map(({ name, packetId }) => ({ name, packetId })),
|
|
2215
|
+
...(provenance === undefined ? {} : { protected: true }),
|
|
2213
2216
|
};
|
|
2214
|
-
if (provenance !== undefined) this.#joinProvenance(provenance, ingress as MmtpIngressContext & { flow: string }
|
|
2217
|
+
if (provenance !== undefined) this.#joinProvenance(provenance, ingress as MmtpIngressContext & { flow: string });
|
|
2215
2218
|
const protectedJoin = this.#provenanceJoin;
|
|
2216
2219
|
const joined = await (ingress === undefined
|
|
2217
2220
|
? this.#transport.join(transportConfig)
|
|
2218
2221
|
: this.#transport.join(transportConfig, ingress));
|
|
2219
|
-
// No media:
|
|
2222
|
+
// No media: release the flow now, not at the next join.
|
|
2220
2223
|
if (!joined && protectedJoin !== null && this.#provenanceJoin === protectedJoin) this.#releaseProvenanceJoin();
|
|
2221
2224
|
return joined;
|
|
2222
2225
|
}
|
|
2223
2226
|
|
|
2224
2227
|
/**
|
|
2225
|
-
* Register the protected flow
|
|
2226
|
-
*
|
|
2227
|
-
* flow's receiver
|
|
2228
|
-
* manifest subscription that fails to join admits nothing: the media is
|
|
2229
|
-
* held and expires, never read unauthenticated.
|
|
2228
|
+
* Register the protected flow (draft -02 7.2.7). Its records arrive in band
|
|
2229
|
+
* on the media join (7.2.3), where the router splits them off on "MHM2" and
|
|
2230
|
+
* feeds the flow's receiver; media datagrams are held until one admits them.
|
|
2230
2231
|
*/
|
|
2231
2232
|
#joinProvenance(
|
|
2232
2233
|
provenance: MulticastProvenanceJoin,
|
|
2233
2234
|
ingress: MmtpIngressContext & { flow: string },
|
|
2234
|
-
media: MulticastTransportConfig,
|
|
2235
2235
|
): void {
|
|
2236
2236
|
const { flow, auth } = provenance;
|
|
2237
2237
|
const receiver = new BcProvenanceV2Receiver({
|
|
@@ -2243,7 +2243,6 @@ export class MulticastManager {
|
|
|
2243
2243
|
sourceAddress: flow.source,
|
|
2244
2244
|
groupAddress: flow.groupAddress,
|
|
2245
2245
|
port: flow.port,
|
|
2246
|
-
manifestPort: flow.manifestPort,
|
|
2247
2246
|
streamId: flow.streamId,
|
|
2248
2247
|
},
|
|
2249
2248
|
keys: auth.keys,
|
|
@@ -2254,26 +2253,14 @@ export class MulticastManager {
|
|
|
2254
2253
|
});
|
|
2255
2254
|
this.#router.registerProvenanceFlow(ingress, receiver);
|
|
2256
2255
|
this.#hookFailback();
|
|
2257
|
-
|
|
2258
|
-
if (this.#provenanceJoin?.manifest === manifest) {
|
|
2259
|
-
this.#router.feedProvenanceRecord(ingress.flow, record, "multicast");
|
|
2260
|
-
}
|
|
2261
|
-
});
|
|
2262
|
-
this.#provenanceJoin = { flow: ingress.flow, manifest };
|
|
2263
|
-
const { packetIds: _packetIds, tracks: _tracks, ...path } = media;
|
|
2264
|
-
void manifest.join({ ...path, port: flow.manifestPort }).then((joined) => {
|
|
2265
|
-
if (!joined && this.#provenanceJoin?.manifest === manifest) {
|
|
2266
|
-
console.warn(`[MulticastManager] manifestPort ${flow.groupAddress}:${flow.manifestPort} not joined`, manifest.lastJoinOutcome);
|
|
2267
|
-
}
|
|
2268
|
-
});
|
|
2256
|
+
this.#provenanceJoin = { flow: ingress.flow };
|
|
2269
2257
|
}
|
|
2270
2258
|
|
|
2271
|
-
/** Un-protect: drop the flow's hold queues (no expiry counted)
|
|
2259
|
+
/** Un-protect: drop the flow's hold queues (no expiry counted). */
|
|
2272
2260
|
#releaseProvenanceJoin(): void {
|
|
2273
2261
|
const joined = this.#provenanceJoin;
|
|
2274
2262
|
if (joined === null) return;
|
|
2275
2263
|
this.#provenanceJoin = null;
|
|
2276
|
-
joined.manifest.dispose();
|
|
2277
2264
|
this.#router.releaseProvenanceFlow(joined.flow);
|
|
2278
2265
|
}
|
|
2279
2266
|
|
|
@@ -2316,7 +2303,7 @@ export class MulticastManager {
|
|
|
2316
2303
|
if (released) return;
|
|
2317
2304
|
// The media join's sender owns the repair too; this group's own first
|
|
2318
2305
|
// sender stands in only until the media join has one.
|
|
2319
|
-
if (sourceAddress !== undefined && sourceAddress
|
|
2306
|
+
if (sourceAddress !== undefined && !sameDatagramSource(sourceAddress, this.#transport.sender ?? (sender ??= sourceAddress))) return;
|
|
2320
2307
|
this.#router.feedPacket(packet, "multicast", ingress);
|
|
2321
2308
|
};
|
|
2322
2309
|
const fail = (error: unknown) =>
|
|
@@ -2341,6 +2328,8 @@ export class MulticastManager {
|
|
|
2341
2328
|
amtRelayPort: endpoint.amtRelayPort ?? (endpoint.amtRelay?.trim() ? DEFAULT_AMT_RELAY_PORT : undefined),
|
|
2342
2329
|
packetIds: endpoint.packetIds,
|
|
2343
2330
|
tracks: endpoint.tracks.map(({ name, packetId }) => ({ name, packetId })),
|
|
2331
|
+
// A protected repair group's records ride in band on its port (7.2.3).
|
|
2332
|
+
...(endpoint.flow === undefined ? {} : { protected: true }),
|
|
2344
2333
|
}).then((joined) => {
|
|
2345
2334
|
if (!joined && !released) fail(transport.lastJoinOutcome);
|
|
2346
2335
|
});
|
|
@@ -3395,7 +3384,22 @@ export class MulticastManager {
|
|
|
3395
3384
|
const repairFetchHost = (manager: MulticastManager): RepairFetchHost => manager;
|
|
3396
3385
|
void repairFetchHost;
|
|
3397
3386
|
|
|
3398
|
-
/**
|
|
3387
|
+
/**
|
|
3388
|
+
* The router's key of a protected flow: its `(S, G, port)`, with S and G in
|
|
3389
|
+
* canonical form (`canonicalIpAddress`), so two spellings of one address key
|
|
3390
|
+
* one flow (multicast-02 7.2.6). Resolved flows already carry canonical
|
|
3391
|
+
* addresses, so `JSON.stringify([source, groupAddress, port])` of one is this
|
|
3392
|
+
* key; text that is not an address literal is kept as given.
|
|
3393
|
+
*/
|
|
3399
3394
|
export function multicastProvenanceFlowKey(flow: Pick<ResolvedMulticastAuthFlow, "source" | "groupAddress" | "port">): string {
|
|
3400
|
-
return JSON.stringify([
|
|
3395
|
+
return JSON.stringify([
|
|
3396
|
+
canonicalIpAddress(flow.source) ?? flow.source,
|
|
3397
|
+
canonicalIpAddress(flow.groupAddress) ?? flow.groupAddress,
|
|
3398
|
+
flow.port,
|
|
3399
|
+
]);
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
/** Datagram sources compared as IP addresses; spelling a provider does not parse falls back to equality. */
|
|
3403
|
+
function sameDatagramSource(a: string, b: string): boolean {
|
|
3404
|
+
return a === b || sameIpAddress(a, b);
|
|
3401
3405
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
MMTP_SIGNALING_PACKET_ID,
|
|
18
18
|
readMMTPPacketId,
|
|
19
19
|
} from '@blockcast/mmt-base'
|
|
20
|
+
import { isBcpV2RecordCandidate } from '@blockcast/mmt-manifest-verify'
|
|
20
21
|
|
|
21
22
|
export interface MulticastTransportConfig {
|
|
22
23
|
/** SSM source; omitted on ASM. */
|
|
@@ -34,6 +35,14 @@ export interface MulticastTransportConfig {
|
|
|
34
35
|
tracks?: { name: string; packetId: number }[]
|
|
35
36
|
/** ATSC 3.0 tuning (`catalogAtsc3NetworkSource`); when set the join is the tuner. */
|
|
36
37
|
atsc3?: { frequency: number; plpId?: number; serviceId?: number }
|
|
38
|
+
/**
|
|
39
|
+
* The join is a protected flow (multicast-02 7.2.3): its bc-provenance
|
|
40
|
+
* records ride in band on this `port`, so a datagram whose first four octets
|
|
41
|
+
* are "MHM2" passes ahead of the packet_id read and the allow-list, and is
|
|
42
|
+
* never counted toward a packet_id. On an unprotected join it is an
|
|
43
|
+
* unparseable MMTP packet, as before.
|
|
44
|
+
*/
|
|
45
|
+
protected?: boolean
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export type ResolvedMulticastTransport = 'native' | 'amt'
|
|
@@ -333,6 +342,7 @@ export class MulticastTransport<TPacketContext = never> {
|
|
|
333
342
|
// per packet once a count outgrows the small-integer range.
|
|
334
343
|
readonly #acceptedBytesByPacketId = new Map<number, { bytes: number }>()
|
|
335
344
|
#packetIds: ReadonlySet<number> | null = null
|
|
345
|
+
#protected = false
|
|
336
346
|
// The sender S this join's accepted packets came from, once known; see
|
|
337
347
|
// packetsDroppedForeignSender. Cleared by every join and by a silence
|
|
338
348
|
// failback, so a replacement publisher is not locked out.
|
|
@@ -462,6 +472,7 @@ export class MulticastTransport<TPacketContext = never> {
|
|
|
462
472
|
}
|
|
463
473
|
this.#packetListener = packetListener
|
|
464
474
|
this.#packetIds = packetIds
|
|
475
|
+
this.#protected = config.protected === true
|
|
465
476
|
this.#providerFilter = {
|
|
466
477
|
acknowledged: packetIds,
|
|
467
478
|
sent: 0,
|
|
@@ -578,7 +589,8 @@ export class MulticastTransport<TPacketContext = never> {
|
|
|
578
589
|
|
|
579
590
|
/**
|
|
580
591
|
* One packet from the subscription: filter, count, and hand it to the
|
|
581
|
-
* callback. Returns whether it was delivered
|
|
592
|
+
* callback. Returns whether it was delivered as liveness (a delivered record
|
|
593
|
+
* candidate is not); the caller notes liveness.
|
|
582
594
|
*/
|
|
583
595
|
#deliver(detail: unknown, sourceAddress?: string): boolean {
|
|
584
596
|
const packet = normalizeBytesEventDetail(detail)
|
|
@@ -587,11 +599,16 @@ export class MulticastTransport<TPacketContext = never> {
|
|
|
587
599
|
return false
|
|
588
600
|
}
|
|
589
601
|
if (!this.#acceptPacket(packet, sourceAddress)) return false
|
|
602
|
+
const liveness = !this.#isRecordCandidate(packet)
|
|
590
603
|
// Read per packet: a callback that changes the admission (an in-place
|
|
591
604
|
// rendition switch committing on this very packet) takes effect from the
|
|
592
605
|
// next packet, including the rest of a batch.
|
|
593
606
|
this.#onPacket(packet, this.#context)
|
|
594
|
-
return
|
|
607
|
+
return liveness
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#isRecordCandidate(packet: Uint8Array): boolean {
|
|
611
|
+
return this.#protected && isBcpV2RecordCandidate(packet)
|
|
595
612
|
}
|
|
596
613
|
|
|
597
614
|
/**
|
|
@@ -647,8 +664,9 @@ export class MulticastTransport<TPacketContext = never> {
|
|
|
647
664
|
if (this.#disposed) return
|
|
648
665
|
const packet = data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
649
666
|
if (!this.#acceptPacket(packet, sourceAddress)) return
|
|
667
|
+
const liveness = !this.#isRecordCandidate(packet)
|
|
650
668
|
this.#onPacket(packet, context)
|
|
651
|
-
this.#resetWatchdog()
|
|
669
|
+
if (liveness) this.#resetWatchdog()
|
|
652
670
|
}
|
|
653
671
|
|
|
654
672
|
confirmVideo(): void {
|
|
@@ -990,6 +1008,7 @@ export class MulticastTransport<TPacketContext = never> {
|
|
|
990
1008
|
this.#resolvedTransport = undefined
|
|
991
1009
|
this.#delivery = undefined
|
|
992
1010
|
this.#packetIds = null
|
|
1011
|
+
this.#protected = false
|
|
993
1012
|
this.#providerFilter = null
|
|
994
1013
|
this.#context = undefined
|
|
995
1014
|
this.#withdrawnPacketIds.clear()
|
|
@@ -1003,6 +1022,15 @@ export class MulticastTransport<TPacketContext = never> {
|
|
|
1003
1022
|
this.#stats.packetsDroppedForeignSender++
|
|
1004
1023
|
return false
|
|
1005
1024
|
}
|
|
1025
|
+
// A record candidate of a protected flow carries no packet_id: the router
|
|
1026
|
+
// splits it off before anything parses it (7.2.7). Step 1 has not run on
|
|
1027
|
+
// it yet, so it neither takes the sender lock nor counts as liveness
|
|
1028
|
+
// (#isRecordCandidate, #deliver): records alone must not hold off
|
|
1029
|
+
// silence failback.
|
|
1030
|
+
if (this.#isRecordCandidate(packet)) {
|
|
1031
|
+
this.#countAccepted(packet, null, undefined)
|
|
1032
|
+
return true
|
|
1033
|
+
}
|
|
1006
1034
|
// This early read is intentional: rejected PIDs must not affect page
|
|
1007
1035
|
// delivery, receive statistics, or the failback liveness watchdog. It
|
|
1008
1036
|
// refuses exactly what parseMMTPHeader refuses but builds no header
|