@serve.zone/interfaces 32.3.0 → 32.4.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.
@@ -5,6 +5,7 @@ import type { IClusterNode } from './clusternode.js';
5
5
  import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
6
6
  import { validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
7
7
  import { hasExactKeys } from '../private/dataproperties.js';
8
+ import { networkNoiseKey } from '../private/networkcontract.js';
8
9
 
9
10
  export interface ICluster {
10
11
  id: string;
@@ -62,6 +63,25 @@ export interface IClusterRelay {
62
63
  origin: string;
63
64
  }
64
65
 
66
+ /**
67
+ * The managed VPN hub a cluster relay bound for its own cluster.
68
+ *
69
+ * The relay is its cluster's packet relay: every node of the cluster dials this hub and no
70
+ * inter-node packet leaves the cluster. It reports what it bound, exactly like `listenAddress` and
71
+ * `listenPort`, and Cloudly composes the platform endpoints from it — a relay names no endpoint id
72
+ * and no TLS name of its own. `publicKey` is the hub's Noise public key in the one canonical
73
+ * 32-byte base64 encoding every Noise key of this contract is read with; it is public material and
74
+ * authenticates nothing by itself. A hub that bound only one transport reports the other port as
75
+ * `null`.
76
+ */
77
+ export interface IClusterRelayVpnHub {
78
+ publicKey: string;
79
+ /** The address the hub bound, as an IPv4 literal inside the address plan's `vpn.hubPrefix`. */
80
+ address: string;
81
+ quicPort: number | null;
82
+ websocketPort: number | null;
83
+ }
84
+
65
85
  /**
66
86
  * What a cluster relay tells Cloudly about itself once its socket is authenticated.
67
87
  *
@@ -88,6 +108,11 @@ export interface IRegisterClusterRelayRequest {
88
108
  */
89
109
  listenAddress: string;
90
110
  listenPort: number;
111
+ /**
112
+ * Absent on a relay that serves no managed VPN hub, which registers exactly as it did before.
113
+ * Cloudly selects no hub for such a cluster and states `relay-vpn-hub-absent` instead.
114
+ */
115
+ vpnHub?: IClusterRelayVpnHub;
91
116
  };
92
117
  /** Monotonic within one relay process; it restarts at 0 when the process restarts. */
93
118
  registrationSequence: number;
@@ -234,9 +259,56 @@ const validateClusterRelayNonNegativeInteger = (valueArg: unknown, pathArg: stri
234
259
  : [`${pathArg} must be a non-negative safe integer`]
235
260
  );
236
261
 
262
+ /**
263
+ * The one canonical 32-byte base64 reading every Noise key of this contract passes, stated as a
264
+ * refusal this validator's callers can collect: a hub key is the same key material the managed VPN
265
+ * credential carries, so it is never read by a second rule that could drift from it.
266
+ */
267
+ const validateClusterRelayVpnKey = (valueArg: unknown, pathArg: string): string[] => {
268
+ try {
269
+ networkNoiseKey(valueArg);
270
+ return [];
271
+ } catch {
272
+ return [`${pathArg} must be a canonical 32-byte base64 public key`];
273
+ }
274
+ };
275
+
276
+ /** A port the hub bound, or `null` for a transport it did not bind. */
277
+ const validateClusterRelayHubPort = (valueArg: unknown, pathArg: string): string[] => (
278
+ valueArg === null || validateClusterRelayListenPort(valueArg, pathArg).length === 0
279
+ ? []
280
+ : [`${pathArg} must be null or an integer from 1 to 65535`]
281
+ );
282
+
283
+ /**
284
+ * Validates one hub advertisement and names every reason it is refused.
285
+ *
286
+ * Shape only: whether the address sits inside the address plan's `vpn.hubPrefix` is Cloudly's to
287
+ * decide against the plan it stores, and the endpoints themselves are composed there.
288
+ */
289
+ export const validateClusterRelayVpnHub = (
290
+ valueArg: unknown,
291
+ pathArg = 'cluster relay vpn hub',
292
+ ): string[] => {
293
+ if (!isRecord(valueArg)
294
+ || !hasExactKeys(valueArg, ['publicKey', 'address', 'quicPort', 'websocketPort'])) {
295
+ return [`${pathArg} must use its exact schema`];
296
+ }
297
+ const errors = [
298
+ ...validateClusterRelayVpnKey(valueArg.publicKey, `${pathArg} publicKey`),
299
+ ...validateClusterRelayListenAddress(valueArg.address, `${pathArg} address`),
300
+ ...validateClusterRelayHubPort(valueArg.quicPort, `${pathArg} quicPort`),
301
+ ...validateClusterRelayHubPort(valueArg.websocketPort, `${pathArg} websocketPort`),
302
+ ];
303
+ if (valueArg.quicPort === null && valueArg.websocketPort === null) {
304
+ errors.push(`${pathArg} must bind at least one of quicPort and websocketPort`);
305
+ }
306
+ return errors;
307
+ };
308
+
237
309
  const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): string[] => {
238
310
  if (!isRecord(valueArg)
239
- || !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'])) {
311
+ || !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'], ['vpnHub'])) {
240
312
  return [`${pathArg} must use its exact schema`];
241
313
  }
242
314
  return [
@@ -244,6 +316,9 @@ const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): strin
244
316
  ...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
245
317
  ...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
246
318
  ...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
319
+ ...(Object.keys(valueArg).includes('vpnHub')
320
+ ? validateClusterRelayVpnHub(valueArg.vpnHub, `${pathArg} vpnHub`)
321
+ : []),
247
322
  ];
