sliftutils 1.7.128 → 1.7.130

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.
Files changed (55) hide show
  1. package/bin/addmachine.js +9 -0
  2. package/bin/machineclient.js +9 -0
  3. package/bin/machineserver.js +10 -0
  4. package/index.d.ts +15 -0
  5. package/misc/dist/ownIPs.ts.cache +69 -0
  6. package/misc/ownIPs.d.ts +11 -0
  7. package/misc/ownIPs.ts +62 -0
  8. package/package.json +9 -3
  9. package/security/authorizedKeys/authorizedKeys.ts +34 -0
  10. package/security/authorizedKeys/daemon/authLog.ts +12 -5
  11. package/security/authorizedKeys/daemon/changes.ts +16 -1
  12. package/security/authorizedKeys/daemon/daemon.ts +43 -34
  13. package/security/authorizedKeys/daemon/dist/changes.ts.cache +40 -0
  14. package/security/authorizedKeys/daemon/dist/git.ts.cache +121 -0
  15. package/security/authorizedKeys/daemon/dist/notify.ts.cache +39 -0
  16. package/security/authorizedKeys/daemon/dist/paths.ts.cache +26 -0
  17. package/security/authorizedKeys/daemon/dist/repoFiles.ts.cache +114 -0
  18. package/security/authorizedKeys/daemon/dist/revocation.ts.cache +0 -0
  19. package/security/authorizedKeys/daemon/dist/sessions.ts.cache +147 -0
  20. package/security/authorizedKeys/daemon/dist/state.ts.cache +76 -0
  21. package/security/authorizedKeys/daemon/dist/trust.ts.cache +295 -0
  22. package/security/authorizedKeys/daemon/notify.ts +8 -2
  23. package/security/authorizedKeys/daemon/paths.ts +0 -3
  24. package/security/authorizedKeys/daemon/repoFiles.ts +118 -0
  25. package/security/authorizedKeys/daemon/revocation.ts +0 -0
  26. package/security/authorizedKeys/daemon/rootKeys.ts +77 -10
  27. package/security/authorizedKeys/daemon/state.ts +21 -7
  28. package/security/authorizedKeys/daemon/trust.ts +37 -16
  29. package/security/authorizedKeys/daemon/userKeys.ts +5 -4
  30. package/security/authorizedKeys/dist/authorizedKeys.ts.cache +37 -3
  31. package/security/authorizedKeys/dist/keysRepo.ts.cache +111 -0
  32. package/security/authorizedKeys/dist/revokeSource.ts.cache +17 -4
  33. package/security/authorizedKeys/dist/secureSSH.ts.cache +147 -71
  34. package/security/authorizedKeys/dist/sources.ts.cache +52 -8
  35. package/security/authorizedKeys/dist/unrevoke.ts.cache +159 -44
  36. package/security/authorizedKeys/keysRepo.ts +111 -0
  37. package/security/authorizedKeys/secureSSH.ts +66 -58
  38. package/security/authorizedKeys/unrevoke.ts +160 -36
  39. package/security/helpers/dist/remoteSSH.ts.cache +19 -9
  40. package/security/machines/addMachine.ts +84 -0
  41. package/security/machines/dist/addMachine.ts.cache +88 -0
  42. package/security/machines/dist/identity.ts.cache +176 -0
  43. package/security/machines/dist/machines.ts.cache +264 -0
  44. package/security/machines/dist/trustClient.ts.cache +48 -0
  45. package/security/machines/dist/trustState.ts.cache +57 -0
  46. package/security/machines/identity.ts +199 -0
  47. package/security/machines/machines.ts +304 -0
  48. package/security/machines/trustClient.ts +51 -0
  49. package/security/machines/trustServer.ts +87 -0
  50. package/security/notifications/discord.ts +14 -5
  51. package/security/notifications/dist/discord.ts.cache +17 -8
  52. package/security/notifications/dist/remoteWebhook.ts.cache +78 -0
  53. package/security/notifications/dist/setupNotify.ts.cache +39 -0
  54. package/security/signedFiles/dist/signFiles.ts.cache +8 -10
  55. package/security/signedFiles/signFiles.ts +6 -10
@@ -7,6 +7,7 @@ import { findSourceKey, KEYS_DIR_NAME, sourceKeyPath, sourceName, sourceRepoPath
7
7
  import { deriveRevokeKey, REVOKE_KEY_LABEL, revokeRepoURL } from "./revokeSource";
8
8
  import { legacySourceKeyPath } from "./sources";
9
9
  import { revokedKeysInRepo } from "./unrevoke";
10
+ import { resolveKeysRepo } from "./keysRepo";
10
11
  import { expandHome } from "../helpers/paths";
11
12
  import { spawnPromise } from "../helpers/spawn";
12
13
  import { describeHost, readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, THIS_MACHINE, writeRemoteFile } from "../helpers/remoteSSH";
@@ -155,38 +156,15 @@ async function cloneRepoForInspection(config: { repoURL: string; keyPath: string
155
156
  return repoPath;
156
157
  }
157
158
 
158
- /** With no repo url given, the repo we are standing in is used. It has to actually hold keys
159
- before we hand it to a host, so a stray working directory cannot be deployed by accident. */
159
+ /** With no repo url given, the keys repo this machine resolves to is used - the one we are
160
+ standing in when that holds keys, and this machine's own otherwise. */
160
161
  async function resolveRepoURL(passedURL: string | undefined) {
161
162
  if (passedURL) {
162
163
  return normalizeRepoURL(passedURL);
163
164
  }
164
- let topLevel = await runLocal({ command: "git", args: ["rev-parse", "--show-toplevel"], allowFailure: true });
165
- if (topLevel.status !== 0) {
166
- throw new Error(`Expected a repo url, or the current directory to be inside a git repo, it is not.\n${USAGE}`);
167
- }
168
- let repoPath = topLevel.stdout.trim();
169
- try {
170
- await readRepoKeys(repoPath);
171
- } catch (e) {
172
- throw new Error(
173
- `Expected a repo url, or the current repo (${repoPath}) to hold keys, it does not.\n${e}\n${USAGE}`
174
- );
175
- }
176
- let origin = await runLocal({
177
- command: "git",
178
- args: ["remote", "get-url", "origin"],
179
- cwd: repoPath,
180
- allowFailure: true,
181
- });
182
- if (origin.status !== 0 || !origin.stdout.trim()) {
183
- throw new Error(
184
- `Expected the current repo (${repoPath}) to have an origin remote, it has none.`
185
- + ` The host clones the source itself, so a local path is no use to it.`
186
- );
187
- }
188
- let repoURL = normalizeRepoURL(origin.stdout.trim());
189
- console.log(`No repo url given, using the current repo: ${repoURL}`);
165
+ let { repoPath, sourceURL } = await resolveKeysRepo();
166
+ let repoURL = normalizeRepoURL(sourceURL);
167
+ console.log(`No repo url given, using ${repoPath}: ${repoURL}`);
190
168
  return repoURL;
191
169
  }
192
170
 
@@ -449,6 +427,31 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
449
427
  console.log(`${describeHost(host)} already has ${repoURL}, refreshing its key and the daemon.`);
450
428
  }
451
429
 
430
+ // Copied into place before anything else needs it. The checks below look this source's key up
431
+ // where the machine keeps it, not at the path it was handed on the command line, so a key that
432
+ // is still only sitting in the caller's directory reads as no key at all.
433
+ //
434
+ // Into the home of whoever the daemon runs as, which is where it looks. Asked of the host
435
+ // rather than worked out here: this may be running on a machine with no such user, and with a
436
+ // home directory in an entirely different shape.
437
+ let hostKeysDirectory = await daemonKeysDir(host);
438
+ await writeRemoteFile({
439
+ host,
440
+ filePath: `${hostKeysDirectory}/${sourceName(repoURL)}`,
441
+ contents: await fs.readFile(keyPath, "utf8"),
442
+ fileMode: "600",
443
+ directoryMode: "700",
444
+ });
445
+ // The public half too, so the deploy key that a repo was given can be looked up on the machine
446
+ // using it, rather than only in whatever github shows.
447
+ await writeRemoteFile({
448
+ host,
449
+ filePath: `${hostKeysDirectory}/${sourceName(repoURL)}.pub`,
450
+ contents: await publicKeyOf(keyPath),
451
+ fileMode: "644",
452
+ directoryMode: "700",
453
+ });
454
+
452
455
  // The merged result is what root ends up with, so our own key has to be somewhere in it. On
453
456
  // this machine there is no ssh session to preserve, so there is nothing to check.
454
457
  let ourFingerprint = "";
@@ -490,27 +493,6 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
490
493
  let webhookURL = await requireRemoteWebhook(host);
491
494
  console.log(`${describeHost(host)} notifies ${webhookURL}`);
492
495
 
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);
497
- await writeRemoteFile({
498
- host,
499
- filePath: `${hostKeysDirectory}/${sourceName(repoURL)}`,
500
- contents: await fs.readFile(keyPath, "utf8"),
501
- fileMode: "600",
502
- directoryMode: "700",
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
- });
513
-
514
496
  let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
