@magland/mochi 0.3.1 → 0.4.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 +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +138 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +192 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
|
@@ -43,6 +43,7 @@ const cli_api_1 = require("./cli-api");
|
|
|
43
43
|
const cli_api_2 = require("./cli-api");
|
|
44
44
|
const wake_1 = require("./ci/wake");
|
|
45
45
|
const deploy_cli_1 = require("./deploy-cli");
|
|
46
|
+
const runners_1 = require("./ci/runners");
|
|
46
47
|
const scan_1 = require("./scan");
|
|
47
48
|
// `mochi deploy fly runner`: put a workflow runner on Fly.io, beside a
|
|
48
49
|
// vault that is already there or anywhere else.
|
|
@@ -81,12 +82,14 @@ function parseArgs(args, usage) {
|
|
|
81
82
|
name: null,
|
|
82
83
|
allow: [],
|
|
83
84
|
labels: [],
|
|
85
|
+
jobTimeout: null,
|
|
84
86
|
region: null,
|
|
85
87
|
volumeGb: null,
|
|
86
88
|
vmSize: null,
|
|
87
89
|
memory: null,
|
|
88
90
|
idle: null,
|
|
89
91
|
image: null,
|
|
92
|
+
imageOnly: false,
|
|
90
93
|
fromSource: false,
|
|
91
94
|
localBuild: false,
|
|
92
95
|
org: null,
|
|
@@ -105,6 +108,16 @@ function parseArgs(args, usage) {
|
|
|
105
108
|
out.allow.push(...list(args[++i] ?? ''));
|
|
106
109
|
else if (a === '--labels' || a === '--label')
|
|
107
110
|
out.labels.push(...list(args[++i] ?? ''));
|
|
111
|
+
else if (a === '--job-timeout') {
|
|
112
|
+
const raw = (args[++i] ?? '').trim();
|
|
113
|
+
const m = /^(\d+)\s*(m|h)?$/i.exec(raw);
|
|
114
|
+
if (!m)
|
|
115
|
+
(0, deploy_cli_1.die)('--job-timeout takes minutes, or a duration like 45m or 2h');
|
|
116
|
+
const n = parseInt(m[1], 10) * ((m[2] ?? 'm').toLowerCase() === 'h' ? 60 : 1);
|
|
117
|
+
if (!(0, runners_1.isUsableJobTimeout)(n))
|
|
118
|
+
(0, deploy_cli_1.die)(`--job-timeout takes 1 to ${runners_1.MAX_JOB_TIMEOUT_MINUTES} minutes`);
|
|
119
|
+
out.jobTimeout = n;
|
|
120
|
+
}
|
|
108
121
|
else if (a === '--region')
|
|
109
122
|
out.region = args[++i];
|
|
110
123
|
else if (a === '--volume') {
|
|
@@ -121,6 +134,8 @@ function parseArgs(args, usage) {
|
|
|
121
134
|
out.idle = args[++i];
|
|
122
135
|
else if (a === '--image')
|
|
123
136
|
out.image = args[++i];
|
|
137
|
+
else if (a === '--image-only')
|
|
138
|
+
out.imageOnly = true;
|
|
124
139
|
else if (a === '--from-source')
|
|
125
140
|
out.fromSource = true;
|
|
126
141
|
else if (a === '--local-build')
|
|
@@ -161,6 +176,7 @@ function rejectShapingFlags(a, usage, allowYes) {
|
|
|
161
176
|
['--vm-memory', a.memory !== null],
|
|
162
177
|
['--idle', a.idle !== null],
|
|
163
178
|
['--image', a.image !== null],
|
|
179
|
+
['--image-only', a.imageOnly],
|
|
164
180
|
['--from-source', a.fromSource],
|
|
165
181
|
['--local-build', a.localBuild],
|
|
166
182
|
['--org', a.org !== null],
|
|
@@ -188,7 +204,8 @@ async function liveSettings(app) {
|
|
|
188
204
|
out.region = vol.region;
|
|
189
205
|
out.volumeGb = vol.size_gb;
|
|
190
206
|
}
|
|
191
|
-
const
|
|
207
|
+
const config = (await (0, deploy_cli_1.machines)(app)).find((m) => m.config)?.config;
|
|
208
|
+
const guest = config?.guest;
|
|
192
209
|
if (guest) {
|
|
193
210
|
if (guest.cpu_kind)
|
|
194
211
|
out.cpuKind = guest.cpu_kind;
|
|
@@ -197,6 +214,11 @@ async function liveSettings(app) {
|
|
|
197
214
|
if (guest.memory_mb)
|
|
198
215
|
out.memory = `${guest.memory_mb}mb`;
|
|
199
216
|
}
|
|
217
|
+
// The machine's own idle timeout, so a redeploy keeps a tuned --idle rather
|
|
218
|
+
// than quietly resetting it to the default.
|
|
219
|
+
const idle = config?.env?.MOCHI_IDLE;
|
|
220
|
+
if (idle)
|
|
221
|
+
out.idle = idle;
|
|
200
222
|
return out;
|
|
201
223
|
}
|
|
202
224
|
function resolveSettings(a, live) {
|
|
@@ -269,6 +291,60 @@ async function registeredRunners(target) {
|
|
|
269
291
|
const data = await (0, cli_api_1.api)(target, 'GET', '/api/runners');
|
|
270
292
|
return (data.runners ?? []) ?? [];
|
|
271
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* The fly deploy itself, shared by the full deploy and --image-only. Exits
|
|
296
|
+
* the process on failure, after saying what to try.
|
|
297
|
+
*/
|
|
298
|
+
async function flyDeploy(app, a, settings, buildRoot, image) {
|
|
299
|
+
const config = writeTempConfig(app, settings);
|
|
300
|
+
if (buildRoot !== null) {
|
|
301
|
+
console.log(`==> Building ${buildRoot} ${a.localBuild ? 'with the local Docker' : "on Fly's builder"}`);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
console.log(`==> Deploying ${image}`);
|
|
305
|
+
}
|
|
306
|
+
const code = await (0, deploy_cli_1.flyStream)([
|
|
307
|
+
'deploy',
|
|
308
|
+
'--app',
|
|
309
|
+
app,
|
|
310
|
+
'--config',
|
|
311
|
+
config,
|
|
312
|
+
...(buildRoot !== null
|
|
313
|
+
? ['--dockerfile', path.join(buildRoot, 'Dockerfile.runner'), ...(a.localBuild ? ['--local-only'] : [])]
|
|
314
|
+
: ['--image', image]),
|
|
315
|
+
'--ha=false',
|
|
316
|
+
'--yes',
|
|
317
|
+
], buildRoot ?? undefined);
|
|
318
|
+
fs.rmSync(path.dirname(config), { recursive: true, force: true });
|
|
319
|
+
if (code !== 0) {
|
|
320
|
+
console.error('');
|
|
321
|
+
console.error('The deploy failed. The app, the volume, and the registration survive, so fix the');
|
|
322
|
+
console.error('cause and run the same command again.');
|
|
323
|
+
if (buildRoot === null && a.image === null) {
|
|
324
|
+
// Named as the likely cause rather than as one possibility among many,
|
|
325
|
+
// because it is the one every early deploy hits: the runner image is
|
|
326
|
+
// published per release like the vault's, so a version of the CLI newer
|
|
327
|
+
// than the newest release has no image to pull. Fly reports a package
|
|
328
|
+
// that is absent, and one that is private, with the same message about
|
|
329
|
+
// authentication, and a package newly published to GHCR is private
|
|
330
|
+
// until someone says otherwise.
|
|
331
|
+
console.error('');
|
|
332
|
+
console.error(`If Fly could not fetch ${image}:`);
|
|
333
|
+
console.error('');
|
|
334
|
+
console.error(' - It may not be published. The runner image is built per release, so a CLI');
|
|
335
|
+
console.error(' newer than the newest release asks for a tag that does not exist yet.');
|
|
336
|
+
console.error(' - It may be private. A package newly published to GHCR is private until it');
|
|
337
|
+
console.error(' is made public, and Fly reports that as an authentication error too.');
|
|
338
|
+
console.error('');
|
|
339
|
+
console.error('Either way, this deploys the checkout you are running instead:');
|
|
340
|
+
console.error('');
|
|
341
|
+
console.error(` mochi deploy fly runner ${app} --from-source`);
|
|
342
|
+
console.error('');
|
|
343
|
+
console.error('or --image <ref> deploys some other tag.');
|
|
344
|
+
}
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
272
348
|
async function deployFlyRunnerCmd(args, usage) {
|
|
273
349
|
const a = parseArgs(args, usage);
|
|
274
350
|
if (!a.app) {
|
|
@@ -292,6 +368,46 @@ async function deployFlyRunnerCmd(args, usage) {
|
|
|
292
368
|
}
|
|
293
369
|
const buildRoot = a.fromSource ? (0, deploy_cli_1.sourceRoot)() : null;
|
|
294
370
|
const image = buildRoot === null ? a.image ?? `${RUNNER_IMAGE_REPO}:${(0, deploy_cli_1.ownVersion)()}` : null;
|
|
371
|
+
if (image !== null && a.image === null)
|
|
372
|
+
await (0, deploy_cli_1.requirePublishedImage)(image, true);
|
|
373
|
+
// --image-only: move the machine to a new image and touch nothing else (no
|
|
374
|
+
// registration, no token, no wake rewrite, no secret staging), so it needs
|
|
375
|
+
// flyctl and no vault login. That is the shape a pipeline wants: the runner
|
|
376
|
+
// image is built beside the vault's, a runner and the vault it serves speak
|
|
377
|
+
// one protocol, and a job holding only a Fly credential can do exactly this
|
|
378
|
+
// much and no more.
|
|
379
|
+
if (a.imageOnly) {
|
|
380
|
+
if (a.allow.length > 0 || a.labels.length > 0) {
|
|
381
|
+
(0, deploy_cli_1.die)(`${a.allow.length > 0 ? '--allow' : '--labels'} changes the registration, which lives in the vault, and --image-only\n` +
|
|
382
|
+
'deploys without touching the vault. Drop one or the other.');
|
|
383
|
+
}
|
|
384
|
+
await (0, deploy_cli_1.requireFly)();
|
|
385
|
+
if (!(await (0, deploy_cli_1.appExists)(app))) {
|
|
386
|
+
(0, deploy_cli_1.die)(`No Fly app named '${app}' that you can see, and --image-only updates one that\n` +
|
|
387
|
+
'already exists. The full deploy creates it:\n\n' +
|
|
388
|
+
` mochi deploy fly runner ${app} --allow '<globs>'\n`);
|
|
389
|
+
}
|
|
390
|
+
const have = await (0, deploy_cli_1.secretNames)(app);
|
|
391
|
+
const missing = [HOST_SECRET, TOKEN_SECRET, WAKE_SECRET].filter((n) => !have.includes(n));
|
|
392
|
+
if (missing.length > 0) {
|
|
393
|
+
(0, deploy_cli_1.die)(`The app is missing ${missing.join(', ')}, so the machine deployed\n` +
|
|
394
|
+
'here could not serve any vault. The full deploy sets them:\n\n' +
|
|
395
|
+
` mochi deploy fly runner ${app}\n`);
|
|
396
|
+
}
|
|
397
|
+
if (!(await (0, deploy_cli_1.namedVolume)(app, VOLUME_NAME))) {
|
|
398
|
+
(0, deploy_cli_1.die)(`The app has no '${VOLUME_NAME}' volume. The full deploy creates it:\n\n mochi deploy fly runner ${app}\n`);
|
|
399
|
+
}
|
|
400
|
+
const settings = resolveSettings(a, await liveSettings(app));
|
|
401
|
+
console.log(`==> Updating '${app}' (${(0, deploy_cli_1.appUrl)(app)}), touching nothing but the machine`);
|
|
402
|
+
await flyDeploy(app, a, settings, buildRoot, image);
|
|
403
|
+
console.log('');
|
|
404
|
+
console.log(`==> Deployed: ${(0, deploy_cli_1.appUrl)(app)}`);
|
|
405
|
+
console.log('');
|
|
406
|
+
console.log('The registration, the runner token, and the wake address were left as they');
|
|
407
|
+
console.log(`were. It stops after ${settings.idle} with no job, and the vault starts it again`);
|
|
408
|
+
console.log('when one is waiting.');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
295
411
|
// The vault first: a runner with no vault to serve is nothing, and finding
|
|
296
412
|
// out that the login is missing after creating a Fly app would be a poor
|
|
297
413
|
// trade. This is the same login every other admin command uses.
|
|
@@ -379,6 +495,7 @@ async function deployFlyRunnerCmd(args, usage) {
|
|
|
379
495
|
name,
|
|
380
496
|
labels: a.labels.length ? a.labels : ['ubuntu-latest'],
|
|
381
497
|
allow: a.allow,
|
|
498
|
+
...(a.jobTimeout === null ? {} : { jobTimeoutMinutes: a.jobTimeout }),
|
|
382
499
|
wakeUrl: wake.url,
|
|
383
500
|
wakeSecret: wake.secret,
|
|
384
501
|
});
|
|
@@ -391,6 +508,12 @@ async function deployFlyRunnerCmd(args, usage) {
|
|
|
391
508
|
const data = await (0, cli_api_1.api)(target, 'POST', `/api/runners/${encodeURIComponent(name)}/token`, {});
|
|
392
509
|
runnerToken = data.token;
|
|
393
510
|
}
|
|
511
|
+
// A timeout named by a flag applies to a runner that was already registered
|
|
512
|
+
// too, on the terms the rest of this command sets: a flag changes the thing
|
|
513
|
+
// it names, and a setting left unnamed keeps whatever the vault has.
|
|
514
|
+
if (a.jobTimeout !== null && registered) {
|
|
515
|
+
await (0, cli_api_1.api)(target, 'PATCH', `/api/runners/${encodeURIComponent(name)}`, { jobTimeoutMinutes: a.jobTimeout });
|
|
516
|
+
}
|
|
394
517
|
// The wake address is rewritten on every deploy, not only on the first. The
|
|
395
518
|
// secret is this run's, and the app it points at is this app; a redeploy
|
|
396
519
|
// that left an older secret in place would leave the vault sending one the
|
|
@@ -409,54 +532,7 @@ async function deployFlyRunnerCmd(args, usage) {
|
|
|
409
532
|
const set = await (0, deploy_cli_1.fly)(['secrets', 'import', '-a', app, '--stage'], staged.join('\n') + '\n');
|
|
410
533
|
if (set.code !== 0)
|
|
411
534
|
(0, deploy_cli_1.die)(`Could not set the secrets:\n${set.stderr.trim() || set.stdout.trim()}`);
|
|
412
|
-
|
|
413
|
-
if (buildRoot !== null) {
|
|
414
|
-
console.log(`==> Building ${buildRoot} ${a.localBuild ? 'with the local Docker' : "on Fly's builder"}`);
|
|
415
|
-
}
|
|
416
|
-
else {
|
|
417
|
-
console.log(`==> Deploying ${image}`);
|
|
418
|
-
}
|
|
419
|
-
const code = await (0, deploy_cli_1.flyStream)([
|
|
420
|
-
'deploy',
|
|
421
|
-
'--app',
|
|
422
|
-
app,
|
|
423
|
-
'--config',
|
|
424
|
-
config,
|
|
425
|
-
...(buildRoot !== null
|
|
426
|
-
? ['--dockerfile', path.join(buildRoot, 'Dockerfile.runner'), ...(a.localBuild ? ['--local-only'] : [])]
|
|
427
|
-
: ['--image', image]),
|
|
428
|
-
'--ha=false',
|
|
429
|
-
'--yes',
|
|
430
|
-
], buildRoot ?? undefined);
|
|
431
|
-
fs.rmSync(path.dirname(config), { recursive: true, force: true });
|
|
432
|
-
if (code !== 0) {
|
|
433
|
-
console.error('');
|
|
434
|
-
console.error('The deploy failed. The app, the volume, and the registration survive, so fix the');
|
|
435
|
-
console.error('cause and run the same command again.');
|
|
436
|
-
if (buildRoot === null && a.image === null) {
|
|
437
|
-
// Named as the likely cause rather than as one possibility among many,
|
|
438
|
-
// because it is the one every early deploy hits: the runner image is
|
|
439
|
-
// published per release like the vault's, so a version of the CLI newer
|
|
440
|
-
// than the newest release has no image to pull. Fly reports a package
|
|
441
|
-
// that is absent, and one that is private, with the same message about
|
|
442
|
-
// authentication, and a package newly published to GHCR is private
|
|
443
|
-
// until someone says otherwise.
|
|
444
|
-
console.error('');
|
|
445
|
-
console.error(`If Fly could not fetch ${image}:`);
|
|
446
|
-
console.error('');
|
|
447
|
-
console.error(' - It may not be published. The runner image is built per release, so a CLI');
|
|
448
|
-
console.error(' newer than the newest release asks for a tag that does not exist yet.');
|
|
449
|
-
console.error(' - It may be private. A package newly published to GHCR is private until it');
|
|
450
|
-
console.error(' is made public, and Fly reports that as an authentication error too.');
|
|
451
|
-
console.error('');
|
|
452
|
-
console.error('Either way, this deploys the checkout you are running instead:');
|
|
453
|
-
console.error('');
|
|
454
|
-
console.error(` mochi deploy fly runner ${app} --from-source`);
|
|
455
|
-
console.error('');
|
|
456
|
-
console.error('or --image <ref> deploys some other tag.');
|
|
457
|
-
}
|
|
458
|
-
process.exit(1);
|
|
459
|
-
}
|
|
535
|
+
await flyDeploy(app, a, settings, buildRoot, image);
|
|
460
536
|
// A machine is started by the deploy and will stop itself once it has been
|
|
461
537
|
// idle for the configured time. Saying so is worth a line, because a
|
|
462
538
|
// `deploy fly` that ends with a stopped machine looks like a failure to
|
package/dist/domains.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DOMAINS_FILE = void 0;
|
|
37
|
+
exports.domainsFilePath = domainsFilePath;
|
|
38
|
+
exports.loadDomains = loadDomains;
|
|
39
|
+
exports.domainRepoFor = domainRepoFor;
|
|
40
|
+
exports.repoDomain = repoDomain;
|
|
41
|
+
exports.isSiteRequest = isSiteRequest;
|
|
42
|
+
exports.setRepoDomain = setRepoDomain;
|
|
43
|
+
exports.clearRepoDomain = clearRepoDomain;
|
|
44
|
+
exports.moveRepoDomains = moveRepoDomains;
|
|
45
|
+
exports.moveCollectionDomains = moveCollectionDomains;
|
|
46
|
+
exports.dropRepoDomains = dropRepoDomains;
|
|
47
|
+
exports.dropCollectionDomains = dropCollectionDomains;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const atomic_1 = require("./atomic");
|
|
51
|
+
const config_1 = require("./config");
|
|
52
|
+
const filecache_1 = require("./filecache");
|
|
53
|
+
const scan_1 = require("./scan");
|
|
54
|
+
const siteshost_1 = require("./siteshost");
|
|
55
|
+
// Custom domains for repository sites, in <vault>/domains.json beside
|
|
56
|
+
// config.json: a map from a hostname the operator points at the vault to the
|
|
57
|
+
// `<collection>/<repo>` whose site answers there.
|
|
58
|
+
//
|
|
59
|
+
// Vault state rather than repository state, deliberately. The server decides
|
|
60
|
+
// per request whether a Host header names a site, so the answer has to come
|
|
61
|
+
// from one stat-cached file the way config.json does, not from a scan of every
|
|
62
|
+
// repository. It also matches who may write it: attaching a hostname is the
|
|
63
|
+
// operator's act, since the operator is who points DNS at the vault, covers
|
|
64
|
+
// the name with a certificate, and answers for what the server serves under
|
|
65
|
+
// it. A repository admin enables the site and may pick a label on the sites
|
|
66
|
+
// host; a custom domain takes a site admin, through the API or the CLI, and
|
|
67
|
+
// the file is hand-editable like everything else in a vault.
|
|
68
|
+
//
|
|
69
|
+
// One domain maps to one repository, and each repository holds at most one
|
|
70
|
+
// domain: setRepoDomain replaces whatever the repository had. A domain equal
|
|
71
|
+
// to the sites host, or under it, is refused, since those names already have a
|
|
72
|
+
// meaning; nothing can check the forge's own hostname, which the server never
|
|
73
|
+
// knows, so mapping that is the one mistake this cannot catch and the
|
|
74
|
+
// documentation warns about.
|
|
75
|
+
exports.DOMAINS_FILE = 'domains.json';
|
|
76
|
+
function domainsFilePath(root) {
|
|
77
|
+
return path.join(root, exports.DOMAINS_FILE);
|
|
78
|
+
}
|
|
79
|
+
function normalize(parsed) {
|
|
80
|
+
const out = {};
|
|
81
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
82
|
+
return out;
|
|
83
|
+
const domains = parsed.domains;
|
|
84
|
+
if (typeof domains !== 'object' || domains === null)
|
|
85
|
+
return out;
|
|
86
|
+
for (const [rawHost, target] of Object.entries(domains)) {
|
|
87
|
+
const host = (0, siteshost_1.normalizeHostname)(rawHost);
|
|
88
|
+
if (!(0, config_1.isPlausibleHostname)(host) || typeof target !== 'string')
|
|
89
|
+
continue;
|
|
90
|
+
const slash = target.indexOf('/');
|
|
91
|
+
if (slash <= 0)
|
|
92
|
+
continue;
|
|
93
|
+
const collection = target.slice(0, slash);
|
|
94
|
+
const repo = target.slice(slash + 1);
|
|
95
|
+
if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(repo))
|
|
96
|
+
continue;
|
|
97
|
+
out[host] = `${collection}/${repo}`;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
const EMPTY = {};
|
|
102
|
+
const cache = (0, filecache_1.fileCache)({
|
|
103
|
+
read: (file) => {
|
|
104
|
+
try {
|
|
105
|
+
return normalize(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
// A file that cannot be read maps nothing: the custom domains stop
|
|
109
|
+
// answering rather than answering wrongly, and the forge is unaffected.
|
|
110
|
+
console.error(`domains.json could not be read: ${e instanceof Error ? e.message : e}`);
|
|
111
|
+
return EMPTY;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
missing: () => EMPTY,
|
|
115
|
+
});
|
|
116
|
+
/** Every custom domain, as hostname -> `<collection>/<repo>`. */
|
|
117
|
+
function loadDomains(root) {
|
|
118
|
+
return cache.get(domainsFilePath(root));
|
|
119
|
+
}
|
|
120
|
+
/** The repository whose custom domain this hostname is, or null. */
|
|
121
|
+
function domainRepoFor(root, hostname) {
|
|
122
|
+
const target = loadDomains(root)[(0, siteshost_1.normalizeHostname)(hostname)];
|
|
123
|
+
if (target === undefined)
|
|
124
|
+
return null;
|
|
125
|
+
const slash = target.indexOf('/');
|
|
126
|
+
return { collection: target.slice(0, slash), repo: target.slice(slash + 1) };
|
|
127
|
+
}
|
|
128
|
+
/** The custom domain a repository holds, or null. */
|
|
129
|
+
function repoDomain(root, collection, repo) {
|
|
130
|
+
const full = `${collection}/${repo}`;
|
|
131
|
+
for (const [host, target] of Object.entries(loadDomains(root))) {
|
|
132
|
+
if (target === full)
|
|
133
|
+
return host;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Whether a request's hostname is a site rather than the forge: under the
|
|
139
|
+
* sites host, or a custom domain. This is the question the session, the rate
|
|
140
|
+
* exemptions, and the egress exemptions each ask, so it is one function; on a
|
|
141
|
+
* hostname it answers true for, no session is resolved and no path is exempt
|
|
142
|
+
* from any limit, because every path there belongs to the site being served.
|
|
143
|
+
*/
|
|
144
|
+
function isSiteRequest(root, hostname) {
|
|
145
|
+
if ((0, siteshost_1.isUnderSitesHost)((0, config_1.loadConfig)(root).sites.host, hostname))
|
|
146
|
+
return true;
|
|
147
|
+
return loadDomains(root)[(0, siteshost_1.normalizeHostname)(hostname)] !== undefined;
|
|
148
|
+
}
|
|
149
|
+
function write(root, domains) {
|
|
150
|
+
const file = domainsFilePath(root);
|
|
151
|
+
if (Object.keys(domains).length === 0) {
|
|
152
|
+
// Nothing mapped, so nothing on disk, the same way redirects.json goes.
|
|
153
|
+
fs.rmSync(file, { force: true });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
(0, atomic_1.writeFileAtomic)(file, JSON.stringify({ domains }, null, 2) + '\n');
|
|
157
|
+
}
|
|
158
|
+
cache.invalidate(file);
|
|
159
|
+
}
|
|
160
|
+
function edit(root, fn) {
|
|
161
|
+
const file = domainsFilePath(root);
|
|
162
|
+
(0, atomic_1.withFileLock)(`${file}.lock`, () => {
|
|
163
|
+
cache.invalidate(file);
|
|
164
|
+
const domains = { ...loadDomains(root) };
|
|
165
|
+
fn(domains);
|
|
166
|
+
write(root, domains);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Attach a domain to a repository, replacing any domain the repository already
|
|
171
|
+
* held. Returns the problem, or null on success; the kind tells a surface
|
|
172
|
+
* whether the caller's value was wrong or merely taken, so each words its own
|
|
173
|
+
* refusal with the right status.
|
|
174
|
+
*/
|
|
175
|
+
function setRepoDomain(root, collection, repo, domain) {
|
|
176
|
+
const host = (0, siteshost_1.normalizeHostname)(domain);
|
|
177
|
+
if (!(0, config_1.isPlausibleHostname)(host)) {
|
|
178
|
+
return { kind: 'invalid', message: 'a domain is a hostname of at least two labels, like docs.example.org' };
|
|
179
|
+
}
|
|
180
|
+
const sitesHost = (0, config_1.loadConfig)(root).sites.host;
|
|
181
|
+
if (sitesHost && (host === sitesHost || host.endsWith(`.${sitesHost}`))) {
|
|
182
|
+
return {
|
|
183
|
+
kind: 'invalid',
|
|
184
|
+
message: `a name under the sites host ${sitesHost} already has a meaning; use the repository's site label instead`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const full = `${collection}/${repo}`;
|
|
188
|
+
let conflict = null;
|
|
189
|
+
edit(root, (domains) => {
|
|
190
|
+
const holder = domains[host];
|
|
191
|
+
if (holder !== undefined && holder !== full) {
|
|
192
|
+
conflict = holder;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const [h, target] of Object.entries(domains)) {
|
|
196
|
+
if (target === full)
|
|
197
|
+
delete domains[h];
|
|
198
|
+
}
|
|
199
|
+
domains[host] = full;
|
|
200
|
+
});
|
|
201
|
+
return conflict ? { kind: 'conflict', message: `${host} already serves the site of ${conflict}` } : null;
|
|
202
|
+
}
|
|
203
|
+
/** Detach whatever domain a repository holds; holding none is fine. */
|
|
204
|
+
function clearRepoDomain(root, collection, repo) {
|
|
205
|
+
const full = `${collection}/${repo}`;
|
|
206
|
+
if (repoDomain(root, collection, repo) === null)
|
|
207
|
+
return;
|
|
208
|
+
edit(root, (domains) => {
|
|
209
|
+
for (const [host, target] of Object.entries(domains)) {
|
|
210
|
+
if (target === full)
|
|
211
|
+
delete domains[host];
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/** A repository moved; its domain, if any, follows it. Called by renameRepo. */
|
|
216
|
+
function moveRepoDomains(root, collection, repo, toCollection, toName) {
|
|
217
|
+
const from = `${collection}/${repo}`;
|
|
218
|
+
if (repoDomain(root, collection, repo) === null)
|
|
219
|
+
return;
|
|
220
|
+
edit(root, (domains) => {
|
|
221
|
+
for (const [host, target] of Object.entries(domains)) {
|
|
222
|
+
if (target === from)
|
|
223
|
+
domains[host] = `${toCollection}/${toName}`;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/** A collection moved; every domain into it follows. Called by renameCollection. */
|
|
228
|
+
function moveCollectionDomains(root, collection, toName) {
|
|
229
|
+
const prefix = `${collection}/`;
|
|
230
|
+
const map = loadDomains(root);
|
|
231
|
+
if (!Object.values(map).some((t) => t.startsWith(prefix)))
|
|
232
|
+
return;
|
|
233
|
+
edit(root, (domains) => {
|
|
234
|
+
for (const [host, target] of Object.entries(domains)) {
|
|
235
|
+
if (target.startsWith(prefix))
|
|
236
|
+
domains[host] = `${toName}/${target.slice(prefix.length)}`;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* A repository or collection is gone; its domains go with it, so a repository
|
|
242
|
+
* created later under the name does not inherit a hostname somebody attached
|
|
243
|
+
* to what was deleted. Called by deleteRepo and deleteCollection.
|
|
244
|
+
*/
|
|
245
|
+
function dropRepoDomains(root, collection, repo) {
|
|
246
|
+
clearRepoDomain(root, collection, repo);
|
|
247
|
+
}
|
|
248
|
+
function dropCollectionDomains(root, collection) {
|
|
249
|
+
const prefix = `${collection}/`;
|
|
250
|
+
const map = loadDomains(root);
|
|
251
|
+
if (!Object.values(map).some((t) => t.startsWith(prefix)))
|
|
252
|
+
return;
|
|
253
|
+
edit(root, (domains) => {
|
|
254
|
+
for (const [host, target] of Object.entries(domains)) {
|
|
255
|
+
if (target.startsWith(prefix))
|
|
256
|
+
delete domains[host];
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
package/dist/egress.js
CHANGED
|
@@ -41,9 +41,10 @@ const fs = __importStar(require("fs"));
|
|
|
41
41
|
const path = __importStar(require("path"));
|
|
42
42
|
const atomic_1 = require("./atomic");
|
|
43
43
|
const config_1 = require("./config");
|
|
44
|
+
const domains_1 = require("./domains");
|
|
44
45
|
const redirects_1 = require("./redirects");
|
|
45
46
|
const scan_1 = require("./scan");
|
|
46
|
-
const
|
|
47
|
+
const sitesettings_1 = require("./sitesettings");
|
|
47
48
|
// Outgoing bytes, counted and capped.
|
|
48
49
|
//
|
|
49
50
|
// A host that bills for egress and does not cap it -- Fly is the one this was
|
|
@@ -198,15 +199,17 @@ function readDays(file) {
|
|
|
198
199
|
* program, and reading the number is what an operator does before changing it;
|
|
199
200
|
* the stylesheet and the icon because the admin page is unreadable without them.
|
|
200
201
|
*
|
|
201
|
-
* None of it applies on a sites hostname, where these paths
|
|
202
|
-
* being served and are ordinary traffic, exactly as
|
|
202
|
+
* None of it applies on a sites hostname or a custom domain, where these paths
|
|
203
|
+
* belong to the site being served and are ordinary traffic, exactly as
|
|
204
|
+
* isRateExempt has it.
|
|
203
205
|
*/
|
|
204
206
|
function isEgressExempt(root, req) {
|
|
205
|
-
if ((0,
|
|
207
|
+
if ((0, domains_1.isSiteRequest)(root, req.hostname))
|
|
206
208
|
return false;
|
|
207
209
|
const p = req.path;
|
|
208
|
-
if (p === '/login' || p === '/logout' || p === '/api/config' || p === '/api/egress')
|
|
210
|
+
if (p === '/login' || p.startsWith('/login/') || p === '/logout' || p === '/api/config' || p === '/api/egress') {
|
|
209
211
|
return true;
|
|
212
|
+
}
|
|
210
213
|
if (p === '/admin' || p.startsWith('/admin/'))
|
|
211
214
|
return true;
|
|
212
215
|
return p.startsWith('/assets/') || p === '/favicon.svg' || p === '/favicon.ico';
|
|
@@ -276,6 +279,18 @@ function normalizeKeys(root, keys) {
|
|
|
276
279
|
}
|
|
277
280
|
return changed ? out : null;
|
|
278
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* The repository a site hostname names, or null for a hostname that serves no
|
|
284
|
+
* site: a custom domain out of domains.json, or a name under the sites host,
|
|
285
|
+
* resolved exactly as the site handler resolves it so that a request's bytes
|
|
286
|
+
* are attributed to whatever answered it.
|
|
287
|
+
*/
|
|
288
|
+
function siteHostRepo(root, hostname) {
|
|
289
|
+
const domain = (0, domains_1.domainRepoFor)(root, hostname);
|
|
290
|
+
if (domain)
|
|
291
|
+
return domain;
|
|
292
|
+
return (0, sitesettings_1.findRepoBySiteHostname)(root, (0, config_1.loadConfig)(root).sites.host, hostname);
|
|
293
|
+
}
|
|
279
294
|
/**
|
|
280
295
|
* Which row a request's bytes belong to.
|
|
281
296
|
*
|
|
@@ -287,11 +302,13 @@ function normalizeKeys(root, keys) {
|
|
|
287
302
|
* to none is one unmatched row rather than a row of its own.
|
|
288
303
|
*/
|
|
289
304
|
function keyFor(root, req) {
|
|
290
|
-
const site = (
|
|
305
|
+
const site = siteHostRepo(root, req.hostname);
|
|
291
306
|
if (site) {
|
|
292
307
|
const resolved = resolveRepoKey(root, site.collection, site.repo);
|
|
293
308
|
return resolved === null ? UNMATCHED_KEY : `${resolved}${SITE_SUFFIX}`;
|
|
294
309
|
}
|
|
310
|
+
if ((0, domains_1.isSiteRequest)(root, req.hostname))
|
|
311
|
+
return VAULT_KEY;
|
|
295
312
|
const m = /^\/([^/]+)\/([^/]+)(?:\/(.*))?$/.exec(req.path);
|
|
296
313
|
if (!m)
|
|
297
314
|
return VAULT_KEY;
|