@serve.zone/interfaces 31.3.0 → 31.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 +72 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +44 -9
- package/dist_ts/data/cluster.js +35 -12
- package/dist_ts/data/clusteringress.d.ts +137 -0
- package/dist_ts/data/clusteringress.js +302 -0
- package/dist_ts/data/coremail.runtime.js +4 -8
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/networkprotection.d.ts +18 -0
- package/dist_ts/data/networkprotection.js +24 -3
- package/dist_ts/private/networkcontract.d.ts +1 -1
- package/dist_ts/private/networkcontract.js +7 -3
- package/dist_ts/private/utccalendarday.d.ts +17 -0
- package/dist_ts/private/utccalendarday.js +27 -0
- package/dist_ts/requests/cluster.d.ts +17 -0
- package/dist_ts/requests/clusteringress.d.ts +120 -0
- package/dist_ts/requests/clusteringress.js +3 -0
- package/dist_ts/requests/index.d.ts +2 -1
- package/dist_ts/requests/index.js +3 -2
- package/package.json +1 -1
- package/readme.md +106 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +76 -17
- package/ts/data/clusteringress.ts +425 -0
- package/ts/data/coremail.runtime.ts +3 -10
- package/ts/data/index.ts +1 -0
- package/ts/data/networkprotection.ts +34 -2
- package/ts/private/networkcontract.ts +8 -2
- package/ts/private/utccalendarday.ts +30 -0
- package/ts/requests/cluster.ts +21 -0
- package/ts/requests/clusteringress.ts +140 -0
- package/ts/requests/index.ts +2 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
|
|
2
|
+
import { isUtcCalendarDay } from '../private/utccalendarday.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The cluster ingress contracts.
|
|
6
|
+
*
|
|
7
|
+
* A cluster's ingress terminates TLS for the hostnames its services publish and
|
|
8
|
+
* forwards to the host ports Pallet published for the workloads behind them.
|
|
9
|
+
* Cloudly computes the table — it is the only party that knows every service,
|
|
10
|
+
* every ready endpoint and every certificate — and the relay carries it to the
|
|
11
|
+
* ingress and the ingress's counters back.
|
|
12
|
+
*
|
|
13
|
+
* Names here carry no version suffix and no schema field: the protocol version
|
|
14
|
+
* is the installed `@serve.zone/interfaces` version.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const clusterIngressLimits = Object.freeze({
|
|
18
|
+
minimumIngressBearerLength: 16,
|
|
19
|
+
maximumIngressBearerLength: 4096,
|
|
20
|
+
maximumHostNameLength: 253,
|
|
21
|
+
maximumRoutes: 4096,
|
|
22
|
+
maximumPortRoutes: 1024,
|
|
23
|
+
maximumDestinationsPerRoute: 64,
|
|
24
|
+
maximumStatisticsPageSize: 1000,
|
|
25
|
+
maximumCursorLength: 512,
|
|
26
|
+
maximumIdentifierLength: 200,
|
|
27
|
+
maximumIngressCapabilities: 32,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** One backend a route forwards to: an address a node answers on, and its port. */
|
|
31
|
+
export interface IClusterRouteDestination {
|
|
32
|
+
address: string;
|
|
33
|
+
port: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Basic authentication for a route, as the proxy has always expressed it. */
|
|
37
|
+
export interface IClusterRouteAuthentication {
|
|
38
|
+
type: 'Basic';
|
|
39
|
+
user: string;
|
|
40
|
+
pass: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One hostname the ingress serves.
|
|
45
|
+
*
|
|
46
|
+
* `certificate` carries the private key the ingress needs to terminate TLS for
|
|
47
|
+
* `hostName`. A route table is therefore secret-bearing end to end: it is never
|
|
48
|
+
* logged, never journaled, never written to disk, and no validation reason
|
|
49
|
+
* derived from it ever quotes it.
|
|
50
|
+
*/
|
|
51
|
+
export interface IClusterHttpRoute {
|
|
52
|
+
hostName: string;
|
|
53
|
+
destinations: IClusterRouteDestination[];
|
|
54
|
+
certificate: {
|
|
55
|
+
publicKey: string;
|
|
56
|
+
privateKey: string;
|
|
57
|
+
};
|
|
58
|
+
authentication?: IClusterRouteAuthentication;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One TCP or UDP port the ingress forwards without terminating anything. */
|
|
62
|
+
export interface IClusterPortRoute {
|
|
63
|
+
name: string;
|
|
64
|
+
listenPort: number;
|
|
65
|
+
transport: 'tcp' | 'udp';
|
|
66
|
+
destinations: IClusterRouteDestination[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Everything a cluster's ingress serves, as one table.
|
|
71
|
+
*
|
|
72
|
+
* `revision` is the only idempotency the feed has: an ingress and a relay apply
|
|
73
|
+
* a table whose revision is greater than the one they hold and ignore anything
|
|
74
|
+
* else, so a repeated push, a replayed push and a push that crossed a newer one
|
|
75
|
+
* all settle the same way.
|
|
76
|
+
*/
|
|
77
|
+
export interface IClusterRouteTable {
|
|
78
|
+
revision: number;
|
|
79
|
+
issuedAt: number;
|
|
80
|
+
routes: IClusterHttpRoute[];
|
|
81
|
+
portRoutes: IClusterPortRoute[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One day of one hostname's traffic on one node, as the ingress counted it.
|
|
86
|
+
*
|
|
87
|
+
* Day buckets rather than rates: a rate answers "how busy is it now", which the
|
|
88
|
+
* live proxy already answers, while a bucket survives a restart, a poll that
|
|
89
|
+
* straddles midnight and a Cloudly outage.
|
|
90
|
+
*/
|
|
91
|
+
export interface IClusterTrafficStatistics {
|
|
92
|
+
serviceId: string;
|
|
93
|
+
hostName: string;
|
|
94
|
+
nodeId: string;
|
|
95
|
+
/** UTC calendar day, `YYYY-MM-DD`. */
|
|
96
|
+
dayUtc: string;
|
|
97
|
+
requests: number;
|
|
98
|
+
bytesIn: number;
|
|
99
|
+
bytesOut: number;
|
|
100
|
+
connections: number;
|
|
101
|
+
/**
|
|
102
|
+
* What the proxy could observe about its backends. HTTP status classes are
|
|
103
|
+
* deliberately absent: the proxy exposes no status counters today, and a
|
|
104
|
+
* number this contract cannot source would be a guess.
|
|
105
|
+
*/
|
|
106
|
+
backendErrors: {
|
|
107
|
+
connect: number;
|
|
108
|
+
handshake: number;
|
|
109
|
+
request: number;
|
|
110
|
+
};
|
|
111
|
+
updatedAt: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
|
|
115
|
+
Boolean(valueArg)
|
|
116
|
+
&& typeof valueArg === 'object'
|
|
117
|
+
&& !Array.isArray(valueArg)
|
|
118
|
+
&& (Object.getPrototypeOf(valueArg) === Object.prototype
|
|
119
|
+
|| Object.getPrototypeOf(valueArg) === null)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const hasExactKeys = (
|
|
123
|
+
valueArg: Record<string, unknown>,
|
|
124
|
+
keysArg: readonly string[],
|
|
125
|
+
optionalKeysArg: readonly string[] = [],
|
|
126
|
+
): boolean => {
|
|
127
|
+
const actualKeys = Object.keys(valueArg);
|
|
128
|
+
return keysArg.every((keyArg) => actualKeys.includes(keyArg))
|
|
129
|
+
&& actualKeys.every((keyArg) => keysArg.includes(keyArg) || optionalKeysArg.includes(keyArg));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const isCounter = (valueArg: unknown): valueArg is number => (
|
|
133
|
+
Number.isSafeInteger(valueArg) && (valueArg as number) >= 0
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const isPort = (valueArg: unknown): valueArg is number => (
|
|
137
|
+
Number.isSafeInteger(valueArg) && (valueArg as number) >= 1 && (valueArg as number) <= 65_535
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const hostLabelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
141
|
+
// Built from the limit rather than beside it, so the two can never disagree.
|
|
142
|
+
const identifierPattern = new RegExp(
|
|
143
|
+
`^[A-Za-z0-9][A-Za-z0-9:._-]{0,${clusterIngressLimits.maximumIdentifierLength - 1}}$`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A hostname a certificate can be issued for and a browser can present.
|
|
148
|
+
*
|
|
149
|
+
* Lowercase labels only, because a certificate names the lowercase form and a
|
|
150
|
+
* table that carried both would serve one of them without a certificate.
|
|
151
|
+
*/
|
|
152
|
+
export const isClusterIngressHostName = (valueArg: unknown): valueArg is string => {
|
|
153
|
+
if (typeof valueArg !== 'string'
|
|
154
|
+
|| valueArg.length === 0
|
|
155
|
+
|| valueArg.length > clusterIngressLimits.maximumHostNameLength) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const labels = valueArg.split('.');
|
|
159
|
+
return labels.length >= 2 && labels.every((labelArg) => hostLabelPattern.test(labelArg));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const validateDestinations = (valueArg: unknown, pathArg: string): string[] => {
|
|
163
|
+
if (!Array.isArray(valueArg)
|
|
164
|
+
|| valueArg.length === 0
|
|
165
|
+
|| valueArg.length > clusterIngressLimits.maximumDestinationsPerRoute) {
|
|
166
|
+
return [`${pathArg} must list 1 to `
|
|
167
|
+
+ `${clusterIngressLimits.maximumDestinationsPerRoute} destinations`];
|
|
168
|
+
}
|
|
169
|
+
const errors: string[] = [];
|
|
170
|
+
for (const [index, destination] of valueArg.entries()) {
|
|
171
|
+
const destinationPath = `${pathArg}[${index}]`;
|
|
172
|
+
if (!isRecord(destination) || !hasExactKeys(destination, ['address', 'port'])) {
|
|
173
|
+
errors.push(`${destinationPath} must use its exact schema`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!isPlausibleUnicastIpv4(destination.address)) {
|
|
177
|
+
errors.push(`${destinationPath} address must be a unicast IPv4 literal`);
|
|
178
|
+
}
|
|
179
|
+
if (!isPort(destination.port)) {
|
|
180
|
+
errors.push(`${destinationPath} port must be an integer from 1 to 65535`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return errors;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const validateCertificate = (valueArg: unknown, pathArg: string): string[] => {
|
|
187
|
+
// Presence and shape only. The material itself is never described in a reason:
|
|
188
|
+
// a validation message travels into logs, and this one would carry a key.
|
|
189
|
+
if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['publicKey', 'privateKey'])) {
|
|
190
|
+
return [`${pathArg} must use its exact schema`];
|
|
191
|
+
}
|
|
192
|
+
const errors: string[] = [];
|
|
193
|
+
if (typeof valueArg.publicKey !== 'string' || valueArg.publicKey.length === 0) {
|
|
194
|
+
errors.push(`${pathArg} publicKey must be a non-empty PEM string`);
|
|
195
|
+
}
|
|
196
|
+
if (typeof valueArg.privateKey !== 'string' || valueArg.privateKey.length === 0) {
|
|
197
|
+
errors.push(`${pathArg} privateKey must be a non-empty PEM string`);
|
|
198
|
+
}
|
|
199
|
+
return errors;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const validateAuthentication = (valueArg: unknown, pathArg: string): string[] => {
|
|
203
|
+
if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['type', 'user', 'pass'])) {
|
|
204
|
+
return [`${pathArg} must use its exact schema`];
|
|
205
|
+
}
|
|
206
|
+
return valueArg.type === 'Basic'
|
|
207
|
+
&& typeof valueArg.user === 'string' && valueArg.user.length > 0
|
|
208
|
+
&& typeof valueArg.pass === 'string' && valueArg.pass.length > 0
|
|
209
|
+
? []
|
|
210
|
+
: [`${pathArg} must be Basic authentication with a user and a pass`];
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const validateHttpRoute = (valueArg: unknown, pathArg: string): string[] => {
|
|
214
|
+
if (!isRecord(valueArg)
|
|
215
|
+
|| !hasExactKeys(valueArg, ['hostName', 'destinations', 'certificate'], ['authentication'])) {
|
|
216
|
+
return [`${pathArg} must use its exact schema`];
|
|
217
|
+
}
|
|
218
|
+
const errors: string[] = [];
|
|
219
|
+
if (!isClusterIngressHostName(valueArg.hostName)) {
|
|
220
|
+
errors.push(`${pathArg} hostName must be a lowercase dotted hostname a certificate can name`);
|
|
221
|
+
}
|
|
222
|
+
errors.push(...validateDestinations(valueArg.destinations, `${pathArg} destinations`));
|
|
223
|
+
errors.push(...validateCertificate(valueArg.certificate, `${pathArg} certificate`));
|
|
224
|
+
if (valueArg.authentication !== undefined) {
|
|
225
|
+
errors.push(...validateAuthentication(valueArg.authentication, `${pathArg} authentication`));
|
|
226
|
+
}
|
|
227
|
+
return errors;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const validatePortRoute = (valueArg: unknown, pathArg: string): string[] => {
|
|
231
|
+
if (!isRecord(valueArg)
|
|
232
|
+
|| !hasExactKeys(valueArg, ['name', 'listenPort', 'transport', 'destinations'])) {
|
|
233
|
+
return [`${pathArg} must use its exact schema`];
|
|
234
|
+
}
|
|
235
|
+
const errors: string[] = [];
|
|
236
|
+
if (typeof valueArg.name !== 'string' || !identifierPattern.test(valueArg.name)) {
|
|
237
|
+
errors.push(`${pathArg} name must be a bounded identifier`);
|
|
238
|
+
}
|
|
239
|
+
if (!isPort(valueArg.listenPort)) {
|
|
240
|
+
errors.push(`${pathArg} listenPort must be an integer from 1 to 65535`);
|
|
241
|
+
}
|
|
242
|
+
if (valueArg.transport !== 'tcp' && valueArg.transport !== 'udp') {
|
|
243
|
+
errors.push(`${pathArg} transport must be tcp or udp`);
|
|
244
|
+
}
|
|
245
|
+
errors.push(...validateDestinations(valueArg.destinations, `${pathArg} destinations`));
|
|
246
|
+
return errors;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Validates one route table and names every reason it is refused.
|
|
251
|
+
*
|
|
252
|
+
* Shape only. Whether a hostname belongs to the cluster, whether a certificate
|
|
253
|
+
* matches it, and whether a destination is a node of this cluster are decisions
|
|
254
|
+
* for the party that computed the table; a table that passes here has claimed
|
|
255
|
+
* none of them. No reason ever quotes certificate material.
|
|
256
|
+
*/
|
|
257
|
+
export const validateClusterRouteTable = (
|
|
258
|
+
valueArg: unknown,
|
|
259
|
+
pathArg = 'cluster route table',
|
|
260
|
+
): string[] => {
|
|
261
|
+
if (!isRecord(valueArg)
|
|
262
|
+
|| !hasExactKeys(valueArg, ['revision', 'issuedAt', 'routes', 'portRoutes'])) {
|
|
263
|
+
return [`${pathArg} must use its exact schema`];
|
|
264
|
+
}
|
|
265
|
+
const errors: string[] = [];
|
|
266
|
+
if (!isCounter(valueArg.revision)) {
|
|
267
|
+
errors.push(`${pathArg} revision must be a non-negative safe integer`);
|
|
268
|
+
}
|
|
269
|
+
if (!Number.isSafeInteger(valueArg.issuedAt) || (valueArg.issuedAt as number) <= 0) {
|
|
270
|
+
errors.push(`${pathArg} issuedAt must be a positive epoch millisecond timestamp`);
|
|
271
|
+
}
|
|
272
|
+
if (!Array.isArray(valueArg.routes)
|
|
273
|
+
|| valueArg.routes.length > clusterIngressLimits.maximumRoutes) {
|
|
274
|
+
errors.push(`${pathArg} routes must list at most ${clusterIngressLimits.maximumRoutes} routes`);
|
|
275
|
+
} else {
|
|
276
|
+
const seenHostNames = new Set<string>();
|
|
277
|
+
for (const [index, route] of valueArg.routes.entries()) {
|
|
278
|
+
errors.push(...validateHttpRoute(route, `${pathArg} routes[${index}]`));
|
|
279
|
+
const hostName = isRecord(route) ? route.hostName : undefined;
|
|
280
|
+
if (typeof hostName === 'string') {
|
|
281
|
+
if (seenHostNames.has(hostName)) {
|
|
282
|
+
errors.push(`${pathArg} routes[${index}] repeats hostName ${hostName}`);
|
|
283
|
+
}
|
|
284
|
+
seenHostNames.add(hostName);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!Array.isArray(valueArg.portRoutes)
|
|
289
|
+
|| valueArg.portRoutes.length > clusterIngressLimits.maximumPortRoutes) {
|
|
290
|
+
errors.push(`${pathArg} portRoutes must list at most `
|
|
291
|
+
+ `${clusterIngressLimits.maximumPortRoutes} port routes`);
|
|
292
|
+
} else {
|
|
293
|
+
const seenPorts = new Set<string>();
|
|
294
|
+
for (const [index, portRoute] of valueArg.portRoutes.entries()) {
|
|
295
|
+
errors.push(...validatePortRoute(portRoute, `${pathArg} portRoutes[${index}]`));
|
|
296
|
+
if (!isRecord(portRoute)) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const key = `${String(portRoute.transport)}:${String(portRoute.listenPort)}`;
|
|
300
|
+
if (seenPorts.has(key)) {
|
|
301
|
+
errors.push(`${pathArg} portRoutes[${index}] repeats ${key}`);
|
|
302
|
+
}
|
|
303
|
+
seenPorts.add(key);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return errors;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** Validates one day bucket of ingress counters. */
|
|
310
|
+
export const validateClusterTrafficStatistics = (
|
|
311
|
+
valueArg: unknown,
|
|
312
|
+
pathArg = 'cluster traffic statistics',
|
|
313
|
+
): string[] => {
|
|
314
|
+
if (!isRecord(valueArg) || !hasExactKeys(valueArg, [
|
|
315
|
+
'serviceId', 'hostName', 'nodeId', 'dayUtc', 'requests', 'bytesIn', 'bytesOut',
|
|
316
|
+
'connections', 'backendErrors', 'updatedAt',
|
|
317
|
+
])) {
|
|
318
|
+
return [`${pathArg} must use its exact schema`];
|
|
319
|
+
}
|
|
320
|
+
const errors: string[] = [];
|
|
321
|
+
for (const key of ['serviceId', 'nodeId'] as const) {
|
|
322
|
+
const value = valueArg[key];
|
|
323
|
+
if (typeof value !== 'string' || !identifierPattern.test(value)) {
|
|
324
|
+
errors.push(`${pathArg} ${key} must be a bounded identifier`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!isClusterIngressHostName(valueArg.hostName)) {
|
|
328
|
+
errors.push(`${pathArg} hostName must be a lowercase dotted hostname`);
|
|
329
|
+
}
|
|
330
|
+
if (!isUtcCalendarDay(valueArg.dayUtc)) {
|
|
331
|
+
errors.push(`${pathArg} dayUtc must be a real YYYY-MM-DD UTC calendar day`);
|
|
332
|
+
}
|
|
333
|
+
for (const key of ['requests', 'bytesIn', 'bytesOut', 'connections'] as const) {
|
|
334
|
+
if (!isCounter(valueArg[key])) {
|
|
335
|
+
errors.push(`${pathArg} ${key} must be a non-negative safe integer`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const backendErrors = valueArg.backendErrors;
|
|
339
|
+
if (!isRecord(backendErrors)
|
|
340
|
+
|| !hasExactKeys(backendErrors, ['connect', 'handshake', 'request'])
|
|
341
|
+
|| !isCounter(backendErrors.connect)
|
|
342
|
+
|| !isCounter(backendErrors.handshake)
|
|
343
|
+
|| !isCounter(backendErrors.request)) {
|
|
344
|
+
errors.push(`${pathArg} backendErrors must count connect, handshake and request failures`);
|
|
345
|
+
}
|
|
346
|
+
if (!Number.isSafeInteger(valueArg.updatedAt) || (valueArg.updatedAt as number) <= 0) {
|
|
347
|
+
errors.push(`${pathArg} updatedAt must be a positive epoch millisecond timestamp`);
|
|
348
|
+
}
|
|
349
|
+
return errors;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Validates one page of ingress counters, as a reader receives it.
|
|
354
|
+
*
|
|
355
|
+
* The cursor is opaque to everyone but the ingress that issued it: bounded and
|
|
356
|
+
* printable here, meaningful only there.
|
|
357
|
+
*/
|
|
358
|
+
export const validateClusterTrafficStatisticsPage = (
|
|
359
|
+
valueArg: unknown,
|
|
360
|
+
pathArg = 'cluster traffic statistics page',
|
|
361
|
+
): string[] => {
|
|
362
|
+
if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['statistics'], ['nextCursor'])) {
|
|
363
|
+
return [`${pathArg} must use its exact schema`];
|
|
364
|
+
}
|
|
365
|
+
const errors: string[] = [];
|
|
366
|
+
if (!Array.isArray(valueArg.statistics)
|
|
367
|
+
|| valueArg.statistics.length > clusterIngressLimits.maximumStatisticsPageSize) {
|
|
368
|
+
errors.push(`${pathArg} statistics must list at most `
|
|
369
|
+
+ `${clusterIngressLimits.maximumStatisticsPageSize} entries`);
|
|
370
|
+
} else {
|
|
371
|
+
for (const [index, item] of valueArg.statistics.entries()) {
|
|
372
|
+
errors.push(...validateClusterTrafficStatistics(item, `${pathArg} statistics[${index}]`));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (valueArg.nextCursor !== undefined
|
|
376
|
+
&& (typeof valueArg.nextCursor !== 'string'
|
|
377
|
+
|| valueArg.nextCursor.length === 0
|
|
378
|
+
|| valueArg.nextCursor.length > clusterIngressLimits.maximumCursorLength)) {
|
|
379
|
+
errors.push(`${pathArg} nextCursor must be 1 to `
|
|
380
|
+
+ `${clusterIngressLimits.maximumCursorLength} characters when present`);
|
|
381
|
+
}
|
|
382
|
+
return errors;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Validates one cluster ingress registration and names every reason it is refused.
|
|
387
|
+
*
|
|
388
|
+
* Shape only, and deliberately so: whether the bearer is the one Cloudly minted
|
|
389
|
+
* for this cluster's ingress is Cloudly's to verify against its own records, and
|
|
390
|
+
* which cluster is speaking comes from the identity tag on the relay's session.
|
|
391
|
+
* A registration that passes here has proven nothing — and no reason it returns
|
|
392
|
+
* ever quotes the bearer, because a reason travels into logs.
|
|
393
|
+
*/
|
|
394
|
+
export const validateClusterIngressRegistration = (
|
|
395
|
+
valueArg: unknown,
|
|
396
|
+
pathArg = 'cluster ingress registration',
|
|
397
|
+
): string[] => {
|
|
398
|
+
if (!isRecord(valueArg)
|
|
399
|
+
|| !hasExactKeys(valueArg, ['bearer', 'version', 'nodeId'], ['capabilities'])) {
|
|
400
|
+
return [`${pathArg} must use its exact schema`];
|
|
401
|
+
}
|
|
402
|
+
const errors: string[] = [];
|
|
403
|
+
if (typeof valueArg.bearer !== 'string'
|
|
404
|
+
|| valueArg.bearer.length < clusterIngressLimits.minimumIngressBearerLength
|
|
405
|
+
|| valueArg.bearer.length > clusterIngressLimits.maximumIngressBearerLength) {
|
|
406
|
+
errors.push(`${pathArg} bearer must be ${clusterIngressLimits.minimumIngressBearerLength} to `
|
|
407
|
+
+ `${clusterIngressLimits.maximumIngressBearerLength} characters`);
|
|
408
|
+
}
|
|
409
|
+
if (typeof valueArg.version !== 'string' || !identifierPattern.test(valueArg.version)) {
|
|
410
|
+
errors.push(`${pathArg} version must be a bounded identifier`);
|
|
411
|
+
}
|
|
412
|
+
if (typeof valueArg.nodeId !== 'string' || !identifierPattern.test(valueArg.nodeId)) {
|
|
413
|
+
errors.push(`${pathArg} nodeId must be a bounded identifier`);
|
|
414
|
+
}
|
|
415
|
+
if (valueArg.capabilities !== undefined
|
|
416
|
+
&& (!Array.isArray(valueArg.capabilities)
|
|
417
|
+
|| valueArg.capabilities.length > clusterIngressLimits.maximumIngressCapabilities
|
|
418
|
+
|| !valueArg.capabilities.every((capabilityArg) => (
|
|
419
|
+
typeof capabilityArg === 'string' && identifierPattern.test(capabilityArg)
|
|
420
|
+
)))) {
|
|
421
|
+
errors.push(`${pathArg} capabilities must be at most `
|
|
422
|
+
+ `${clusterIngressLimits.maximumIngressCapabilities} bounded identifiers when present`);
|
|
423
|
+
}
|
|
424
|
+
return errors;
|
|
425
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRealUtcCalendarDay, utcCalendarDayPattern } from '../private/utccalendarday.js';
|
|
1
2
|
import {
|
|
2
3
|
canonicalizeStrictJson,
|
|
3
4
|
createCanonicalJsonSha256Hex,
|
|
@@ -2047,20 +2048,12 @@ export const normalizeCoreMailGatewayPeerDesiredState = (
|
|
|
2047
2048
|
});
|
|
2048
2049
|
};
|
|
2049
2050
|
|
|
2050
|
-
const utcCalendarDayRegex = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
|
|
2051
|
-
|
|
2052
2051
|
const requireUtcCalendarDay = (valueArg: unknown, pathArg: string): string => {
|
|
2053
2052
|
const value = requireString(valueArg, pathArg, 10);
|
|
2054
|
-
if (!
|
|
2053
|
+
if (!utcCalendarDayPattern.test(value)) {
|
|
2055
2054
|
fail(`${pathArg} must be a canonical YYYY-MM-DD UTC day`);
|
|
2056
2055
|
}
|
|
2057
|
-
|
|
2058
|
-
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
2059
|
-
if (
|
|
2060
|
-
parsed.getUTCFullYear() !== year
|
|
2061
|
-
|| parsed.getUTCMonth() + 1 !== month
|
|
2062
|
-
|| parsed.getUTCDate() !== day
|
|
2063
|
-
) {
|
|
2056
|
+
if (!isRealUtcCalendarDay(value)) {
|
|
2064
2057
|
fail(`${pathArg} must be a real UTC calendar day`);
|
|
2065
2058
|
}
|
|
2066
2059
|
return value;
|
package/ts/data/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
|
|
1
2
|
import type { TSha256Digest } from './immutableimage.js';
|
|
2
3
|
import type { IRuntimeAuthorityReferenceV1 } from './runtimeassignment.js';
|
|
3
4
|
import type { IRuntimeNetworkNodeScopeV1 } from './networkprojection.js';
|
|
@@ -32,6 +33,16 @@ export interface IRuntimeNetworkProtectionReceiptV1 extends IRuntimeNetworkNodeS
|
|
|
32
33
|
export interface IReportRuntimeNetworkProtectionRequestV1 {
|
|
33
34
|
session: IRuntimeSessionBindingV1;
|
|
34
35
|
receipt: IRuntimeNetworkProtectionReceiptV1;
|
|
36
|
+
/**
|
|
37
|
+
* The address this node answers on, as the node itself observed it.
|
|
38
|
+
*
|
|
39
|
+
* A route to one of this node's published host ports uses exactly this
|
|
40
|
+
* address. Only the node can prove it — it holds the uplink observation the
|
|
41
|
+
* address belongs to — so it states it here, on the report it already sends
|
|
42
|
+
* about its own network, and Cloudly stores it rather than inferring one.
|
|
43
|
+
* Absent from a node that has not stated one; never defaulted, never guessed.
|
|
44
|
+
*/
|
|
45
|
+
uplinkAddress?: string;
|
|
35
46
|
}
|
|
36
47
|
export interface IReportRuntimeNetworkProtectionResponseV1 {
|
|
37
48
|
status: 'accepted' | 'replay';
|
|
@@ -113,10 +124,31 @@ export const admitRuntimeNetworkProtectionReceiptV1 = async (
|
|
|
113
124
|
};
|
|
114
125
|
|
|
115
126
|
export const snapshotReportRuntimeNetworkProtectionRequestV1 = (value: unknown): IReportRuntimeNetworkProtectionRequestV1 => networkSafe(() => {
|
|
116
|
-
const item = networkRecord(captureNetwork(value), ['session', 'receipt']);
|
|
117
|
-
|
|
127
|
+
const item = networkRecord(captureNetwork(value), ['session', 'receipt'], ['uplinkAddress']);
|
|
128
|
+
const request: IReportRuntimeNetworkProtectionRequestV1 = {
|
|
129
|
+
session: snapshotRuntimeSessionBindingV1(item.session),
|
|
130
|
+
receipt: snapshotRuntimeNetworkProtectionReceiptV1(item.receipt),
|
|
131
|
+
};
|
|
132
|
+
if (Object.hasOwn(item, 'uplinkAddress')) {
|
|
133
|
+
request.uplinkAddress = runtimeNodeUplinkAddress(item.uplinkAddress);
|
|
134
|
+
}
|
|
135
|
+
return request;
|
|
118
136
|
});
|
|
119
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The address a route to this node's published ports uses.
|
|
140
|
+
*
|
|
141
|
+
* A unicast IPv4 literal that is neither loopback nor link-local: a node that
|
|
142
|
+
* stated one of those would publish an address no other node can reach, and a
|
|
143
|
+
* route table built from it would send every request nowhere.
|
|
144
|
+
*/
|
|
145
|
+
export const runtimeNodeUplinkAddress = (value: unknown): string => {
|
|
146
|
+
requireNetwork(isPlausibleUnicastIpv4(value)
|
|
147
|
+
&& !value.startsWith('127.')
|
|
148
|
+
&& !value.startsWith('169.254.'));
|
|
149
|
+
return value;
|
|
150
|
+
};
|
|
151
|
+
|
|
120
152
|
/** Both trusted bindings come from the receiver's fenced physical-session/history owner. */
|
|
121
153
|
export const bindReportRuntimeNetworkProtectionRequestV1 = (
|
|
122
154
|
value: unknown, trustedCurrentSessionValue: unknown, trustedReporterSessionValue: unknown,
|
|
@@ -56,10 +56,16 @@ export const captureNetwork = (value: unknown): unknown => networkSafe(() => {
|
|
|
56
56
|
return result;
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
export const networkRecord = (
|
|
59
|
+
export const networkRecord = (
|
|
60
|
+
value: unknown, keys: string[], optionalKeys: string[] = [],
|
|
61
|
+
): Record<string, unknown> => {
|
|
60
62
|
requireNetwork(value !== null && typeof value === 'object' && !Array.isArray(value));
|
|
61
63
|
const actual = Object.keys(value);
|
|
62
|
-
|
|
64
|
+
// Absence stays absent: a stated optional key is read, an omitted one is not
|
|
65
|
+
// defaulted, so a receiver can tell a sender that stated nothing from one that
|
|
66
|
+
// stated a value.
|
|
67
|
+
requireNetwork(keys.every(key => Object.hasOwn(value, key))
|
|
68
|
+
&& actual.every(key => keys.includes(key) || optionalKeys.includes(key)));
|
|
63
69
|
return value as Record<string, unknown>;
|
|
64
70
|
};
|
|
65
71
|
export const networkList = <T>(value: unknown, maximum: number, read: (item: unknown) => T): T[] => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UTC calendar day every consumer of this package agrees on.
|
|
3
|
+
*
|
|
4
|
+
* Two facts, deliberately separate: whether a value has the canonical shape, and whether that
|
|
5
|
+
* shape names a day that exists. `2026-13-45` passes the first and fails the second, and a
|
|
6
|
+
* caller that only checked the pattern would store a day no clock can ever produce.
|
|
7
|
+
*/
|
|
8
|
+
export const utcCalendarDayPattern = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether a canonical `YYYY-MM-DD` string names a real day.
|
|
12
|
+
*
|
|
13
|
+
* Round-trips through `Date.UTC`, which normalises an impossible day into a neighbouring one:
|
|
14
|
+
* a value that survives the round trip unchanged is the day it claims to be.
|
|
15
|
+
*/
|
|
16
|
+
export const isRealUtcCalendarDay = (valueArg: string): boolean => {
|
|
17
|
+
if (!utcCalendarDayPattern.test(valueArg)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const [year, month, day] = valueArg.split('-').map(Number);
|
|
21
|
+
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
22
|
+
return parsed.getUTCFullYear() === year
|
|
23
|
+
&& parsed.getUTCMonth() + 1 === month
|
|
24
|
+
&& parsed.getUTCDate() === day;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Both facts at once, for a caller that reports one reason. */
|
|
28
|
+
export const isUtcCalendarDay = (valueArg: unknown): valueArg is string => (
|
|
29
|
+
typeof valueArg === 'string' && isRealUtcCalendarDay(valueArg)
|
|
30
|
+
);
|
package/ts/requests/cluster.ts
CHANGED
|
@@ -109,6 +109,27 @@ export interface IReq_Any_Cloudly_DisableClusterRelayV1 extends plugins.typedreq
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The relay registrations an administrator asks Cloudly about.
|
|
114
|
+
*
|
|
115
|
+
* Read-only: it answers with what Cloudly accepted, plus whether a verified socket carries each
|
|
116
|
+
* relay at this instant. `clusterIds` narrows the answer to the named clusters; omitting it asks
|
|
117
|
+
* for every cluster whose relay ever registered.
|
|
118
|
+
*/
|
|
119
|
+
export interface IReq_Any_Cloudly_GetClusterRelayRegistrations extends plugins.typedrequestInterfaces.implementsTR<
|
|
120
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
121
|
+
IReq_Any_Cloudly_GetClusterRelayRegistrations
|
|
122
|
+
> {
|
|
123
|
+
method: 'getClusterRelayRegistrations';
|
|
124
|
+
request: {
|
|
125
|
+
identity: userInterfaces.IIdentity;
|
|
126
|
+
clusterIds?: string[];
|
|
127
|
+
};
|
|
128
|
+
response: {
|
|
129
|
+
registrations: clusterInterfaces.IClusterRelayRegistration[];
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
/**
|
|
113
134
|
* The cluster relay tells Cloudly where it listens, over its own authenticated socket.
|
|
114
135
|
*
|