sliftutils 1.7.124 → 1.7.125

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.
@@ -0,0 +1,427 @@
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 { expandHome } from "../helpers/paths";
8
+ import { spawnPromise } from "../helpers/spawn";
9
+ import { readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, writeRemoteFile } from "../helpers/remoteSSH";
10
+
11
+ const DAEMON_SOURCE = path.join(__dirname, "daemon", "portsecureDaemon.js");
12
+ const SERVICE_SOURCE = path.join(__dirname, "daemon", "portsecure.service");
13
+ const REMOTE_DAEMON_PATH = "/opt/portsecure/portsecure-daemon.js";
14
+ const REMOTE_SERVICE_PATH = "/etc/systemd/system/portsecure.service";
15
+ const REMOTE_CONFIG_PATH = "/etc/portsecure/daemon.json";
16
+ const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
17
+ const SERVICE_NAME = "portsecure";
18
+ const MAX_ERROR_BODY_LENGTH = 500;
19
+ const VERBS = ["add", "remove", "list"];
20
+ // The repo url is optional, and defaults to the repo the command is run from.
21
+ const USAGE = `Usage:
22
+ yarn securessh <host> add <repo-private-key> [repo-url]
23
+ yarn securessh <host> remove [repo-url]
24
+ yarn securessh <host> list`;
25
+
26
+ async function pathExists(filePath: string) {
27
+ try {
28
+ await fs.access(filePath);
29
+ return true;
30
+ } catch (e) {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async function runLocal(config: { command: string; args: string[]; cwd?: string; allowFailure?: boolean }) {
36
+ let { command, args, cwd, allowFailure } = config;
37
+ let result = await spawnPromise({ command, args, cwd });
38
+ if (result.error) {
39
+ throw new Error(`Expected ${command} to run, failed with ${result.error.message}`);
40
+ }
41
+ if (result.status !== 0 && !allowFailure) {
42
+ throw new Error(
43
+ `Expected ${command} ${args.join(" ")} to exit 0, was ${result.status}. `
44
+ + `${(result.stderr || "").slice(0, MAX_ERROR_BODY_LENGTH)}`
45
+ );
46
+ }
47
+ return result;
48
+ }
49
+
50
+ /** A private key cannot authenticate an https remote, so github urls are converted to the ssh form
51
+ the key can actually be used with. */
52
+ function normalizeRepoURL(repoURL: string) {
53
+ let httpsMatch = repoURL.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/);
54
+ if (!httpsMatch) {
55
+ return repoURL;
56
+ }
57
+ let [, host, repoPath] = httpsMatch;
58
+ return `git@${host}:${repoPath}.git`;
59
+ }
60
+
61
+ async function gitWithKey(config: { keyPath: string; args: string[]; cwd?: string; allowFailure?: boolean }) {
62
+ let { keyPath, args, cwd, allowFailure } = config;
63
+ // core.sshCommand keeps key selection with the command instead of in the environment.
64
+ let sshCommand = `ssh -i ${keyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
65
+ let result = await spawnPromise({ command: "git", args: ["-c", `core.sshCommand=${sshCommand}`, ...args], cwd });
66
+ if (result.error) {
67
+ throw new Error(`Expected git to run, failed with ${result.error.message}`);
68
+ }
69
+ if (result.status !== 0 && !allowFailure) {
70
+ throw new Error(
71
+ `Expected git ${args.join(" ")} to exit 0, was ${result.status}. `
72
+ + `${(result.stderr || "").slice(0, MAX_ERROR_BODY_LENGTH)}`
73
+ );
74
+ }
75
+ return result;
76
+ }
77
+
78
+ /** Asks ssh which key it actually authenticated with. This is the key that must survive the
79
+ daemon taking over authorized_keys, otherwise a deploy locks us out. */
80
+ async function findAuthenticatingFingerprint(host: string) {
81
+ let result = await spawnPromise({
82
+ command: "ssh",
83
+ args: ["-v", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", host, "true"],
84
+ });
85
+ let verboseOutput = result.stderr || "";
86
+ if (result.status !== 0) {
87
+ throw new Error(`Expected to ssh into ${host}, failed. ${verboseOutput.slice(-MAX_ERROR_BODY_LENGTH)}`);
88
+ }
89
+ let acceptedMatch = verboseOutput.match(/Server accepts key:.*?(SHA256:[A-Za-z0-9+/=]+)/);
90
+ if (!acceptedMatch) {
91
+ throw new Error(
92
+ `Expected ${host} to accept a public key, but the session did not authenticate with one.`
93
+ + ` portsecure disables password login, so key based access has to work first.`
94
+ );
95
+ }
96
+ return acceptedMatch[1];
97
+ }
98
+
99
+ async function fingerprintKeys(keys: string[]) {
100
+ if (!keys.length) {
101
+ return [];
102
+ }
103
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-keys-"));
104
+ let keysPath = path.join(temporaryDirectory, "authorized_keys");
105
+ await fs.writeFile(keysPath, `${keys.join("\n")}\n`);
106
+ let result = await runLocal({ command: "ssh-keygen", args: ["-lf", keysPath], allowFailure: true });
107
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
108
+ let fingerprints: string[] = [];
109
+ for (let line of result.stdout.split("\n")) {
110
+ let match = line.match(/(SHA256:[A-Za-z0-9+/=]+)/);
111
+ if (match) {
112
+ fingerprints.push(match[1]);
113
+ }
114
+ }
115
+ return fingerprints;
116
+ }
117
+
118
+ async function cloneRepoForInspection(config: { repoURL: string; keyPath: string }) {
119
+ let { repoURL, keyPath } = config;
120
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-repo-"));
121
+ let repoPath = path.join(temporaryDirectory, "repo");
122
+ await gitWithKey({ keyPath, args: ["clone", "--depth", "1", repoURL, repoPath] });
123
+ return repoPath;
124
+ }
125
+
126
+ /** With no repo url given, the repo we are standing in is used. It has to actually hold keys
127
+ before we hand it to a host, so a stray working directory cannot be deployed by accident. */
128
+ async function resolveRepoURL(passedURL: string | undefined) {
129
+ if (passedURL) {
130
+ return normalizeRepoURL(passedURL);
131
+ }
132
+ let topLevel = await runLocal({ command: "git", args: ["rev-parse", "--show-toplevel"], allowFailure: true });
133
+ if (topLevel.status !== 0) {
134
+ throw new Error(`Expected a repo url, or the current directory to be inside a git repo, it is not.\n${USAGE}`);
135
+ }
136
+ let repoPath = topLevel.stdout.trim();
137
+ try {
138
+ await readRepoKeys(repoPath);
139
+ } catch (e) {
140
+ throw new Error(
141
+ `Expected a repo url, or the current repo (${repoPath}) to hold keys, it does not.\n${e}\n${USAGE}`
142
+ );
143
+ }
144
+ let origin = await runLocal({
145
+ command: "git",
146
+ args: ["remote", "get-url", "origin"],
147
+ cwd: repoPath,
148
+ allowFailure: true,
149
+ });
150
+ if (origin.status !== 0 || !origin.stdout.trim()) {
151
+ throw new Error(
152
+ `Expected the current repo (${repoPath}) to have an origin remote, it has none.`
153
+ + ` The host clones the source itself, so a local path is no use to it.`
154
+ );
155
+ }
156
+ let repoURL = normalizeRepoURL(origin.stdout.trim());
157
+ console.log(`No repo url given, using the current repo: ${repoURL}`);
158
+ return repoURL;
159
+ }
160
+
161
+ async function readRemoteConfig(host: string) {
162
+ let contents = await readRemoteFile({ host, filePath: REMOTE_CONFIG_PATH });
163
+ if (!contents) {
164
+ return { hostLabel: host, repoSources: [] as string[] };
165
+ }
166
+ let parsed = JSON.parse(contents) as { hostLabel?: string; repoSources?: string[] };
167
+ return { hostLabel: parsed.hostLabel || host, repoSources: parsed.repoSources || [] };
168
+ }
169
+
170
+ /** Reads the keys a source's checkout currently holds on the host, so the merged result can be
171
+ worked out without needing that source's private key locally. */
172
+ async function readRemoteSourceKeys(config: { host: string; repoURL: string }) {
173
+ let { host, repoURL } = config;
174
+ let repoPath = sourceRepoPath(repoURL);
175
+ let output = await runOverSSH({
176
+ host,
177
+ script: `${SUDO_PREAMBLE}
178
+ if $SUDO test -f "${repoPath}/authorized_keys"; then
179
+ $SUDO cat "${repoPath}/authorized_keys"
180
+ elif $SUDO test -d "${repoPath}"; then
181
+ $SUDO cat "${repoPath}"/*.pub 2>/dev/null || true
182
+ fi`,
183
+ allowFailure: true,
184
+ });
185
+ return normalizeKeys(output.stdout);
186
+ }
187
+
188
+ async function installDaemon(config: { host: string; hostLabel: string; repoSources: string[] }) {
189
+ let { host, hostLabel, repoSources } = config;
190
+ for (let command of ["node", "git"]) {
191
+ if (!await remoteCommandExists({ host, command })) {
192
+ throw new Error(`Expected ${command} to be installed on ${host}, it is not. Install it and rerun.`);
193
+ }
194
+ }
195
+ await writeRemoteFile({
196
+ host,
197
+ filePath: REMOTE_CONFIG_PATH,
198
+ // Only what differs between machines. Every path the daemon uses is derived in the daemon
199
+ // itself, so there is nothing here to drift out of sync.
200
+ contents: JSON.stringify({ hostLabel, repoSources }, undefined, 4) + "\n",
201
+ fileMode: "600",
202
+ directoryMode: "700",
203
+ });
204
+ await writeRemoteFile({
205
+ host,
206
+ filePath: REMOTE_DAEMON_PATH,
207
+ contents: await fs.readFile(DAEMON_SOURCE, "utf8"),
208
+ fileMode: "755",
209
+ directoryMode: "755",
210
+ });
211
+ await writeRemoteFile({
212
+ host,
213
+ filePath: REMOTE_SERVICE_PATH,
214
+ contents: await fs.readFile(SERVICE_SOURCE, "utf8"),
215
+ fileMode: "644",
216
+ directoryMode: "755",
217
+ });
218
+ await runOverSSH({
219
+ host,
220
+ script: `${SUDO_PREAMBLE}
221
+ set -e
222
+ $SUDO systemctl daemon-reload
223
+ $SUDO systemctl enable ${SERVICE_NAME}
224
+ $SUDO systemctl restart ${SERVICE_NAME}`,
225
+ });
226
+
227
+ let status = (await runOverSSH({
228
+ host,
229
+ script: `systemctl is-active ${SERVICE_NAME} || true`,
230
+ allowFailure: true,
231
+ })).stdout.trim();
232
+ if (status !== "active") {
233
+ let journal = (await runOverSSH({
234
+ host,
235
+ script: `${SUDO_PREAMBLE}
236
+ $SUDO journalctl -u ${SERVICE_NAME} -n 40 --no-pager || true`,
237
+ allowFailure: true,
238
+ })).stdout;
239
+ throw new Error(`Expected ${SERVICE_NAME} to be active on ${host}, was ${status}.\n${journal.slice(-2000)}`);
240
+ }
241
+
242
+ let stillReachable = await runOverSSH({ host, script: "echo reachable", allowFailure: true });
243
+ if (stillReachable.stdout.trim() !== "reachable") {
244
+ throw new Error(
245
+ `Expected ${host} to still be reachable after the daemon started, it is not.`
246
+ + ` Check console access immediately.`
247
+ );
248
+ }
249
+ }
250
+
251
+ async function requireRemoteWebhook(host: string) {
252
+ let contents = await readRemoteFile({ host, filePath: DEFAULT_WEBHOOK_FILE_PATH });
253
+ if (!contents) {
254
+ throw new Error(
255
+ `Expected a Discord webhook at ${DEFAULT_WEBHOOK_FILE_PATH} on ${host}, no such file exists.`
256
+ + ` The daemon will not start without one.\n`
257
+ + `Set it up first:\n yarn setupnotify ${host} <discord-webhook-url>`
258
+ );
259
+ }
260
+ return parseWebhookFile({ contents, sourceName: `${host}:${DEFAULT_WEBHOOK_FILE_PATH}` });
261
+ }
262
+
263
+ async function addSource(config: { host: string; keyPath: string; repoURL: string }) {
264
+ let { host, keyPath, repoURL } = config;
265
+ if (!await pathExists(keyPath)) {
266
+ throw new Error(`Expected a private key at ${keyPath}, no such file exists`);
267
+ }
268
+
269
+ console.log(`Checking ${repoURL} is reachable with ${keyPath}`);
270
+ let reachable = await gitWithKey({ keyPath, args: ["ls-remote", repoURL], allowFailure: true });
271
+ if (reachable.status !== 0) {
272
+ throw new Error(
273
+ `Expected ${repoURL} to be reachable with ${keyPath}, git ls-remote failed.`
274
+ + ` The daemon would have no way to fetch keys.\n${reachable.stderr.slice(0, MAX_ERROR_BODY_LENGTH)}`
275
+ );
276
+ }
277
+
278
+ let remoteConfig = await readRemoteConfig(host);
279
+ if (remoteConfig.repoSources.includes(repoURL)) {
280
+ console.log(`${host} already has ${repoURL}, refreshing its key and the daemon.`);
281
+ }
282
+
283
+ // The merged result is what root ends up with, so our own key has to be somewhere in it.
284
+ console.log(`Checking our access to ${host} survives the merged keys`);
285
+ let ourFingerprint = await findAuthenticatingFingerprint(host);
286
+ let inspectionPath = await cloneRepoForInspection({ repoURL, keyPath });
287
+ let newKeys = await readRepoKeys(inspectionPath);
288
+ // Whatever is applied on the host came from the existing sources, so it stays in the merge.
289
+ let existingKeys = normalizeKeys(await readRemoteFile({ host, filePath: ROOT_AUTHORIZED_KEYS }) || "");
290
+ let mergedFingerprints = await fingerprintKeys([...existingKeys, ...newKeys]);
291
+ if (!mergedFingerprints.includes(ourFingerprint)) {
292
+ throw new Error(
293
+ `Expected the key we use for ${host} to be in the merged keys, it is not.\n`
294
+ + `Ours: ${ourFingerprint}\n`
295
+ + `Merged: ${mergedFingerprints.join("\n ") || "(none)"}\n`
296
+ + `The daemon replaces root's authorized_keys with the merged sources, so this would`
297
+ + ` lock you out of ${host}. Add your public key to ${repoURL} first.`
298
+ );
299
+ }
300
+ console.log(`Our key ${ourFingerprint} is in the merged keys, access will survive.`);
301
+
302
+ let webhookURL = await requireRemoteWebhook(host);
303
+ console.log(`${host} notifies ${webhookURL}`);
304
+
305
+ await writeRemoteFile({
306
+ host,
307
+ filePath: sourceKeyPath(repoURL),
308
+ contents: await fs.readFile(keyPath, "utf8"),
309
+ fileMode: "600",
310
+ directoryMode: "700",
311
+ });
312
+
313
+ let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
314
+ repoSources.push(repoURL);
315
+ await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
316
+ console.log(`${repoURL} added to ${host}. ${repoSources.length} source(s) now merged.`);
317
+ }
318
+
319
+ async function removeSource(config: { host: string; repoURL: string }) {
320
+ let { host, repoURL } = config;
321
+ let remoteConfig = await readRemoteConfig(host);
322
+ if (!remoteConfig.repoSources.includes(repoURL)) {
323
+ throw new Error(
324
+ `Expected ${repoURL} to be a source on ${host}, it is not.\n`
325
+ + `Configured:\n ${remoteConfig.repoSources.join("\n ") || "(none)"}`
326
+ );
327
+ }
328
+ let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
329
+
330
+ if (repoSources.length) {
331
+ // The keys left over are what root gets, so our own key has to be among them.
332
+ console.log(`Checking our access to ${host} survives without ${repoURL}`);
333
+ let ourFingerprint = await findAuthenticatingFingerprint(host);
334
+ let remainingKeys: string[] = [];
335
+ for (let source of repoSources) {
336
+ remainingKeys.push(...await readRemoteSourceKeys({ host, repoURL: source }));
337
+ }
338
+ let remainingFingerprints = await fingerprintKeys(remainingKeys);
339
+ if (!remainingFingerprints.includes(ourFingerprint)) {
340
+ throw new Error(
341
+ `Expected the key we use for ${host} to still be in the remaining sources, it is not.\n`
342
+ + `Ours: ${ourFingerprint}\n`
343
+ + `Remaining: ${remainingFingerprints.join("\n ") || "(none)"}\n`
344
+ + `Removing ${repoURL} would lock you out of ${host}.`
345
+ );
346
+ }
347
+ } else {
348
+ // Nothing left to merge, so the daemon leaves root's authorized_keys exactly as it is.
349
+ console.log(`${repoURL} is the last source, so root's authorized_keys stays as it is now.`);
350
+ }
351
+
352
+ await requireRemoteWebhook(host);
353
+ await runOverSSH({
354
+ host,
355
+ script: `${SUDO_PREAMBLE}
356
+ $SUDO rm -f "${sourceKeyPath(repoURL)}"
357
+ $SUDO rm -rf "${sourceRepoPath(repoURL)}"`,
358
+ });
359
+ await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
360
+ console.log(`${repoURL} removed from ${host}. ${repoSources.length} source(s) left.`);
361
+ }
362
+
363
+ /** Answers "who can log into this box, and which repo says so". The paths the daemon uses are
364
+ left out on purpose, they are plumbing rather than something to act on. */
365
+ async function listSources(host: string) {
366
+ let remoteConfig = await readRemoteConfig(host);
367
+ if (!remoteConfig.repoSources.length) {
368
+ console.log(`${host} has no key sources. root's authorized_keys is left exactly as it is.`);
369
+ return;
370
+ }
371
+ console.log(`${host} lets root log in with the keys from ${remoteConfig.repoSources.length} repo(s):`);
372
+ let merged = new Set<string>();
373
+ for (let repoURL of remoteConfig.repoSources) {
374
+ let keys = await readRemoteSourceKeys({ host, repoURL });
375
+ console.log(`\n ${repoURL}`);
376
+ if (!keys.length) {
377
+ console.log(` grants no keys - the checkout is missing or empty`);
378
+ continue;
379
+ }
380
+ console.log(` grants ${keys.length} key(s):`);
381
+ for (let key of keys) {
382
+ console.log(` ${summarizeKey(key)}`);
383
+ merged.add(key);
384
+ }
385
+ }
386
+ if (remoteConfig.repoSources.length > 1) {
387
+ console.log(`\n${merged.size} key(s) in total once duplicates are merged.`);
388
+ }
389
+ }
390
+
391
+ /** The verb is a fixed word rather than a position, so it is pulled out of the arguments wherever
392
+ it was typed and everything left over is positional. */
393
+ function parseArgs(argv: string[]) {
394
+ let verbs = argv.filter(arg => VERBS.includes(arg));
395
+ if (!verbs.length) {
396
+ throw new Error(`Expected one of ${VERBS.join(", ")} somewhere in the arguments, was ${argv.join(" ") || "(nothing)"}\n${USAGE}`);
397
+ }
398
+ if (verbs.length > 1) {
399
+ throw new Error(`Expected one of ${VERBS.join(", ")}, was ${verbs.join(" and ")}\n${USAGE}`);
400
+ }
401
+ let verb = verbs[0];
402
+ let [host, ...rest] = argv.filter(arg => arg !== verb);
403
+ if (!host) {
404
+ throw new Error(`Expected a host, was nothing\n${USAGE}`);
405
+ }
406
+ return { verb, host, rest };
407
+ }
408
+
409
+ export async function main() {
410
+ let { verb, host, rest } = parseArgs(process.argv.slice(2));
411
+
412
+ if (verb === "list") {
413
+ await listSources(host);
414
+ return;
415
+ }
416
+ if (verb === "add") {
417
+ if (!rest.length || rest.length > 2) {
418
+ throw new Error(`Expected a private key and optionally a repo url, was ${rest.length} argument(s)\n${USAGE}`);
419
+ }
420
+ await addSource({ host, keyPath: expandHome(rest[0]), repoURL: await resolveRepoURL(rest[1]) });
421
+ return;
422
+ }
423
+ if (rest.length > 1) {
424
+ throw new Error(`Expected at most a repo url to remove, was ${rest.length} argument(s)\n${USAGE}`);
425
+ }
426
+ await removeSource({ host, repoURL: await resolveRepoURL(rest[0]) });
427
+ }
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ import os from "os";
2
+ import path from "path";
3
+
4
+ /** Windows shells do not expand ~ themselves, and hunting down your home folder by hand is
5
+ annoying, so we expand it on every platform. Both separators are accepted, because a Windows
6
+ user may type either one. */
7
+ export function expandHome(filePath: string) {
8
+ if (filePath === "~") {
9
+ return os.homedir();
10
+ }
11
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
12
+ return path.join(os.homedir(), filePath.slice(2));
13
+ }
14
+ if (filePath.startsWith("~")) {
15
+ // ~otheruser needs an account database we cannot read portably, and quietly resolving it
16
+ // to the wrong home would be worse than refusing.
17
+ throw new Error(`Expected ~ or an ordinary path, was ${filePath}. Referring to another user's home with ~name is not supported.`);
18
+ }
19
+ return path.resolve(filePath);
20
+ }
@@ -0,0 +1,94 @@
1
+ import { spawnPromise } from "./spawn";
2
+
3
+ const SSH_CONNECT_TIMEOUT = 10;
4
+ const EXISTS_MARKER = "__PORTSECURE_EXISTS__";
5
+ const MISSING_MARKER = "__PORTSECURE_MISSING__";
6
+ const MAX_ERROR_BODY_LENGTH = 500;
7
+
8
+ // /etc is root owned, so fall back to sudo whenever the SSH user is not root.
9
+ export const SUDO_PREAMBLE = `SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo -n"; fi`;
10
+
11
+ /** The host string is handed to ssh untouched. Users, keys and ports belong in the caller's ssh
12
+ config, so BatchMode makes a missing setup fail immediately instead of prompting. */
13
+ export async function runOverSSH(config: { host: string; script: string; input?: string; allowFailure?: boolean }) {
14
+ let { host, script, input, allowFailure } = config;
15
+ let result = await spawnPromise({
16
+ command: "ssh",
17
+ args: [
18
+ "-o", "BatchMode=yes",
19
+ "-o", `ConnectTimeout=${SSH_CONNECT_TIMEOUT}`,
20
+ host,
21
+ script,
22
+ ],
23
+ input,
24
+ inheritStderr: !allowFailure,
25
+ });
26
+ if (result.error) {
27
+ throw new Error(`Expected ssh to run against ${host}, failed with ${result.error.message}`);
28
+ }
29
+ if (result.status !== 0 && !allowFailure) {
30
+ throw new Error(
31
+ `Expected ssh to ${host} to exit 0, was ${result.status} (see the error output above).`
32
+ + ` Non-interactive ssh access to ${host} has to work on its own - fix it in your ssh config.`
33
+ );
34
+ }
35
+ return { stdout: result.stdout || "", stderr: result.stderr || "", status: result.status };
36
+ }
37
+
38
+ /** Returns undefined when the file does not exist, so a missing file reads differently from an
39
+ empty one. */
40
+ export async function readRemoteFile(config: { host: string; filePath: string }) {
41
+ let { host, filePath } = config;
42
+ let output = (await runOverSSH({
43
+ host,
44
+ script: `${SUDO_PREAMBLE}
45
+ if $SUDO test -f "${filePath}"; then
46
+ echo "${EXISTS_MARKER}"
47
+ $SUDO cat "${filePath}"
48
+ else
49
+ echo "${MISSING_MARKER}"
50
+ fi`,
51
+ })).stdout;
52
+ let newlineIndex = output.indexOf("\n");
53
+ let marker = output.slice(0, newlineIndex).trim();
54
+ if (marker === MISSING_MARKER) {
55
+ return undefined;
56
+ }
57
+ if (marker !== EXISTS_MARKER) {
58
+ throw new Error(`Expected ${EXISTS_MARKER} or ${MISSING_MARKER} from ${host}, was ${output.slice(0, MAX_ERROR_BODY_LENGTH)}`);
59
+ }
60
+ return output.slice(newlineIndex + 1);
61
+ }
62
+
63
+ export async function writeRemoteFile(config: {
64
+ host: string;
65
+ filePath: string;
66
+ contents: string;
67
+ fileMode: string;
68
+ directoryMode: string;
69
+ }) {
70
+ let { host, filePath, contents, fileMode, directoryMode } = config;
71
+ let directory = filePath.slice(0, filePath.lastIndexOf("/"));
72
+ await runOverSSH({
73
+ host,
74
+ // Deploying from Windows must not carry CRLF onto the target, where it breaks systemd
75
+ // units and makes private keys unreadable to ssh.
76
+ input: contents.replace(/\r\n/g, "\n"),
77
+ script: `${SUDO_PREAMBLE}
78
+ set -e
79
+ $SUDO mkdir -p "${directory}"
80
+ $SUDO chmod ${directoryMode} "${directory}"
81
+ $SUDO tee "${filePath}" > /dev/null
82
+ $SUDO chmod ${fileMode} "${filePath}"`,
83
+ });
84
+ }
85
+
86
+ export async function remoteCommandExists(config: { host: string; command: string }) {
87
+ let { host, command } = config;
88
+ let result = await runOverSSH({
89
+ host,
90
+ script: `command -v "${command}" > /dev/null 2>&1 && echo yes || echo no`,
91
+ allowFailure: true,
92
+ });
93
+ return result.stdout.trim() === "yes";
94
+ }
@@ -0,0 +1,32 @@
1
+ import { spawn } from "child_process";
2
+
3
+ // PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js contains a plain JS port of spawnPromise, so it can run
4
+ // with no dependencies. If you change one, make the matching change in the other.
5
+
6
+ /** An argument list rather than a shell string, so hostnames and paths can never be parsed as
7
+ shell syntax. Resolves with the exit code instead of throwing, callers decide what a failure
8
+ means. `inheritStderr` lets a child's own errors reach the terminal as they happen. */
9
+ export function spawnPromise(config: {
10
+ command: string;
11
+ args: string[];
12
+ cwd?: string;
13
+ input?: string;
14
+ inheritStderr?: boolean;
15
+ }) {
16
+ let { command, args, cwd, input, inheritStderr } = config;
17
+ return new Promise<{ stdout: string; stderr: string; status: number | undefined; error: Error | undefined }>(resolve => {
18
+ let child = spawn(command, args, {
19
+ cwd,
20
+ stdio: ["pipe", "pipe", inheritStderr && "inherit" || "pipe"],
21
+ });
22
+ let stdout = "";
23
+ let stderr = "";
24
+ child.stdout?.on("data", chunk => stdout += chunk);
25
+ child.stderr?.on("data", chunk => stderr += chunk);
26
+ child.on("error", error => resolve({ stdout, stderr, status: undefined, error }));
27
+ child.on("close", status => resolve({ stdout, stderr, status: status ?? undefined, error: undefined }));
28
+ // A child that exits before reading everything would otherwise raise EPIPE.
29
+ child.stdin?.on("error", () => undefined);
30
+ child.stdin?.end(input || "");
31
+ });
32
+ }