@serve.zone/interfaces 32.5.0 → 32.7.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 +80 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +4 -5
- package/dist_ts/data/cluster.js +4 -13
- package/dist_ts/data/clustervpn.d.ts +18 -1
- package/dist_ts/data/clustervpn.js +23 -7
- package/dist_ts/data/deploymentpreflight.d.ts +21 -0
- package/dist_ts/data/deploymentpreflight.js +19 -1
- package/dist_ts/data/networkaddressplan.d.ts +21 -15
- package/dist_ts/data/networkaddressplan.js +27 -20
- package/dist_ts/data/networkcredential.d.ts +1 -7
- package/dist_ts/data/networkcredential.js +7 -24
- package/dist_ts/private/networkcontract.d.ts +16 -0
- package/dist_ts/private/networkcontract.js +21 -1
- package/dist_ts/requests/service.d.ts +13 -0
- package/package.json +1 -1
- package/readme.md +58 -25
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +7 -20
- package/ts/data/clustervpn.ts +34 -9
- package/ts/data/deploymentpreflight.ts +38 -0
- package/ts/data/networkaddressplan.ts +32 -25
- package/ts/data/networkcredential.ts +9 -33
- package/ts/private/networkcontract.ts +20 -0
- package/ts/requests/service.ts +13 -0
package/ts/data/clustervpn.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { runtimeNetworkVpnControlPrefix } from './networkaddressplan.js';
|
|
2
|
+
import { captureNetwork, failNetwork, networkAuthorityId, networkCanonical, networkContains, networkCounter, networkEqual,
|
|
3
|
+
networkIpv4, networkList, networkMaximumAuthorityIdBytes, networkNodeId, networkNoiseKey, networkOverlaps,
|
|
4
|
+
networkPrivatePrefix, networkRecord, networkSafe, networkSorted, requireNetwork } from '../private/networkcontract.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* One workload prefix a node owns inside its cluster's managed VPN, and the policy domain that
|
|
@@ -39,10 +40,18 @@ export interface IClusterVpnNetworkGrant {
|
|
|
39
40
|
* Complete, never a patch: a relay applies it as the whole authority its hub serves, so a member or
|
|
40
41
|
* a grant that is missing is withdrawn. `revision` is Cloudly's own counter for this authority and
|
|
41
42
|
* 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
|
+
* another cluster is refused rather than applied. It is bounded by
|
|
44
|
+
* `clusterVpnNetworkContract.maximumAuthorityIdBytes`, the same bound the hub daemon holds.
|
|
45
|
+
*
|
|
46
|
+
* `controlPrefix` is the subnet the hub's control plane serves: the address plan's
|
|
47
|
+
* `vpn.controlPrefix`, which the plan contract never lets change, stated here because a hub fixes
|
|
48
|
+
* its control subnet when it binds and no other carrier hands a relay the plan. Without it a relay
|
|
49
|
+
* could only infer a subnet from the members it was sent, which is how a hub comes to disagree with
|
|
50
|
+
* the plan it serves.
|
|
43
51
|
*/
|
|
44
52
|
export interface IClusterVpnNetwork {
|
|
45
53
|
authorityId: string;
|
|
54
|
+
controlPrefix: string;
|
|
46
55
|
revision: number;
|
|
47
56
|
nodes: IClusterVpnNetworkNode[];
|
|
48
57
|
grants: IClusterVpnNetworkGrant[];
|
|
@@ -57,6 +66,11 @@ export const clusterVpnNetworkContract = Object.freeze({
|
|
|
57
66
|
/** Per node; one workload lease the node's signed projection carries. */
|
|
58
67
|
maximumWorkloadPrefixes: 128,
|
|
59
68
|
maximumGrants: 4096,
|
|
69
|
+
/**
|
|
70
|
+
* UTF-8 bytes, matching what the hub daemon admits for the authority it binds: an id above this
|
|
71
|
+
* is refused where the network is compiled rather than by the daemon at apply time.
|
|
72
|
+
*/
|
|
73
|
+
maximumAuthorityIdBytes: networkMaximumAuthorityIdBytes,
|
|
60
74
|
maximumCanonicalBytes: 917_504,
|
|
61
75
|
completeness: 'whole-authority-never-a-patch',
|
|
62
76
|
membership: 'presence-only-an-absent-node-is-withdrawn',
|
|
@@ -168,20 +182,27 @@ const grantKey = (grant: IClusterVpnNetworkGrant): string => `${grant.sourceDoma
|
|
|
168
182
|
* workload domain this network itself carries, on two different nodes. That last rule is what a
|
|
169
183
|
* per-cluster hub means: a grant whose other end sits on another cluster's hub cannot be expressed
|
|
170
184
|
* here at all, which is why Cloudly refuses it by name while it compiles instead of pushing it.
|
|
185
|
+
*
|
|
186
|
+
* Every control address lies inside `controlPrefix` and no workload prefix meets it — the two rules
|
|
187
|
+
* a hub enforces against the subnet it bound. Checking them while the network is compiled turns a
|
|
188
|
+
* push the hub could only refuse into a network Cloudly never states.
|
|
171
189
|
*/
|
|
172
190
|
export const snapshotClusterVpnNetwork = (value: unknown): IClusterVpnNetwork => networkSafe(() => {
|
|
173
191
|
const captured = captureNetwork(value);
|
|
174
192
|
requireNetwork(new TextEncoder().encode(networkCanonical(captured)).length <= clusterVpnNetworkContract.maximumCanonicalBytes);
|
|
175
|
-
const item = networkRecord(captured, ['authorityId', 'revision', 'nodes', 'grants']);
|
|
193
|
+
const item = networkRecord(captured, ['authorityId', 'controlPrefix', 'revision', 'nodes', 'grants']);
|
|
194
|
+
const controlPrefix = runtimeNetworkVpnControlPrefix(item.controlPrefix);
|
|
176
195
|
const nodes = networkSorted(networkList(item.nodes, clusterVpnNetworkContract.maximumNodes, nodeOf), node => node.nodeId);
|
|
177
196
|
const grants = networkSorted(networkList(item.grants, clusterVpnNetworkContract.maximumGrants, grantOf), grantKey);
|
|
178
197
|
const domainOwners = new Map<string, string>();
|
|
179
198
|
const addresses = new Set<string>(), keys = new Set<string>(), cidrs = new Set<string>();
|
|
180
199
|
for (const node of nodes) {
|
|
181
|
-
requireNetwork(!keys.has(node.publicKey) && !addresses.has(node.controlAddress)
|
|
200
|
+
requireNetwork(!keys.has(node.publicKey) && !addresses.has(node.controlAddress)
|
|
201
|
+
&& networkContains(controlPrefix, `${node.controlAddress}/32`));
|
|
182
202
|
keys.add(node.publicKey); addresses.add(node.controlAddress);
|
|
183
203
|
for (const prefix of node.workloadPrefixes) {
|
|
184
|
-
requireNetwork(!cidrs.has(prefix.cidr) && !domainOwners.has(prefix.policyDomain)
|
|
204
|
+
requireNetwork(!cidrs.has(prefix.cidr) && !domainOwners.has(prefix.policyDomain)
|
|
205
|
+
&& !networkOverlaps(controlPrefix, prefix.cidr));
|
|
185
206
|
cidrs.add(prefix.cidr); domainOwners.set(prefix.policyDomain, node.nodeId);
|
|
186
207
|
}
|
|
187
208
|
}
|
|
@@ -189,7 +210,7 @@ export const snapshotClusterVpnNetwork = (value: unknown): IClusterVpnNetwork =>
|
|
|
189
210
|
const source = domainOwners.get(grant.sourceDomain), destination = domainOwners.get(grant.destinationDomain);
|
|
190
211
|
requireNetwork(source && destination && source !== destination);
|
|
191
212
|
}
|
|
192
|
-
return { authorityId:
|
|
213
|
+
return { authorityId: networkAuthorityId(item.authorityId), controlPrefix, revision: networkCounter(item.revision), nodes, grants };
|
|
193
214
|
});
|
|
194
215
|
|
|
195
216
|
export type TClusterVpnNetworkAdmission =
|
|
@@ -208,7 +229,11 @@ export const admitClusterVpnNetwork = (heldValue: unknown | null, nextValue: unk
|
|
|
208
229
|
const next = snapshotClusterVpnNetwork(nextValue);
|
|
209
230
|
if (heldValue === null) return { status: 'accepted' };
|
|
210
231
|
const held = snapshotClusterVpnNetwork(heldValue);
|
|
211
|
-
|
|
232
|
+
// A hub binds one control subnet for its lifetime, so a network that moves the prefix under the
|
|
233
|
+
// same authority is as foreign to this hub as one that names another authority.
|
|
234
|
+
if (held.authorityId !== next.authorityId || held.controlPrefix !== next.controlPrefix) {
|
|
235
|
+
return { status: 'refused', reason: 'vpn-network-authority-foreign' };
|
|
236
|
+
}
|
|
212
237
|
if (next.revision > held.revision) return { status: 'accepted' };
|
|
213
238
|
return networkEqual(held, next) ? { status: 'replay' } : { status: 'refused', reason: 'vpn-network-revision-stale' };
|
|
214
239
|
});
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from './immutableimage.js';
|
|
6
6
|
import { normalizeImmutableReleaseTag, normalizeSha256Digest } from './immutableimage.js';
|
|
7
7
|
import type { TServiceDeploymentCapability } from './user.js';
|
|
8
|
+
import { validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
|
|
8
9
|
import type {
|
|
9
10
|
IDeploymentRouteRequest,
|
|
10
11
|
IServiceDeploymentOperation,
|
|
@@ -190,6 +191,26 @@ export interface IDeploymentPreflightRequestData {
|
|
|
190
191
|
routes?: IDeploymentRouteRequest[];
|
|
191
192
|
/** Legacy v14 discriminator. Equivalent to mode: 'greenfield'. */
|
|
192
193
|
greenfield?: true;
|
|
194
|
+
/**
|
|
195
|
+
* The deploying build's protocol offer, read and negotiated before this request is validated.
|
|
196
|
+
*
|
|
197
|
+
* This is the one request that opens a deployment, which is why the offer rides here and on no
|
|
198
|
+
* mutating deployment carrier: Cloudly derives `reserveServiceDeployment`'s `requestDigest` from
|
|
199
|
+
* the whole request body, so an offer inside one of those would bind an operation's identity to
|
|
200
|
+
* the interfaces release the deployer happened to have installed, and an in-flight deployment
|
|
201
|
+
* would stop being resumable across a deployer upgrade. Naming the skew on the read that opens
|
|
202
|
+
* the flow costs nothing durable and refuses it before anything is reserved.
|
|
203
|
+
*
|
|
204
|
+
* Optional, because a peer that speaks this release states nothing by offering and older
|
|
205
|
+
* deployers send none: absence is a valid request, not a refusal. A member stated as `undefined`
|
|
206
|
+
* is absent too, like every other optional member of this shape, so a caller that spreads an
|
|
207
|
+
* offer it may not hold states no offer rather than a malformed one. When an offer is stated it
|
|
208
|
+
* is exact-key, exactly like every other offer in this package —
|
|
209
|
+
* `validateDeploymentPreflightRequest` judges it with the same reader
|
|
210
|
+
* `protocol.readProtocolOffer` negotiates from, so a body cannot pass this validator with an
|
|
211
|
+
* offer the server's own read would refuse.
|
|
212
|
+
*/
|
|
213
|
+
protocol?: IProtocolOffer;
|
|
193
214
|
}
|
|
194
215
|
|
|
195
216
|
export interface IDeploymentPreflightReport {
|
|
@@ -412,6 +433,23 @@ export const validateDeploymentPreflightRequest = (
|
|
|
412
433
|
)) {
|
|
413
434
|
blockers.push({ code: 'INVALID_REQUEST', message: 'Deployment route declaration is malformed.' });
|
|
414
435
|
}
|
|
436
|
+
// Presence is decided from the own property alone: reading `requestArg.protocol` would run an
|
|
437
|
+
// accessor a caller attached to the body, and the offer a validator judges has to be the value
|
|
438
|
+
// the server negotiates from. A data property holding `undefined` states no offer, exactly as
|
|
439
|
+
// the optional members above read an omitted value, so spreading an offer a caller may not hold
|
|
440
|
+
// is not a malformed one. An accessor is neither of those: it is refused, and never invoked —
|
|
441
|
+
// the check reads a descriptor, and `validateProtocolOfferOn` reads the key as data as well.
|
|
442
|
+
const protocolProperty = Object.getOwnPropertyDescriptor(requestArg, 'protocol');
|
|
443
|
+
const statedProtocolOffer = protocolProperty !== undefined
|
|
444
|
+
&& !(Object.hasOwn(protocolProperty, 'value') && protocolProperty.value === undefined)
|
|
445
|
+
? validateProtocolOfferOn(requestArg, 'deployment preflight protocol offer')
|
|
446
|
+
: [];
|
|
447
|
+
if (statedProtocolOffer.length > 0) {
|
|
448
|
+
blockers.push({
|
|
449
|
+
code: 'INVALID_REQUEST',
|
|
450
|
+
message: `The stated protocol offer is malformed: ${statedProtocolOffer.join('; ')}.`,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
415
453
|
if (mode === 'greenfield' && Array.isArray(requestArg.routes)
|
|
416
454
|
&& JSON.stringify([...new Set(requestArg.routes.map((routeArg) => (
|
|
417
455
|
isRecord(routeArg) && typeof routeArg.hostname === 'string' ? routeArg.hostname : ''
|
|
@@ -39,14 +39,13 @@ export interface IRuntimeNetworkAddressPlan {
|
|
|
39
39
|
* One cluster's managed VPN hub, as the cluster's relay bound it.
|
|
40
40
|
*
|
|
41
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`.
|
|
43
|
-
*
|
|
42
|
+
* that may select it, and `address` is an underlay address inside the plan's `vpn.hubPrefix`.
|
|
43
|
+
* `quicPort` is the port it bound for the one transport a managed hub serves.
|
|
44
44
|
*/
|
|
45
45
|
export interface IRuntimeNetworkVpnHub {
|
|
46
46
|
clusterId: string;
|
|
47
47
|
address: string;
|
|
48
|
-
quicPort: number
|
|
49
|
-
websocketPort: number | null;
|
|
48
|
+
quicPort: number;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
/** What an administrator writes; Cloudly supplies the id and the revision. */
|
|
@@ -80,50 +79,60 @@ export const runtimeNetworkAddressPlanContract = Object.freeze({
|
|
|
80
79
|
*/
|
|
81
80
|
maximumControlPrefixLength: 30,
|
|
82
81
|
/**
|
|
83
|
-
* Every transport a hub
|
|
82
|
+
* Every transport a managed hub serves, in the order a node prefers them.
|
|
84
83
|
*
|
|
85
84
|
* One row states all three facts that belong together: the transport a credential names, the
|
|
86
85
|
* suffix its platform endpoint id carries, and the protocol the endpoint is composed with. A hub
|
|
87
|
-
*
|
|
86
|
+
* contributes one endpoint of its address per row, so a transport added here is added once for
|
|
87
|
+
* the id derivation, the composition and the credential binder alike.
|
|
88
|
+
*
|
|
89
|
+
* QUIC is the one row: a managed hub terminates TLS on its QUIC listener alone, so no other
|
|
90
|
+
* transport of it can be dialled over a protected connection.
|
|
88
91
|
*/
|
|
89
92
|
hubTransports: Object.freeze([
|
|
90
93
|
Object.freeze({ transport: 'quic', endpointSuffix: 'quic', protocol: 'udp' }),
|
|
91
|
-
Object.freeze({ transport: 'websocket', endpointSuffix: 'ws', protocol: 'tcp' }),
|
|
92
94
|
]),
|
|
93
95
|
} as const);
|
|
94
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The one reader for a managed VPN control prefix: a canonical private prefix no longer than
|
|
99
|
+
* `maximumControlPrefixLength`. The plan states it for the whole runtime network and a cluster's
|
|
100
|
+
* network repeats it for the hub that binds it, so both read it here and neither can admit a
|
|
101
|
+
* subnet the other would refuse.
|
|
102
|
+
*/
|
|
103
|
+
export const runtimeNetworkVpnControlPrefix = (value: unknown): string => networkSafe(() => {
|
|
104
|
+
const prefix = networkPrivatePrefix(value);
|
|
105
|
+
requireNetwork(Number(prefix.split('/')[1]) <= runtimeNetworkAddressPlanContract.maximumControlPrefixLength);
|
|
106
|
+
return prefix;
|
|
107
|
+
});
|
|
108
|
+
|
|
95
109
|
/** How a node reaches its cluster's hub, read off the one table that states the transports. */
|
|
96
110
|
export type TRuntimeNetworkVpnTransport = typeof runtimeNetworkAddressPlanContract.hubTransports[number]['transport'];
|
|
97
111
|
|
|
98
|
-
/** The port a hub bound for one transport
|
|
99
|
-
const hubPort = (hub: IRuntimeNetworkVpnHub, transport: TRuntimeNetworkVpnTransport): number
|
|
100
|
-
|
|
112
|
+
/** The port a hub bound for one transport; one entry per row of the transport table. */
|
|
113
|
+
const hubPort = (hub: IRuntimeNetworkVpnHub, transport: TRuntimeNetworkVpnTransport): number =>
|
|
114
|
+
({ quic: hub.quicPort })[transport];
|
|
101
115
|
|
|
102
116
|
const byEndpointId = <T extends { id: string }>(left: T, right: T): number =>
|
|
103
117
|
left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
104
118
|
|
|
105
119
|
const hubOf = (value: unknown): IRuntimeNetworkVpnHub => {
|
|
106
|
-
const item = networkRecord(value, ['clusterId', 'address', 'quicPort'
|
|
107
|
-
|
|
108
|
-
|
|
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;
|
|
120
|
+
const item = networkRecord(value, ['clusterId', 'address', 'quicPort']);
|
|
121
|
+
return { clusterId: networkId(item.clusterId), address: networkIpv4(item.address),
|
|
122
|
+
quicPort: networkCounter(item.quicPort, 65535) };
|
|
113
123
|
};
|
|
114
124
|
|
|
115
125
|
/**
|
|
116
|
-
* The platform endpoints one hub contributes to a protected authority: one per transport
|
|
117
|
-
* in id order. Every reader derives the same ids from this one function — the composition
|
|
118
|
-
* the authority, and the router selection that names which of them a node may dial.
|
|
126
|
+
* The platform endpoints one hub contributes to a protected authority: one per transport a managed
|
|
127
|
+
* hub serves, in id order. Every reader derives the same ids from this one function — the composition
|
|
128
|
+
* that seals the authority, and the router selection that names which of them a node may dial.
|
|
119
129
|
*/
|
|
120
130
|
export const runtimeNetworkVpnHubEndpoints = (hubValue: unknown): IRuntimeNetworkProtectedAuthority['platformEndpoints'] =>
|
|
121
131
|
networkSafe(() => {
|
|
122
132
|
const hub = hubOf(captureNetwork(hubValue));
|
|
123
133
|
return runtimeNetworkAddressPlanContract.hubTransports
|
|
124
|
-
.filter(row => hubPort(hub, row.transport) !== null)
|
|
125
134
|
.map(row => ({ id: `${hub.clusterId}:${row.endpointSuffix}`, address: hub.address,
|
|
126
|
-
protocol: row.protocol, port: hubPort(hub, row.transport)
|
|
135
|
+
protocol: row.protocol, port: hubPort(hub, row.transport) }))
|
|
127
136
|
.sort(byEndpointId);
|
|
128
137
|
});
|
|
129
138
|
|
|
@@ -154,9 +163,7 @@ const setsWithHubs = (plan: IRuntimeNetworkAddressPlan, hubs: IRuntimeNetworkVpn
|
|
|
154
163
|
|
|
155
164
|
const vpnOf = (value: unknown): IRuntimeNetworkAddressPlan['vpn'] => {
|
|
156
165
|
const item = networkRecord(value, ['controlPrefix', 'hubPrefix']);
|
|
157
|
-
|
|
158
|
-
requireNetwork(Number(controlPrefix.split('/')[1]) <= runtimeNetworkAddressPlanContract.maximumControlPrefixLength);
|
|
159
|
-
return { controlPrefix, hubPrefix: networkPrefix(item.hubPrefix) };
|
|
166
|
+
return { controlPrefix: runtimeNetworkVpnControlPrefix(item.controlPrefix), hubPrefix: networkPrefix(item.hubPrefix) };
|
|
160
167
|
};
|
|
161
168
|
|
|
162
169
|
/**
|
|
@@ -3,9 +3,9 @@ import { runtimeNetworkAddressPlanContract, type TRuntimeNetworkVpnTransport } f
|
|
|
3
3
|
import { snapshotRuntimeSessionBinding, type IRuntimeSessionBinding } from './runtimesession.js';
|
|
4
4
|
import { validateRuntimeNetworkProjection, type IRuntimeNetworkProjection } from './networkprojection.js';
|
|
5
5
|
import { snapshotRuntimeNetworkDnsLeaseRenewal, validateRuntimeNetworkDnsLeaseRenewal } from './networklease.js';
|
|
6
|
-
import { captureNetwork, failNetwork, networkCounter, networkEffectiveDnsWindow, networkEqual,
|
|
7
|
-
networkNoiseKey, networkRecord, networkReference, networkReferenceOf, networkSafe,
|
|
8
|
-
requireNetwork } from '../private/networkcontract.js';
|
|
6
|
+
import { captureNetwork, failNetwork, networkAuthorityId, networkCounter, networkEffectiveDnsWindow, networkEqual,
|
|
7
|
+
networkId, networkIpv4, networkNoiseKey, networkRecord, networkReference, networkReferenceOf, networkSafe,
|
|
8
|
+
networkSameScope, requireNetwork } from '../private/networkcontract.js';
|
|
9
9
|
|
|
10
10
|
export interface IGetRuntimeManagedVpnCredentialRequest {
|
|
11
11
|
session: IRuntimeSessionBinding;
|
|
@@ -19,13 +19,7 @@ export interface IGetRuntimeManagedVpnCredentialResponse extends IGetRuntimeMana
|
|
|
19
19
|
transport: TRuntimeNetworkVpnTransport;
|
|
20
20
|
/** The cluster hub endpoint this node dials, named by the id its signed projection selected. */
|
|
21
21
|
hub: { id: string; address: string; port: number };
|
|
22
|
-
/**
|
|
23
|
-
* The TLS name the hub's WebSocket transport is dialled under, present exactly when `transport`
|
|
24
|
-
* is `websocket` and absent otherwise: QUIC is dialled on the address alone. It is the hub
|
|
25
|
-
* relay's own origin hostname, which the node cross-checks against the relay origin it stores,
|
|
26
|
-
* so a name the node does not already trust ends the handshake before any native effect.
|
|
27
|
-
*/
|
|
28
|
-
serverName?: string;
|
|
22
|
+
/** The hub's managed authority, under the one bound the hub daemon holds for it. */
|
|
29
23
|
authorityId: string;
|
|
30
24
|
serverPublicKey: string;
|
|
31
25
|
clientPublicKey: string;
|
|
@@ -42,21 +36,6 @@ export const snapshotGetRuntimeManagedVpnCredentialRequest = (value: unknown): I
|
|
|
42
36
|
return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection) };
|
|
43
37
|
});
|
|
44
38
|
|
|
45
|
-
/**
|
|
46
|
-
* The TLS name a WebSocket hub is dialled under: a lower-case domain name of at least two labels,
|
|
47
|
-
* which is what a relay origin's hostname is and what a certificate can be issued for. An address
|
|
48
|
-
* literal is not one, so a node can never be sent to a name nothing vouches for.
|
|
49
|
-
*/
|
|
50
|
-
const serverName = (value: unknown): string => {
|
|
51
|
-
requireNetwork(typeof value === 'string' && value.length <= 253);
|
|
52
|
-
const labels = (value as string).split('.');
|
|
53
|
-
// The last label starts with a letter, which is what makes this a name and not an address
|
|
54
|
-
// literal: a hub reached by address is reached over QUIC, which needs no name at all.
|
|
55
|
-
requireNetwork(labels.length >= 2 && labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
|
56
|
-
&& /^[a-z]/.test(labels[labels.length - 1]));
|
|
57
|
-
return value as string;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
39
|
const transportOf = (value: unknown): TRuntimeNetworkVpnTransport => {
|
|
61
40
|
const row = runtimeNetworkAddressPlanContract.hubTransports.find(candidate => candidate.transport === value);
|
|
62
41
|
requireNetwork(row); return row.transport;
|
|
@@ -64,15 +43,12 @@ const transportOf = (value: unknown): TRuntimeNetworkVpnTransport => {
|
|
|
64
43
|
|
|
65
44
|
export const snapshotGetRuntimeManagedVpnCredentialResponse = (value: unknown): IGetRuntimeManagedVpnCredentialResponse => networkSafe(() => {
|
|
66
45
|
const item = networkRecord(captureNetwork(value), ['session', 'projection', 'transport', 'hub', 'authorityId',
|
|
67
|
-
'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt']
|
|
68
|
-
const hub = networkRecord(item.hub, ['id', 'address', 'port'])
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
requireNetwork(Object.hasOwn(item, 'serverName') === (transport === 'websocket'));
|
|
72
|
-
return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection), transport,
|
|
46
|
+
'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt']);
|
|
47
|
+
const hub = networkRecord(item.hub, ['id', 'address', 'port']);
|
|
48
|
+
return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection),
|
|
49
|
+
transport: transportOf(item.transport),
|
|
73
50
|
hub: { id: networkId(hub.id), address: networkIpv4(hub.address), port: networkCounter(hub.port, 65535) },
|
|
74
|
-
|
|
75
|
-
authorityId: networkId(item.authorityId), serverPublicKey: networkNoiseKey(item.serverPublicKey),
|
|
51
|
+
authorityId: networkAuthorityId(item.authorityId), serverPublicKey: networkNoiseKey(item.serverPublicKey),
|
|
76
52
|
clientPublicKey: networkNoiseKey(item.clientPublicKey), clientPrivateKey: networkNoiseKey(item.clientPrivateKey),
|
|
77
53
|
expiresAt: networkCounter(item.expiresAt) };
|
|
78
54
|
});
|
|
@@ -80,6 +80,26 @@ export const networkSorted = <T>(values: T[], key: (item: T) => string): T[] =>
|
|
|
80
80
|
export const networkId = (value: unknown): string => {
|
|
81
81
|
requireNetwork(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(value)); return value;
|
|
82
82
|
};
|
|
83
|
+
/**
|
|
84
|
+
* The longest managed VPN authority id any contract of this package admits, in UTF-8 bytes.
|
|
85
|
+
*
|
|
86
|
+
* SmartVPN's managed registry refuses an authority id longer than 128 bytes, and it is the daemon
|
|
87
|
+
* that applies a pushed network, so an id between that bound and the generic identifier bound
|
|
88
|
+
* above would pass every contract on the way and be refused only at apply time, on the far side of
|
|
89
|
+
* a push, as a hub reporting `state: 'failed'`. Bounding it where the id is admitted moves that
|
|
90
|
+
* refusal to where Cloudly compiles the network, on its own stack, before anything is pushed.
|
|
91
|
+
*
|
|
92
|
+
* Bytes, because that is what the daemon counts. The identifier grammar admits ASCII alone, so a
|
|
93
|
+
* byte and a code point are the same thing for every id that gets this far; measuring bytes keeps
|
|
94
|
+
* the bound equal to the daemon's the day the grammar admits anything wider.
|
|
95
|
+
*/
|
|
96
|
+
export const networkMaximumAuthorityIdBytes = 128;
|
|
97
|
+
/** A managed VPN authority id: a canonical identifier the cluster's hub daemon also admits. */
|
|
98
|
+
export const networkAuthorityId = (value: unknown): string => {
|
|
99
|
+
const id = networkId(value);
|
|
100
|
+
requireNetwork(new TextEncoder().encode(id).length <= networkMaximumAuthorityIdBytes);
|
|
101
|
+
return id;
|
|
102
|
+
};
|
|
83
103
|
/** A node id is read in the node vocabulary here as everywhere else, never in the canonical one. */
|
|
84
104
|
export const networkNodeId = (value: unknown): string => {
|
|
85
105
|
requireNetwork(isClusterNodeId(value)); return value as string;
|
package/ts/requests/service.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
IServiceDeploymentStatus,
|
|
17
17
|
} from '../data/deploymentoperation.js';
|
|
18
18
|
import type { TSha256Digest } from '../data/immutableimage.js';
|
|
19
|
+
import type { IProtocolOffer } from '../protocol.js';
|
|
19
20
|
import type {
|
|
20
21
|
IServiceRuntimeAssignmentView,
|
|
21
22
|
IServiceRuntimeSpec,
|
|
@@ -113,6 +114,18 @@ extends plugins.typedrequestInterfaces.implementsTR<
|
|
|
113
114
|
request: {
|
|
114
115
|
identity: IIdentity;
|
|
115
116
|
serviceId: string;
|
|
117
|
+
/**
|
|
118
|
+
* The calling build's protocol offer, read and negotiated before the request is served.
|
|
119
|
+
*
|
|
120
|
+
* The read that opens a deployment adoption, and the only other deployment carrier that
|
|
121
|
+
* carries an offer: the mutating ones stay byte-exact because Cloudly derives a deployment
|
|
122
|
+
* operation's `requestDigest` from the whole request body, and an offer there would bind that
|
|
123
|
+
* operation's identity to the caller's interfaces release. Optional, because a peer of this
|
|
124
|
+
* release states nothing by offering and a caller that opens no deployment sends none —
|
|
125
|
+
* absence is a valid request. A stated offer is exact-key, judged by
|
|
126
|
+
* `protocol.readProtocolOffer` / `protocol.validateProtocolOfferOn` like every other offer.
|
|
127
|
+
*/
|
|
128
|
+
protocol?: IProtocolOffer;
|
|
116
129
|
};
|
|
117
130
|
response: {
|
|
118
131
|
service: IService;
|