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.
@@ -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 repo url is optional, and defaults to the repo the command is run from.
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 <host> add <repo-private-key> [repo-url]
28
- yarn securessh <host> remove [repo-url]
29
- yarn securessh <host> list
30
- yarn securessh <host> update`;
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
- let clone = await runLocal({ command: "git", args: ["clone", revokeURL, checkoutPath], allowFailure: true });
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
- console.log(`Checking our access to ${host} survives the merged keys`);
400
- let ourFingerprint = await findAuthenticatingFingerprint(host);
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
- console.log(`Our key ${ourFingerprint} is in the merged keys, access will survive.`);
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: sourceKeyPath(repoURL),
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
- if (repoSources.length) {
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}. ${repoSources.length} source(s), unchanged.`);
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 [host, ...rest] = argv.filter(arg => arg !== verb);
582
- if (!host) {
583
- throw new Error(`Expected a host, was nothing\n${USAGE}`);
584
- }
585
- return { verb, host, rest };
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
- // PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js contains a plain JS port of everything here, so it can
2
- // resolve the same locations with no dependencies. Both sides must derive identical paths from a
3
- // repo url - if you change one, make the matching change in the other.
1
+ import fs from "fs/promises";
2
+ import os from "os";
3
+ import path from "path";
4
4
 
5
- export const REPO_KEYS_DIR = "/etc/portsecure/repo-keys";
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 `${REPO_KEYS_DIR}/${sourceName(repoURL)}`;
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 USAGE = `Usage: yarn unrevoke [keys-repo]
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, with whatever
32
- credentials this machine already has - the derived deploy key is for servers, and a person
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 clone = await spawnPromise({
39
- command: "git",
40
- args: ["clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath],
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
- let repoPath = (await runPromise("git rev-parse --show-toplevel", { quiet: true, cwd })).trim();
96
- if (!repoPath) {
97
- throw new Error(`Expected ${named || "the current directory"} to be inside a git repo, it is not.\n${USAGE}`);
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 originURL = (await runPromise("git remote get-url origin", { quiet: true, cwd: repoPath })).trim();
105
- if (!originURL) {
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
- if (argv.length > 1) {
114
- throw new Error(`Expected at most a keys repo, was ${argv.length} argument(s)\n${USAGE}`);
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(argv[0]);
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
- console.log(`Each machine waits an hour after seeing it before the keys work again.`);
148
- console.log(`Sign and publish it with:\n yarn signfiles git`);
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 ssh to run against ${host}, failed with ${result.error.message}`);
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 ssh to ${host} to exit 0, was ${result.status}.`
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
- let content = message;
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
  }