sliftutils 1.7.126 → 1.7.128
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/bin/testnotify.js +9 -0
- package/package.json +5 -3
- package/security/authorizedKeys/authorizedKeys.ts +49 -9
- package/security/authorizedKeys/daemon/authLog.ts +74 -5
- package/security/authorizedKeys/daemon/changes.ts +26 -0
- package/security/authorizedKeys/daemon/daemon.ts +58 -30
- package/security/authorizedKeys/daemon/git.ts +3 -3
- package/security/authorizedKeys/daemon/revocation.ts +60 -31
- package/security/authorizedKeys/daemon/rootKeys.ts +88 -25
- package/security/authorizedKeys/daemon/sessions.ts +139 -0
- package/security/authorizedKeys/daemon/state.ts +16 -2
- package/security/authorizedKeys/dist/authorizedKeys.ts.cache +49 -11
- package/security/authorizedKeys/dist/secureSSH.ts.cache +18 -31
- package/security/authorizedKeys/dist/unrevoke.ts.cache +36 -14
- package/security/authorizedKeys/revokeSource.ts +12 -2
- package/security/authorizedKeys/secureSSH.ts +116 -64
- package/security/authorizedKeys/sources.ts +47 -5
- package/security/authorizedKeys/unrevoke.ts +55 -20
- package/security/helpers/remoteSSH.ts +17 -6
- package/security/notifications/discord.ts +18 -1
- package/security/notifications/dist/discord.ts.cache +21 -4
- package/security/notifications/setupNotify.ts +17 -8
- package/security/notifications/testNotify.ts +48 -0
- package/security/signedFiles/dist/manifest.ts.cache +21 -4
- package/security/signedFiles/dist/signFiles.ts.cache +48 -13
- package/security/signedFiles/manifest.ts +19 -2
- package/security/signedFiles/signFiles.ts +51 -12
|
@@ -3,12 +3,13 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { DEFAULT_WEBHOOK_FILE_PATH, parseWebhookFile } from "../notifications/discord";
|
|
5
5
|
import { normalizeKeys, readRepoKeys, summarizeKey } from "./authorizedKeys";
|
|
6
|
-
import { sourceKeyPath, sourceRepoPath } from "./sources";
|
|
6
|
+
import { findSourceKey, KEYS_DIR_NAME, sourceKeyPath, sourceName, sourceRepoPath } from "./sources";
|
|
7
7
|
import { deriveRevokeKey, REVOKE_KEY_LABEL, revokeRepoURL } from "./revokeSource";
|
|
8
|
+
import { legacySourceKeyPath } from "./sources";
|
|
8
9
|
import { revokedKeysInRepo } from "./unrevoke";
|
|
9
10
|
import { expandHome } from "../helpers/paths";
|
|
10
11
|
import { spawnPromise } from "../helpers/spawn";
|
|
11
|
-
import { readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, writeRemoteFile } from "../helpers/remoteSSH";
|
|
12
|
+
import { describeHost, readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, THIS_MACHINE, writeRemoteFile } from "../helpers/remoteSSH";
|
|
12
13
|
|
|
13
14
|
const SERVICE_SOURCE = path.join(__dirname, "daemon", "portsecure.service");
|
|
14
15
|
// The daemon runs from a checkout of this repo on the host, rather than from a copy we upload, so
|
|
@@ -22,12 +23,16 @@ const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
|
22
23
|
const SERVICE_NAME = "portsecure";
|
|
23
24
|
const MAX_ERROR_BODY_LENGTH = 500;
|
|
24
25
|
const VERBS = ["add", "remove", "list", "update"];
|
|
25
|
-
// The
|
|
26
|
+
// The host is optional, and without one everything happens on this machine. The repo url is
|
|
27
|
+
// optional too, and defaults to the repo the command is run from.
|
|
26
28
|
const USAGE = `Usage:
|
|
27
|
-
yarn securessh
|
|
28
|
-
yarn securessh
|
|
29
|
-
yarn securessh
|
|
30
|
-
yarn securessh
|
|
29
|
+
yarn securessh [host] add <repo-private-key> [repo-url]
|
|
30
|
+
yarn securessh [host] remove [repo-url]
|
|
31
|
+
yarn securessh [host] list
|
|
32
|
+
yarn securessh [host] update
|
|
33
|
+
|
|
34
|
+
With no host it acts on this machine, and still installs from github rather than from wherever
|
|
35
|
+
this was run.`;
|
|
31
36
|
|
|
32
37
|
async function pathExists(filePath: string) {
|
|
33
38
|
try {
|
|
@@ -121,6 +126,27 @@ async function fingerprintKeys(keys: string[]) {
|
|
|
121
126
|
return fingerprints;
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
/** Where the daemon on that machine keeps its keys: the home of the user it runs as. Asked of the
|
|
130
|
+
machine itself, because the answer is not the same everywhere and certainly not the same as the
|
|
131
|
+
home of whoever is running this. */
|
|
132
|
+
async function daemonKeysDir(host: string) {
|
|
133
|
+
let result = await runOverSSH({
|
|
134
|
+
host,
|
|
135
|
+
script: `${SUDO_PREAMBLE}\n$SUDO getent passwd root | cut -d: -f6`,
|
|
136
|
+
});
|
|
137
|
+
let home = result.stdout.trim();
|
|
138
|
+
if (!home) {
|
|
139
|
+
throw new Error(`Expected ${describeHost(host)} to report a home directory for root, it reported nothing`);
|
|
140
|
+
}
|
|
141
|
+
return `${home}/${KEYS_DIR_NAME}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The public half of a private key, as ssh-keygen derives it. */
|
|
145
|
+
async function publicKeyOf(privateKeyPath: string) {
|
|
146
|
+
let result = await runLocal({ command: "ssh-keygen", args: ["-y", "-f", privateKeyPath] });
|
|
147
|
+
return `${result.stdout.trim()}\n`;
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
async function cloneRepoForInspection(config: { repoURL: string; keyPath: string }) {
|
|
125
151
|
let { repoURL, keyPath } = config;
|
|
126
152
|
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-repo-"));
|
|
@@ -176,7 +202,20 @@ async function ensureRevokeReposExist(repoSources: string[]) {
|
|
|
176
202
|
let revokeURL = revokeRepoURL(repoURL);
|
|
177
203
|
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-revoke-"));
|
|
178
204
|
let checkoutPath = path.join(temporaryDirectory, "repo");
|
|
179
|
-
|
|
205
|
+
// On a machine that is already a portsecure host, the source's deploy key is right there,
|
|
206
|
+
// and the key derived from it is the one credential that repo is guaranteed to accept.
|
|
207
|
+
// Anywhere else, a person is running this and has their own access.
|
|
208
|
+
let ownKey = "";
|
|
209
|
+
let localSourceKey = await findSourceKey(repoURL);
|
|
210
|
+
if (localSourceKey) {
|
|
211
|
+
let derived = deriveRevokeKey(await fs.readFile(localSourceKey, "utf8"));
|
|
212
|
+
ownKey = path.join(temporaryDirectory, "key");
|
|
213
|
+
await fs.writeFile(ownKey, derived.privateKeyFile, { mode: 0o600 });
|
|
214
|
+
}
|
|
215
|
+
let cloneRevoke = async (allowFailure: boolean) => ownKey
|
|
216
|
+
&& await gitWithKey({ keyPath: ownKey, args: ["clone", revokeURL, checkoutPath], allowFailure })
|
|
217
|
+
|| await runLocal({ command: "git", args: ["clone", revokeURL, checkoutPath], allowFailure });
|
|
218
|
+
let clone = await cloneRevoke(true);
|
|
180
219
|
if (clone.status !== 0) {
|
|
181
220
|
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
182
221
|
throw new Error(
|
|
@@ -194,10 +233,15 @@ async function ensureRevokeReposExist(repoSources: string[]) {
|
|
|
194
233
|
for (let args of [
|
|
195
234
|
["-C", checkoutPath, "add", "-A"],
|
|
196
235
|
["-C", checkoutPath, "-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", "initialise revoke repo"],
|
|
197
|
-
["-C", checkoutPath, "push", "origin", "HEAD"],
|
|
198
236
|
]) {
|
|
199
237
|
await runLocal({ command: "git", args });
|
|
200
238
|
}
|
|
239
|
+
let push = ["-C", checkoutPath, "push", "origin", "HEAD"];
|
|
240
|
+
if (ownKey) {
|
|
241
|
+
await gitWithKey({ keyPath: ownKey, args: push });
|
|
242
|
+
} else {
|
|
243
|
+
await runLocal({ command: "git", args: push });
|
|
244
|
+
}
|
|
201
245
|
}
|
|
202
246
|
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
203
247
|
console.log(`${revokeURL} is ready`);
|
|
@@ -296,14 +340,24 @@ async function installDaemon(config: { host: string; hostLabel: string; repoSour
|
|
|
296
340
|
|
|
297
341
|
// The checkout is brought to the latest commit rather than a copy being pushed, so what runs on
|
|
298
342
|
// the host is exactly what is on github.
|
|
299
|
-
console.log(`Updating ${REMOTE_CHECKOUT_PATH} on ${host}`);
|
|
343
|
+
console.log(`Updating ${REMOTE_CHECKOUT_PATH} on ${describeHost(host)}`);
|
|
300
344
|
await runOverSSH({
|
|
301
345
|
host,
|
|
302
346
|
script: `${SUDO_PREAMBLE}
|
|
303
347
|
set -e
|
|
304
348
|
$SUDO mkdir -p "${path.posix.dirname(REMOTE_CHECKOUT_PATH)}"
|
|
305
349
|
if $SUDO test -d "${REMOTE_CHECKOUT_PATH}/.git"; then
|
|
350
|
+
# Stashed first, the way machine-alwaysup does it. Anything sitting modified in a host's
|
|
351
|
+
# checkout is either an accident or somebody editing the daemon in place, and either way it is
|
|
352
|
+
# worth keeping rather than quietly erasing. The identity is passed inline because a host has
|
|
353
|
+
# no git config of its own and stash writes a commit.
|
|
354
|
+
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" add --all
|
|
355
|
+
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" -c user.email=portsecure@localhost -c user.name=portsecure stash
|
|
306
356
|
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" fetch --prune origin
|
|
357
|
+
# A stash and a pull cannot cross a branch that has diverged, and a drifted checkout must not be
|
|
358
|
+
# able to leave a host running old code, so it is put on the remote's state rather than merged
|
|
359
|
+
# with it. set-head so that is the remote's default branch now, not the one it had at clone.
|
|
360
|
+
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" remote set-head origin --auto
|
|
307
361
|
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" reset --hard origin/HEAD
|
|
308
362
|
else
|
|
309
363
|
$SUDO rm -rf "${REMOTE_CHECKOUT_PATH}"
|
|
@@ -368,7 +422,7 @@ async function requireRemoteWebhook(host: string) {
|
|
|
368
422
|
throw new Error(
|
|
369
423
|
`Expected a Discord webhook at ${DEFAULT_WEBHOOK_FILE_PATH} on ${host}, no such file exists.`
|
|
370
424
|
+ ` The daemon will not start without one.\n`
|
|
371
|
-
+ `Set it up first:\n yarn setupnotify ${host} <discord-webhook-url
|
|
425
|
+
+ `Set it up first:\n yarn setupnotify ${host} <discord-webhook-url>`.replace(" <discord", host && " <discord" || "<discord")
|
|
372
426
|
);
|
|
373
427
|
}
|
|
374
428
|
return parseWebhookFile({ contents, sourceName: `${host}:${DEFAULT_WEBHOOK_FILE_PATH}` });
|
|
@@ -392,18 +446,22 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
|
|
|
392
446
|
|
|
393
447
|
let remoteConfig = await readRemoteConfig(host);
|
|
394
448
|
if (remoteConfig.repoSources.includes(repoURL)) {
|
|
395
|
-
console.log(`${host} already has ${repoURL}, refreshing its key and the daemon.`);
|
|
449
|
+
console.log(`${describeHost(host)} already has ${repoURL}, refreshing its key and the daemon.`);
|
|
396
450
|
}
|
|
397
451
|
|
|
398
|
-
// The merged result is what root ends up with, so our own key has to be somewhere in it.
|
|
399
|
-
|
|
400
|
-
let ourFingerprint =
|
|
452
|
+
// The merged result is what root ends up with, so our own key has to be somewhere in it. On
|
|
453
|
+
// this machine there is no ssh session to preserve, so there is nothing to check.
|
|
454
|
+
let ourFingerprint = "";
|
|
455
|
+
if (host) {
|
|
456
|
+
console.log(`Checking our access to ${describeHost(host)} survives the merged keys`);
|
|
457
|
+
ourFingerprint = await findAuthenticatingFingerprint(host);
|
|
458
|
+
}
|
|
401
459
|
let inspectionPath = await cloneRepoForInspection({ repoURL, keyPath });
|
|
402
460
|
let newKeys = await readRepoKeys(inspectionPath);
|
|
403
461
|
// Whatever is applied on the host came from the existing sources, so it stays in the merge.
|
|
404
462
|
let existingKeys = normalizeKeys(await readRemoteFile({ host, filePath: ROOT_AUTHORIZED_KEYS }) || "");
|
|
405
463
|
let mergedFingerprints = await fingerprintKeys([...existingKeys, ...newKeys]);
|
|
406
|
-
if (!mergedFingerprints.includes(ourFingerprint)) {
|
|
464
|
+
if (ourFingerprint && !mergedFingerprints.includes(ourFingerprint)) {
|
|
407
465
|
throw new Error(
|
|
408
466
|
`Expected the key we use for ${host} to be in the merged keys, it is not.\n`
|
|
409
467
|
+ `Ours: ${ourFingerprint}\n`
|
|
@@ -412,7 +470,9 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
|
|
|
412
470
|
+ ` lock you out of ${host}. Add your public key to ${repoURL} first.`
|
|
413
471
|
);
|
|
414
472
|
}
|
|
415
|
-
|
|
473
|
+
if (ourFingerprint) {
|
|
474
|
+
console.log(`Our key ${ourFingerprint} is in the merged keys, access will survive.`);
|
|
475
|
+
}
|
|
416
476
|
|
|
417
477
|
await ensureRevokeRepo({ keyPath, repoURL });
|
|
418
478
|
|
|
@@ -428,20 +488,33 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
|
|
|
428
488
|
}
|
|
429
489
|
|
|
430
490
|
let webhookURL = await requireRemoteWebhook(host);
|
|
431
|
-
console.log(`${host} notifies ${webhookURL}`);
|
|
491
|
+
console.log(`${describeHost(host)} notifies ${webhookURL}`);
|
|
432
492
|
|
|
493
|
+
// Into the home of whoever the daemon runs as, which is where it looks. Asked of the host
|
|
494
|
+
// rather than worked out here: this may be running on a machine with no such user, and with a
|
|
495
|
+
// home directory in an entirely different shape.
|
|
496
|
+
let hostKeysDirectory = await daemonKeysDir(host);
|
|
433
497
|
await writeRemoteFile({
|
|
434
498
|
host,
|
|
435
|
-
filePath:
|
|
499
|
+
filePath: `${hostKeysDirectory}/${sourceName(repoURL)}`,
|
|
436
500
|
contents: await fs.readFile(keyPath, "utf8"),
|
|
437
501
|
fileMode: "600",
|
|
438
502
|
directoryMode: "700",
|
|
439
503
|
});
|
|
504
|
+
// The public half too, so the deploy key that a repo was given can be looked up on the machine
|
|
505
|
+
// using it, rather than only in whatever github shows.
|
|
506
|
+
await writeRemoteFile({
|
|
507
|
+
host,
|
|
508
|
+
filePath: `${hostKeysDirectory}/${sourceName(repoURL)}.pub`,
|
|
509
|
+
contents: await publicKeyOf(keyPath),
|
|
510
|
+
fileMode: "644",
|
|
511
|
+
directoryMode: "700",
|
|
512
|
+
});
|
|
440
513
|
|
|
441
514
|
let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
|
|
442
515
|
repoSources.push(repoURL);
|
|
443
516
|
await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
|
|
444
|
-
console.log(`${repoURL} added to ${host}. ${repoSources.length} source(s) now merged.`);
|
|
517
|
+
console.log(`${repoURL} added to ${describeHost(host)}. ${repoSources.length} source(s) now merged.`);
|
|
445
518
|
}
|
|
446
519
|
|
|
447
520
|
async function removeSource(config: { host: string; repoURL: string }) {
|
|
@@ -455,9 +528,10 @@ async function removeSource(config: { host: string; repoURL: string }) {
|
|
|
455
528
|
}
|
|
456
529
|
let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
|
|
457
530
|
|
|
458
|
-
|
|
531
|
+
// On this machine there is no ssh session to preserve, so there is nothing to check.
|
|
532
|
+
if (repoSources.length && host) {
|
|
459
533
|
// The keys left over are what root gets, so our own key has to be among them.
|
|
460
|
-
console.log(`Checking our access to ${host} survives without ${repoURL}`);
|
|
534
|
+
console.log(`Checking our access to ${describeHost(host)} survives without ${repoURL}`);
|
|
461
535
|
let ourFingerprint = await findAuthenticatingFingerprint(host);
|
|
462
536
|
let remainingKeys: string[] = [];
|
|
463
537
|
for (let source of repoSources) {
|
|
@@ -481,51 +555,19 @@ async function removeSource(config: { host: string; repoURL: string }) {
|
|
|
481
555
|
await runOverSSH({
|
|
482
556
|
host,
|
|
483
557
|
script: `${SUDO_PREAMBLE}
|
|
484
|
-
$SUDO rm -f "${sourceKeyPath(repoURL)}"
|
|
558
|
+
$SUDO rm -f "${sourceKeyPath(repoURL)}" "${sourceKeyPath(repoURL)}.pub" "${legacySourceKeyPath(repoURL)}"
|
|
485
559
|
$SUDO rm -rf "${sourceRepoPath(repoURL)}"`,
|
|
486
560
|
});
|
|
487
561
|
await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
|
|
488
|
-
console.log(`${repoURL} removed from ${host}. ${repoSources.length} source(s) left.`);
|
|
562
|
+
console.log(`${repoURL} removed from ${describeHost(host)}. ${repoSources.length} source(s) left.`);
|
|
489
563
|
}
|
|
490
564
|
|
|
491
565
|
/** Answers "who can log into this box, and which repo says so". The paths the daemon uses are
|
|
492
566
|
left out on purpose, they are plumbing rather than something to act on. */
|
|
493
|
-
/** The daemon is installed from this checkout, so the checkout is brought up to date first.
|
|
494
|
-
Anything that stops the pull - a conflict, local commits, no upstream - stops the update too,
|
|
495
|
-
rather than quietly installing whatever happened to be on disk. */
|
|
496
|
-
async function pullLocalCheckout() {
|
|
497
|
-
let topLevel = await spawnPromise({ command: "git", args: ["rev-parse", "--show-toplevel"], cwd: __dirname });
|
|
498
|
-
if (topLevel.status !== 0) {
|
|
499
|
-
throw new Error(
|
|
500
|
-
`Expected ${__dirname} to be inside a git checkout, it is not.`
|
|
501
|
-
+ ` update installs the daemon from this checkout, so it has to be one.`
|
|
502
|
-
);
|
|
503
|
-
}
|
|
504
|
-
let repoPath = topLevel.stdout.trim();
|
|
505
|
-
let pull = await spawnPromise({ command: "git", args: ["pull", "--ff-only"], cwd: repoPath });
|
|
506
|
-
if (pull.status !== 0) {
|
|
507
|
-
throw new Error(
|
|
508
|
-
`Expected git pull in ${repoPath} to succeed, it did not, so nothing was installed.\n`
|
|
509
|
-
+ `${(pull.stdout + pull.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
// The host installs from github, not from here, so local commits that have not been pushed
|
|
513
|
-
// are not what it will run.
|
|
514
|
-
let local = await spawnPromise({ command: "git", args: ["rev-parse", "HEAD"], cwd: repoPath });
|
|
515
|
-
let remote = await spawnPromise({ command: "git", args: ["rev-parse", "@{u}"], cwd: repoPath });
|
|
516
|
-
if (local.stdout.trim() !== remote.stdout.trim()) {
|
|
517
|
-
console.log(`WARNING: ${repoPath} has commits that are not pushed. The host installs from github,`);
|
|
518
|
-
console.log(` so it will run the pushed version, not what is here.`);
|
|
519
|
-
}
|
|
520
|
-
console.log(`Pulled ${repoPath}`);
|
|
521
|
-
}
|
|
522
567
|
|
|
523
568
|
/** Pushes the current daemon onto a host that already has one, for when this code has moved on.
|
|
524
569
|
Nothing about which keys the host trusts is touched. */
|
|
525
570
|
async function updateDaemon(host: string) {
|
|
526
|
-
// Pulled before anything else, so a checkout that cannot be brought up to date fails here
|
|
527
|
-
// rather than after we have already started changing the host.
|
|
528
|
-
await pullLocalCheckout();
|
|
529
571
|
let contents = await readRemoteFile({ host, filePath: REMOTE_CONFIG_PATH });
|
|
530
572
|
if (!contents) {
|
|
531
573
|
throw new Error(
|
|
@@ -538,16 +580,21 @@ async function updateDaemon(host: string) {
|
|
|
538
580
|
await requireRemoteWebhook(host);
|
|
539
581
|
await ensureRevokeReposExist(repoSources);
|
|
540
582
|
await installDaemon({ host, hostLabel: parsed.hostLabel || host, repoSources });
|
|
541
|
-
console.log(`Updated the daemon on ${host}
|
|
583
|
+
console.log(`Updated the daemon on ${describeHost(host)}, and restarted it.`);
|
|
584
|
+
console.log(`Its ${repoSources.length} key source(s) were left as they are, along with the keys and`);
|
|
585
|
+
console.log(`signers it has already accepted. Only the daemon itself changed:`);
|
|
586
|
+
for (let repoURL of repoSources) {
|
|
587
|
+
console.log(` ${repoURL}`);
|
|
588
|
+
}
|
|
542
589
|
}
|
|
543
590
|
|
|
544
591
|
async function listSources(host: string) {
|
|
545
592
|
let remoteConfig = await readRemoteConfig(host);
|
|
546
593
|
if (!remoteConfig.repoSources.length) {
|
|
547
|
-
console.log(`${host} has no key sources. root's authorized_keys is left exactly as it is.`);
|
|
594
|
+
console.log(`${describeHost(host)} has no key sources. root's authorized_keys is left exactly as it is.`);
|
|
548
595
|
return;
|
|
549
596
|
}
|
|
550
|
-
console.log(`${host} lets root log in with the keys from ${remoteConfig.repoSources.length} repo(s):`);
|
|
597
|
+
console.log(`${describeHost(host)} lets root log in with the keys from ${remoteConfig.repoSources.length} repo(s):`);
|
|
551
598
|
let merged = new Set<string>();
|
|
552
599
|
for (let repoURL of remoteConfig.repoSources) {
|
|
553
600
|
let keys = await readRemoteSourceKeys({ host, repoURL });
|
|
@@ -578,11 +625,16 @@ function parseArgs(argv: string[]) {
|
|
|
578
625
|
throw new Error(`Expected one of ${VERBS.join(", ")}, was ${verbs.join(" and ")}\n${USAGE}`);
|
|
579
626
|
}
|
|
580
627
|
let verb = verbs[0];
|
|
581
|
-
let
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
628
|
+
let positional = argv.filter(arg => arg !== verb);
|
|
629
|
+
// A host, when there is one, comes first and is a bare name or address. Everything else that
|
|
630
|
+
// can appear here is a path or a repo url, and those all carry a slash, which is what tells
|
|
631
|
+
// them apart. So no host at all means this machine.
|
|
632
|
+
let host = THIS_MACHINE;
|
|
633
|
+
if (positional.length && !/[\/~\\]/.test(positional[0])) {
|
|
634
|
+
host = positional[0];
|
|
635
|
+
positional = positional.slice(1);
|
|
636
|
+
}
|
|
637
|
+
return { verb, host, rest: positional };
|
|
586
638
|
}
|
|
587
639
|
|
|
588
640
|
export async function main() {
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// PORTED CODE: nothing here is duplicated any more, the daemon imports it directly. Kept in one
|
|
6
|
+
// place because the daemon and the deploy have to agree on where a source's key and checkout live.
|
|
7
|
+
|
|
8
|
+
// The user's own folder, so this works the same on a machine that has no /etc. The daemon runs as
|
|
9
|
+
// root, so on a host this is root's home.
|
|
10
|
+
export const KEYS_DIR_NAME = "authorized_keys";
|
|
11
|
+
// Where keys used to go. Still read, so a host set up before this keeps working, but nothing is
|
|
12
|
+
// written here any more.
|
|
13
|
+
export const LEGACY_REPO_KEYS_DIR = "/etc/portsecure/repo-keys";
|
|
6
14
|
export const REPOS_DIR = "/var/lib/portsecure/authorized-keys-repos";
|
|
7
15
|
|
|
16
|
+
export function keysDir() {
|
|
17
|
+
return path.join(os.homedir(), KEYS_DIR_NAME);
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
/** A repo url reduced to something usable as a file name. Derived rather than configured, so the
|
|
9
21
|
daemon and the deploy script always agree on where a source's key and checkout live. */
|
|
10
22
|
export function sourceName(repoURL: string) {
|
|
@@ -12,9 +24,39 @@ export function sourceName(repoURL: string) {
|
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
export function sourceKeyPath(repoURL: string) {
|
|
15
|
-
return
|
|
27
|
+
return path.join(keysDir(), sourceName(repoURL));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function legacySourceKeyPath(repoURL: string) {
|
|
31
|
+
return `${LEGACY_REPO_KEYS_DIR}/${sourceName(repoURL)}`;
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
export function sourceRepoPath(repoURL: string) {
|
|
19
35
|
return `${REPOS_DIR}/${sourceName(repoURL)}`;
|
|
20
36
|
}
|
|
37
|
+
|
|
38
|
+
async function pathExists(filePath: string) {
|
|
39
|
+
try {
|
|
40
|
+
await fs.access(filePath);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The key for a source, wherever it happens to be. The user folder is where they go now, and the
|
|
48
|
+
old location is still read so a host that predates the move keeps working. */
|
|
49
|
+
export async function findKey(config: { current: string; legacy: string }) {
|
|
50
|
+
let { current, legacy } = config;
|
|
51
|
+
if (await pathExists(current)) {
|
|
52
|
+
return current;
|
|
53
|
+
}
|
|
54
|
+
if (await pathExists(legacy)) {
|
|
55
|
+
return legacy;
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function findSourceKey(repoURL: string) {
|
|
61
|
+
return await findKey({ current: sourceKeyPath(repoURL), legacy: legacySourceKeyPath(repoURL) });
|
|
62
|
+
}
|
|
@@ -3,18 +3,22 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { runPromise } from "socket-function/src/runPromise";
|
|
5
5
|
import { keyFingerprint, normalizeKeys } from "./authorizedKeys";
|
|
6
|
-
import { revokeRepoURL } from "./revokeSource";
|
|
6
|
+
import { deriveRevokeKey, revokeRepoURL } from "./revokeSource";
|
|
7
|
+
import { findSourceKey } from "./sources";
|
|
8
|
+
import { signRepo } from "../signedFiles/signFiles";
|
|
7
9
|
import { readRepoKeys } from "./authorizedKeys";
|
|
8
10
|
import { expandHome } from "../helpers/paths";
|
|
9
11
|
import { spawnPromise } from "../helpers/spawn";
|
|
10
12
|
|
|
11
13
|
const UNREVOKES_DIR = "unrevoked";
|
|
12
14
|
const REVOCATIONS_DIR = "revocations";
|
|
13
|
-
const
|
|
15
|
+
const GIT_KEYWORD = "git";
|
|
16
|
+
const COMMIT_MESSAGE = "unrevoke keys";
|
|
17
|
+
const USAGE = `Usage: yarn unrevoke [keys-repo] [${GIT_KEYWORD}]
|
|
14
18
|
|
|
15
19
|
Run this in a keys repo, or name one. It reads that repo's revoke repo and writes one unrevoke file
|
|
16
20
|
naming every revocation in it, so the keys are accepted again once each machine's hour long wait
|
|
17
|
-
passes.
|
|
21
|
+
passes. It signs the result, and with ${GIT_KEYWORD} it commits and pushes it too.
|
|
18
22
|
|
|
19
23
|
Keys that were revoked should normally be deleted from the repo instead. Unrevoking only matters
|
|
20
24
|
for a key you still want.`;
|
|
@@ -28,17 +32,25 @@ export type Revocation = {
|
|
|
28
32
|
attempt?: { ip?: string; user?: string; required?: string };
|
|
29
33
|
};
|
|
30
34
|
|
|
31
|
-
/** Every revocation the revoke repo lists. Cloned read only into a temp directory
|
|
32
|
-
|
|
33
|
-
running this has their own access to the repo.
|
|
35
|
+
/** Every revocation the revoke repo lists. Cloned read only into a temp directory.
|
|
36
|
+
|
|
37
|
+
A person running this has their own access to the repo, so their credentials are what is used.
|
|
38
|
+
On a machine that is already a portsecure host there may be no such credentials, but the source
|
|
39
|
+
deploy key is right there, and the key derived from it is one that repo definitely accepts. */
|
|
34
40
|
export async function readRemoteRevocations(config: { sourceURL: string }) {
|
|
35
41
|
let { sourceURL } = config;
|
|
36
42
|
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "unrevoke-"));
|
|
37
43
|
let repoPath = path.join(temporaryDirectory, "repo");
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
let cloneArgs = ["clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath];
|
|
45
|
+
let sourceKey = await findSourceKey(sourceURL);
|
|
46
|
+
if (sourceKey) {
|
|
47
|
+
let derived = deriveRevokeKey(await fs.readFile(sourceKey, "utf8"));
|
|
48
|
+
let derivedKeyPath = path.join(temporaryDirectory, "key");
|
|
49
|
+
await fs.writeFile(derivedKeyPath, derived.privateKeyFile, { mode: 0o600 });
|
|
50
|
+
let sshCommand = `ssh -i ${derivedKeyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
|
|
51
|
+
cloneArgs = ["-c", `core.sshCommand=${sshCommand}`, ...cloneArgs];
|
|
52
|
+
}
|
|
53
|
+
let clone = await spawnPromise({ command: "git", args: cloneArgs });
|
|
42
54
|
if (clone.status !== 0) {
|
|
43
55
|
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
44
56
|
throw new Error(
|
|
@@ -92,17 +104,24 @@ export async function revokedKeysInRepo(config: { repoPath: string; sourceURL: s
|
|
|
92
104
|
repo that actually holds keys and has an origin to clone from. */
|
|
93
105
|
async function resolveKeysRepo(named: string | undefined) {
|
|
94
106
|
let cwd = named && expandHome(named) || undefined;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
// spawnPromise rather than runPromise, because these two are read for their value. runPromise
|
|
108
|
+
// returns stdout and stderr joined, so one git warning ends up glued to the front of the path.
|
|
109
|
+
let topLevel = await spawnPromise({ command: "git", args: ["rev-parse", "--show-toplevel"], cwd });
|
|
110
|
+
let repoPath = topLevel.stdout.trim();
|
|
111
|
+
if (topLevel.status !== 0 || !repoPath) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Expected ${named || "the current directory"} to be inside a git repo, it is not.\n`
|
|
114
|
+
+ `${(topLevel.stdout + topLevel.stderr).trim()}\n${USAGE}`
|
|
115
|
+
);
|
|
98
116
|
}
|
|
99
117
|
try {
|
|
100
118
|
await readRepoKeys(repoPath);
|
|
101
119
|
} catch (e) {
|
|
102
120
|
throw new Error(`Expected ${repoPath} to be a keys repo, it holds no keys.\n${e}\n${USAGE}`);
|
|
103
121
|
}
|
|
104
|
-
let
|
|
105
|
-
|
|
122
|
+
let origin = await spawnPromise({ command: "git", args: ["remote", "get-url", "origin"], cwd: repoPath });
|
|
123
|
+
let originURL = origin.stdout.trim();
|
|
124
|
+
if (origin.status !== 0 || !originURL) {
|
|
106
125
|
throw new Error(`Expected ${repoPath} to have an origin remote, it has none.\n${USAGE}`);
|
|
107
126
|
}
|
|
108
127
|
return { repoPath, originURL };
|
|
@@ -110,10 +129,12 @@ async function resolveKeysRepo(named: string | undefined) {
|
|
|
110
129
|
|
|
111
130
|
export async function main() {
|
|
112
131
|
let argv = process.argv.slice(2);
|
|
113
|
-
|
|
114
|
-
|
|
132
|
+
let pushToGit = argv.includes(GIT_KEYWORD);
|
|
133
|
+
let positional = argv.filter(arg => arg !== GIT_KEYWORD);
|
|
134
|
+
if (positional.length > 1) {
|
|
135
|
+
throw new Error(`Expected at most a keys repo, was ${positional.length} argument(s)\n${USAGE}`);
|
|
115
136
|
}
|
|
116
|
-
let { repoPath, originURL } = await resolveKeysRepo(
|
|
137
|
+
let { repoPath, originURL } = await resolveKeysRepo(positional[0]);
|
|
117
138
|
|
|
118
139
|
let revocations = await readRemoteRevocations({ sourceURL: originURL });
|
|
119
140
|
if (!revocations.length) {
|
|
@@ -144,6 +165,20 @@ export async function main() {
|
|
|
144
165
|
for (let revocation of revocations) {
|
|
145
166
|
console.log(` ${revocation.fingerprint} revoked by ${revocation.revokedBy || "?"} from ${revocation.attempt?.ip || "?"}`);
|
|
146
167
|
}
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
|
|
169
|
+
// Signed here rather than left as a step to remember. An unrevoke nobody signed does nothing at
|
|
170
|
+
// all, and the repo would sit there looking done while every machine ignored it.
|
|
171
|
+
await signRepo({ repoPath });
|
|
172
|
+
|
|
173
|
+
if (!pushToGit) {
|
|
174
|
+
console.log(`\nCommit and push it, and each machine will wait an hour after seeing it before`);
|
|
175
|
+
console.log(`those keys work again:`);
|
|
176
|
+
console.log(`\`\`\`\ngit add -A\ngit commit -m "${COMMIT_MESSAGE}"\ngit push\n\`\`\``);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
await runPromise(`git add -A`, { cwd: repoPath });
|
|
180
|
+
await runPromise(`git commit -m "${COMMIT_MESSAGE}"`, { cwd: repoPath });
|
|
181
|
+
await runPromise(`git push`, { cwd: repoPath });
|
|
182
|
+
console.log(`\nCommitted and pushed. Each machine waits an hour after seeing it before those`);
|
|
183
|
+
console.log(`keys work again.`);
|
|
149
184
|
}
|
|
@@ -8,28 +8,39 @@ const MAX_ERROR_BODY_LENGTH = 500;
|
|
|
8
8
|
// /etc is root owned, so fall back to sudo whenever the SSH user is not root.
|
|
9
9
|
export const SUDO_PREAMBLE = `SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo -n"; fi`;
|
|
10
10
|
|
|
11
|
+
/** No host means this machine, so the very same script runs with nothing in front of it. Local and
|
|
12
|
+
remote then do exactly the same thing, rather than being two pieces of code that have to be kept
|
|
13
|
+
saying the same thing. */
|
|
14
|
+
export const THIS_MACHINE = "";
|
|
15
|
+
|
|
16
|
+
export function describeHost(host: string) {
|
|
17
|
+
return host || "this machine";
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/** The host string is handed to ssh untouched. Users, keys and ports belong in the caller's ssh
|
|
12
21
|
config, so BatchMode makes a missing setup fail immediately instead of prompting. */
|
|
13
22
|
export async function runOverSSH(config: { host: string; script: string; input?: string; allowFailure?: boolean }) {
|
|
14
23
|
let { host, script, input, allowFailure } = config;
|
|
15
24
|
let result = await spawnPromise({
|
|
16
|
-
command: "ssh",
|
|
17
|
-
args: [
|
|
25
|
+
command: host && "ssh" || "sh",
|
|
26
|
+
args: host && [
|
|
18
27
|
"-o", "BatchMode=yes",
|
|
19
28
|
"-o", `ConnectTimeout=${SSH_CONNECT_TIMEOUT}`,
|
|
20
29
|
host,
|
|
21
30
|
script,
|
|
22
|
-
],
|
|
31
|
+
] || ["-c", script],
|
|
23
32
|
input,
|
|
24
33
|
inheritStderr: !allowFailure,
|
|
25
34
|
});
|
|
26
35
|
if (result.error) {
|
|
27
|
-
throw new Error(`Expected
|
|
36
|
+
throw new Error(`Expected to run against ${describeHost(host)}, failed with ${result.error.message}`);
|
|
28
37
|
}
|
|
29
38
|
if (result.status !== 0 && !allowFailure) {
|
|
39
|
+
let advice = host
|
|
40
|
+
&& ` Non-interactive ssh access to ${host} has to work on its own - fix it in your ssh config.`
|
|
41
|
+
|| "";
|
|
30
42
|
throw new Error(
|
|
31
|
-
`Expected
|
|
32
|
-
+ ` Non-interactive ssh access to ${host} has to work on its own - fix it in your ssh config.\n`
|
|
43
|
+
`Expected the script on ${describeHost(host)} to exit 0, was ${result.status}.${advice}\n`
|
|
33
44
|
+ `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
34
45
|
);
|
|
35
46
|
}
|
|
@@ -72,8 +72,25 @@ async function readWebhookFile(filePath: string) {
|
|
|
72
72
|
return parseWebhookFile({ contents, sourceName: filePath });
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** When the message was sent, in the sending machine's own time zone. Discord shows when it
|
|
76
|
+
received something, which is not the same thing when a machine has been offline or a send has
|
|
77
|
+
been retried, and the zone matters when the machines are not all in one place. */
|
|
78
|
+
export function messageTimestamp(now: Date) {
|
|
79
|
+
let date = now.toLocaleDateString("en-CA");
|
|
80
|
+
let time = now.toLocaleTimeString("en-US", {
|
|
81
|
+
hour: "2-digit",
|
|
82
|
+
minute: "2-digit",
|
|
83
|
+
second: "2-digit",
|
|
84
|
+
hour12: true,
|
|
85
|
+
timeZoneName: "short",
|
|
86
|
+
});
|
|
87
|
+
return `${date} ${time}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
async function postToWebhook(webhookURL: string, message: string) {
|
|
76
|
-
|
|
91
|
+
// Stamped once rather than per attempt, so a retry says when the thing happened rather than
|
|
92
|
+
// when we last managed to get it out.
|
|
93
|
+
let content = `\`${messageTimestamp(new Date())}\` ${message}`;
|
|
77
94
|
if (content.length > DISCORD_MESSAGE_LIMIT) {
|
|
78
95
|
content = content.slice(0, DISCORD_MESSAGE_LIMIT - 3) + "...";
|
|
79
96
|
}
|