sliftutils 1.7.125 → 1.7.127
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/bin/derivekey.js +10 -0
- package/bin/portsecuredaemon.js +13 -0
- package/bin/testnotify.js +9 -0
- package/bin/unrevoke.js +9 -0
- package/package.json +10 -3
- package/security/README.md +66 -2
- package/security/authorizedKeys/authorizedKeys.ts +68 -5
- package/security/authorizedKeys/daemon/authLog.ts +186 -0
- package/security/authorizedKeys/daemon/changes.ts +26 -0
- package/security/authorizedKeys/daemon/daemon.ts +335 -0
- package/security/authorizedKeys/daemon/git.ts +119 -0
- package/security/authorizedKeys/daemon/notify.ts +25 -0
- package/security/authorizedKeys/daemon/paths.ts +26 -0
- package/security/authorizedKeys/daemon/portsecure.service +3 -2
- package/security/authorizedKeys/daemon/revocation.ts +329 -0
- package/security/authorizedKeys/daemon/rootKeys.ts +198 -0
- package/security/authorizedKeys/daemon/sessions.ts +139 -0
- package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
- package/security/authorizedKeys/daemon/state.ts +122 -0
- package/security/authorizedKeys/daemon/trust.ts +291 -0
- package/security/authorizedKeys/daemon/userKeys.ts +76 -0
- package/security/authorizedKeys/dist/authorizedKeys.ts.cache +111 -0
- package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
- package/security/authorizedKeys/dist/secureSSH.ts.cache +539 -0
- package/security/authorizedKeys/dist/sources.ts.cache +24 -0
- package/security/authorizedKeys/dist/unrevoke.ts.cache +167 -0
- package/security/authorizedKeys/revokeSource.ts +40 -0
- package/security/authorizedKeys/secureSSH.ts +240 -38
- package/security/authorizedKeys/unrevoke.ts +175 -0
- package/security/helpers/dist/paths.ts.cache +28 -0
- package/security/helpers/dist/remoteSSH.ts.cache +90 -0
- package/security/helpers/dist/spawn.ts.cache +34 -0
- package/security/helpers/remoteSSH.ts +18 -6
- package/security/helpers/spawn.ts +5 -1
- package/security/keys/deriveKey.ts +72 -0
- package/security/keys/dist/deriveKey.ts.cache +72 -0
- package/security/keys/dist/sshKeyFile.ts.cache +153 -0
- package/security/keys/sshKeyFile.ts +156 -0
- package/security/notifications/discord.ts +18 -1
- package/security/notifications/dist/discord.ts.cache +197 -0
- package/security/notifications/setupNotify.ts +17 -8
- package/security/notifications/testNotify.ts +48 -0
- package/security/signedFiles/dist/manifest.ts.cache +85 -0
- package/security/signedFiles/dist/signFiles.ts.cache +181 -0
- package/security/signedFiles/manifest.ts +38 -8
- package/security/signedFiles/signFiles.ts +123 -49
- package/security/authorizedKeys/daemon/portsecureDaemon.js +0 -1032
|
@@ -4,24 +4,34 @@ import path from "path";
|
|
|
4
4
|
import { DEFAULT_WEBHOOK_FILE_PATH, parseWebhookFile } from "../notifications/discord";
|
|
5
5
|
import { normalizeKeys, readRepoKeys, summarizeKey } from "./authorizedKeys";
|
|
6
6
|
import { sourceKeyPath, sourceRepoPath } from "./sources";
|
|
7
|
+
import { deriveRevokeKey, REVOKE_KEY_LABEL, revokeRepoURL } from "./revokeSource";
|
|
8
|
+
import { revokedKeysInRepo } from "./unrevoke";
|
|
7
9
|
import { expandHome } from "../helpers/paths";
|
|
8
10
|
import { spawnPromise } from "../helpers/spawn";
|
|
9
|
-
import { readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, writeRemoteFile } from "../helpers/remoteSSH";
|
|
11
|
+
import { describeHost, readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, THIS_MACHINE, writeRemoteFile } from "../helpers/remoteSSH";
|
|
10
12
|
|
|
11
|
-
const DAEMON_SOURCE = path.join(__dirname, "daemon", "portsecureDaemon.js");
|
|
12
13
|
const SERVICE_SOURCE = path.join(__dirname, "daemon", "portsecure.service");
|
|
13
|
-
|
|
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";
|
|
14
19
|
const REMOTE_SERVICE_PATH = "/etc/systemd/system/portsecure.service";
|
|
15
20
|
const REMOTE_CONFIG_PATH = "/etc/portsecure/daemon.json";
|
|
16
21
|
const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
17
22
|
const SERVICE_NAME = "portsecure";
|
|
18
23
|
const MAX_ERROR_BODY_LENGTH = 500;
|
|
19
|
-
const VERBS = ["add", "remove", "list"];
|
|
20
|
-
// The
|
|
24
|
+
const VERBS = ["add", "remove", "list", "update"];
|
|
25
|
+
// The host is optional, and without one everything happens on this machine. The repo url is
|
|
26
|
+
// optional too, and defaults to the repo the command is run from.
|
|
21
27
|
const USAGE = `Usage:
|
|
22
|
-
yarn securessh
|
|
23
|
-
yarn securessh
|
|
24
|
-
yarn securessh
|
|
28
|
+
yarn securessh [host] add <repo-private-key> [repo-url]
|
|
29
|
+
yarn securessh [host] remove [repo-url]
|
|
30
|
+
yarn securessh [host] list
|
|
31
|
+
yarn securessh [host] update
|
|
32
|
+
|
|
33
|
+
With no host it acts on this machine, and still installs from github rather than from wherever
|
|
34
|
+
this was run.`;
|
|
25
35
|
|
|
26
36
|
async function pathExists(filePath: string) {
|
|
27
37
|
try {
|
|
@@ -41,7 +51,7 @@ async function runLocal(config: { command: string; args: string[]; cwd?: string;
|
|
|
41
51
|
if (result.status !== 0 && !allowFailure) {
|
|
42
52
|
throw new Error(
|
|
43
53
|
`Expected ${command} ${args.join(" ")} to exit 0, was ${result.status}. `
|
|
44
|
-
+ `${(result.
|
|
54
|
+
+ `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
45
55
|
);
|
|
46
56
|
}
|
|
47
57
|
return result;
|
|
@@ -69,7 +79,7 @@ async function gitWithKey(config: { keyPath: string; args: string[]; cwd?: strin
|
|
|
69
79
|
if (result.status !== 0 && !allowFailure) {
|
|
70
80
|
throw new Error(
|
|
71
81
|
`Expected git ${args.join(" ")} to exit 0, was ${result.status}. `
|
|
72
|
-
+ `${(result.
|
|
82
|
+
+ `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
73
83
|
);
|
|
74
84
|
}
|
|
75
85
|
return result;
|
|
@@ -158,6 +168,118 @@ async function resolveRepoURL(passedURL: string | undefined) {
|
|
|
158
168
|
return repoURL;
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
/** Every source's revoke repo has to exist and hold at least one commit, or the hosts using it
|
|
172
|
+
cannot record a revocation. Checked with this machine's own git credentials, since update is
|
|
173
|
+
not given any deploy key, and an empty repo is initialised rather than merely complained about.
|
|
174
|
+
|
|
175
|
+
A host that cannot write a revocation silently keeps accepting a key it just saw being misused,
|
|
176
|
+
which is the one failure this whole thing exists to prevent, so it is checked on every deploy
|
|
177
|
+
and not only when a source is first added. */
|
|
178
|
+
async function ensureRevokeReposExist(repoSources: string[]) {
|
|
179
|
+
for (let repoURL of repoSources) {
|
|
180
|
+
let revokeURL = revokeRepoURL(repoURL);
|
|
181
|
+
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-revoke-"));
|
|
182
|
+
let checkoutPath = path.join(temporaryDirectory, "repo");
|
|
183
|
+
// On a machine that is already a portsecure host, the source's deploy key is right there,
|
|
184
|
+
// and the key derived from it is the one credential that repo is guaranteed to accept.
|
|
185
|
+
// Anywhere else, a person is running this and has their own access.
|
|
186
|
+
let ownKey = "";
|
|
187
|
+
if (await pathExists(sourceKeyPath(repoURL))) {
|
|
188
|
+
let derived = deriveRevokeKey(await fs.readFile(sourceKeyPath(repoURL), "utf8"));
|
|
189
|
+
ownKey = path.join(temporaryDirectory, "key");
|
|
190
|
+
await fs.writeFile(ownKey, derived.privateKeyFile, { mode: 0o600 });
|
|
191
|
+
}
|
|
192
|
+
let cloneRevoke = async (allowFailure: boolean) => ownKey
|
|
193
|
+
&& await gitWithKey({ keyPath: ownKey, args: ["clone", revokeURL, checkoutPath], allowFailure })
|
|
194
|
+
|| await runLocal({ command: "git", args: ["clone", revokeURL, checkoutPath], allowFailure });
|
|
195
|
+
let clone = await cloneRevoke(true);
|
|
196
|
+
if (clone.status !== 0) {
|
|
197
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Expected ${revokeURL} to exist, it does not, so ${repoURL} has nowhere to record a`
|
|
200
|
+
+ ` revocation.\nCreate it, then run "yarn securessh <host> add" for that source to`
|
|
201
|
+
+ ` register its deploy key.\n${(clone.stdout + clone.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
let head = await runLocal({ command: "git", args: ["-C", checkoutPath, "rev-parse", "HEAD"], allowFailure: true });
|
|
205
|
+
if (head.status !== 0) {
|
|
206
|
+
console.log(`${revokeURL} is empty, giving it a first commit`);
|
|
207
|
+
await fs.writeFile(path.join(checkoutPath, "README.md"),
|
|
208
|
+
`# revoked keys\n\nWritten by portsecure. Each file under revocations/ is one key that was used from an\n`
|
|
209
|
+
+ `address it is not allowed from, and is no longer accepted anywhere.\n`);
|
|
210
|
+
for (let args of [
|
|
211
|
+
["-C", checkoutPath, "add", "-A"],
|
|
212
|
+
["-C", checkoutPath, "-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", "initialise revoke repo"],
|
|
213
|
+
]) {
|
|
214
|
+
await runLocal({ command: "git", args });
|
|
215
|
+
}
|
|
216
|
+
let push = ["-C", checkoutPath, "push", "origin", "HEAD"];
|
|
217
|
+
if (ownKey) {
|
|
218
|
+
await gitWithKey({ keyPath: ownKey, args: push });
|
|
219
|
+
} else {
|
|
220
|
+
await runLocal({ command: "git", args: push });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
224
|
+
console.log(`${revokeURL} is ready`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The revoke repo has to exist and be writable before a host is set up, because a host that
|
|
229
|
+
cannot write a revocation cannot revoke a key that is being misused. The key for it is derived
|
|
230
|
+
from the source's, since github will not take one public key on two repos. */
|
|
231
|
+
async function ensureRevokeRepo(config: { keyPath: string; repoURL: string }) {
|
|
232
|
+
let { keyPath, repoURL } = config;
|
|
233
|
+
let revokeURL = revokeRepoURL(repoURL);
|
|
234
|
+
let derived = deriveRevokeKey(await fs.readFile(keyPath, "utf8"));
|
|
235
|
+
|
|
236
|
+
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-revoke-"));
|
|
237
|
+
let derivedKeyPath = path.join(temporaryDirectory, "key");
|
|
238
|
+
await fs.writeFile(derivedKeyPath, derived.privateKeyFile, { mode: 0o600 });
|
|
239
|
+
let checkoutPath = path.join(temporaryDirectory, "repo");
|
|
240
|
+
|
|
241
|
+
let explain = (problem: string) => new Error(
|
|
242
|
+
`${problem}\n`
|
|
243
|
+
+ `Create ${revokeURL} and add this as a deploy key WITH WRITE ACCESS:\n`
|
|
244
|
+
+ ` ${derived.publicKey} ${REVOKE_KEY_LABEL}\n`
|
|
245
|
+
+ `It has to be this key: it is derived from ${keyPath}, and github will not accept the same`
|
|
246
|
+
+ ` public key on two repositories.`
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
console.log(`Checking ${revokeURL}`);
|
|
250
|
+
let clone = await gitWithKey({ keyPath: derivedKeyPath, args: ["clone", revokeURL, checkoutPath], allowFailure: true });
|
|
251
|
+
if (clone.status !== 0) {
|
|
252
|
+
throw explain(`Expected ${revokeURL} to be readable with the derived key, it is not.`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// An empty repo has no branch for the daemon to clone, so it gets its first commit here. That
|
|
256
|
+
// doubles as the proof that we can write to it.
|
|
257
|
+
let head = await gitWithKey({ keyPath: derivedKeyPath, args: ["rev-parse", "HEAD"], cwd: checkoutPath, allowFailure: true });
|
|
258
|
+
if (head.status !== 0) {
|
|
259
|
+
await fs.writeFile(path.join(checkoutPath, "README.md"),
|
|
260
|
+
`# revoked keys\n\nWritten by portsecure. Each file under revocations/ is one key that was used from an\n`
|
|
261
|
+
+ `address it is not allowed from, and is no longer accepted anywhere.\n`);
|
|
262
|
+
for (let args of [
|
|
263
|
+
["add", "-A"],
|
|
264
|
+
["-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", "initialise revoke repo"],
|
|
265
|
+
]) {
|
|
266
|
+
await gitWithKey({ keyPath: derivedKeyPath, args, cwd: checkoutPath });
|
|
267
|
+
}
|
|
268
|
+
let push = await gitWithKey({ keyPath: derivedKeyPath, args: ["push", "origin", "HEAD"], cwd: checkoutPath, allowFailure: true });
|
|
269
|
+
if (push.status !== 0) {
|
|
270
|
+
throw explain(`Expected write access to ${revokeURL}, the first push was refused.\n${(push.stdout + push.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
let dryRun = await gitWithKey({ keyPath: derivedKeyPath, args: ["push", "--dry-run", "origin", "HEAD"], cwd: checkoutPath, allowFailure: true });
|
|
274
|
+
if (dryRun.status !== 0) {
|
|
275
|
+
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)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
280
|
+
console.log(`${revokeURL} is writable`);
|
|
281
|
+
}
|
|
282
|
+
|
|
161
283
|
async function readRemoteConfig(host: string) {
|
|
162
284
|
let contents = await readRemoteFile({ host, filePath: REMOTE_CONFIG_PATH });
|
|
163
285
|
if (!contents) {
|
|
@@ -187,11 +309,41 @@ fi`,
|
|
|
187
309
|
|
|
188
310
|
async function installDaemon(config: { host: string; hostLabel: string; repoSources: string[] }) {
|
|
189
311
|
let { host, hostLabel, repoSources } = config;
|
|
190
|
-
for (let command of ["node", "git"]) {
|
|
312
|
+
for (let command of ["node", "git", "yarn"]) {
|
|
191
313
|
if (!await remoteCommandExists({ host, command })) {
|
|
192
314
|
throw new Error(`Expected ${command} to be installed on ${host}, it is not. Install it and rerun.`);
|
|
193
315
|
}
|
|
194
316
|
}
|
|
317
|
+
|
|
318
|
+
// The checkout is brought to the latest commit rather than a copy being pushed, so what runs on
|
|
319
|
+
// the host is exactly what is on github.
|
|
320
|
+
console.log(`Updating ${REMOTE_CHECKOUT_PATH} on ${describeHost(host)}`);
|
|
321
|
+
await runOverSSH({
|
|
322
|
+
host,
|
|
323
|
+
script: `${SUDO_PREAMBLE}
|
|
324
|
+
set -e
|
|
325
|
+
$SUDO mkdir -p "${path.posix.dirname(REMOTE_CHECKOUT_PATH)}"
|
|
326
|
+
if $SUDO test -d "${REMOTE_CHECKOUT_PATH}/.git"; then
|
|
327
|
+
# Stashed first, the way machine-alwaysup does it. Anything sitting modified in a host's
|
|
328
|
+
# checkout is either an accident or somebody editing the daemon in place, and either way it is
|
|
329
|
+
# worth keeping rather than quietly erasing. The identity is passed inline because a host has
|
|
330
|
+
# no git config of its own and stash writes a commit.
|
|
331
|
+
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" add --all
|
|
332
|
+
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" -c user.email=portsecure@localhost -c user.name=portsecure stash
|
|
333
|
+
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" fetch --prune origin
|
|
334
|
+
# A stash and a pull cannot cross a branch that has diverged, and a drifted checkout must not be
|
|
335
|
+
# able to leave a host running old code, so it is put on the remote's state rather than merged
|
|
336
|
+
# with it. set-head so that is the remote's default branch now, not the one it had at clone.
|
|
337
|
+
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" remote set-head origin --auto
|
|
338
|
+
$SUDO git -C "${REMOTE_CHECKOUT_PATH}" reset --hard origin/HEAD
|
|
339
|
+
else
|
|
340
|
+
$SUDO rm -rf "${REMOTE_CHECKOUT_PATH}"
|
|
341
|
+
$SUDO git clone "${SLIFTUTILS_URL}" "${REMOTE_CHECKOUT_PATH}"
|
|
342
|
+
fi
|
|
343
|
+
$SUDO yarn --cwd "${REMOTE_CHECKOUT_PATH}" install --production --non-interactive
|
|
344
|
+
# The single file daemon this replaced, left over on a host set up by an older version.
|
|
345
|
+
$SUDO rm -f /opt/portsecure/portsecure-daemon.js`,
|
|
346
|
+
});
|
|
195
347
|
await writeRemoteFile({
|
|
196
348
|
host,
|
|
197
349
|
filePath: REMOTE_CONFIG_PATH,
|
|
@@ -201,13 +353,6 @@ async function installDaemon(config: { host: string; hostLabel: string; repoSour
|
|
|
201
353
|
fileMode: "600",
|
|
202
354
|
directoryMode: "700",
|
|
203
355
|
});
|
|
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
356
|
await writeRemoteFile({
|
|
212
357
|
host,
|
|
213
358
|
filePath: REMOTE_SERVICE_PATH,
|
|
@@ -254,7 +399,7 @@ async function requireRemoteWebhook(host: string) {
|
|
|
254
399
|
throw new Error(
|
|
255
400
|
`Expected a Discord webhook at ${DEFAULT_WEBHOOK_FILE_PATH} on ${host}, no such file exists.`
|
|
256
401
|
+ ` The daemon will not start without one.\n`
|
|
257
|
-
+ `Set it up first:\n yarn setupnotify ${host} <discord-webhook-url
|
|
402
|
+
+ `Set it up first:\n yarn setupnotify ${host} <discord-webhook-url>`.replace(" <discord", host && " <discord" || "<discord")
|
|
258
403
|
);
|
|
259
404
|
}
|
|
260
405
|
return parseWebhookFile({ contents, sourceName: `${host}:${DEFAULT_WEBHOOK_FILE_PATH}` });
|
|
@@ -271,24 +416,29 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
|
|
|
271
416
|
if (reachable.status !== 0) {
|
|
272
417
|
throw new Error(
|
|
273
418
|
`Expected ${repoURL} to be reachable with ${keyPath}, git ls-remote failed.`
|
|
274
|
-
+ ` The daemon would have no way to fetch keys.\n
|
|
419
|
+
+ ` The daemon would have no way to fetch keys.\n`
|
|
420
|
+
+ `${(reachable.stdout + reachable.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
275
421
|
);
|
|
276
422
|
}
|
|
277
423
|
|
|
278
424
|
let remoteConfig = await readRemoteConfig(host);
|
|
279
425
|
if (remoteConfig.repoSources.includes(repoURL)) {
|
|
280
|
-
console.log(`${host} already has ${repoURL}, refreshing its key and the daemon.`);
|
|
426
|
+
console.log(`${describeHost(host)} already has ${repoURL}, refreshing its key and the daemon.`);
|
|
281
427
|
}
|
|
282
428
|
|
|
283
|
-
// The merged result is what root ends up with, so our own key has to be somewhere in it.
|
|
284
|
-
|
|
285
|
-
let ourFingerprint =
|
|
429
|
+
// The merged result is what root ends up with, so our own key has to be somewhere in it. On
|
|
430
|
+
// this machine there is no ssh session to preserve, so there is nothing to check.
|
|
431
|
+
let ourFingerprint = "";
|
|
432
|
+
if (host) {
|
|
433
|
+
console.log(`Checking our access to ${describeHost(host)} survives the merged keys`);
|
|
434
|
+
ourFingerprint = await findAuthenticatingFingerprint(host);
|
|
435
|
+
}
|
|
286
436
|
let inspectionPath = await cloneRepoForInspection({ repoURL, keyPath });
|
|
287
437
|
let newKeys = await readRepoKeys(inspectionPath);
|
|
288
438
|
// Whatever is applied on the host came from the existing sources, so it stays in the merge.
|
|
289
439
|
let existingKeys = normalizeKeys(await readRemoteFile({ host, filePath: ROOT_AUTHORIZED_KEYS }) || "");
|
|
290
440
|
let mergedFingerprints = await fingerprintKeys([...existingKeys, ...newKeys]);
|
|
291
|
-
if (!mergedFingerprints.includes(ourFingerprint)) {
|
|
441
|
+
if (ourFingerprint && !mergedFingerprints.includes(ourFingerprint)) {
|
|
292
442
|
throw new Error(
|
|
293
443
|
`Expected the key we use for ${host} to be in the merged keys, it is not.\n`
|
|
294
444
|
+ `Ours: ${ourFingerprint}\n`
|
|
@@ -297,10 +447,25 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
|
|
|
297
447
|
+ ` lock you out of ${host}. Add your public key to ${repoURL} first.`
|
|
298
448
|
);
|
|
299
449
|
}
|
|
300
|
-
|
|
450
|
+
if (ourFingerprint) {
|
|
451
|
+
console.log(`Our key ${ourFingerprint} is in the merged keys, access will survive.`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
await ensureRevokeRepo({ keyPath, repoURL });
|
|
455
|
+
|
|
456
|
+
// Deploying a repo that still holds a revoked key would hand it back to every machine.
|
|
457
|
+
let revoked = await revokedKeysInRepo({ repoPath: inspectionPath, sourceURL: repoURL });
|
|
458
|
+
if (revoked.length) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`Expected ${repoURL} to hold no revoked keys, it holds ${revoked.length}:\n`
|
|
461
|
+
+ revoked.map(entry => ` ${entry.revocation.fingerprint} revoked by`
|
|
462
|
+
+ ` ${entry.revocation.revokedBy || "?"} after use from ${entry.revocation.attempt?.ip || "?"}`).join("\n")
|
|
463
|
+
+ `\nDelete them from the repo, or run "yarn unrevoke" there to allow them again.`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
301
466
|
|
|
302
467
|
let webhookURL = await requireRemoteWebhook(host);
|
|
303
|
-
console.log(`${host} notifies ${webhookURL}`);
|
|
468
|
+
console.log(`${describeHost(host)} notifies ${webhookURL}`);
|
|
304
469
|
|
|
305
470
|
await writeRemoteFile({
|
|
306
471
|
host,
|
|
@@ -313,7 +478,7 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
|
|
|
313
478
|
let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
|
|
314
479
|
repoSources.push(repoURL);
|
|
315
480
|
await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
|
|
316
|
-
console.log(`${repoURL} added to ${host}. ${repoSources.length} source(s) now merged.`);
|
|
481
|
+
console.log(`${repoURL} added to ${describeHost(host)}. ${repoSources.length} source(s) now merged.`);
|
|
317
482
|
}
|
|
318
483
|
|
|
319
484
|
async function removeSource(config: { host: string; repoURL: string }) {
|
|
@@ -327,9 +492,10 @@ async function removeSource(config: { host: string; repoURL: string }) {
|
|
|
327
492
|
}
|
|
328
493
|
let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
|
|
329
494
|
|
|
330
|
-
|
|
495
|
+
// On this machine there is no ssh session to preserve, so there is nothing to check.
|
|
496
|
+
if (repoSources.length && host) {
|
|
331
497
|
// 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}`);
|
|
498
|
+
console.log(`Checking our access to ${describeHost(host)} survives without ${repoURL}`);
|
|
333
499
|
let ourFingerprint = await findAuthenticatingFingerprint(host);
|
|
334
500
|
let remainingKeys: string[] = [];
|
|
335
501
|
for (let source of repoSources) {
|
|
@@ -357,18 +523,42 @@ $SUDO rm -f "${sourceKeyPath(repoURL)}"
|
|
|
357
523
|
$SUDO rm -rf "${sourceRepoPath(repoURL)}"`,
|
|
358
524
|
});
|
|
359
525
|
await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
|
|
360
|
-
console.log(`${repoURL} removed from ${host}. ${repoSources.length} source(s) left.`);
|
|
526
|
+
console.log(`${repoURL} removed from ${describeHost(host)}. ${repoSources.length} source(s) left.`);
|
|
361
527
|
}
|
|
362
528
|
|
|
363
529
|
/** Answers "who can log into this box, and which repo says so". The paths the daemon uses are
|
|
364
530
|
left out on purpose, they are plumbing rather than something to act on. */
|
|
531
|
+
|
|
532
|
+
/** Pushes the current daemon onto a host that already has one, for when this code has moved on.
|
|
533
|
+
Nothing about which keys the host trusts is touched. */
|
|
534
|
+
async function updateDaemon(host: string) {
|
|
535
|
+
let contents = await readRemoteFile({ host, filePath: REMOTE_CONFIG_PATH });
|
|
536
|
+
if (!contents) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`Expected ${host} to already have portsecure, ${REMOTE_CONFIG_PATH} does not exist.\n`
|
|
539
|
+
+ `Set it up with:\n yarn securessh ${host} add <repo-private-key> [repo-url]`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
let parsed = JSON.parse(contents) as { hostLabel?: string; repoSources?: string[] };
|
|
543
|
+
let repoSources = parsed.repoSources || [];
|
|
544
|
+
await requireRemoteWebhook(host);
|
|
545
|
+
await ensureRevokeReposExist(repoSources);
|
|
546
|
+
await installDaemon({ host, hostLabel: parsed.hostLabel || host, repoSources });
|
|
547
|
+
console.log(`Updated the daemon on ${describeHost(host)}, and restarted it.`);
|
|
548
|
+
console.log(`Its ${repoSources.length} key source(s) were left as they are, along with the keys and`);
|
|
549
|
+
console.log(`signers it has already accepted. Only the daemon itself changed:`);
|
|
550
|
+
for (let repoURL of repoSources) {
|
|
551
|
+
console.log(` ${repoURL}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
365
555
|
async function listSources(host: string) {
|
|
366
556
|
let remoteConfig = await readRemoteConfig(host);
|
|
367
557
|
if (!remoteConfig.repoSources.length) {
|
|
368
|
-
console.log(`${host} has no key sources. root's authorized_keys is left exactly as it is.`);
|
|
558
|
+
console.log(`${describeHost(host)} has no key sources. root's authorized_keys is left exactly as it is.`);
|
|
369
559
|
return;
|
|
370
560
|
}
|
|
371
|
-
console.log(`${host} lets root log in with the keys from ${remoteConfig.repoSources.length} repo(s):`);
|
|
561
|
+
console.log(`${describeHost(host)} lets root log in with the keys from ${remoteConfig.repoSources.length} repo(s):`);
|
|
372
562
|
let merged = new Set<string>();
|
|
373
563
|
for (let repoURL of remoteConfig.repoSources) {
|
|
374
564
|
let keys = await readRemoteSourceKeys({ host, repoURL });
|
|
@@ -399,11 +589,16 @@ function parseArgs(argv: string[]) {
|
|
|
399
589
|
throw new Error(`Expected one of ${VERBS.join(", ")}, was ${verbs.join(" and ")}\n${USAGE}`);
|
|
400
590
|
}
|
|
401
591
|
let verb = verbs[0];
|
|
402
|
-
let
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
592
|
+
let positional = argv.filter(arg => arg !== verb);
|
|
593
|
+
// A host, when there is one, comes first and is a bare name or address. Everything else that
|
|
594
|
+
// can appear here is a path or a repo url, and those all carry a slash, which is what tells
|
|
595
|
+
// them apart. So no host at all means this machine.
|
|
596
|
+
let host = THIS_MACHINE;
|
|
597
|
+
if (positional.length && !/[\/~\\]/.test(positional[0])) {
|
|
598
|
+
host = positional[0];
|
|
599
|
+
positional = positional.slice(1);
|
|
600
|
+
}
|
|
601
|
+
return { verb, host, rest: positional };
|
|
407
602
|
}
|
|
408
603
|
|
|
409
604
|
export async function main() {
|
|
@@ -413,6 +608,13 @@ export async function main() {
|
|
|
413
608
|
await listSources(host);
|
|
414
609
|
return;
|
|
415
610
|
}
|
|
611
|
+
if (verb === "update") {
|
|
612
|
+
if (rest.length) {
|
|
613
|
+
throw new Error(`Expected nothing after update, was ${rest.length} argument(s)\n${USAGE}`);
|
|
614
|
+
}
|
|
615
|
+
await updateDaemon(host);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
416
618
|
if (verb === "add") {
|
|
417
619
|
if (!rest.length || rest.length > 2) {
|
|
418
620
|
throw new Error(`Expected a private key and optionally a repo url, was ${rest.length} argument(s)\n${USAGE}`);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { runPromise } from "socket-function/src/runPromise";
|
|
5
|
+
import { keyFingerprint, normalizeKeys } from "./authorizedKeys";
|
|
6
|
+
import { revokeRepoURL } from "./revokeSource";
|
|
7
|
+
import { signRepo } from "../signedFiles/signFiles";
|
|
8
|
+
import { readRepoKeys } from "./authorizedKeys";
|
|
9
|
+
import { expandHome } from "../helpers/paths";
|
|
10
|
+
import { spawnPromise } from "../helpers/spawn";
|
|
11
|
+
|
|
12
|
+
const UNREVOKES_DIR = "unrevoked";
|
|
13
|
+
const REVOCATIONS_DIR = "revocations";
|
|
14
|
+
const GIT_KEYWORD = "git";
|
|
15
|
+
const COMMIT_MESSAGE = "unrevoke keys";
|
|
16
|
+
const USAGE = `Usage: yarn unrevoke [keys-repo] [${GIT_KEYWORD}]
|
|
17
|
+
|
|
18
|
+
Run this in a keys repo, or name one. It reads that repo's revoke repo and writes one unrevoke file
|
|
19
|
+
naming every revocation in it, so the keys are accepted again once each machine's hour long wait
|
|
20
|
+
passes. It signs the result, and with ${GIT_KEYWORD} it commits and pushes it too.
|
|
21
|
+
|
|
22
|
+
Keys that were revoked should normally be deleted from the repo instead. Unrevoking only matters
|
|
23
|
+
for a key you still want.`;
|
|
24
|
+
|
|
25
|
+
export type Revocation = {
|
|
26
|
+
revocationId: string;
|
|
27
|
+
fingerprint: string;
|
|
28
|
+
key?: string;
|
|
29
|
+
revokedAt?: string;
|
|
30
|
+
revokedBy?: string;
|
|
31
|
+
attempt?: { ip?: string; user?: string; required?: string };
|
|
32
|
+
};
|
|
33
|
+
|
|
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. */
|
|
37
|
+
export async function readRemoteRevocations(config: { sourceURL: string }) {
|
|
38
|
+
let { sourceURL } = config;
|
|
39
|
+
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "unrevoke-"));
|
|
40
|
+
let repoPath = path.join(temporaryDirectory, "repo");
|
|
41
|
+
let clone = await spawnPromise({
|
|
42
|
+
command: "git",
|
|
43
|
+
args: ["clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath],
|
|
44
|
+
});
|
|
45
|
+
if (clone.status !== 0) {
|
|
46
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Expected to be able to read ${revokeRepoURL(sourceURL)}.\n`
|
|
49
|
+
+ `${(clone.stdout + clone.stderr).trim()}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
let revocations: Revocation[] = [];
|
|
53
|
+
let directory = path.join(repoPath, REVOCATIONS_DIR);
|
|
54
|
+
try {
|
|
55
|
+
for (let name of (await fs.readdir(directory)).sort()) {
|
|
56
|
+
if (name.endsWith(".json")) {
|
|
57
|
+
revocations.push(JSON.parse(await fs.readFile(path.join(directory, name), "utf8")));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// No revocations directory at all just means nothing has ever been revoked.
|
|
62
|
+
}
|
|
63
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
64
|
+
return revocations;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Which keys in a repo are revoked. What signfiles and securessh refuse over. */
|
|
68
|
+
export async function revokedKeysInRepo(config: { repoPath: string; sourceURL: string }) {
|
|
69
|
+
let { repoPath, sourceURL } = config;
|
|
70
|
+
let revocations = await readRemoteRevocations({ sourceURL });
|
|
71
|
+
let revokedFingerprints = new Set(revocations.map(revocation => revocation.fingerprint));
|
|
72
|
+
let unrevoked = new Set<string>();
|
|
73
|
+
try {
|
|
74
|
+
let directory = path.join(repoPath, UNREVOKES_DIR);
|
|
75
|
+
for (let name of await fs.readdir(directory)) {
|
|
76
|
+
if (!name.endsWith(".json")) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let parsed = JSON.parse(await fs.readFile(path.join(directory, name), "utf8"));
|
|
80
|
+
for (let revocationId of parsed.revocationIds || []) {
|
|
81
|
+
unrevoked.add(revocationId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// Nothing has been unrevoked.
|
|
86
|
+
}
|
|
87
|
+
let stillRevoked = revocations.filter(revocation => !unrevoked.has(revocation.revocationId));
|
|
88
|
+
let keys = normalizeKeys(await fs.readFile(path.join(repoPath, "authorized_keys"), "utf8").catch(() => ""));
|
|
89
|
+
return stillRevoked
|
|
90
|
+
.filter(revocation => keys.some(key => keyFingerprint(key) === revocation.fingerprint))
|
|
91
|
+
.map(revocation => ({ revocation, key: keys.find(key => keyFingerprint(key) === revocation.fingerprint) || "" }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The repo named, or the one we are standing in. Checked the same way securessh checks it: a git
|
|
95
|
+
repo that actually holds keys and has an origin to clone from. */
|
|
96
|
+
async function resolveKeysRepo(named: string | undefined) {
|
|
97
|
+
let cwd = named && expandHome(named) || undefined;
|
|
98
|
+
// spawnPromise rather than runPromise, because these two are read for their value. runPromise
|
|
99
|
+
// returns stdout and stderr joined, so one git warning ends up glued to the front of the path.
|
|
100
|
+
let topLevel = await spawnPromise({ command: "git", args: ["rev-parse", "--show-toplevel"], cwd });
|
|
101
|
+
let repoPath = topLevel.stdout.trim();
|
|
102
|
+
if (topLevel.status !== 0 || !repoPath) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Expected ${named || "the current directory"} to be inside a git repo, it is not.\n`
|
|
105
|
+
+ `${(topLevel.stdout + topLevel.stderr).trim()}\n${USAGE}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await readRepoKeys(repoPath);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
throw new Error(`Expected ${repoPath} to be a keys repo, it holds no keys.\n${e}\n${USAGE}`);
|
|
112
|
+
}
|
|
113
|
+
let origin = await spawnPromise({ command: "git", args: ["remote", "get-url", "origin"], cwd: repoPath });
|
|
114
|
+
let originURL = origin.stdout.trim();
|
|
115
|
+
if (origin.status !== 0 || !originURL) {
|
|
116
|
+
throw new Error(`Expected ${repoPath} to have an origin remote, it has none.\n${USAGE}`);
|
|
117
|
+
}
|
|
118
|
+
return { repoPath, originURL };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function main() {
|
|
122
|
+
let argv = process.argv.slice(2);
|
|
123
|
+
let pushToGit = argv.includes(GIT_KEYWORD);
|
|
124
|
+
let positional = argv.filter(arg => arg !== GIT_KEYWORD);
|
|
125
|
+
if (positional.length > 1) {
|
|
126
|
+
throw new Error(`Expected at most a keys repo, was ${positional.length} argument(s)\n${USAGE}`);
|
|
127
|
+
}
|
|
128
|
+
let { repoPath, originURL } = await resolveKeysRepo(positional[0]);
|
|
129
|
+
|
|
130
|
+
let revocations = await readRemoteRevocations({ sourceURL: originURL });
|
|
131
|
+
if (!revocations.length) {
|
|
132
|
+
console.log(`${revokeRepoURL(originURL)} lists no revocations, so there is nothing to undo.`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let directory = path.join(repoPath, UNREVOKES_DIR);
|
|
137
|
+
await fs.mkdir(directory, { recursive: true });
|
|
138
|
+
let stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
139
|
+
let unrevokeId = `${stamp}-unrevoke`;
|
|
140
|
+
await fs.writeFile(path.join(directory, `${unrevokeId}.json`), JSON.stringify({
|
|
141
|
+
unrevokeId,
|
|
142
|
+
createdAt: new Date().toISOString(),
|
|
143
|
+
// Named one by one rather than as a blanket "allow everything again", so this file only
|
|
144
|
+
// ever undoes the revocations that existed when it was written.
|
|
145
|
+
revocationIds: revocations.map(revocation => revocation.revocationId),
|
|
146
|
+
revocations: revocations.map(revocation => ({
|
|
147
|
+
revocationId: revocation.revocationId,
|
|
148
|
+
fingerprint: revocation.fingerprint,
|
|
149
|
+
revokedAt: revocation.revokedAt,
|
|
150
|
+
revokedBy: revocation.revokedBy,
|
|
151
|
+
attempt: revocation.attempt,
|
|
152
|
+
})),
|
|
153
|
+
}, undefined, 4) + "\n");
|
|
154
|
+
|
|
155
|
+
console.log(`Wrote ${UNREVOKES_DIR}/${unrevokeId}.json covering ${revocations.length} revocation(s):`);
|
|
156
|
+
for (let revocation of revocations) {
|
|
157
|
+
console.log(` ${revocation.fingerprint} revoked by ${revocation.revokedBy || "?"} from ${revocation.attempt?.ip || "?"}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Signed here rather than left as a step to remember. An unrevoke nobody signed does nothing at
|
|
161
|
+
// all, and the repo would sit there looking done while every machine ignored it.
|
|
162
|
+
await signRepo({ repoPath });
|
|
163
|
+
|
|
164
|
+
if (!pushToGit) {
|
|
165
|
+
console.log(`\nCommit and push it, and each machine will wait an hour after seeing it before`);
|
|
166
|
+
console.log(`those keys work again:`);
|
|
167
|
+
console.log(`\`\`\`\ngit add -A\ngit commit -m "${COMMIT_MESSAGE}"\ngit push\n\`\`\``);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
await runPromise(`git add -A`, { cwd: repoPath });
|
|
171
|
+
await runPromise(`git commit -m "${COMMIT_MESSAGE}"`, { cwd: repoPath });
|
|
172
|
+
await runPromise(`git push`, { cwd: repoPath });
|
|
173
|
+
console.log(`\nCommitted and pushed. Each machine waits an hour after seeing it before those`);
|
|
174
|
+
console.log(`keys work again.`);
|
|
175
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule && mod.default) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true , configurable: true});
|
|
6
|
+
//exports.expandHome = void 0;
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
/** Windows shells do not expand ~ themselves, and hunting down your home folder by hand is
|
|
10
|
+
annoying, so we expand it on every platform. Both separators are accepted, because a Windows
|
|
11
|
+
user may type either one. */
|
|
12
|
+
function expandHome(filePath) {
|
|
13
|
+
if (filePath === "~") {
|
|
14
|
+
return os_1.default.homedir();
|
|
15
|
+
}
|
|
16
|
+
if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
|
|
17
|
+
return path_1.default.join(os_1.default.homedir(), filePath.slice(2));
|
|
18
|
+
}
|
|
19
|
+
if (filePath.startsWith("~")) {
|
|
20
|
+
// ~otheruser needs an account database we cannot read portably, and quietly resolving it
|
|
21
|
+
// to the wrong home would be worse than refusing.
|
|
22
|
+
throw new Error(`Expected ~ or an ordinary path, was ${filePath}. Referring to another user's home with ~name is not supported.`);
|
|
23
|
+
}
|
|
24
|
+
return path_1.default.resolve(filePath);
|
|
25
|
+
}
|
|
26
|
+
exports.expandHome = expandHome;
|
|
27
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGF0aHMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJwYXRocy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7QUFBQSw0Q0FBb0I7QUFDcEIsZ0RBQXdCO0FBRXhCOztnQ0FFZ0M7QUFDaEMsU0FBZ0IsVUFBVSxDQUFDLFFBQWdCO0lBQ3ZDLElBQUksUUFBUSxLQUFLLEdBQUcsRUFBRSxDQUFDO1FBQ25CLE9BQU8sWUFBRSxDQUFDLE9BQU8sRUFBRSxDQUFDO0lBQ3hCLENBQUM7SUFDRCxJQUFJLFFBQVEsQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLElBQUksUUFBUSxDQUFDLFVBQVUsQ0FBQyxLQUFLLENBQUMsRUFBRSxDQUFDO1FBQzFELE9BQU8sY0FBSSxDQUFDLElBQUksQ0FBQyxZQUFFLENBQUMsT0FBTyxFQUFFLEVBQUUsUUFBUSxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQ3RELENBQUM7SUFDRCxJQUFJLFFBQVEsQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQztRQUMzQix5RkFBeUY7UUFDekYsa0RBQWtEO1FBQ2xELE1BQU0sSUFBSSxLQUFLLENBQUMsdUNBQXVDLFFBQVEsaUVBQWlFLENBQUMsQ0FBQztJQUN0SSxDQUFDO0lBQ0QsT0FBTyxjQUFJLENBQUMsT0FBTyxDQUFDLFFBQVEsQ0FBQyxDQUFDO0FBQ2xDLENBQUM7QUFiRCxnQ0FhQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBvcyBmcm9tIFwib3NcIjtcbmltcG9ydCBwYXRoIGZyb20gXCJwYXRoXCI7XG5cbi8qKiBXaW5kb3dzIHNoZWxscyBkbyBub3QgZXhwYW5kIH4gdGhlbXNlbHZlcywgYW5kIGh1bnRpbmcgZG93biB5b3VyIGhvbWUgZm9sZGVyIGJ5IGhhbmQgaXNcbiAgICBhbm5veWluZywgc28gd2UgZXhwYW5kIGl0IG9uIGV2ZXJ5IHBsYXRmb3JtLiBCb3RoIHNlcGFyYXRvcnMgYXJlIGFjY2VwdGVkLCBiZWNhdXNlIGEgV2luZG93c1xuICAgIHVzZXIgbWF5IHR5cGUgZWl0aGVyIG9uZS4gKi9cbmV4cG9ydCBmdW5jdGlvbiBleHBhbmRIb21lKGZpbGVQYXRoOiBzdHJpbmcpIHtcbiAgICBpZiAoZmlsZVBhdGggPT09IFwiflwiKSB7XG4gICAgICAgIHJldHVybiBvcy5ob21lZGlyKCk7XG4gICAgfVxuICAgIGlmIChmaWxlUGF0aC5zdGFydHNXaXRoKFwifi9cIikgfHwgZmlsZVBhdGguc3RhcnRzV2l0aChcIn5cXFxcXCIpKSB7XG4gICAgICAgIHJldHVybiBwYXRoLmpvaW4ob3MuaG9tZWRpcigpLCBmaWxlUGF0aC5zbGljZSgyKSk7XG4gICAgfVxuICAgIGlmIChmaWxlUGF0aC5zdGFydHNXaXRoKFwiflwiKSkge1xuICAgICAgICAvLyB+b3RoZXJ1c2VyIG5lZWRzIGFuIGFjY291bnQgZGF0YWJhc2Ugd2UgY2Fubm90IHJlYWQgcG9ydGFibHksIGFuZCBxdWlldGx5IHJlc29sdmluZyBpdFxuICAgICAgICAvLyB0byB0aGUgd3JvbmcgaG9tZSB3b3VsZCBiZSB3b3JzZSB0aGFuIHJlZnVzaW5nLlxuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoYEV4cGVjdGVkIH4gb3IgYW4gb3JkaW5hcnkgcGF0aCwgd2FzICR7ZmlsZVBhdGh9LiBSZWZlcnJpbmcgdG8gYW5vdGhlciB1c2VyJ3MgaG9tZSB3aXRoIH5uYW1lIGlzIG5vdCBzdXBwb3J0ZWQuYCk7XG4gICAgfVxuICAgIHJldHVybiBwYXRoLnJlc29sdmUoZmlsZVBhdGgpO1xufVxuIl19
|
|
28
|
+
/* _JS_SOURCE_HASH = "aa5698729d5def416d177e211ac514eed7d8c8fcb796c68dcc520ddf0d83526b"; */
|