515
497
  repoSources.push(repoURL);
516
498
  await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
@@ -634,32 +616,58 @@ function parseArgs(argv: string[]) {
634
616
  host = positional[0];
635
617
  positional = positional.slice(1);
636
618
  }
619
+
620
+ // How many arguments each verb takes, checked here rather than where the verb is acted on, so
621
+ // that a command which was typed wrong says so before anything else is complained about.
622
+ if (verb === "list" && positional.length) {
623
+ throw new Error(`Expected nothing after list, was ${positional.length} argument(s)\n${USAGE}`);
624
+ }
625
+ if (verb === "update" && positional.length) {
626
+ throw new Error(`Expected nothing after update, was ${positional.length} argument(s)\n${USAGE}`);
627
+ }
628
+ if (verb === "add" && (!positional.length || positional.length > 2)) {
629
+ throw new Error(`Expected a private key and optionally a repo url, was ${positional.length} argument(s)\n${USAGE}`);
630
+ }
631
+ if (verb === "remove" && positional.length > 1) {
632
+ throw new Error(`Expected at most a repo url to remove, was ${positional.length} argument(s)\n${USAGE}`);
633
+ }
637
634
  return { verb, host, rest: positional };
638
635
  }
639
636
 
637
+ /** Everything here is root's: root's authorized_keys, root's keys directory, the sshd config, the
638
+ systemd unit. Run as anyone else it reads a different home and writes files the daemon will
639
+ never see, and it gets there several steps in rather than failing at the start. */
640
+ function requireRoot() {
641
+ let uid = process.getuid?.();
642
+ if (uid !== 0) {
643
+ throw new Error(
644
+ `Expected to be running as root, is uid ${uid ?? "unknown"}.\n`
645
+ + `Rerun this with sudo.`
646
+ );
647
+ }
648
+ }
649
+
640
650
  export async function main() {
651
+ // What was typed is checked before anything about this machine is. A command that does not
652
+ // make sense is worth saying so about whoever is running it, and being told to find sudo only
653
+ // to then be told the arguments were wrong is two trips for one mistake.
641
654
  let { verb, host, rest } = parseArgs(process.argv.slice(2));
655
+ // With a host, everything root-owned happens on that host through $SUDO over ssh, so being root here proves nothing and would block running this from a workstation.
656
+ if (host === THIS_MACHINE) {
657
+ requireRoot();
658
+ }
642
659
 
643
660
  if (verb === "list") {
644
661
  await listSources(host);
645
662
  return;
646
663
  }
647
664
  if (verb === "update") {
648
- if (rest.length) {
649
- throw new Error(`Expected nothing after update, was ${rest.length} argument(s)\n${USAGE}`);
650
- }
651
665
  await updateDaemon(host);
652
666
  return;
653
667
  }
654
668
  if (verb === "add") {
655
- if (!rest.length || rest.length > 2) {
656
- throw new Error(`Expected a private key and optionally a repo url, was ${rest.length} argument(s)\n${USAGE}`);
657
- }
658
669
  await addSource({ host, keyPath: expandHome(rest[0]), repoURL: await resolveRepoURL(rest[1]) });
659
670
  return;
660
671
  }
661
- if (rest.length > 1) {
662
- throw new Error(`Expected at most a repo url to remove, was ${rest.length} argument(s)\n${USAGE}`);
663
- }
664
672
  await removeSource({ host, repoURL: await resolveRepoURL(rest[0]) });
665
673
  }
@@ -2,13 +2,15 @@ import fs from "fs/promises";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { runPromise } from "socket-function/src/runPromise";
5
- import { keyFingerprint, normalizeKeys } from "./authorizedKeys";
5
+ import { allowAddresses, keyFingerprint, normalizeKeys, summarizeKey } from "./authorizedKeys";
6
6
  import { deriveRevokeKey, revokeRepoURL } from "./revokeSource";
7
7
  import { findSourceKey } from "./sources";
8
8
  import { signRepo } from "../signedFiles/signFiles";
9
9
  import { readRepoKeys } from "./authorizedKeys";
10
10
  import { expandHome } from "../helpers/paths";
11
11
  import { spawnPromise } from "../helpers/spawn";
12
+ import { getMachines, setMachines } from "../machines/machines";
13
+ import { resolveKeysRepo } from "./keysRepo";
12
14
 
13
15
  const UNREVOKES_DIR = "unrevoked";
14
16
  const REVOCATIONS_DIR = "revocations";
@@ -17,21 +19,41 @@ const COMMIT_MESSAGE = "unrevoke keys";
17
19
  const USAGE = `Usage: yarn unrevoke [keys-repo] [${GIT_KEYWORD}]
18
20
 
19
21
  Run this in a keys repo, or name one. It reads that repo's revoke repo and writes one unrevoke file
20
- naming every revocation in it, so the keys are accepted again once each machine's hour long wait
21
- passes. It signs the result, and with ${GIT_KEYWORD} it commits and pushes it too.
22
+ naming every revocation in it, so the keys are accepted again as each machine picks it up. It signs
23
+ the result, and with ${GIT_KEYWORD} it commits and pushes it too.
22
24
 
23
25
  Keys that were revoked should normally be deleted from the repo instead. Unrevoking only matters
24
26
  for a key you still want.`;
25
27
 
28
+ /** One revocation event. Either an ssh key used from an address it is not allowed from, or a
29
+ machine that talked to us from one. Both are the same shape - an identity and an address - and
30
+ an unrevoke undoes either by allowing that same pair.
31
+
32
+ Which is why unrevoking never makes anything unrevokable: used from some other address, it is
33
+ revoked again, under a new id no existing unrevoke names. */
26
34
  export type Revocation = {
27
35
  revocationId: string;
28
- fingerprint: string;
36
+ // Exactly one of these. A key revocation names a fingerprint, a machine revocation a machineId.
37
+ fingerprint?: string;
38
+ machineId?: string;
39
+ ip?: string;
29
40
  key?: string;
30
41
  revokedAt?: string;
31
42
  revokedBy?: string;
43
+ reason?: string;
32
44
  attempt?: { ip?: string; user?: string; required?: string };
33
45
  };
34
46
 
