@serve.zone/interfaces 32.3.0 → 32.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,11 @@
1
1
  import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
2
+ import { runtimeNetworkAddressPlanContract, type TRuntimeNetworkVpnTransport } from './networkaddressplan.js';
2
3
  import { snapshotRuntimeSessionBinding, type IRuntimeSessionBinding } from './runtimesession.js';
3
4
  import { validateRuntimeNetworkProjection, type IRuntimeNetworkProjection } from './networkprojection.js';
4
5
  import { snapshotRuntimeNetworkDnsLeaseRenewal, validateRuntimeNetworkDnsLeaseRenewal } from './networklease.js';
5
6
  import { captureNetwork, failNetwork, networkCounter, networkEffectiveDnsWindow, networkEqual, networkId, networkIpv4,
6
- networkRecord, networkReference, networkReferenceOf, networkSafe, networkSameScope, requireNetwork } from '../private/networkcontract.js';
7
+ networkNoiseKey, networkRecord, networkReference, networkReferenceOf, networkSafe, networkSameScope,
8
+ requireNetwork } from '../private/networkcontract.js';
7
9
 
8
10
  export interface IGetRuntimeManagedVpnCredentialRequest {
9
11
  session: IRuntimeSessionBinding;
@@ -14,8 +16,16 @@ export interface IGetRuntimeManagedVpnCredentialRequest {
14
16
  * persist, hash, log, or include it in a signed network projection. The native
15
17
  * Noise owner validates and uses the keys; these shapes confer no TUN readiness. */
16
18
  export interface IGetRuntimeManagedVpnCredentialResponse extends IGetRuntimeManagedVpnCredentialRequest {
17
- transport: 'quic';
18
- relay: { id: string; address: string; port: number };
19
+ transport: TRuntimeNetworkVpnTransport;
20
+ /** The cluster hub endpoint this node dials, named by the id its signed projection selected. */
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;
19
29
  authorityId: string;
20
30
  serverPublicKey: string;
21
31
  clientPublicKey: string;
@@ -27,28 +37,44 @@ export interface IGetRuntimeManagedVpnCredentialResponse extends IGetRuntimeMana
27
37
  expiresAt: number;
28
38
  }
29
39
 
30
- /** Canonical 32-byte key encoding only. Cryptographic key agreement and pair
31
- * validation belong to the native Noise implementation, not this JSON parser. */
32
- const key = (value: unknown): string => {
33
- requireNetwork(typeof value === 'string' && /^[A-Za-z0-9+/]{43}=$/.test(value));
34
- const decoded = atob(value);
35
- requireNetwork(decoded.length === 32 && btoa(decoded) === value && [...decoded].some(byte => byte.charCodeAt(0) !== 0));
36
- return value;
37
- };
38
-
39
40
  export const snapshotGetRuntimeManagedVpnCredentialRequest = (value: unknown): IGetRuntimeManagedVpnCredentialRequest => networkSafe(() => {
40
41
  const item = networkRecord(captureNetwork(value), ['session', 'projection']);
41
42
  return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection) };
42
43
  });
