@serve.zone/cli 33.9.0 → 33.11.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.
@@ -1,26 +1,37 @@
1
1
  import * as plugins from './plugins.js';
2
- export class BootstrapTokenInputError extends Error {
3
- constructor(refusal) {
4
- super(`The dcrouter admin token on stdin was refused: ${refusal}.`);
2
+ /** A refused token input: which token, and why, never a byte of what was read. */
3
+ export class TokenInputError extends Error {
4
+ constructor(refusal, subjectArg) {
5
+ super(`${subjectArg} on stdin was refused: ${refusal}.`);
5
6
  this.refusal = refusal;
7
+ this.name = 'TokenInputError';
8
+ }
9
+ }
10
+ export class BootstrapTokenInputError extends TokenInputError {
11
+ constructor(refusalArg) {
12
+ super(refusalArg, 'The dcrouter admin token');
6
13
  this.name = 'BootstrapTokenInputError';
7
14
  }
8
15
  }
9
- function refuseUnless(conditionArg, refusalArg) {
10
- if (!conditionArg)
11
- throw new BootstrapTokenInputError(refusalArg);
16
+ export class CorestoreControlTokenInputError extends TokenInputError {
17
+ constructor(refusalArg) {
18
+ super(refusalArg, 'The Corestore control token');
19
+ this.name = 'CorestoreControlTokenInputError';
20
+ }
12
21
  }
13
22
  /**
14
- * The dcrouter admin token piped into `gateway bootstrap`, as bytes the caller wipes.
23
+ * A token piped into a command, as bytes the caller wipes.
15
24
  *
16
25
  * stdin is the only source: an argument would sit in the process list and the shell history, and
17
26
  * the environment leaks into child processes. One trailing line break, `\n` or `\r\n`, is what
18
- * `echo` or a file adds and is not part of the token; everything else must pass the contract's
19
- * token rule. Every byte read is wiped before this answers, whatever it answers.
27
+ * `echo` or a file adds and is not part of the token; everything else must pass the token's rule.
28
+ * Reading stops as soon as stdin carries more than the rule's bound and one line break. Every byte
29
+ * read is wiped before this answers, whatever it answers.
20
30
  */
21
- export const readBootstrapTokenInput = async (inputArg) => {
22
- refuseUnless(inputArg.isTTY !== true, 'token-input-terminal');
23
- const maximumBytes = plugins.servezoneInterfaces.data.externalGatewayBootstrapContract.maximumTokenBytes + 2;
31
+ const readTokenInput = async (inputArg, ruleArg) => {
32
+ if (inputArg.isTTY === true)
33
+ throw ruleArg.refuse('token-input-terminal');
34
+ const maximumBytes = ruleArg.maximumTokenBytes + 2;
24
35
  const chunks = [];
25
36
  let input;
26
37
  try {
@@ -28,7 +39,8 @@ export const readBootstrapTokenInput = async (inputArg) => {
28
39
  for await (const chunk of inputArg) {
29
40
  chunks.push(chunk);
30
41
  byteLength += chunk.byteLength;
31
- refuseUnless(byteLength <= maximumBytes, 'token-input-oversized');
42
+ if (byteLength > maximumBytes)
43
+ throw ruleArg.refuse('token-input-oversized');
32
44
  }
33
45
  input = Buffer.concat(chunks, byteLength);
34
46
  let end = input.byteLength;
@@ -38,7 +50,8 @@ export const readBootstrapTokenInput = async (inputArg) => {
38
50
  end -= 1;
39
51
  }
40
52
  const token = input.subarray(0, end);
41
- refuseUnless(plugins.servezoneInterfaces.data.isExternalGatewayBootstrapToken(token), 'token-input-invalid');
53
+ if (!ruleArg.isToken(token))
54
+ throw ruleArg.refuse('token-input-invalid');
42
55
  // A copy, because the input and every view of it are wiped below.
43
56
  return new Uint8Array(token);
44
57
  }
@@ -48,4 +61,37 @@ export const readBootstrapTokenInput = async (inputArg) => {
48
61
  chunk.fill(0);
49
62
  }
50
63
  };
51
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidG9rZW5pbnB1dC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzX2NsaWNsaWVudC90b2tlbmlucHV0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sY0FBYyxDQUFDO0FBV3hDLE1BQU0sT0FBTyx3QkFBeUIsU0FBUSxLQUFLO0lBQ2pELFlBQTRCLE9BQW9DO1FBQzlELEtBQUssQ0FBQyxrREFBa0QsT0FBTyxHQUFHLENBQUMsQ0FBQztRQUQxQyxZQUFPLEdBQVAsT0FBTyxDQUE2QjtRQUU5RCxJQUFJLENBQUMsSUFBSSxHQUFHLDBCQUEwQixDQUFDO0lBQ3pDLENBQUM7Q0FDRjtBQUVELFNBQVMsWUFBWSxDQUFDLFlBQXFCLEVBQUUsVUFBdUM7SUFDbEYsSUFBSSxDQUFDLFlBQVk7UUFBRSxNQUFNLElBQUksd0JBQXdCLENBQUMsVUFBVSxDQUFDLENBQUM7QUFDcEUsQ0FBQztBQUtEOzs7Ozs7O0dBT0c7QUFDSCxNQUFNLENBQUMsTUFBTSx1QkFBdUIsR0FBRyxLQUFLLEVBQUUsUUFBOEIsRUFBdUIsRUFBRTtJQUNuRyxZQUFZLENBQUMsUUFBUSxDQUFDLEtBQUssS0FBSyxJQUFJLEVBQUUsc0JBQXNCLENBQUMsQ0FBQztJQUM5RCxNQUFNLFlBQVksR0FBRyxPQUFPLENBQUMsbUJBQW1CLENBQUMsSUFBSSxDQUFDLGdDQUFnQyxDQUFDLGlCQUFpQixHQUFHLENBQUMsQ0FBQztJQUM3RyxNQUFNLE1BQU0sR0FBaUIsRUFBRSxDQUFDO0lBQ2hDLElBQUksS0FBNkIsQ0FBQztJQUNsQyxJQUFJLENBQUM7UUFDSCxJQUFJLFVBQVUsR0FBRyxDQUFDLENBQUM7UUFDbkIsSUFBSSxLQUFLLEVBQUUsTUFBTSxLQUFLLElBQUksUUFBUSxFQUFFLENBQUM7WUFDbkMsTUFBTSxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsQ0FBQztZQUNuQixVQUFVLElBQUksS0FBSyxDQUFDLFVBQVUsQ0FBQztZQUMvQixZQUFZLENBQUMsVUFBVSxJQUFJLFlBQVksRUFBRSx1QkFBdUIsQ0FBQyxDQUFDO1FBQ3BFLENBQUM7UUFDRCxLQUFLLEdBQUcsTUFBTSxDQUFDLE1BQU0sQ0FBQyxNQUFNLEVBQUUsVUFBVSxDQUFDLENBQUM7UUFDMUMsSUFBSSxHQUFHLEdBQUcsS0FBSyxDQUFDLFVBQVUsQ0FBQztRQUMzQixJQUFJLEdBQUcsR0FBRyxDQUFDLElBQUksS0FBSyxDQUFDLEdBQUcsR0FBRyxDQUFDLENBQUMsS0FBSyxJQUFJLEVBQUUsQ0FBQztZQUN2QyxHQUFHLElBQUksQ0FBQyxDQUFDO1lBQ1QsSUFBSSxHQUFHLEdBQUcsQ0FBQyxJQUFJLEtBQUssQ0FBQyxHQUFHLEdBQUcsQ0FBQyxDQUFDLEtBQUssSUFBSTtnQkFBRSxHQUFHLElBQUksQ0FBQyxDQUFDO1FBQ25ELENBQUM7UUFDRCxNQUFNLEtBQUssR0FBRyxLQUFLLENBQUMsUUFBUSxDQUFDLENBQUMsRUFBRSxHQUFHLENBQUMsQ0FBQztRQUNyQyxZQUFZLENBQUMsT0FBTyxDQUFDLG1CQUFtQixDQUFDLElBQUksQ0FBQywrQkFBK0IsQ0FBQyxLQUFLLENBQUMsRUFBRSxxQkFBcUIsQ0FBQyxDQUFDO1FBQzdHLGtFQUFrRTtRQUNsRSxPQUFPLElBQUksVUFBVSxDQUFDLEtBQUssQ0FBQyxDQUFDO0lBQy9CLENBQUM7WUFBUyxDQUFDO1FBQ1QsS0FBSyxFQUFFLElBQUksQ0FBQyxDQUFDLENBQUMsQ0FBQztRQUNmLEtBQUssTUFBTSxLQUFLLElBQUksTUFBTTtZQUFFLEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDNUMsQ0FBQztBQUNILENBQUMsQ0FBQyJ9
64
+ /**
65
+ * The dcrouter admin token piped into `gateway bootstrap`: at most the bootstrap contract's
66
+ * `maximumTokenBytes` of visible ASCII.
67
+ */
68
+ export const readBootstrapTokenInput = async (inputArg) => await readTokenInput(inputArg, {
69
+ maximumTokenBytes: plugins.servezoneInterfaces.data.externalGatewayBootstrapContract.maximumTokenBytes,
70
+ isToken: (tokenArg) => plugins.servezoneInterfaces.data.isExternalGatewayBootstrapToken(tokenArg),
71
+ refuse: (refusalArg) => new BootstrapTokenInputError(refusalArg),
72
+ });
73
+ /**
74
+ * The token as text, exactly as its bytes say: invalid UTF-8 throws, and a leading byte order mark
75
+ * is kept, so the contract judges and Cloudly receives every character that was piped in.
76
+ */
77
+ export const decodeCorestoreControlToken = (tokenArg) => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(tokenArg);
78
+ /**
79
+ * The Corestore control token piped into `corestore set-control-token`: what the contract's
80
+ * `isCorestoreControlToken` accepts, so at most `corestoreCredentialRuntimeLimits
81
+ * .maximumControlTokenBytes` of UTF-8 without whitespace.
82
+ */
83
+ export const readCorestoreControlTokenInput = async (inputArg) => await readTokenInput(inputArg, {
84
+ maximumTokenBytes: plugins.servezoneInterfacesRuntime.corestoreCredentialRuntimeLimits.maximumControlTokenBytes,
85
+ isToken: (tokenArg) => {
86
+ let token;
87
+ try {
88
+ token = decodeCorestoreControlToken(tokenArg);
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ return plugins.servezoneInterfacesRuntime.isCorestoreControlToken(token);
94
+ },
95
+ refuse: (refusalArg) => new CorestoreControlTokenInputError(refusalArg),
96
+ });
97
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidG9rZW5pbnB1dC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzX2NsaWNsaWVudC90b2tlbmlucHV0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sY0FBYyxDQUFDO0FBY3hDLGtGQUFrRjtBQUNsRixNQUFNLE9BQU8sZUFBZ0IsU0FBUSxLQUFLO0lBQ3hDLFlBQTRCLE9BQTJCLEVBQUUsVUFBa0I7UUFDekUsS0FBSyxDQUFDLEdBQUcsVUFBVSwwQkFBMEIsT0FBTyxHQUFHLENBQUMsQ0FBQztRQUQvQixZQUFPLEdBQVAsT0FBTyxDQUFvQjtRQUVyRCxJQUFJLENBQUMsSUFBSSxHQUFHLGlCQUFpQixDQUFDO0lBQ2hDLENBQUM7Q0FDRjtBQUVELE1BQU0sT0FBTyx3QkFBeUIsU0FBUSxlQUFlO0lBQzNELFlBQVksVUFBOEI7UUFDeEMsS0FBSyxDQUFDLFVBQVUsRUFBRSwwQkFBMEIsQ0FBQyxDQUFDO1FBQzlDLElBQUksQ0FBQyxJQUFJLEdBQUcsMEJBQTBCLENBQUM7SUFDekMsQ0FBQztDQUNGO0FBRUQsTUFBTSxPQUFPLCtCQUFnQyxTQUFRLGVBQWU7SUFDbEUsWUFBWSxVQUE4QjtRQUN4QyxLQUFLLENBQUMsVUFBVSxFQUFFLDZCQUE2QixDQUFDLENBQUM7UUFDakQsSUFBSSxDQUFDLElBQUksR0FBRyxpQ0FBaUMsQ0FBQztJQUNoRCxDQUFDO0NBQ0Y7QUFlRDs7Ozs7Ozs7R0FRRztBQUNILE1BQU0sY0FBYyxHQUFHLEtBQUssRUFBRSxRQUFxQixFQUFFLE9BQXdCLEVBQXVCLEVBQUU7SUFDcEcsSUFBSSxRQUFRLENBQUMsS0FBSyxLQUFLLElBQUk7UUFBRSxNQUFNLE9BQU8sQ0FBQyxNQUFNLENBQUMsc0JBQXNCLENBQUMsQ0FBQztJQUMxRSxNQUFNLFlBQVksR0FBRyxPQUFPLENBQUMsaUJBQWlCLEdBQUcsQ0FBQyxDQUFDO0lBQ25ELE1BQU0sTUFBTSxHQUFpQixFQUFFLENBQUM7SUFD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
package/package.json CHANGED
@@ -38,7 +38,7 @@
38
38
  "security"
39
39
  ],
40
40
  "name": "@serve.zone/cli",
41
- "version": "33.9.0",
41
+ "version": "33.11.0",
42
42
  "type": "module",
43
43
  "description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
44
44
  "main": "./dist_ts_cliclient/index.js",
@@ -50,10 +50,11 @@
50
50
  }
51
51
  },
52
52
  "dependencies": {
53
- "@serve.zone/api": "32.1.0",
54
- "@serve.zone/interfaces": "32.40.0",
53
+ "@serve.zone/api": "32.5.0",
54
+ "@serve.zone/interfaces": "32.42.0",
55
55
  "@push.rocks/projectinfo": "^5.1.0",
56
56
  "@push.rocks/qenv": "^8.1.0",
57
+ "@push.rocks/smartfs": "^1.8.0",
57
58
  "@push.rocks/smartcli": "^4.3.0"
58
59
  },
59
60
  "bin": {
package/readme.md CHANGED
@@ -16,6 +16,8 @@ This submodule is intentionally small in the current codebase:
16
16
  - Creates a `CliClient` wrapper around the API client.
17
17
  - Without arguments, calls `CliClient.getClusters()` and prints the result.
18
18
  - Runs `servezone gateway bootstrap` and `servezone gateway status` for Cloudly's dcrouter gateway enrollment.
19
+ - Runs `servezone relay export-authorization` to write a cluster relay's bearer to a private file.
20
+ - Runs `servezone corestore set-control-token` and `servezone corestore control-token-state` for the Corestore control token the cluster relays use.
19
21
 
20
22
  It is not currently a full command tree for services, secrets, deployments, logs, profiles, or shell completion. Those flows should be implemented against `@serve.zone/api` before documenting them as CLI commands.
21
23
 
@@ -73,6 +75,43 @@ servezone gateway status
73
75
 
74
76
  Revoke the admin token at dcrouter once `gateway status` reads `ready`.
75
77
 
78
+ ## Relay Bearer Export
79
+
80
+ `relay export-authorization` hands a cluster relay's bearer to the operator who deploys that relay, without any access to Cloudly's process. It needs an administrator who signed in within the last five minutes.
81
+
82
+ ```sh
83
+ install -d -m 0700 /root/relay-export
84
+ CLOUDLY_URL=https://cloudly.example.com \
85
+ CLOUDLY_USERNAME=admin CLOUDLY_PASSWORD=change-me \
86
+ servezone relay export-authorization --cluster-id <clusterId> --expected-generation <n> \
87
+ --output /root/relay-export/relay-authorization
88
+ ```
89
+
90
+ - `--expected-generation` is the exact generation `getClusterRelayCredential` answered (a positive integer without leading zeros); Cloudly refuses any other one by name (`relay-credential-generation-mismatch`), so a rotation that committed in between is never exported by accident.
91
+ - `--output` must be a normalized absolute path. Its directory must exist, resolve to itself (no symbolic link on its path), be owned by you and be writable by neither its group nor other users, and nothing may exist at the path yet. Otherwise the command refuses (`output-directory-missing`, `output-directory-not-private`, `output-exists`) before it asks Cloudly, so no bearer is fetched and no audit receipt is written.
92
+ - The bearer is fetched with `@serve.zone/api` (`cluster.exportClusterRelayAuthorization`, which checks the answer against the request) and written with `@push.rocks/smartfs` as a new file, mode `0600` whatever the umask, exclusive and atomic: the whole bearer or no file, never over an existing entry and never through a symbolic link at the path.
93
+ - It is never written to stdout. stdout carries one JSON line of metadata: `clusterId`, `generation`, `outputPath`, `byteLength`, `sha256` (what `sha256sum` prints for the file) and `receiptId`, the audit receipt Cloudly recorded. A refusal is printed to stderr with exit code 1 and never carries the bearer.
94
+
95
+ ## Corestore Control Token
96
+
97
+ Every cluster relay calls its nodes' Corestore control API with one control token, which Cloudly stores for both Corestore provider configs. These commands set and read it over the API, without any access to Cloudly's process. Both sign in exactly as the default action does; the set needs an administrator who signed in within the last five minutes.
98
+
99
+ ```sh
100
+ CLOUDLY_URL=https://cloudly.example.com \
101
+ CLOUDLY_USERNAME=admin CLOUDLY_PASSWORD=change-me \
102
+ servezone corestore control-token-state
103
+
104
+ # Pipe the token in, naming the state the read answered:
105
+ servezone corestore set-control-token --stdin --expect absent < control-token.txt
106
+ servezone corestore set-control-token --stdin --expect 3 < control-token.txt
107
+ ```
108
+
109
+ - `control-token-state` prints one JSON line: `{"ok":true,"command":"corestore control-token-state","state":…,"receipts":[…]}`. `state` is `{"state":"absent"}` or `{"state":"present","revision":<n>}`; `receipts` are the value-free audit receipts (`id`, `revision`, `via`, `actorId`, `setAt`), oldest first.
110
+ - `set-control-token --stdin --expect absent|<revision>` stores the token only while it is still exactly at the state `--expect` names: `absent`, or the revision `control-token-state` answered (a positive integer without leading zeros). It is never a minimum, so a set that committed in between is refused rather than overwritten.
111
+ - The token is read from stdin and from nowhere else: `--stdin` names that, and an argument, `--token`, or any other extra argument is refused as usage without echoing it; the environment is never read for it. stdin must not be a terminal. One trailing line break (`\n` or `\r\n`) is stripped; what remains must pass the contract's `isCorestoreControlToken` (32 to 4096 bytes of UTF-8 without whitespace). A refused stdin is named (`token-input-terminal`, `token-input-oversized`, `token-input-invalid`) before anything connects, and every byte read is wiped.
112
+ - The token is sent once with `@serve.zone/api` (`platform.setCorestoreControlToken`, past every TypedRequest hook), and the answer is checked against the request. stdout carries one JSON line with the state the set produced and its receipt, never the token: `{"ok":true,"command":"corestore set-control-token","state":{"state":"present","revision":<n>},"receipt":{…}}`.
113
+ - A refusal is printed to stderr with exit code 1 as `The Corestore control token was not stored: <refusal>.`, naming one of the contract's `corestoreControlTokenSetRefusals`: `token-present` (`--expect absent`, and a token is stored), `token-absent` (a revision was expected, and none is stored), `token-revision-mismatch` (the stored token is at another revision), `token-invalid` or `provider-unavailable` (a Corestore provider config is missing or disabled). Every other failure, an unauthorized or stale administrator included, is Cloudly's one denial text, `Corestore control token request denied.`, with exit code 1.
114
+
76
115
  ## Programmatic Use
77
116
 
78
117
  The published submodule exports the `runCli()` entry point. For automation, most callers should use `@serve.zone/api` directly; the internal `CliClient` currently wraps `CloudlyApiClient` only to run the default cluster-list action:
@@ -98,9 +137,11 @@ await cli.getClusters();
98
137
  | Path | Purpose |
99
138
  | --- | --- |
100
139
  | `index.ts` | Runtime entry point for the published CLI. |
101
- | `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()` and `getGatewayStatus()`. |
140
+ | `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()`, `getGatewayStatus()`, `exportRelayAuthorization()`, `setCorestoreControlToken()` and `getCorestoreControlTokenState()`. |
102
141
  | `commands.ts` | Parses the argument vector into one command, refusing anything else as usage. |
103
- | `tokeninput.ts` | Reads the dcrouter admin token from stdin. |
142
+ | `tokeninput.ts` | Reads the dcrouter admin token and the Corestore control token from stdin, each bounded and checked by its contract. |
143
+ | `corestorecontroltoken.ts` | Sets and reads the Corestore control token through the API client and names the contract's refusals. |
144
+ | `relayauthorization.ts` | Checks the private output directory and writes a cluster relay's bearer as a new 0600 file. |
104
145
  | `plugins.ts` | Centralized imports for the submodule. |
105
146
  | `tspublish.json` | Published package name, dependencies, `servezone` bin metadata, and `useBase`, which takes the registries from the release's npm target. |
106
147
 
@@ -1,5 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { CloudlyApiClient } from '@serve.zone/api';
3
+ import { exportRelayAuthorizationToFile } from './relayauthorization.js';
4
+ import { getCorestoreControlTokenState, setCorestoreControlToken } from './corestorecontroltoken.js';
3
5
 
4
6
  export class CliClient {
5
7
  public cloudlyApiClient: CloudlyApiClient;
@@ -28,6 +30,36 @@ export class CliClient {
28
30
  console.log(JSON.stringify(state, null, 2));
29
31
  }
30
32
 
33
+ /**
34
+ * Writes one cluster relay's bearer to a new 0600 file and prints what describes the file:
35
+ * the cluster, the generation, the size, the SHA-256 and the audit receipt, never the bearer.
36
+ */
37
+ public async exportRelayAuthorization(
38
+ requestArg: plugins.servezoneInterfaces.data.IClusterRelayAuthorizationExportRequest,
39
+ outputPathArg: string,
40
+ ) {
41
+ const output = await exportRelayAuthorizationToFile(
42
+ this.cloudlyApiClient, requestArg, outputPathArg);
43
+ console.log(JSON.stringify({ ok: true, command: 'relay export-authorization', ...output }));
44
+ }
45
+
46
+ /**
47
+ * Stores the Corestore control token while it is still exactly at `expectedArg` and prints the
48
+ * state and the receipt the set produced, never the token. The token is the caller's to wipe.
49
+ */
50
+ public async setCorestoreControlToken(
51
+ expectedArg: plugins.servezoneInterfaces.data.TCorestoreControlTokenState,
52
+ tokenArg: Uint8Array,
53
+ ) {
54
+ const output = await setCorestoreControlToken(this.cloudlyApiClient, expectedArg, tokenArg);
55
+ console.log(JSON.stringify(output));
56
+ }
57
+
58
+ /** Prints whether Cloudly holds the Corestore control token, its revision and its receipts. */
59
+ public async getCorestoreControlTokenState() {
60
+ console.log(JSON.stringify(await getCorestoreControlTokenState(this.cloudlyApiClient)));
61
+ }
62
+
31
63
  public async getGatewayStatus() {
32
64
  const { state } = await this.cloudlyApiClient.settings.getExternalGatewayEnrollmentState();
33
65
  console.log(JSON.stringify(state, null, 2));
@@ -1,11 +1,23 @@
1
- import type * as plugins from './plugins.js';
1
+ import * as plugins from './plugins.js';
2
2
 
3
3
  /** What one invocation of the CLI asks for, parsed before anything is read or connected. */
4
4
  export type TCliCommand =
5
5
  /** No arguments: the default action, listing the clusters. */
6
6
  | { kind: 'clusters' }
7
7
  | { kind: 'gateway-status' }
8
- | { kind: 'gateway-bootstrap'; target: plugins.servezoneApi.IExternalGatewayBootstrapTarget };
8
+ | { kind: 'gateway-bootstrap'; target: plugins.servezoneApi.IExternalGatewayBootstrapTarget }
9
+ | {
10
+ kind: 'relay-export-authorization';
11
+ request: plugins.servezoneInterfaces.data.IClusterRelayAuthorizationExportRequest;
12
+ /** A normalized absolute path in a private directory; the file must not exist yet. */
13
+ outputPath: string;
14
+ }
15
+ | {
16
+ kind: 'corestore-set-control-token';
17
+ /** Exactly the state the administrator last read; never a minimum. */
18
+ expected: plugins.servezoneInterfaces.data.TCorestoreControlTokenState;
19
+ }
20
+ | { kind: 'corestore-control-token-state' };
9
21
 
10
22
  export const cliUsage = [
11
23
  'Usage:',
@@ -13,6 +25,11 @@ export const cliUsage = [
13
25
  ' servezone gateway status',
14
26
  ' servezone gateway bootstrap --gateway-url <https url> --client-id <id>',
15
27
  ' The dcrouter admin token is read from stdin, never from arguments or the environment.',
28
+ ' servezone relay export-authorization --cluster-id <id> --expected-generation <n> --output <absolute path>',
29
+ ' Writes the relay bearer to a new 0600 file in a directory only you can write; never to stdout.',
30
+ ' servezone corestore set-control-token --stdin --expect absent|<revision>',
31
+ ' The Corestore control token is read from stdin, never from arguments or the environment.',
32
+ ' servezone corestore control-token-state',
16
33
  ].join('\n');
17
34
 
18
35
  /**
@@ -51,6 +68,8 @@ const parseOptions = <TName extends string>(
51
68
  export const parseCliCommand = (argsArg: readonly string[]): TCliCommand => {
52
69
  if (argsArg.length === 0) return { kind: 'clusters' };
53
70
  const [group, action, ...rest] = argsArg;
71
+ if (group === 'relay') return parseRelayCommand(action, rest);
72
+ if (group === 'corestore') return parseCorestoreCommand(action, rest);
54
73
  requireUsage(group === 'gateway');
55
74
  if (action === 'status') {
56
75
  requireUsage(rest.length === 0);
@@ -63,3 +82,51 @@ export const parseCliCommand = (argsArg: readonly string[]): TCliCommand => {
63
82
  target: { gatewayUrl: options['--gateway-url'], gatewayClientId: options['--client-id'] },
64
83
  };
65
84
  };
85
+
86
+ /** A positive integer without leading zeros, as a generation or a revision is named. */
87
+ const parseCounter = (valueArg: string): number => {
88
+ requireUsage(/^[1-9][0-9]{0,15}$/.test(valueArg) && Number.isSafeInteger(Number(valueArg)));
89
+ return Number(valueArg);
90
+ };
91
+
92
+ /** `relay export-authorization`: the cluster, the exact generation and a normalized absolute output. */
93
+ const parseRelayCommand = (actionArg: string | undefined, restArg: readonly string[]): TCliCommand => {
94
+ requireUsage(actionArg === 'export-authorization');
95
+ const options = parseOptions(restArg, ['--cluster-id', '--expected-generation', '--output']);
96
+ const generation = parseCounter(options['--expected-generation']);
97
+ const outputPath = options['--output'];
98
+ requireUsage(plugins.path.isAbsolute(outputPath) && plugins.path.normalize(outputPath) === outputPath
99
+ && !outputPath.endsWith(plugins.path.sep));
100
+ return {
101
+ kind: 'relay-export-authorization',
102
+ request: { clusterId: options['--cluster-id'], expectedGeneration: generation },
103
+ outputPath,
104
+ };
105
+ };
106
+
107
+ /**
108
+ * `corestore set-control-token --stdin --expect absent|<revision>` and
109
+ * `corestore control-token-state`.
110
+ *
111
+ * `--stdin` names where the token comes from, so no invocation reads it from anywhere else, and
112
+ * `--expect` is the state `control-token-state` answered: `absent`, or the stored revision.
113
+ */
114
+ const parseCorestoreCommand = (actionArg: string | undefined, restArg: readonly string[]): TCliCommand => {
115
+ if (actionArg === 'control-token-state') {
116
+ requireUsage(restArg.length === 0);
117
+ return { kind: 'corestore-control-token-state' };
118
+ }
119
+ requireUsage(actionArg === 'set-control-token');
120
+ // `--stdin` is a flag in an option's place, either before or after `--expect <value>`.
121
+ requireUsage(restArg.length === 3);
122
+ const stdinIndex = restArg[0] === '--stdin' ? 0 : 2;
123
+ requireUsage(restArg[stdinIndex] === '--stdin');
124
+ const options = parseOptions(restArg.filter((_argArg, indexArg) => indexArg !== stdinIndex), ['--expect']);
125
+ const expected = options['--expect'];
126
+ return {
127
+ kind: 'corestore-set-control-token',
128
+ expected: expected === 'absent'
129
+ ? { state: 'absent' }
130
+ : { state: 'present', revision: parseCounter(expected) },
131
+ };
132
+ };
@@ -0,0 +1,71 @@
1
+ import * as plugins from './plugins.js';
2
+ import { decodeCorestoreControlToken } from './tokeninput.js';
3
+
4
+ type TCorestoreControlTokenSetRefusal = plugins.servezoneInterfaces.data.TCorestoreControlTokenSetRefusal;
5
+
6
+ /** Cloudly did not store the token, for one of the contract's refusals, which it names. */
7
+ export class CorestoreControlTokenSetRefusedError extends Error {
8
+ constructor(public readonly refusal: TCorestoreControlTokenSetRefusal) {
9
+ super(`The Corestore control token was not stored: ${refusal}.`);
10
+ this.name = 'CorestoreControlTokenSetRefusedError';
11
+ }
12
+ }
13
+
14
+ /**
15
+ * The contract refusal an error is, or `undefined` for every other error.
16
+ *
17
+ * Cloudly answers a refusal by its name and nothing else, so the name is the whole of the error's
18
+ * message; any other message, Cloudly's one denial text included, is not a refusal.
19
+ */
20
+ export const nameCorestoreControlTokenSetRefusal = (
21
+ errorArg: unknown,
22
+ ): TCorestoreControlTokenSetRefusal | undefined => {
23
+ if (!(errorArg instanceof Error)) return undefined;
24
+ return plugins.servezoneInterfaces.data.corestoreControlTokenSetRefusals
25
+ .find((refusalArg) => refusalArg === errorArg.message);
26
+ };
27
+
28
+ /** What the CLI prints about a stored token: the state it produced and its receipt, never the token. */
29
+ export interface ICorestoreControlTokenSetOutput
30
+ extends plugins.servezoneInterfaces.data.ICorestoreControlTokenSetResult {
31
+ ok: true;
32
+ command: 'corestore set-control-token';
33
+ }
34
+
35
+ /** What the CLI prints about the token's state: the state and its value-free receipts. */
36
+ export interface ICorestoreControlTokenStateOutput
37
+ extends plugins.servezoneInterfaces.data.ICorestoreControlTokenStateRead {
38
+ ok: true;
39
+ command: 'corestore control-token-state';
40
+ }
41
+
42
+ /**
43
+ * Stores the token through the API client while it is still exactly at `expected`, answering only
44
+ * the state and the receipt. The token bytes are the caller's to wipe; the text the request needs
45
+ * is decoded from them here and never printed, logged or put into an error.
46
+ */
47
+ export const setCorestoreControlToken = async (
48
+ apiClientArg: plugins.servezoneApi.CloudlyApiClient,
49
+ expectedArg: plugins.servezoneInterfaces.data.TCorestoreControlTokenState,
50
+ tokenArg: Uint8Array,
51
+ ): Promise<ICorestoreControlTokenSetOutput> => {
52
+ try {
53
+ const { state, receipt } = await apiClientArg.platform.setCorestoreControlToken({
54
+ token: decodeCorestoreControlToken(tokenArg),
55
+ expected: expectedArg,
56
+ });
57
+ return { ok: true, command: 'corestore set-control-token', state, receipt };
58
+ } catch (error) {
59
+ const refusal = nameCorestoreControlTokenSetRefusal(error);
60
+ if (refusal !== undefined) throw new CorestoreControlTokenSetRefusedError(refusal);
61
+ throw error;
62
+ }
63
+ };
64
+
65
+ /** Whether Cloudly holds the token, at which revision, and its receipts, oldest first. */
66
+ export const getCorestoreControlTokenState = async (
67
+ apiClientArg: plugins.servezoneApi.CloudlyApiClient,
68
+ ): Promise<ICorestoreControlTokenStateOutput> => {
69
+ const { state, receipts } = await apiClientArg.platform.getCorestoreControlTokenState();
70
+ return { ok: true, command: 'corestore control-token-state', state, receipts };
71
+ };
@@ -1,29 +1,66 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { CliClient } from './classes.cliclient.js';
3
3
  import { parseCliCommand, type TCliCommand } from './commands.js';
4
- import { readBootstrapTokenInput } from './tokeninput.js';
4
+ import { readBootstrapTokenInput, readCorestoreControlTokenInput } from './tokeninput.js';
5
+ import { requirePrivateOutputDirectory } from './relayauthorization.js';
5
6
 
6
7
  export { CliUsageError, parseCliCommand, type TCliCommand } from './commands.js';
8
+ export {
9
+ RelayAuthorizationOutputError,
10
+ exportRelayAuthorizationToFile,
11
+ requirePrivateOutputDirectory,
12
+ writeRelayAuthorization,
13
+ type IRelayAuthorizationOutput,
14
+ type TRelayAuthorizationOutputRefusal,
15
+ } from './relayauthorization.js';
7
16
  export {
8
17
  BootstrapTokenInputError,
18
+ CorestoreControlTokenInputError,
19
+ TokenInputError,
9
20
  readBootstrapTokenInput,
21
+ readCorestoreControlTokenInput,
10
22
  type TBootstrapTokenInputRefusal,
23
+ type TTokenInputRefusal,
11
24
  } from './tokeninput.js';
25
+ export {
26
+ CorestoreControlTokenSetRefusedError,
27
+ getCorestoreControlTokenState,
28
+ nameCorestoreControlTokenSetRefusal,
29
+ setCorestoreControlToken,
30
+ type ICorestoreControlTokenSetOutput,
31
+ type ICorestoreControlTokenStateOutput,
32
+ } from './corestorecontroltoken.js';
12
33
 
13
- /** A parsed command with everything it reads locally; a bootstrap carries the token from stdin. */
34
+ /**
35
+ * A parsed command with everything it reads locally: a bootstrap carries the admin token from
36
+ * stdin, a control token set the Corestore control token.
37
+ */
14
38
  type TCliInvocation =
15
- | Exclude<TCliCommand, { kind: 'gateway-bootstrap' }>
16
- | (Extract<TCliCommand, { kind: 'gateway-bootstrap' }> & { bootstrapToken: Uint8Array });
39
+ | Exclude<TCliCommand, { kind: 'gateway-bootstrap' | 'corestore-set-control-token' }>
40
+ | (Extract<TCliCommand, { kind: 'gateway-bootstrap' }> & { bootstrapToken: Uint8Array })
41
+ | (Extract<TCliCommand, { kind: 'corestore-set-control-token' }> & { controlToken: Uint8Array });
42
+
43
+ /** Reads the token the command needs from stdin, before anything connects. */
44
+ const readInvocation = async (commandArg: TCliCommand): Promise<TCliInvocation> => {
45
+ if (commandArg.kind === 'gateway-bootstrap') {
46
+ return { ...commandArg, bootstrapToken: await readBootstrapTokenInput(process.stdin) };
47
+ }
48
+ if (commandArg.kind === 'corestore-set-control-token') {
49
+ return { ...commandArg, controlToken: await readCorestoreControlTokenInput(process.stdin) };
50
+ }
51
+ return commandArg;
52
+ };
17
53
 
18
54
  export const runCli = async () => {
19
55
  // The command and the token are settled before anything connects, so a wrong invocation or a
20
56
  // token that could never be one fails without reaching Cloudly.
21
57
  let command: TCliInvocation;
22
58
  try {
23
- const parsed = parseCliCommand(process.argv.slice(2));
24
- command = parsed.kind === 'gateway-bootstrap'
25
- ? { ...parsed, bootstrapToken: await readBootstrapTokenInput(process.stdin) }
26
- : parsed;
59
+ command = await readInvocation(parseCliCommand(process.argv.slice(2)));
60
+ // An output the bearer could not be written to fetches no bearer and records no export.
61
+ if (command.kind === 'relay-export-authorization') {
62
+ await requirePrivateOutputDirectory(command.outputPath);
63
+ }
27
64
  } catch (error) {
28
65
  console.error((error as Error).message);
29
66
  process.exitCode = 1;
@@ -41,11 +78,17 @@ export const runCli = async () => {
41
78
  try {
42
79
  if (command.kind === 'gateway-bootstrap') {
43
80
  await cliClient.bootstrapGateway(command.target, command.bootstrapToken);
81
+ } else if (command.kind === 'relay-export-authorization') {
82
+ await cliClient.exportRelayAuthorization(command.request, command.outputPath);
83
+ } else if (command.kind === 'corestore-set-control-token') {
84
+ await cliClient.setCorestoreControlToken(command.expected, command.controlToken);
85
+ } else if (command.kind === 'corestore-control-token-state') {
86
+ await cliClient.getCorestoreControlTokenState();
44
87
  } else {
45
88
  await cliClient.getGatewayStatus();
46
89
  }
47
90
  } catch (error) {
48
- // Cloudly answers a refusal by name; it never carries the token.
91
+ // Cloudly answers a refusal by name; it never carries a token or a bearer.
49
92
  console.error((error as Error).message);
50
93
  process.exitCode = 1;
51
94
  } finally {
@@ -53,6 +96,7 @@ export const runCli = async () => {
53
96
  }
54
97
  } finally {
55
98
  if (command.kind === 'gateway-bootstrap') command.bootstrapToken.fill(0);
99
+ if (command.kind === 'corestore-set-control-token') command.controlToken.fill(0);
56
100
  }
57
101
  };
58
102
 
@@ -1,17 +1,32 @@
1
+ // node native scope
2
+ import * as crypto from 'node:crypto';
3
+ import * as fs from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+
6
+ export {
7
+ crypto,
8
+ fs,
9
+ path,
10
+ }
11
+
1
12
  // @serve.zone scope
2
13
  import * as servezoneApi from '@serve.zone/api';
3
14
  import * as servezoneInterfaces from '@serve.zone/interfaces';
15
+ import * as servezoneInterfacesRuntime from '@serve.zone/interfaces/runtime';
4
16
 
5
17
  export {
6
18
  servezoneApi,
7
- servezoneInterfaces
19
+ servezoneInterfaces,
20
+ servezoneInterfacesRuntime,
8
21
  }
9
22
 
10
23
  // @push.rocks scope
11
24
  import * as projectinfo from '@push.rocks/projectinfo';
12
25
  import * as qenv from '@push.rocks/qenv';
26
+ import * as smartfs from '@push.rocks/smartfs';
13
27
 
14
28
  export {
15
29
  projectinfo,
16
30
  qenv,
31
+ smartfs,
17
32
  }
@@ -16,6 +16,8 @@ This submodule is intentionally small in the current codebase:
16
16
  - Creates a `CliClient` wrapper around the API client.
17
17
  - Without arguments, calls `CliClient.getClusters()` and prints the result.
18
18
  - Runs `servezone gateway bootstrap` and `servezone gateway status` for Cloudly's dcrouter gateway enrollment.
19
+ - Runs `servezone relay export-authorization` to write a cluster relay's bearer to a private file.
20
+ - Runs `servezone corestore set-control-token` and `servezone corestore control-token-state` for the Corestore control token the cluster relays use.
19
21
 
20
22
  It is not currently a full command tree for services, secrets, deployments, logs, profiles, or shell completion. Those flows should be implemented against `@serve.zone/api` before documenting them as CLI commands.
21
23
 
@@ -73,6 +75,43 @@ servezone gateway status
73
75
 
74
76
  Revoke the admin token at dcrouter once `gateway status` reads `ready`.
75
77
 
78
+ ## Relay Bearer Export
79
+
80
+ `relay export-authorization` hands a cluster relay's bearer to the operator who deploys that relay, without any access to Cloudly's process. It needs an administrator who signed in within the last five minutes.
81
+
82
+ ```sh
83
+ install -d -m 0700 /root/relay-export
84
+ CLOUDLY_URL=https://cloudly.example.com \
85
+ CLOUDLY_USERNAME=admin CLOUDLY_PASSWORD=change-me \
86
+ servezone relay export-authorization --cluster-id <clusterId> --expected-generation <n> \
87
+ --output /root/relay-export/relay-authorization
88
+ ```
89
+
90
+ - `--expected-generation` is the exact generation `getClusterRelayCredential` answered (a positive integer without leading zeros); Cloudly refuses any other one by name (`relay-credential-generation-mismatch`), so a rotation that committed in between is never exported by accident.
91
+ - `--output` must be a normalized absolute path. Its directory must exist, resolve to itself (no symbolic link on its path), be owned by you and be writable by neither its group nor other users, and nothing may exist at the path yet. Otherwise the command refuses (`output-directory-missing`, `output-directory-not-private`, `output-exists`) before it asks Cloudly, so no bearer is fetched and no audit receipt is written.
92
+ - The bearer is fetched with `@serve.zone/api` (`cluster.exportClusterRelayAuthorization`, which checks the answer against the request) and written with `@push.rocks/smartfs` as a new file, mode `0600` whatever the umask, exclusive and atomic: the whole bearer or no file, never over an existing entry and never through a symbolic link at the path.
93
+ - It is never written to stdout. stdout carries one JSON line of metadata: `clusterId`, `generation`, `outputPath`, `byteLength`, `sha256` (what `sha256sum` prints for the file) and `receiptId`, the audit receipt Cloudly recorded. A refusal is printed to stderr with exit code 1 and never carries the bearer.
94
+
95
+ ## Corestore Control Token
96
+
97
+ Every cluster relay calls its nodes' Corestore control API with one control token, which Cloudly stores for both Corestore provider configs. These commands set and read it over the API, without any access to Cloudly's process. Both sign in exactly as the default action does; the set needs an administrator who signed in within the last five minutes.
98
+
99
+ ```sh
100
+ CLOUDLY_URL=https://cloudly.example.com \
101
+ CLOUDLY_USERNAME=admin CLOUDLY_PASSWORD=change-me \
102
+ servezone corestore control-token-state
103
+
104
+ # Pipe the token in, naming the state the read answered:
105
+ servezone corestore set-control-token --stdin --expect absent < control-token.txt
106
+ servezone corestore set-control-token --stdin --expect 3 < control-token.txt
107
+ ```
108
+
109
+ - `control-token-state` prints one JSON line: `{"ok":true,"command":"corestore control-token-state","state":…,"receipts":[…]}`. `state` is `{"state":"absent"}` or `{"state":"present","revision":<n>}`; `receipts` are the value-free audit receipts (`id`, `revision`, `via`, `actorId`, `setAt`), oldest first.
110
+ - `set-control-token --stdin --expect absent|<revision>` stores the token only while it is still exactly at the state `--expect` names: `absent`, or the revision `control-token-state` answered (a positive integer without leading zeros). It is never a minimum, so a set that committed in between is refused rather than overwritten.
111
+ - The token is read from stdin and from nowhere else: `--stdin` names that, and an argument, `--token`, or any other extra argument is refused as usage without echoing it; the environment is never read for it. stdin must not be a terminal. One trailing line break (`\n` or `\r\n`) is stripped; what remains must pass the contract's `isCorestoreControlToken` (32 to 4096 bytes of UTF-8 without whitespace). A refused stdin is named (`token-input-terminal`, `token-input-oversized`, `token-input-invalid`) before anything connects, and every byte read is wiped.
112
+ - The token is sent once with `@serve.zone/api` (`platform.setCorestoreControlToken`, past every TypedRequest hook), and the answer is checked against the request. stdout carries one JSON line with the state the set produced and its receipt, never the token: `{"ok":true,"command":"corestore set-control-token","state":{"state":"present","revision":<n>},"receipt":{…}}`.
113
+ - A refusal is printed to stderr with exit code 1 as `The Corestore control token was not stored: <refusal>.`, naming one of the contract's `corestoreControlTokenSetRefusals`: `token-present` (`--expect absent`, and a token is stored), `token-absent` (a revision was expected, and none is stored), `token-revision-mismatch` (the stored token is at another revision), `token-invalid` or `provider-unavailable` (a Corestore provider config is missing or disabled). Every other failure, an unauthorized or stale administrator included, is Cloudly's one denial text, `Corestore control token request denied.`, with exit code 1.
114
+
76
115
  ## Programmatic Use
77
116
 
78
117
  The published submodule exports the `runCli()` entry point. For automation, most callers should use `@serve.zone/api` directly; the internal `CliClient` currently wraps `CloudlyApiClient` only to run the default cluster-list action:
@@ -98,9 +137,11 @@ await cli.getClusters();
98
137
  | Path | Purpose |
99
138
  | --- | --- |
100
139
  | `index.ts` | Runtime entry point for the published CLI. |
101
- | `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()` and `getGatewayStatus()`. |
140
+ | `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()`, `getGatewayStatus()`, `exportRelayAuthorization()`, `setCorestoreControlToken()` and `getCorestoreControlTokenState()`. |
102
141
  | `commands.ts` | Parses the argument vector into one command, refusing anything else as usage. |
103
- | `tokeninput.ts` | Reads the dcrouter admin token from stdin. |
142
+ | `tokeninput.ts` | Reads the dcrouter admin token and the Corestore control token from stdin, each bounded and checked by its contract. |
143
+ | `corestorecontroltoken.ts` | Sets and reads the Corestore control token through the API client and names the contract's refusals. |
144
+ | `relayauthorization.ts` | Checks the private output directory and writes a cluster relay's bearer as a new 0600 file. |
104
145
  | `plugins.ts` | Centralized imports for the submodule. |
105
146
  | `tspublish.json` | Published package name, dependencies, `servezone` bin metadata, and `useBase`, which takes the registries from the release's npm target. |
106
147