@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.
- package/dist_ts_cliclient/classes.cliclient.d.ts +12 -0
- package/dist_ts_cliclient/classes.cliclient.js +23 -1
- package/dist_ts_cliclient/commands.d.ts +12 -1
- package/dist_ts_cliclient/commands.js +56 -1
- package/dist_ts_cliclient/corestorecontroltoken.d.ts +33 -0
- package/dist_ts_cliclient/corestorecontroltoken.js +48 -0
- package/dist_ts_cliclient/index.d.ts +3 -1
- package/dist_ts_cliclient/index.js +33 -8
- package/dist_ts_cliclient/plugins.d.ts +8 -2
- package/dist_ts_cliclient/plugins.js +10 -3
- package/dist_ts_cliclient/relayauthorization.d.ts +48 -0
- package/dist_ts_cliclient/relayauthorization.js +95 -0
- package/dist_ts_cliclient/tokeninput.d.ts +32 -15
- package/dist_ts_cliclient/tokeninput.js +61 -15
- package/package.json +4 -3
- package/readme.md +43 -2
- package/ts_cliclient/classes.cliclient.ts +32 -0
- package/ts_cliclient/commands.ts +69 -2
- package/ts_cliclient/corestorecontroltoken.ts +71 -0
- package/ts_cliclient/index.ts +53 -9
- package/ts_cliclient/plugins.ts +16 -1
- package/ts_cliclient/readme.md +43 -2
- package/ts_cliclient/relayauthorization.ts +125 -0
- package/ts_cliclient/tokeninput.ts +81 -18
|
@@ -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
|
+
};
|
|
@@ -1,39 +1,64 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
|
|
3
|
-
/** Why
|
|
4
|
-
export type
|
|
3
|
+
/** Why a token piped in was not taken, named without quoting any of it. */
|
|
4
|
+
export type TTokenInputRefusal =
|
|
5
5
|
/** stdin is a terminal, which would echo a token typed into it; pipe the token in instead. */
|
|
6
6
|
| 'token-input-terminal'
|
|
7
7
|
/** stdin carried more bytes than a token and one line break. */
|
|
8
8
|
| 'token-input-oversized'
|
|
9
|
-
/** What stdin carried is not a token: empty, or a byte
|
|
9
|
+
/** What stdin carried is not a token by the token's own rule: empty, or a byte the rule refuses. */
|
|
10
10
|
| 'token-input-invalid';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/** Why the dcrouter admin token piped into `gateway bootstrap` was not taken. */
|
|
13
|
+
export type TBootstrapTokenInputRefusal = TTokenInputRefusal;
|
|
14
|
+
|
|
15
|
+
/** A refused token input: which token, and why, never a byte of what was read. */
|
|
16
|
+
export class TokenInputError extends Error {
|
|
17
|
+
constructor(public readonly refusal: TTokenInputRefusal, subjectArg: string) {
|
|
18
|
+
super(`${subjectArg} on stdin was refused: ${refusal}.`);
|
|
19
|
+
this.name = 'TokenInputError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class BootstrapTokenInputError extends TokenInputError {
|
|
24
|
+
constructor(refusalArg: TTokenInputRefusal) {
|
|
25
|
+
super(refusalArg, 'The dcrouter admin token');
|
|
15
26
|
this.name = 'BootstrapTokenInputError';
|
|
16
27
|
}
|
|
17
28
|
}
|
|
18
29
|
|
|
19
|
-
|
|
20
|
-
|
|
30
|
+
export class CorestoreControlTokenInputError extends TokenInputError {
|
|
31
|
+
constructor(refusalArg: TTokenInputRefusal) {
|
|
32
|
+
super(refusalArg, 'The Corestore control token');
|
|
33
|
+
this.name = 'CorestoreControlTokenInputError';
|
|
34
|
+
}
|
|
21
35
|
}
|
|
22
36
|
|
|
23
|
-
/** What
|
|
24
|
-
export type
|
|
37
|
+
/** What a token is read from: the command's stdin, or a stream a test hands in. */
|
|
38
|
+
export type TTokenInput = AsyncIterable<Uint8Array> & { isTTY?: boolean };
|
|
39
|
+
export type TBootstrapTokenInput = TTokenInput;
|
|
40
|
+
|
|
41
|
+
/** One token's rule: its contract's bound and check, and the error its refusals are named with. */
|
|
42
|
+
interface ITokenInputRule {
|
|
43
|
+
/** The most bytes the token itself may have; one line break more is read and stripped. */
|
|
44
|
+
maximumTokenBytes: number;
|
|
45
|
+
/** The contract's check of the token's bytes, line break stripped. */
|
|
46
|
+
isToken: (tokenArg: Uint8Array) => boolean;
|
|
47
|
+
refuse: (refusalArg: TTokenInputRefusal) => TokenInputError;
|
|
48
|
+
}
|
|
25
49
|
|
|
26
50
|
/**
|
|
27
|
-
*
|
|
51
|
+
* A token piped into a command, as bytes the caller wipes.
|
|
28
52
|
*
|
|
29
53
|
* stdin is the only source: an argument would sit in the process list and the shell history, and
|
|
30
54
|
* the environment leaks into child processes. One trailing line break, `\n` or `\r\n`, is what
|
|
31
|
-
* `echo` or a file adds and is not part of the token; everything else must pass the
|
|
32
|
-
*
|
|
55
|
+
* `echo` or a file adds and is not part of the token; everything else must pass the token's rule.
|
|
56
|
+
* Reading stops as soon as stdin carries more than the rule's bound and one line break. Every byte
|
|
57
|
+
* read is wiped before this answers, whatever it answers.
|
|
33
58
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const maximumBytes =
|
|
59
|
+
const readTokenInput = async (inputArg: TTokenInput, ruleArg: ITokenInputRule): Promise<Uint8Array> => {
|
|
60
|
+
if (inputArg.isTTY === true) throw ruleArg.refuse('token-input-terminal');
|
|
61
|
+
const maximumBytes = ruleArg.maximumTokenBytes + 2;
|
|
37
62
|
const chunks: Uint8Array[] = [];
|
|
38
63
|
let input: Uint8Array | undefined;
|
|
39
64
|
try {
|
|
@@ -41,7 +66,7 @@ export const readBootstrapTokenInput = async (inputArg: TBootstrapTokenInput): P
|
|
|
41
66
|
for await (const chunk of inputArg) {
|
|
42
67
|
chunks.push(chunk);
|
|
43
68
|
byteLength += chunk.byteLength;
|
|
44
|
-
|
|
69
|
+
if (byteLength > maximumBytes) throw ruleArg.refuse('token-input-oversized');
|
|
45
70
|
}
|
|
46
71
|
input = Buffer.concat(chunks, byteLength);
|
|
47
72
|
let end = input.byteLength;
|
|
@@ -50,7 +75,7 @@ export const readBootstrapTokenInput = async (inputArg: TBootstrapTokenInput): P
|
|
|
50
75
|
if (end > 0 && input[end - 1] === 0x0d) end -= 1;
|
|
51
76
|
}
|
|
52
77
|
const token = input.subarray(0, end);
|
|
53
|
-
|
|
78
|
+
if (!ruleArg.isToken(token)) throw ruleArg.refuse('token-input-invalid');
|
|
54
79
|
// A copy, because the input and every view of it are wiped below.
|
|
55
80
|
return new Uint8Array(token);
|
|
56
81
|
} finally {
|
|
@@ -58,3 +83,41 @@ export const readBootstrapTokenInput = async (inputArg: TBootstrapTokenInput): P
|
|
|
58
83
|
for (const chunk of chunks) chunk.fill(0);
|
|
59
84
|
}
|
|
60
85
|
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The dcrouter admin token piped into `gateway bootstrap`: at most the bootstrap contract's
|
|
89
|
+
* `maximumTokenBytes` of visible ASCII.
|
|
90
|
+
*/
|
|
91
|
+
export const readBootstrapTokenInput = async (inputArg: TTokenInput): Promise<Uint8Array> =>
|
|
92
|
+
await readTokenInput(inputArg, {
|
|
93
|
+
maximumTokenBytes: plugins.servezoneInterfaces.data.externalGatewayBootstrapContract.maximumTokenBytes,
|
|
94
|
+
isToken: (tokenArg) => plugins.servezoneInterfaces.data.isExternalGatewayBootstrapToken(tokenArg),
|
|
95
|
+
refuse: (refusalArg) => new BootstrapTokenInputError(refusalArg),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The token as text, exactly as its bytes say: invalid UTF-8 throws, and a leading byte order mark
|
|
100
|
+
* is kept, so the contract judges and Cloudly receives every character that was piped in.
|
|
101
|
+
*/
|
|
102
|
+
export const decodeCorestoreControlToken = (tokenArg: Uint8Array): string =>
|
|
103
|
+
new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(tokenArg);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The Corestore control token piped into `corestore set-control-token`: what the contract's
|
|
107
|
+
* `isCorestoreControlToken` accepts, so at most `corestoreCredentialRuntimeLimits
|
|
108
|
+
* .maximumControlTokenBytes` of UTF-8 without whitespace.
|
|
109
|
+
*/
|
|
110
|
+
export const readCorestoreControlTokenInput = async (inputArg: TTokenInput): Promise<Uint8Array> =>
|
|
111
|
+
await readTokenInput(inputArg, {
|
|
112
|
+
maximumTokenBytes: plugins.servezoneInterfacesRuntime.corestoreCredentialRuntimeLimits.maximumControlTokenBytes,
|
|
113
|
+
isToken: (tokenArg) => {
|
|
114
|
+
let token: string;
|
|
115
|
+
try {
|
|
116
|
+
token = decodeCorestoreControlToken(tokenArg);
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return plugins.servezoneInterfacesRuntime.isCorestoreControlToken(token);
|
|
121
|
+
},
|
|
122
|
+
refuse: (refusalArg) => new CorestoreControlTokenInputError(refusalArg),
|
|
123
|
+
});
|