sliftutils 1.7.124 → 1.7.126

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 (50) hide show
  1. package/CLAUDE.md +3 -1
  2. package/bin/derivekey.js +10 -0
  3. package/bin/portsecuredaemon.js +13 -0
  4. package/bin/securessh.js +10 -0
  5. package/bin/setupnotify.js +9 -0
  6. package/bin/signfiles.js +10 -0
  7. package/bin/unrevoke.js +9 -0
  8. package/package.json +14 -3
  9. package/security/README.md +141 -0
  10. package/security/authorizedKeys/authorizedKeys.ts +66 -0
  11. package/security/authorizedKeys/daemon/authLog.ts +117 -0
  12. package/security/authorizedKeys/daemon/daemon.ts +308 -0
  13. package/security/authorizedKeys/daemon/git.ts +119 -0
  14. package/security/authorizedKeys/daemon/notify.ts +25 -0
  15. package/security/authorizedKeys/daemon/paths.ts +26 -0
  16. package/security/authorizedKeys/daemon/portsecure.service +19 -0
  17. package/security/authorizedKeys/daemon/revocation.ts +306 -0
  18. package/security/authorizedKeys/daemon/rootKeys.ts +135 -0
  19. package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
  20. package/security/authorizedKeys/daemon/state.ts +108 -0
  21. package/security/authorizedKeys/daemon/trust.ts +291 -0
  22. package/security/authorizedKeys/daemon/userKeys.ts +76 -0
  23. package/security/authorizedKeys/dist/authorizedKeys.ts.cache +73 -0
  24. package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
  25. package/security/authorizedKeys/dist/secureSSH.ts.cache +552 -0
  26. package/security/authorizedKeys/dist/sources.ts.cache +24 -0
  27. package/security/authorizedKeys/dist/unrevoke.ts.cache +145 -0
  28. package/security/authorizedKeys/revokeSource.ts +40 -0
  29. package/security/authorizedKeys/secureSSH.ts +613 -0
  30. package/security/authorizedKeys/sources.ts +20 -0
  31. package/security/authorizedKeys/unrevoke.ts +149 -0
  32. package/security/helpers/dist/paths.ts.cache +28 -0
  33. package/security/helpers/dist/remoteSSH.ts.cache +90 -0
  34. package/security/helpers/dist/spawn.ts.cache +34 -0
  35. package/security/helpers/paths.ts +20 -0
  36. package/security/helpers/remoteSSH.ts +95 -0
  37. package/security/helpers/spawn.ts +36 -0
  38. package/security/keys/deriveKey.ts +72 -0
  39. package/security/keys/dist/deriveKey.ts.cache +72 -0
  40. package/security/keys/dist/sshKeyFile.ts.cache +153 -0
  41. package/security/keys/sshKeyFile.ts +156 -0
  42. package/security/notifications/discord.ts +190 -0
  43. package/security/notifications/dist/discord.ts.cache +180 -0
  44. package/security/notifications/remoteWebhook.ts +85 -0
  45. package/security/notifications/setupNotify.ts +29 -0
  46. package/security/signedFiles/dist/manifest.ts.cache +68 -0
  47. package/security/signedFiles/dist/signFiles.ts.cache +146 -0
  48. package/security/signedFiles/manifest.ts +69 -0
  49. package/security/signedFiles/signFiles.ts +151 -0
  50. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +17 -20
