@serve.zone/interfaces 31.2.0 → 31.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.
- package/changelog.md +74 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +52 -0
- package/dist_ts/data/cluster.js +68 -1
- 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/data/privatenetwork.js +3 -5
- package/dist_ts/private/ipv4.d.ts +9 -0
- package/dist_ts/private/ipv4.js +23 -0
- 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 +47 -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 +126 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +126 -0
- 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/data/privatenetwork.ts +3 -4
- package/ts/private/ipv4.ts +24 -0
- package/ts/private/networkcontract.ts +8 -2
- package/ts/private/utccalendarday.ts +30 -0
- package/ts/requests/cluster.ts +59 -0
- package/ts/requests/clusteringress.ts +140 -0
- package/ts/requests/index.ts +2 -0
package/readme.md
CHANGED
|
@@ -1113,6 +1113,132 @@ const certificateDomainName = data.clusterRelayCertificateDomainName(relay);
|
|
|
1113
1113
|
// 'relay.cluster-a.serve.zone'
|
|
1114
1114
|
```
|
|
1115
1115
|
|
|
1116
|
+
Enabling a relay is one operator flip per cluster. `enableClusterRelayV1 { identity, clusterId }`
|
|
1117
|
+
answers with the block Cloudly published, and `disableClusterRelayV1` removes it and answers with
|
|
1118
|
+
an explicit `relay: null`. The caller names only the cluster: Cloudly derives the origin itself
|
|
1119
|
+
from that cluster and its own public hostname, and refuses the request when no DNS zone it
|
|
1120
|
+
manages matches the derived name, so a relay name that nothing can publish is never written.
|
|
1121
|
+
|
|
1122
|
+
Once its socket is authenticated with `registerCloudlyClientSessionV1`, the relay sends
|
|
1123
|
+
`registerClusterRelayV1` carrying `IClusterRelayRegistrationV1`: the relay build, the cluster node
|
|
1124
|
+
it runs on, the exact endpoint it bound for its cluster's Pallet nodes, and a
|
|
1125
|
+
`registrationSequence` that counts up once per registration within one relay process and restarts
|
|
1126
|
+
at `0` when that process restarts. `validateClusterRelayRegistrationV1(value, path?)` returns one
|
|
1127
|
+
named reason per violation, like every validator here, and checks shape only. `listenAddress` is
|
|
1128
|
+
an IPv4 literal because Cloudly publishes it verbatim as the A record of the relay's certificate
|
|
1129
|
+
domain name — a hostname would need a `CNAME` and an IPv6 address an `AAAA` record, so admitting
|
|
1130
|
+
either is an additive change once Cloudly publishes it. A loopback or link-local address is
|
|
1131
|
+
refused by its own name, because a relay that bound one can never serve the nodes of its cluster.
|
|
1132
|
+
|
|
1133
|
+
Authority stays with Cloudly, and none of it is a request field. The cluster is the one the
|
|
1134
|
+
verified machine identity on that socket names; whether `relay.nodeId` is a node of that cluster
|
|
1135
|
+
is decided against Cloudly's own records; and one cluster has one relay, so when two peers are
|
|
1136
|
+
live the one holding the highest accepted `registrationSequence` is the relay and the other is
|
|
1137
|
+
never selected. The response carries the origin Cloudly published, or `null` while the cluster
|
|
1138
|
+
carries no relay block, which a relay must read as "not enabled yet" rather than as licence to
|
|
1139
|
+
invent an origin: it is how the relay checks that the certificate it holds and the name its nodes
|
|
1140
|
+
are told to reach are the same one.
|
|
1141
|
+
|
|
1142
|
+
```typescript
|
|
1143
|
+
const registration: data.IClusterRelayRegistrationV1 = {
|
|
1144
|
+
schemaVersion: 1,
|
|
1145
|
+
relay: {
|
|
1146
|
+
version: '4.0.0',
|
|
1147
|
+
nodeId: 'node-a',
|
|
1148
|
+
listenAddress: '10.0.4.7',
|
|
1149
|
+
listenPort: 8443,
|
|
1150
|
+
},
|
|
1151
|
+
registrationSequence: 0,
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
const registrationReasons = data.validateClusterRelayRegistrationV1(registration); // []
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
### Cluster Ingress
|
|
1158
|
+
|
|
1159
|
+
A cluster's ingress terminates TLS for the hostnames its services publish and forwards to the
|
|
1160
|
+
host ports Pallet published for the workloads behind them. Cloudly computes what it serves — it
|
|
1161
|
+
is the only party that knows every service, every ready endpoint, the node address each published
|
|
1162
|
+
port answers on and the certificate each hostname needs — and the cluster relay carries that table
|
|
1163
|
+
to the ingress and the ingress's counters back. These contracts carry no version suffix and no
|
|
1164
|
+
schema field: the protocol version is the installed `@serve.zone/interfaces` version.
|
|
1165
|
+
|
|
1166
|
+
`IClusterRouteTable { revision, issuedAt, routes, portRoutes }` is the whole table, never a delta.
|
|
1167
|
+
`revision` is its only idempotency: a relay and an ingress apply a table whose revision is greater
|
|
1168
|
+
than the one they hold and ignore anything else, so a repeated push, a replayed push and a push
|
|
1169
|
+
that crossed a newer one all settle the same way. `IClusterHttpRoute` names one hostname, its
|
|
1170
|
+
`destinations` (`{ address, port }`, unicast IPv4 and a real port), the `certificate` that
|
|
1171
|
+
terminates it and optional Basic `authentication`; `IClusterPortRoute` forwards a TCP or UDP
|
|
1172
|
+
listen port without terminating anything. `validateClusterRouteTable(value, path?)` returns one
|
|
1173
|
+
named reason per violation, refuses two routes for one hostname and two port routes for one
|
|
1174
|
+
transport and port, and — because a validation reason travels into logs — **never quotes
|
|
1175
|
+
certificate material or a password in a reason**.
|
|
1176
|
+
|
|
1177
|
+
The table is secret-bearing end to end: it carries the private key of every hostname the ingress
|
|
1178
|
+
serves. Exclude it from hooks, logs, journals and disk on every hop, exactly as a runtime session
|
|
1179
|
+
credential is excluded.
|
|
1180
|
+
|
|
1181
|
+
| Method | Direction | Body |
|
|
1182
|
+
| --- | --- | --- |
|
|
1183
|
+
| `pushClusterRouteTable` | Cloudly → relay | `{ table }` → `{ appliedRevision }` |
|
|
1184
|
+
| `getClusterRouteTable` | relay → Cloudly | `{}` → `{ table \| null }` |
|
|
1185
|
+
| `registerClusterIngress` | ingress → relay → Cloudly | `{ bearer, version, nodeId, capabilities? }` → `{ accepted: true, revision \| null }` |
|
|
1186
|
+
| `getClusterIngressRouteTable` | ingress → relay | `{ knownRevision \| null }` → `{ table \| null }` |
|
|
1187
|
+
| `getClusterTrafficStatistics` | Cloudly → relay → ingress | `{ fromDayUtc, toDayUtc, cursor?, limit }` → `{ statistics, nextCursor? }` |
|
|
1188
|
+
|
|
1189
|
+
`null` for a table means Cloudly has computed none yet — a fact, not an empty table, and an
|
|
1190
|
+
ingress must not serve it as one. An ingress announces itself with a **method**, not a connection
|
|
1191
|
+
tag: the relay learns what connected from what it registered through, which a server owns, rather
|
|
1192
|
+
than from a label a client authored.
|
|
1193
|
+
|
|
1194
|
+
**Who may be the ingress is Cloudly's decision, not the relay's.** The ingress shares the relay's
|
|
1195
|
+
node listener, so anything that reaches it could ask to register, and a relay cannot tell an
|
|
1196
|
+
ingress from anything else. `registerClusterIngress` therefore carries the `bearer` Cloudly minted
|
|
1197
|
+
into the ingress workload's assignment environment and travels both hops with one body: the
|
|
1198
|
+
ingress sends it to its relay, the relay forwards it untouched, and Cloudly verifies the bearer
|
|
1199
|
+
and derives the cluster from the identity tag on the relay's own session. The bearer is sensitive
|
|
1200
|
+
ephemeral input — excluded from hooks, logs, diagnostics and journals on every hop, and never
|
|
1201
|
+
quoted in a validation reason.
|
|
1202
|
+
|
|
1203
|
+
Isolation on that shared listener is therefore by **authorization, not by routing**: only the
|
|
1204
|
+
connection whose registration Cloudly accepted is the ingress, `getClusterIngressRouteTable` and
|
|
1205
|
+
`getClusterTrafficStatistics` are answered for that connection alone, and any other connection —
|
|
1206
|
+
including a Pallet node's — is refused by name. `validateClusterIngressRegistration(value, path?)`
|
|
1207
|
+
checks shape only: whether the bearer is the one Cloudly minted is Cloudly's to verify.
|
|
1208
|
+
|
|
1209
|
+
`IClusterTrafficStatistics` is one UTC day of one hostname's traffic on one node —
|
|
1210
|
+
`requests`, `bytesIn`, `bytesOut`, `connections` and the backend failures the proxy can observe
|
|
1211
|
+
(`connect`, `handshake`, `request`). Day buckets rather than rates: a rate answers "how busy is it
|
|
1212
|
+
now", which the live proxy already answers, while a bucket survives a restart, a poll that
|
|
1213
|
+
straddles midnight and a Cloudly outage. HTTP status classes are deliberately absent — the proxy
|
|
1214
|
+
exposes no status counters today, and a number this contract cannot source would be a guess.
|
|
1215
|
+
Readers page it exactly like CoreMail's mail statistics — the same `fromDayUtc`, `toDayUtc`,
|
|
1216
|
+
`cursor` and `limit`, answered as `{ statistics, nextCursor? }` — re-reading yesterday and today on
|
|
1217
|
+
every pass because a day only stops changing once it has closed. `dayUtc` must name a day that
|
|
1218
|
+
exists: the shared UTC calendar-day rule round-trips through `Date.UTC`, so `2026-13-45` is
|
|
1219
|
+
refused rather than stored.
|
|
1220
|
+
|
|
1221
|
+
```typescript
|
|
1222
|
+
const reasons = data.validateClusterRouteTable(table); // []
|
|
1223
|
+
const pageReasons = data.validateClusterTrafficStatisticsPage({ statistics: [] }); // []
|
|
1224
|
+
const ingressReasons = data.validateClusterIngressRegistration({
|
|
1225
|
+
bearer: ingressBearerFromAssignmentEnvironment,
|
|
1226
|
+
version: '3.0.0',
|
|
1227
|
+
nodeId: 'node-a',
|
|
1228
|
+
}); // []
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
### Node Uplink Address
|
|
1232
|
+
|
|
1233
|
+
A route to a node's published host ports needs the address that node answers on, and only the
|
|
1234
|
+
node can prove it: it holds the uplink observation the address belongs to. It therefore states it
|
|
1235
|
+
on the network report it already sends — `IReportRuntimeNetworkProtectionRequestV1` gains the
|
|
1236
|
+
optional `uplinkAddress`, a unicast IPv4 literal that is neither loopback nor link-local
|
|
1237
|
+
(`runtimeNodeUplinkAddress`). Absence stays absent: a node that states no address is told apart
|
|
1238
|
+
from one that states a value, and nothing downstream defaults or infers one. Cloudly stores what
|
|
1239
|
+
the node stated and resolves it into the route table; the relay never substitutes an address of
|
|
1240
|
+
its own.
|
|
1241
|
+
|
|
1116
1242
|
Shared service port helpers are exported from `data` so Cloudly, App Store resolution, Coreflow, Coretraffic, Onebox, and dcrouter agree on the same normalization rules:
|
|
1117
1243
|
|
|
1118
1244
|
```typescript
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/data/cluster.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
|
|
|
2
2
|
|
|
3
3
|
import { type IDockerRegistryInfo } from '../data/docker.js';
|
|
4
4
|
import type { IClusterNode } from './clusternode.js';
|
|
5
|
+
import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
|
|
5
6
|
|
|
6
7
|
export interface ICluster {
|
|
7
8
|
id: string;
|
|
@@ -60,7 +61,52 @@ export interface IClusterRelayV1 {
|
|
|
60
61
|
origin: string;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* What a cluster relay tells Cloudly about itself once its socket is authenticated.
|
|
66
|
+
*
|
|
67
|
+
* The relay sends this after `registerCloudlyClientSessionV1`, so the cluster it registers for
|
|
68
|
+
* is the one its verified machine identity names and is never a field here. `relay.nodeId` is
|
|
69
|
+
* the cluster node the relay process runs on, `listenAddress` and `listenPort` are the exact
|
|
70
|
+
* endpoint it bound for the Pallet nodes of its cluster, and `registrationSequence` counts up
|
|
71
|
+
* once per registration within one relay process, so Cloudly can tell a newer registration from
|
|
72
|
+
* a replayed older one while both peers are live.
|
|
73
|
+
*/
|
|
74
|
+
export interface IClusterRelayRegistrationV1 {
|
|
75
|
+
schemaVersion: 1;
|
|
76
|
+
relay: {
|
|
77
|
+
/** The relay build that is registering; a bounded label for operators and logs. */
|
|
78
|
+
version: string;
|
|
79
|
+
nodeId: string;
|
|
80
|
+
/**
|
|
81
|
+
* The address the relay bound, as an IPv4 literal.
|
|
82
|
+
*
|
|
83
|
+
* Cloudly publishes this value verbatim as the A record of the relay's certificate domain
|
|
84
|
+
* name, so the contract admits exactly what that record can carry: a hostname would need a
|
|
85
|
+
* `CNAME` and an IPv6 address an `AAAA` record, and neither is published today. Admitting
|
|
86
|
+
* either is an additive change once Cloudly publishes it.
|
|
87
|
+
*/
|
|
88
|
+
listenAddress: string;
|
|
89
|
+
listenPort: number;
|
|
90
|
+
};
|
|
91
|
+
/** Monotonic within one relay process; it restarts at 0 when the process restarts. */
|
|
92
|
+
registrationSequence: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What Cloudly tells the relay back.
|
|
97
|
+
*
|
|
98
|
+
* `origin` is the origin Cloudly has published for this cluster, so a relay can check that the
|
|
99
|
+
* certificate it holds and the name its nodes are told to reach are the same one. It is `null`
|
|
100
|
+
* while the cluster carries no relay block, which a relay must treat as "not enabled yet"
|
|
101
|
+
* rather than as an origin it may invent.
|
|
102
|
+
*/
|
|
103
|
+
export interface IClusterRelayRegistrationResponseV1 {
|
|
104
|
+
accepted: true;
|
|
105
|
+
origin: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
63
108
|
const maximumClusterRelayOriginLength = 2048;
|
|
109
|
+
const maximumClusterRelayVersionLength = 64;
|
|
64
110
|
|
|
65
111
|
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
|
|
66
112
|
Boolean(valueArg)
|
|
@@ -130,6 +176,86 @@ export const validateClusterRelayV1 = (
|
|
|
130
176
|
return errors;
|
|
131
177
|
};
|
|
132
178
|
|
|
179
|
+
const validateClusterRelayVersion = (valueArg: unknown, pathArg: string): string[] => (
|
|
180
|
+
typeof valueArg === 'string'
|
|
181
|
+
&& valueArg.length <= maximumClusterRelayVersionLength
|
|
182
|
+
&& /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(valueArg)
|
|
183
|
+
? []
|
|
184
|
+
: [`${pathArg} must be 1 to ${maximumClusterRelayVersionLength} characters of letters, `
|
|
185
|
+
+ 'digits, dot, plus, underscore or hyphen, starting with a letter or digit']
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
/** The same node id rule a node's own runtime session registers under. */
|
|
189
|
+
const validateClusterRelayNodeId = (valueArg: unknown, pathArg: string): string[] => (
|
|
190
|
+
typeof valueArg === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(valueArg)
|
|
191
|
+
? []
|
|
192
|
+
: [`${pathArg} must be 1 to 128 characters of letters, digits, underscore or hyphen`]
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const validateClusterRelayListenAddress = (valueArg: unknown, pathArg: string): string[] => {
|
|
196
|
+
if (!isPlausibleUnicastIpv4(valueArg)) {
|
|
197
|
+
return [`${pathArg} must be a unicast IPv4 literal`];
|
|
198
|
+
}
|
|
199
|
+
// A relay that bound one of these can never serve the nodes of its cluster, and publishing
|
|
200
|
+
// it would put an unreachable address into every node's relay origin.
|
|
201
|
+
if (valueArg.startsWith('127.')) {
|
|
202
|
+
return [`${pathArg} must not be a loopback address`];
|
|
203
|
+
}
|
|
204
|
+
if (valueArg.startsWith('169.254.')) {
|
|
205
|
+
return [`${pathArg} must not be a link-local address`];
|
|
206
|
+
}
|
|
207
|
+
return [];
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const validateClusterRelayListenPort = (valueArg: unknown, pathArg: string): string[] => (
|
|
211
|
+
typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg >= 1 && valueArg <= 65535
|
|
212
|
+
? []
|
|
213
|
+
: [`${pathArg} must be an integer from 1 to 65535`]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): string[] => {
|
|
217
|
+
if (!isRecord(valueArg)
|
|
218
|
+
|| !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'])) {
|
|
219
|
+
return [`${pathArg} must use its exact schema`];
|
|
220
|
+
}
|
|
221
|
+
return [
|
|
222
|
+
...validateClusterRelayVersion(valueArg.version, `${pathArg} version`),
|
|
223
|
+
...validateClusterRelayNodeId(valueArg.nodeId, `${pathArg} nodeId`),
|
|
224
|
+
...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
|
|
225
|
+
...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
|
|
226
|
+
];
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Validates one cluster relay registration and names every reason it is refused.
|
|
231
|
+
*
|
|
232
|
+
* Shape only, exactly like every other validator here: which cluster the relay speaks for comes
|
|
233
|
+
* from the verified identity on its socket, whether `relay.nodeId` is a node of that cluster is
|
|
234
|
+
* Cloudly's to decide against its own records, and whether this registration supersedes a live
|
|
235
|
+
* one is Cloudly's comparison of `registrationSequence` between peers. A registration that
|
|
236
|
+
* passes here has claimed nothing.
|
|
237
|
+
*/
|
|
238
|
+
export const validateClusterRelayRegistrationV1 = (
|
|
239
|
+
valueArg: unknown,
|
|
240
|
+
pathArg = 'cluster relay registration',
|
|
241
|
+
): string[] => {
|
|
242
|
+
if (!isRecord(valueArg)
|
|
243
|
+
|| !hasExactKeys(valueArg, ['schemaVersion', 'relay', 'registrationSequence'])) {
|
|
244
|
+
return [`${pathArg} must use its exact schema`];
|
|
245
|
+
}
|
|
246
|
+
const errors: string[] = [];
|
|
247
|
+
if (valueArg.schemaVersion !== 1) {
|
|
248
|
+
errors.push(`${pathArg} schemaVersion must be 1`);
|
|
249
|
+
}
|
|
250
|
+
errors.push(...validateClusterRelayEndpoint(valueArg.relay, `${pathArg} relay`));
|
|
251
|
+
if (typeof valueArg.registrationSequence !== 'number'
|
|
252
|
+
|| !Number.isSafeInteger(valueArg.registrationSequence)
|
|
253
|
+
|| valueArg.registrationSequence < 0) {
|
|
254
|
+
errors.push(`${pathArg} registrationSequence must be a non-negative safe integer`);
|
|
255
|
+
}
|
|
256
|
+
return errors;
|
|
257
|
+
};
|
|
258
|
+
|
|
133
259
|
/**
|
|
134
260
|
* The name the relay's certificate is issued for, and the A record Cloudly
|
|
135
261
|
* owns for it. Derived from the origin so every consumer derives it
|