@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.
- package/changelog.md +88 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +30 -0
- package/dist_ts/data/cluster.js +48 -3
- package/dist_ts/data/clusteringress.js +9 -8
- package/dist_ts/data/clusternode.d.ts +6 -1
- package/dist_ts/data/clusternode.js +32 -5
- package/dist_ts/data/clustervpn.d.ts +135 -0
- package/dist_ts/data/clustervpn.js +147 -0
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/isolatedrestore.js +12 -4
- package/dist_ts/data/networkaddressplan.d.ts +54 -11
- package/dist_ts/data/networkaddressplan.js +67 -23
- package/dist_ts/data/networkauthority.js +9 -3
- package/dist_ts/data/networkcredential.d.ts +13 -4
- package/dist_ts/data/networkcredential.js +41 -23
- package/dist_ts/data/networklease.js +3 -3
- package/dist_ts/data/networknode.js +3 -3
- package/dist_ts/data/networkprojection.js +8 -3
- package/dist_ts/data/nodeenrollment.js +4 -2
- package/dist_ts/data/noderetirement.js +3 -3
- package/dist_ts/data/runtimeassignment.d.ts +35 -0
- package/dist_ts/data/runtimeassignment.js +70 -10
- package/dist_ts/data/runtimesession.js +3 -2
- package/dist_ts/data/serviceruntime.js +6 -5
- package/dist_ts/data/sparknodereport.js +6 -3
- package/dist_ts/private/networkcontract.d.ts +8 -0
- package/dist_ts/private/networkcontract.js +19 -2
- package/dist_ts/private/nodeidentifier.d.ts +22 -0
- package/dist_ts/private/nodeidentifier.js +23 -0
- package/dist_ts/requests/cluster.d.ts +27 -0
- package/dist_ts/requests/cluster.js +1 -1
- package/dist_ts/requests/corestore.js +4 -4
- package/dist_ts/runtime.js +7 -4
- package/package.json +1 -1
- package/readme.md +132 -21
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +78 -2
- package/ts/data/clusteringress.ts +8 -7
- package/ts/data/clusternode.ts +32 -4
- package/ts/data/clustervpn.ts +248 -0
- package/ts/data/index.ts +1 -0
- package/ts/data/isolatedrestore.ts +12 -3
- package/ts/data/networkaddressplan.ts +103 -27
- package/ts/data/networkauthority.ts +8 -2
- package/ts/data/networkcredential.ts +52 -24
- package/ts/data/networklease.ts +2 -2
- package/ts/data/networknode.ts +2 -2
- package/ts/data/networkprojection.ts +6 -2
- package/ts/data/nodeenrollment.ts +4 -2
- package/ts/data/noderetirement.ts +2 -2
- package/ts/data/runtimeassignment.ts +84 -8
- package/ts/data/runtimesession.ts +3 -2
- package/ts/data/serviceruntime.ts +6 -5
- package/ts/data/sparknodereport.ts +7 -2
- package/ts/private/networkcontract.ts +17 -1
- package/ts/private/nodeidentifier.ts +25 -0
- package/ts/requests/cluster.ts +35 -0
- package/ts/requests/corestore.ts +3 -3
- 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
|
@@ -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:
|
|
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:
|
|
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 =
|
|
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,
|
|
4
|
-
|
|
5
|
-
import { captureNetwork, failNetwork, networkContains, networkCounter, networkEqual, networkId, networkIpv4,
|
|
6
|
-
networkPrefixBounds, networkPrivatePrefix, networkRecord, networkSafe,
|
|
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
|
|
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
|
-
/**
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
150
|
+
const setsWithHubs = (plan: IRuntimeNetworkAddressPlan, hubs: IRuntimeNetworkVpnHub[]): TAuthoritySets => ({
|
|
85
151
|
prefixes: plan.prefixes, pools: plan.pools, resolvers: plan.resolvers,
|
|
86
|
-
platformEndpoints: [...plan.platformEndpoints,
|
|
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', '
|
|
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,
|
|
159
|
+
return { controlPrefix, hubPrefix: networkPrefix(item.hubPrefix) };
|
|
96
160
|
};
|
|
97
161
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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(
|
|
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
|
|
124
|
-
*
|
|
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
|
|
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:
|
|
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:
|
|
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
|
});
|