47
+ /** Older revocation files carry the address under attempt only. */
48
+ export function revocationIP(revocation: Revocation) {
49
+ return revocation.ip || revocation.attempt?.ip || "";
50
+ }
51
+
52
+ /** What the revocation is about, which is what an unrevoke has to name to undo it. */
53
+ export function revocationIdentity(revocation: Revocation) {
54
+ return revocation.fingerprint || revocation.machineId || "";
55
+ }
56
+
35
57
  /** Every revocation the revoke repo lists. Cloned read only into a temp directory.
36
58
 
37
59
  A person running this has their own access to the repo, so their credentials are what is used.
@@ -86,6 +108,10 @@ export async function revokedKeysInRepo(config: { repoPath: string; sourceURL: s
86
108
  continue;
87
109
  }
88
110
  let parsed = JSON.parse(await fs.readFile(path.join(directory, name), "utf8"));
111
+ for (let allowed of parsed.allowed || []) {
112
+ unrevoked.add(`${allowed.fingerprint || allowed.machineId} ${allowed.ip}`);
113
+ }
114
+ // Named ids, for the unrevokes written before this was about pairs.
89
115
  for (let revocationId of parsed.revocationIds || []) {
90
116
  unrevoked.add(revocationId);
91
117
  }
@@ -93,40 +119,85 @@ export async function revokedKeysInRepo(config: { repoPath: string; sourceURL: s
93
119
  } catch (e) {
94
120
  // Nothing has been unrevoked.
95
121
  }
96
- let stillRevoked = revocations.filter(revocation => !unrevoked.has(revocation.revocationId));
122
+ let stillRevoked = revocations.filter(revocation => !unrevoked.has(revocation.revocationId)
123
+ && !unrevoked.has(`${revocation.fingerprint} ${revocationIP(revocation)}`));
97
124
  let keys = normalizeKeys(await fs.readFile(path.join(repoPath, "authorized_keys"), "utf8").catch(() => ""));
98
125
  return stillRevoked
99
126
  .filter(revocation => keys.some(key => keyFingerprint(key) === revocation.fingerprint))
100
127
  .map(revocation => ({ revocation, key: keys.find(key => keyFingerprint(key) === revocation.fingerprint) || "" }));
101
128
  }
102
129
 
103
- /** The repo named, or the one we are standing in. Checked the same way securessh checks it: a git
104
- repo that actually holds keys and has an origin to clone from. */
105
- async function resolveKeysRepo(named: string | undefined) {
106
- let cwd = named && expandHome(named) || undefined;
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
- );
116
- }
130
+ /** Writes the addresses into the from= list of each key in the repo's authorized_keys, and says
131
+ which ones were actually new. Only the lines being changed are touched, so comments, ordering
132
+ and any key not involved come out of this byte for byte the same. */
133
+ async function allowAddressesInRepo(config: {
134
+ repoPath: string;
135
+ addressesByFingerprint: Map<string, string[]>;
136
+ }) {
137
+ let { repoPath, addressesByFingerprint } = config;
138
+ let added = new Map<string, string[]>();
139
+ let filePath = path.join(repoPath, "authorized_keys");
140
+ let contents;
117
141
  try {
118
- await readRepoKeys(repoPath);
142
+ contents = await fs.readFile(filePath, "utf8");
119
143
  } catch (e) {
120
- throw new Error(`Expected ${repoPath} to be a keys repo, it holds no keys.\n${e}\n${USAGE}`);
144
+ // A repo holding its keys as separate .pub files. Nothing to edit in one place, so the
145
+ // caller's listing is all whoever ran this gets.
146
+ console.log(`No authorized_keys in ${repoPath}, so the addresses were not added to any key.`);
147
+ return added;
148
+ }
149
+ let lines = contents.split("\n");
150
+ for (let index = 0; index < lines.length; index++) {
151
+ let fingerprint = keyFingerprint(lines[index]);
152
+ let addresses = fingerprint && addressesByFingerprint.get(fingerprint);
153
+ if (!addresses || !addresses.length) {
154
+ continue;
155
+ }
156
+ let result = allowAddresses(lines[index], addresses);
157
+ lines[index] = result.keyLine;
158
+ added.set(fingerprint, [...(added.get(fingerprint) || []), ...result.added]);
159
+ }
160
+ if ([...added.values()].some(list => list.length)) {
161
+ await fs.writeFile(filePath, lines.join("\n"));
162
+ }
163
+ return added;
164
+ }
165
+
166
+ /** Puts each machine's addresses back into its own file, which is the machine equivalent of adding
167
+ an address to a key's from= list: without it the machine is allowed again in principle and
168
+ still rejected on sight, which revokes it once more. */
169
+ async function allowAddressesForMachines(config: { repoPath: string; revocations: Revocation[] }) {
170
+ let { repoPath, revocations } = config;
171
+ let added = new Map<string, string[]>();
172
+ // Read the whole list, change what these revocations are about, write it back. Setting is by
173
+ // the whole set, so writing one machine at a time would delete every other machine.
174
+ let machines = await getMachines(repoPath);
175
+ for (let revocation of revocations) {
176
+ let machineId = revocation.machineId;
177
+ let ip = revocationIP(revocation);
178
+ if (!machineId || !ip) {
179
+ continue;
180
+ }
181
+ let existing = machines.find(machine => machine.machineId === machineId);
182
+ if (!existing) {
183
+ // Removed from the repo since it was revoked, so there is nothing to allow. Deleting a
184
+ // machine is the stronger statement and it stands.
185
+ console.log(`machines/${machineId}.json is not in this repo, so ${ip} was not added to it`);
186
+ continue;
187
+ }
188
+ if (existing.ips.includes(ip)) {
189
+ continue;
190
+ }
191
+ existing.ips = [...existing.ips, ip];
192
+ added.set(machineId, [...(added.get(machineId) || []), ip]);
121
193
  }
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) {
125
- throw new Error(`Expected ${repoPath} to have an origin remote, it has none.\n${USAGE}`);
194
+ if (added.size) {
195
+ await setMachines({ repoPath, machines });
126
196
  }
127
- return { repoPath, originURL };
197
+ return added;
128
198
  }
129
199
 
