@serve.zone/interfaces 32.3.0 → 32.5.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.
Files changed (61) hide show
  1. package/changelog.md +88 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/cluster.d.ts +30 -0
  4. package/dist_ts/data/cluster.js +48 -3
  5. package/dist_ts/data/clusteringress.js +9 -8
  6. package/dist_ts/data/clusternode.d.ts +6 -1
  7. package/dist_ts/data/clusternode.js +32 -5
  8. package/dist_ts/data/clustervpn.d.ts +135 -0
  9. package/dist_ts/data/clustervpn.js +147 -0
  10. package/dist_ts/data/index.d.ts +1 -0
  11. package/dist_ts/data/index.js +2 -1
  12. package/dist_ts/data/isolatedrestore.js +12 -4
  13. package/dist_ts/data/networkaddressplan.d.ts +54 -11
  14. package/dist_ts/data/networkaddressplan.js +67 -23
  15. package/dist_ts/data/networkauthority.js +9 -3
  16. package/dist_ts/data/networkcredential.d.ts +13 -4
  17. package/dist_ts/data/networkcredential.js +41 -23
  18. package/dist_ts/data/networklease.js +3 -3
  19. package/dist_ts/data/networknode.js +3 -3
  20. package/dist_ts/data/networkprojection.js +8 -3
  21. package/dist_ts/data/nodeenrollment.js +4 -2
  22. package/dist_ts/data/noderetirement.js +3 -3
  23. package/dist_ts/data/runtimeassignment.d.ts +35 -0
  24. package/dist_ts/data/runtimeassignment.js +70 -10
  25. package/dist_ts/data/runtimesession.js +3 -2
  26. package/dist_ts/data/serviceruntime.js +6 -5
  27. package/dist_ts/data/sparknodereport.js +6 -3
  28. package/dist_ts/private/networkcontract.d.ts +8 -0
  29. package/dist_ts/private/networkcontract.js +19 -2
  30. package/dist_ts/private/nodeidentifier.d.ts +22 -0
  31. package/dist_ts/private/nodeidentifier.js +23 -0
  32. package/dist_ts/requests/cluster.d.ts +27 -0
  33. package/dist_ts/requests/cluster.js +1 -1
  34. package/dist_ts/requests/corestore.js +4 -4
  35. package/dist_ts/runtime.js +7 -4
  36. package/package.json +1 -1
  37. package/readme.md +132 -21
  38. package/ts/00_commitinfo_data.ts +1 -1
  39. package/ts/data/cluster.ts +78 -2
  40. package/ts/data/clusteringress.ts +8 -7
  41. package/ts/data/clusternode.ts +32 -4
  42. package/ts/data/clustervpn.ts +248 -0
  43. package/ts/data/index.ts +1 -0
  44. package/ts/data/isolatedrestore.ts +12 -3
  45. package/ts/data/networkaddressplan.ts +103 -27
  46. package/ts/data/networkauthority.ts +8 -2
  47. package/ts/data/networkcredential.ts +52 -24
  48. package/ts/data/networklease.ts +2 -2
  49. package/ts/data/networknode.ts +2 -2
  50. package/ts/data/networkprojection.ts +6 -2
  51. package/ts/data/nodeenrollment.ts +4 -2
  52. package/ts/data/noderetirement.ts +2 -2
  53. package/ts/data/runtimeassignment.ts +84 -8
  54. package/ts/data/runtimesession.ts +3 -2
  55. package/ts/data/serviceruntime.ts +6 -5
  56. package/ts/data/sparknodereport.ts +7 -2
  57. package/ts/private/networkcontract.ts +17 -1
  58. package/ts/private/nodeidentifier.ts +25 -0
  59. package/ts/requests/cluster.ts +35 -0
  60. package/ts/requests/corestore.ts +3 -3
  61. package/ts/runtime.ts +6 -3
