@serve.zone/cli 33.9.0 → 33.10.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_ts_cliclient/classes.cliclient.d.ts +5 -0
- package/dist_ts_cliclient/classes.cliclient.js +10 -1
- package/dist_ts_cliclient/commands.d.ts +6 -1
- package/dist_ts_cliclient/commands.js +21 -1
- package/dist_ts_cliclient/index.d.ts +1 -0
- package/dist_ts_cliclient/index.js +11 -2
- package/dist_ts_cliclient/plugins.d.ts +6 -1
- package/dist_ts_cliclient/plugins.js +8 -2
- package/dist_ts_cliclient/relayauthorization.d.ts +48 -0
- package/dist_ts_cliclient/relayauthorization.js +95 -0
- package/package.json +4 -3
- package/readme.md +20 -1
- package/ts_cliclient/classes.cliclient.ts +14 -0
- package/ts_cliclient/commands.ts +27 -2
- package/ts_cliclient/index.ts +16 -1
- package/ts_cliclient/plugins.ts +13 -0
- package/ts_cliclient/readme.md +20 -1
- package/ts_cliclient/relayauthorization.ts +125 -0
|
@@ -9,5 +9,10 @@ export declare class CliClient {
|
|
|
9
9
|
* the caller's to wipe; the API client seals its own copy and never logs or keeps it.
|
|
10
10
|
*/
|
|
11
11
|
bootstrapGateway(targetArg: plugins.servezoneApi.IExternalGatewayBootstrapTarget, bootstrapTokenArg: Uint8Array): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Writes one cluster relay's bearer to a new 0600 file and prints what describes the file:
|
|
14
|
+
* the cluster, the generation, the size, the SHA-256 and the audit receipt, never the bearer.
|
|
15
|
+
*/
|
|
16
|
+
exportRelayAuthorization(requestArg: plugins.servezoneInterfaces.data.IClusterRelayAuthorizationExportRequest, outputPathArg: string): Promise<void>;
|
|
12
17
|
getGatewayStatus(): Promise<void>;
|
|
13
18
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { CloudlyApiClient } from '@serve.zone/api';
|
|
3
|
+
import { exportRelayAuthorizationToFile } from './relayauthorization.js';
|
|
3
4
|
export class CliClient {
|
|
4
5
|
constructor(cloudlyApiClientArg) {
|
|
5
6
|
this.cloudlyApiClient = cloudlyApiClientArg;
|
|
@@ -19,9 +20,17 @@ export class CliClient {
|
|
|
19
20
|
});
|
|
20
21
|
console.log(JSON.stringify(state, null, 2));
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Writes one cluster relay's bearer to a new 0600 file and prints what describes the file:
|
|
25
|
+
* the cluster, the generation, the size, the SHA-256 and the audit receipt, never the bearer.
|
|
26
|
+
*/
|
|
27
|
+
async exportRelayAuthorization(requestArg, outputPathArg) {
|
|
28
|
+
const output = await exportRelayAuthorizationToFile(this.cloudlyApiClient, requestArg, outputPathArg);
|
|
29
|
+
console.log(JSON.stringify({ ok: true, command: 'relay export-authorization', ...output }));
|
|
30
|
+
}
|
|
22
31
|
async getGatewayStatus() {
|
|
23
32
|
const { state } = await this.cloudlyApiClient.settings.getExternalGatewayEnrollmentState();
|
|
24
33
|
console.log(JSON.stringify(state, null, 2));
|
|
25
34
|
}
|
|
26
35
|
}
|
|
27
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
36
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5jbGljbGllbnQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90c19jbGljbGllbnQvY2xhc3Nlcy5jbGljbGllbnQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFDeEMsT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFDbkQsT0FBTyxFQUFFLDhCQUE4QixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFFekUsTUFBTSxPQUFPLFNBQVM7SUFHcEIsWUFBWSxtQkFBcUM7UUFDL0MsSUFBSSxDQUFDLGdCQUFnQixHQUFHLG1CQUFtQixDQUFDO0lBQzlDLENBQUM7SUFFTSxLQUFLLENBQUMsV0FBVztRQUN0QixNQUFNLFFBQVEsR0FBRyxNQUFNLElBQUksQ0FBQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsV0FBVyxFQUFFLENBQUM7UUFDbkUsT0FBTyxDQUFDLEdBQUcsQ0FBQyxRQUFRLENBQUMsQ0FBQztJQUN4QixDQUFDO0lBRUQ7OztPQUdHO0lBQ0ksS0FBSyxDQUFDLGdCQUFnQixDQUMzQixTQUErRCxFQUMvRCxpQkFBNkI7UUFFN0IsTUFBTSxFQUFFLEtBQUssRUFBRSxHQUFHLE1BQU0sSUFBSSxDQUFDLGdCQUFnQixDQUFDLFFBQVEsQ0FBQyxrQ0FBa0MsQ0FBQztZQUN4RixNQUFNLEVBQUUsU0FBUztZQUNqQixjQUFjLEVBQUUsaUJBQWlCO1NBQ2xDLENBQUMsQ0FBQztRQUNILE9BQU8sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxLQUFLLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDOUMsQ0FBQztJQUVEOzs7T0FHRztJQUNJLEtBQUssQ0FBQyx3QkFBd0IsQ0FDbkMsVUFBb0YsRUFDcEYsYUFBcUI7UUFFckIsTUFBTSxNQUFNLEdBQUcsTUFBTSw4QkFBOEIsQ0FDakQsSUFBSSxDQUFDLGdCQUFnQixFQUFFLFVBQVUsRUFBRSxhQUFhLENBQUMsQ0FBQztRQUNwRCxPQUFPLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUMsRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLE9BQU8sRUFBRSw0QkFBNEIsRUFBRSxHQUFHLE1BQU0sRUFBRSxDQUFDLENBQUMsQ0FBQztJQUM5RixDQUFDO0lBRU0sS0FBSyxDQUFDLGdCQUFnQjtRQUMzQixNQUFNLEVBQUUsS0FBSyxFQUFFLEdBQUcsTUFBTSxJQUFJLENBQUMsZ0JBQWdCLENBQUMsUUFBUSxDQUFDLGlDQUFpQyxFQUFFLENBQUM7UUFDM0YsT0FBTyxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLEtBQUssRUFBRSxJQUFJLEVBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQztJQUM5QyxDQUFDO0NBQ0YifQ==
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
2
|
/** What one invocation of the CLI asks for, parsed before anything is read or connected. */
|
|
3
3
|
export type TCliCommand =
|
|
4
4
|
/** No arguments: the default action, listing the clusters. */
|
|
@@ -9,6 +9,11 @@ export type TCliCommand =
|
|
|
9
9
|
} | {
|
|
10
10
|
kind: 'gateway-bootstrap';
|
|
11
11
|
target: plugins.servezoneApi.IExternalGatewayBootstrapTarget;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'relay-export-authorization';
|
|
14
|
+
request: plugins.servezoneInterfaces.data.IClusterRelayAuthorizationExportRequest;
|
|
15
|
+
/** A normalized absolute path in a private directory; the file must not exist yet. */
|
|
16
|
+
outputPath: string;
|
|
12
17
|
};
|
|
13
18
|
export declare const cliUsage: string;
|
|
14
19
|
/**
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
1
2
|
export const cliUsage = [
|
|
2
3
|
'Usage:',
|
|
3
4
|
' servezone',
|
|
4
5
|
' servezone gateway status',
|
|
5
6
|
' servezone gateway bootstrap --gateway-url <https url> --client-id <id>',
|
|
6
7
|
' The dcrouter admin token is read from stdin, never from arguments or the environment.',
|
|
8
|
+
' servezone relay export-authorization --cluster-id <id> --expected-generation <n> --output <absolute path>',
|
|
9
|
+
' Writes the relay bearer to a new 0600 file in a directory only you can write; never to stdout.',
|
|
7
10
|
].join('\n');
|
|
8
11
|
/**
|
|
9
12
|
* An invocation the CLI does not run. It never quotes what it was given, because an argument may
|
|
@@ -37,6 +40,8 @@ export const parseCliCommand = (argsArg) => {
|
|
|
37
40
|
if (argsArg.length === 0)
|
|
38
41
|
return { kind: 'clusters' };
|
|
39
42
|
const [group, action, ...rest] = argsArg;
|
|
43
|
+
if (group === 'relay')
|
|
44
|
+
return parseRelayCommand(action, rest);
|
|
40
45
|
requireUsage(group === 'gateway');
|
|
41
46
|
if (action === 'status') {
|
|
42
47
|
requireUsage(rest.length === 0);
|
|
@@ -49,4 +54,19 @@ export const parseCliCommand = (argsArg) => {
|
|
|
49
54
|
target: { gatewayUrl: options['--gateway-url'], gatewayClientId: options['--client-id'] },
|
|
50
55
|
};
|
|
51
56
|
};
|
|
52
|
-
|
|
57
|
+
/** `relay export-authorization`: the cluster, the exact generation and a normalized absolute output. */
|
|
58
|
+
const parseRelayCommand = (actionArg, restArg) => {
|
|
59
|
+
requireUsage(actionArg === 'export-authorization');
|
|
60
|
+
const options = parseOptions(restArg, ['--cluster-id', '--expected-generation', '--output']);
|
|
61
|
+
const generation = options['--expected-generation'];
|
|
62
|
+
requireUsage(/^[1-9][0-9]{0,15}$/.test(generation) && Number.isSafeInteger(Number(generation)));
|
|
63
|
+
const outputPath = options['--output'];
|
|
64
|
+
requireUsage(plugins.path.isAbsolute(outputPath) && plugins.path.normalize(outputPath) === outputPath
|
|
65
|
+
&& !outputPath.endsWith(plugins.path.sep));
|
|
66
|
+
return {
|
|
67
|
+
kind: 'relay-export-authorization',
|
|
68
|
+
request: { clusterId: options['--cluster-id'], expectedGeneration: Number(generation) },
|
|
69
|
+
outputPath,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29tbWFuZHMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90c19jbGljbGllbnQvY29tbWFuZHMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFleEMsTUFBTSxDQUFDLE1BQU0sUUFBUSxHQUFHO0lBQ3RCLFFBQVE7SUFDUixhQUFhO0lBQ2IsNEJBQTRCO0lBQzVCLDBFQUEwRTtJQUMxRSwyRkFBMkY7SUFDM0YsNkdBQTZHO0lBQzdHLG9HQUFvRztDQUNyRyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQztBQUViOzs7R0FHRztBQUNILE1BQU0sT0FBTyxhQUFjLFNBQVEsS0FBSztJQUN0QztRQUNFLEtBQUssQ0FBQyxRQUFRLENBQUMsQ0FBQztRQUNoQixJQUFJLENBQUMsSUFBSSxHQUFHLGVBQWUsQ0FBQztJQUM5QixDQUFDO0NBQ0Y7QUFFRCxTQUFTLFlBQVksQ0FBQyxZQUFxQjtJQUN6QyxJQUFJLENBQUMsWUFBWTtRQUFFLE1BQU0sSUFBSSxhQUFhLEVBQUUsQ0FBQztBQUMvQyxDQUFDO0FBRUQsbUZBQW1GO0FBQ25GLE1BQU0sWUFBWSxHQUFHLENBQ25CLE9BQTBCLEVBQzFCLFFBQTBCLEVBQ0gsRUFBRTtJQUN6QixNQUFNLE9BQU8sR0FBRyxJQUFJLEdBQUcsRUFBa0IsQ0FBQztJQUMxQyxZQUFZLENBQUMsT0FBTyxDQUFDLE1BQU0sS0FBSyxRQUFRLENBQUMsTUFBTSxHQUFHLENBQUMsQ0FBQyxDQUFDO0lBQ3JELEtBQUssSUFBSSxLQUFLLEdBQUcsQ0FBQyxFQUFFLEtBQUssR0FBRyxPQUFPLENBQUMsTUFBTSxFQUFFLEtBQUssSUFBSSxDQUFDLEVBQUUsQ0FBQztRQUN2RCxNQUFNLElBQUksR0FBRyxPQUFPLENBQUMsS0FBSyxDQUFDLENBQUM7UUFDNUIsTUFBTSxLQUFLLEdBQUcsT0FBTyxDQUFDLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQztRQUNqQyxZ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
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { CliUsageError, parseCliCommand, type TCliCommand } from './commands.js';
|
|
2
|
+
export { RelayAuthorizationOutputError, exportRelayAuthorizationToFile, requirePrivateOutputDirectory, writeRelayAuthorization, type IRelayAuthorizationOutput, type TRelayAuthorizationOutputRefusal, } from './relayauthorization.js';
|
|
2
3
|
export { BootstrapTokenInputError, readBootstrapTokenInput, type TBootstrapTokenInputRefusal, } from './tokeninput.js';
|
|
3
4
|
export declare const runCli: () => Promise<void>;
|
|
@@ -2,7 +2,9 @@ import * as plugins from './plugins.js';
|
|
|
2
2
|
import { CliClient } from './classes.cliclient.js';
|
|
3
3
|
import { parseCliCommand } from './commands.js';
|
|
4
4
|
import { readBootstrapTokenInput } from './tokeninput.js';
|
|
5
|
+
import { requirePrivateOutputDirectory } from './relayauthorization.js';
|
|
5
6
|
export { CliUsageError, parseCliCommand } from './commands.js';
|
|
7
|
+
export { RelayAuthorizationOutputError, exportRelayAuthorizationToFile, requirePrivateOutputDirectory, writeRelayAuthorization, } from './relayauthorization.js';
|
|
6
8
|
export { BootstrapTokenInputError, readBootstrapTokenInput, } from './tokeninput.js';
|
|
7
9
|
export const runCli = async () => {
|
|
8
10
|
// The command and the token are settled before anything connects, so a wrong invocation or a
|
|
@@ -13,6 +15,10 @@ export const runCli = async () => {
|
|
|
13
15
|
command = parsed.kind === 'gateway-bootstrap'
|
|
14
16
|
? { ...parsed, bootstrapToken: await readBootstrapTokenInput(process.stdin) }
|
|
15
17
|
: parsed;
|
|
18
|
+
// An output the bearer could not be written to fetches no bearer and records no export.
|
|
19
|
+
if (command.kind === 'relay-export-authorization') {
|
|
20
|
+
await requirePrivateOutputDirectory(command.outputPath);
|
|
21
|
+
}
|
|
16
22
|
}
|
|
17
23
|
catch (error) {
|
|
18
24
|
console.error(error.message);
|
|
@@ -31,12 +37,15 @@ export const runCli = async () => {
|
|
|
31
37
|
if (command.kind === 'gateway-bootstrap') {
|
|
32
38
|
await cliClient.bootstrapGateway(command.target, command.bootstrapToken);
|
|
33
39
|
}
|
|
40
|
+
else if (command.kind === 'relay-export-authorization') {
|
|
41
|
+
await cliClient.exportRelayAuthorization(command.request, command.outputPath);
|
|
42
|
+
}
|
|
34
43
|
else {
|
|
35
44
|
await cliClient.getGatewayStatus();
|
|
36
45
|
}
|
|
37
46
|
}
|
|
38
47
|
catch (error) {
|
|
39
|
-
// Cloudly answers a refusal by name; it never carries
|
|
48
|
+
// Cloudly answers a refusal by name; it never carries a token or a bearer.
|
|
40
49
|
console.error(error.message);
|
|
41
50
|
process.exitCode = 1;
|
|
42
51
|
}
|
|
@@ -82,4 +91,4 @@ const connect = async () => {
|
|
|
82
91
|
}
|
|
83
92
|
return apiClient;
|
|
84
93
|
};
|
|
85
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
94
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90c19jbGljbGllbnQvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFDeEMsT0FBTyxFQUFFLFNBQVMsRUFBRSxNQUFNLHdCQUF3QixDQUFDO0FBQ25ELE9BQU8sRUFBRSxlQUFlLEVBQW9CLE1BQU0sZUFBZSxDQUFDO0FBQ2xFLE9BQU8sRUFBRSx1QkFBdUIsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBQzFELE9BQU8sRUFBRSw2QkFBNkIsRUFBRSxNQUFNLHlCQUF5QixDQUFDO0FBRXhFLE9BQU8sRUFBRSxhQUFhLEVBQUUsZUFBZSxFQUFvQixNQUFNLGVBQWUsQ0FBQztBQUNqRixPQUFPLEVBQ0wsNkJBQTZCLEVBQzdCLDhCQUE4QixFQUM5Qiw2QkFBNkIsRUFDN0IsdUJBQXVCLEdBR3hCLE1BQU0seUJBQXlCLENBQUM7QUFDakMsT0FBTyxFQUNMLHdCQUF3QixFQUN4Qix1QkFBdUIsR0FFeEIsTUFBTSxpQkFBaUIsQ0FBQztBQU96QixNQUFNLENBQUMsTUFBTSxNQUFNLEdBQUcsS0FBSyxJQUFJLEVBQUU7SUFDL0IsNkZBQTZGO0lBQzdGLGdFQUFnRTtJQUNoRSxJQUFJLE9BQXVCLENBQUM7SUFDNUIsSUFBSSxDQUFDO1FBQ0gsTUFBTSxNQUFNLEdBQUcsZUFBZSxDQUFDLE9BQU8sQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7UUFDdEQsT0FBTyxHQUFHLE1BQU0sQ0FBQyxJQUFJLEtBQUssbUJBQW1CO1lBQzNDLENBQUMsQ0FBQyxFQUFFLEdBQUcsTUFBTSxFQUFFLGNBQWMsRUFBRSxNQUFNLHVCQUF1QixDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUMsRUFBRTtZQUM3RSxDQUFDLENBQUMsTUFBTSxDQUFDO1FBQ1gsd0ZBQXdGO1FBQ3hGLElBQUksT0FBTyxDQUFDLElBQUks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
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
export { crypto, fs, path, };
|
|
1
5
|
import * as servezoneApi from '@serve.zone/api';
|
|
2
6
|
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
|
3
7
|
export { servezoneApi, servezoneInterfaces };
|
|
4
8
|
import * as projectinfo from '@push.rocks/projectinfo';
|
|
5
9
|
import * as qenv from '@push.rocks/qenv';
|
|
6
|
-
|
|
10
|
+
import * as smartfs from '@push.rocks/smartfs';
|
|
11
|
+
export { projectinfo, qenv, smartfs, };
|
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
export { crypto, fs, path, };
|
|
1
6
|
// @serve.zone scope
|
|
2
7
|
import * as servezoneApi from '@serve.zone/api';
|
|
3
8
|
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
|
@@ -5,5 +10,6 @@ export { servezoneApi, servezoneInterfaces };
|
|
|
5
10
|
// @push.rocks scope
|
|
6
11
|
import * as projectinfo from '@push.rocks/projectinfo';
|
|
7
12
|
import * as qenv from '@push.rocks/qenv';
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
import * as smartfs from '@push.rocks/smartfs';
|
|
14
|
+
export { projectinfo, qenv, smartfs, };
|
|
15
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzX2NsaWNsaWVudC9wbHVnaW5zLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLG9CQUFvQjtBQUNwQixPQUFPLEtBQUssTUFBTSxNQUFNLGFBQWEsQ0FBQztBQUN0QyxPQUFPLEtBQUssRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBQ3ZDLE9BQU8sS0FBSyxJQUFJLE1BQU0sV0FBVyxDQUFDO0FBRWxDLE9BQU8sRUFDTCxNQUFNLEVBQ04sRUFBRSxFQUNGLElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxZQUFZLE1BQU0saUJBQWlCLENBQUM7QUFDaEQsT0FBTyxLQUFLLG1CQUFtQixNQUFNLHdCQUF3QixDQUFDO0FBRTlELE9BQU8sRUFDTCxZQUFZLEVBQ1osbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFFL0MsT0FBTyxFQUNMLFdBQVcsRUFDWCxJQUFJLEVBQ0osT0FBTyxHQUNSLENBQUEifQ==
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
/** Why the relay bearer was not written, named without quoting the bearer. */
|
|
3
|
+
export type TRelayAuthorizationOutputRefusal =
|
|
4
|
+
/** The output's directory does not exist, or a component of its path is not a directory. */
|
|
5
|
+
'output-directory-missing'
|
|
6
|
+
/**
|
|
7
|
+
* The output's directory is not a private directory of the caller: it is reached through a
|
|
8
|
+
* symbolic link, owned by another user, or writable by its group or by other users.
|
|
9
|
+
*/
|
|
10
|
+
| 'output-directory-not-private'
|
|
11
|
+
/** Something already exists at the output path; it is left untouched. */
|
|
12
|
+
| 'output-exists';
|
|
13
|
+
export declare class RelayAuthorizationOutputError extends Error {
|
|
14
|
+
readonly refusal: TRelayAuthorizationOutputRefusal;
|
|
15
|
+
constructor(refusal: TRelayAuthorizationOutputRefusal);
|
|
16
|
+
}
|
|
17
|
+
/** What the CLI prints about the file it wrote: metadata only, never a byte of the bearer. */
|
|
18
|
+
export interface IRelayAuthorizationOutput {
|
|
19
|
+
clusterId: string;
|
|
20
|
+
generation: number;
|
|
21
|
+
outputPath: string;
|
|
22
|
+
byteLength: number;
|
|
23
|
+
/** Hex SHA-256 of the file's bytes, as `sha256sum` prints it for the same file. */
|
|
24
|
+
sha256: string;
|
|
25
|
+
/** The audit receipt Cloudly recorded for this export. */
|
|
26
|
+
receiptId: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Refuses an output whose directory is not a private directory of the caller, or that exists.
|
|
30
|
+
*
|
|
31
|
+
* Exclusive creation guards only the file's own name, so the directory is checked here: its path
|
|
32
|
+
* resolves to itself (no symbolic link anywhere on it), it is a directory owned by the caller,
|
|
33
|
+
* and neither its group nor other users can write it. Checked before Cloudly is asked, so a bad
|
|
34
|
+
* output fetches no bearer and writes no audit receipt.
|
|
35
|
+
*/
|
|
36
|
+
export declare const requirePrivateOutputDirectory: (outputPathArg: string) => Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Writes the bearer as a new file, mode 0600 whatever the umask, whole or not at all.
|
|
39
|
+
*
|
|
40
|
+
* The write is exclusive and atomic: an existing entry at the path is never replaced or
|
|
41
|
+
* followed, and the bytes go to a private temporary file that is linked into place.
|
|
42
|
+
*/
|
|
43
|
+
export declare const writeRelayAuthorization: (outputPathArg: string, bearerArg: Uint8Array) => Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Fetches one cluster relay's bearer through the API client and writes it to the operator's
|
|
46
|
+
* file, answering only what describes the file.
|
|
47
|
+
*/
|
|
48
|
+
export declare const exportRelayAuthorizationToFile: (apiClientArg: plugins.servezoneApi.CloudlyApiClient, requestArg: plugins.servezoneInterfaces.data.IClusterRelayAuthorizationExportRequest, outputPathArg: string) => Promise<IRelayAuthorizationOutput>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
export class RelayAuthorizationOutputError extends Error {
|
|
3
|
+
constructor(refusal) {
|
|
4
|
+
super(`The relay bearer was not written: ${refusal}.`);
|
|
5
|
+
this.refusal = refusal;
|
|
6
|
+
this.name = 'RelayAuthorizationOutputError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const errorCodeOf = (errorArg) => errorArg instanceof Error && 'code' in errorArg && typeof errorArg.code === 'string'
|
|
10
|
+
? errorArg.code : undefined;
|
|
11
|
+
/** A directory that is not there, or not reachable as one, is the operator's to create. */
|
|
12
|
+
const refuseMissingDirectory = (errorArg) => {
|
|
13
|
+
const code = errorCodeOf(errorArg);
|
|
14
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
15
|
+
throw new RelayAuthorizationOutputError('output-directory-missing');
|
|
16
|
+
}
|
|
17
|
+
throw errorArg;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Refuses an output whose directory is not a private directory of the caller, or that exists.
|
|
21
|
+
*
|
|
22
|
+
* Exclusive creation guards only the file's own name, so the directory is checked here: its path
|
|
23
|
+
* resolves to itself (no symbolic link anywhere on it), it is a directory owned by the caller,
|
|
24
|
+
* and neither its group nor other users can write it. Checked before Cloudly is asked, so a bad
|
|
25
|
+
* output fetches no bearer and writes no audit receipt.
|
|
26
|
+
*/
|
|
27
|
+
export const requirePrivateOutputDirectory = async (outputPathArg) => {
|
|
28
|
+
const directory = plugins.path.dirname(outputPathArg);
|
|
29
|
+
const stats = await plugins.fs.lstat(directory).catch(refuseMissingDirectory);
|
|
30
|
+
const resolved = await plugins.fs.realpath(directory).catch(refuseMissingDirectory);
|
|
31
|
+
// A symbolic link anywhere on the path is a directory someone else could redirect.
|
|
32
|
+
if (resolved !== directory)
|
|
33
|
+
throw new RelayAuthorizationOutputError('output-directory-not-private');
|
|
34
|
+
if (!stats.isDirectory())
|
|
35
|
+
throw new RelayAuthorizationOutputError('output-directory-missing');
|
|
36
|
+
if (stats.uid !== process.getuid?.() || (stats.mode & 0o022) !== 0) {
|
|
37
|
+
throw new RelayAuthorizationOutputError('output-directory-not-private');
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
await plugins.fs.lstat(outputPathArg);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (errorCodeOf(error) === 'ENOENT')
|
|
44
|
+
return;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
throw new RelayAuthorizationOutputError('output-exists');
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Writes the bearer as a new file, mode 0600 whatever the umask, whole or not at all.
|
|
51
|
+
*
|
|
52
|
+
* The write is exclusive and atomic: an existing entry at the path is never replaced or
|
|
53
|
+
* followed, and the bytes go to a private temporary file that is linked into place.
|
|
54
|
+
*/
|
|
55
|
+
export const writeRelayAuthorization = async (outputPathArg, bearerArg) => {
|
|
56
|
+
await requirePrivateOutputDirectory(outputPathArg);
|
|
57
|
+
const smartFs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
|
|
58
|
+
const bytes = Buffer.from(bearerArg);
|
|
59
|
+
try {
|
|
60
|
+
await smartFs.file(outputPathArg).mode(0o600).exclusive().atomic().write(bytes);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof plugins.smartfs.SmartFsExclusiveWriteError) {
|
|
64
|
+
throw new RelayAuthorizationOutputError(error.code === 'EEXIST' ? 'output-exists' : 'output-directory-missing');
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
bytes.fill(0);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Fetches one cluster relay's bearer through the API client and writes it to the operator's
|
|
74
|
+
* file, answering only what describes the file.
|
|
75
|
+
*/
|
|
76
|
+
export const exportRelayAuthorizationToFile = async (apiClientArg, requestArg, outputPathArg) => {
|
|
77
|
+
await requirePrivateOutputDirectory(outputPathArg);
|
|
78
|
+
const exported = await apiClientArg.cluster.exportClusterRelayAuthorization(requestArg);
|
|
79
|
+
const bearer = new TextEncoder().encode(exported.authorization);
|
|
80
|
+
try {
|
|
81
|
+
await writeRelayAuthorization(outputPathArg, bearer);
|
|
82
|
+
return {
|
|
83
|
+
clusterId: exported.credential.clusterId,
|
|
84
|
+
generation: exported.credential.generation,
|
|
85
|
+
outputPath: outputPathArg,
|
|
86
|
+
byteLength: bearer.byteLength,
|
|
87
|
+
sha256: plugins.crypto.createHash('sha256').update(bearer).digest('hex'),
|
|
88
|
+
receiptId: exported.receipt.id,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
bearer.fill(0);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVsYXlhdXRob3JpemF0aW9uLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHNfY2xpY2xpZW50L3JlbGF5YXV0aG9yaXphdGlvbi50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGNBQWMsQ0FBQztBQWN4QyxNQUFNLE9BQU8sNkJBQThCLFNBQVEsS0FBSztJQUN0RCxZQUE0QixPQUF5QztRQUNuRSxLQUFLLENBQUMscUNBQXFDLE9BQU8sR0FBRyxDQUFDLENBQUM7UUFEN0IsWUFBTyxHQUFQLE9BQU8sQ0FBa0M7UUFFbkUsSUFBSSxDQUFDLElBQUksR0FBRywrQkFBK0IsQ0FBQztJQUM5QyxDQUFDO0NBQ0Y7QUFjRCxNQUFNLFdBQVcsR0FBRyxDQUFDLFFBQWlCLEVBQXNCLEVBQUUsQ0FDNUQsUUFBUSxZQUFZLEtBQUssSUFBSSxNQUFNLElBQUksUUFBUSxJQUFJLE9BQU8sUUFBUSxDQUFDLElBQUksS0FBSyxRQUFRO0lBQ2xGLENBQUMsQ0FBQyxRQUFRLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxTQUFTLENBQUM7QUFFaEMsMkZBQTJGO0FBQzNGLE1BQU0sc0JBQXNCLEdBQUcsQ0FBQyxRQUFpQixFQUFTLEVBQUU7SUFDMUQsTUFBTSxJQUFJLEdBQUcsV0FBVyxDQUFDLFFBQVEsQ0FBQyxDQUFDO0lBQ25DLElBQUksSUFBSSxLQUFLLFFBQVEsSUFBSSxJQUFJLEtBQUssU0FBUyxFQUFFLENBQUM7UUFDNUMsTUFBTSxJQUFJLDZCQUE2QixDQUFDLDBCQUEwQixDQUFDLENBQUM7SUFDdEUsQ0FBQztJQUNELE1BQU0sUUFBUSxDQUFDO0FBQ2pCLENBQUMsQ0FBQztBQUVGOzs7Ozs7O0dBT0c7QUFDSCxNQUFNLENBQUMsTUFBTSw2QkFBNkIsR0FBRyxLQUFLLEVBQUUsYUFBcUIsRUFBaUIsRUFBRTtJQUMxRixNQUFNLFNBQVMsR0FBRyxPQUFPLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBQyxhQUFhLENBQUMsQ0FBQztJQUN0RCxNQUFNLEtBQUssR0FBRyxNQUFNLE9BQU8sQ0FBQyxFQUFFLENBQUMsS0FBSyxDQUFDLFNBQVMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxzQkFBc0IsQ0FBQyxDQUFDO0lBQzlFLE1BQU0sUUFBUSxHQUFHLE1BQU0sT0FBTyxDQUFDLEVBQUUsQ0FBQyxRQUFRLENBQUMsU0FBUyxDQUFDLENBQUMsS0FBSyxDQUFDLHNCQUFzQixDQUFDLENBQUM7SUFDcEYsbUZBQW1GO0lBQ25GLElBQUksUUFBUSxLQUFLLFNBQVM7UUFBRSxNQUFNLElBQUksNkJBQTZCLENBQUMsOEJBQThCLENBQUMsQ0FBQztJQUNwRyxJQUFJLENBQUMsS0FBSyxDQUFDLFdBQVcsRUFBRTtRQUFFLE1BQU0sSUFBSSw2QkFBNkIsQ0FBQywwQkFBMEIsQ0FBQyxDQUFDO0lBQzlGLElBQUksS0FBSyxDQUFDLEdBQUcsS0FBSyxPQUFPLENBQUMsTUFBTSxFQUFFLEVBQUUsSUFBSSxDQUFDLEtBQUssQ0FBQyxJQUFJLEdBQUcsS0FBSyxDQUFDLEtBQUssQ0FBQyxFQUFFLENBQUM7UUFDbkUsTUFBTSxJQUFJLDZCQUE2QixDQUFDLDhCQUE4QixDQUFDLENBQUM7SUFDMUUsQ0FBQztJQUNELElBQUksQ0FBQztRQUNILE1BQU0sT0FBTyxDQUFDLEVBQUUsQ0FBQyxLQUFLLENBQUMsYUFBYSxDQUFDLENBQUM7SUFDeEMsQ0FBQztJQUFDLE9BQU8sS0FBSyxFQUFFLENBQUM7UUFDZixJQUFJLFdBQVcsQ0FBQyxLQUFLLENBQUMsS0FBSyxRQUFRO1lBQUUsT0FBTztRQUM1QyxNQUFNLEtBQUssQ0FBQztJQUNkLENBQUM7SUFDRCxNQUFNLElBQUksNkJBQTZCLENBQUMsZUFBZSxDQUFDLENBQUM7QUFDM0QsQ0FBQyxDQUFDO0FBRUY7Ozs7O0dBS0c7QUFDSCxNQUFNLENBQUMsTUFBTSx1QkFBdUIsR0FBRyxLQUFLLEVBQzFDLGFBQXFCLEVBQ3JCLFNBQXFCLEVBQ04sRUFBRTtJQUNqQixNQUFNLDZCQUE2QixDQUFDLGFBQWEsQ0FBQyxDQUFDO0lBQ25ELE1BQU0sT0FBTyxHQUFHLElBQUksT0FBTyxDQUFDLE9BQU8sQ0FBQyxPQUFPLENBQUMsSUFBSSxPQUFPLENBQUMsT0FBTyxDQUFDLG1CQUFtQixFQUFFLENBQUMsQ0FBQztJQUN2RixNQUFNLEtBQUssR0FBRyxNQUFNLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxDQUFDO0lBQ3JDLElBQUksQ0FBQztRQUNILE1BQU0sT0FBTyxDQUFDLElBQUksQ0FBQyxhQUFhLENBQUMsQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLENBQUMsU0FBUyxFQUFFLENBQUMsTUFBTSxFQUFFLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDO0lBQ2xGLENBQUM7SUFBQyxPQUFPLEtBQUssRUFBRSxDQUFDO1FBQ2YsSUFBSSxLQUFLLFlBQVksT0FBTyxDQUFDLE9BQU8sQ0FBQywwQkFBMEIsRUFBRSxDQUFDO1lBQ2hFLE1BQU0sSUFBSSw2QkFBNkIsQ0FDckMsS0FBSyxDQUFDLElBQUksS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLGVBQWUsQ0FBQyxDQUFDLENBQUMsMEJBQTBCLENBQUMsQ0FBQztRQUM1RSxDQUFDO1FBQ0QsTUFBTSxLQUFLLENBQUM7SUFDZCxDQUFDO1lBQVMsQ0FBQztRQUNULEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDaEIsQ0FBQztBQUNILENBQUMsQ0FBQztBQUVGOzs7R0FHRztBQUNILE1BQU0sQ0FBQyxNQUFNLDhCQUE4QixHQUFHLEtBQUssRUFDakQsWUFBbUQsRUFDbkQsVUFBb0YsRUFDcEYsYUFBcUIsRUFDZSxFQUFFO0lBQ3RDLE1BQU0sNkJBQTZCLENBQUMsYUFBYSxDQUFDLENBQUM7SUFDbkQsTUFBTSxRQUFRLEdBQUcsTUFBTSxZQUFZLENBQUMsT0FBTyxDQUFDLCtCQUErQixDQUFDLFVBQVUsQ0FBQyxDQUFDO0lBQ3hGLE1BQU0sTUFBTSxHQUFHLElBQUksV0FBVyxFQUFFLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxhQUFhLENBQUMsQ0FBQztJQUNoRSxJQUFJLENBQUM7UUFDSCxNQUFNLHVCQUF1QixDQUFDLGFBQWEsRUFBRSxNQUFNLENBQUMsQ0FBQztRQUNyRCxPQUFPO1lBQ0wsU0FBUyxFQUFFLFFBQVEsQ0FBQyxVQUFVLENBQUMsU0FBUztZQUN4QyxVQUFVLEVBQUUsUUFBUSxDQUFDLFVBQVUsQ0FBQyxVQUFVO1lBQzFDLFVBQVUsRUFBRSxhQUFhO1lBQ3pCLFVBQVUsRUFBRSxNQUFNLENBQUMsVUFBVTtZQUM3QixNQUFNLEVBQUUsT0FBTyxDQUFDLE1BQU0sQ0FBQyxVQUFVLENBQUMsUUFBUSxDQUFDLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUM7WUFDeEUsU0FBUyxFQUFFLFFBQVEsQ0FBQyxPQUFPLENBQUMsRUFBRTtTQUMvQixDQUFDO0lBQ0osQ0FBQztZQUFTLENBQUM7UUFDVCxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQ2pCLENBQUM7QUFDSCxDQUFDLENBQUMifQ==
|
package/package.json
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"security"
|
|
39
39
|
],
|
|
40
40
|
"name": "@serve.zone/cli",
|
|
41
|
-
"version": "33.
|
|
41
|
+
"version": "33.10.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.
|
|
54
|
-
"@serve.zone/interfaces": "32.
|
|
53
|
+
"@serve.zone/api": "32.4.0",
|
|
54
|
+
"@serve.zone/interfaces": "32.41.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,7 @@ 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.
|
|
19
20
|
|
|
20
21
|
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
22
|
|
|
@@ -73,6 +74,23 @@ servezone gateway status
|
|
|
73
74
|
|
|
74
75
|
Revoke the admin token at dcrouter once `gateway status` reads `ready`.
|
|
75
76
|
|
|
77
|
+
## Relay Bearer Export
|
|
78
|
+
|
|
79
|
+
`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.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
install -d -m 0700 /root/relay-export
|
|
83
|
+
CLOUDLY_URL=https://cloudly.example.com \
|
|
84
|
+
CLOUDLY_USERNAME=admin CLOUDLY_PASSWORD=change-me \
|
|
85
|
+
servezone relay export-authorization --cluster-id <clusterId> --expected-generation <n> \
|
|
86
|
+
--output /root/relay-export/relay-authorization
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `--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.
|
|
90
|
+
- `--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.
|
|
91
|
+
- 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.
|
|
92
|
+
- 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.
|
|
93
|
+
|
|
76
94
|
## Programmatic Use
|
|
77
95
|
|
|
78
96
|
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 +116,10 @@ await cli.getClusters();
|
|
|
98
116
|
| Path | Purpose |
|
|
99
117
|
| --- | --- |
|
|
100
118
|
| `index.ts` | Runtime entry point for the published CLI. |
|
|
101
|
-
| `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()` and `
|
|
119
|
+
| `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()`, `getGatewayStatus()` and `exportRelayAuthorization()`. |
|
|
102
120
|
| `commands.ts` | Parses the argument vector into one command, refusing anything else as usage. |
|
|
103
121
|
| `tokeninput.ts` | Reads the dcrouter admin token from stdin. |
|
|
122
|
+
| `relayauthorization.ts` | Checks the private output directory and writes a cluster relay's bearer as a new 0600 file. |
|
|
104
123
|
| `plugins.ts` | Centralized imports for the submodule. |
|
|
105
124
|
| `tspublish.json` | Published package name, dependencies, `servezone` bin metadata, and `useBase`, which takes the registries from the release's npm target. |
|
|
106
125
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { CloudlyApiClient } from '@serve.zone/api';
|
|
3
|
+
import { exportRelayAuthorizationToFile } from './relayauthorization.js';
|
|
3
4
|
|
|
4
5
|
export class CliClient {
|
|
5
6
|
public cloudlyApiClient: CloudlyApiClient;
|
|
@@ -28,6 +29,19 @@ export class CliClient {
|
|
|
28
29
|
console.log(JSON.stringify(state, null, 2));
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Writes one cluster relay's bearer to a new 0600 file and prints what describes the file:
|
|
34
|
+
* the cluster, the generation, the size, the SHA-256 and the audit receipt, never the bearer.
|
|
35
|
+
*/
|
|
36
|
+
public async exportRelayAuthorization(
|
|
37
|
+
requestArg: plugins.servezoneInterfaces.data.IClusterRelayAuthorizationExportRequest,
|
|
38
|
+
outputPathArg: string,
|
|
39
|
+
) {
|
|
40
|
+
const output = await exportRelayAuthorizationToFile(
|
|
41
|
+
this.cloudlyApiClient, requestArg, outputPathArg);
|
|
42
|
+
console.log(JSON.stringify({ ok: true, command: 'relay export-authorization', ...output }));
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
public async getGatewayStatus() {
|
|
32
46
|
const { state } = await this.cloudlyApiClient.settings.getExternalGatewayEnrollmentState();
|
|
33
47
|
console.log(JSON.stringify(state, null, 2));
|
package/ts_cliclient/commands.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import
|
|
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
|
+
};
|
|
9
15
|
|
|
10
16
|
export const cliUsage = [
|
|
11
17
|
'Usage:',
|
|
@@ -13,6 +19,8 @@ export const cliUsage = [
|
|
|
13
19
|
' servezone gateway status',
|
|
14
20
|
' servezone gateway bootstrap --gateway-url <https url> --client-id <id>',
|
|
15
21
|
' The dcrouter admin token is read from stdin, never from arguments or the environment.',
|
|
22
|
+
' servezone relay export-authorization --cluster-id <id> --expected-generation <n> --output <absolute path>',
|
|
23
|
+
' Writes the relay bearer to a new 0600 file in a directory only you can write; never to stdout.',
|
|
16
24
|
].join('\n');
|
|
17
25
|
|
|
18
26
|
/**
|
|
@@ -51,6 +59,7 @@ const parseOptions = <TName extends string>(
|
|
|
51
59
|
export const parseCliCommand = (argsArg: readonly string[]): TCliCommand => {
|
|
52
60
|
if (argsArg.length === 0) return { kind: 'clusters' };
|
|
53
61
|
const [group, action, ...rest] = argsArg;
|
|
62
|
+
if (group === 'relay') return parseRelayCommand(action, rest);
|
|
54
63
|
requireUsage(group === 'gateway');
|
|
55
64
|
if (action === 'status') {
|
|
56
65
|
requireUsage(rest.length === 0);
|
|
@@ -63,3 +72,19 @@ export const parseCliCommand = (argsArg: readonly string[]): TCliCommand => {
|
|
|
63
72
|
target: { gatewayUrl: options['--gateway-url'], gatewayClientId: options['--client-id'] },
|
|
64
73
|
};
|
|
65
74
|
};
|
|
75
|
+
|
|
76
|
+
/** `relay export-authorization`: the cluster, the exact generation and a normalized absolute output. */
|
|
77
|
+
const parseRelayCommand = (actionArg: string | undefined, restArg: readonly string[]): TCliCommand => {
|
|
78
|
+
requireUsage(actionArg === 'export-authorization');
|
|
79
|
+
const options = parseOptions(restArg, ['--cluster-id', '--expected-generation', '--output']);
|
|
80
|
+
const generation = options['--expected-generation'];
|
|
81
|
+
requireUsage(/^[1-9][0-9]{0,15}$/.test(generation) && Number.isSafeInteger(Number(generation)));
|
|
82
|
+
const outputPath = options['--output'];
|
|
83
|
+
requireUsage(plugins.path.isAbsolute(outputPath) && plugins.path.normalize(outputPath) === outputPath
|
|
84
|
+
&& !outputPath.endsWith(plugins.path.sep));
|
|
85
|
+
return {
|
|
86
|
+
kind: 'relay-export-authorization',
|
|
87
|
+
request: { clusterId: options['--cluster-id'], expectedGeneration: Number(generation) },
|
|
88
|
+
outputPath,
|
|
89
|
+
};
|
|
90
|
+
};
|
package/ts_cliclient/index.ts
CHANGED
|
@@ -2,8 +2,17 @@ import * as plugins from './plugins.js';
|
|
|
2
2
|
import { CliClient } from './classes.cliclient.js';
|
|
3
3
|
import { parseCliCommand, type TCliCommand } from './commands.js';
|
|
4
4
|
import { readBootstrapTokenInput } 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,
|
|
9
18
|
readBootstrapTokenInput,
|
|
@@ -24,6 +33,10 @@ export const runCli = async () => {
|
|
|
24
33
|
command = parsed.kind === 'gateway-bootstrap'
|
|
25
34
|
? { ...parsed, bootstrapToken: await readBootstrapTokenInput(process.stdin) }
|
|
26
35
|
: parsed;
|
|
36
|
+
// An output the bearer could not be written to fetches no bearer and records no export.
|
|
37
|
+
if (command.kind === 'relay-export-authorization') {
|
|
38
|
+
await requirePrivateOutputDirectory(command.outputPath);
|
|
39
|
+
}
|
|
27
40
|
} catch (error) {
|
|
28
41
|
console.error((error as Error).message);
|
|
29
42
|
process.exitCode = 1;
|
|
@@ -41,11 +54,13 @@ export const runCli = async () => {
|
|
|
41
54
|
try {
|
|
42
55
|
if (command.kind === 'gateway-bootstrap') {
|
|
43
56
|
await cliClient.bootstrapGateway(command.target, command.bootstrapToken);
|
|
57
|
+
} else if (command.kind === 'relay-export-authorization') {
|
|
58
|
+
await cliClient.exportRelayAuthorization(command.request, command.outputPath);
|
|
44
59
|
} else {
|
|
45
60
|
await cliClient.getGatewayStatus();
|
|
46
61
|
}
|
|
47
62
|
} catch (error) {
|
|
48
|
-
// Cloudly answers a refusal by name; it never carries
|
|
63
|
+
// Cloudly answers a refusal by name; it never carries a token or a bearer.
|
|
49
64
|
console.error((error as Error).message);
|
|
50
65
|
process.exitCode = 1;
|
|
51
66
|
} finally {
|
package/ts_cliclient/plugins.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
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';
|
|
@@ -10,8 +21,10 @@ export {
|
|
|
10
21
|
// @push.rocks scope
|
|
11
22
|
import * as projectinfo from '@push.rocks/projectinfo';
|
|
12
23
|
import * as qenv from '@push.rocks/qenv';
|
|
24
|
+
import * as smartfs from '@push.rocks/smartfs';
|
|
13
25
|
|
|
14
26
|
export {
|
|
15
27
|
projectinfo,
|
|
16
28
|
qenv,
|
|
29
|
+
smartfs,
|
|
17
30
|
}
|
package/ts_cliclient/readme.md
CHANGED
|
@@ -16,6 +16,7 @@ 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.
|
|
19
20
|
|
|
20
21
|
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
22
|
|
|
@@ -73,6 +74,23 @@ servezone gateway status
|
|
|
73
74
|
|
|
74
75
|
Revoke the admin token at dcrouter once `gateway status` reads `ready`.
|
|
75
76
|
|
|
77
|
+
## Relay Bearer Export
|
|
78
|
+
|
|
79
|
+
`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.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
install -d -m 0700 /root/relay-export
|
|
83
|
+
CLOUDLY_URL=https://cloudly.example.com \
|
|
84
|
+
CLOUDLY_USERNAME=admin CLOUDLY_PASSWORD=change-me \
|
|
85
|
+
servezone relay export-authorization --cluster-id <clusterId> --expected-generation <n> \
|
|
86
|
+
--output /root/relay-export/relay-authorization
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `--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.
|
|
90
|
+
- `--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.
|
|
91
|
+
- 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.
|
|
92
|
+
- 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.
|
|
93
|
+
|
|
76
94
|
## Programmatic Use
|
|
77
95
|
|
|
78
96
|
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 +116,10 @@ await cli.getClusters();
|
|
|
98
116
|
| Path | Purpose |
|
|
99
117
|
| --- | --- |
|
|
100
118
|
| `index.ts` | Runtime entry point for the published CLI. |
|
|
101
|
-
| `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()` and `
|
|
119
|
+
| `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()`, `getGatewayStatus()` and `exportRelayAuthorization()`. |
|
|
102
120
|
| `commands.ts` | Parses the argument vector into one command, refusing anything else as usage. |
|
|
103
121
|
| `tokeninput.ts` | Reads the dcrouter admin token from stdin. |
|
|
122
|
+
| `relayauthorization.ts` | Checks the private output directory and writes a cluster relay's bearer as a new 0600 file. |
|
|
104
123
|
| `plugins.ts` | Centralized imports for the submodule. |
|
|
105
124
|
| `tspublish.json` | Published package name, dependencies, `servezone` bin metadata, and `useBase`, which takes the registries from the release's npm target. |
|
|
106
125
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
/** Why the relay bearer was not written, named without quoting the bearer. */
|
|
4
|
+
export type TRelayAuthorizationOutputRefusal =
|
|
5
|
+
/** The output's directory does not exist, or a component of its path is not a directory. */
|
|
6
|
+
| 'output-directory-missing'
|
|
7
|
+
/**
|
|
8
|
+
* The output's directory is not a private directory of the caller: it is reached through a
|
|
9
|
+
* symbolic link, owned by another user, or writable by its group or by other users.
|
|
10
|
+
*/
|
|
11
|
+
| 'output-directory-not-private'
|
|
12
|
+
/** Something already exists at the output path; it is left untouched. */
|
|
13
|
+
| 'output-exists';
|
|
14
|
+
|
|
15
|
+
export class RelayAuthorizationOutputError extends Error {
|
|
16
|
+
constructor(public readonly refusal: TRelayAuthorizationOutputRefusal) {
|
|
17
|
+
super(`The relay bearer was not written: ${refusal}.`);
|
|
18
|
+
this.name = 'RelayAuthorizationOutputError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the CLI prints about the file it wrote: metadata only, never a byte of the bearer. */
|
|
23
|
+
export interface IRelayAuthorizationOutput {
|
|
24
|
+
clusterId: string;
|
|
25
|
+
generation: number;
|
|
26
|
+
outputPath: string;
|
|
27
|
+
byteLength: number;
|
|
28
|
+
/** Hex SHA-256 of the file's bytes, as `sha256sum` prints it for the same file. */
|
|
29
|
+
sha256: string;
|
|
30
|
+
/** The audit receipt Cloudly recorded for this export. */
|
|
31
|
+
receiptId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const errorCodeOf = (errorArg: unknown): string | undefined =>
|
|
35
|
+
errorArg instanceof Error && 'code' in errorArg && typeof errorArg.code === 'string'
|
|
36
|
+
? errorArg.code : undefined;
|
|
37
|
+
|
|
38
|
+
/** A directory that is not there, or not reachable as one, is the operator's to create. */
|
|
39
|
+
const refuseMissingDirectory = (errorArg: unknown): never => {
|
|
40
|
+
const code = errorCodeOf(errorArg);
|
|
41
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
42
|
+
throw new RelayAuthorizationOutputError('output-directory-missing');
|
|
43
|
+
}
|
|
44
|
+
throw errorArg;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Refuses an output whose directory is not a private directory of the caller, or that exists.
|
|
49
|
+
*
|
|
50
|
+
* Exclusive creation guards only the file's own name, so the directory is checked here: its path
|
|
51
|
+
* resolves to itself (no symbolic link anywhere on it), it is a directory owned by the caller,
|
|
52
|
+
* and neither its group nor other users can write it. Checked before Cloudly is asked, so a bad
|
|
53
|
+
* output fetches no bearer and writes no audit receipt.
|
|
54
|
+
*/
|
|
55
|
+
export const requirePrivateOutputDirectory = async (outputPathArg: string): Promise<void> => {
|
|
56
|
+
const directory = plugins.path.dirname(outputPathArg);
|
|
57
|
+
const stats = await plugins.fs.lstat(directory).catch(refuseMissingDirectory);
|
|
58
|
+
const resolved = await plugins.fs.realpath(directory).catch(refuseMissingDirectory);
|
|
59
|
+
// A symbolic link anywhere on the path is a directory someone else could redirect.
|
|
60
|
+
if (resolved !== directory) throw new RelayAuthorizationOutputError('output-directory-not-private');
|
|
61
|
+
if (!stats.isDirectory()) throw new RelayAuthorizationOutputError('output-directory-missing');
|
|
62
|
+
if (stats.uid !== process.getuid?.() || (stats.mode & 0o022) !== 0) {
|
|
63
|
+
throw new RelayAuthorizationOutputError('output-directory-not-private');
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await plugins.fs.lstat(outputPathArg);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (errorCodeOf(error) === 'ENOENT') return;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
throw new RelayAuthorizationOutputError('output-exists');
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Writes the bearer as a new file, mode 0600 whatever the umask, whole or not at all.
|
|
76
|
+
*
|
|
77
|
+
* The write is exclusive and atomic: an existing entry at the path is never replaced or
|
|
78
|
+
* followed, and the bytes go to a private temporary file that is linked into place.
|
|
79
|
+
*/
|
|
80
|
+
export const writeRelayAuthorization = async (
|
|
81
|
+
outputPathArg: string,
|
|
82
|
+
bearerArg: Uint8Array,
|
|
83
|
+
): Promise<void> => {
|
|
84
|
+
await requirePrivateOutputDirectory(outputPathArg);
|
|
85
|
+
const smartFs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
|
|
86
|
+
const bytes = Buffer.from(bearerArg);
|
|
87
|
+
try {
|
|
88
|
+
await smartFs.file(outputPathArg).mode(0o600).exclusive().atomic().write(bytes);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof plugins.smartfs.SmartFsExclusiveWriteError) {
|
|
91
|
+
throw new RelayAuthorizationOutputError(
|
|
92
|
+
error.code === 'EEXIST' ? 'output-exists' : 'output-directory-missing');
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
} finally {
|
|
96
|
+
bytes.fill(0);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fetches one cluster relay's bearer through the API client and writes it to the operator's
|
|
102
|
+
* file, answering only what describes the file.
|
|
103
|
+
*/
|
|
104
|
+
export const exportRelayAuthorizationToFile = async (
|
|
105
|
+
apiClientArg: plugins.servezoneApi.CloudlyApiClient,
|
|
106
|
+
requestArg: plugins.servezoneInterfaces.data.IClusterRelayAuthorizationExportRequest,
|
|
107
|
+
outputPathArg: string,
|
|
108
|
+
): Promise<IRelayAuthorizationOutput> => {
|
|
109
|
+
await requirePrivateOutputDirectory(outputPathArg);
|
|
110
|
+
const exported = await apiClientArg.cluster.exportClusterRelayAuthorization(requestArg);
|
|
111
|
+
const bearer = new TextEncoder().encode(exported.authorization);
|
|
112
|
+
try {
|
|
113
|
+
await writeRelayAuthorization(outputPathArg, bearer);
|
|
114
|
+
return {
|
|
115
|
+
clusterId: exported.credential.clusterId,
|
|
116
|
+
generation: exported.credential.generation,
|
|
117
|
+
outputPath: outputPathArg,
|
|
118
|
+
byteLength: bearer.byteLength,
|
|
119
|
+
sha256: plugins.crypto.createHash('sha256').update(bearer).digest('hex'),
|
|
120
|
+
receiptId: exported.receipt.id,
|
|
121
|
+
};
|
|
122
|
+
} finally {
|
|
123
|
+
bearer.fill(0);
|
|
124
|
+
}
|
|
125
|
+
};
|