@@ -0,0 +1,613 @@
1
+ import fs from "fs/promises";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { DEFAULT_WEBHOOK_FILE_PATH, parseWebhookFile } from "../notifications/discord";
5
+ import { normalizeKeys, readRepoKeys, summarizeKey } from "./authorizedKeys";
6
+ import { sourceKeyPath, sourceRepoPath } from "./sources";
7
+ import { deriveRevokeKey, REVOKE_KEY_LABEL, revokeRepoURL } from "./revokeSource";
8
+ import { revokedKeysInRepo } from "./unrevoke";
9
+ import { expandHome } from "../helpers/paths";
10
+ import { spawnPromise } from "../helpers/spawn";
11
+ import { readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, writeRemoteFile } from "../helpers/remoteSSH";
12
+
13
+ const SERVICE_SOURCE = path.join(__dirname, "daemon", "portsecure.service");
14
+ // The daemon runs from a checkout of this repo on the host, rather than from a copy we upload, so
15
+ // a host updates itself from github the same way anything else does. sliftutils is public, so this
16
+ // needs no key.
17
+ const SLIFTUTILS_URL = "https://github.com/sliftist/sliftutils.git";
18
+ const REMOTE_CHECKOUT_PATH = "/opt/portsecure/sliftutils";
19
+ const REMOTE_SERVICE_PATH = "/etc/systemd/system/portsecure.service";
20
+ const REMOTE_CONFIG_PATH = "/etc/portsecure/daemon.json";
21
+ const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
22
+ const SERVICE_NAME = "portsecure";
23
+ const MAX_ERROR_BODY_LENGTH = 500;
24
+ const VERBS = ["add", "remove", "list", "update"];
25
+ // The repo url is optional, and defaults to the repo the command is run from.
26
+ 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`;
31
+
32
+ async function pathExists(filePath: string) {
33
+ try {
34
+ await fs.access(filePath);
35
+ return true;
36
+ } catch (e) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ async function runLocal(config: { command: string; args: string[]; cwd?: string; allowFailure?: boolean }) {
42
+ let { command, args, cwd, allowFailure } = config;
43
+ let result = await spawnPromise({ command, args, cwd });
44
+ if (result.error) {
45
+ throw new Error(`Expected ${command} to run, failed with ${result.error.message}`);
46
+ }
47
+ if (result.status !== 0 && !allowFailure) {
48
+ throw new Error(
49
+ `Expected ${command} ${args.join(" ")} to exit 0, was ${result.status}. `
50
+ + `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
51
+ );
52
+ }
53
+ return result;
54
+ }
55
+
56
+ /** A private key cannot authenticate an https remote, so github urls are converted to the ssh form
57
+ the key can actually be used with. */
58
+ function normalizeRepoURL(repoURL: string) {
59
+ let httpsMatch = repoURL.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/);
60
+ if (!httpsMatch) {
61
+ return repoURL;
62
+ }
63
+ let [, host, repoPath] = httpsMatch;
64
+ return `git@${host}:${repoPath}.git`;
65
+ }
66
+
67
+ async function gitWithKey(config: { keyPath: string; args: string[]; cwd?: string; allowFailure?: boolean }) {
68
+ let { keyPath, args, cwd, allowFailure } = config;
69
+ // core.sshCommand keeps key selection with the command instead of in the environment.
70
+ let sshCommand = `ssh -i ${keyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
71
+ let result = await spawnPromise({ command: "git", args: ["-c", `core.sshCommand=${sshCommand}`, ...args], cwd });
72
+ if (result.error) {
73
+ throw new Error(`Expected git to run, failed with ${result.error.message}`);
74
+ }
75
+ if (result.status !== 0 && !allowFailure) {
76
+ throw new Error(
77
+ `Expected git ${args.join(" ")} to exit 0, was ${result.status}. `
78
+ + `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
79
+ );
80
+ }
81
+ return result;
82
+ }
83
+
84
+ /** Asks ssh which key it actually authenticated with. This is the key that must survive the
85
+ daemon taking over authorized_keys, otherwise a deploy locks us out. */
86
+ async function findAuthenticatingFingerprint(host: string) {
87
+ let result = await spawnPromise({
88
+ command: "ssh",
89
+ args: ["-v", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", host, "true"],
90
+ });
91
+ let verboseOutput = result.stderr || "";
92
+ if (result.status !== 0) {
93
+ throw new Error(`Expected to ssh into ${host}, failed. ${verboseOutput.slice(-MAX_ERROR_BODY_LENGTH)}`);
94
+ }
95
+ let acceptedMatch = verboseOutput.match(/Server accepts key:.*?(SHA256:[A-Za-z0-9+/=]+)/);
96
+ if (!acceptedMatch) {
97
+ throw new Error(
98
+ `Expected ${host} to accept a public key, but the session did not authenticate with one.`
99
+ + ` portsecure disables password login, so key based access has to work first.`
100
+ );
101
+ }
102
+ return acceptedMatch[1];
103
+ }
104
+
105
+ async function fingerprintKeys(keys: string[]) {
106
+ if (!keys.length) {
107
+ return [];
108
+ }
109
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-keys-"));
110
+ let keysPath = path.join(temporaryDirectory, "authorized_keys");
111
+ await fs.writeFile(keysPath, `${keys.join("\n")}\n`);
112
+ let result = await runLocal({ command: "ssh-keygen", args: ["-lf", keysPath], allowFailure: true });
113
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
114
+ let fingerprints: string[] = [];
115
+ for (let line of result.stdout.split("\n")) {
116
+ let match = line.match(/(SHA256:[A-Za-z0-9+/=]+)/);
117
+ if (match) {
118
+ fingerprints.push(match[1]);
119
+ }
120
+ }
121
+ return fingerprints;
122
+ }
123
+
124
+ async function cloneRepoForInspection(config: { repoURL: string; keyPath: string }) {
125
+ let { repoURL, keyPath } = config;
126
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-repo-"));
127
+ let repoPath = path.join(temporaryDirectory, "repo");
128
+ await gitWithKey({ keyPath, args: ["clone", "--depth", "1", repoURL, repoPath] });
129
+ return repoPath;
130
+ }
131
+
132
+ /** With no repo url given, the repo we are standing in is used. It has to actually hold keys
133
+ before we hand it to a host, so a stray working directory cannot be deployed by accident. */
134
+ async function resolveRepoURL(passedURL: string | undefined) {
135
+ if (passedURL) {
136
+ return normalizeRepoURL(passedURL);
137
+ }
138
+ let topLevel = await runLocal({ command: "git", args: ["rev-parse", "--show-toplevel"], allowFailure: true });
139
+ if (topLevel.status !== 0) {
140
+ throw new Error(`Expected a repo url, or the current directory to be inside a git repo, it is not.\n${USAGE}`);
141
+ }
142
+ let repoPath = topLevel.stdout.trim();
143
+ try {
144
+ await readRepoKeys(repoPath);
145
+ } catch (e) {
146
+ throw new Error(
147
+ `Expected a repo url, or the current repo (${repoPath}) to hold keys, it does not.\n${e}\n${USAGE}`
148
+ );
149
+ }
150
+ let origin = await runLocal({
151
+ command: "git",
152
+ args: ["remote", "get-url", "origin"],
153
+ cwd: repoPath,
154
+ allowFailure: true,
155
+ });
156
+ if (origin.status !== 0 || !origin.stdout.trim()) {
157
+ throw new Error(
158
+ `Expected the current repo (${repoPath}) to have an origin remote, it has none.`
159
+ + ` The host clones the source itself, so a local path is no use to it.`
160
+ );
161
+ }
162
+ let repoURL = normalizeRepoURL(origin.stdout.trim());
163
+ console.log(`No repo url given, using the current repo: ${repoURL}`);
164
+ return repoURL;
165
+ }
166
+
167
+ /** Every source's revoke repo has to exist and hold at least one commit, or the hosts using it
168
+ cannot record a revocation. Checked with this machine's own git credentials, since update is
169
+ not given any deploy key, and an empty repo is initialised rather than merely complained about.
170
+
171
+ A host that cannot write a revocation silently keeps accepting a key it just saw being misused,
172
+ which is the one failure this whole thing exists to prevent, so it is checked on every deploy
173
+ and not only when a source is first added. */
174
+ async function ensureRevokeReposExist(repoSources: string[]) {
175
+ for (let repoURL of repoSources) {
176
+ let revokeURL = revokeRepoURL(repoURL);
177
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-revoke-"));
178
+ let checkoutPath = path.join(temporaryDirectory, "repo");
179
+ let clone = await runLocal({ command: "git", args: ["clone", revokeURL, checkoutPath], allowFailure: true });
180
+ if (clone.status !== 0) {
181
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
182
+ throw new Error(
183
+ `Expected ${revokeURL} to exist, it does not, so ${repoURL} has nowhere to record a`
184
+ + ` revocation.\nCreate it, then run "yarn securessh <host> add" for that source to`
185
+ + ` register its deploy key.\n${(clone.stdout + clone.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
186
+ );
187
+ }
188
+ let head = await runLocal({ command: "git", args: ["-C", checkoutPath, "rev-parse", "HEAD"], allowFailure: true });
189
+ if (head.status !== 0) {
190
+ console.log(`${revokeURL} is empty, giving it a first commit`);
191
+ await fs.writeFile(path.join(checkoutPath, "README.md"),
192
+ `# revoked keys\n\nWritten by portsecure. Each file under revocations/ is one key that was used from an\n`
193
+ + `address it is not allowed from, and is no longer accepted anywhere.\n`);
194
+ for (let args of [
195
+ ["-C", checkoutPath, "add", "-A"],
196
+ ["-C", checkoutPath, "-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", "initialise revoke repo"],
197
+ ["-C", checkoutPath, "push", "origin", "HEAD"],
198
+ ]) {
199
+ await runLocal({ command: "git", args });
200
+ }
201
+ }
202
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
203
+ console.log(`${revokeURL} is ready`);
204
+ }
205
+ }
206
+
207
+ /** The revoke repo has to exist and be writable before a host is set up, because a host that
208
+ cannot write a revocation cannot revoke a key that is being misused. The key for it is derived
209
+ from the source's, since github will not take one public key on two repos. */
210
+ async function ensureRevokeRepo(config: { keyPath: string; repoURL: string }) {
211
+ let { keyPath, repoURL } = config;
212
+ let revokeURL = revokeRepoURL(repoURL);
213
+ let derived = deriveRevokeKey(await fs.readFile(keyPath, "utf8"));
214
+
215
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-revoke-"));
216
+ let derivedKeyPath = path.join(temporaryDirectory, "key");
217
+ await fs.writeFile(derivedKeyPath, derived.privateKeyFile, { mode: 0o600 });
218
+ let checkoutPath = path.join(temporaryDirectory, "repo");
219
+
220
+ let explain = (problem: string) => new Error(
221
+ `${problem}\n`
222
+ + `Create ${revokeURL} and add this as a deploy key WITH WRITE ACCESS:\n`
223
+ + ` ${derived.publicKey} ${REVOKE_KEY_LABEL}\n`
224
+ + `It has to be this key: it is derived from ${keyPath}, and github will not accept the same`
225
+ + ` public key on two repositories.`
226
+ );
227
+
228
+ console.log(`Checking ${revokeURL}`);
229
+ let clone = await gitWithKey({ keyPath: derivedKeyPath, args: ["clone", revokeURL, checkoutPath], allowFailure: true });
230
+ if (clone.status !== 0) {
231
+ throw explain(`Expected ${revokeURL} to be readable with the derived key, it is not.`);
232
+ }
233
+
234
+ // An empty repo has no branch for the daemon to clone, so it gets its first commit here. That
235
+ // doubles as the proof that we can write to it.
236
+ let head = await gitWithKey({ keyPath: derivedKeyPath, args: ["rev-parse", "HEAD"], cwd: checkoutPath, allowFailure: true });
237
+ if (head.status !== 0) {
238
+ await fs.writeFile(path.join(checkoutPath, "README.md"),
239
+ `# revoked keys\n\nWritten by portsecure. Each file under revocations/ is one key that was used from an\n`
240
+ + `address it is not allowed from, and is no longer accepted anywhere.\n`);
241
+ for (let args of [
242
+ ["add", "-A"],
243
+ ["-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", "initialise revoke repo"],
244
+ ]) {
245
+ await gitWithKey({ keyPath: derivedKeyPath, args, cwd: checkoutPath });
246
+ }
247
+ let push = await gitWithKey({ keyPath: derivedKeyPath, args: ["push", "origin", "HEAD"], cwd: checkoutPath, allowFailure: true });
248
+ if (push.status !== 0) {
249
+ throw explain(`Expected write access to ${revokeURL}, the first push was refused.\n${(push.stdout + push.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`);
250
+ }
251
+ } else {
252
+ let dryRun = await gitWithKey({ keyPath: derivedKeyPath, args: ["push", "--dry-run", "origin", "HEAD"], cwd: checkoutPath, allowFailure: true });
253
+ if (dryRun.status !== 0) {
254
+ throw explain(`Expected write access to ${revokeURL}, a dry run push was refused.\n${(dryRun.stdout + dryRun.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`);
255
+ }
256
+ }
257
+
258
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
259
+ console.log(`${revokeURL} is writable`);
260
+ }
261
+
262
+ async function readRemoteConfig(host: string) {
263
+ let contents = await readRemoteFile({ host, filePath: REMOTE_CONFIG_PATH });
264
+ if (!contents) {
265
+ return { hostLabel: host, repoSources: [] as string[] };
266
+ }
267
+ let parsed = JSON.parse(contents) as { hostLabel?: string; repoSources?: string[] };
268
+ return { hostLabel: parsed.hostLabel || host, repoSources: parsed.repoSources || [] };
269
+ }
270
+
271
+ /** Reads the keys a source's checkout currently holds on the host, so the merged result can be
272
+ worked out without needing that source's private key locally. */
273
+ async function readRemoteSourceKeys(config: { host: string; repoURL: string }) {
274
+ let { host, repoURL } = config;
275
+ let repoPath = sourceRepoPath(repoURL);
276
+ let output = await runOverSSH({
277
+ host,
278
+ script: `${SUDO_PREAMBLE}
279
+ if $SUDO test -f "${repoPath}/authorized_keys"; then
280
+ $SUDO cat "${repoPath}/authorized_keys"
281
+ elif $SUDO test -d "${repoPath}"; then
282
+ $SUDO cat "${repoPath}"/*.pub 2>/dev/null || true
283
+ fi`,
284
+ allowFailure: true,
285
+ });
286
+ return normalizeKeys(output.stdout);
287
+ }
288
+
289
+ async function installDaemon(config: { host: string; hostLabel: string; repoSources: string[] }) {
290
+ let { host, hostLabel, repoSources } = config;
291
+ for (let command of ["node", "git", "yarn"]) {
292
+ if (!await remoteCommandExists({ host, command })) {
293
+ throw new Error(`Expected ${command} to be installed on ${host}, it is not. Install it and rerun.`);
294
+ }
295
+ }
296
+
297
+ // The checkout is brought to the latest commit rather than a copy being pushed, so what runs on
298
+ // the host is exactly what is on github.
299
+ console.log(`Updating ${REMOTE_CHECKOUT_PATH} on ${host}`);
300
+ await runOverSSH({
301
+ host,
302
+ script: `${SUDO_PREAMBLE}
303
+ set -e
304
+ $SUDO mkdir -p "${path.posix.dirname(REMOTE_CHECKOUT_PATH)}"
305
+ if $SUDO test -d "${REMOTE_CHECKOUT_PATH}/.git"; then
306
+ $SUDO git -C "${REMOTE_CHECKOUT_PATH}" fetch --prune origin
307
+ $SUDO git -C "${REMOTE_CHECKOUT_PATH}" reset --hard origin/HEAD
308
+ else
309
+ $SUDO rm -rf "${REMOTE_CHECKOUT_PATH}"
310
+ $SUDO git clone "${SLIFTUTILS_URL}" "${REMOTE_CHECKOUT_PATH}"
311
+ fi
312
+ $SUDO yarn --cwd "${REMOTE_CHECKOUT_PATH}" install --production --non-interactive
313
+ # The single file daemon this replaced, left over on a host set up by an older version.
314
+ $SUDO rm -f /opt/portsecure/portsecure-daemon.js`,
315
+ });
316
+ await writeRemoteFile({
317
+ host,
318
+ filePath: REMOTE_CONFIG_PATH,
319
+ // Only what differs between machines. Every path the daemon uses is derived in the daemon
320
+ // itself, so there is nothing here to drift out of sync.
321
+ contents: JSON.stringify({ hostLabel, repoSources }, undefined, 4) + "\n",
322
+ fileMode: "600",
323
+ directoryMode: "700",
324
+ });
325
+ await writeRemoteFile({
326
+ host,
327
+ filePath: REMOTE_SERVICE_PATH,
328
+ contents: await fs.readFile(SERVICE_SOURCE, "utf8"),
329
+ fileMode: "644",
330
+ directoryMode: "755",
331
+ });
332
+ await runOverSSH({
333
+ host,
334
+ script: `${SUDO_PREAMBLE}
335
+ set -e
336
+ $SUDO systemctl daemon-reload
337
+ $SUDO systemctl enable ${SERVICE_NAME}
338
+ $SUDO systemctl restart ${SERVICE_NAME}`,
339
+ });
340
+
341
+ let status = (await runOverSSH({
342
+ host,
343
+ script: `systemctl is-active ${SERVICE_NAME} || true`,
344
+ allowFailure: true,
345
+ })).stdout.trim();
346
+ if (status !== "active") {
347
+ let journal = (await runOverSSH({
348
+ host,
349
+ script: `${SUDO_PREAMBLE}
350
+ $SUDO journalctl -u ${SERVICE_NAME} -n 40 --no-pager || true`,
351
+ allowFailure: true,
352
+ })).stdout;
353
+ throw new Error(`Expected ${SERVICE_NAME} to be active on ${host}, was ${status}.\n${journal.slice(-2000)}`);
354
+ }
355
+
356
+ let stillReachable = await runOverSSH({ host, script: "echo reachable", allowFailure: true });
357
+ if (stillReachable.stdout.trim() !== "reachable") {
358
+ throw new Error(
359
+ `Expected ${host} to still be reachable after the daemon started, it is not.`
360
+ + ` Check console access immediately.`
361
+ );
362
+ }
363
+ }
364
+
365
+ async function requireRemoteWebhook(host: string) {
366
+ let contents = await readRemoteFile({ host, filePath: DEFAULT_WEBHOOK_FILE_PATH });
367
+ if (!contents) {
368
+ throw new Error(
369
+ `Expected a Discord webhook at ${DEFAULT_WEBHOOK_FILE_PATH} on ${host}, no such file exists.`
370
+ + ` The daemon will not start without one.\n`
371
+ + `Set it up first:\n yarn setupnotify ${host} <discord-webhook-url>`
372
+ );
373
+ }
374
+ return parseWebhookFile({ contents, sourceName: `${host}:${DEFAULT_WEBHOOK_FILE_PATH}` });
375
+ }
376
+
377
+ async function addSource(config: { host: string; keyPath: string; repoURL: string }) {
378
+ let { host, keyPath, repoURL } = config;
379
+ if (!await pathExists(keyPath)) {
380
+ throw new Error(`Expected a private key at ${keyPath}, no such file exists`);
381
+ }
382
+
383
+ console.log(`Checking ${repoURL} is reachable with ${keyPath}`);
384
+ let reachable = await gitWithKey({ keyPath, args: ["ls-remote", repoURL], allowFailure: true });
385
+ if (reachable.status !== 0) {
386
+ throw new Error(
387
+ `Expected ${repoURL} to be reachable with ${keyPath}, git ls-remote failed.`
388
+ + ` The daemon would have no way to fetch keys.\n`
389
+ + `${(reachable.stdout + reachable.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
390
+ );
391
+ }
392
+
393
+ let remoteConfig = await readRemoteConfig(host);
394
+ if (remoteConfig.repoSources.includes(repoURL)) {
395
+ console.log(`${host} already has ${repoURL}, refreshing its key and the daemon.`);
396
+ }
397
+
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);
401
+ let inspectionPath = await cloneRepoForInspection({ repoURL, keyPath });
402
+ let newKeys = await readRepoKeys(inspectionPath);
403
+ // Whatever is applied on the host came from the existing sources, so it stays in the merge.
404
+ let existingKeys = normalizeKeys(await readRemoteFile({ host, filePath: ROOT_AUTHORIZED_KEYS }) || "");
405
+ let mergedFingerprints = await fingerprintKeys([...existingKeys, ...newKeys]);
406
+ if (!mergedFingerprints.includes(ourFingerprint)) {
407
+ throw new Error(
408
+ `Expected the key we use for ${host} to be in the merged keys, it is not.\n`
409
+ + `Ours: ${ourFingerprint}\n`
410
+ + `Merged: ${mergedFingerprints.join("\n ") || "(none)"}\n`
411
+ + `The daemon replaces root's authorized_keys with the merged sources, so this would`
412
+ + ` lock you out of ${host}. Add your public key to ${repoURL} first.`
413
+ );
414
+ }
415
+ console.log(`Our key ${ourFingerprint} is in the merged keys, access will survive.`);
416
+
417
+ await ensureRevokeRepo({ keyPath, repoURL });
418
+
419
+ // Deploying a repo that still holds a revoked key would hand it back to every machine.
420
+ let revoked = await revokedKeysInRepo({ repoPath: inspectionPath, sourceURL: repoURL });
421
+ if (revoked.length) {
422
+ throw new Error(
423
+ `Expected ${repoURL} to hold no revoked keys, it holds ${revoked.length}:\n`
424
+ + revoked.map(entry => ` ${entry.revocation.fingerprint} revoked by`
425
+ + ` ${entry.revocation.revokedBy || "?"} after use from ${entry.revocation.attempt?.ip || "?"}`).join("\n")
426
+ + `\nDelete them from the repo, or run "yarn unrevoke" there to allow them again.`
427
+ );
428
+ }
429
+
430
+ let webhookURL = await requireRemoteWebhook(host);
431
+ console.log(`${host} notifies ${webhookURL}`);
432
+
433
+ await writeRemoteFile({
434
+ host,
435
+ filePath: sourceKeyPath(repoURL),
436
+ contents: await fs.readFile(keyPath, "utf8"),
437
+ fileMode: "600",
438
+ directoryMode: "700",
439
+ });
440
+
441
+ let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
442
+ repoSources.push(repoURL);
443
+ await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
444
+ console.log(`${repoURL} added to ${host}. ${repoSources.length} source(s) now merged.`);
445
+ }
446
+
447
+ async function removeSource(config: { host: string; repoURL: string }) {
448
+ let { host, repoURL } = config;
449
+ let remoteConfig = await readRemoteConfig(host);
450
+ if (!remoteConfig.repoSources.includes(repoURL)) {
451
+ throw new Error(
452
+ `Expected ${repoURL} to be a source on ${host}, it is not.\n`
453
+ + `Configured:\n ${remoteConfig.repoSources.join("\n ") || "(none)"}`
454
+ );
455
+ }
456
+ let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
457
+
458
+ if (repoSources.length) {
459
+ // 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}`);
461
+ let ourFingerprint = await findAuthenticatingFingerprint(host);
462
+ let remainingKeys: string[] = [];
463
+ for (let source of repoSources) {
464
+ remainingKeys.push(...await readRemoteSourceKeys({ host, repoURL: source }));
465
+ }
466
+ let remainingFingerprints = await fingerprintKeys(remainingKeys);
467
+ if (!remainingFingerprints.includes(ourFingerprint)) {
468
+ throw new Error(
469
+ `Expected the key we use for ${host} to still be in the remaining sources, it is not.\n`
470
+ + `Ours: ${ourFingerprint}\n`
471
+ + `Remaining: ${remainingFingerprints.join("\n ") || "(none)"}\n`
472
+ + `Removing ${repoURL} would lock you out of ${host}.`
473
+ );
474
+ }
475
+ } else {
476
+ // Nothing left to merge, so the daemon leaves root's authorized_keys exactly as it is.
477
+ console.log(`${repoURL} is the last source, so root's authorized_keys stays as it is now.`);
478
+ }
479
+
480
+ await requireRemoteWebhook(host);
481
+ await runOverSSH({
482
+ host,
483
+ script: `${SUDO_PREAMBLE}
484
+ $SUDO rm -f "${sourceKeyPath(repoURL)}"
485
+ $SUDO rm -rf "${sourceRepoPath(repoURL)}"`,
486
+ });
487
+ await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
488
+ console.log(`${repoURL} removed from ${host}. ${repoSources.length} source(s) left.`);
489
+ }
490
+
491
+ /** Answers "who can log into this box, and which repo says so". The paths the daemon uses are
492
+ 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
+
523
+ /** Pushes the current daemon onto a host that already has one, for when this code has moved on.
524
+ Nothing about which keys the host trusts is touched. */
525
+ 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
+ let contents = await readRemoteFile({ host, filePath: REMOTE_CONFIG_PATH });
530
+ if (!contents) {
531
+ throw new Error(
532
+ `Expected ${host} to already have portsecure, ${REMOTE_CONFIG_PATH} does not exist.\n`
533
+ + `Set it up with:\n yarn securessh ${host} add <repo-private-key> [repo-url]`
534
+ );
535
+ }
536
+ let parsed = JSON.parse(contents) as { hostLabel?: string; repoSources?: string[] };
537
+ let repoSources = parsed.repoSources || [];
538
+ await requireRemoteWebhook(host);
539
+ await ensureRevokeReposExist(repoSources);
540
+ await installDaemon({ host, hostLabel: parsed.hostLabel || host, repoSources });
541
+ console.log(`Updated the daemon on ${host}. ${repoSources.length} source(s), unchanged.`);
542
+ }
543
+
544
+ async function listSources(host: string) {
545
+ let remoteConfig = await readRemoteConfig(host);
546
+ if (!remoteConfig.repoSources.length) {
547
+ console.log(`${host} has no key sources. root's authorized_keys is left exactly as it is.`);
548
+ return;
549
+ }
550
+ console.log(`${host} lets root log in with the keys from ${remoteConfig.repoSources.length} repo(s):`);
551
+ let merged = new Set<string>();
552
+ for (let repoURL of remoteConfig.repoSources) {
553
+ let keys = await readRemoteSourceKeys({ host, repoURL });
554
+ console.log(`\n ${repoURL}`);
555
+ if (!keys.length) {
556
+ console.log(` grants no keys - the checkout is missing or empty`);
557
+ continue;
558
+ }
559
+ console.log(` grants ${keys.length} key(s):`);
560
+ for (let key of keys) {
561
+ console.log(` ${summarizeKey(key)}`);
562
+ merged.add(key);
563
+ }
564
+ }
565
+ if (remoteConfig.repoSources.length > 1) {
566
+ console.log(`\n${merged.size} key(s) in total once duplicates are merged.`);
567
+ }
568
+ }
569
+
570
+ /** The verb is a fixed word rather than a position, so it is pulled out of the arguments wherever
571
+ it was typed and everything left over is positional. */
572
+ function parseArgs(argv: string[]) {
573
+ let verbs = argv.filter(arg => VERBS.includes(arg));
574
+ if (!verbs.length) {
575
+ throw new Error(`Expected one of ${VERBS.join(", ")} somewhere in the arguments, was ${argv.join(" ") || "(nothing)"}\n${USAGE}`);
576
+ }
577
+ if (verbs.length > 1) {
578
+ throw new Error(`Expected one of ${VERBS.join(", ")}, was ${verbs.join(" and ")}\n${USAGE}`);
579
+ }
580
+ 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 };
586
+ }
587
+
588
+ export async function main() {
589
+ let { verb, host, rest } = parseArgs(process.argv.slice(2));
590
+
591
+ if (verb === "list") {
592
+ await listSources(host);
593
+ return;
594
+ }
595
+ if (verb === "update") {
596
+ if (rest.length) {
597
+ throw new Error(`Expected nothing after update, was ${rest.length} argument(s)\n${USAGE}`);
598
+ }
599
+ await updateDaemon(host);
600
+ return;
601
+ }
602
+ if (verb === "add") {
603
+ if (!rest.length || rest.length > 2) {
604
+ throw new Error(`Expected a private key and optionally a repo url, was ${rest.length} argument(s)\n${USAGE}`);
605
+ }
606
+ await addSource({ host, keyPath: expandHome(rest[0]), repoURL: await resolveRepoURL(rest[1]) });
607
+ return;
608
+ }
609
+ if (rest.length > 1) {
610
+ throw new Error(`Expected at most a repo url to remove, was ${rest.length} argument(s)\n${USAGE}`);
611
+ }
612
+ await removeSource({ host, repoURL: await resolveRepoURL(rest[0]) });
613
+ }
@@ -0,0 +1,20 @@
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.
4
+
5
+ export const REPO_KEYS_DIR = "/etc/portsecure/repo-keys";
6
+ export const REPOS_DIR = "/var/lib/portsecure/authorized-keys-repos";
7
+
8
+ /** A repo url reduced to something usable as a file name. Derived rather than configured, so the
9
+ daemon and the deploy script always agree on where a source's key and checkout live. */
10
+ export function sourceName(repoURL: string) {
11
+ return repoURL.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
12
+ }
13
+
14
+ export function sourceKeyPath(repoURL: string) {
15
+ return `${REPO_KEYS_DIR}/${sourceName(repoURL)}`;
16
+ }
17
+
18
+ export function sourceRepoPath(repoURL: string) {
19
+ return `${REPOS_DIR}/${sourceName(repoURL)}`;
20
+ }