eoas 3.0.5 → 3.1.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/README.md +5 -5
- package/bin/dev.js +3 -0
- package/bin/run.js +3 -0
- package/dist/commands/generate-certs.js +22 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/publish.js +55 -31
- package/dist/commands/republish.js +87 -27
- package/dist/commands/server/init.d.ts +17 -0
- package/dist/commands/server/init.js +654 -0
- package/dist/commands/server/validate.d.ts +10 -0
- package/dist/commands/server/validate.js +115 -0
- package/dist/lib/assets.d.ts +30 -8
- package/dist/lib/assets.js +158 -4
- package/dist/lib/auth.js +2 -1
- package/dist/lib/log.d.ts +8 -2
- package/dist/lib/log.js +47 -31
- package/dist/lib/ora.d.ts +10 -9
- package/dist/lib/ora.js +49 -88
- package/dist/lib/packageRunner.js +2 -1
- package/dist/lib/prompts.d.ts +32 -0
- package/dist/lib/prompts.js +86 -1
- package/dist/lib/serverConfig/choices.d.ts +62 -0
- package/dist/lib/serverConfig/choices.js +65 -0
- package/dist/lib/serverConfig/envCatalog.d.ts +41 -0
- package/dist/lib/serverConfig/envCatalog.js +582 -0
- package/dist/lib/serverConfig/helmValues.d.ts +22 -0
- package/dist/lib/serverConfig/helmValues.js +281 -0
- package/dist/lib/serverConfig/passwordPolicy.d.ts +3 -0
- package/dist/lib/serverConfig/passwordPolicy.js +36 -0
- package/dist/lib/serverUpdates.d.ts +45 -0
- package/dist/lib/serverUpdates.js +108 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +18 -14
- package/package.json +8 -6
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readExistingMasterKey = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const core_1 = require("@oclif/core");
|
|
6
|
+
const chalk_1 = tslib_1.__importDefault(require("chalk"));
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
8
|
+
const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
|
|
9
|
+
const path_1 = tslib_1.__importDefault(require("path"));
|
|
10
|
+
const log_1 = tslib_1.__importDefault(require("../../lib/log"));
|
|
11
|
+
const prompts_1 = require("../../lib/prompts");
|
|
12
|
+
const choices_1 = require("../../lib/serverConfig/choices");
|
|
13
|
+
const envCatalog_1 = require("../../lib/serverConfig/envCatalog");
|
|
14
|
+
const helmValues_1 = require("../../lib/serverConfig/helmValues");
|
|
15
|
+
const passwordPolicy_1 = require("../../lib/serverConfig/passwordPolicy");
|
|
16
|
+
const utils_1 = require("../../lib/utils");
|
|
17
|
+
const DOCKER_IMAGE = 'ghcr.io/mercuretechnologies/xprem:latest';
|
|
18
|
+
const HELM_CHART = 'oci://ghcr.io/mercuretechnologies/charts/xprem';
|
|
19
|
+
const ACCENT = chalk_1.default.hex('#818cf8');
|
|
20
|
+
const BADGE = chalk_1.default.bgHex('#4f46e5').white.bold;
|
|
21
|
+
const ENV_FILE_NAME = '.env.xprem';
|
|
22
|
+
const HELM_OUT_DIR = 'xprem-helm';
|
|
23
|
+
const MASTER_KEY_VAR = 'DB_KEYS_MASTER_KEY_B64';
|
|
24
|
+
const SECRET_FILE_MODE = 0o600;
|
|
25
|
+
const SECRET_FILE_REASON = 'Generated by eoas server:init, holds server secrets';
|
|
26
|
+
function generateSecret() {
|
|
27
|
+
return (0, crypto_1.randomBytes)(32).toString('base64');
|
|
28
|
+
}
|
|
29
|
+
// writeFile only applies mode when it creates the file, so an existing one
|
|
30
|
+
// would keep whatever permissions it already had.
|
|
31
|
+
async function writeSecretFile(filePath, content) {
|
|
32
|
+
await fs_extra_1.default.remove(filePath);
|
|
33
|
+
await fs_extra_1.default.writeFile(filePath, content, { mode: SECRET_FILE_MODE });
|
|
34
|
+
}
|
|
35
|
+
// The master key seals the OTA signing keys and the OIDC client secret already
|
|
36
|
+
// in the database, so a re-run reuses it instead of minting a new one.
|
|
37
|
+
async function readExistingMasterKey(deployment) {
|
|
38
|
+
const filePath = deployment === 'helm'
|
|
39
|
+
? path_1.default.resolve(process.cwd(), HELM_OUT_DIR, helmValues_1.HELM_SECRETS_FILE)
|
|
40
|
+
: path_1.default.resolve(process.cwd(), ENV_FILE_NAME);
|
|
41
|
+
if (!(await fs_extra_1.default.pathExists(filePath))) {
|
|
42
|
+
return { unreadable: false };
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const content = await fs_extra_1.default.readFile(filePath, 'utf8');
|
|
46
|
+
const env = deployment === 'helm' ? (0, helmValues_1.extractSecretEnv)((0, helmValues_1.parseYamlFile)(content)) : (0, envCatalog_1.parseEnvFile)(content);
|
|
47
|
+
const key = env?.[MASTER_KEY_VAR];
|
|
48
|
+
if (!key || (0, envCatalog_1.isPlaceholder)(key)) {
|
|
49
|
+
return { unreadable: false };
|
|
50
|
+
}
|
|
51
|
+
return { key, unreadable: false };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { unreadable: true };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.readExistingMasterKey = readExistingMasterKey;
|
|
58
|
+
function isHttpUrl(value) {
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(value);
|
|
61
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function shortPasswordHint(missing) {
|
|
68
|
+
const short = missing.map(rule => rule
|
|
69
|
+
.replace('at least 8 characters', '8+ chars')
|
|
70
|
+
.replace('an uppercase letter', 'uppercase')
|
|
71
|
+
.replace('a lowercase letter', 'lowercase')
|
|
72
|
+
.replace('a digit', 'digit')
|
|
73
|
+
.replace('a special character', 'special char'));
|
|
74
|
+
return `Missing: ${short.join(', ')}`;
|
|
75
|
+
}
|
|
76
|
+
const AWS_AUTH_CHOICES = [
|
|
77
|
+
{
|
|
78
|
+
title: 'IAM role',
|
|
79
|
+
value: 'iam-role',
|
|
80
|
+
description: 'role on the runtime, no keys in the env',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
title: 'Access key pair in the env',
|
|
84
|
+
value: 'access-keys',
|
|
85
|
+
description: 'AWS_ACCESS_KEY_ID + SECRET in the env',
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
const STORAGE_CHOICES = [
|
|
89
|
+
{ title: 'AWS S3', value: { storage: 'aws-s3' } },
|
|
90
|
+
...Object.keys(choices_1.S3_PROVIDER_DEFAULTS).map(provider => ({
|
|
91
|
+
title: choices_1.S3_PROVIDER_DEFAULTS[provider].label,
|
|
92
|
+
value: { storage: 's3-compatible', provider },
|
|
93
|
+
description: 'S3-compatible',
|
|
94
|
+
})),
|
|
95
|
+
{ title: 'Google Cloud Storage', value: { storage: 'gcs' } },
|
|
96
|
+
{ title: 'Azure Blob Storage', value: { storage: 'azure' } },
|
|
97
|
+
];
|
|
98
|
+
const DELIVERY_CHOICES = {
|
|
99
|
+
cloudfront: {
|
|
100
|
+
title: 'CloudFront',
|
|
101
|
+
description: 'signed URLs, the bucket stays private',
|
|
102
|
+
},
|
|
103
|
+
presigned: {
|
|
104
|
+
title: 'Pre-signed storage URLs',
|
|
105
|
+
description: 'signed URLs straight to the private bucket',
|
|
106
|
+
},
|
|
107
|
+
'through-server': {
|
|
108
|
+
title: 'Through the server',
|
|
109
|
+
description: 'the server streams assets, costs bandwidth',
|
|
110
|
+
},
|
|
111
|
+
'generic-cdn': {
|
|
112
|
+
title: 'Generic CDN',
|
|
113
|
+
description: 'a CDN in front of a public bucket',
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
function summaryLines(state) {
|
|
117
|
+
const storageChoice = STORAGE_CHOICES.find(choice => choice.value === state.storagePick);
|
|
118
|
+
const set = ACCENT;
|
|
119
|
+
const later = chalk_1.default.dim('to fill later');
|
|
120
|
+
const rows = [
|
|
121
|
+
['Base URL', state.baseUrl ? set(state.baseUrl) : later],
|
|
122
|
+
['JWT secret', state.jwtSecret ? set('generated') : later],
|
|
123
|
+
['PostgreSQL', state.dbUrl ? set(state.dbUrl) : later],
|
|
124
|
+
['Master key', set('generated, keep a backup')],
|
|
125
|
+
[
|
|
126
|
+
'Storage',
|
|
127
|
+
set(storageChoice?.title ?? 'AWS S3') +
|
|
128
|
+
(state.awsAuth
|
|
129
|
+
? chalk_1.default.dim(` · ${state.awsAuth === 'iam-role' ? 'IAM role' : 'access keys'}`)
|
|
130
|
+
: ''),
|
|
131
|
+
],
|
|
132
|
+
['Delivery', set(DELIVERY_CHOICES[state.delivery ?? 'presigned'].title)],
|
|
133
|
+
['Replicas', set(state.multiReplica ? 'multiple' : 'single')],
|
|
134
|
+
['Cache', set(state.cacheMode ?? 'local')],
|
|
135
|
+
['Dashboard admin', state.adminEmail ? set(state.adminEmail) : later],
|
|
136
|
+
['Observe', state.observe ? set('on') : chalk_1.default.dim('off')],
|
|
137
|
+
[
|
|
138
|
+
'Geolocation',
|
|
139
|
+
state.geoip
|
|
140
|
+
? set(state.geoipStrategy === 'maxmind' ? 'MaxMind GeoLite2' : 'proxy headers')
|
|
141
|
+
: chalk_1.default.dim('off'),
|
|
142
|
+
],
|
|
143
|
+
[
|
|
144
|
+
'Deployment',
|
|
145
|
+
set(state.deployment === 'helm'
|
|
146
|
+
? 'Helm (values.yaml + secrets.yaml)'
|
|
147
|
+
: state.deployment === 'binary'
|
|
148
|
+
? 'Binary (.env.xprem)'
|
|
149
|
+
: 'Docker (.env.xprem)'),
|
|
150
|
+
],
|
|
151
|
+
];
|
|
152
|
+
return rows.map(([label, value]) => `${label.padEnd(16)}${value}`).join('\n');
|
|
153
|
+
}
|
|
154
|
+
class ServerInit extends core_1.Command {
|
|
155
|
+
static args = {};
|
|
156
|
+
static description = 'Interactive wizard that generates a pre-filled server configuration: .env.xprem for Docker or a binary, xprem-helm/ (values.yaml + secrets.yaml) for Helm';
|
|
157
|
+
static examples = ['<%= config.bin %> <%= command.id %>'];
|
|
158
|
+
static flags = {};
|
|
159
|
+
async run() {
|
|
160
|
+
log_1.default.intro(`${BADGE(' xprem ')} ${chalk_1.default.bold('server setup')} ${chalk_1.default.dim('· control plane')}`);
|
|
161
|
+
log_1.default.gray('Answers shape the generated config; skipped secrets stay as <placeholders>.');
|
|
162
|
+
log_1.default.gray(`Pick "← Back" in a list, or type "${prompts_1.BACK_INPUT}" in a text answer, to go back one step.`);
|
|
163
|
+
const state = {};
|
|
164
|
+
const steps = [
|
|
165
|
+
{
|
|
166
|
+
id: 'base-url',
|
|
167
|
+
run: async () => {
|
|
168
|
+
const value = await (0, prompts_1.textStep)('Public HTTPS URL of the server', {
|
|
169
|
+
optional: true,
|
|
170
|
+
initial: state.baseUrl,
|
|
171
|
+
validate: v => isHttpUrl(v) || 'Must be a valid http(s) URL',
|
|
172
|
+
});
|
|
173
|
+
if (value === prompts_1.BACK) {
|
|
174
|
+
return 'back';
|
|
175
|
+
}
|
|
176
|
+
state.baseUrl = value;
|
|
177
|
+
return 'next';
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'jwt',
|
|
182
|
+
run: async (allowBack) => {
|
|
183
|
+
while (true) {
|
|
184
|
+
const mode = await (0, prompts_1.selectStep)('JWT secret (signs sessions and upload tokens)', [
|
|
185
|
+
{ title: 'Generate one now', value: 'generate' },
|
|
186
|
+
{ title: 'Paste my own', value: 'provide' },
|
|
187
|
+
{ title: 'Set it later', value: 'later' },
|
|
188
|
+
], { allowBack });
|
|
189
|
+
if (mode === prompts_1.BACK) {
|
|
190
|
+
return 'back';
|
|
191
|
+
}
|
|
192
|
+
if (mode === 'generate') {
|
|
193
|
+
state.jwtSecret = generateSecret();
|
|
194
|
+
log_1.default.succeed('JWT secret generated.');
|
|
195
|
+
}
|
|
196
|
+
else if (mode === 'provide') {
|
|
197
|
+
const value = await (0, prompts_1.textStep)('JWT secret', { secret: true, allowBack: true });
|
|
198
|
+
if (value === prompts_1.BACK) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
state.jwtSecret = value;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
state.jwtSecret = undefined;
|
|
205
|
+
}
|
|
206
|
+
return 'next';
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'db-url',
|
|
212
|
+
run: async (allowBack) => {
|
|
213
|
+
const value = await (0, prompts_1.textStep)('PostgreSQL connection string', {
|
|
214
|
+
optional: true,
|
|
215
|
+
allowBack,
|
|
216
|
+
initial: state.dbUrl,
|
|
217
|
+
});
|
|
218
|
+
if (value === prompts_1.BACK) {
|
|
219
|
+
return 'back';
|
|
220
|
+
}
|
|
221
|
+
state.dbUrl = value;
|
|
222
|
+
return 'next';
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'storage',
|
|
227
|
+
run: async (allowBack) => {
|
|
228
|
+
const pick = await (0, prompts_1.selectStep)('Where are the published updates stored?', STORAGE_CHOICES, {
|
|
229
|
+
allowBack,
|
|
230
|
+
initial: state.storagePick,
|
|
231
|
+
});
|
|
232
|
+
if (pick === prompts_1.BACK) {
|
|
233
|
+
return 'back';
|
|
234
|
+
}
|
|
235
|
+
state.storagePick = pick;
|
|
236
|
+
return 'next';
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'storage-details',
|
|
241
|
+
run: async () => {
|
|
242
|
+
const pick = state.storagePick ?? { storage: 'aws-s3' };
|
|
243
|
+
if (pick.storage === 'aws-s3') {
|
|
244
|
+
const auth = await (0, prompts_1.selectStep)('How does the server authenticate against S3?', AWS_AUTH_CHOICES, { allowBack: true, initial: state.awsAuth });
|
|
245
|
+
if (auth === prompts_1.BACK) {
|
|
246
|
+
return 'back';
|
|
247
|
+
}
|
|
248
|
+
state.awsAuth = auth;
|
|
249
|
+
const bucket = await (0, prompts_1.textStep)('S3 bucket name', {
|
|
250
|
+
optional: true,
|
|
251
|
+
allowBack: true,
|
|
252
|
+
initial: state.s3BucketName,
|
|
253
|
+
});
|
|
254
|
+
if (bucket === prompts_1.BACK) {
|
|
255
|
+
return 'back';
|
|
256
|
+
}
|
|
257
|
+
state.s3BucketName = bucket;
|
|
258
|
+
const region = await (0, prompts_1.textStep)('AWS region', {
|
|
259
|
+
optional: true,
|
|
260
|
+
allowBack: true,
|
|
261
|
+
initial: state.awsRegion ?? 'eu-west-1',
|
|
262
|
+
});
|
|
263
|
+
if (region === prompts_1.BACK) {
|
|
264
|
+
return 'back';
|
|
265
|
+
}
|
|
266
|
+
state.awsRegion = region;
|
|
267
|
+
}
|
|
268
|
+
else if (pick.storage === 's3-compatible' && pick.provider) {
|
|
269
|
+
const defaults = choices_1.S3_PROVIDER_DEFAULTS[pick.provider];
|
|
270
|
+
const endpoint = await (0, prompts_1.textStep)("Provider's S3 endpoint", {
|
|
271
|
+
optional: true,
|
|
272
|
+
allowBack: true,
|
|
273
|
+
initial: state.awsBaseEndpoint ?? defaults.endpoint,
|
|
274
|
+
});
|
|
275
|
+
if (endpoint === prompts_1.BACK) {
|
|
276
|
+
return 'back';
|
|
277
|
+
}
|
|
278
|
+
state.awsBaseEndpoint = endpoint;
|
|
279
|
+
const region = await (0, prompts_1.textStep)('Region', {
|
|
280
|
+
optional: true,
|
|
281
|
+
allowBack: true,
|
|
282
|
+
initial: state.awsRegion ?? defaults.region,
|
|
283
|
+
});
|
|
284
|
+
if (region === prompts_1.BACK) {
|
|
285
|
+
return 'back';
|
|
286
|
+
}
|
|
287
|
+
state.awsRegion = region;
|
|
288
|
+
const bucket = await (0, prompts_1.textStep)('Bucket name', {
|
|
289
|
+
optional: true,
|
|
290
|
+
allowBack: true,
|
|
291
|
+
initial: state.s3BucketName,
|
|
292
|
+
});
|
|
293
|
+
if (bucket === prompts_1.BACK) {
|
|
294
|
+
return 'back';
|
|
295
|
+
}
|
|
296
|
+
state.s3BucketName = bucket;
|
|
297
|
+
}
|
|
298
|
+
else if (pick.storage === 'gcs') {
|
|
299
|
+
const bucket = await (0, prompts_1.textStep)('GCS bucket name', {
|
|
300
|
+
optional: true,
|
|
301
|
+
allowBack: true,
|
|
302
|
+
initial: state.gcsBucketName,
|
|
303
|
+
});
|
|
304
|
+
if (bucket === prompts_1.BACK) {
|
|
305
|
+
return 'back';
|
|
306
|
+
}
|
|
307
|
+
state.gcsBucketName = bucket;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const container = await (0, prompts_1.textStep)('Blob container name', {
|
|
311
|
+
optional: true,
|
|
312
|
+
allowBack: true,
|
|
313
|
+
initial: state.azureContainerName,
|
|
314
|
+
});
|
|
315
|
+
if (container === prompts_1.BACK) {
|
|
316
|
+
return 'back';
|
|
317
|
+
}
|
|
318
|
+
state.azureContainerName = container;
|
|
319
|
+
const account = await (0, prompts_1.textStep)('Storage account name', {
|
|
320
|
+
optional: true,
|
|
321
|
+
allowBack: true,
|
|
322
|
+
initial: state.azureAccountName,
|
|
323
|
+
});
|
|
324
|
+
if (account === prompts_1.BACK) {
|
|
325
|
+
return 'back';
|
|
326
|
+
}
|
|
327
|
+
state.azureAccountName = account;
|
|
328
|
+
}
|
|
329
|
+
return 'next';
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'delivery',
|
|
334
|
+
run: async (allowBack) => {
|
|
335
|
+
while (true) {
|
|
336
|
+
const storage = state.storagePick?.storage ?? 'aws-s3';
|
|
337
|
+
const options = (0, choices_1.deliveryOptionsFor)(storage);
|
|
338
|
+
const delivery = await (0, prompts_1.selectStep)('How do devices download the update assets?', options.map(option => ({ ...DELIVERY_CHOICES[option], value: option })), {
|
|
339
|
+
allowBack,
|
|
340
|
+
initial: state.delivery && options.includes(state.delivery) ? state.delivery : undefined,
|
|
341
|
+
});
|
|
342
|
+
if (delivery === prompts_1.BACK) {
|
|
343
|
+
return 'back';
|
|
344
|
+
}
|
|
345
|
+
state.delivery = delivery;
|
|
346
|
+
if (delivery === 'generic-cdn') {
|
|
347
|
+
const url = await (0, prompts_1.textStep)('CDN base URL', {
|
|
348
|
+
optional: true,
|
|
349
|
+
allowBack: true,
|
|
350
|
+
initial: state.cdnBaseUrl,
|
|
351
|
+
});
|
|
352
|
+
if (url === prompts_1.BACK) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
state.cdnBaseUrl = url;
|
|
356
|
+
}
|
|
357
|
+
return 'next';
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: 'replicas',
|
|
363
|
+
run: async (allowBack) => {
|
|
364
|
+
const multi = await (0, prompts_1.yesNoStep)('Will you run more than one replica of the server?', {
|
|
365
|
+
allowBack,
|
|
366
|
+
initial: state.multiReplica ?? false,
|
|
367
|
+
});
|
|
368
|
+
if (multi === prompts_1.BACK) {
|
|
369
|
+
return 'back';
|
|
370
|
+
}
|
|
371
|
+
state.multiReplica = multi;
|
|
372
|
+
if (multi && state.cacheMode === 'local') {
|
|
373
|
+
state.cacheMode = undefined;
|
|
374
|
+
}
|
|
375
|
+
return 'next';
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
id: 'cache',
|
|
380
|
+
run: async (allowBack) => {
|
|
381
|
+
const replicas = state.multiReplica ? 'multi' : 'single';
|
|
382
|
+
const cacheMode = await (0, prompts_1.selectStep)(state.multiReplica ? 'Which shared cache? (required with replicas)' : 'Which cache?', (0, choices_1.cacheOptionsFor)(replicas).map(option => option === 'redis'
|
|
383
|
+
? { title: 'Redis', value: option }
|
|
384
|
+
: option === 'redis-sentinel'
|
|
385
|
+
? { title: 'Redis Sentinel', value: option }
|
|
386
|
+
: {
|
|
387
|
+
title: 'In-memory',
|
|
388
|
+
value: option,
|
|
389
|
+
description: 'single replica only',
|
|
390
|
+
}), { allowBack, initial: state.cacheMode });
|
|
391
|
+
if (cacheMode === prompts_1.BACK) {
|
|
392
|
+
return 'back';
|
|
393
|
+
}
|
|
394
|
+
state.cacheMode = cacheMode;
|
|
395
|
+
return 'next';
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: 'admin',
|
|
400
|
+
run: async () => {
|
|
401
|
+
while (true) {
|
|
402
|
+
const email = await (0, prompts_1.textStep)('Dashboard admin email (seeded at first boot)', {
|
|
403
|
+
allowBack: true,
|
|
404
|
+
initial: state.adminEmail,
|
|
405
|
+
validate: v => /^\S+@\S+\.\S+$/.test(v) || 'Must be an email address',
|
|
406
|
+
});
|
|
407
|
+
if (email === prompts_1.BACK) {
|
|
408
|
+
return 'back';
|
|
409
|
+
}
|
|
410
|
+
state.adminEmail = email;
|
|
411
|
+
const password = await (0, prompts_1.textStep)('Admin password', {
|
|
412
|
+
secret: true,
|
|
413
|
+
allowBack: true,
|
|
414
|
+
validate: v => {
|
|
415
|
+
const missing = (0, passwordPolicy_1.missingPasswordRules)(v);
|
|
416
|
+
return missing.length === 0 || shortPasswordHint(missing);
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
if (password === prompts_1.BACK) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
state.adminPassword = password;
|
|
423
|
+
return 'next';
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
id: 'deployment',
|
|
429
|
+
run: async (allowBack) => {
|
|
430
|
+
const deployment = await (0, prompts_1.selectStep)('How will you deploy the server?', [
|
|
431
|
+
{ title: 'Docker', value: 'docker', description: 'generates .env.xprem' },
|
|
432
|
+
{ title: 'Binary', value: 'binary', description: 'generates .env.xprem' },
|
|
433
|
+
{
|
|
434
|
+
title: 'Helm (Kubernetes)',
|
|
435
|
+
value: 'helm',
|
|
436
|
+
description: 'generates xprem-helm/ (values + secrets)',
|
|
437
|
+
},
|
|
438
|
+
], { allowBack, initial: state.deployment });
|
|
439
|
+
if (deployment === prompts_1.BACK) {
|
|
440
|
+
return 'back';
|
|
441
|
+
}
|
|
442
|
+
state.deployment = deployment;
|
|
443
|
+
return 'next';
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
id: 'observe',
|
|
448
|
+
run: async (allowBack) => {
|
|
449
|
+
while (true) {
|
|
450
|
+
const observe = await (0, prompts_1.yesNoStep)('Enable Observe (device metrics, needs ClickHouse)?', {
|
|
451
|
+
allowBack,
|
|
452
|
+
initial: state.observe ?? false,
|
|
453
|
+
});
|
|
454
|
+
if (observe === prompts_1.BACK) {
|
|
455
|
+
return 'back';
|
|
456
|
+
}
|
|
457
|
+
state.observe = observe;
|
|
458
|
+
if (observe) {
|
|
459
|
+
const url = await (0, prompts_1.textStep)('ClickHouse URL', {
|
|
460
|
+
optional: true,
|
|
461
|
+
allowBack: true,
|
|
462
|
+
initial: state.clickhouseUrl,
|
|
463
|
+
});
|
|
464
|
+
if (url === prompts_1.BACK) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
state.clickhouseUrl = url;
|
|
468
|
+
}
|
|
469
|
+
return 'next';
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
id: 'geoip',
|
|
475
|
+
run: async (allowBack) => {
|
|
476
|
+
while (true) {
|
|
477
|
+
const geoip = await (0, prompts_1.yesNoStep)('Locate devices on the Identity dashboard?', {
|
|
478
|
+
allowBack,
|
|
479
|
+
initial: state.geoip ?? false,
|
|
480
|
+
});
|
|
481
|
+
if (geoip === prompts_1.BACK) {
|
|
482
|
+
return 'back';
|
|
483
|
+
}
|
|
484
|
+
state.geoip = geoip;
|
|
485
|
+
if (!geoip) {
|
|
486
|
+
return 'next';
|
|
487
|
+
}
|
|
488
|
+
const strategy = await (0, prompts_1.selectStep)('How should devices be located?', [
|
|
489
|
+
{
|
|
490
|
+
title: 'Proxy or CDN headers',
|
|
491
|
+
description: 'your edge (Cloudflare, CloudFront...) sends the location',
|
|
492
|
+
value: 'proxy-headers',
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
title: 'MaxMind GeoLite2',
|
|
496
|
+
description: 'the server downloads the free GeoIP database itself',
|
|
497
|
+
value: 'maxmind',
|
|
498
|
+
},
|
|
499
|
+
], { allowBack: true, initial: state.geoipStrategy });
|
|
500
|
+
if (strategy === prompts_1.BACK) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
state.geoipStrategy = strategy;
|
|
504
|
+
if (strategy === 'maxmind') {
|
|
505
|
+
const accountId = await (0, prompts_1.textStep)('MaxMind account ID', {
|
|
506
|
+
optional: true,
|
|
507
|
+
allowBack: true,
|
|
508
|
+
initial: state.maxmindAccountId,
|
|
509
|
+
});
|
|
510
|
+
if (accountId === prompts_1.BACK) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
state.maxmindAccountId = accountId;
|
|
514
|
+
const licenseKey = await (0, prompts_1.textStep)('MaxMind license key', {
|
|
515
|
+
optional: true,
|
|
516
|
+
allowBack: true,
|
|
517
|
+
initial: state.maxmindLicenseKey,
|
|
518
|
+
});
|
|
519
|
+
if (licenseKey === prompts_1.BACK) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
state.maxmindLicenseKey = licenseKey;
|
|
523
|
+
}
|
|
524
|
+
return 'next';
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
id: 'summary',
|
|
530
|
+
run: async () => {
|
|
531
|
+
log_1.default.note(summaryLines(state), 'Configuration');
|
|
532
|
+
const action = await (0, prompts_1.selectStep)('Generate the configuration?', [{ title: 'Generate', value: 'generate' }], { allowBack: true });
|
|
533
|
+
return action === prompts_1.BACK ? 'back' : 'next';
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
];
|
|
537
|
+
let index = 0;
|
|
538
|
+
while (index < steps.length) {
|
|
539
|
+
const result = await steps[index].run(index > 0);
|
|
540
|
+
index = result === 'back' ? Math.max(0, index - 1) : index + 1;
|
|
541
|
+
}
|
|
542
|
+
const storagePick = state.storagePick ?? { storage: 'aws-s3' };
|
|
543
|
+
const deployment = state.deployment ?? 'docker';
|
|
544
|
+
const existingMasterKey = await readExistingMasterKey(deployment);
|
|
545
|
+
if (existingMasterKey.unreadable) {
|
|
546
|
+
log_1.default.warn(`Could not read ${MASTER_KEY_VAR} from the existing configuration.`);
|
|
547
|
+
const proceed = await (0, prompts_1.confirmStep)(`Generate a new master key? Signing keys and the SSO client secret sealed with the current one become unreadable.`);
|
|
548
|
+
if (!proceed) {
|
|
549
|
+
log_1.default.cancel('Aborted, nothing was written.');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const choices = {
|
|
554
|
+
baseUrl: state.baseUrl,
|
|
555
|
+
jwtSecret: state.jwtSecret,
|
|
556
|
+
dbUrl: state.dbUrl,
|
|
557
|
+
// The wizard always seals the master key in the env; storing it in AWS
|
|
558
|
+
// Secrets Manager (AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID) is a hand edit.
|
|
559
|
+
masterKeySource: 'environment',
|
|
560
|
+
masterKey: existingMasterKey.key ?? generateSecret(),
|
|
561
|
+
awsAuth: state.awsAuth,
|
|
562
|
+
storage: storagePick.storage,
|
|
563
|
+
s3Provider: storagePick.provider,
|
|
564
|
+
s3BucketName: state.s3BucketName,
|
|
565
|
+
awsRegion: state.awsRegion,
|
|
566
|
+
awsBaseEndpoint: state.awsBaseEndpoint,
|
|
567
|
+
forcePathStyle: storagePick.provider
|
|
568
|
+
? choices_1.S3_PROVIDER_DEFAULTS[storagePick.provider].forcePathStyle
|
|
569
|
+
: undefined,
|
|
570
|
+
gcsBucketName: state.gcsBucketName,
|
|
571
|
+
azureContainerName: state.azureContainerName,
|
|
572
|
+
azureAccountName: state.azureAccountName,
|
|
573
|
+
delivery: state.delivery ?? 'presigned',
|
|
574
|
+
cdnBaseUrl: state.cdnBaseUrl,
|
|
575
|
+
replicas: state.multiReplica ? 'multi' : 'single',
|
|
576
|
+
cacheMode: state.cacheMode ?? 'local',
|
|
577
|
+
adminEmail: state.adminEmail,
|
|
578
|
+
adminPassword: state.adminPassword,
|
|
579
|
+
deployment,
|
|
580
|
+
observe: state.observe ?? false,
|
|
581
|
+
clickhouseUrl: state.clickhouseUrl,
|
|
582
|
+
geoip: state.geoip ?? false,
|
|
583
|
+
geoipStrategy: state.geoipStrategy,
|
|
584
|
+
maxmindAccountId: state.maxmindAccountId,
|
|
585
|
+
maxmindLicenseKey: state.maxmindLicenseKey,
|
|
586
|
+
};
|
|
587
|
+
let content;
|
|
588
|
+
let writtenLabel;
|
|
589
|
+
let validateTarget;
|
|
590
|
+
if (deployment === 'helm') {
|
|
591
|
+
const outDir = path_1.default.resolve(process.cwd(), HELM_OUT_DIR);
|
|
592
|
+
const valuesPath = path_1.default.join(outDir, 'values.yaml');
|
|
593
|
+
const secretsPath = path_1.default.join(outDir, helmValues_1.HELM_SECRETS_FILE);
|
|
594
|
+
if ((await fs_extra_1.default.pathExists(valuesPath)) || (await fs_extra_1.default.pathExists(secretsPath))) {
|
|
595
|
+
const overwrite = await (0, prompts_1.confirmStep)('xprem-helm/ already holds a generated pair. Overwrite it?');
|
|
596
|
+
if (!overwrite) {
|
|
597
|
+
log_1.default.cancel('Aborted, nothing was written.');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
await fs_extra_1.default.mkdirp(outDir);
|
|
602
|
+
// Before the write, so a failure between the two cannot leave a tracked
|
|
603
|
+
// secret behind. values.yaml carries no secret and stays committable.
|
|
604
|
+
(0, utils_1.ensureGitIgnored)(process.cwd(), `${HELM_OUT_DIR}/${helmValues_1.HELM_SECRETS_FILE}`, SECRET_FILE_REASON);
|
|
605
|
+
const valuesContent = (0, helmValues_1.renderHelmValues)(choices);
|
|
606
|
+
const secretsContent = (0, helmValues_1.renderHelmSecretsValues)(choices);
|
|
607
|
+
await fs_extra_1.default.writeFile(valuesPath, valuesContent);
|
|
608
|
+
await writeSecretFile(secretsPath, secretsContent);
|
|
609
|
+
content = valuesContent + secretsContent;
|
|
610
|
+
writtenLabel = `xprem-helm/values.yaml and xprem-helm/${helmValues_1.HELM_SECRETS_FILE}`;
|
|
611
|
+
validateTarget = 'xprem-helm';
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
const fileName = ENV_FILE_NAME;
|
|
615
|
+
const filePath = path_1.default.resolve(process.cwd(), fileName);
|
|
616
|
+
if (await fs_extra_1.default.pathExists(filePath)) {
|
|
617
|
+
const overwrite = await (0, prompts_1.confirmStep)(`${fileName} already exists. Overwrite it?`);
|
|
618
|
+
if (!overwrite) {
|
|
619
|
+
log_1.default.cancel('Aborted, nothing was written.');
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
(0, utils_1.ensureGitIgnored)(process.cwd(), ENV_FILE_NAME, SECRET_FILE_REASON);
|
|
624
|
+
content = (0, envCatalog_1.renderEnvFile)(choices);
|
|
625
|
+
await writeSecretFile(filePath, content);
|
|
626
|
+
writtenLabel = fileName;
|
|
627
|
+
validateTarget = fileName;
|
|
628
|
+
}
|
|
629
|
+
log_1.default.succeed(`Wrote ${chalk_1.default.bold(writtenLabel)}`);
|
|
630
|
+
const placeholders = new Set(content.match(/<[^>\n]+>/g) ?? []);
|
|
631
|
+
if (placeholders.size > 0) {
|
|
632
|
+
log_1.default.withInfo(`${placeholders.size} placeholder(s) left to fill: ${chalk_1.default.dim([...placeholders].join(', '))}`);
|
|
633
|
+
}
|
|
634
|
+
if (existingMasterKey.key) {
|
|
635
|
+
log_1.default.withInfo(`${MASTER_KEY_VAR} was carried over from the previous configuration.`);
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
log_1.default.warn(`${MASTER_KEY_VAR} was generated in the file. Back it up now; it is not recoverable.`);
|
|
639
|
+
}
|
|
640
|
+
const nextSteps = [];
|
|
641
|
+
if (deployment === 'docker') {
|
|
642
|
+
nextSteps.push(`docker run --env-file ${writtenLabel} -p 3000:3000 ${DOCKER_IMAGE}`);
|
|
643
|
+
}
|
|
644
|
+
else if (deployment === 'binary') {
|
|
645
|
+
nextSteps.push(`Rename ${writtenLabel} to .env next to the binary: the server loads .env at startup.`);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
nextSteps.push(`helm install xprem ${HELM_CHART} \\`, ` -f xprem-helm/values.yaml -f xprem-helm/${helmValues_1.HELM_SECRETS_FILE} -n <namespace>`, `${HELM_OUT_DIR}/${helmValues_1.HELM_SECRETS_FILE} is gitignored and readable by you only; values.yaml is safe to commit.`, 'To change a secret later: edit it there and helm upgrade with the same flags.');
|
|
649
|
+
}
|
|
650
|
+
log_1.default.note(nextSteps.join('\n'), 'Next steps');
|
|
651
|
+
log_1.default.outro(`Check the configuration anytime: ${ACCENT(`npx eoas server:validate ${validateTarget}`)}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
exports.default = ServerInit;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class ServerValidate extends Command {
|
|
3
|
+
static args: {
|
|
4
|
+
file: import("@oclif/core/lib/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {};
|
|
9
|
+
run(): Promise<void>;
|
|
10
|
+
}
|