248
323
  };
249
324
 
@@ -0,0 +1,248 @@
1
+ import { captureNetwork, failNetwork, networkCanonical, networkCounter, networkEqual, networkId, networkIpv4, networkList,
2
+ networkNoiseKey, networkPrivatePrefix, networkRecord, networkSafe, networkSorted,
3
+ requireNetwork } from '../private/networkcontract.js';
4
+
5
+ /**
6
+ * One workload prefix a node owns inside its cluster's managed VPN, and the policy domain that
7
+ * names it in a grant. Both are Cloudly's words: the relay matches them, it never invents one.
8
+ */
9
+ export interface IClusterVpnNetworkPrefix {
10
+ cidr: string;
11
+ policyDomain: string;
12
+ }
13
+
14
+ /**
15
+ * One member of a cluster's managed VPN.
16
+ *
17
+ * Membership is presence: a node the network does not list is not a member, so there is no
18
+ * disabled state to carry. `publicKey` is the node's own Noise public key, the one Cloudly issued
19
+ * the node's credential for, and `expiresAt` is that credential's expiry in Unix milliseconds — the
20
+ * hub stops relaying for a member whose window ended even if nothing pushes a new network.
21
+ */
22
+ export interface IClusterVpnNetworkNode {
23
+ nodeId: string;
24
+ publicKey: string;
25
+ controlAddress: string;
26
+ expiresAt: number;
27
+ workloadPrefixes: IClusterVpnNetworkPrefix[];
28
+ }
29
+
30
+ /** One directed relay permission between two workload policy domains of two different nodes. */
31
+ export interface IClusterVpnNetworkGrant {
32
+ sourceDomain: string;
33
+ destinationDomain: string;
34
+ }
35
+
36
+ /**
37
+ * The complete managed VPN of one cluster, as Cloudly states it to that cluster's relay.
38
+ *
39
+ * Complete, never a patch: a relay applies it as the whole authority its hub serves, so a member or
40
+ * a grant that is missing is withdrawn. `revision` is Cloudly's own counter for this authority and
41
+ * only ever rises; `authorityId` names the authority the hub is bound to, so a network compiled for
42
+ * another cluster is refused rather than applied.
43
+ */
44
+ export interface IClusterVpnNetwork {
45
+ authorityId: string;
46
+ revision: number;
47
+ nodes: IClusterVpnNetworkNode[];
48
+ grants: IClusterVpnNetworkGrant[];
49
+ }
50
+
51
+ export const clusterVpnNetworkContract = Object.freeze({
52
+ /** The policy domain of a node's control address; the hub relays no grant between control domains. */
53
+ controlPolicyDomainPrefix: 'control:',
54
+ /** The policy domain of one workload lease, named by that lease's digest. */
55
+ workloadPolicyDomainPrefix: 'workload:',
56
+ maximumNodes: 256,
57
+ /** Per node; one workload lease the node's signed projection carries. */
58
+ maximumWorkloadPrefixes: 128,
59
+ maximumGrants: 4096,
60
+ maximumCanonicalBytes: 917_504,
61
+ completeness: 'whole-authority-never-a-patch',
62
+ membership: 'presence-only-an-absent-node-is-withdrawn',
63
+ } as const);
64
+
65
+ /**
66
+ * Every reason a relay refuses a pushed network, in the words the refusal leads with.
67
+ *
68
+ * A push is applied to a daemon whose lifetime Cloudly cannot see, so the refusal is the only
69
+ * statement about whether the cluster's hub carries this authority. The relay writes
70
+ * `<name>: <reason>` and Cloudly matches the name, which is why the names are stated here instead
71
+ * of being spelled out on both sides.
72
+ */
73
+ export const applyClusterVpnNetworkRefusals = Object.freeze([
74
+ /** The network names another authority than the one this cluster's hub is bound to. */
75
+ 'vpn-network-authority-foreign',
76
+ /** A revision the hub has already passed, or the same revision with different content. */
77
+ 'vpn-network-revision-stale',
78
+ ] as const);
79
+
80
+ /** One of the reasons a relay states that its hub did not take a network over. */
81
+ export type TApplyClusterVpnNetworkRefusal = typeof applyClusterVpnNetworkRefusals[number];
82
+
83
+ /**
84
+ * Every reason Cloudly states that a cluster has no managed VPN to push.
85
+ *
86
+ * Both are Cloudly reading its own records: a hub it cannot select, and a grant it cannot compile
87
+ * because hubs are per cluster and a grant whose two ends sit in different clusters has no path
88
+ * through any one of them.
89
+ */
90
+ export const clusterVpnNetworkSelectionRefusals = Object.freeze([
91
+ /** The cluster's relay registered no hub, so no node of it may be sent to one. */
92
+ 'relay-vpn-hub-absent',
93
+ /** The grant's two workloads are placed on two clusters; hub federation is a later lane. */
94
+ 'grant-cross-cluster-unsupported',
95
+ ] as const);
96
+
97
+ /** One of the reasons Cloudly states that it compiled no managed VPN for a cluster. */
98
+ export type TClusterVpnNetworkSelectionRefusal = typeof clusterVpnNetworkSelectionRefusals[number];
99
+
100
+ /** What a pushed network did to the cluster's hub, in the hub's own words. */
101
+ export type TClusterVpnNetworkState = 'idle' | 'applying' | 'failed';
102
+
103
+ export interface IApplyClusterVpnNetworkRequest {
104
+ network: IClusterVpnNetwork;
105
+ }
106
+
107
+ /**
108
+ * What the relay answers: the revision its hub has applied, or `null` while it has applied none,
109
+ * and what the hub is doing. `idle` means the pushed revision is the applied one.
110
+ */
111
+ export interface IApplyClusterVpnNetworkResponse {
112
+ appliedRevision: number | null;
113
+ state: TClusterVpnNetworkState;
114
+ }
115
+
116
+ /**
117
+ * The pull carries no body. The relay's own socket names the cluster, and a hub daemon that just
118
+ * started holds nothing it could state about what it is missing.
119
+ */
120
+ export type TGetClusterVpnNetworkRequest = Record<string, never>;
121
+
122
+ /** `null` while the cluster has no managed VPN: no address plan, or no hub selected for it yet. */
123
+ export interface IGetClusterVpnNetworkResponse {
124
+ network: IClusterVpnNetwork | null;
125
+ }
126
+
127
+ /** The policy domain of one node's control address. One rule, so both sides name it identically. */
128
+ export const clusterVpnControlPolicyDomain = (addressValue: unknown): string => networkSafe(() =>
129
+ `${clusterVpnNetworkContract.controlPolicyDomainPrefix}${networkIpv4(addressValue)}`);
130
+
131
+ /** The policy domain of one workload lease, from that lease's canonical digest. */
132
+ export const clusterVpnWorkloadPolicyDomain = (digestValue: unknown): string => networkSafe(() => {
133
+ requireNetwork(typeof digestValue === 'string' && /^sha256:[a-f0-9]{64}$/.test(digestValue));
134
+ return `${clusterVpnNetworkContract.workloadPolicyDomainPrefix}${(digestValue as string).slice(7)}`;
135
+ });
136
+
137
+ const workloadPolicyDomain = (value: unknown): string => {
138
+ requireNetwork(typeof value === 'string'
139
+ && value.startsWith(clusterVpnNetworkContract.workloadPolicyDomainPrefix)
140
+ && /^[a-f0-9]{64}$/.test(value.slice(clusterVpnNetworkContract.workloadPolicyDomainPrefix.length)));
141
+ return value as string;
142
+ };
143
+
144
+ const prefixOf = (value: unknown): IClusterVpnNetworkPrefix => {
145
+ const item = networkRecord(value, ['cidr', 'policyDomain']);
146
+ return { cidr: networkPrivatePrefix(item.cidr), policyDomain: workloadPolicyDomain(item.policyDomain) };
147
+ };
148
+
149
+ const nodeOf = (value: unknown): IClusterVpnNetworkNode => {
150
+ const item = networkRecord(value, ['nodeId', 'publicKey', 'controlAddress', 'expiresAt', 'workloadPrefixes']);
151
+ return { nodeId: networkId(item.nodeId), publicKey: networkNoiseKey(item.publicKey), controlAddress: networkIpv4(item.controlAddress),
152
+ expiresAt: networkCounter(item.expiresAt),
153
+ workloadPrefixes: networkSorted(networkList(item.workloadPrefixes, clusterVpnNetworkContract.maximumWorkloadPrefixes, prefixOf),
154
+ prefix => prefix.cidr) };
155
+ };
156
+
157
+ const grantOf = (value: unknown): IClusterVpnNetworkGrant => {
158
+ const item = networkRecord(value, ['sourceDomain', 'destinationDomain']);
159
+ return { sourceDomain: workloadPolicyDomain(item.sourceDomain), destinationDomain: workloadPolicyDomain(item.destinationDomain) };
160
+ };
161
+
162
+ const grantKey = (grant: IClusterVpnNetworkGrant): string => `${grant.sourceDomain}\0${grant.destinationDomain}`;
163
+
164
+ /**
165
+ * Structural snapshot of a complete cluster network.
166
+ *
167
+ * Every node, prefix and grant is stated once and in canonical order, and every grant names a
168
+ * workload domain this network itself carries, on two different nodes. That last rule is what a
169
+ * per-cluster hub means: a grant whose other end sits on another cluster's hub cannot be expressed
170
+ * here at all, which is why Cloudly refuses it by name while it compiles instead of pushing it.
171
+ */
172
+ export const snapshotClusterVpnNetwork = (value: unknown): IClusterVpnNetwork => networkSafe(() => {
173
+ const captured = captureNetwork(value);
174
+ requireNetwork(new TextEncoder().encode(networkCanonical(captured)).length <= clusterVpnNetworkContract.maximumCanonicalBytes);
175
+ const item = networkRecord(captured, ['authorityId', 'revision', 'nodes', 'grants']);
176
+ const nodes = networkSorted(networkList(item.nodes, clusterVpnNetworkContract.maximumNodes, nodeOf), node => node.nodeId);
177
+ const grants = networkSorted(networkList(item.grants, clusterVpnNetworkContract.maximumGrants, grantOf), grantKey);
178
+ const domainOwners = new Map<string, string>();
179
+ const addresses = new Set<string>(), keys = new Set<string>(), cidrs = new Set<string>();
180
+ for (const node of nodes) {
181
+ requireNetwork(!keys.has(node.publicKey) && !addresses.has(node.controlAddress));
182
+ keys.add(node.publicKey); addresses.add(node.controlAddress);
183
+ for (const prefix of node.workloadPrefixes) {
184
+ requireNetwork(!cidrs.has(prefix.cidr) && !domainOwners.has(prefix.policyDomain));
185
+ cidrs.add(prefix.cidr); domainOwners.set(prefix.policyDomain, node.nodeId);
186
+ }
187
+ }
188
+ for (const grant of grants) {
189
+ const source = domainOwners.get(grant.sourceDomain), destination = domainOwners.get(grant.destinationDomain);
190
+ requireNetwork(source && destination && source !== destination);
191
+ }
192
+ return { authorityId: networkId(item.authorityId), revision: networkCounter(item.revision), nodes, grants };
193
+ });
194
+
195
+ export type TClusterVpnNetworkAdmission =
196
+ | { status: 'accepted' }
197
+ | { status: 'replay' }
198
+ | { status: 'refused'; reason: TApplyClusterVpnNetworkRefusal };
199
+
200
+ /**
201
+ * The pure decision a relay makes about a pushed network against the one its hub holds, or null
202
+ * when the hub holds none — which is every hub that just started, because a daemon restart starts
203
+ * empty. A repeat of the held network is a replay the relay may apply again without effect; a lower
204
+ * revision, or the same revision with different content, is stale and refused by name.
205
+ */
206
+ export const admitClusterVpnNetwork = (heldValue: unknown | null, nextValue: unknown): TClusterVpnNetworkAdmission =>
207
+ networkSafe(() => {
208
+ const next = snapshotClusterVpnNetwork(nextValue);
209
+ if (heldValue === null) return { status: 'accepted' };
210
+ const held = snapshotClusterVpnNetwork(heldValue);
211
+ if (held.authorityId !== next.authorityId) return { status: 'refused', reason: 'vpn-network-authority-foreign' };
212
+ if (next.revision > held.revision) return { status: 'accepted' };
213
+ return networkEqual(held, next) ? { status: 'replay' } : { status: 'refused', reason: 'vpn-network-revision-stale' };
214
+ });
215
+
216
+ export const snapshotApplyClusterVpnNetworkRequest = (value: unknown): IApplyClusterVpnNetworkRequest => networkSafe(() => {
217
+ const item = networkRecord(captureNetwork(value), ['network']);
218
+ return { network: snapshotClusterVpnNetwork(item.network) };
219
+ });
220
+
221
+ export const snapshotApplyClusterVpnNetworkResponse = (value: unknown): IApplyClusterVpnNetworkResponse => networkSafe(() => {
222
+ const item = networkRecord(captureNetwork(value), ['appliedRevision', 'state']);
223
+ requireNetwork(item.state === 'idle' || item.state === 'applying' || item.state === 'failed');
224
+ return { appliedRevision: item.appliedRevision === null ? null : networkCounter(item.appliedRevision), state: item.state };
225
+ });
226
+
227
+ /**
228
+ * Compare the answer with the exact SENT network: a hub that reports itself idle has applied the
229
+ * revision it was sent, and no hub can report having applied a later one than exists.
230
+ */
231
+ export const bindApplyClusterVpnNetworkResponse = (value: unknown, sentRequestValue: unknown): IApplyClusterVpnNetworkResponse =>
232
+ networkSafe(() => {
233
+ const response = snapshotApplyClusterVpnNetworkResponse(value);
234
+ const sent = snapshotApplyClusterVpnNetworkRequest(sentRequestValue);
235
+ requireNetwork(response.appliedRevision === null || response.appliedRevision <= sent.network.revision);
236
+ requireNetwork(response.state !== 'idle' || response.appliedRevision === sent.network.revision);
237
+ return response;
238
+ });
239
+
240
+ export const snapshotGetClusterVpnNetworkRequest = (value: unknown): TGetClusterVpnNetworkRequest => networkSafe(() => {
241
+ networkRecord(captureNetwork(value), []);
242
+ return {};
243
+ });
244
+
245
+ export const snapshotGetClusterVpnNetworkResponse = (value: unknown): IGetClusterVpnNetworkResponse => networkSafe(() => {
246
+ const item = networkRecord(captureNetwork(value), ['network']);
247
+ return { network: item.network === null ? null : snapshotClusterVpnNetwork(item.network) };
248
+ });
package/ts/data/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './cloudlyconfig.js';
2
2
  export * from './cluster.js';