@@ -0,0 +1,248 @@
1
+ import { captureNetwork, failNetwork, networkCanonical, networkCounter, networkEqual, networkId, networkIpv4, networkList,
2
+ networkNodeId, 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: networkNodeId(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';
@@ -12,6 +12,7 @@ import {
12
12
  strictCanonicalJsonRules,
13
13
  } from '../private/canonicaljson.js';
14
14
  import { hasExactKeys } from '../private/dataproperties.js';
15
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
15
16
 
16
17
  /** Operations carried by a short-lived, single-operation restore grant. */
17
18
  export const isolatedRestoreGrantOperations = Object.freeze([
@@ -521,6 +522,14 @@ const readIdentifier = (valueArg: unknown, fieldNameArg: string): string => {
521
522
  return valueArg;
522
523
  };
523
524
 
525
+ /** A node of the cluster, read in the node vocabulary the controller named it in. */
526
+ const readNodeId = (valueArg: unknown, fieldNameArg: string): string => {
527
+ if (!isClusterNodeId(valueArg)) {
528
+ return fail(`${fieldNameArg} must be a node id`);
529
+ }
530
+ return valueArg;
531
+ };
532
+
524
533
  const readScopedId = (valueArg: unknown, fieldNameArg: string): string => {
525
534
  if (typeof valueArg !== 'string' || !canonicalScopedIdPattern.test(valueArg)) {
526
535
  return fail(`${fieldNameArg} must be a canonical scoped identifier`);
@@ -1469,7 +1478,7 @@ export const normalizeIsolatedRestoreNodeControl = <TControlGeneric>(
1469
1478
  const request = readRecord(valueArg, 'node control request');
1470
1479
  assertExactKeys(request, ['nodeId', 'control'], 'node control request');
1471
1480
  return Object.freeze({
1472
- nodeId: readIdentifier(request.nodeId, 'node control request.nodeId'),
1481
+ nodeId: readNodeId(request.nodeId, 'node control request.nodeId'),
1473
1482
  control: normalizeControlArg(request.control),
1474
1483
  });
1475
1484
  };
@@ -1656,7 +1665,7 @@ export const normalizeIsolatedRestoreProgress = (
1656
1665
  );
1657
1666
  const preparedAt = readSafeInteger(progress.preparedAt, 'restore progress.preparedAt', 1);
1658
1667
  const normalized: IIsolatedRestoreProgress = {
1659
- nodeId: readIdentifier(progress.nodeId, 'restore progress.nodeId'),
1668
+ nodeId: readNodeId(progress.nodeId, 'restore progress.nodeId'),
1660
1669
  restoreId: readScopedId(progress.restoreId, 'restore progress.restoreId'),
1661
1670
  stagingArchiveId: readScopedId(
1662
1671
  progress.stagingArchiveId,
@@ -1737,7 +1746,7 @@ export const bindIsolatedRestoreProgressToNode = (
1737
1746
  ): Readonly<IIsolatedRestoreProgress> => {
1738
1747
  const expectation = readRecord(expectationArg, 'restore progress expectation');
1739
1748
  assertExactKeys(expectation, ['nodeId', 'restoreId'], 'restore progress expectation');
1740
- const nodeId = readIdentifier(expectation.nodeId, 'restore progress expectation.nodeId');
1749
+ const nodeId = readNodeId(expectation.nodeId, 'restore progress expectation.nodeId');
1741
1750
  const restoreId = readScopedId(
1742
1751
  expectation.restoreId,
1743
1752
  'restore progress expectation.restoreId',
@@ -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
  };
@@ -2,6 +2,7 @@ import type { TSha256Digest } from './immutableimage.js';
2
2
  import type { IRuntimeAuthorityReference, IRuntimeController } from './runtimeassignment.js';
3
3
  import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from '../private/canonicaljson.js';
4
4
  import { networkPrefix, networkPrivatePrefix } from '../private/networkcontract.js';
5
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
5
6
 
6
7
  /** Controller declaration; a digest does not authenticate completeness or activate a pool. */
7
8
  export interface IRuntimeNetworkProtectedAuthority {
@@ -91,6 +92,11 @@ const id = (value: unknown): string => {
91
92
  requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(value));
92
93
  return value;
93
94
  };
95
+ /** A node id, in the node vocabulary every other contract reads it in. */
96
+ const nodeId = (value: unknown): string => {
97
+ requireValue(isClusterNodeId(value));
98
+ return value as string;
99
+ };
94
100
  const integer = (value: unknown, maximum = Number.MAX_SAFE_INTEGER - 1): number => {
95
101
  requireValue(typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= maximum);
96
102
  return value;
@@ -185,7 +191,7 @@ export const snapshotRuntimeNetworkProtectedAuthority = (value: unknown): IRunti
185
191
  requireValue(new Set(allEndpointKeys).size === allEndpointKeys.length);
186
192
  const egressAuthorities = sorted(list(item.egressAuthorities, runtimeNetworkAuthorityContract.maximumEgressAuthorities, value => {
187
193
  const authority = record(value, ['nodeId', 'runtimeNamespace']);
188
- return { nodeId: id(authority.nodeId), runtimeNamespace: id(authority.runtimeNamespace) };
194
+ return { nodeId: nodeId(authority.nodeId), runtimeNamespace: id(authority.runtimeNamespace) };
189
195
  }), value => canonical([value.nodeId, value.runtimeNamespace]));
190
196
  requireValue(egressAuthorities.length > 0);
191
197
  return { id: identity, generation, controller: controller(item.controller), previous,
@@ -217,7 +223,7 @@ export const snapshotRuntimeNetworkHandoffLease = (value: unknown): IRuntimeNetw
217
223
  requireValue(typeof item.conntrackLabel === 'string' && /^[a-f0-9]{32}$/.test(item.conntrackLabel)
218
224
  && item.conntrackLabel !== '0'.repeat(32));
219
225
  return { id: id(item.id), generation: integer(item.generation), controller: controller(item.controller),
220
- nodeId: id(item.nodeId), runtimeNamespace: id(item.runtimeNamespace), protectedAuthority: reference(item.protectedAuthority),
226
+ nodeId: nodeId(item.nodeId), runtimeNamespace: id(item.runtimeNamespace), protectedAuthority: reference(item.protectedAuthority),
221
227
  routerIncarnationId: id(item.routerIncarnationId), transit: { poolId: id(transit.poolId), prefix: transitPrefix, routerAddress, hostAddress },
222
228
  sourcePortRanges, conntrackZone: integer(item.conntrackZone, 65535), conntrackLabel: item.conntrackLabel, digest: digest(item.digest) };
223
229
  });