200
+
130
201
  export async function main() {
131
202
  let argv = process.argv.slice(2);
132
203
  let pushToGit = argv.includes(GIT_KEYWORD);
@@ -134,7 +205,7 @@ export async function main() {
134
205
  if (positional.length > 1) {
135
206
  throw new Error(`Expected at most a keys repo, was ${positional.length} argument(s)\n${USAGE}`);
136
207
  }
137
- let { repoPath, originURL } = await resolveKeysRepo(positional[0]);
208
+ let { repoPath, sourceURL: originURL } = await resolveKeysRepo(positional[0]);
138
209
 
139
210
  let revocations = await readRemoteRevocations({ sourceURL: originURL });
140
211
  if (!revocations.length) {
@@ -149,36 +220,89 @@ export async function main() {
149
220
  await fs.writeFile(path.join(directory, `${unrevokeId}.json`), JSON.stringify({
150
221
  unrevokeId,
151
222
  createdAt: new Date().toISOString(),
152
- // Named one by one rather than as a blanket "allow everything again", so this file only
153
- // ever undoes the revocations that existed when it was written.
154
- revocationIds: revocations.map(revocation => revocation.revocationId),
223
+ // What this file says: each of these keys is allowed from this address. Pairs, not keys,
224
+ // and not a blanket "allow everything again" - a key forgiven here for one address is
225
+ // still revoked the moment it is used from another.
226
+ allowed: revocations.map(revocation => ({
227
+ fingerprint: revocation.fingerprint,
228
+ machineId: revocation.machineId,
229
+ ip: revocationIP(revocation),
230
+ })),
155
231
  revocations: revocations.map(revocation => ({
156
232
  revocationId: revocation.revocationId,
157
233
  fingerprint: revocation.fingerprint,
234
+ machineId: revocation.machineId,
235
+ ip: revocationIP(revocation),
158
236
  revokedAt: revocation.revokedAt,
159
237
  revokedBy: revocation.revokedBy,
160
238
  attempt: revocation.attempt,
161
239
  })),
162
240
  }, undefined, 4) + "\n");
163
241
 
164
- console.log(`Wrote ${UNREVOKES_DIR}/${unrevokeId}.json covering ${revocations.length} revocation(s):`);
242
+ // Named the way the notifications name them. A fingerprint is not in authorized_keys and
243
+ // cannot be worked out by eye, so on its own it tells whoever is reading this nothing about
244
+ // which of their keys is being talked about.
245
+ let keysByFingerprint = new Map<string, string>();
246
+ for (let key of await readRepoKeys(repoPath).catch(() => [] as string[])) {
247
+ keysByFingerprint.set(keyFingerprint(key), key);
248
+ }
249
+ let describeKey = (revocation: Revocation) => {
250
+ let key = keysByFingerprint.get(revocation.fingerprint || "") || revocation.key || "";
251
+ return key && summarizeKey(key) || `a key no longer in this repo (${revocation.fingerprint})`;
252
+ };
253
+
254
+ let addressesByIdentity = new Map<string, string[]>();
165
255
  for (let revocation of revocations) {
166
- console.log(` ${revocation.fingerprint} revoked by ${revocation.revokedBy || "?"} from ${revocation.attempt?.ip || "?"}`);
256
+ let ip = revocationIP(revocation);
257
+ let identity = revocationIdentity(revocation);
258
+ let existing = addressesByIdentity.get(identity) || [];
259
+ if (ip && !existing.includes(ip)) {
260
+ existing.push(ip);
261
+ }
262
+ addressesByIdentity.set(identity, existing);
263
+ }
264
+
265
+ // Cancelling the revocation on its own would leave sshd refusing that address, and the machines
266
+ // revoking all over again, so the addresses go into the key's from= list and into the machine's
267
+ // own file here. We are in the repo that owns both, so there is nothing to hand back to whoever
268
+ // ran this.
269
+ let added = await allowAddressesInRepo({ repoPath, addressesByFingerprint: addressesByIdentity });
270
+ let addedForMachines = await allowAddressesForMachines({ repoPath, revocations });
271
+
272
+ console.log("");
273
+ for (let [identity, addresses] of addressesByIdentity) {
274
+ let entries = revocations.filter(entry => revocationIdentity(entry) === identity);
275
+ let machineId = entries[0].machineId;
276
+ console.log(`Unfreezing ${machineId && `machine ${machineId}` || describeKey(entries[0])}`);
277
+ console.log(`Allowing IP ${addresses.join(", ") || "(no address recorded)"}`);
278
+ let addedHere = machineId && addedForMachines.get(machineId) || added.get(identity) || [];
279
+ if (addedHere.length) {
280
+ let where = machineId && `machines/${machineId}.json` || `that key's from= list in authorized_keys`;
281
+ console.log(` added to ${where}: ${addedHere.join(", ")}`);
282
+ } else {
283
+ console.log(` already allowed from ${addresses.join(", ") || "everywhere"}`);
284
+ }
285
+ for (let entry of entries) {
286
+ console.log(
287
+ ` frozen by ${entry.revokedBy || "?"} ${entry.revokedAt || "at an unrecorded time"}`
288
+ );
289
+ }
290
+ console.log("");
167
291
  }
292
+ console.log(`Every other address stays frozen, and any of these used from anywhere else is`);
293
+ console.log(`frozen again.`);
168
294
 
169
295
  // Signed here rather than left as a step to remember. An unrevoke nobody signed does nothing at
170
296
  // all, and the repo would sit there looking done while every machine ignored it.
171
297
  await signRepo({ repoPath });
172
298
 
173
299
  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:`);
300
+ console.log(`\nCommit and push it, and those keys work again as each machine picks it up:`);
176
301
  console.log(`\`\`\`\ngit add -A\ngit commit -m "${COMMIT_MESSAGE}"\ngit push\n\`\`\``);
177
302
  return;
178
303
  }
179
304
  await runPromise(`git add -A`, { cwd: repoPath });
180
305
  await runPromise(`git commit -m "${COMMIT_MESSAGE}"`, { cwd: repoPath });
181
306
  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.`);
307
+ console.log(`\nCommitted and pushed. Those keys work again as each machine picks it up.`);
184
308
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true , configurable: true});
3
- //exports.remoteCommandExists = exports.writeRemoteFile = exports.readRemoteFile = exports.runOverSSH = exports.SUDO_PREAMBLE = void 0;
3
+ //exports.remoteCommandExists = exports.writeRemoteFile = exports.readRemoteFile = exports.runOverSSH = exports.describeHost = exports.THIS_MACHINE = exports.SUDO_PREAMBLE = void 0;
4
4
  const spawn_1 = require("./spawn");
5
5
  const SSH_CONNECT_TIMEOUT = 10;
6
6
  const EXISTS_MARKER = "__PORTSECURE_EXISTS__";
@@ -8,27 +8,37 @@ const MISSING_MARKER = "__PORTSECURE_MISSING__";
8
8
  const MAX_ERROR_BODY_LENGTH = 500;
9
9
  // /etc is root owned, so fall back to sudo whenever the SSH user is not root.
10
10
  exports.SUDO_PREAMBLE = `SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo -n"; fi`;
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
+ exports.THIS_MACHINE = "";
15
+ function describeHost(host) {
16
+ return host || "this machine";
17
+ }
18
+ exports.describeHost = describeHost;
11
19
  /** The host string is handed to ssh untouched. Users, keys and ports belong in the caller's ssh
12
20
  config, so BatchMode makes a missing setup fail immediately instead of prompting. */
13
21
  async function runOverSSH(config) {
14
22
  let { host, script, input, allowFailure } = config;
15
23
  let result = await (0, spawn_1.spawnPromise)({
16
- command: "ssh",
17
- args: [
24
+ command: host && "ssh" || "sh",
25
+ args: host && [
18
26
  "-o", "BatchMode=yes",
19
27
  "-o", `ConnectTimeout=${SSH_CONNECT_TIMEOUT}`,
20
28
  host,
21
29
  script,
22
- ],
30
+ ] || ["-c", script],
23
31
  input,
24
32
  inheritStderr: !allowFailure,
25
33
  });
26
34
  if (result.error) {
27
- throw new Error(`Expected ssh to run against ${host}, failed with ${result.error.message}`);
35
+ throw new Error(`Expected to run against ${describeHost(host)}, failed with ${result.error.message}`);
28
36
  }
29
37
  if (result.status !== 0 && !allowFailure) {
30
- throw new Error(`Expected ssh to ${host} to exit 0, was ${result.status}.`
31
- + ` Non-interactive ssh access to ${host} has to work on its own - fix it in your ssh config.\n`
38
+ let advice = host
39
+ && ` Non-interactive ssh access to ${host} has to work on its own - fix it in your ssh config.`
40
+ || "";
41
+ throw new Error(`Expected the script on ${describeHost(host)} to exit 0, was ${result.status}.${advice}\n`
32
42
  + `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`);
33
43
  }
34
44
  return { stdout: result.stdout || "", stderr: result.stderr || "", status: result.status };
@@ -86,5 +96,5 @@ async function remoteCommandExists(config) {
86
96
  return result.stdout.trim() === "yes";
87
97
  }
88
98
  exports.remoteCommandExists = remoteCommandExists;
89
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVtb3RlU1NILmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsicmVtb3RlU1NILnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7OztBQUFBLG1DQUF1QztBQUV2QyxNQUFNLG1CQUFtQixHQUFHLEVBQUUsQ0FBQztBQUMvQixNQUFNLGFBQWEsR0FBRyx1QkFBdUIsQ0FBQztBQUM5QyxNQUFNLGNBQWMsR0FBRyx3QkFBd0IsQ0FBQztBQUNoRCxNQUFNLHFCQUFxQixHQUFHLEdBQUcsQ0FBQztBQUVsQyw4RUFBOEU7QUFDakUsUUFBQSxhQUFhLEdBQUcsMkRBQTJELENBQUM7QUFFekY7d0ZBQ3dGO0FBQ2pGLEtBQUssVUFBVSxVQUFVLENBQUMsTUFBZ0Y7SUFDN0csSUFBSSxFQUFFLElBQUksRUFBRSxNQUFNLEVBQUUsS0FBSyxFQUFFLFlBQVksRUFBRSxHQUFHLE1BQU0sQ0FBQztJQUNuRCxJQUFJLE1BQU0sR0FBRyxNQUFNLElBQUEsb0JBQVksRUFBQztRQUM1QixPQUFPLEVBQUUsS0FBSztRQUNkLElBQUksRUFBRTtZQUNGLElBQUksRUFBRSxlQUFlO1lBQ3JCLElBQUksRUFBRSxrQkFBa0IsbUJBQW1CLEVBQUU7WUFDN0MsSUFBSTtZQUNKLE1BQU07U0FDVDtRQUNELEtBQUs7UUFDTCxhQUFhLEVBQUUsQ0FBQyxZQUFZO0tBQy9CLENBQUMsQ0FBQztJQUNILElBQUksTUFBTSxDQUFDLEtBQUssRUFBRSxDQUFDO1FBQ2YsTUFBTSxJQUFJLEtBQUssQ0FBQywrQkFBK0IsSUFBSSxpQkFBaUIsTUFBTSxDQUFDLEtBQUssQ0FBQyxPQUFPLEVBQUUsQ0FBQyxDQUFDO0lBQ2hHLENBQUM7SUFDRCxJQUFJLE1BQU0sQ0FBQyxNQUFNLEtBQUssQ0FBQyxJQUFJLENBQUMsWUFBWSxFQUFFLENBQUM7UUFDdkMsTUFBTSxJQUFJLEtBQUssQ0FDWCxtQkFBbUIsSUFBSSxtQkFBbUIsTUFBTSxDQUFDLE1BQU0sR0FBRztjQUN4RCxrQ0FBa0MsSUFBSSx3REFBd0Q7Y0FDOUYsR0FBRyxDQUFDLE1BQU0sQ0FBQyxNQUFNLEdBQUcsTUFBTSxDQUFDLE1BQU0sQ0FBQyxDQUFDLElBQUksRUFBRSxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUscUJBQXFCLENBQUMsRUFBRSxDQUNoRixDQUFDO0lBQ04sQ0FBQztJQUNELE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSxDQUFDLE1BQU0sSUFBSSxFQUFFLEVBQUUsTUFBTSxFQUFFLE1BQU0sQ0FBQyxNQUFNLElBQUksRUFBRSxFQUFFLE1BQU0sRUFBRSxNQUFNLENBQUMsTUFBTSxFQUFFLENBQUM7QUFDL0YsQ0FBQztBQXhCRCxnQ0F3QkM7QUFFRDtpQkFDaUI7QUFDVixLQUFLLFVBQVUsY0FBYyxDQUFDLE1BQTBDO0lBQzNFLElBQUksRUFBRSxJQUFJLEVBQUUsUUFBUSxFQUFFLEdBQUcsTUFBTSxDQUFDO0lBQ2hDLElBQUksTUFBTSxHQUFHLENBQUMsTUFBTSxVQUFVLENBQUM7UUFDM0IsSUFBSTtRQUNKLE1BQU0sRUFBRSxHQUFHLHFCQUFhO29CQUNaLFFBQVE7WUFDaEIsYUFBYTtpQkFDUixRQUFROztZQUViLGNBQWM7R0FDdkI7S0FDRSxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUM7SUFDWCxJQUFJLFlBQVksR0FBRyxNQUFNLENBQUMsT0FBTyxDQUFDLElBQUksQ0FBQyxDQUFDO0lBQ3hDLElBQUksTUFBTSxHQUFHLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQyxFQUFFLFlBQVksQ0FBQyxDQUFDLElBQUksRUFBRSxDQUFDO0lBQ2xELElBQUksTUFBTSxLQUFLLGNBQWMsRUFBRSxDQUFDO1FBQzVCLE9BQU8sU0FBUyxDQUFDO0lBQ3JCLENBQUM7SUFDRCxJQUFJLE1BQU0sS0FBSyxhQUFhLEVBQUUsQ0FBQztRQUMzQixNQUFNLElBQUksS0FBSyxDQUFDLFlBQVksYUFBYSxPQUFPLGNBQWMsU0FBUyxJQUFJLFNBQVMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUscUJBQXFCLENBQUMsRUFBRSxDQUFDLENBQUM7SUFDbEksQ0FBQztJQUNELE9BQU8sTUFBTSxDQUFDLEtBQUssQ0FBQyxZQUFZLEdBQUcsQ0FBQyxDQUFDLENBQUM7QUFDMUMsQ0FBQztBQXJCRCx3Q0FxQkM7QUFFTSxLQUFLLFVBQVUsZUFBZSxDQUFDLE1BTXJDO0lBQ0csSUFBSSxFQUFFLElBQUksRUFBRSxRQUFRLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxhQUFhLEVBQUUsR0FBRyxNQUFNLENBQUM7SUFDbkUsSUFBSSxTQUFTLEdBQUcsUUFBUSxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUsUUFBUSxDQUFDLFdBQVcsQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDO0lBQzdELE1BQU0sVUFBVSxDQUFDO1FBQ2IsSUFBSTtRQUNKLHNGQUFzRjtRQUN0RixrREFBa0Q7UUFDbEQsS0FBSyxFQUFFLFFBQVEsQ0FBQyxPQUFPLENBQUMsT0FBTyxFQUFFLElBQUksQ0FBQztRQUN0QyxNQUFNLEVBQUUsR0FBRyxxQkFBYTs7a0JBRWQsU0FBUztjQUNiLGFBQWEsS0FBSyxTQUFTO2FBQzVCLFFBQVE7Y0FDUCxRQUFRLEtBQUssUUFBUSxHQUFHO0tBQ2pDLENBQUMsQ0FBQztBQUNQLENBQUM7QUFyQkQsMENBcUJDO0FBRU0sS0FBSyxVQUFVLG1CQUFtQixDQUFDLE1BQXlDO0lBQy9FLElBQUksRUFBRSxJQUFJLEVBQUUsT0FBTyxFQUFFLEdBQUcsTUFBTSxDQUFDO0lBQy9CLElBQUksTUFBTSxHQUFHLE1BQU0sVUFBVSxDQUFDO1FBQzFCLElBQUk7UUFDSixNQUFNLEVBQUUsZUFBZSxPQUFPLDJDQUEyQztRQUN6RSxZQUFZLEVBQUUsSUFBSTtLQUNyQixDQUFDLENBQUM7SUFDSCxPQUFPLE1BQU0sQ0FBQyxNQUFNLENBQUMsSUFBSSxFQUFFLEtBQUssS0FBSyxDQUFDO0FBQzFDLENBQUM7QUFSRCxrREFRQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IHNwYXduUHJvbWlzZSB9IGZyb20gXCIuL3NwYXduXCI7XG5cbmNvbnN0IFNTSF9DT05ORUNUX1RJTUVPVVQgPSAxMDtcbmNvbnN0IEVYSVNUU19NQVJLRVIgPSBcIl9fUE9SVFNFQ1VSRV9FWElTVFNfX1wiO1xuY29uc3QgTUlTU0lOR19NQVJLRVIgPSBcIl9fUE9SVFNFQ1VSRV9NSVNTSU5HX19cIjtcbmNvbnN0IE1BWF9FUlJPUl9CT0RZX0xFTkdUSCA9IDUwMDtcblxuLy8gL2V0YyBpcyByb290IG93bmVkLCBzbyBmYWxsIGJhY2sgdG8gc3VkbyB3aGVuZXZlciB0aGUgU1NIIHVzZXIgaXMgbm90IHJvb3QuXG5leHBvcnQgY29uc3QgU1VET19QUkVBTUJMRSA9IGBTVURPPVwiXCI7IGlmIFsgXCIkKGlkIC11KVwiIC1uZSAwIF07IHRoZW4gU1VETz1cInN1ZG8gLW5cIjsgZmlgO1xuXG4vKiogVGhlIGhvc3Qgc3RyaW5nIGlzIGhhbmRlZCB0byBzc2ggdW50b3VjaGVkLiBVc2Vycywga2V5cyBhbmQgcG9ydHMgYmVsb25nIGluIHRoZSBjYWxsZXIncyBzc2hcbiAgICBjb25maWcsIHNvIEJhdGNoTW9kZSBtYWtlcyBhIG1pc3Npbmcgc2V0dXAgZmFpbCBpbW1lZGlhdGVseSBpbnN0ZWFkIG9mIHByb21wdGluZy4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBydW5PdmVyU1NIKGNvbmZpZzogeyBob3N0OiBzdHJpbmc7IHNjcmlwdDogc3RyaW5nOyBpbnB1dD86IHN0cmluZzsgYWxsb3dGYWlsdXJlPzogYm9vbGVhbiB9KSB7XG4gICAgbGV0IHsgaG9zdCwgc2NyaXB0LCBpbnB1dCwgYWxsb3dGYWlsdXJlIH0gPSBjb25maWc7XG4gICAgbGV0IHJlc3VsdCA9IGF3YWl0IHNwYXduUHJvbWlzZSh7XG4gICAgICAgIGNvbW1hbmQ6IFwic3NoXCIsXG4gICAgICAgIGFyZ3M6IFtcbiAgICAgICAgICAgIFwiLW9cIiwgXCJCYXRjaE1vZGU9eWVzXCIsXG4gICAgICAgICAgICBcIi1vXCIsIGBDb25uZWN0VGltZW91dD0ke1NTSF9DT05ORUNUX1RJTUVPVVR9YCxcbiAgICAgICAgICAgIGhvc3QsXG4gICAgICAgICAgICBzY3JpcHQsXG4gICAgICAgIF0sXG4gICAgICAgIGlucHV0LFxuICAgICAgICBpbmhlcml0U3RkZXJyOiAhYWxsb3dGYWlsdXJlLFxuICAgIH0pO1xuICAgIGlmIChyZXN1bHQuZXJyb3IpIHtcbiAgICAgICAgdGhyb3cgbmV3IEVycm9yKGBFeHBlY3RlZCBzc2ggdG8gcnVuIGFnYWluc3QgJHtob3N0fSwgZmFpbGVkIHdpdGggJHtyZXN1bHQuZXJyb3IubWVzc2FnZX1gKTtcbiAgICB9XG4gICAgaWYgKHJlc3VsdC5zdGF0dXMgIT09IDAgJiYgIWFsbG93RmFpbHVyZSkge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoXG4gICAgICAgICAgICBgRXhwZWN0ZWQgc3NoIHRvICR7aG9zdH0gdG8gZXhpdCAwLCB3YXMgJHtyZXN1bHQuc3RhdHVzfS5gXG4gICAgICAgICAgICArIGAgTm9uLWludGVyYWN0aXZlIHNzaCBhY2Nlc3MgdG8gJHtob3N0fSBoYXMgdG8gd29yayBvbiBpdHMgb3duIC0gZml4IGl0IGluIHlvdXIgc3NoIGNvbmZpZy5cXG5gXG4gICAgICAgICAgICArIGAkeyhyZXN1bHQuc3Rkb3V0ICsgcmVzdWx0LnN0ZGVycikudHJpbSgpLnNsaWNlKDAsIE1BWF9FUlJPUl9CT0RZX0xFTkdUSCl9YFxuICAgICAgICApO1xuICAgIH1cbiAgICByZXR1cm4geyBzdGRvdXQ6IHJlc3VsdC5zdGRvdXQgfHwgXCJcIiwgc3RkZXJyOiByZXN1bHQuc3RkZXJyIHx8IFwiXCIsIHN0YXR1czogcmVzdWx0LnN0YXR1cyB9O1xufVxuXG4vKiogUmV0dXJucyB1bmRlZmluZWQgd2hlbiB0aGUgZmlsZSBkb2VzIG5vdCBleGlzdCwgc28gYSBtaXNzaW5nIGZpbGUgcmVhZHMgZGlmZmVyZW50bHkgZnJvbSBhblxuICAgIGVtcHR5IG9uZS4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiByZWFkUmVtb3RlRmlsZShjb25maWc6IHsgaG9zdDogc3RyaW5nOyBmaWxlUGF0aDogc3RyaW5nIH0pIHtcbiAgICBsZXQgeyBob3N0LCBmaWxlUGF0aCB9ID0gY29uZmlnO1xuICAgIGxldCBvdXRwdXQgPSAoYXdhaXQgcnVuT3ZlclNTSCh7XG4gICAgICAgIGhvc3QsXG4gICAgICAgIHNjcmlwdDogYCR7U1VET19QUkVBTUJMRX1cbmlmICRTVURPIHRlc3QgLWYgXCIke2ZpbGVQYXRofVwiOyB0aGVuXG4gICAgZWNobyBcIiR7RVhJU1RTX01BUktFUn1cIlxuICAgICRTVURPIGNhdCBcIiR7ZmlsZVBhdGh9XCJcbmVsc2VcbiAgICBlY2hvIFwiJHtNSVNTSU5HX01BUktFUn1cIlxuZmlgLFxuICAgIH0pKS5zdGRvdXQ7XG4gICAgbGV0IG5ld2xpbmVJbmRleCA9IG91dHB1dC5pbmRleE9mKFwiXFxuXCIpO1xuICAgIGxldCBtYXJrZXIgPSBvdXRwdXQuc2xpY2UoMCwgbmV3bGluZUluZGV4KS50cmltKCk7XG4gICAgaWYgKG1hcmtlciA9PT0gTUlTU0lOR19NQVJLRVIpIHtcbiAgICAgICAgcmV0dXJuIHVuZGVmaW5lZDtcbiAgICB9XG4gICAgaWYgKG1hcmtlciAhPT0gRVhJU1RTX01BUktFUikge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoYEV4cGVjdGVkICR7RVhJU1RTX01BUktFUn0gb3IgJHtNSVNTSU5HX01BUktFUn0gZnJvbSAke2hvc3R9LCB3YXMgJHtvdXRwdXQuc2xpY2UoMCwgTUFYX0VSUk9SX0JPRFlfTEVOR1RIKX1gKTtcbiAgICB9XG4gICAgcmV0dXJuIG91dHB1dC5zbGljZShuZXdsaW5lSW5kZXggKyAxKTtcbn1cblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHdyaXRlUmVtb3RlRmlsZShjb25maWc6IHtcbiAgICBob3N0OiBzdHJpbmc7XG4gICAgZmlsZVBhdGg6IHN0cmluZztcbiAgICBjb250ZW50czogc3RyaW5nO1xuICAgIGZpbGVNb2RlOiBzdHJpbmc7XG4gICAgZGlyZWN0b3J5TW9kZTogc3RyaW5nO1xufSkge1xuICAgIGxldCB7IGhvc3QsIGZpbGVQYXRoLCBjb250ZW50cywgZmlsZU1vZGUsIGRpcmVjdG9yeU1vZGUgfSA9IGNvbmZpZztcbiAgICBsZXQgZGlyZWN0b3J5ID0gZmlsZVBhdGguc2xpY2UoMCwgZmlsZVBhdGgubGFzdEluZGV4T2YoXCIvXCIpKTtcbiAgICBhd2FpdCBydW5PdmVyU1NIKHtcbiAgICAgICAgaG9zdCxcbiAgICAgICAgLy8gRGVwbG95aW5nIGZyb20gV2luZG93cyBtdXN0IG5vdCBjYXJyeSBDUkxGIG9udG8gdGhlIHRhcmdldCwgd2hlcmUgaXQgYnJlYWtzIHN5c3RlbWRcbiAgICAgICAgLy8gdW5pdHMgYW5kIG1ha2VzIHByaXZhdGUga2V5cyB1bnJlYWRhYmxlIHRvIHNzaC5cbiAgICAgICAgaW5wdXQ6IGNvbnRlbnRzLnJlcGxhY2UoL1xcclxcbi9nLCBcIlxcblwiKSxcbiAgICAgICAgc2NyaXB0OiBgJHtTVURPX1BSRUFNQkxFfVxuc2V0IC1lXG4kU1VETyBta2RpciAtcCBcIiR7ZGlyZWN0b3J5fVwiXG4kU1VETyBjaG1vZCAke2RpcmVjdG9yeU1vZGV9IFwiJHtkaXJlY3Rvcnl9XCJcbiRTVURPIHRlZSBcIiR7ZmlsZVBhdGh9XCIgPiAvZGV2L251bGxcbiRTVURPIGNobW9kICR7ZmlsZU1vZGV9IFwiJHtmaWxlUGF0aH1cImAsXG4gICAgfSk7XG59XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiByZW1vdGVDb21tYW5kRXhpc3RzKGNvbmZpZzogeyBob3N0OiBzdHJpbmc7IGNvbW1hbmQ6IHN0cmluZyB9KSB7XG4gICAgbGV0IHsgaG9zdCwgY29tbWFuZCB9ID0gY29uZmlnO1xuICAgIGxldCByZXN1bHQgPSBhd2FpdCBydW5PdmVyU1NIKHtcbiAgICAgICAgaG9zdCxcbiAgICAgICAgc2NyaXB0OiBgY29tbWFuZCAtdiBcIiR7Y29tbWFuZH1cIiA+IC9kZXYvbnVsbCAyPiYxICYmIGVjaG8geWVzIHx8IGVjaG8gbm9gLFxuICAgICAgICBhbGxvd0ZhaWx1cmU6IHRydWUsXG4gICAgfSk7XG4gICAgcmV0dXJuIHJlc3VsdC5zdGRvdXQudHJpbSgpID09PSBcInllc1wiO1xufVxuIl19
90
- /* _JS_SOURCE_HASH = "b9707fe65376e19327371edb2ebdda68daa9b9b21220d7ac91ec21ef22520290"; */
99
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVtb3RlU1NILmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsicmVtb3RlU1NILnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7OztBQUFBLG1DQUF1QztBQUV2QyxNQUFNLG1CQUFtQixHQUFHLEVBQUUsQ0FBQztBQUMvQixNQUFNLGFBQWEsR0FBRyx1QkFBdUIsQ0FBQztBQUM5QyxNQUFNLGNBQWMsR0FBRyx3QkFBd0IsQ0FBQztBQUNoRCxNQUFNLHFCQUFxQixHQUFHLEdBQUcsQ0FBQztBQUVsQyw4RUFBOEU7QUFDakUsUUFBQSxhQUFhLEdBQUcsMkRBQTJELENBQUM7QUFFekY7OzZCQUU2QjtBQUNoQixRQUFBLFlBQVksR0FBRyxFQUFFLENBQUM7QUFFL0IsU0FBZ0IsWUFBWSxDQUFDLElBQVk7SUFDckMsT0FBTyxJQUFJLElBQUksY0FBYyxDQUFDO0FBQ2xDLENBQUM7QUFGRCxvQ0FFQztBQUVEO3dGQUN3RjtBQUNqRixLQUFLLFVBQVUsVUFBVSxDQUFDLE1BQWdGO0lBQzdHLElBQUksRUFBRSxJQUFJLEVBQUUsTUFBTSxFQUFFLEtBQUssRUFBRSxZQUFZLEVBQUUsR0FBRyxNQUFNLENBQUM7SUFDbkQsSUFBSSxNQUFNLEdBQUcsTUFBTSxJQUFBLG9CQUFZLEVBQUM7UUFDNUIsT0FBTyxFQUFFLElBQUksSUFBSSxLQUFLLElBQUksSUFBSTtRQUM5QixJQUFJLEVBQUUsSUFBSSxJQUFJO1lBQ1YsSUFBSSxFQUFFLGVBQWU7WUFDckIsSUFBSSxFQUFFLGtCQUFrQixtQkFBbUIsRUFBRTtZQUM3QyxJQUFJO1lBQ0osTUFBTTtTQUNULElBQUksQ0FBQyxJQUFJLEVBQUUsTUFBTSxDQUFDO1FBQ25CLEtBQUs7UUFDTCxhQUFhLEVBQUUsQ0FBQyxZQUFZO0tBQy9CLENBQUMsQ0FBQztJQUNILElBQUksTUFBTSxDQUFDLEtBQUssRUFBRSxDQUFDO1FBQ2YsTUFBTSxJQUFJLEtBQUssQ0FBQywyQkFBMkIsWUFBWSxDQUFDLElBQUksQ0FBQyxpQkFBaUIs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LEdBQUcsTUFBTSxVQUFVLENBQUM7UUFDMUIsSUFBSTtRQUNKLE1BQU0sRUFBRSxlQUFlLE9BQU8sMkNBQTJDO1FBQ3pFLFlBQVksRUFBRSxJQUFJO0tBQ3JCLENBQUMsQ0FBQztJQUNILE9BQU8sTUFBTSxDQUFDLE1BQU0sQ0FBQyxJQUFJLEVBQUUsS0FBSyxLQUFLLENBQUM7QUFDMUMsQ0FBQztBQVJELGtEQVFDIiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgc3Bhd25Qcm9taXNlIH0gZnJvbSBcIi4vc3Bhd25cIjtcblxuY29uc3QgU1NIX0NPTk5FQ1RfVElNRU9VVCA9IDEwO1xuY29uc3QgRVhJU1RTX01BUktFUiA9IFwiX19QT1JUU0VDVVJFX0VYSVNUU19fXCI7XG5jb25zdCBNSVNTSU5HX01BUktFUiA9IFwiX19QT1JUU0VDVVJFX01JU1NJTkdfX1wiO1xuY29uc3QgTUFYX0VSUk9SX0JPRFlfTEVOR1RIID0gNTAwO1xuXG4vLyAvZXRjIGlzIHJvb3Qgb3duZWQsIHNvIGZhbGwgYmFjayB0byBzdWRvIHdoZW5ldmVyIHRoZSBTU0ggdXNlciBpcyBub3Qgcm9vdC5cbmV4cG9ydCBjb25zdCBTVURPX1BSRUFNQkxFID0gYFNVRE89XCJcIjsgaWYgWyBcIiQoaWQgLXUpXCIgLW5lIDAgXTsgdGhlbiBTVURPPVwic3VkbyAtblwiOyBmaWA7XG5cbi8qKiBObyBob3N0IG1lYW5zIHRoaXMgbWFjaGluZSwgc28gdGhlIHZlcnkgc2FtZSBzY3JpcHQgcnVucyB3aXRoIG5vdGhpbmcgaW4gZnJvbnQgb2YgaXQuIExvY2FsIGFuZFxuICAgIHJlbW90ZSB0aGVuIGRvIGV4YWN0bHkgdGhlIHNhbWUgdGhpbmcsIHJhdGhlciB0aGFuIGJlaW5nIHR3byBwaWVjZXMgb2YgY29kZSB0aGF0IGhhdmUgdG8gYmUga2VwdFxuICAgIHNheWluZyB0aGUgc2FtZSB0aGluZy4gKi9cbmV4cG9ydCBjb25zdCBUSElTX01BQ0hJTkUgPSBcIlwiO1xuXG5leHBvcnQgZnVuY3Rpb24gZGVzY3JpYmVIb3N0KGhvc3Q6IHN0cmluZykge1xuICAgIHJldHVybiBob3N0IHx8IFwidGhpcyBtYWNoaW5lXCI7XG59XG5cbi8qKiBUaGUgaG9zdCBzdHJpbmcgaXMgaGFuZGVkIHRvIHNzaCB1bnRvdWNoZWQuIFVzZXJzLCBrZXlzIGFuZCBwb3J0cyBiZWxvbmcgaW4gdGhlIGNhbGxlcidzIHNzaFxuICAgIGNvbmZpZywgc28gQmF0Y2hNb2RlIG1ha2VzIGEgbWlzc2luZyBzZXR1cCBmYWlsIGltbWVkaWF0ZWx5IGluc3RlYWQgb2YgcHJvbXB0aW5nLiAqL1xuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHJ1bk92ZXJTU0goY29uZmlnOiB7IGhvc3Q6IHN0cmluZzsgc2NyaXB0OiBzdHJpbmc7IGlucHV0Pzogc3RyaW5nOyBhbGxvd0ZhaWx1cmU/OiBib29sZWFuIH0pIHtcbiAgICBsZXQgeyBob3N0LCBzY3JpcHQsIGlucHV0LCBhbGxvd0ZhaWx1cmUgfSA9IGNvbmZpZztcbiAgICBsZXQgcmVzdWx0ID0gYXdhaXQgc3Bhd25Qcm9taXNlKHtcbiAgICAgICAgY29tbWFuZDogaG9zdCAmJiBcInNzaFwiIHx8IFwic2hcIixcbiAgICAgICAgYXJnczogaG9zdCAmJiBbXG4gICAgICAgICAgICBcIi1vXCIsIFwiQmF0Y2hNb2RlPXllc1wiLFxuICAgICAgICAgICAgXCItb1wiLCBgQ29ubmVjdFRpbWVvdXQ9JHtTU0hfQ09OTkVDVF9USU1FT1VUfWAsXG4gICAgICAgICAgICBob3N0LFxuICAgICAgICAgICAgc2NyaXB0LFxuICAgICAgICBdIHx8IFtcIi1jXCIsIHNjcmlwdF0sXG4gICAgICAgIGlucHV0LFxuICAgICAgICBpbmhlcml0U3RkZXJyOiAhYWxsb3dGYWlsdXJlLFxuICAgIH0pO1xuICAgIGlmIChyZXN1bHQuZXJyb3IpIHtcbiAgICAgICAgdGhyb3cgbmV3IEVycm9yKGBFeHBlY3RlZCB0byBydW4gYWdhaW5zdCAke2Rlc2NyaWJlSG9zdChob3N0KX0sIGZhaWxlZCB3aXRoICR7cmVzdWx0LmVycm9yLm1lc3NhZ2V9YCk7XG4gICAgfVxuICAgIGlmIChyZXN1bHQuc3RhdHVzICE9PSAwICYmICFhbGxvd0ZhaWx1cmUpIHtcbiAgICAgICAgbGV0IGFkdmljZSA9IGhvc3RcbiAgICAgICAgICAgICYmIGAgTm9uLWludGVyYWN0aXZlIHNzaCBhY2Nlc3MgdG8gJHtob3N0fSBoYXMgdG8gd29yayBvbiBpdHMgb3duIC0gZml4IGl0IGluIHlvdXIgc3NoIGNvbmZpZy5gXG4gICAgICAgICAgICB8fCBcIlwiO1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoXG4gICAgICAgICAgICBgRXhwZWN0ZWQgdGhlIHNjcmlwdCBvbiAke2Rlc2NyaWJlSG9zdChob3N0KX0gdG8gZXhpdCAwLCB3YXMgJHtyZXN1bHQuc3RhdHVzfS4ke2FkdmljZX1cXG5gXG4gICAgICAgICAgICArIGAkeyhyZXN1bHQuc3Rkb3V0ICsgcmVzdWx0LnN0ZGVycikudHJpbSgpLnNsaWNlKDAsIE1BWF9FUlJPUl9CT0RZX0xFTkdUSCl9YFxuICAgICAgICApO1xuICAgIH1cbiAgICByZXR1cm4geyBzdGRvdXQ6IHJlc3VsdC5zdGRvdXQgfHwgXCJcIiwgc3RkZXJyOiByZXN1bHQuc3RkZXJyIHx8IFwiXCIsIHN0YXR1czogcmVzdWx0LnN0YXR1cyB9O1xufVxuXG4vKiogUmV0dXJucyB1bmRlZmluZWQgd2hlbiB0aGUgZmlsZSBkb2VzIG5vdCBleGlzdCwgc28gYSBtaXNzaW5nIGZpbGUgcmVhZHMgZGlmZmVyZW50bHkgZnJvbSBhblxuICAgIGVtcHR5IG9uZS4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiByZWFkUmVtb3RlRmlsZShjb25maWc6IHsgaG9zdDogc3RyaW5nOyBmaWxlUGF0aDogc3RyaW5nIH0pIHtcbiAgICBsZXQgeyBob3N0LCBmaWxlUGF0aCB9ID0gY29uZmlnO1xuICAgIGxldCBvdXRwdXQgPSAoYXdhaXQgcnVuT3ZlclNTSCh7XG4gICAgICAgIGhvc3QsXG4gICAgICAgIHNjcmlwdDogYCR7U1VET19QUkVBTUJMRX1cbmlmICRTVURPIHRlc3QgLWYgXCIke2ZpbGVQYXRofVwiOyB0aGVuXG4gICAgZWNobyBcIiR7RVhJU1RTX01BUktFUn1cIlxuICAgICRTVURPIGNhdCBcIiR7ZmlsZVBhdGh9XCJcbmVsc2VcbiAgICBlY2hvIFwiJHtNSVNTSU5HX01BUktFUn1cIlxuZmlgLFxuICAgIH0pKS5zdGRvdXQ7XG4gICAgbGV0IG5ld2xpbmVJbmRleCA9IG91dHB1dC5pbmRleE9mKFwiXFxuXCIpO1xuICAgIGxldCBtYXJrZXIgPSBvdXRwdXQuc2xpY2UoMCwgbmV3bGluZUluZGV4KS50cmltKCk7XG4gICAgaWYgKG1hcmtlciA9PT0gTUlTU0lOR19NQVJLRVIpIHtcbiAgICAgICAgcmV0dXJuIHVuZGVmaW5lZDtcbiAgICB9XG4gICAgaWYgKG1hcmtlciAhPT0gRVhJU1RTX01BUktFUikge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoYEV4cGVjdGVkICR7RVhJU1RTX01BUktFUn0gb3IgJHtNSVNTSU5HX01BUktFUn0gZnJvbSAke2hvc3R9LCB3YXMgJHtvdXRwdXQuc2xpY2UoMCwgTUFYX0VSUk9SX0JPRFlfTEVOR1RIKX1gKTtcbiAgICB9XG4gICAgcmV0dXJuIG91dHB1dC5zbGljZShuZXdsaW5lSW5kZXggKyAxKTtcbn1cblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHdyaXRlUmVtb3RlRmlsZShjb25maWc6IHtcbiAgICBob3N0OiBzdHJpbmc7XG4gICAgZmlsZVBhdGg6IHN0cmluZztcbiAgICBjb250ZW50czogc3RyaW5nO1xuICAgIGZpbGVNb2RlOiBzdHJpbmc7XG4gICAgZGlyZWN0b3J5TW9kZTogc3RyaW5nO1xufSkge1xuICAgIGxldCB7IGhvc3QsIGZpbGVQYXRoLCBjb250ZW50cywgZmlsZU1vZGUsIGRpcmVjdG9yeU1vZGUgfSA9IGNvbmZpZztcbiAgICBsZXQgZGlyZWN0b3J5ID0gZmlsZVBhdGguc2xpY2UoMCwgZmlsZVBhdGgubGFzdEluZGV4T2YoXCIvXCIpKTtcbiAgICBhd2FpdCBydW5PdmVyU1NIKHtcbiAgICAgICAgaG9zdCxcbiAgICAgICAgLy8gRGVwbG95aW5nIGZyb20gV2luZG93cyBtdXN0IG5vdCBjYXJyeSBDUkxGIG9udG8gdGhlIHRhcmdldCwgd2hlcmUgaXQgYnJlYWtzIHN5c3RlbWRcbiAgICAgICAgLy8gdW5pdHMgYW5kIG1ha2VzIHByaXZhdGUga2V5cyB1bnJlYWRhYmxlIHRvIHNzaC5cbiAgICAgICAgaW5wdXQ6IGNvbnRlbnRzLnJlcGxhY2UoL1xcclxcbi9nLCBcIlxcblwiKSxcbiAgICAgICAgc2NyaXB0OiBgJHtTVURPX1BSRUFNQkxFfVxuc2V0IC1lXG4kU1VETyBta2RpciAtcCBcIiR7ZGlyZWN0b3J5fVwiXG4kU1VETyBjaG1vZCAke2RpcmVjdG9yeU1vZGV9IFwiJHtkaXJlY3Rvcnl9XCJcbiRTVURPIHRlZSBcIiR7ZmlsZVBhdGh9XCIgPiAvZGV2L251bGxcbiRTVURPIGNobW9kICR7ZmlsZU1vZGV9IFwiJHtmaWxlUGF0aH1cImAsXG4gICAgfSk7XG59XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiByZW1vdGVDb21tYW5kRXhpc3RzKGNvbmZpZzogeyBob3N0OiBzdHJpbmc7IGNvbW1hbmQ6IHN0cmluZyB9KSB7XG4gICAgbGV0IHsgaG9zdCwgY29tbWFuZCB9ID0gY29uZmlnO1xuICAgIGxldCByZXN1bHQgPSBhd2FpdCBydW5PdmVyU1NIKHtcbiAgICAgICAgaG9zdCxcbiAgICAgICAgc2NyaXB0OiBgY29tbWFuZCAtdiBcIiR7Y29tbWFuZH1cIiA+IC9kZXYvbnVsbCAyPiYxICYmIGVjaG8geWVzIHx8IGVjaG8gbm9gLFxuICAgICAgICBhbGxvd0ZhaWx1cmU6IHRydWUsXG4gICAgfSk7XG4gICAgcmV0dXJuIHJlc3VsdC5zdGRvdXQudHJpbSgpID09PSBcInllc1wiO1xufVxuIl19
100
+ /* _JS_SOURCE_HASH = "50b1a08f2aa4b96f04f676f20c516bc6c2897a47ad7c12729c0f651735c71057"; */
@@ -0,0 +1,84 @@
1
+ import os from "os";
2
+ import { getOwnIPs } from "../../misc/ownIPs";
3
+ import { describeHost } from "../helpers/remoteSSH";
4
+ import { createRemoteIdentity, Identity, localDomains, localIdentity, remoteIdentity } from "./identity";
5
+ import { resolveKeysRepo } from "../authorizedKeys/keysRepo";
6
+ import { getMachines, setMachines } from "./machines";
7
+
8
+ const USAGE = `Usage: yarn addmachine <domain> [ip]
9
+
10
+ The domain is required: a machine has a different identity, and so a different machine id, under
11
+ every domain it runs under, so there is no such thing as "this machine" without one.
12
+
13
+ With no ip it adds this machine, with its own addresses. With an ip it asks that machine over ssh
14
+ for its identity under that domain, gives it one if it has none, and adds it under that address.
15
+
16
+ It edits the keys repo this machine reads, wherever that is, not whatever directory this was run
17
+ from. Nothing is signed or pushed here. Run "yarn signfiles git" in that repo afterwards, or no
18
+ machine will believe any of it.`;
19
+
20
+ export async function main() {
21
+ let argv = process.argv.slice(2);
22
+ if (!argv.length || argv.length > 2) {
23
+ throw new Error(`Expected a domain and optionally an ip, was ${argv.length} argument(s)\n${USAGE}`);
24
+ }
25
+ let [domain, host = ""] = argv;
26
+ // The same repo the acceptance check reads. Editing anything else would write a machine list
27
+ // nothing on this machine ever looks at.
28
+ let { repoPath } = await resolveKeysRepo();
29
+
30
+ let identity: Identity;
31
+ let addresses: string[];
32
+ if (!host) {
33
+ let found = await localIdentity(domain);
34
+ if (!found) {
35
+ throw new Error(
36
+ `Expected an identity for ${domain} in ${os.homedir()}, this machine has none.\n`
37
+ + `It has: ${(await localDomains()).join(", ") || "no identities at all"}\n`
38
+ + `One is generated the first time something runs here under a domain, so run that`
39
+ + ` first, or name another machine to add instead.`
40
+ );
41
+ }
42
+ identity = found;
43
+ addresses = await getOwnIPs();
44
+ if (!addresses.length) {
45
+ throw new Error(`Expected this machine to have an address anything else can see, it has none`);
46
+ }
47
+ } else {
48
+ // Nothing there under this domain means it gets one. Its own from this point on: the key
49
+ // is written there and nothing keeps a copy.
50
+ let found = await remoteIdentity(host, domain);
51
+ if (!found) {
52
+ console.log(`${describeHost(host)} has no identity for ${domain}, generating one`);
53
+ }
54
+ identity = found || await createRemoteIdentity({ host, domain });
55
+ // The address it was named by is the address it talks to us from, which is the one thing
56
+ // it cannot lie about.
57
+ addresses = [host];
58
+ }
59
+
60
+ // The whole list is read and written back, because setting is by the set: writing this one
61
+ // machine alone would remove every other machine from the repo.
62
+ let machines = await getMachines(repoPath);
63
+ let existing = machines.find(machine => machine.machineId === identity.machineId);
64
+ let ips = [...(existing?.ips || [])];
65
+ let added = addresses.filter(address => !ips.includes(address));
66
+ if (existing) {
67
+ existing.ips = [...ips, ...added];
68
+ } else {
69
+ machines.push({ machineId: identity.machineId, ips: [...added], addedAt: "" });
70
+ }
71
+ await setMachines({ repoPath, machines });
72
+
73
+ console.log(`\nTrusting machine ${identity.machineId}`);
74
+ console.log(`Allowing IP ${[...ips, ...added].join(", ")}`);
75
+ if (added.length) {
76
+ console.log(` added to machines/${identity.machineId}.json: ${added.join(", ")}`);
77
+ } else {
78
+ console.log(` already allowed from ${addresses.join(", ")}`);
79
+ }
80
+ console.log(` identity ${identity.fullDomain}`);
81
+ console.log(` in ${repoPath}`);
82
+ console.log(`\nNothing believes this until it is signed. In that repo, run:`);
83
+ console.log(`\`\`\`\nyarn signfiles git\n\`\`\``);
84
+ }