sliftutils 1.7.127 → 1.7.128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.7.127",
3
+ "version": "1.7.128",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -1,7 +1,7 @@
1
1
  import fs from "fs/promises";
2
2
  import os from "os";
3
3
  import { configureDiscordNotifications, DEFAULT_WEBHOOK_FILE_PATH } from "../../notifications/discord";
4
- import { sourceKeyPath, sourceRepoPath } from "../sources";
4
+ import { findSourceKey, legacySourceKeyPath, sourceKeyPath, sourceRepoPath } from "../sources";
5
5
  import { cloneRepo, syncRepo } from "./git";
6
6
  import {
7
7
  CHECK_INTERVAL,
@@ -74,10 +74,11 @@ async function loadConfig(): Promise<DaemonConfig> {
74
74
  process.exit(1);
75
75
  }
76
76
  for (let repoURL of parsed.repoSources) {
77
- try {
78
- await fs.access(sourceKeyPath(repoURL));
79
- } catch (e) {
80
- console.error(`portsecure: expected the private key for ${repoURL} at ${sourceKeyPath(repoURL)}, ${e}`);
77
+ if (!await findSourceKey(repoURL)) {
78
+ console.error(
79
+ `portsecure: expected the private key for ${repoURL} at ${sourceKeyPath(repoURL)}`
80
+ + ` or ${legacySourceKeyPath(repoURL)}, neither exists`
81
+ );
81
82
  process.exit(1);
82
83
  }
83
84
  }
@@ -213,7 +214,7 @@ async function pollSource(repoURL: string) {
213
214
  // Availability over tidiness: throw the working copy away and start again.
214
215
  console.log(`Discarding the checkout of ${repoURL} and cloning from scratch`);
215
216
  try {
216
- await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: sourceKeyPath(repoURL) });
217
+ await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: await findSourceKey(repoURL) || sourceKeyPath(repoURL) });
217
218
  repoFailureCounts[repoURL] = 0;
218
219
  return true;
