@serve.zone/interfaces 32.27.0 → 32.29.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 +48 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +1 -1
- package/dist_ts/data/cluster.js +6 -1
- package/dist_ts/data/networkaddressplan.d.ts +27 -3
- package/dist_ts/data/networkaddressplan.js +38 -2
- package/dist_ts/data/runtimelocalstorageclaim.d.ts +76 -0
- package/dist_ts/data/runtimelocalstorageclaim.js +99 -8
- package/dist_ts/data/secret.d.ts +11 -0
- package/dist_ts/data/secret.js +13 -1
- package/dist_ts/data/settings.d.ts +51 -0
- package/dist_ts/data/settings.js +83 -1
- package/dist_ts/private/networkcontract.d.ts +18 -0
- package/dist_ts/private/networkcontract.js +23 -6
- package/dist_ts/requests/network.d.ts +2 -1
- package/dist_ts/requests/runtimesession.d.ts +12 -0
- package/dist_ts/requests/settings.d.ts +56 -2
- package/dist_ts/requests/settings.js +91 -1
- package/package.json +1 -1
- package/readme.md +110 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +5 -0
- package/ts/data/networkaddressplan.ts +66 -4
- package/ts/data/runtimelocalstorageclaim.ts +173 -8
- package/ts/data/secret.ts +16 -0
- package/ts/data/settings.ts +127 -0
- package/ts/private/networkcontract.ts +36 -1
- package/ts/requests/network.ts +2 -1
- package/ts/requests/runtimesession.ts +16 -0
- package/ts/requests/settings.ts +162 -2
|
@@ -3,8 +3,10 @@ import type { IRuntimeAssignment, IRuntimeAuthorityReference, IRuntimeController
|
|
|
3
3
|
import { snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
|
|
4
4
|
import type { IRuntimeSessionBinding } from './runtimesession.js';
|
|
5
5
|
import { snapshotRuntimeSessionBinding } from './runtimesession.js';
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import type { IIdentityCredential } from './user.js';
|
|
7
|
+
import { captureNetwork, networkController, networkCounter, networkDigest, networkEqual, networkHash, networkId,
|
|
8
|
+
networkList, networkNodeId, networkRecord, networkReference, networkReferenceOf, networkRevision, networkSorted,
|
|
9
|
+
requireNetwork,
|
|
8
10
|
} from '../private/networkcontract.js';
|
|
9
11
|
import { parseCanonicalIpAddress } from '../private/ipaddress.js';
|
|
10
12
|
import { isRuntimeIdentityNumber } from '../private/runtimepolicy.js';
|
|
@@ -161,6 +163,60 @@ export interface IApplyRuntimeLocalStorageClaimResponse {
|
|
|
161
163
|
claim: IRuntimeAuthorityReference;
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* What an administrator reads about one local storage claim a controller issued: where the volume
|
|
168
|
+
* is, whose it is, how it starts and what its node last answered.
|
|
169
|
+
*
|
|
170
|
+
* It is a projection of the controller's current generation, never the claim itself: it carries no
|
|
171
|
+
* digest, no controller epoch and no source beyond its kind, so an SMB claim's credential reference
|
|
172
|
+
* is not in it. `claimId` is the claim's `id`, the name the node keys the volume by, including the
|
|
173
|
+
* `import/<claimId>` directory an operator places an `import` volume's bytes in; a reader takes it
|
|
174
|
+
* from here and never derives it.
|
|
175
|
+
*
|
|
176
|
+
* `state` is what the node answered about exactly `generation`, or `null` while the node has not
|
|
177
|
+
* acknowledged that generation. It follows the rule `bindApplyRuntimeLocalStorageClaimResponse`
|
|
178
|
+
* holds an answer to: a bound claim is `ready`, `import-required` (an `import` claim only) or
|
|
179
|
+
* `uncertain`; a purge is `purged` or `uncertain`.
|
|
180
|
+
*/
|
|
181
|
+
export interface IRuntimeLocalStorageClaimSummary {
|
|
182
|
+
claimId: string;
|
|
183
|
+
generation: number;
|
|
184
|
+
clusterId: string;
|
|
185
|
+
nodeId: string;
|
|
186
|
+
serviceId: string;
|
|
187
|
+
volumeId: string;
|
|
188
|
+
mountPath: string;
|
|
189
|
+
sourceKind: TRuntimeLocalStorageSource['kind'];
|
|
190
|
+
initialization: IRuntimeLocalStorageClaim['initialization'];
|
|
191
|
+
lifecycle: TRuntimeLocalStorageLifecycle;
|
|
192
|
+
state: TRuntimeLocalStorageClaimState | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* An administrator's read of the local storage claims of one cluster, optionally of one node.
|
|
197
|
+
*
|
|
198
|
+
* Paged: `cursor` is the opaque `nextCursor` of the previous page, and `limit` asks for at most that
|
|
199
|
+
* many summaries, which the controller may lower but never exceed; both absent read the first page
|
|
200
|
+
* at the controller's own size.
|
|
201
|
+
*/
|
|
202
|
+
export interface IGetRuntimeLocalStorageClaimsRequest {
|
|
203
|
+
/** The controller verifies this credential and derives every actor claim itself. */
|
|
204
|
+
identity: IIdentityCredential;
|
|
205
|
+
clusterId: string;
|
|
206
|
+
nodeId?: string;
|
|
207
|
+
cursor?: string;
|
|
208
|
+
limit?: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* One page of summaries in strictly ascending `claimId` order. `nextCursor` is omitted on the final
|
|
213
|
+
* page; a page that carries it carries at least one summary.
|
|
214
|
+
*/
|
|
215
|
+
export interface IGetRuntimeLocalStorageClaimsResponse {
|
|
216
|
+
claims: IRuntimeLocalStorageClaimSummary[];
|
|
217
|
+
nextCursor?: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
164
220
|
export const runtimeLocalStorageClaimContract = Object.freeze({
|
|
165
221
|
digestDomain: 'serve.zone/runtime-local-storage-claim',
|
|
166
222
|
error: 'Runtime local storage claim contract rejected.',
|
|
@@ -178,6 +234,11 @@ export const runtimeLocalStorageClaimContract = Object.freeze({
|
|
|
178
234
|
maximumSegmentLength: 255,
|
|
179
235
|
/** The longest SMB share name. */
|
|
180
236
|
maximumShareLength: 80,
|
|
237
|
+
/**
|
|
238
|
+
* The most claim summaries one `getRuntimeLocalStorageClaims` page carries. A page of summaries
|
|
239
|
+
* with every field at its bound stays inside the network contract's one-mebibyte capture.
|
|
240
|
+
*/
|
|
241
|
+
maximumSummaryPageSize: 200,
|
|
181
242
|
} as const);
|
|
182
243
|
|
|
183
244
|
const fail = (): never => { throw new Error(runtimeLocalStorageClaimContract.error); };
|
|
@@ -241,6 +302,14 @@ const shareOf = (valueArg: unknown): string => {
|
|
|
241
302
|
return valueArg;
|
|
242
303
|
};
|
|
243
304
|
|
|
305
|
+
/** The states a node may answer about one generation, whatever the answer's status. */
|
|
306
|
+
const isClaimStateOf = (
|
|
307
|
+
claimArg: Pick<IRuntimeLocalStorageClaim, 'lifecycle' | 'initialization'>, stateArg: TRuntimeLocalStorageClaimState,
|
|
308
|
+
): boolean => claimArg.lifecycle === 'purge'
|
|
309
|
+
? stateArg === 'purged' || stateArg === 'uncertain'
|
|
310
|
+
: stateArg === 'ready' || stateArg === 'uncertain'
|
|
311
|
+
|| (stateArg === 'import-required' && claimArg.initialization === 'import');
|
|
312
|
+
|
|
244
313
|
const literalOf = <TLiteral extends string>(valueArg: unknown, allowedArg: readonly TLiteral[]): TLiteral => {
|
|
245
314
|
requireNetwork((allowedArg as readonly unknown[]).includes(valueArg));
|
|
246
315
|
return valueArg as TLiteral;
|
|
@@ -424,11 +493,107 @@ export const bindApplyRuntimeLocalStorageClaimResponse = (
|
|
|
424
493
|
const response = snapshotApplyRuntimeLocalStorageClaimResponse(valueArg);
|
|
425
494
|
const { claim } = snapshotApplyRuntimeLocalStorageClaimRequest(requestArg);
|
|
426
495
|
requireNetwork(networkEqual(response.claim, getRuntimeLocalStorageClaimReference(claim)));
|
|
427
|
-
if (response.status !== 'historical')
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
496
|
+
if (response.status !== 'historical') requireNetwork(isClaimStateOf(claim, response.state));
|
|
497
|
+
return response;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const sourceKinds = ['directory', 'nfs', 'smb'] as const;
|
|
501
|
+
const initializations = ['empty', 'import'] as const;
|
|
502
|
+
const lifecycles = ['bound', 'purge'] as const;
|
|
503
|
+
|
|
504
|
+
/** Detached exact-schema snapshot of one summary, its state judged against its lifecycle. */
|
|
505
|
+
export const snapshotRuntimeLocalStorageClaimSummary = (
|
|
506
|
+
valueArg: unknown,
|
|
507
|
+
): IRuntimeLocalStorageClaimSummary => safe(() => {
|
|
508
|
+
const value = networkRecord(captureNetwork(valueArg), ['claimId', 'generation', 'clusterId', 'nodeId',
|
|
509
|
+
'serviceId', 'volumeId', 'mountPath', 'sourceKind', 'initialization', 'lifecycle', 'state']);
|
|
510
|
+
const summary: IRuntimeLocalStorageClaimSummary = {
|
|
511
|
+
claimId: networkId(value.claimId),
|
|
512
|
+
generation: networkCounter(value.generation),
|
|
513
|
+
clusterId: networkId(value.clusterId),
|
|
514
|
+
nodeId: networkNodeId(value.nodeId),
|
|
515
|
+
serviceId: networkId(value.serviceId),
|
|
516
|
+
volumeId: networkId(value.volumeId),
|
|
517
|
+
mountPath: readMountPath(value.mountPath, 'mountPath'),
|
|
518
|
+
sourceKind: literalOf(value.sourceKind, sourceKinds),
|
|
519
|
+
initialization: literalOf(value.initialization, initializations),
|
|
520
|
+
lifecycle: literalOf(value.lifecycle, lifecycles),
|
|
521
|
+
state: value.state === null ? null : literalOf(value.state, claimStates),
|
|
522
|
+
};
|
|
523
|
+
// A purge always follows a bound generation, and an SMB volume always starts empty.
|
|
524
|
+
requireNetwork((summary.lifecycle === 'bound' || summary.generation > 1)
|
|
525
|
+
&& (summary.sourceKind !== 'smb' || summary.initialization === 'empty')
|
|
526
|
+
&& (summary.state === null || isClaimStateOf(summary, summary.state)));
|
|
527
|
+
return summary;
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The one projection of a claim and its node's last answer into a summary. `stateArg` is what the
|
|
532
|
+
* node answered about exactly this generation, or `null` while it has not acknowledged it.
|
|
533
|
+
*/
|
|
534
|
+
export const summarizeRuntimeLocalStorageClaim = (
|
|
535
|
+
claimArg: unknown, stateArg: TRuntimeLocalStorageClaimState | null,
|
|
536
|
+
): IRuntimeLocalStorageClaimSummary => {
|
|
537
|
+
const claim = snapshotRuntimeLocalStorageClaim(claimArg);
|
|
538
|
+
return snapshotRuntimeLocalStorageClaimSummary({
|
|
539
|
+
claimId: claim.id,
|
|
540
|
+
generation: claim.generation,
|
|
541
|
+
clusterId: claim.clusterId,
|
|
542
|
+
nodeId: claim.nodeId,
|
|
543
|
+
serviceId: claim.serviceId,
|
|
544
|
+
volumeId: claim.volumeId,
|
|
545
|
+
mountPath: claim.mount.mountPath,
|
|
546
|
+
sourceKind: claim.source.kind,
|
|
547
|
+
initialization: claim.initialization,
|
|
548
|
+
lifecycle: claim.lifecycle,
|
|
549
|
+
state: stateArg,
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Detached exact-schema snapshot of a read request. It checks the identity's shape only: the
|
|
555
|
+
* controller authenticates it.
|
|
556
|
+
*/
|
|
557
|
+
export const snapshotGetRuntimeLocalStorageClaimsRequest = (
|
|
558
|
+
valueArg: unknown,
|
|
559
|
+
): IGetRuntimeLocalStorageClaimsRequest => safe(() => {
|
|
560
|
+
const value = networkRecord(captureNetwork(valueArg), ['identity', 'clusterId'], ['nodeId', 'cursor', 'limit']);
|
|
561
|
+
const identity = networkRecord(value.identity, ['jwt']);
|
|
562
|
+
requireNetwork(typeof identity.jwt === 'string' && identity.jwt.length > 0);
|
|
563
|
+
return {
|
|
564
|
+
identity: { jwt: identity.jwt as string },
|
|
565
|
+
clusterId: networkId(value.clusterId),
|
|
566
|
+
...(Object.hasOwn(value, 'nodeId') ? { nodeId: networkNodeId(value.nodeId) } : {}),
|
|
567
|
+
...(Object.hasOwn(value, 'cursor') ? { cursor: networkId(value.cursor) } : {}),
|
|
568
|
+
...(Object.hasOwn(value, 'limit')
|
|
569
|
+
? { limit: networkCounter(value.limit, runtimeLocalStorageClaimContract.maximumSummaryPageSize) } : {}),
|
|
570
|
+
};
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
/** Detached exact-schema snapshot of one page: bounded, strictly ascending and never empty with a cursor. */
|
|
574
|
+
export const snapshotGetRuntimeLocalStorageClaimsResponse = (
|
|
575
|
+
valueArg: unknown,
|
|
576
|
+
): IGetRuntimeLocalStorageClaimsResponse => safe(() => {
|
|
577
|
+
const value = networkRecord(captureNetwork(valueArg), ['claims'], ['nextCursor']);
|
|
578
|
+
const claims = networkList(value.claims, runtimeLocalStorageClaimContract.maximumSummaryPageSize,
|
|
579
|
+
snapshotRuntimeLocalStorageClaimSummary);
|
|
580
|
+
networkSorted(claims, (claimArg) => claimArg.claimId);
|
|
581
|
+
if (!Object.hasOwn(value, 'nextCursor')) return { claims };
|
|
582
|
+
requireNetwork(claims.length > 0);
|
|
583
|
+
return { claims, nextCursor: networkId(value.nextCursor) };
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Matches a page to the read that asked for it: every summary names the requested cluster and, when
|
|
588
|
+
* the request named one, the requested node, and the page holds no more than the requested `limit`.
|
|
589
|
+
*/
|
|
590
|
+
export const bindGetRuntimeLocalStorageClaimsResponse = (
|
|
591
|
+
valueArg: unknown, requestArg: unknown,
|
|
592
|
+
): IGetRuntimeLocalStorageClaimsResponse => safe(() => {
|
|
593
|
+
const response = snapshotGetRuntimeLocalStorageClaimsResponse(valueArg);
|
|
594
|
+
const request = snapshotGetRuntimeLocalStorageClaimsRequest(requestArg);
|
|
595
|
+
requireNetwork(response.claims.length <= (request.limit ?? runtimeLocalStorageClaimContract.maximumSummaryPageSize)
|
|
596
|
+
&& response.claims.every((claimArg) => claimArg.clusterId === request.clusterId
|
|
597
|
+
&& (request.nodeId === undefined || claimArg.nodeId === request.nodeId)));
|
|
433
598
|
return response;
|
|
434
599
|
});
|
package/ts/data/secret.ts
CHANGED
|
@@ -1893,6 +1893,22 @@ export const createAppStoreInstallSecretEnvelopeContext = (contextArg: {
|
|
|
1893
1893
|
: null,
|
|
1894
1894
|
});
|
|
1895
1895
|
|
|
1896
|
+
/**
|
|
1897
|
+
* The context a dcrouter admin bootstrap token is sealed under for `bootstrapExternalGatewayCredential`.
|
|
1898
|
+
*
|
|
1899
|
+
* It binds the envelope to the dcrouter URL and the gateway client id the administrator named, so a
|
|
1900
|
+
* sealed token opens only for a request that states the same target, and Cloudly hands it only to
|
|
1901
|
+
* a gateway its own settings name.
|
|
1902
|
+
*/
|
|
1903
|
+
export const createGatewayBootstrapEnvelopeContext = (contextArg: {
|
|
1904
|
+
gatewayUrl: string;
|
|
1905
|
+
gatewayClientId: string;
|
|
1906
|
+
}): Uint8Array => contextBytes({
|
|
1907
|
+
purpose: 'serve.zone/gateway-bootstrap',
|
|
1908
|
+
gatewayUrl: contextArg.gatewayUrl,
|
|
1909
|
+
gatewayClientId: contextArg.gatewayClientId,
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1896
1912
|
export const createResolvedSecretMaterialEnvelopeContext = (contextArg: {
|
|
1897
1913
|
recipientKeyId: string;
|
|
1898
1914
|
organizationId: string;
|
package/ts/data/settings.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TServiceMailSubmissionTlsMode } from './mail.js';
|
|
2
2
|
import type { ISystemSecretOwner, TSecretManagementScope } from './secret.js';
|
|
3
|
+
import { hasExactKeys, readDataProperties } from '../private/dataproperties.js';
|
|
3
4
|
|
|
4
5
|
export interface ICloudlyS3BackupTargetDescriptor {
|
|
5
6
|
type: 's3';
|
|
@@ -109,3 +110,129 @@ export const cloudlySystemSecretKeys = Object.freeze({
|
|
|
109
110
|
coreMailSmtpTlsCertificatePem: 'COREMAIL_SMTP_TLS_CERTIFICATE_PEM',
|
|
110
111
|
coreMailSmtpTlsPrivateKeyPem: 'COREMAIL_SMTP_TLS_PRIVATE_KEY_PEM',
|
|
111
112
|
} as const);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How far Cloudly's enrollment as a dcrouter gateway client has come.
|
|
116
|
+
*
|
|
117
|
+
* - `unconfigured` — no `dcrouterGatewayUrl` is set, so there is nothing to enroll with.
|
|
118
|
+
* - `checking` — Cloudly is validating or provisioning its credential right now.
|
|
119
|
+
* - `ready` — Cloudly holds a validated gateway-client credential whose policy matches its settings.
|
|
120
|
+
* - `degraded` — dcrouter could not be reached; Cloudly retries on its own.
|
|
121
|
+
* - `admin-bootstrap-required` — the held credential is missing, rejected or stale (its policy no
|
|
122
|
+
* longer matches Cloudly's settings or the runtime network address plan's DNS record allowance),
|
|
123
|
+
* and only a dcrouter admin token handed in with `bootstrapExternalGatewayCredential` moves it on.
|
|
124
|
+
*/
|
|
125
|
+
export type TExternalGatewayEnrollmentStatus =
|
|
126
|
+
| 'unconfigured'
|
|
127
|
+
| 'checking'
|
|
128
|
+
| 'ready'
|
|
129
|
+
| 'degraded'
|
|
130
|
+
| 'admin-bootstrap-required';
|
|
131
|
+
|
|
132
|
+
/** Cloudly's gateway-client enrollment as an administrator reads it; it never carries a credential. */
|
|
133
|
+
export interface IExternalGatewayEnrollmentState {
|
|
134
|
+
status: TExternalGatewayEnrollmentStatus;
|
|
135
|
+
/** Why the state is what it is, in Cloudly's words; absent when there is nothing to say. */
|
|
136
|
+
reason?: string;
|
|
137
|
+
/** When Cloudly last finished a check, in milliseconds since the epoch. */
|
|
138
|
+
lastCheckedAt?: number;
|
|
139
|
+
/** When Cloudly retries on its own, for a `degraded` state. */
|
|
140
|
+
nextRetryAt?: number;
|
|
141
|
+
/** Whether Cloudly still holds a validated runtime credential it can serve gateway calls with. */
|
|
142
|
+
cachedValidationProof: boolean;
|
|
143
|
+
/** Whether a provisioned candidate credential still awaits finalization at dcrouter. */
|
|
144
|
+
finalizationPending: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const externalGatewayEnrollmentStatuses = Object.freeze([
|
|
148
|
+
'unconfigured',
|
|
149
|
+
'checking',
|
|
150
|
+
'ready',
|
|
151
|
+
'degraded',
|
|
152
|
+
'admin-bootstrap-required',
|
|
153
|
+
] as const satisfies readonly TExternalGatewayEnrollmentStatus[]);
|
|
154
|
+
|
|
155
|
+
export const externalGatewayBootstrapContract = Object.freeze({
|
|
156
|
+
/**
|
|
157
|
+
* The longest admin bootstrap token Cloudly accepts, in bytes. A dcrouter API token is far
|
|
158
|
+
* shorter; the bound only keeps an unopened envelope from carrying an arbitrary payload.
|
|
159
|
+
*/
|
|
160
|
+
maximumTokenBytes: 1_024,
|
|
161
|
+
/** The longest `reason` an enrollment state carries, in characters. */
|
|
162
|
+
maximumReasonLength: 4_096,
|
|
163
|
+
/** The longest `expectedGatewayUrl` a bootstrap request states, in characters. */
|
|
164
|
+
maximumGatewayUrlLength: 2_048,
|
|
165
|
+
/** The longest `expectedGatewayClientId` a bootstrap request states, in characters. */
|
|
166
|
+
maximumGatewayClientIdLength: 253,
|
|
167
|
+
} as const);
|
|
168
|
+
|
|
169
|
+
/** Every reason Cloudly answers instead of bootstrapping its gateway-client credential. */
|
|
170
|
+
export const externalGatewayBootstrapRefusals = Object.freeze([
|
|
171
|
+
/** Cloudly has no `dcrouterGatewayUrl` or no `dcrouterGatewayClientId` to enroll under. */
|
|
172
|
+
'gateway-unconfigured',
|
|
173
|
+
/**
|
|
174
|
+
* The request's `expectedGatewayUrl` or `expectedGatewayClientId` is not what Cloudly's settings
|
|
175
|
+
* state, so the token would reach a dcrouter or a client the administrator did not name.
|
|
176
|
+
*/
|
|
177
|
+
'gateway-target-mismatch',
|
|
178
|
+
/** The sealed token does not open, or what it opens to breaks the bootstrap token rule. */
|
|
179
|
+
'bootstrap-token-invalid',
|
|
180
|
+
/** dcrouter did not accept the token at all: unknown, revoked, disabled or expired. */
|
|
181
|
+
'bootstrap-token-rejected',
|
|
182
|
+
/** dcrouter accepted the token, but its role is not `admin`, so it cannot provision a client. */
|
|
183
|
+
'bootstrap-token-not-admin',
|
|
184
|
+
/** A candidate credential from an earlier provisioning is still being finalized; retry later. */
|
|
185
|
+
'bootstrap-candidate-pending',
|
|
186
|
+
/** dcrouter could not be reached; the held credential is unchanged and the call may be repeated. */
|
|
187
|
+
'gateway-unreachable',
|
|
188
|
+
/** dcrouter refused provisioning, or the credential it issued failed Cloudly's validation. */
|
|
189
|
+
'bootstrap-provisioning-refused',
|
|
190
|
+
] as const);
|
|
191
|
+
|
|
192
|
+
export type TExternalGatewayBootstrapRefusal = typeof externalGatewayBootstrapRefusals[number];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The bootstrap token rule, judged on the opened bytes: 1 to `maximumTokenBytes` bytes of visible
|
|
196
|
+
* ASCII, with no whitespace or control character. The token is dcrouter's; this only refuses what
|
|
197
|
+
* could never be one, so a pasted line break or an empty pipe never reaches dcrouter.
|
|
198
|
+
*/
|
|
199
|
+
export const isExternalGatewayBootstrapToken = (valueArg: unknown): valueArg is Uint8Array => (
|
|
200
|
+
valueArg instanceof Uint8Array
|
|
201
|
+
&& valueArg.length > 0
|
|
202
|
+
&& valueArg.length <= externalGatewayBootstrapContract.maximumTokenBytes
|
|
203
|
+
&& valueArg.every((byteArg) => byteArg >= 0x21 && byteArg <= 0x7e)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const isEpochMilliseconds = (valueArg: unknown): boolean => (
|
|
207
|
+
Number.isSafeInteger(valueArg) && (valueArg as number) >= 0
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
/** Structural check of an enrollment state as a client receives it; every problem is named. */
|
|
211
|
+
export const validateExternalGatewayEnrollmentState = (valueArg: unknown): string[] => {
|
|
212
|
+
const state = readDataProperties(valueArg);
|
|
213
|
+
if (state === null) return ['external gateway enrollment state must be a plain record'];
|
|
214
|
+
const errors: string[] = [];
|
|
215
|
+
if (!hasExactKeys(state, ['status', 'cachedValidationProof', 'finalizationPending'],
|
|
216
|
+
['reason', 'lastCheckedAt', 'nextRetryAt'])) {
|
|
217
|
+
errors.push('external gateway enrollment state contains fields outside its schema');
|
|
218
|
+
}
|
|
219
|
+
if (!externalGatewayEnrollmentStatuses.some((statusArg) => statusArg === state.status)) {
|
|
220
|
+
errors.push('external gateway enrollment status is unknown');
|
|
221
|
+
}
|
|
222
|
+
if (state.reason !== undefined && (typeof state.reason !== 'string'
|
|
223
|
+
|| state.reason.length === 0
|
|
224
|
+
|| state.reason.length > externalGatewayBootstrapContract.maximumReasonLength)) {
|
|
225
|
+
errors.push('external gateway enrollment reason must be a bounded non-empty string');
|
|
226
|
+
}
|
|
227
|
+
for (const key of ['lastCheckedAt', 'nextRetryAt'] as const) {
|
|
228
|
+
if (state[key] !== undefined && !isEpochMilliseconds(state[key])) {
|
|
229
|
+
errors.push(`external gateway enrollment ${key} must be epoch milliseconds`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const key of ['cachedValidationProof', 'finalizationPending'] as const) {
|
|
233
|
+
if (typeof state[key] !== 'boolean') {
|
|
234
|
+
errors.push(`external gateway enrollment ${key} must be a boolean`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return errors;
|
|
238
|
+
};
|
|
@@ -9,7 +9,42 @@ export const failNetwork = (): never => { throw new Error('Runtime network proje
|
|
|
9
9
|
export function requireNetwork(value: unknown): asserts value { if (!value) failNetwork(); }
|
|
10
10
|
export const networkCanonical = (value: unknown): string => canonicalizeStrictJson(value, failNetwork);
|
|
11
11
|
export const networkEqual = (left: unknown, right: unknown): boolean => networkCanonical(left) === networkCanonical(right);
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
/** The runtime network address plan's lists that must be stated in their one canonical order. */
|
|
14
|
+
export type TRuntimeNetworkAddressPlanOrderedField = 'prefixes' | 'pools' | 'resolvers' | 'platformEndpoints';
|
|
15
|
+
|
|
16
|
+
/** The structure rules an address plan refusal names; `order` is the only one named so far. */
|
|
17
|
+
export type TRuntimeNetworkAddressPlanStructureRule = 'order';
|
|
18
|
+
|
|
19
|
+
/** Which list of a plan broke which structure rule; never a value from the plan. */
|
|
20
|
+
export interface IRuntimeNetworkAddressPlanStructureViolation {
|
|
21
|
+
field: TRuntimeNetworkAddressPlanOrderedField;
|
|
22
|
+
rule: TRuntimeNetworkAddressPlanStructureRule;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A plan refused for a structure rule the contract names, rather than with the contract's generic
|
|
27
|
+
* rejection. It is the one error `networkSafe` lets through, so a caller can tell an administrator
|
|
28
|
+
* which list to fix; its message names the field and the rule and quotes nothing from the plan.
|
|
29
|
+
*/
|
|
30
|
+
export class RuntimeNetworkAddressPlanStructureError extends Error {
|
|
31
|
+
public readonly violation: IRuntimeNetworkAddressPlanStructureViolation;
|
|
32
|
+
|
|
33
|
+
public constructor(violationArg: IRuntimeNetworkAddressPlanStructureViolation) {
|
|
34
|
+
super(`Runtime network address plan rejected: ${violationArg.field} breaks the ${violationArg.rule} rule.`);
|
|
35
|
+
this.name = 'RuntimeNetworkAddressPlanStructureError';
|
|
36
|
+
this.violation = { field: violationArg.field, rule: violationArg.rule };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const networkSafe = <T>(operation: () => T): T => {
|
|
41
|
+
try {
|
|
42
|
+
return operation();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error instanceof RuntimeNetworkAddressPlanStructureError) throw error;
|
|
45
|
+
return failNetwork();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
13
48
|
|
|
14
49
|
/** Bound decoded transport input before canonicalization; never execute getters/toJSON. */
|
|
15
50
|
export const captureNetwork = (value: unknown): unknown => networkSafe(() => {
|
package/ts/requests/network.ts
CHANGED
|
@@ -108,7 +108,8 @@ export interface IRequest_Any_Cloudly_GetNetworkNodes {
|
|
|
108
108
|
* validates the plan with `snapshotRuntimeNetworkAddressPlan`, decides the change with
|
|
109
109
|
* `admitRuntimeNetworkAddressPlanChange`, refuses the change while a resolver or platform endpoint
|
|
110
110
|
* it removes or changes is still referenced, and answers with the stored plan: at its new revision,
|
|
111
|
-
* or unchanged.
|
|
111
|
+
* or unchanged. A list stated out of its canonical order (see `IRuntimeNetworkAddressPlan`) is
|
|
112
|
+
* refused `plan-unsorted`, naming the list as the refusal's reference; the plan is never reordered.
|
|
112
113
|
*/
|
|
113
114
|
export interface IReq_Any_Cloudly_SetRuntimeNetworkAddressPlan extends plugins.typedrequestInterfaces.implementsTR<
|
|
114
115
|
plugins.typedrequestInterfaces.ITypedRequest, IReq_Any_Cloudly_SetRuntimeNetworkAddressPlan
|
|
@@ -179,6 +179,22 @@ export interface IReq_Controller_Pallet_ApplyRuntimeLocalStorageClaim extends pl
|
|
|
179
179
|
response: localStorage.IApplyRuntimeLocalStorageClaimResponse;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* An administrator reads the local storage claims Cloudly issued in one cluster, optionally on one
|
|
184
|
+
* node, one page at a time: each claim's id, volume, mount path, initialization, lifecycle and what
|
|
185
|
+
* its node last answered. `snapshotGetRuntimeLocalStorageClaimsRequest` and
|
|
186
|
+
* `bindGetRuntimeLocalStorageClaimsResponse` judge both sides; a summary never carries the claim's
|
|
187
|
+
* source beyond its kind, so no credential reference leaves Cloudly through it.
|
|
188
|
+
*/
|
|
189
|
+
export interface IReq_Any_Cloudly_GetRuntimeLocalStorageClaims extends plugins.typedrequestInterfaces.implementsTR<
|
|
190
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
191
|
+
IReq_Any_Cloudly_GetRuntimeLocalStorageClaims
|
|
192
|
+
> {
|
|
193
|
+
method: 'getRuntimeLocalStorageClaims';
|
|
194
|
+
request: localStorage.IGetRuntimeLocalStorageClaimsRequest;
|
|
195
|
+
response: localStorage.IGetRuntimeLocalStorageClaimsResponse;
|
|
196
|
+
}
|
|
197
|
+
|
|
182
198
|
/** Current-peer report, including exact historical outbox recovery after reconnect. */
|
|
183
199
|
export interface IReq_Pallet_Controller_ReportRuntimeAssignmentObservation extends plugins.typedrequestInterfaces.implementsTR<
|
|
184
200
|
plugins.typedrequestInterfaces.ITypedRequest,
|
package/ts/requests/settings.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import * as secretData from '../data/secret.js';
|
|
3
|
+
import * as settingsData from '../data/settings.js';
|
|
4
|
+
import type { IActiveSecretRecipientMetadata } from '../data/secret.js';
|
|
5
|
+
import type { ICloudlySettings, IExternalGatewayEnrollmentState } from '../data/settings.js';
|
|
6
|
+
import type { IIdentity, IIdentityCredential } from '../data/user.js';
|
|
7
|
+
import { readDataProperties, hasExactKeys } from '../private/dataproperties.js';
|
|
4
8
|
|
|
5
9
|
// Get Settings
|
|
6
10
|
export interface IRequest_GetSettings extends plugins.typedrequestInterfaces.ITypedRequest {
|
|
@@ -64,3 +68,159 @@ export interface IRequest_GetSetting extends plugins.typedrequestInterfaces.ITyp
|
|
|
64
68
|
value: ICloudlySettings[keyof ICloudlySettings] | undefined;
|
|
65
69
|
};
|
|
66
70
|
}
|
|
71
|
+
|
|
72
|
+
/** A dcrouter admin bootstrap token, sealed to Cloudly's active secret ingress recipient. */
|
|
73
|
+
export interface IExternalGatewayBootstrapTokenInput {
|
|
74
|
+
mode: 'sealed';
|
|
75
|
+
/** Sealed under `data.createGatewayBootstrapEnvelopeContext` for the request's expected target. */
|
|
76
|
+
envelope: plugins.smartcrypto.IX25519EnvelopeV1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Enrolls Cloudly as its dcrouter gateway client with a dcrouter admin token an administrator
|
|
81
|
+
* hands in once.
|
|
82
|
+
*
|
|
83
|
+
* Cloudly opens the token, checks the target the request names against its own settings, asks
|
|
84
|
+
* dcrouter whether the token is an admin token, and provisions, finalizes and stores its own
|
|
85
|
+
* least-privilege gateway-client credential with it — under the policy its settings and the runtime
|
|
86
|
+
* network address plan state now. The admin token is used for this one call and never stored; the
|
|
87
|
+
* administrator revokes it at dcrouter afterwards. A refusal leaves the credential Cloudly already
|
|
88
|
+
* holds unchanged and is answered with one of `data.externalGatewayBootstrapRefusals`.
|
|
89
|
+
*
|
|
90
|
+
* This is the one way a gateway credential enters Cloudly, for a first enrollment and for the
|
|
91
|
+
* re-bootstrap a changed address plan or gateway setting asks for (`admin-bootstrap-required`).
|
|
92
|
+
*/
|
|
93
|
+
export interface IReq_Any_Cloudly_BootstrapExternalGatewayCredential
|
|
94
|
+
extends plugins.typedrequestInterfaces.implementsTR<
|
|
95
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
96
|
+
IReq_Any_Cloudly_BootstrapExternalGatewayCredential
|
|
97
|
+
> {
|
|
98
|
+
method: 'bootstrapExternalGatewayCredential';
|
|
99
|
+
request: {
|
|
100
|
+
identity: IIdentityCredential;
|
|
101
|
+
/** The `dcrouterGatewayUrl` the administrator read from Cloudly's settings. */
|
|
102
|
+
expectedGatewayUrl: string;
|
|
103
|
+
/** The `dcrouterGatewayClientId` the administrator read from Cloudly's settings. */
|
|
104
|
+
expectedGatewayClientId: string;
|
|
105
|
+
bootstrapToken: IExternalGatewayBootstrapTokenInput;
|
|
106
|
+
};
|
|
107
|
+
response: {
|
|
108
|
+
/** The enrollment after the bootstrap: `ready` when the new credential is Cloudly's runtime authority. */
|
|
109
|
+
state: IExternalGatewayEnrollmentState;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Cloudly's gateway-client enrollment state, for an administrator; it never carries a credential. */
|
|
114
|
+
export interface IReq_Any_Cloudly_GetExternalGatewayEnrollmentState
|
|
115
|
+
extends plugins.typedrequestInterfaces.implementsTR<
|
|
116
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
117
|
+
IReq_Any_Cloudly_GetExternalGatewayEnrollmentState
|
|
118
|
+
> {
|
|
119
|
+
method: 'getExternalGatewayEnrollmentState';
|
|
120
|
+
request: {
|
|
121
|
+
identity: IIdentityCredential;
|
|
122
|
+
};
|
|
123
|
+
response: {
|
|
124
|
+
state: IExternalGatewayEnrollmentState;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hasJwtIdentity = (valueArg: unknown): boolean => {
|
|
129
|
+
const identity = readDataProperties(valueArg);
|
|
130
|
+
return identity !== null && typeof identity.jwt === 'string' && identity.jwt.length > 0;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Structural check of the target a bootstrap request names; empty when both are well formed. */
|
|
134
|
+
const validateExpectedGatewayTarget = (urlArg: unknown, clientIdArg: unknown): string[] => {
|
|
135
|
+
const errors: string[] = [];
|
|
136
|
+
let url: URL | undefined;
|
|
137
|
+
if (typeof urlArg === 'string'
|
|
138
|
+
&& urlArg.length > 0
|
|
139
|
+
&& urlArg.length <= settingsData.externalGatewayBootstrapContract.maximumGatewayUrlLength) {
|
|
140
|
+
try {
|
|
141
|
+
url = new URL(urlArg);
|
|
142
|
+
} catch {
|
|
143
|
+
url = undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!url
|
|
147
|
+
|| url.protocol !== 'https:'
|
|
148
|
+
|| url.username !== ''
|
|
149
|
+
|| url.password !== ''
|
|
150
|
+
|| url.search !== ''
|
|
151
|
+
|| url.hash !== '') {
|
|
152
|
+
errors.push('bootstrap expectedGatewayUrl must be an HTTPS URL without credentials, query or fragment');
|
|
153
|
+
}
|
|
154
|
+
if (typeof clientIdArg !== 'string'
|
|
155
|
+
|| clientIdArg.length === 0
|
|
156
|
+
|| clientIdArg.length > settingsData.externalGatewayBootstrapContract.maximumGatewayClientIdLength
|
|
157
|
+
|| !/^[\x21-\x7e]+$/.test(clientIdArg)) {
|
|
158
|
+
errors.push('bootstrap expectedGatewayClientId must be a bounded identifier without whitespace');
|
|
159
|
+
}
|
|
160
|
+
return errors;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Checks a bootstrap request against its schema and the active ingress recipient, without opening
|
|
165
|
+
* the envelope: the recipient, the context digest of the named target and the ciphertext bound of
|
|
166
|
+
* `data.externalGatewayBootstrapContract.maximumTokenBytes`. Every problem is named.
|
|
167
|
+
*/
|
|
168
|
+
export const validateBootstrapExternalGatewayCredentialRequest = async (
|
|
169
|
+
requestArg: unknown,
|
|
170
|
+
activeIngressRecipientArg: IActiveSecretRecipientMetadata,
|
|
171
|
+
): Promise<string[]> => {
|
|
172
|
+
const request = readDataProperties(requestArg);
|
|
173
|
+
if (request === null) return ['bootstrap request must be a plain record'];
|
|
174
|
+
const errors: string[] = [];
|
|
175
|
+
if (!hasExactKeys(request, ['identity', 'expectedGatewayUrl', 'expectedGatewayClientId', 'bootstrapToken'])) {
|
|
176
|
+
errors.push('bootstrap request contains fields outside its schema');
|
|
177
|
+
}
|
|
178
|
+
if (!hasJwtIdentity(request.identity)) {
|
|
179
|
+
errors.push('bootstrap identity must be a JWT credential');
|
|
180
|
+
}
|
|
181
|
+
const targetErrors = validateExpectedGatewayTarget(
|
|
182
|
+
request.expectedGatewayUrl,
|
|
183
|
+
request.expectedGatewayClientId,
|
|
184
|
+
);
|
|
185
|
+
errors.push(...targetErrors);
|
|
186
|
+
const tokenInput = readDataProperties(request.bootstrapToken);
|
|
187
|
+
if (tokenInput === null || tokenInput.mode !== 'sealed') {
|
|
188
|
+
errors.push('bootstrap token must be a sealed input');
|
|
189
|
+
return errors;
|
|
190
|
+
}
|
|
191
|
+
const expectedContext = targetErrors.length === 0
|
|
192
|
+
? secretData.createGatewayBootstrapEnvelopeContext({
|
|
193
|
+
gatewayUrl: request.expectedGatewayUrl as string,
|
|
194
|
+
gatewayClientId: request.expectedGatewayClientId as string,
|
|
195
|
+
})
|
|
196
|
+
: new Uint8Array();
|
|
197
|
+
errors.push(...await secretData.validateSecretValueInputForRecipient(
|
|
198
|
+
tokenInput,
|
|
199
|
+
activeIngressRecipientArg,
|
|
200
|
+
expectedContext,
|
|
201
|
+
));
|
|
202
|
+
const envelope = readDataProperties(tokenInput.envelope);
|
|
203
|
+
if (envelope !== null
|
|
204
|
+
&& typeof envelope.ciphertext === 'string'
|
|
205
|
+
&& envelope.ciphertext.length
|
|
206
|
+
> Math.ceil(settingsData.externalGatewayBootstrapContract.maximumTokenBytes * 4 / 3)) {
|
|
207
|
+
errors.push(
|
|
208
|
+
`bootstrap token must not exceed ${settingsData.externalGatewayBootstrapContract.maximumTokenBytes} bytes`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return errors;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Checks an enrollment state read; every problem is named. */
|
|
215
|
+
export const validateGetExternalGatewayEnrollmentStateRequest = (requestArg: unknown): string[] => {
|
|
216
|
+
const request = readDataProperties(requestArg);
|
|
217
|
+
if (request === null) return ['enrollment state request must be a plain record'];
|
|
218
|
+
const errors: string[] = [];
|
|
219
|
+
if (!hasExactKeys(request, ['identity'])) {
|
|
220
|
+
errors.push('enrollment state request contains fields outside its schema');
|
|
221
|
+
}
|
|
222
|
+
if (!hasJwtIdentity(request.identity)) {
|
|
223
|
+
errors.push('enrollment state identity must be a JWT credential');
|
|
224
|
+
}
|
|
225
|
+
return errors;
|
|
226
|
+
};
|