@serve.zone/interfaces 32.3.0 → 32.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 +88 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +30 -0
- package/dist_ts/data/cluster.js +48 -3
- package/dist_ts/data/clusteringress.js +9 -8
- package/dist_ts/data/clusternode.d.ts +6 -1
- package/dist_ts/data/clusternode.js +32 -5
- package/dist_ts/data/clustervpn.d.ts +135 -0
- package/dist_ts/data/clustervpn.js +147 -0
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/isolatedrestore.js +12 -4
- package/dist_ts/data/networkaddressplan.d.ts +54 -11
- package/dist_ts/data/networkaddressplan.js +67 -23
- package/dist_ts/data/networkauthority.js +9 -3
- package/dist_ts/data/networkcredential.d.ts +13 -4
- package/dist_ts/data/networkcredential.js +41 -23
- package/dist_ts/data/networklease.js +3 -3
- package/dist_ts/data/networknode.js +3 -3
- package/dist_ts/data/networkprojection.js +8 -3
- package/dist_ts/data/nodeenrollment.js +4 -2
- package/dist_ts/data/noderetirement.js +3 -3
- package/dist_ts/data/runtimeassignment.d.ts +35 -0
- package/dist_ts/data/runtimeassignment.js +70 -10
- package/dist_ts/data/runtimesession.js +3 -2
- package/dist_ts/data/serviceruntime.js +6 -5
- package/dist_ts/data/sparknodereport.js +6 -3
- package/dist_ts/private/networkcontract.d.ts +8 -0
- package/dist_ts/private/networkcontract.js +19 -2
- package/dist_ts/private/nodeidentifier.d.ts +22 -0
- package/dist_ts/private/nodeidentifier.js +23 -0
- package/dist_ts/requests/cluster.d.ts +27 -0
- package/dist_ts/requests/cluster.js +1 -1
- package/dist_ts/requests/corestore.js +4 -4
- package/dist_ts/runtime.js +7 -4
- package/package.json +1 -1
- package/readme.md +132 -21
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +78 -2
- package/ts/data/clusteringress.ts +8 -7
- package/ts/data/clusternode.ts +32 -4
- package/ts/data/clustervpn.ts +248 -0
- package/ts/data/index.ts +1 -0
- package/ts/data/isolatedrestore.ts +12 -3
- package/ts/data/networkaddressplan.ts +103 -27
- package/ts/data/networkauthority.ts +8 -2
- package/ts/data/networkcredential.ts +52 -24
- package/ts/data/networklease.ts +2 -2
- package/ts/data/networknode.ts +2 -2
- package/ts/data/networkprojection.ts +6 -2
- package/ts/data/nodeenrollment.ts +4 -2
- package/ts/data/noderetirement.ts +2 -2
- package/ts/data/runtimeassignment.ts +84 -8
- package/ts/data/runtimesession.ts +3 -2
- package/ts/data/serviceruntime.ts +6 -5
- package/ts/data/sparknodereport.ts +7 -2
- package/ts/private/networkcontract.ts +17 -1
- package/ts/private/nodeidentifier.ts +25 -0
- package/ts/requests/cluster.ts +35 -0
- package/ts/requests/corestore.ts +3 -3
- package/ts/runtime.ts +6 -3
package/readme.md
CHANGED
|
@@ -31,6 +31,34 @@ import { appstore, data, platform, platformservice, protocol, requests } from '@
|
|
|
31
31
|
|
|
32
32
|
This package intentionally has no service implementation logic. It is a stable vocabulary for services that need to agree on payload shape, method names, and response types.
|
|
33
33
|
|
|
34
|
+
### Identifier Vocabularies
|
|
35
|
+
|
|
36
|
+
Two vocabularies name things in these contracts, and which one a member uses is part of the
|
|
37
|
+
contract.
|
|
38
|
+
|
|
39
|
+
| Vocabulary | Rule | Members |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| Canonical identifier | `^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$` — first character alphanumeric, up to 200 characters | organizations, clusters, services, namespaces, sessions, assignments, attempts, authorities, pools, endpoints and every content id derived from them |
|
|
42
|
+
| Node id | `^[A-Za-z0-9_-]{1,128}$` — the URL alphabet, a leading `-` or `_` included, up to 128 characters | every member that names a node of a cluster |
|
|
43
|
+
|
|
44
|
+
A node may be named `-abc` or `_abc`: enrollment, the runtime session registration and the Spark
|
|
45
|
+
wire admit a node id that begins with a separator, so every member that names the same node admits
|
|
46
|
+
one too. `data.isClusterNodeId(value)` is that rule, and it is the rule at every node-bearing member:
|
|
47
|
+
`nodeId` on a session binding, a registration, an enrollment, a Spark heartbeat, a relay block, an
|
|
48
|
+
assignment, a workload lease, a handoff lease, a DNS-lease renewal run, a protection receipt, an
|
|
49
|
+
egress authority, a cluster VPN node, an ingress registration, a traffic bucket, a Corestore
|
|
50
|
+
inventory and an isolated-restore node control; `id` on a router incarnation and on a node
|
|
51
|
+
retirement; `cloudlyNodeId` on a cluster runtime target and on a secret runtime target; and
|
|
52
|
+
`placement.nodeIds` on a service runtime spec. A service that stores or forwards a node id
|
|
53
|
+
should judge it with the same reader rather than with the canonical one, which would refuse a node
|
|
54
|
+
these contracts themselves enrolled and registered.
|
|
55
|
+
|
|
56
|
+
A replica id is the one composed name: a producer names a replica slot `<node id>.<index>`, counted
|
|
57
|
+
from zero. `data.isRuntimeReplicaId(value)` reads it by decomposition — the part before the last dot
|
|
58
|
+
in the node vocabulary, the index on its own and never zero-padded — and reads a name that carries
|
|
59
|
+
no such index, a zero-padded `node-a.01` among them, as an opaque slot name in the canonical
|
|
60
|
+
identifier vocabulary.
|
|
61
|
+
|
|
34
62
|
WorkloadInit release verifiers should use the dedicated Node-compatible subpath:
|
|
35
63
|
|
|
36
64
|
```typescript
|
|
@@ -490,17 +518,25 @@ The `getRuntimeManagedVpnCredential` method contract in `requests.runtimesession
|
|
|
490
518
|
uses `IGetRuntimeManagedVpnCredentialRequest` and
|
|
491
519
|
`IGetRuntimeManagedVpnCredentialResponse`. Its request carries the exact current
|
|
492
520
|
session and signed projection reference. The secret response repeats those
|
|
493
|
-
bindings and supplies
|
|
494
|
-
|
|
495
|
-
|
|
521
|
+
bindings and supplies the `hub` tuple the node dials, the `transport` it dials it
|
|
522
|
+
over (`quic` or `websocket`), the managed authority ID, the Noise server public
|
|
523
|
+
key, the client keypair and the expiry in Unix milliseconds. `serverName` is present
|
|
524
|
+
exactly when the transport is `websocket` — the TLS name that hub is dialled under,
|
|
525
|
+
which the node cross-checks against the relay origin it stores — and absent for
|
|
526
|
+
`quic`, which is dialled on the address alone. The hub is the node's own cluster's
|
|
527
|
+
relay (see [Cluster VPN Hub And Network](#cluster-vpn-hub-and-network)). Keep the
|
|
528
|
+
response process-local; never persist, hash, log or embed it in a signed projection.
|
|
496
529
|
|
|
497
530
|
`snapshotGetRuntimeManagedVpnCredentialRequest` and its response counterpart
|
|
498
531
|
capture closed detached JSON shapes and canonical 32-byte key encodings.
|
|
499
532
|
`bindGetRuntimeManagedVpnCredentialRequest(request, projection, trustedSession)`
|
|
500
533
|
validates the complete projection digest and binds node, controller, namespace,
|
|
501
534
|
physical-session identity and projection reference. The response binder takes
|
|
502
|
-
`(response, request, projection, trustedSession, now, renewal = null)` and additionally requires
|
|
503
|
-
|
|
535
|
+
`(response, request, projection, trustedSession, now, renewal = null)` and additionally requires the
|
|
536
|
+
hub's exact tuple to be selected in `router.platformEndpointIds`, carrying the protocol of the
|
|
537
|
+
credential's transport — `udp` for `quic` and `tcp` for `websocket`, as
|
|
538
|
+
`runtimeNetworkAddressPlanContract.hubTransports` states. A relay therefore cannot name itself: the
|
|
539
|
+
endpoint has to be one the node's **signed** projection already selected.
|
|
504
540
|
Expiry must be after the caller-qualified time and no later than the referenced
|
|
505
541
|
projection's effective DNS window (see [DNS Lease Renewals](#dns-lease-renewals));
|
|
506
542
|
acceptance before that window starts rejects.
|
|
@@ -650,30 +686,45 @@ const window = await data.getRuntimeNetworkEffectiveDnsWindow(admittedProjection
|
|
|
650
686
|
|
|
651
687
|
`data.IRuntimeNetworkAddressPlan` is the address space an administrator gives the runtime network,
|
|
652
688
|
and the single input Cloudly composes the protected authority from:
|
|
653
|
-
`{ id: 'address-plan', revision, prefixes, pools, resolvers, platformEndpoints, vpn: { controlPrefix,
|
|
654
|
-
Pools, resolvers and platform endpoints use the protected authority's own element shapes
|
|
655
|
-
|
|
656
|
-
|
|
689
|
+
`{ id: 'address-plan', revision, prefixes, pools, resolvers, platformEndpoints, vpn: { controlPrefix, hubPrefix } }`.
|
|
690
|
+
Pools, resolvers and platform endpoints use the protected authority's own element shapes.
|
|
691
|
+
`controlPrefix` is the private prefix VPN control addresses are allocated from, and `hubPrefix` is
|
|
692
|
+
the protected prefix every cluster's VPN hub binds inside. The hub endpoints themselves are no
|
|
693
|
+
administrator's input: each cluster's relay reports what it bound, and the composition turns that
|
|
694
|
+
into platform endpoints, so `platformEndpoints` never lists one.
|
|
657
695
|
|
|
658
696
|
`snapshotRuntimeNetworkAddressPlan` refuses any plan that could describe an authority the authority
|
|
659
|
-
contract refuses
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
length is stated
|
|
666
|
-
|
|
697
|
+
contract refuses: it runs `snapshotRuntimeNetworkProtectedAuthority` over the plan's own sets, so
|
|
698
|
+
overlapping pools, more than 16 resolvers and a resolver or endpoint outside `prefixes` or inside a
|
|
699
|
+
pool are all refused by that one validator. On top it requires both VPN prefixes to sit inside
|
|
700
|
+
`prefixes`, to overlap no pool, to hold no resolver or platform endpoint, and never to overlap each
|
|
701
|
+
other — control addresses live inside the tunnel, hub endpoints on the underlay. `controlPrefix` is
|
|
702
|
+
additionally a private prefix no longer than /30
|
|
703
|
+
(`runtimeNetworkAddressPlanContract.maximumControlPrefixLength`); no minimum length is stated,
|
|
704
|
+
because the private-range rule already keeps it at /8 or longer.
|
|
705
|
+
|
|
706
|
+
`data.IRuntimeNetworkVpnHub { clusterId, address, quicPort, websocketPort }` is one cluster's hub as
|
|
707
|
+
its relay bound it, with `null` for a transport it did not bind and at least one of the two bound.
|
|
708
|
+
`runtimeNetworkVpnHubEndpoints(hub)` is the one derivation of what it contributes:
|
|
709
|
+
`<clusterId>:quic` on `udp` and `<clusterId>:ws` on `tcp`, in id order, as
|
|
710
|
+
`runtimeNetworkAddressPlanContract.hubTransports` states. Every reader calls it — the composition
|
|
711
|
+
that seals the authority and the router selection that names which endpoint a node may dial — so
|
|
712
|
+
two readers can never derive two ids for one hub.
|
|
713
|
+
`composeRuntimeNetworkProtectedAuthority(plan, frame, hubs)` is the composition itself: the producer
|
|
667
714
|
supplies the frame (`TRuntimeNetworkProtectedAuthorityFrame`: authority id, generation, controller,
|
|
668
|
-
previous reference and egress authorities) and
|
|
669
|
-
the validator and the protection producer can
|
|
715
|
+
previous reference and egress authorities) and the hubs of the clusters that registered one, and
|
|
716
|
+
receives the complete authority with its digest, so the validator and the protection producer can
|
|
717
|
+
never compose differently. Each hub's address must sit inside `vpn.hubPrefix` and one cluster is
|
|
718
|
+
named once; an empty `hubs` composes the authority of a runtime network whose clusters have no hub
|
|
719
|
+
yet. One hub costs up to two of the authority's 64 platform endpoints, so 32 clusters fit one
|
|
720
|
+
authority.
|
|
670
721
|
|
|
671
722
|
`admitRuntimeNetworkAddressPlanChange(previous | null, next)` is the pure decision for a plan write at
|
|
672
723
|
the following revision; any other revision is a contract violation. Leases and the managed VPN
|
|
673
724
|
outlive a plan revision, so it answers:
|
|
674
725
|
|
|
675
726
|
- `unchanged` when the content equals the stored plan; Cloudly writes nothing;
|
|
676
|
-
- `refused` with `plan-vpn-immutable` when
|
|
727
|
+
- `refused` with `plan-vpn-immutable` when either VPN prefix differs, `plan-pool-removed` when any pool is gone
|
|
677
728
|
or changed its purpose or prefix, and `plan-prefix-shrunk` when the new prefixes, however split,
|
|
678
729
|
no longer cover every address the old ones did;
|
|
679
730
|
- `accepted` with the ids of the resolvers and platform endpoints the change removes or changes. An
|
|
@@ -722,7 +773,19 @@ rule read the other way, for a receiver checking a list it was sent. Canonical i
|
|
|
722
773
|
strictly increasing in the code-unit order of the names themselves — a rule a peer
|
|
723
774
|
in another language implements from the vocabulary alone, rather than from the
|
|
724
775
|
order this package happens to declare it in — and both directions compare through
|
|
725
|
-
that one rule, so neither can drift from the other.
|
|
776
|
+
that one rule, so neither can drift from the other.
|
|
777
|
+
|
|
778
|
+
`workload.registryHost` is the registry that published the image, which is the publication identity
|
|
779
|
+
the image release is bound to, and it is never rewritten. `workload.pullEndpoint` is the optional
|
|
780
|
+
`host[:port]` the node fetches the bytes from instead — a cluster whose relay forwards the registry
|
|
781
|
+
states the relay's own origin, so a node pulls inside its cluster and nothing cluster-side dials the
|
|
782
|
+
control plane. `data.buildRuntimeAssignmentImageReference(workload)` is the one place that choice is
|
|
783
|
+
made: `pullEndpoint ?? registryHost`, digest-pinned either way, so the endpoint decides reachability
|
|
784
|
+
and never content, and a controller and a node cannot disagree about the reference. Absence stays
|
|
785
|
+
absence: an assignment that states no endpoint is different content, and so a different digest, from
|
|
786
|
+
one that does.
|
|
787
|
+
|
|
788
|
+
All fields except the digest itself participate in a domain-separated
|
|
726
789
|
SHA-256 digest. Digests provide integrity and compare-and-swap identity;
|
|
727
790
|
authentication must separately establish the trusted controller/node scope.
|
|
728
791
|
|
|
@@ -1666,6 +1729,54 @@ const registrationReasons = data.validateRegisterClusterRelayRequest(registratio
|
|
|
1666
1729
|
How the relay reaches each node's Corestore is part of the same document: see
|
|
1667
1730
|
[Corestore Through The Relay](#corestore-through-the-relay).
|
|
1668
1731
|
|
|
1732
|
+
### Cluster VPN Hub And Network
|
|
1733
|
+
|
|
1734
|
+
A cluster's relay is also that cluster's managed VPN hub: every node of the cluster dials it, and
|
|
1735
|
+
an inter-node workload packet is relayed inside the cluster instead of crossing the control plane.
|
|
1736
|
+
Cloudly keeps every authority — it compiles the network and issues each node's credential — and
|
|
1737
|
+
carries no packet.
|
|
1738
|
+
|
|
1739
|
+
A relay that bound a hub says so in its registration, in `relay.vpnHub`:
|
|
1740
|
+
`data.IClusterRelayVpnHub { publicKey, address, quicPort, websocketPort }`. It reports what it
|
|
1741
|
+
bound, exactly like `listenAddress` and `listenPort`: `address` is an IPv4 literal inside the
|
|
1742
|
+
address plan's `vpn.hubPrefix`, a transport it did not bind is `null`, and at least one of the two
|
|
1743
|
+
is bound. `publicKey` is the hub's Noise public key in the one canonical 32-byte base64 encoding
|
|
1744
|
+
every Noise key of this contract is read with — this advertisement, the managed VPN credential's
|
|
1745
|
+
keys and a cluster network's nodes all pass the same reader. A relay names no endpoint id and no
|
|
1746
|
+
TLS name of its own: Cloudly composes the endpoints with `runtimeNetworkVpnHubEndpoints` (see
|
|
1747
|
+
[Runtime Network Address Plan](#runtime-network-address-plan)) and states the TLS name from the
|
|
1748
|
+
origin it published for that relay. The member is optional — a relay that serves no hub registers
|
|
1749
|
+
exactly as before, and Cloudly selects no hub for that cluster, stating `relay-vpn-hub-absent`.
|
|
1750
|
+
`validateClusterRelayVpnHub(value, path?)` returns one named reason per violation.
|
|
1751
|
+
|
|
1752
|
+
`data.IClusterVpnNetwork { authorityId, revision, nodes, grants }` is the whole managed VPN of one
|
|
1753
|
+
cluster. It is never a patch: a node or a grant the network does not carry is withdrawn, and
|
|
1754
|
+
membership is presence, so there is no enabled flag. Each node states `{ nodeId, publicKey,
|
|
1755
|
+
controlAddress, expiresAt, workloadPrefixes }`, where `expiresAt` is that node's credential expiry
|
|
1756
|
+
in Unix milliseconds and each prefix is `{ cidr, policyDomain }`. Each grant is one directed
|
|
1757
|
+
`{ sourceDomain, destinationDomain }` pair. `clusterVpnControlPolicyDomain(address)` and
|
|
1758
|
+
`clusterVpnWorkloadPolicyDomain(leaseDigest)` state the domain names once for both sides.
|
|
1759
|
+
|
|
1760
|
+
`snapshotClusterVpnNetwork` requires sorted, unique members, prefixes and grants, and requires
|
|
1761
|
+
every grant to join two workload domains the network itself carries, on two different nodes. That
|
|
1762
|
+
is what a per-cluster hub means: a grant whose other end sits on another cluster's hub cannot be
|
|
1763
|
+
expressed at all, which is why Cloudly refuses it while it compiles, by name, with
|
|
1764
|
+
`grant-cross-cluster-unsupported` from `data.clusterVpnNetworkSelectionRefusals`.
|
|
1765
|
+
`clusterVpnNetworkContract` bounds a network at 256 nodes, 128 workload prefixes per node, 4096
|
|
1766
|
+
grants and 917 504 canonical bytes.
|
|
1767
|
+
|
|
1768
|
+
`applyClusterVpnNetwork { network }` is Cloudly's push over the relay's already registered session;
|
|
1769
|
+
the relay answers `{ appliedRevision, state }`, where `state` is `idle`, `applying` or `failed` and
|
|
1770
|
+
`idle` means the pushed revision is the applied one — which is what
|
|
1771
|
+
`bindApplyClusterVpnNetworkResponse(response, sentRequest)` checks. `admitClusterVpnNetwork(held |
|
|
1772
|
+
null, next)` is the pure decision the relay makes against what its hub holds: `accepted` for a
|
|
1773
|
+
higher revision or an empty hub, `replay` for a repeat, and `refused` with
|
|
1774
|
+
`vpn-network-revision-stale` or `vpn-network-authority-foreign` from
|
|
1775
|
+
`data.applyClusterVpnNetworkRefusals`. `getClusterVpnNetwork` is the pull the other way, with no
|
|
1776
|
+
request body — the relay's socket names the cluster — answering `{ network }` or `{ network: null }`
|
|
1777
|
+
while the cluster has none. A hub daemon starts empty and its lifetime is the relay's own, so a
|
|
1778
|
+
relay that just bound one pulls instead of waiting for the next push.
|
|
1779
|
+
|
|
1669
1780
|
### Cluster Ingress
|
|
1670
1781
|
|
|
1671
1782
|
A cluster's ingress terminates TLS for the hostnames its services publish and forwards to the
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/data/cluster.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { IClusterNode } from './clusternode.js';
|
|
|
5
5
|
import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
|
|
6
6
|
import { validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
|
|
7
7
|
import { hasExactKeys } from '../private/dataproperties.js';
|
|
8
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
9
|
+
import { networkNoiseKey } from '../private/networkcontract.js';
|
|
8
10
|
|
|
9
11
|
export interface ICluster {
|
|
10
12
|
id: string;
|
|
@@ -62,6 +64,25 @@ export interface IClusterRelay {
|
|
|
62
64
|
origin: string;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The managed VPN hub a cluster relay bound for its own cluster.
|
|
69
|
+
*
|
|
70
|
+
* The relay is its cluster's packet relay: every node of the cluster dials this hub and no
|
|
71
|
+
* inter-node packet leaves the cluster. It reports what it bound, exactly like `listenAddress` and
|
|
72
|
+
* `listenPort`, and Cloudly composes the platform endpoints from it — a relay names no endpoint id
|
|
73
|
+
* and no TLS name of its own. `publicKey` is the hub's Noise public key in the one canonical
|
|
74
|
+
* 32-byte base64 encoding every Noise key of this contract is read with; it is public material and
|
|
75
|
+
* authenticates nothing by itself. A hub that bound only one transport reports the other port as
|
|
76
|
+
* `null`.
|
|
77
|
+
*/
|
|
78
|
+
export interface IClusterRelayVpnHub {
|
|
79
|
+
publicKey: string;
|
|
80
|
+
/** The address the hub bound, as an IPv4 literal inside the address plan's `vpn.hubPrefix`. */
|
|
81
|
+
address: string;
|
|
82
|
+
quicPort: number | null;
|
|
83
|
+
websocketPort: number | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
/**
|
|
66
87
|
* What a cluster relay tells Cloudly about itself once its socket is authenticated.
|
|
67
88
|
*
|
|
@@ -88,6 +109,11 @@ export interface IRegisterClusterRelayRequest {
|
|
|
88
109
|
*/
|
|
89
110
|
listenAddress: string;
|
|
90
111
|
listenPort: number;
|
|
112
|
+
/**
|
|
113
|
+
* Absent on a relay that serves no managed VPN hub, which registers exactly as it did before.
|
|
114
|
+
* Cloudly selects no hub for such a cluster and states `relay-vpn-hub-absent` instead.
|
|
115
|
+
*/
|
|
116
|
+
vpnHub?: IClusterRelayVpnHub;
|
|
91
117
|
};
|
|
92
118
|
/** Monotonic within one relay process; it restarts at 0 when the process restarts. */
|
|
93
119
|
registrationSequence: number;
|
|
@@ -202,7 +228,7 @@ const validateClusterRelayVersion = (valueArg: unknown, pathArg: string): string
|
|
|
202
228
|
|
|
203
229
|
/** The same identifier rule a node's own runtime session registers under. */
|
|
204
230
|
const validateClusterRelayIdentifier = (valueArg: unknown, pathArg: string): string[] => (
|
|
205
|
-
|
|
231
|
+
isClusterNodeId(valueArg)
|
|
206
232
|
? []
|
|
207
233
|
: [`${pathArg} must be 1 to 128 characters of letters, digits, underscore or hyphen`]
|
|
208
234
|
);
|
|
@@ -234,9 +260,56 @@ const validateClusterRelayNonNegativeInteger = (valueArg: unknown, pathArg: stri
|
|
|
234
260
|
: [`${pathArg} must be a non-negative safe integer`]
|
|
235
261
|
);
|
|
236
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The one canonical 32-byte base64 reading every Noise key of this contract passes, stated as a
|
|
265
|
+
* refusal this validator's callers can collect: a hub key is the same key material the managed VPN
|
|
266
|
+
* credential carries, so it is never read by a second rule that could drift from it.
|
|
267
|
+
*/
|
|
268
|
+
const validateClusterRelayVpnKey = (valueArg: unknown, pathArg: string): string[] => {
|
|
269
|
+
try {
|
|
270
|
+
networkNoiseKey(valueArg);
|
|
271
|
+
return [];
|
|
272
|
+
} catch {
|
|
273
|
+
return [`${pathArg} must be a canonical 32-byte base64 public key`];
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** A port the hub bound, or `null` for a transport it did not bind. */
|
|
278
|
+
const validateClusterRelayHubPort = (valueArg: unknown, pathArg: string): string[] => (
|
|
279
|
+
valueArg === null || validateClusterRelayListenPort(valueArg, pathArg).length === 0
|
|
280
|
+
? []
|
|
281
|
+
: [`${pathArg} must be null or an integer from 1 to 65535`]
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Validates one hub advertisement and names every reason it is refused.
|
|
286
|
+
*
|
|
287
|
+
* Shape only: whether the address sits inside the address plan's `vpn.hubPrefix` is Cloudly's to
|
|
288
|
+
* decide against the plan it stores, and the endpoints themselves are composed there.
|
|
289
|
+
*/
|
|
290
|
+
export const validateClusterRelayVpnHub = (
|
|
291
|
+
valueArg: unknown,
|
|
292
|
+
pathArg = 'cluster relay vpn hub',
|
|
293
|
+
): string[] => {
|
|
294
|
+
if (!isRecord(valueArg)
|
|
295
|
+
|| !hasExactKeys(valueArg, ['publicKey', 'address', 'quicPort', 'websocketPort'])) {
|
|
296
|
+
return [`${pathArg} must use its exact schema`];
|
|
297
|
+
}
|
|
298
|
+
const errors = [
|
|
299
|
+
...validateClusterRelayVpnKey(valueArg.publicKey, `${pathArg} publicKey`),
|
|
300
|
+
...validateClusterRelayListenAddress(valueArg.address, `${pathArg} address`),
|
|
301
|
+
...validateClusterRelayHubPort(valueArg.quicPort, `${pathArg} quicPort`),
|
|
302
|
+
...validateClusterRelayHubPort(valueArg.websocketPort, `${pathArg} websocketPort`),
|
|
303
|
+
];
|
|
304
|
+
if (valueArg.quicPort === null && valueArg.websocketPort === null) {
|
|
305
|
+
errors.push(`${pathArg} must bind at least one of quicPort and websocketPort`);
|
|
306
|
+
}
|
|
307
|
+
return errors;
|
|
308
|
+
};
|
|
309
|
+
|
|
237
310
|
const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): string[] => {
|
|
238
311
|
if (!isRecord(valueArg)
|
|
239
|
-
|| !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'])) {
|
|
312
|
+
|| !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'], ['vpnHub'])) {
|
|
240
313
|
return [`${pathArg} must use its exact schema`];
|
|
241
314
|
}
|
|
242
315
|
return [
|
|
@@ -244,6 +317,9 @@ const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): strin
|
|
|
244
317
|
...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
|
|
245
318
|
...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
|
|
246
319
|
...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
|
|
320
|
+
...(Object.keys(valueArg).includes('vpnHub')
|
|
321
|
+
? validateClusterRelayVpnHub(valueArg.vpnHub, `${pathArg} vpnHub`)
|
|
322
|
+
: []),
|
|
247
323
|
];
|
|
248
324
|
};
|
|
249
325
|
|
|
@@ -2,6 +2,7 @@ import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
|
|
|
2
2
|
import { isUtcCalendarDay } from '../private/utccalendarday.js';
|
|
3
3
|
import { validateProtocolOfferOn } from '../protocol.js';
|
|
4
4
|
import { hasExactKeys } from '../private/dataproperties.js';
|
|
5
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The cluster ingress contracts.
|
|
@@ -310,11 +311,11 @@ export const validateClusterTrafficStatistics = (
|
|
|
310
311
|
return [`${pathArg} must use its exact schema`];
|
|
311
312
|
}
|
|
312
313
|
const errors: string[] = [];
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
314
|
+
if (typeof valueArg.serviceId !== 'string' || !identifierPattern.test(valueArg.serviceId)) {
|
|
315
|
+
errors.push(`${pathArg} serviceId must be a bounded identifier`);
|
|
316
|
+
}
|
|
317
|
+
if (!isClusterNodeId(valueArg.nodeId)) {
|
|
318
|
+
errors.push(`${pathArg} nodeId must be a node id`);
|
|
318
319
|
}
|
|
319
320
|
if (!isClusterIngressHostName(valueArg.hostName)) {
|
|
320
321
|
errors.push(`${pathArg} hostName must be a lowercase dotted hostname`);
|
|
@@ -409,8 +410,8 @@ export const validateClusterIngressRegistration = (
|
|
|
409
410
|
if (typeof valueArg.version !== 'string' || !identifierPattern.test(valueArg.version)) {
|
|
410
411
|
errors.push(`${pathArg} version must be a bounded identifier`);
|
|
411
412
|
}
|
|
412
|
-
if (
|
|
413
|
-
errors.push(`${pathArg} nodeId must be a
|
|
413
|
+
if (!isClusterNodeId(valueArg.nodeId)) {
|
|
414
|
+
errors.push(`${pathArg} nodeId must be a node id`);
|
|
414
415
|
}
|
|
415
416
|
if (valueArg.capabilities !== undefined
|
|
416
417
|
&& (!Array.isArray(valueArg.capabilities)
|
package/ts/data/clusternode.ts
CHANGED
|
@@ -11,6 +11,13 @@ import {
|
|
|
11
11
|
readDataElements,
|
|
12
12
|
readDataProperties,
|
|
13
13
|
} from '../private/dataproperties.js';
|
|
14
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The vocabulary every node id in this package is read in, published here beside `IClusterNode`
|
|
18
|
+
* so a service that stores or forwards a node id judges it exactly as these contracts do.
|
|
19
|
+
*/
|
|
20
|
+
export { isClusterNodeId };
|
|
14
21
|
|
|
15
22
|
export interface IClusterNodeMetrics {
|
|
16
23
|
cpuUsagePercent: number;
|
|
@@ -625,8 +632,8 @@ const validateSparkReporterIdentity = (
|
|
|
625
632
|
labelArg: string,
|
|
626
633
|
): string[] => {
|
|
627
634
|
const errors: string[] = [];
|
|
628
|
-
if (!
|
|
629
|
-
errors.push(`${labelArg} nodeId must be
|
|
635
|
+
if (!isClusterNodeId(requestArg.nodeId)) {
|
|
636
|
+
errors.push(`${labelArg} nodeId must be a node id`);
|
|
630
637
|
}
|
|
631
638
|
if (typeof requestArg.nodeToken !== 'string'
|
|
632
639
|
|| requestArg.nodeToken.length < 1
|
|
@@ -922,6 +929,11 @@ const createSparkFieldErrors = (
|
|
|
922
929
|
errors.push(`${labelArg} ${keyArg} must be canonical`);
|
|
923
930
|
}
|
|
924
931
|
},
|
|
932
|
+
nodeIdentifier: (keyArg: string): void => {
|
|
933
|
+
if (!isClusterNodeId(recordArg[keyArg])) {
|
|
934
|
+
errors.push(`${labelArg} ${keyArg} must be a node id`);
|
|
935
|
+
}
|
|
936
|
+
},
|
|
925
937
|
boolean: (keyArg: string): void => {
|
|
926
938
|
if (typeof recordArg[keyArg] !== 'boolean') {
|
|
927
939
|
errors.push(`${labelArg} ${keyArg} must be a boolean`);
|
|
@@ -1054,7 +1066,7 @@ const validateSparkNodeRuntimeInfo = (valueArg: unknown, labelArg: string): stri
|
|
|
1054
1066
|
if (runtimeInfo.runtime !== 'spark') {
|
|
1055
1067
|
field.errors.push(`${labelArg} runtime must be spark`);
|
|
1056
1068
|
}
|
|
1057
|
-
field.
|
|
1069
|
+
field.nodeIdentifier('nodeId');
|
|
1058
1070
|
field.text('platform');
|
|
1059
1071
|
field.text('arch');
|
|
1060
1072
|
field.text('sparkVersion');
|
|
@@ -1118,6 +1130,17 @@ export const validateSparkSwarmNodeHeartbeatRequest = async (
|
|
|
1118
1130
|
envelope.record.runtimeInfo,
|
|
1119
1131
|
`${label} runtimeInfo`,
|
|
1120
1132
|
));
|
|
1133
|
+
// One body, one node. The bearer authenticates the sender named by `nodeId`, so a runtime info
|
|
1134
|
+
// that names another node would report that node's Docker, Cloudly connection and services
|
|
1135
|
+
// under a credential it never held. Only the pair is checked here: an unreadable member of
|
|
1136
|
+
// either has already named itself above.
|
|
1137
|
+
const runtimeInfo = envelope.record.runtimeInfo;
|
|
1138
|
+
if (isClusterNodeId(envelope.record.nodeId)
|
|
1139
|
+
&& isRuntimeRecord(runtimeInfo)
|
|
1140
|
+
&& isClusterNodeId(runtimeInfo.nodeId)
|
|
1141
|
+
&& runtimeInfo.nodeId !== envelope.record.nodeId) {
|
|
1142
|
+
envelope.errors.push(`${label} runtimeInfo nodeId must name the request nodeId`);
|
|
1143
|
+
}
|
|
1121
1144
|
return envelope.errors;
|
|
1122
1145
|
} catch {
|
|
1123
1146
|
return [`${label} must be safely inspectable`];
|
|
@@ -1290,7 +1313,6 @@ export const validateClusterRuntimeTargetSet = async (
|
|
|
1290
1313
|
'nodeName',
|
|
1291
1314
|
'platform',
|
|
1292
1315
|
])
|
|
1293
|
-
|| !isRuntimeIdentifier(targetArg.cloudlyNodeId)
|
|
1294
1316
|
|| !isRuntimeIdentifier(targetArg.swarmClusterId)
|
|
1295
1317
|
|| !isRuntimeIdentifier(targetArg.swarmNodeId)
|
|
1296
1318
|
|| !isRuntimeIdentifier(targetArg.nodeName)
|
|
@@ -1299,6 +1321,12 @@ export const validateClusterRuntimeTargetSet = async (
|
|
|
1299
1321
|
errors.push(`cluster runtime target set targets[${index}] must be canonical`);
|
|
1300
1322
|
continue;
|
|
1301
1323
|
}
|
|
1324
|
+
// The node this target names is judged in the node vocabulary, so it states that and not
|
|
1325
|
+
// the canonical rule the Swarm identities beside it are held to.
|
|
1326
|
+
if (!isClusterNodeId(targetArg.cloudlyNodeId)) {
|
|
1327
|
+
errors.push(`cluster runtime target set targets[${index}].cloudlyNodeId must be a node id`);
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1302
1330
|
const sortKey = `${targetArg.cloudlyNodeId}\0${targetArg.swarmClusterId}\0${
|
|
1303
1331
|
targetArg.swarmNodeId
|
|
1304
1332
|
}\0${targetArg.nodeName}\0${targetArg.platform}`;
|