3
3
  export * from './clusterruntime.js';
4
+ export * from './clustervpn.js';
4
5
  export * from './clusteringress.js';
5
6
  export * from './config.js';
6
7
  export * from './corestore.js';
@@ -1,9 +1,10 @@
1
1
  import type { TSha256Digest } from './immutableimage.js';
2
2
  import type { IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
3
- import { computeRuntimeNetworkProtectedAuthorityDigest, snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
4
- import type { IGetRuntimeManagedVpnCredentialResponse } from './networkcredential.js';
5
- import { captureNetwork, failNetwork, networkContains, networkCounter, networkEqual, networkId, networkIpv4, networkOverlaps,
6
- networkPrefixBounds, networkPrivatePrefix, networkRecord, networkSafe, requireNetwork } from '../private/networkcontract.js';
3
+ import { computeRuntimeNetworkProtectedAuthorityDigest, runtimeNetworkAuthorityContract,
4
+ snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
5
+ import { captureNetwork, failNetwork, networkContains, networkCounter, networkEqual, networkId, networkIpv4, networkList,
6
+ networkOverlaps, networkPrefix, networkPrefixBounds, networkPrivatePrefix, networkRecord, networkSafe,
7
+ requireNetwork } from '../private/networkcontract.js';
7
8
 
8
9
  /**
9
10
  * The address space an administrator gives the runtime network, and the one input the protected
@@ -18,16 +19,36 @@ export interface IRuntimeNetworkAddressPlan {
18
19
  prefixes: string[];
19
20
  pools: IRuntimeNetworkProtectedAuthority['pools'];
20
21
  resolvers: IRuntimeNetworkProtectedAuthority['resolvers'];
21
- /** Without the VPN relay, which the composed authority adds as a UDP platform endpoint. */
22
+ /** Without the cluster hubs, which the composed authority adds from the hubs it is given. */
22
23
  platformEndpoints: IRuntimeNetworkProtectedAuthority['platformEndpoints'];
23
24
  vpn: {
24
25
  /** The private prefix VPN control addresses are allocated from. */
25
26
  controlPrefix: string;
26
- /** The endpoint every node dials for the managed VPN. */
27
- relay: IGetRuntimeManagedVpnCredentialResponse['relay'];
27
+ /**
28
+ * The protected prefix every cluster hub binds inside.
29
+ *
30
+ * The endpoints themselves are no administrator's input: a cluster's relay reports the one it
31
+ * bound and the composition turns it into platform endpoints, so this prefix is what an
32
+ * administrator reserves for them.
33
+ */
34
+ hubPrefix: string;
28
35
  };
29
36
  }
30
37
 
38
+ /**
39
+ * One cluster's managed VPN hub, as the cluster's relay bound it.
40
+ *
41
+ * The hub is its cluster's own packet relay, so `clusterId` names both the hub and the authority
42
+ * that may select it, and `address` is an underlay address inside the plan's `vpn.hubPrefix`. A hub
43
+ * offers at least one transport; the port of a transport it did not bind is `null`.
44
+ */
45
+ export interface IRuntimeNetworkVpnHub {
46
+ clusterId: string;
47
+ address: string;
48
+ quicPort: number | null;
49
+ websocketPort: number | null;
50
+ }
51
+
31
52
  /** What an administrator writes; Cloudly supplies the id and the revision. */
32
53
  export type TRuntimeNetworkAddressPlanWritableData = Omit<IRuntimeNetworkAddressPlan, 'id' | 'revision'>;
33
54
 
@@ -58,9 +79,54 @@ export const runtimeNetworkAddressPlanContract = Object.freeze({
58
79
  * is stated: the private-range rule already keeps the control prefix at /8 or longer.
59
80
  */
60
81
  maximumControlPrefixLength: 30,
61
- relayProtocol: 'udp',
82
+ /**
83
+ * Every transport a hub may offer, in the order a node prefers them.
84
+ *
85
+ * One row states all three facts that belong together: the transport a credential names, the
86
+ * suffix its platform endpoint id carries, and the protocol the endpoint is composed with. A hub
87
+ * that bound both transports therefore contributes exactly two endpoints of one address.
88
+ */
89
+ hubTransports: Object.freeze([
90
+ Object.freeze({ transport: 'quic', endpointSuffix: 'quic', protocol: 'udp' }),
91
+ Object.freeze({ transport: 'websocket', endpointSuffix: 'ws', protocol: 'tcp' }),
92
+ ]),
62
93
  } as const);
63
94
 
95
+ /** How a node reaches its cluster's hub, read off the one table that states the transports. */
96
+ export type TRuntimeNetworkVpnTransport = typeof runtimeNetworkAddressPlanContract.hubTransports[number]['transport'];
97
+
98
+ /** The port a hub bound for one transport, or null when it did not bind that transport. */
99
+ const hubPort = (hub: IRuntimeNetworkVpnHub, transport: TRuntimeNetworkVpnTransport): number | null =>
100
+ transport === 'quic' ? hub.quicPort : hub.websocketPort;
101
+
102
+ const byEndpointId = <T extends { id: string }>(left: T, right: T): number =>
103
+ left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
104
+
105
+ const hubOf = (value: unknown): IRuntimeNetworkVpnHub => {
106
+ const item = networkRecord(value, ['clusterId', 'address', 'quicPort', 'websocketPort']);
107
+ const port = (value: unknown): number | null => value === null ? null : networkCounter(value, 65535);
108
+ const hub: IRuntimeNetworkVpnHub = { clusterId: networkId(item.clusterId), address: networkIpv4(item.address),
109
+ quicPort: port(item.quicPort), websocketPort: port(item.websocketPort) };
110
+ // A hub that bound no transport is not reachable, so it is not a hub a node could be sent to.
111
+ requireNetwork(runtimeNetworkAddressPlanContract.hubTransports.some(row => hubPort(hub, row.transport) !== null));
112
+ return hub;
113
+ };
114
+
115
+ /**
116
+ * The platform endpoints one hub contributes to a protected authority: one per transport it bound,
117
+ * in id order. Every reader derives the same ids from this one function — the composition that seals
118
+ * the authority, and the router selection that names which of them a node may dial.
119
+ */
120
+ export const runtimeNetworkVpnHubEndpoints = (hubValue: unknown): IRuntimeNetworkProtectedAuthority['platformEndpoints'] =>
121
+ networkSafe(() => {
122
+ const hub = hubOf(captureNetwork(hubValue));
123
+ return runtimeNetworkAddressPlanContract.hubTransports
124
+ .filter(row => hubPort(hub, row.transport) !== null)
125
+ .map(row => ({ id: `${hub.clusterId}:${row.endpointSuffix}`, address: hub.address,
126
+ protocol: row.protocol, port: hubPort(hub, row.transport) as number }))
127
+ .sort(byEndpointId);
128
+ });
129
+
64
130
  /**
65
131
  * The structural authority validator reads a frame's shape and nothing else, and a plan fixes none of
66
132
  * the frame, so every valid frame decides a plan the same way. The digest is a well-formed
@@ -81,31 +147,36 @@ const authorityOf = (
81
147
  generation: frame.generation, controller: frame.controller, previous: frame.previous, egressAuthorities: frame.egressAuthorities,
82
148
  prefixes: sets.prefixes, pools: sets.pools, resolvers: sets.resolvers, platformEndpoints: sets.platformEndpoints, digest: unsealedDigest });
83
149
 
84
- const setsWithRelay = (plan: IRuntimeNetworkAddressPlan): TAuthoritySets => ({
150
+ const setsWithHubs = (plan: IRuntimeNetworkAddressPlan, hubs: IRuntimeNetworkVpnHub[]): TAuthoritySets => ({
85
151
  prefixes: plan.prefixes, pools: plan.pools, resolvers: plan.resolvers,
86
- platformEndpoints: [...plan.platformEndpoints, { ...plan.vpn.relay, protocol: runtimeNetworkAddressPlanContract.relayProtocol }]
87
- .sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
152
+ platformEndpoints: [...plan.platformEndpoints, ...hubs.flatMap(runtimeNetworkVpnHubEndpoints)].sort(byEndpointId),
88
153
  });
89
154
 
90
155
  const vpnOf = (value: unknown): IRuntimeNetworkAddressPlan['vpn'] => {
91
- const item = networkRecord(value, ['controlPrefix', 'relay']);
92
- const relay = networkRecord(item.relay, ['id', 'address', 'port']);
156
+ const item = networkRecord(value, ['controlPrefix', 'hubPrefix']);
93
157
  const controlPrefix = networkPrivatePrefix(item.controlPrefix);
94
158
  requireNetwork(Number(controlPrefix.split('/')[1]) <= runtimeNetworkAddressPlanContract.maximumControlPrefixLength);
95
- return { controlPrefix, relay: { id: networkId(relay.id), address: networkIpv4(relay.address), port: networkCounter(relay.port, 65535) } };
159
+ return { controlPrefix, hubPrefix: networkPrefix(item.hubPrefix) };
96
160
  };
97
161
 
98
- /** The control prefix is protected, and no pool, resolver or platform endpoint, the relay included, sits inside it. */
99
- const requireVpnProtected = (authority: IRuntimeNetworkProtectedAuthority, controlPrefix: string): void => {
100
- requireNetwork(authority.prefixes.some(prefix => networkContains(prefix, controlPrefix))
101
- && authority.pools.every(pool => !networkOverlaps(pool.prefix, controlPrefix))
102
- && [...authority.resolvers, ...authority.platformEndpoints].every(endpoint => !networkContains(controlPrefix, `${endpoint.address}/32`)));
162
+ /**
163
+ * Both VPN prefixes are protected, they hold none of the plan's own pools, resolvers or platform
164
+ * endpoints, and they do not overlap each other: control addresses live inside the tunnel and hub
165
+ * endpoints on the underlay, so one prefix can never contain the other's addresses.
166
+ */
167
+ const requireVpnProtected = (authority: IRuntimeNetworkProtectedAuthority, vpn: IRuntimeNetworkAddressPlan['vpn']): void => {
168
+ const endpoints = [...authority.resolvers, ...authority.platformEndpoints];
169
+ requireNetwork([vpn.controlPrefix, vpn.hubPrefix].every(prefix =>
170
+ authority.prefixes.some(range => networkContains(range, prefix))
171
+ && authority.pools.every(pool => !networkOverlaps(pool.prefix, prefix))
172
+ && endpoints.every(endpoint => !networkContains(prefix, `${endpoint.address}/32`)))
173
+ && !networkOverlaps(vpn.controlPrefix, vpn.hubPrefix));
103
174
  };
104
175
 
105
176
  /**
106
- * Structural snapshot for exact persistence and for a plan write. A plan passes only when both
107
- * authorities it can describe pass the protected authority's own validator: its sets alone, and
108
- * its sets with the relay added, so no plan can compose an authority that contract refuses.
177
+ * Structural snapshot for exact persistence and for a plan write. The plan's own sets pass the
178
+ * protected authority's own validator, so no plan can describe an authority that contract refuses,
179
+ * and both VPN prefixes are reserved inside the protected space the plan states.
109
180
  */
110
181
  export const snapshotRuntimeNetworkAddressPlan = (value: unknown): IRuntimeNetworkAddressPlan => networkSafe(() => {
111
182
  const item = networkRecord(captureNetwork(value), ['id', 'revision', 'prefixes', 'pools', 'resolvers', 'platformEndpoints', 'vpn']);
@@ -114,22 +185,27 @@ export const snapshotRuntimeNetworkAddressPlan = (value: unknown): IRuntimeNetwo
114
185
  platformEndpoints: item.platformEndpoints }, validationFrame);
115
186
  const plan: IRuntimeNetworkAddressPlan = { id: runtimeNetworkAddressPlanContract.id, revision: networkCounter(item.revision),
116
187
  prefixes: own.prefixes, pools: own.pools, resolvers: own.resolvers, platformEndpoints: own.platformEndpoints, vpn: vpnOf(item.vpn) };
117
- requireVpnProtected(authorityOf(setsWithRelay(plan), validationFrame), plan.vpn.controlPrefix);
188
+ requireVpnProtected(own, plan.vpn);
118
189
  return plan;
119
190
  });
120
191
 
121
192
  /**
122
193
  * The protected authority a plan describes inside a producer's frame, digest included: the plan's
123
- * sets with the VPN relay as the UDP platform endpoint every node dials. Both inputs are read before
124
- * the digest is computed.
194
+ * sets plus the endpoints of every cluster hub it is given, each bound inside `vpn.hubPrefix`. One
195
+ * cluster contributes one hub, so `hubs` never names a cluster twice. All inputs are read before the
196
+ * digest is computed; an empty `hubs` composes the authority of a runtime network whose clusters
197
+ * have not registered a hub yet.
125
198
  */
126
199
  export const composeRuntimeNetworkProtectedAuthority = async (
127
- planValue: unknown, frameValue: unknown,
200
+ planValue: unknown, frameValue: unknown, hubsValue: unknown,
128
201
  ): Promise<IRuntimeNetworkProtectedAuthority> => {
129
202
  try {
130
203
  const plan = snapshotRuntimeNetworkAddressPlan(planValue);
131
204
  const frame = networkRecord(captureNetwork(frameValue), ['id', 'generation', 'controller', 'previous', 'egressAuthorities']);
132
- const authority = authorityOf(setsWithRelay(plan), frame);
205
+ const hubs = networkList(captureNetwork(hubsValue), runtimeNetworkAuthorityContract.maximumPlatformEndpoints, hubOf);
206
+ requireNetwork(new Set(hubs.map(hub => hub.clusterId)).size === hubs.length
207
+ && hubs.every(hub => networkContains(plan.vpn.hubPrefix, `${hub.address}/32`)));
208
+ const authority = authorityOf(setsWithHubs(plan, hubs), frame);
133
209
  return { ...authority, digest: await computeRuntimeNetworkProtectedAuthorityDigest(authority) };
134
210
  } catch { return failNetwork(); }
135
211
  };