43
44
 
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
+ const transportOf = (value: unknown): TRuntimeNetworkVpnTransport => {
61
+ const row = runtimeNetworkAddressPlanContract.hubTransports.find(candidate => candidate.transport === value);
62
+ requireNetwork(row); return row.transport;
63
+ };
64
+
44
65
  export const snapshotGetRuntimeManagedVpnCredentialResponse = (value: unknown): IGetRuntimeManagedVpnCredentialResponse => networkSafe(() => {
45
- const item = networkRecord(captureNetwork(value), ['session', 'projection', 'transport', 'relay', 'authorityId',
46
- 'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt']);
47
- const relay = networkRecord(item.relay, ['id', 'address', 'port']); requireNetwork(item.transport === 'quic');
48
- return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection), transport: 'quic',
49
- relay: { id: networkId(relay.id), address: networkIpv4(relay.address), port: networkCounter(relay.port, 65535) },
50
- authorityId: networkId(item.authorityId), serverPublicKey: key(item.serverPublicKey), clientPublicKey: key(item.clientPublicKey),
51
- clientPrivateKey: key(item.clientPrivateKey), expiresAt: networkCounter(item.expiresAt) };
66
+ const item = networkRecord(captureNetwork(value), ['session', 'projection', 'transport', 'hub', 'authorityId',
67
+ 'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt'], ['serverName']);
68
+ const hub = networkRecord(item.hub, ['id', 'address', 'port']), transport = transportOf(item.transport);
69
+ // A name belongs to the one transport that is dialled by name; stating it for QUIC would name a
70
+ // trust the node never checks, and omitting it for WebSocket leaves the TLS identity unstated.
71
+ requireNetwork(Object.hasOwn(item, 'serverName') === (transport === 'websocket'));
72
+ return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection), transport,
73
+ hub: { id: networkId(hub.id), address: networkIpv4(hub.address), port: networkCounter(hub.port, 65535) },
74
+ ...(transport === 'websocket' ? { serverName: serverName(item.serverName) } : {}),
75
+ authorityId: networkId(item.authorityId), serverPublicKey: networkNoiseKey(item.serverPublicKey),
76
+ clientPublicKey: networkNoiseKey(item.clientPublicKey), clientPrivateKey: networkNoiseKey(item.clientPrivateKey),
77
+ expiresAt: networkCounter(item.expiresAt) };
52
78
  });
53
79
 
54
80
  const bindRequest = (request: IGetRuntimeManagedVpnCredentialRequest,
@@ -70,8 +96,8 @@ export const bindGetRuntimeManagedVpnCredentialRequest = async (value: unknown,
70
96
  } catch { return failNetwork(); }
71
97
  };
72
98
 
73
- /** Bind the secret response to the original request and exact selected router
74
- * UDP endpoint, inside the projection's effective DNS window. renewalValue is the
99
+ /** Bind the secret response to the original request and the exact selected router
100
+ * endpoint of the credential's transport, inside the projection's effective DNS window. renewalValue is the
75
101
  * bare lease renewal the caller already verified and admitted for this exact
76
102
  * projection, or null; this helper verifies no signature. Time is caller-qualified;
77
103
  * this helper does not sample a clock, authenticate a transport, admit policy or
@@ -89,9 +115,11 @@ export const bindGetRuntimeManagedVpnCredentialResponse = async (value: unknown,
89
115
  const window = networkEffectiveDnsWindow(projection, renewal);
90
116
  bindRequest(request, projection, trusted);
91
117
  requireNetwork(networkEqual(response.session, request.session) && networkEqual(response.projection, request.projection));
92
- requireNetwork(projection.router?.platformEndpointIds.includes(response.relay.id));
93
- const endpoint = projection.protectedAuthority.platformEndpoints.find(item => item.id === response.relay.id);
94
- requireNetwork(endpoint && endpoint.protocol === 'udp' && endpoint.address === response.relay.address && endpoint.port === response.relay.port);
118
+ requireNetwork(projection.router?.platformEndpointIds.includes(response.hub.id));
119
+ const endpoint = projection.protectedAuthority.platformEndpoints.find(item => item.id === response.hub.id);
120
+ const expected = runtimeNetworkAddressPlanContract.hubTransports.find(row => row.transport === response.transport);
121
+ requireNetwork(endpoint && expected && endpoint.protocol === expected.protocol
122
+ && endpoint.address === response.hub.address && endpoint.port === response.hub.port);
95
123
  requireNetwork(window.notBefore <= now && now < response.expiresAt && response.expiresAt <= window.expiresAt);
96
124
  return response;
97
125
  } catch { return failNetwork(); }
@@ -55,7 +55,20 @@ export interface IRuntimeAssignment extends IRuntimeAssignmentScope {
55
55
  /** No automatic relocation or expiry-based claim of a stopped writer. */