219
220
  } catch (cloneError) {
@@ -1,7 +1,7 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import { spawnPromise } from "../../helpers/spawn";
4
- import { sourceKeyPath, sourceRepoPath } from "../sources";
4
+ import { findSourceKey, sourceKeyPath, sourceRepoPath } from "../sources";
5
5
  import { GIT_TIMEOUT, MAX_ERROR_BODY_LENGTH } from "./paths";
6
6
  import { sourceState } from "./state";
7
7
 
@@ -68,7 +68,7 @@ export async function currentBranch(config: { repoPath: string; keyPath: string
68
68
 
69
69
  async function ensureSourceRepo(repoURL: string) {
70
70
  let repoPath = sourceRepoPath(repoURL);
71
- let keyPath = sourceKeyPath(repoURL);
71
+ let keyPath = await findSourceKey(repoURL) || sourceKeyPath(repoURL);
72
72
  if (!await repoIsUsable({ repoPath, keyPath })) {
73
73
  await cloneRepo({ repoURL, repoPath, keyPath });
74
74
  }
@@ -82,7 +82,7 @@ async function ensureSourceRepo(repoURL: string) {
82
82
  export async function syncRepo(repoURL: string) {
83
83
  await ensureSourceRepo(repoURL);
84
84
  let repoPath = sourceRepoPath(repoURL);
85
- let keyPath = sourceKeyPath(repoURL);
85
+ let keyPath = await findSourceKey(repoURL) || sourceKeyPath(repoURL);
86
86
  let branch = sourceState(repoURL).branch;
87
87
  let localSha = (await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath, keyPath })).stdout.trim();
88
88
 
@@ -1,8 +1,8 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import { keyFingerprint, summarizeKey } from "../authorizedKeys";
4
- import { deriveRevokeKey, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
5
- import { sourceKeyPath, sourceRepoPath } from "../sources";
4
+ import { deriveRevokeKey, findRevokeKey, REVOKE_KEY_LABEL, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
5
+ import { findSourceKey, sourceKeyPath, sourceRepoPath } from "../sources";
6
6
  import { cloneRepo, repoIsUsable, runGit } from "./git";
7
7
  import { messageTimestamp } from "../../notifications/discord";
8
8
  import { UNREVOKE_DELAY } from "./paths";
@@ -40,14 +40,20 @@ async function pathExists(filePath: string) {
40
40
  /** The revoke repo's key is worked out from the source's, so nothing extra had to be uploaded and
41
41
  nothing extra is stored anywhere it could be taken from. */
42
42
  async function ensureRevokeKey(sourceURL: string) {
43
- let keyPath = revokeKeyPath(sourceURL);
44
- if (await pathExists(keyPath)) {
45
- return keyPath;
43
+ let existing = await findRevokeKey(sourceURL);
44
+ if (existing) {
45
+ return existing;
46
+ }
47
+ let sourceKey = await findSourceKey(sourceURL);
48
+ if (!sourceKey) {
49
+ throw new Error(`Expected a key for ${sourceURL} at ${sourceKeyPath(sourceURL)}, no such file exists`);
46
50
  }
47
- let derived = deriveRevokeKey(await fs.readFile(sourceKeyPath(sourceURL), "utf8"));
51
+ let derived = deriveRevokeKey(await fs.readFile(sourceKey, "utf8"));
52
+ let keyPath = revokeKeyPath(sourceURL);
48
53
  await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
49
54
  await fs.writeFile(keyPath, derived.privateKeyFile, { mode: 0o600 });
50
- console.log(`Derived the revoke key for ${sourceURL}`);
55
+ await fs.writeFile(`${keyPath}.pub`, `${derived.publicKey} ${REVOKE_KEY_LABEL}\n`, { mode: 0o644 });
56
+ console.log(`Derived the revoke key for ${sourceURL} into ${keyPath}`);
51
57
  return keyPath;
52
58
  }
53
59
 
@@ -165,7 +171,7 @@ export async function recordRevocation(config: {
165
171
  }
166
172
 
167
173
  let repoPath = revokeRepoPath(sourceURL);
168
- let keyPath = revokeKeyPath(sourceURL);
174
+ let keyPath = await ensureRevokeKey(sourceURL);
169
175
  let directory = path.join(repoPath, REVOCATIONS_DIR);
170
176
  await fs.mkdir(directory, { recursive: true });
171
177
  await fs.writeFile(path.join(directory, `${revocationId}.json`), JSON.stringify({
@@ -1,6 +1,7 @@
1
1
  import { deriveEd25519Key } from "../keys/deriveKey";
2
2
  import { formatOpenSSHPrivateKey, parseOpenSSHPrivateKey } from "../keys/sshKeyFile";
3
- import { REPO_KEYS_DIR, REPOS_DIR, sourceName } from "./sources";
3
+ import path from "path";
4
+ import { findKey, keysDir, LEGACY_REPO_KEYS_DIR, REPOS_DIR, sourceName } from "./sources";
4
5
 
5
6
  // A source's revocations live in a repo beside it. Derived rather than configured, so adding a
6
7
  // source brings its revoke repo with it and there is nothing extra to pass on the command line.
@@ -15,7 +16,16 @@ export function revokeRepoURL(sourceURL: string) {
15
16
  }
16
17
 
17
18
  export function revokeKeyPath(sourceURL: string) {
18
- return `${REPO_KEYS_DIR}/${sourceName(sourceURL)}${REVOKED_SUFFIX}`;
19
+ return path.join(keysDir(), `${sourceName(sourceURL)}${REVOKED_SUFFIX}`);
20
+ }
21
+
22
+ export function legacyRevokeKeyPath(sourceURL: string) {
23
+ return `${LEGACY_REPO_KEYS_DIR}/${sourceName(sourceURL)}${REVOKED_SUFFIX}`;
24
+ }
25
+
26
+ /** Wherever this source's revoke key already is, or nothing if it has not been derived yet. */
27
+ export async function findRevokeKey(sourceURL: string) {
28
+ return await findKey({ current: revokeKeyPath(sourceURL), legacy: legacyRevokeKeyPath(sourceURL) });
19
29
  }
20
30
 
21
31
  export function revokeRepoPath(sourceURL: string) {
@@ -3,8 +3,9 @@ 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";
@@ -125,6 +126,27 @@ async function fingerprintKeys(keys: string[]) {
125
126
  return fingerprints;
126
127
  }
127
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
+
128
150
  async function cloneRepoForInspection(config: { repoURL: string; keyPath: string }) {
129
151
  let { repoURL, keyPath } = config;
130
152
  let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-repo-"));
@@ -184,8 +206,9 @@ async function ensureRevokeReposExist(repoSources: string[]) {
184
206
  // and the key derived from it is the one credential that repo is guaranteed to accept.
185
207
  // Anywhere else, a person is running this and has their own access.
186
208
  let ownKey = "";
187
- if (await pathExists(sourceKeyPath(repoURL))) {
188
- let derived = deriveRevokeKey(await fs.readFile(sourceKeyPath(repoURL), "utf8"));
209
+ let localSourceKey = await findSourceKey(repoURL);
210
+ if (localSourceKey) {
211
+ let derived = deriveRevokeKey(await fs.readFile(localSourceKey, "utf8"));
189
212
  ownKey = path.join(temporaryDirectory, "key");
190
213
  await fs.writeFile(ownKey, derived.privateKeyFile, { mode: 0o600 });
191
214
  }
@@ -467,13 +490,26 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
467
490
  let webhookURL = await requireRemoteWebhook(host);
468
491
  console.log(`${describeHost(host)} notifies ${webhookURL}`);
469
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);
470
497
  await writeRemoteFile({
471
498
  host,
472
- filePath: sourceKeyPath(repoURL),
499
+ filePath: `${hostKeysDirectory}/${sourceName(repoURL)}`,
473
500
  contents: await fs.readFile(keyPath, "utf8"),
474
501
  fileMode: "600",
475
502
  directoryMode: "700",
476
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
+ });
477
513
 
478
514
  let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
479
515
  repoSources.push(repoURL);
@@ -519,7 +555,7 @@ async function removeSource(config: { host: string; repoURL: string }) {
519
555
  await runOverSSH({
520
556
  host,
521
557
  script: `${SUDO_PREAMBLE}
522
- $SUDO rm -f "${sourceKeyPath(repoURL)}"
558
+ $SUDO rm -f "${sourceKeyPath(repoURL)}" "${sourceKeyPath(repoURL)}.pub" "${legacySourceKeyPath(repoURL)}"
523
559
  $SUDO rm -rf "${sourceRepoPath(repoURL)}"`,
524
560
  });
525
561
  await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
@@ -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,7 +3,8 @@ 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";
7
8
  import { signRepo } from "../signedFiles/signFiles";
8
9
  import { readRepoKeys } from "./authorizedKeys";
9
10
  import { expandHome } from "../helpers/paths";
@@ -31,17 +32,25 @@ export type Revocation = {
31
32
  attempt?: { ip?: string; user?: string; required?: string };
32
33
  };
33
34
 
34
- /** Every revocation the revoke repo lists. Cloned read only into a temp directory, with whatever
35
- credentials this machine already has - the derived deploy key is for servers, and a person
36
- 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. */
37
40
  export async function readRemoteRevocations(config: { sourceURL: string }) {
38
41
  let { sourceURL } = config;
39
42
  let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "unrevoke-"));
40
43
  let repoPath = path.join(temporaryDirectory, "repo");
41
- let clone = await spawnPromise({
42
- command: "git",
43
- args: ["clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath],
44
- });
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 });
45
54
  if (clone.status !== 0) {
46
55
  await fs.rm(temporaryDirectory, { recursive: true, force: true });
47
56
  throw new Error(