@kici-dev/orchestrator 0.14.1 → 0.15.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/dist/__test-helpers__/peer-mutual-auth.d.ts +42 -0
- package/dist/agent/host-roster.d.ts +100 -37
- package/dist/app.d.ts +4 -2
- package/dist/cli/commands/host.d.ts +3 -0
- package/dist/cli/commands/source-manifest.d.ts +3 -3
- package/dist/cli/commands/source.d.ts +23 -0
- package/dist/cli/join.d.ts +2 -2
- package/dist/cli/kici-admin.d.ts +5 -0
- package/dist/cli/local-github-ingress-url.d.ts +6 -0
- package/dist/cli/root-option-routing.d.ts +46 -0
- package/dist/cli/webhook-url-notes.d.ts +15 -0
- package/dist/cli.js +1429 -885
- package/dist/cluster/coordinator-credential.d.ts +21 -0
- package/dist/cluster/index.d.ts +4 -4
- package/dist/cluster/join-client.d.ts +23 -21
- package/dist/cluster/join-handler.d.ts +24 -16
- package/dist/cluster/join-protocol-v2.d.ts +84 -0
- package/dist/cluster/join-token.d.ts +78 -56
- package/dist/cluster/peer-auth-coordinator.d.ts +16 -2
- package/dist/cluster/peer-client.d.ts +72 -4
- package/dist/cluster/peer-crypto.d.ts +60 -3
- package/dist/cluster/peer-discovery.d.ts +67 -0
- package/dist/cluster/peer-handler.d.ts +17 -5
- package/dist/cluster/peer-registry.d.ts +15 -1
- package/dist/config/schema.d.ts +14 -0
- package/dist/config/types.d.ts +4 -0
- package/dist/config.d.ts +9 -0
- package/dist/db/migrations/157_host_roster_identity_source.d.ts +21 -0
- package/dist/db/types.d.ts +16 -2
- package/dist/github-app-name-refresher/github-app-name-refresher.d.ts +25 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +225 -81
- package/dist/providers/github/manifest.d.ts +58 -7
- package/dist/routes/admin-sources.d.ts +26 -23
- package/dist/routes/admin.d.ts +14 -17
- package/dist/routes/cluster-join.d.ts +13 -0
- package/dist/routes/github-webhook.d.ts +13 -11
- package/dist/scaler/bare-metal-backend.d.ts +42 -1
- package/dist/scaler/types.d.ts +7 -5
- package/dist/server.js +9040 -7631
- package/dist/sources/source-manager.d.ts +0 -24
- package/dist/sources/webhook-url-resolvers.d.ts +60 -0
- package/dist/standalone.js +9944 -8818
- package/dist/ws/dashboard-fleet-handler.d.ts +4 -3
- package/dist/ws/dashboard-fleet-write-handler.d.ts +7 -1
- package/dist/ws/platform-client.d.ts +18 -6
- package/installer-image-digests.json +3 -3
- package/package.json +4 -4
- package/sbom.spdx.json +53 -53
|
@@ -17,6 +17,27 @@ export declare enum CredentialIssueReason {
|
|
|
17
17
|
FileMissing = "credential-file-missing",
|
|
18
18
|
FileMismatch = "credential-file-mismatch"
|
|
19
19
|
}
|
|
20
|
+
/** What a coordinator's own cluster database says about a credential a peer rejected. */
|
|
21
|
+
export declare enum RejectionCorroboration {
|
|
22
|
+
/** An unrevoked, unexpired row holds this credential: the peer's rejection is not this cluster's view. */
|
|
23
|
+
HeldValid = "held-valid",
|
|
24
|
+
/** No live row holds it: the rejection is right, and the file goes. */
|
|
25
|
+
NotHeld = "not-held",
|
|
26
|
+
/** The database could not be read: keep the file and retry on the next attempt. */
|
|
27
|
+
Unreadable = "unreadable"
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The `corroborateRejection` callback a coordinator's `PeerAuthCoordinator`
|
|
31
|
+
* runs before it deletes its credential file after a peer rejected it. Any
|
|
32
|
+
* dialled endpoint can send a rejection, so the coordinator's own database
|
|
33
|
+
* decides: an expired or revoked row does not hold the credential, and the file
|
|
34
|
+
* then goes so the self-issue path reconciles.
|
|
35
|
+
*/
|
|
36
|
+
export declare function coordinatorRejectionCorroborator(opts: {
|
|
37
|
+
db: Kysely<Database>;
|
|
38
|
+
instanceId: string;
|
|
39
|
+
logger?: Logger;
|
|
40
|
+
}): (credential: string) => Promise<RejectionCorroboration>;
|
|
20
41
|
export declare const COORDINATOR_CREDENTIAL_REVOKED_MESSAGE = "Peer credential for this coordinator was revoked; not issuing a new one";
|
|
21
42
|
export type CoordinatorCredentialStore = Pick<PeerCredentialStore, 'save' | 'findUnrevokedByInstanceId' | 'findLatestRevokedByInstanceId' | 'retireSelfIssued'>;
|
|
22
43
|
export interface EnsureCoordinatorCredentialOptions {
|
package/dist/cluster/index.d.ts
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { PeerRegistry } from './peer-registry.js';
|
|
8
8
|
export type { PeerInfo, PeerAgentInfo, PeerRegistryOptions } from './peer-registry.js';
|
|
9
|
-
export { PeerClient } from './peer-client.js';
|
|
9
|
+
export { PeerClient, PeerDialOrigin } from './peer-client.js';
|
|
10
10
|
export type { PeerClientOptions } from './peer-client.js';
|
|
11
|
-
export { PeerAuthCoordinator } from './peer-auth-coordinator.js';
|
|
12
|
-
export { coordinatorSelfIssuer } from './coordinator-credential.js';
|
|
13
|
-
export type { AuthDecision
|
|
11
|
+
export { PeerAuthCoordinator, RejectionAction } from './peer-auth-coordinator.js';
|
|
12
|
+
export { coordinatorRejectionCorroborator, coordinatorSelfIssuer, RejectionCorroboration, } from './coordinator-credential.js';
|
|
13
|
+
export type { AuthDecision } from './peer-auth-coordinator.js';
|
|
14
14
|
export { createPeerHandler } from './peer-handler.js';
|
|
15
15
|
export type { PeerHandlerDeps } from './peer-handler.js';
|
|
16
16
|
export { PeerCredentialStore, readCredentialFile, writeCredentialFile, } from './peer-credentials.js';
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Join client
|
|
2
|
+
* Join client: the joiner side of `kici-admin join` (join protocol v2).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* 1. Build a join.request from the token: its routing part, a one-time X25519
|
|
5
|
+
* public key, a nonce, and a proof that this host holds the token secret.
|
|
6
|
+
* The secret itself stays on this host.
|
|
7
|
+
* 2. Send it through the Platform relay (WS) or directly to a peer (HTTP POST).
|
|
8
|
+
* 3. Check the existing orchestrator's proof in the join.response, then open the
|
|
9
|
+
* configuration bundle sealed to the one-time key.
|
|
10
|
+
* 4. Write the bundle as the env file the orchestrator boots from.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* can derive the encryption key -- the Platform relay sees only ciphertext.
|
|
12
|
+
* A relay sees the routing part, the public keys, the nonces, the proofs and the
|
|
13
|
+
* ciphertext; none of them lets it read or change the bundle.
|
|
14
14
|
*/
|
|
15
15
|
import { type JoinRequest, type JoinResponse } from '@kici-dev/engine';
|
|
16
16
|
import type { ConfigBundle } from './join-handler.js';
|
|
@@ -27,10 +27,12 @@ interface JoinClientOptions {
|
|
|
27
27
|
}
|
|
28
28
|
/** Default path the join writes its env file to. */
|
|
29
29
|
export declare const DEFAULT_JOIN_ENV_FILE = "./kici-orchestrator.env";
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
*/
|
|
33
|
-
export declare function
|
|
30
|
+
/** How long a join waits for the join.response, through either transport. */
|
|
31
|
+
export declare const JOIN_TIMEOUT_MS = 30000;
|
|
32
|
+
/** POST target for `--peer`, keeping any base path of the peer URL. */
|
|
33
|
+
export declare function joinEndpointUrl(peerUrl: string): string;
|
|
34
|
+
/** One-line operator message for a refused join. */
|
|
35
|
+
export declare function describeJoinRefusal(response: JoinResponse): string;
|
|
34
36
|
/**
|
|
35
37
|
* Startup environment variable each `ConfigBundle.storage` field is delivered
|
|
36
38
|
* through.
|
|
@@ -67,19 +69,19 @@ export declare class JoinClient {
|
|
|
67
69
|
private readonly options;
|
|
68
70
|
constructor(options: JoinClientOptions);
|
|
69
71
|
/**
|
|
70
|
-
* Execute the join flow:
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* 3. Decrypt bundle using token-derived key
|
|
74
|
-
* 4. Write the env file the orchestrator boots from
|
|
72
|
+
* Execute the join flow: send the v2 join.request, check the existing
|
|
73
|
+
* orchestrator's proof, open the sealed bundle, and write the env file. Nothing
|
|
74
|
+
* is written when the response fails its proof.
|
|
75
75
|
*/
|
|
76
76
|
join(): Promise<void>;
|
|
77
77
|
/**
|
|
78
|
-
* Join via Platform relay: connect WS, authenticate
|
|
78
|
+
* Join via Platform relay: connect WS, authenticate with the API key, send the
|
|
79
|
+
* join.request, receive the join.response.
|
|
79
80
|
*/
|
|
80
81
|
joinViaPlatform(request: JoinRequest): Promise<JoinResponse>;
|
|
81
82
|
/**
|
|
82
|
-
* Join via direct peer: POST to peer's join endpoint.
|
|
83
|
+
* Join via direct peer: POST the join.request to the peer's join endpoint.
|
|
84
|
+
* There is no fallback to an earlier join protocol.
|
|
83
85
|
*/
|
|
84
86
|
joinViaPeer(request: JoinRequest): Promise<JoinResponse>;
|
|
85
87
|
}
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Join handler
|
|
2
|
+
* Join handler: answers join protocol v2 requests from new orchestrators, whether they
|
|
3
|
+
* arrive through the Platform relay or on POST /api/v1/cluster/join.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 4. Consumes the token (one-time use)
|
|
10
|
-
* 5. Returns the encrypted bundle to the joiner
|
|
11
|
-
*
|
|
12
|
-
* The Platform relay sees only routing metadata and ciphertext -- zero-knowledge.
|
|
5
|
+
* It finds the join token row by the request's routing fields, verifies the joiner's
|
|
6
|
+
* proof against each candidate row, claims the row by hash, and seals the configuration
|
|
7
|
+
* bundle (database URL, object storage, secrets key, cluster ID) to the joiner's
|
|
8
|
+
* one-time key. A version-1 frame (one that carries the token) is refused before any
|
|
9
|
+
* lookup.
|
|
13
10
|
*/
|
|
14
|
-
import
|
|
11
|
+
import { type JoinResponse } from '@kici-dev/engine';
|
|
15
12
|
import type { Kysely } from 'kysely';
|
|
16
13
|
import type { ClusterIdentity } from './cluster-identity.js';
|
|
17
14
|
import type { SharedConfigStore } from '../config/shared-store.js';
|
|
@@ -20,6 +17,11 @@ interface JoinHandlerDeps {
|
|
|
20
17
|
sharedConfigStore: SharedConfigStore;
|
|
21
18
|
clusterIdentity: ClusterIdentity;
|
|
22
19
|
databaseUrl: string;
|
|
20
|
+
/**
|
|
21
|
+
* This orchestrator's own secrets key (`KICI_SECRET_KEY` or its key file). The
|
|
22
|
+
* bundle carries it when the shared configuration holds no `secrets.key`.
|
|
23
|
+
*/
|
|
24
|
+
secretKey?: string;
|
|
23
25
|
}
|
|
24
26
|
/**
|
|
25
27
|
* Config bundle distributed to new orchestrators via join token.
|
|
@@ -40,18 +42,24 @@ export interface ConfigBundle {
|
|
|
40
42
|
secretKey?: string;
|
|
41
43
|
clusterId: string;
|
|
42
44
|
}
|
|
45
|
+
/** The answer to a failure the joiner cannot act on; the detail goes to the log. */
|
|
46
|
+
export declare const JOIN_INTERNAL_ERROR_MESSAGE = "Internal error";
|
|
43
47
|
export declare class JoinHandler {
|
|
44
48
|
private readonly deps;
|
|
45
49
|
private readonly tokenManager;
|
|
46
50
|
constructor(deps: JoinHandlerDeps);
|
|
47
51
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
52
|
+
* Answer one join.request frame. Every frame gets exactly one answer, a refused
|
|
53
|
+
* version-1 or malformed one included. Echoes the request's messageId for Platform
|
|
54
|
+
* relay correlation. A failure the joiner cannot act on (a database outage) carries
|
|
55
|
+
* no errorCode and a generic message.
|
|
51
56
|
*/
|
|
52
|
-
handleJoinRequest(
|
|
57
|
+
handleJoinRequest(raw: unknown): Promise<JoinResponse>;
|
|
58
|
+
private answer;
|
|
53
59
|
/**
|
|
54
|
-
* Build the config bundle from SharedConfig + local config.
|
|
60
|
+
* Build the config bundle from SharedConfig + local config. The secrets key is
|
|
61
|
+
* the shared configuration's `secrets.key`, else this orchestrator's own key, so
|
|
62
|
+
* a joined orchestrator decrypts what the cluster encrypted.
|
|
55
63
|
*/
|
|
56
64
|
buildConfigBundle(): Promise<ConfigBundle>;
|
|
57
65
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join protocol v2: the key schedule, transcripts, proofs and sealed bundle.
|
|
3
|
+
*
|
|
4
|
+
* The joiner proves it holds the join token, and the existing orchestrator proves the
|
|
5
|
+
* same back, without either side sending the token secret. Both key from
|
|
6
|
+
* H = SHA-256(secret), the value `join_tokens.token_hash` stores. The bundle is sealed
|
|
7
|
+
* under a key that mixes an ephemeral X25519 agreement with a token-derived binding
|
|
8
|
+
* key, so a relay that records every frame can neither open nor forge it.
|
|
9
|
+
*
|
|
10
|
+
* Key schedule (HKDF-SHA256, salt "kici-join-v2"):
|
|
11
|
+
* - requestKey = HKDF(H, info "joiner-proof"), responseKey = HKDF(H, info "server-proof"),
|
|
12
|
+
* bindingKey = HKDF(H, info "bundle-binding")
|
|
13
|
+
* - T1 = SHA-256("kici-join-v2/request" || lp(routing) || lp(E_j) || lp(n_j))
|
|
14
|
+
* - T2 = SHA-256(T1 || lp(E_s) || lp(n_s))
|
|
15
|
+
* - joinerProof = HMAC(requestKey, T1), serverProof = HMAC(responseKey, T2)
|
|
16
|
+
* - bundleKey = HKDF(ikm X25519(e_s, E_j), salt bindingKey, info "kici-join-v2/bundle" || T2)
|
|
17
|
+
* - encryptedBundle = AES-256-GCM(bundleKey, aad T2), packed IV || tag || ciphertext
|
|
18
|
+
*
|
|
19
|
+
* lp(x) is a 4-byte big-endian length, then x.
|
|
20
|
+
*/
|
|
21
|
+
import { type KeyObject } from 'node:crypto';
|
|
22
|
+
import { type EcdhKeyPair } from './peer-crypto.js';
|
|
23
|
+
export declare const JOIN_PROTOCOL_V2: 2;
|
|
24
|
+
export declare const SERVER_PROOF_MISMATCH_MESSAGE = "The join response did not come from an orchestrator that holds this token";
|
|
25
|
+
export interface JoinKeys {
|
|
26
|
+
requestKey: Buffer;
|
|
27
|
+
responseKey: Buffer;
|
|
28
|
+
bindingKey: Buffer;
|
|
29
|
+
}
|
|
30
|
+
/** Derive the proof and binding keys from H = SHA-256(secret), as 32 raw bytes. */
|
|
31
|
+
export declare function deriveJoinKeys(tokenHash: Buffer): JoinKeys;
|
|
32
|
+
export declare function requestTranscript(routingB64: string, joinerPublicKey: Buffer, joinerNonce: Buffer): Buffer;
|
|
33
|
+
export declare function responseTranscript(requestT: Buffer, serverPublicKey: Buffer, serverNonce: Buffer): Buffer;
|
|
34
|
+
export declare const computeJoinerProof: (keys: JoinKeys, t1: Buffer) => string;
|
|
35
|
+
export declare const verifyJoinerProof: (keys: JoinKeys, t1: Buffer, presented: string) => boolean;
|
|
36
|
+
export declare const computeServerProof: (keys: JoinKeys, t2: Buffer) => string;
|
|
37
|
+
export declare const verifyServerProof: (keys: JoinKeys, t2: Buffer, presented: string) => boolean;
|
|
38
|
+
/** Load a DER SPKI public key, refusing anything but X25519. */
|
|
39
|
+
export declare function loadX25519PublicKey(der: Buffer): KeyObject;
|
|
40
|
+
/** A low-order peer key yields an all-zero agreement, which carries no secret. */
|
|
41
|
+
export declare function assertNonZeroSharedSecret(shared: Buffer): void;
|
|
42
|
+
export declare function agreeSharedSecret(privateKey: KeyObject, peerPublicKeyDer: Buffer): Buffer;
|
|
43
|
+
export declare function deriveBundleKey(shared: Buffer, bindingKey: Buffer, responseT: Buffer): Buffer;
|
|
44
|
+
/** AES-256-GCM with a random 12-byte IV; packed IV || tag || ciphertext. */
|
|
45
|
+
export declare function sealBundle(plaintext: string, key: Buffer, aad: Buffer): Buffer;
|
|
46
|
+
export declare function openBundle(sealed: Buffer, key: Buffer, aad: Buffer): string;
|
|
47
|
+
export interface JoinRequestV2Fields {
|
|
48
|
+
joinProtocol: typeof JOIN_PROTOCOL_V2;
|
|
49
|
+
routing: string;
|
|
50
|
+
joinerPublicKey: string;
|
|
51
|
+
joinerNonce: string;
|
|
52
|
+
joinerProof: string;
|
|
53
|
+
}
|
|
54
|
+
/** What the joiner keeps between its request and the response. Never leaves the host. */
|
|
55
|
+
export interface JoinerState {
|
|
56
|
+
keys: JoinKeys;
|
|
57
|
+
requestT: Buffer;
|
|
58
|
+
ephemeral: EcdhKeyPair;
|
|
59
|
+
}
|
|
60
|
+
/** Joiner: build the request fields from the token's routing part and H. */
|
|
61
|
+
export declare function createJoinRequest(input: {
|
|
62
|
+
routingB64: string;
|
|
63
|
+
tokenHash: Buffer;
|
|
64
|
+
}): {
|
|
65
|
+
fields: JoinRequestV2Fields;
|
|
66
|
+
state: JoinerState;
|
|
67
|
+
};
|
|
68
|
+
export interface SealedJoinResponse {
|
|
69
|
+
joinProtocol: typeof JOIN_PROTOCOL_V2;
|
|
70
|
+
serverPublicKey: string;
|
|
71
|
+
serverNonce: string;
|
|
72
|
+
serverProof: string;
|
|
73
|
+
encryptedBundle: string;
|
|
74
|
+
}
|
|
75
|
+
/** Existing orchestrator: seal the bundle to the joiner's one-time key, with a fresh key of its own. */
|
|
76
|
+
export declare function sealJoinResponse(input: {
|
|
77
|
+
keys: JoinKeys;
|
|
78
|
+
requestT: Buffer;
|
|
79
|
+
joinerPublicKey: Buffer;
|
|
80
|
+
bundle: object;
|
|
81
|
+
}): SealedJoinResponse;
|
|
82
|
+
/** Joiner: check the server proof first, then open the bundle. */
|
|
83
|
+
export declare function openJoinResponse(state: JoinerState, response: SealedJoinResponse): unknown;
|
|
84
|
+
//# sourceMappingURL=join-protocol-v2.d.ts.map
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Join
|
|
2
|
+
* Join tokens: creation, lookup and claim.
|
|
3
3
|
*
|
|
4
4
|
* Token format: kici_join_v1.<base64url(routing_json)>.<random_256bit_hex>
|
|
5
|
-
* - Routing JSON: { orgId, routingKey, expiry }
|
|
6
|
-
*
|
|
5
|
+
* - Routing JSON: { orgId, routingKey, expiry, role }. A lookup key only: the
|
|
6
|
+
* row the token was stored as decides the role and routing a claim returns.
|
|
7
|
+
* - Secret: 32 random bytes as hex. The database stores token_hash =
|
|
8
|
+
* SHA-256(secret), never the secret.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Config bundle encryption: AES-256-GCM with random 12-byte IV.
|
|
13
|
-
* Wire format: <12-byte IV><16-byte auth tag><ciphertext>
|
|
10
|
+
* A row is found by its hash (claimByHash, readByHash), or by its routing fields
|
|
11
|
+
* plus a proof the caller checks against each candidate hash
|
|
12
|
+
* (resolveLiveTokenByRouting).
|
|
14
13
|
*/
|
|
14
|
+
import { OrchRole } from '@kici-dev/engine';
|
|
15
15
|
import { Kysely } from 'kysely';
|
|
16
|
+
import { z } from 'zod';
|
|
16
17
|
/**
|
|
17
18
|
* Silence the module-level logger (e.g. when a CLI is emitting JSON on stdout
|
|
18
19
|
* and a stray log line would break the contract).
|
|
@@ -20,16 +21,41 @@ import { Kysely } from 'kysely';
|
|
|
20
21
|
export declare function silenceJoinTokenLogger(): void;
|
|
21
22
|
export declare const TOKEN_ALREADY_USED_MESSAGE = "Join token has already been used. Create a new token with: kici-admin peer create-token";
|
|
22
23
|
export declare const TOKEN_EXPIRED_MESSAGE = "Join token has expired. Create a new token with: kici-admin peer create-token";
|
|
23
|
-
|
|
24
|
+
export declare const INVALID_JOIN_TOKEN_MESSAGE = "Invalid join token";
|
|
25
|
+
/** Rows sharing one routing triple that a lookup will try; bounds HMAC work per request. */
|
|
26
|
+
export declare const JOIN_TOKEN_ROUTING_CANDIDATE_CAP = 16;
|
|
27
|
+
export type JoinTokenRole = OrchRole;
|
|
28
|
+
export interface TokenRouting {
|
|
24
29
|
orgId: string;
|
|
25
30
|
routingKey: string;
|
|
26
31
|
expiry: number;
|
|
27
|
-
role:
|
|
32
|
+
role: JoinTokenRole;
|
|
28
33
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
/** The routing fields a presented token claims. A lookup key only: the row decides. */
|
|
35
|
+
export type JoinRoutingClaim = Pick<TokenRouting, 'orgId' | 'routingKey' | 'expiry'>;
|
|
36
|
+
/** A consumed (or same-instance re-validated) join token, as the database records it. */
|
|
37
|
+
export interface ClaimedJoinToken {
|
|
38
|
+
/** Hex SHA-256 of the token secret (`join_tokens.token_hash`). */
|
|
39
|
+
tokenHash: string;
|
|
40
|
+
/** From `routing_info`, never from the presented token. */
|
|
41
|
+
routing: TokenRouting;
|
|
42
|
+
/** From the `role` column. */
|
|
43
|
+
role: JoinTokenRole;
|
|
32
44
|
}
|
|
45
|
+
export declare const ResolvedJoinTokenStatus: z.ZodEnum<{
|
|
46
|
+
expired: "expired";
|
|
47
|
+
live: "live";
|
|
48
|
+
unknown: "unknown";
|
|
49
|
+
}>;
|
|
50
|
+
export type ResolvedJoinTokenStatus = z.infer<typeof ResolvedJoinTokenStatus>;
|
|
51
|
+
export type ResolvedJoinToken = {
|
|
52
|
+
status: typeof ResolvedJoinTokenStatus.enum.live;
|
|
53
|
+
tokenHash: string;
|
|
54
|
+
} | {
|
|
55
|
+
status: typeof ResolvedJoinTokenStatus.enum.expired;
|
|
56
|
+
} | {
|
|
57
|
+
status: typeof ResolvedJoinTokenStatus.enum.unknown;
|
|
58
|
+
};
|
|
33
59
|
interface JoinTokenManagerDeps {
|
|
34
60
|
db: Kysely<any>;
|
|
35
61
|
}
|
|
@@ -44,39 +70,44 @@ export declare class JoinTokenManager {
|
|
|
44
70
|
orgId: string;
|
|
45
71
|
routingKey: string;
|
|
46
72
|
createdBy: string;
|
|
47
|
-
role?:
|
|
73
|
+
role?: JoinTokenRole;
|
|
48
74
|
expiryMs?: number;
|
|
49
75
|
}): Promise<string>;
|
|
50
76
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* Single UPDATE with `WHERE token_hash = ? AND consumed_at IS NULL AND
|
|
54
|
-
* expires_at > NOW()` — only one caller can win the claim across a
|
|
55
|
-
* shared-DB multi-coordinator mesh. The winner gets `{ routing, keys }`;
|
|
56
|
-
* every other concurrent caller gets `TOKEN_ALREADY_USED_MESSAGE` and is
|
|
57
|
-
* expected to fall into the idempotent recovery branch in
|
|
58
|
-
* `peer-handler.ts` (which serialises credential issuance via the
|
|
59
|
-
* `peer_credentials_active_uniq` partial unique index).
|
|
77
|
+
* Peer token mode: claim the row the presented token's secret hashes to. Role and
|
|
78
|
+
* routing come from the row (`claimByHash`), never from the token's routing part.
|
|
60
79
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
80
|
+
* Self-healing reuse: a join token is re-consumable by the same joining peer
|
|
81
|
+
* (`peerInstanceId`) until its `expires_at`. A peer that lost its credential
|
|
82
|
+
* re-presents the still-valid join token already in its env, and the coordinator
|
|
83
|
+
* issues a fresh credential without an operator action. Reuse is bounded by both
|
|
84
|
+
* `expires_at` and the consuming instanceId, so it never widens a leaked token's
|
|
85
|
+
* usefulness beyond the instance that first consumed it.
|
|
86
|
+
*/
|
|
87
|
+
validateAndConsumeToken(token: string, consumedBy: string, peerInstanceId: string): Promise<ClaimedJoinToken>;
|
|
88
|
+
/**
|
|
89
|
+
* Find the row whose proof the caller accepts, among rows whose routing matches
|
|
90
|
+
* the claimed routing. Consumption state is not checked here; `claimByHash` owns it.
|
|
91
|
+
* At most JOIN_TOKEN_ROUTING_CANDIDATE_CAP rows, newest first, are offered.
|
|
92
|
+
*/
|
|
93
|
+
resolveLiveTokenByRouting(claimed: JoinRoutingClaim, accepts: (tokenHash: string) => boolean): Promise<ResolvedJoinToken>;
|
|
94
|
+
/**
|
|
95
|
+
* Atomically consume the row with this hash, or re-validate it for the instance
|
|
96
|
+
* that consumed it (self-healing reuse, bounded by expires_at). Routing and role
|
|
97
|
+
* come from the row.
|
|
66
98
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* redeploy. Reuse is bounded by BOTH `expires_at` AND the consuming
|
|
73
|
-
* instanceId, so it never widens a leaked token's usefulness beyond the
|
|
74
|
-
* instance that first consumed it.
|
|
99
|
+
* One UPDATE ... WHERE consumed_at IS NULL wins the claim across a shared-DB
|
|
100
|
+
* multi-coordinator mesh; every other concurrent caller gets
|
|
101
|
+
* TOKEN_ALREADY_USED_MESSAGE, the message peer-handler's idempotent retry keys on.
|
|
102
|
+
* On a 0-row claim, a follow-up SELECT tells not-found, expired, already-used and
|
|
103
|
+
* reusable-by-the-same-instance apart.
|
|
75
104
|
*/
|
|
76
|
-
|
|
105
|
+
claimByHash(tokenHash: string, consumedBy: string, consumerInstanceId: string): Promise<ClaimedJoinToken>;
|
|
106
|
+
/** Role and routing of the row with this hash, or null. */
|
|
107
|
+
readByHash(tokenHash: string): Promise<{
|
|
77
108
|
routing: TokenRouting;
|
|
78
|
-
|
|
79
|
-
}>;
|
|
109
|
+
role: JoinTokenRole;
|
|
110
|
+
} | null>;
|
|
80
111
|
}
|
|
81
112
|
/**
|
|
82
113
|
* Build a JoinTokenManager backed by its own connection pool to the given
|
|
@@ -104,23 +135,14 @@ export declare function isTokenAlreadyUsedError(err: unknown): boolean;
|
|
|
104
135
|
*/
|
|
105
136
|
export declare function parseToken(token: string): {
|
|
106
137
|
routing: TokenRouting;
|
|
138
|
+
routingB64: string;
|
|
107
139
|
secretHex: string;
|
|
108
140
|
};
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
*/
|
|
114
|
-
export declare function
|
|
115
|
-
/**
|
|
116
|
-
* Encrypt a config bundle with the derived encryption key.
|
|
117
|
-
* Format: <12-byte IV><16-byte auth tag><ciphertext>
|
|
118
|
-
*/
|
|
119
|
-
export declare function encryptBundle(plaintext: object, encryptionKey: Buffer): Buffer;
|
|
120
|
-
/**
|
|
121
|
-
* Decrypt a config bundle with the derived encryption key.
|
|
122
|
-
* Expects format: <12-byte IV><16-byte auth tag><ciphertext>
|
|
123
|
-
*/
|
|
124
|
-
export declare function decryptBundle(data: Buffer, encryptionKey: Buffer): object;
|
|
141
|
+
/** Hex SHA-256 of a token secret: the value `join_tokens.token_hash` stores. */
|
|
142
|
+
export declare function tokenHashOf(secretHex: string): string;
|
|
143
|
+
/** A log-safe tag for a token hash. Never log the hash itself: it is the join key root. */
|
|
144
|
+
export declare function tokenFingerprint(tokenHash: string): string;
|
|
145
|
+
/** Decode the base64url routing part of a token into its lookup fields. */
|
|
146
|
+
export declare function decodeJoinRouting(routingB64: string): JoinRoutingClaim;
|
|
125
147
|
export {};
|
|
126
148
|
//# sourceMappingURL=join-token.d.ts.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type CredentialFileData } from './peer-credentials.js';
|
|
2
|
-
import { CoordinatorCredentialOutcome } from './coordinator-credential.js';
|
|
2
|
+
import { CoordinatorCredentialOutcome, RejectionCorroboration } from './coordinator-credential.js';
|
|
3
3
|
export type AuthDecision = {
|
|
4
4
|
mode: 'credential';
|
|
5
5
|
credential: CredentialFileData;
|
|
@@ -10,13 +10,22 @@ export type AuthDecision = {
|
|
|
10
10
|
} | {
|
|
11
11
|
mode: 'no-auth';
|
|
12
12
|
};
|
|
13
|
-
|
|
13
|
+
/** What `reportRejection` did with the credential file. */
|
|
14
|
+
export declare enum RejectionAction {
|
|
15
|
+
/** A sibling refreshed the file: retry with the fresh credential. */
|
|
16
|
+
RetryCredential = "retry-credential",
|
|
17
|
+
/** The file was deleted: the next decision token-joins or self-issues. */
|
|
18
|
+
Rejoin = "rejoin",
|
|
19
|
+
/** This cluster's database holds the credential as valid (or is unreadable): the file stays. */
|
|
20
|
+
KeepCredential = "keep-credential"
|
|
21
|
+
}
|
|
14
22
|
export declare class PeerAuthCoordinator {
|
|
15
23
|
private readonly credentialFile;
|
|
16
24
|
private readonly instanceId;
|
|
17
25
|
private readonly joinToken?;
|
|
18
26
|
private readonly joinWaitTimeoutMs;
|
|
19
27
|
private readonly selfIssue?;
|
|
28
|
+
private readonly corroborateRejection?;
|
|
20
29
|
/** The issuance in progress; concurrent sibling clients share it. */
|
|
21
30
|
private selfIssueInFlight;
|
|
22
31
|
/** An operator revoked this coordinator's credential: never issue again in this process. */
|
|
@@ -35,6 +44,11 @@ export declare class PeerAuthCoordinator {
|
|
|
35
44
|
* credential and no join token. Only a coordinator wires it.
|
|
36
45
|
*/
|
|
37
46
|
selfIssue?: () => Promise<CoordinatorCredentialOutcome>;
|
|
47
|
+
/**
|
|
48
|
+
* Asks this coordinator's own cluster database whether it holds a rejected
|
|
49
|
+
* credential as valid. Only a coordinator with a database wires it.
|
|
50
|
+
*/
|
|
51
|
+
corroborateRejection?: (credential: string) => Promise<RejectionCorroboration>;
|
|
38
52
|
});
|
|
39
53
|
/** Run `fn` exclusively against the credential file. */
|
|
40
54
|
private withLock;
|
|
@@ -7,16 +7,36 @@
|
|
|
7
7
|
* - Periodic heartbeat (30s default)
|
|
8
8
|
* - Intentional disconnect flag to prevent reconnection
|
|
9
9
|
*
|
|
10
|
-
* Authentication
|
|
11
|
-
* or
|
|
10
|
+
* Authentication is mutual-v2: after the ECDH key exchange the client proves
|
|
11
|
+
* its credential (or, on first join, its join token) with an HMAC over the
|
|
12
|
+
* handshake transcript, and accepts nothing until the server proves it holds
|
|
13
|
+
* the same credential or token. The join token never leaves this host. Every
|
|
14
|
+
* frame after acceptance uses an application key that needs the shared secret.
|
|
15
|
+
* A server that does not offer mutual-v2 gets no proof at all.
|
|
12
16
|
*/
|
|
13
17
|
import { type PeerHeartbeat, type PeerToPeerMessage, type JobReroute, type JobProgress, type JobProgressAck, type PeerJobCancel, type PeerLogChunk, type PeerCacheUploadRequest, type PeerCacheUploadResponse, type PeerConfigReload, type PeerConfigReloadResponse, type PeerScalerOrphansRequest, type PeerForgetRequest, type PeerForgetResponse, type PeerScalerOrphansResponse, type PeerClusterSettingsRequest, type PeerClusterSettingsResponse, type PeerLogsCollectRequest, type PeerLeaving, type PeerAgentTokenRevoke, type RaftVoteRequest, type RaftVoteResponse, type RaftAppendEntries } from '@kici-dev/engine';
|
|
14
|
-
import type
|
|
18
|
+
import { type PeerRegistry } from './peer-registry.js';
|
|
15
19
|
import { type PEER_FORGET_TIMEOUT, type PeerForgetRequestHandler } from './peer-forget.js';
|
|
16
20
|
import { type SCALER_ORPHANS_TIMEOUT, type ScalerOrphansRequestHandler } from './scaler-orphans-peer.js';
|
|
17
21
|
import type { PeerAuthCoordinator } from './peer-auth-coordinator.js';
|
|
18
22
|
/** Logged when this instance has neither a credential file nor a join token. */
|
|
19
23
|
export declare const NO_AUTH_METHOD_MESSAGE = "No peer auth method: this instance has no credential file and no join token";
|
|
24
|
+
/** Logged when a dialled server does not offer mutual authentication. */
|
|
25
|
+
export declare const PEER_MUTUAL_AUTH_UNSUPPORTED_MESSAGE = "Peer does not support mutual authentication; upgrade every orchestrator in the cluster";
|
|
26
|
+
/** Where the address this client dials came from. */
|
|
27
|
+
export declare enum PeerDialOrigin {
|
|
28
|
+
/** KICI_CLUSTER_PEERS or a worker's coordinator URLs: reconnects with backoff. */
|
|
29
|
+
Static = "static",
|
|
30
|
+
/** A Platform announcement: a hint, dropped for good when mutual authentication fails. */
|
|
31
|
+
Discovered = "discovered"
|
|
32
|
+
}
|
|
33
|
+
/** Why a server failed mutual authentication. */
|
|
34
|
+
export declare enum PeerMutualAuthFailure {
|
|
35
|
+
SchemeMissing = "scheme-missing",
|
|
36
|
+
ServerProofInvalid = "server-proof-invalid",
|
|
37
|
+
InstanceIdMissing = "instance-id-missing",
|
|
38
|
+
InstanceIdMismatch = "instance-id-mismatch"
|
|
39
|
+
}
|
|
20
40
|
type PeerConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'authenticating' | 'connected';
|
|
21
41
|
export interface PeerClientOptions {
|
|
22
42
|
/** WebSocket URL of the remote peer orchestrator. */
|
|
@@ -31,6 +51,21 @@ export interface PeerClientOptions {
|
|
|
31
51
|
instanceId: string;
|
|
32
52
|
/** Peer registry to update on heartbeats from remote peer. */
|
|
33
53
|
peerRegistry: PeerRegistry;
|
|
54
|
+
/**
|
|
55
|
+
* Where the dialled address came from. A static target reconnects with
|
|
56
|
+
* backoff after any failure; a discovered target that fails mutual
|
|
57
|
+
* authentication is dropped. Default: static.
|
|
58
|
+
*/
|
|
59
|
+
origin?: PeerDialOrigin;
|
|
60
|
+
/** The instance id a discovered target was announced with; the server must report it. */
|
|
61
|
+
expectedInstanceId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Closes a connection that has not completed authentication within this
|
|
64
|
+
* many ms, after which the normal reconnect policy applies. Default: 15000.
|
|
65
|
+
*/
|
|
66
|
+
handshakeTimeoutMs?: number;
|
|
67
|
+
/** Called when a discovered target fails mutual authentication; the client then stays down. */
|
|
68
|
+
onMutualAuthFailed?: (failure: PeerMutualAuthFailure) => void;
|
|
34
69
|
/** Callback to get this orchestrator's local agent inventory for heartbeats. */
|
|
35
70
|
getLocalInventory: () => Omit<PeerHeartbeat, 'type'>;
|
|
36
71
|
/** Heartbeat interval in ms. Default: 30000 (30s). */
|
|
@@ -127,7 +162,13 @@ export declare class PeerClient {
|
|
|
127
162
|
private noAuthWarned;
|
|
128
163
|
private intentionalDisconnect;
|
|
129
164
|
private _targetInstanceId;
|
|
165
|
+
/** The key the current frames use: K_hs while authenticating, K_app once connected. */
|
|
130
166
|
private sessionKey;
|
|
167
|
+
/** The handshake in flight; cleared on acceptance or close. */
|
|
168
|
+
private handshake;
|
|
169
|
+
/** The proof this connection sent, until the server's proof is checked. */
|
|
170
|
+
private pendingProof;
|
|
171
|
+
private handshakeTimer;
|
|
131
172
|
private readonly ackWaiters;
|
|
132
173
|
private readonly cacheWaiters;
|
|
133
174
|
private readonly configReloadWaiters;
|
|
@@ -142,11 +183,15 @@ export declare class PeerClient {
|
|
|
142
183
|
private isJoiner;
|
|
143
184
|
/** complete() callback for the in-flight token-join, if this client is joiner. */
|
|
144
185
|
private joinComplete;
|
|
145
|
-
/** The credential string this client last built
|
|
186
|
+
/** The credential string this client last built a proof with. */
|
|
146
187
|
private lastProvedCredential;
|
|
147
188
|
private readonly instanceId;
|
|
148
189
|
private readonly role;
|
|
149
190
|
private readonly peerRegistry;
|
|
191
|
+
private readonly origin;
|
|
192
|
+
private readonly expectedInstanceId?;
|
|
193
|
+
private readonly handshakeTimeoutMs;
|
|
194
|
+
private readonly onMutualAuthFailed?;
|
|
150
195
|
private readonly getLocalInventory;
|
|
151
196
|
private readonly heartbeatIntervalMs;
|
|
152
197
|
private readonly maxReconnectDelayMs;
|
|
@@ -248,6 +293,29 @@ export declare class PeerClient {
|
|
|
248
293
|
getReconnectDelay(): number;
|
|
249
294
|
private doConnect;
|
|
250
295
|
private handleMessage;
|
|
296
|
+
private armHandshakeTimer;
|
|
297
|
+
private clearHandshakeTimer;
|
|
298
|
+
/** A token join in flight must be released so sibling clients stop waiting on it. */
|
|
299
|
+
private releaseJoin;
|
|
300
|
+
/** Forget the handshake of a connection that closed or is being torn down. */
|
|
301
|
+
private resetHandshake;
|
|
302
|
+
/** Close a connection whose handshake broke the protocol. */
|
|
303
|
+
private abortHandshake;
|
|
304
|
+
/** Close a connection whose server failed mutual authentication. */
|
|
305
|
+
private failMutualAuth;
|
|
306
|
+
/** The server's plaintext peer.hello: refuse it unless it offers mutual-v2. */
|
|
307
|
+
private handleHello;
|
|
308
|
+
/**
|
|
309
|
+
* The encrypted peer.auth.response. Any frame that is not one closes the
|
|
310
|
+
* socket. An acceptance is checked synchronously, before any state change,
|
|
311
|
+
* registry write or callback, so a frame pipelined behind an unproven
|
|
312
|
+
* acceptance meets a client that is not connected.
|
|
313
|
+
*/
|
|
314
|
+
private handleAuthResponse;
|
|
315
|
+
/** Returns why the acceptance fails mutual authentication, or null when it passes. */
|
|
316
|
+
private checkAcceptance;
|
|
317
|
+
/** The server proved itself: register the peer and start the connected session. */
|
|
318
|
+
private completeAcceptance;
|
|
251
319
|
/**
|
|
252
320
|
* Handle a rejected peer.auth.response. For the three credential-divergence
|
|
253
321
|
* reasons, delegate to the coordinator (which deletes the shared file only if
|