56
56
  offlinePolicy: 'continue-node-bound';
57
57
  workload: {
58
+ /**
59
+ * The registry that published this image, which is the publication identity the image release
60
+ * is bound to. It is never rewritten: only `pullEndpoint` decides where the bytes are fetched.
61
+ */
58
62
  registryHost: string;
63
+ /**
64
+ * Where this node fetches the image instead of `registryHost`, as `host[:port]`.
65
+ *
66
+ * A cluster whose relay forwards the registry states the relay's own origin here, so a node
67
+ * pulls inside its cluster and nothing cluster-side dials the control plane. Absent when the
68
+ * node pulls from the publishing registry itself. The reference stays digest-pinned either
69
+ * way, so the endpoint decides reachability and never content.
70
+ */
71
+ pullEndpoint?: string;
59
72
  repository: string;
60
73
  imageDigest: TSha256Digest;
61
74
  platform: TImmutableContainerPlatform;
@@ -99,7 +112,7 @@ const workloadAuthorityDemands: Readonly<Record<
99
112
  export const requiredWorkloadAuthorities = (
100
113
  workloadArg: IRuntimeAssignment['workload'],
101
114
  ): TRuntimeWorkloadAuthority[] => {
102
- if (!isExactRecord(workloadArg, workloadKeys) || !Array.isArray(workloadArg.storage)) {
115
+ if (!isExactRecord(workloadArg, workloadKeys, optionalWorkloadKeys) || !Array.isArray(workloadArg.storage)) {
103
116
  fail(workloadSchemaMessage);
104
117
  }
105
118
  return runtimeWorkloadAuthorities
@@ -107,6 +120,30 @@ export const requiredWorkloadAuthorities = (
107
120
  .sort(compareWireTokens);
108
121
  };
109
122
 
123
+ /**
124
+ * The digest-pinned image reference a node pulls this workload by.
125
+ *
126
+ * One function decides where the bytes come from: the stated `pullEndpoint`, else the publishing
127
+ * `registryHost`. The digest is the same either way, so a node that pulls through its cluster's
128
+ * relay fetches exactly the content the publishing registry named, and a controller and a node can
129
+ * never disagree about the reference. The workload is read as a wire value, so one whose members
130
+ * this contract refuses is refused here rather than by a member read.
131
+ */
132
+ export const buildRuntimeAssignmentImageReference = (
133
+ workloadArg: IRuntimeAssignment['workload'],
134
+ ): string => {
135
+ if (!isExactRecord(workloadArg, workloadKeys, optionalWorkloadKeys)) {
136
+ fail(workloadSchemaMessage);
137
+ }
138
+ const endpoint = Object.hasOwn(workloadArg, 'pullEndpoint')
139
+ ? workloadArg.pullEndpoint : workloadArg.registryHost;
140
+ if (typeof endpoint !== 'string' || typeof workloadArg.repository !== 'string'
141
+ || !isDigest(workloadArg.imageDigest)) {
142
+ fail(workloadSchemaMessage);
143
+ }
144
+ return buildDigestPinnedImageReference(endpoint, workloadArg.repository, workloadArg.imageDigest);
145
+ };
146
+
110
147
  /** True when a value is exactly a canonical authority list drawn from the authority vocabulary. */
111
148
  export const isResolvedWorkloadAuthorities = (
112
149
  valueArg: unknown,
@@ -152,18 +189,24 @@ const isPositiveInteger = (valueArg: unknown): valueArg is number =>
152
189
  const isDigest = (valueArg: unknown): valueArg is TSha256Digest =>
153
190
  typeof valueArg === 'string' && isSha256Digest(valueArg);
154
191
 
155
- /** Reject accessors before reading any fields; do not run input-owned code. */
192
+ /**
193
+ * Reject accessors before reading any fields; do not run input-owned code.
194
+ *
195
+ * Absence stays absence: a stated optional key is read, an omitted one is not defaulted, so a
196
+ * controller that stated nothing is distinguishable from one that stated a value.
197
+ */
156
198
  const isExactRecord = (
157
199
  valueArg: unknown,
158
200
  keysArg: readonly string[],
201
+ optionalKeysArg: readonly string[] = [],
159
202
  ): valueArg is Record<string, unknown> => {
160
203
  if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
161
204
  const prototype = Object.getPrototypeOf(valueArg);
162
205
  if (prototype !== Object.prototype && prototype !== null) return false;
163
206
  const keys = Reflect.ownKeys(valueArg);
164
- return keys.length === keysArg.length && keys.every((keyArg) => {
207
+ return keysArg.every((keyArg) => keys.includes(keyArg)) && keys.every((keyArg) => {
165
208
  const descriptor = Object.getOwnPropertyDescriptor(valueArg, keyArg);
166
- return typeof keyArg === 'string' && keysArg.includes(keyArg)
209
+ return typeof keyArg === 'string' && (keysArg.includes(keyArg) || optionalKeysArg.includes(keyArg))
167
210
  && descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value');
168
211
  });
169
212
  };
@@ -183,6 +226,8 @@ const isAssignmentReference = (valueArg: unknown): valueArg is IRuntimeAssignmen
183
226
  /** The workload's exact members, read by the authority vocabulary and by the snapshot alike. */
184
227
  const workloadKeys = ['registryHost', 'repository', 'imageDigest', 'platform',
185
228
  'config', 'network', 'secrets', 'storage'] as const;
229
+ /** Stated only by a controller that sends the node somewhere else for the image bytes. */
230
+ const optionalWorkloadKeys = ['pullEndpoint'] as const;
186
231
  const workloadSchemaMessage = 'runtime assignment workload must use exact immutable references';
187
232
 
188
233
  const assignmentKeys = [
@@ -213,8 +258,10 @@ const assertAssignment = (valueArg: unknown): IRuntimeAssignment => {
213
258
  fail('runtime assignment must bind its exact preceding generation');
214
259
  }
215
260
  const workload = valueArg.workload;
216
- if (!isExactRecord(workload, workloadKeys)
261
+ if (!isExactRecord(workload, workloadKeys, optionalWorkloadKeys)
217
262
  || typeof workload.registryHost !== 'string' || workload.registryHost.length > 253
263
+ || (Object.hasOwn(workload, 'pullEndpoint')
264
+ && (typeof workload.pullEndpoint !== 'string' || workload.pullEndpoint.length > 253))
218
265
  || typeof workload.repository !== 'string' || workload.repository.length > 255
219
266
  || !isDigest(workload.imageDigest)
220
267
  || !['linux/amd64', 'linux/arm64'].includes(workload.platform as string)
@@ -227,6 +274,12 @@ const assertAssignment = (valueArg: unknown): IRuntimeAssignment => {
227
274
  }
228
275
  buildDigestPinnedImageReference(workload.registryHost as string,
229
276
  workload.repository as string, workload.imageDigest as TSha256Digest);
277
+ // The pull endpoint is an OCI registry host like the publishing one, so the same reference the
278
+ // node builds proves it: an endpoint no reference can name is refused where it is written.
279
+ if (Object.hasOwn(workload, 'pullEndpoint')) {
280
+ buildDigestPinnedImageReference(workload.pullEndpoint as string,
281
+ workload.repository as string, workload.imageDigest as TSha256Digest);
282
+ }
230
283
  let previousId = '';
231
284
  for (let index = 0; index < (workload.storage as unknown[]).length; index++) {
232
285
  const descriptor = Object.getOwnPropertyDescriptor(workload.storage, String(index));
@@ -89,6 +89,17 @@ export const networkDigest = (value: unknown): TSha256Digest => {
89
89
  export const networkSignature = (value: unknown): string => {
90
90
  requireNetwork(typeof value === 'string' && /^[a-f0-9]{128}$/.test(value)); return value;
91
91
  };
92
+ /**
93
+ * One encoding for every Noise key this contract carries — the managed VPN credential's, a relay's
94
+ * hub advertisement and a cluster's VPN network: canonical 32-byte base64, never the all-zero key.
95
+ * Key agreement and key-pair validation belong to the native Noise implementation, not to a reader.
96
+ */
97
+ export const networkNoiseKey = (value: unknown): string => {
98
+ requireNetwork(typeof value === 'string' && /^[A-Za-z0-9+/]{43}=$/.test(value));
99
+ const decoded = atob(value as string);
100
+ requireNetwork(decoded.length === 32 && btoa(decoded) === value && [...decoded].some(byte => byte.charCodeAt(0) !== 0));
101
+ return value as string;
102
+ };
92
103
  export const networkController = (value: unknown): IRuntimeController => {
93
104
  const item = networkRecord(value, ['kind', 'id', 'epoch']);
94
105
  requireNetwork(item.kind === 'cloudly' || item.kind === 'onebox');
@@ -1,5 +1,6 @@
1
1
  import * as userInterfaces from '../data/user.js';
2
2
  import * as clusterInterfaces from '../data/cluster.js';
3
+ import type * as clusterVpnInterfaces from '../data/clustervpn.js';
3
4
  import type { IClusterRuntime, TClusterRuntimeBlocker } from '../data/clusterruntime.js';
4
5
  import type { IServiceRuntimeAssignmentView } from '../data/serviceruntime.js';
5
6
  import type * as leaseInterfaces from '../data/networklease.js';
@@ -168,6 +169,40 @@ export interface IReq_Cloudly_Relay_HoldRuntimeNetworkDnsLeaseRenewals extends p
168
169
  response: leaseInterfaces.IHoldRuntimeNetworkDnsLeaseRenewalsResponse;
169
170
  }
170
171
 
172
+ /**
173
+ * Cloudly pushes one cluster's complete managed VPN to that cluster's relay.
174
+ *
175
+ * The relay is its cluster's own hub: it runs the managed VPN server every node of the cluster
176
+ * dials, so this is the whole authority that hub serves, never a patch. It rides the relay's
177
+ * already registered session, which is why no cluster is named — the socket names it. The relay
178
+ * answers with what its hub applied, and refuses a network for another authority or a revision it
179
+ * has passed with one of `data.applyClusterVpnNetworkRefusals`.
180
+ */
181
+ export interface IReq_Cloudly_Relay_ApplyClusterVpnNetwork extends plugins.typedrequestInterfaces.implementsTR<
182
+ plugins.typedrequestInterfaces.ITypedRequest,
183
+ IReq_Cloudly_Relay_ApplyClusterVpnNetwork
184
+ > {
185
+ method: 'applyClusterVpnNetwork';
186
+ request: clusterVpnInterfaces.IApplyClusterVpnNetworkRequest;
187
+ response: clusterVpnInterfaces.IApplyClusterVpnNetworkResponse;
188
+ }
189
+
190
+ /**
191
+ * The relay asks Cloudly for its cluster's current managed VPN.
192
+ *
193
+ * A hub daemon starts empty and its lifetime is the relay's own, invisible to Cloudly, so a relay
194
+ * that just bound its hub pulls the current network instead of waiting for the next push. The
195
+ * answer is `null` while the cluster has none.
196
+ */
197
+ export interface IReq_Relay_Cloudly_GetClusterVpnNetwork extends plugins.typedrequestInterfaces.implementsTR<
198
+ plugins.typedrequestInterfaces.ITypedRequest,
199
+ IReq_Relay_Cloudly_GetClusterVpnNetwork
200
+ > {
201
+ method: 'getClusterVpnNetwork';
202
+ request: clusterVpnInterfaces.TGetClusterVpnNetworkRequest;
203
+ response: clusterVpnInterfaces.IGetClusterVpnNetworkResponse;
204
+ }
205
+
171
206
  /**
172
207
  * deletes a cluster
173
208
  */