sliftutils 1.9.8 → 1.9.9
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
|
@@ -247,7 +247,6 @@ async function pollSource(repoURL: string) {
|
|
|
247
247
|
// Said when the file is written, and only if it came out different. A commit that does not
|
|
248
248
|
// touch the keys is not worth telling anyone about.
|
|
249
249
|
addChangeReason(`The key repo \`${repoURL}\` moved to \`${result.remoteSha.slice(0, 12)}\`.`);
|
|
250
|
-
sourceState(repoURL).lastSha = result.remoteSha;
|
|
251
250
|
return true;
|
|
252
251
|
}
|
|
253
252
|
|
|
@@ -71,12 +71,14 @@ export async function currentBranch(config: { repoPath: string; keyPath: string
|
|
|
71
71
|
for good: it sits in a stash if it is ever wanted. */
|
|
72
72
|
export async function setGitRef(config: { repoPath: string; gitRef: string; keyPath: string }) {
|
|
73
73
|
let { repoPath, gitRef, keyPath } = config;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
let dirty = (await runGit({ args: ["status", "--porcelain"], cwd: repoPath, keyPath })).stdout.trim();
|
|
75
|
+
if (dirty) {
|
|
76
|
+
console.log(`[pid ${process.pid}] Stashing local changes in ${repoPath} before resetting it to ${gitRef}:\n${dirty.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
77
|
+
// Staging the untracked files first is what lets the stash carry them off, so the reset lands
|
|
78
|
+
// on a genuinely clean tree.
|
|
79
|
+
await runGit({ args: ["add", "--all"], cwd: repoPath, keyPath, allowFailure: true });
|
|
80
|
+
await runGit({ args: ["stash"], cwd: repoPath, keyPath, allowFailure: true });
|
|
81
|
+
}
|
|
80
82
|
await runGit({ args: ["fetch", "--prune", "origin"], cwd: repoPath, keyPath });
|
|
81
83
|
await runGit({ args: ["reset", "--hard", gitRef], cwd: repoPath, keyPath });
|
|
82
84
|
// Drops objects nothing references any more, so a file committed by mistake cannot bloat the
|
|
@@ -140,5 +142,6 @@ export async function syncRepo(repoURL: string, options?: { forceUpdate?: boolea
|
|
|
140
142
|
});
|
|
141
143
|
historyRewritten = ancestry.status !== 0;
|
|
142
144
|
}
|
|
145
|
+
sourceState(repoURL).lastSha = remoteSha;
|
|
143
146
|
return { changed: true, historyRewritten, remoteSha, previousSha };
|
|
144
147
|
}
|
|
@@ -8,6 +8,7 @@ import { spawnPromise } from "../helpers/spawn";
|
|
|
8
8
|
|
|
9
9
|
export const UNREVOKES_DIR = "unrevoked";
|
|
10
10
|
const REVOCATIONS_DIR = "revocations";
|
|
11
|
+
const MAX_LOGGED_CONTENTS = 500;
|
|
11
12
|
|
|
12
13
|
/** One revocation event. Either an ssh key used from an address it is not allowed from, or a
|
|
13
14
|
machine that talked to us from one. Both are the same shape - an identity and an address - and
|
|
@@ -38,25 +39,20 @@ export function revocationIdentity(revocation: Revocation) {
|
|
|
38
39
|
return revocation.fingerprint || revocation.machineId || "";
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
A person running this has their own access to the repo, so their credentials are what is used.
|
|
44
|
-
On a machine that is already a portsecure host there may be no such credentials, but the source
|
|
45
|
-
deploy key is right there, and the key derived from it is one that repo definitely accepts. */
|
|
46
|
-
export async function readRemoteRevocations(config: { sourceURL: string }) {
|
|
42
|
+
async function cloneRevokeRepo(config: { sourceURL: string }) {
|
|
47
43
|
let { sourceURL } = config;
|
|
48
44
|
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "unrevoke-"));
|
|
49
45
|
let repoPath = path.join(temporaryDirectory, "repo");
|
|
50
|
-
let
|
|
46
|
+
let gitConfig: string[] = [];
|
|
51
47
|
let sourceKey = await findSourceKey(sourceURL);
|
|
52
48
|
if (sourceKey) {
|
|
53
49
|
let derived = deriveRevokeKey(await fs.readFile(sourceKey, "utf8"));
|
|
54
50
|
let derivedKeyPath = path.join(temporaryDirectory, "key");
|
|
55
51
|
await fs.writeFile(derivedKeyPath, derived.privateKeyFile, { mode: 0o600 });
|
|
56
52
|
let sshCommand = `ssh -i ${derivedKeyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
|
|
57
|
-
|
|
53
|
+
gitConfig = ["-c", `core.sshCommand=${sshCommand}`];
|
|
58
54
|
}
|
|
59
|
-
let clone = await spawnPromise({ command: "git", args:
|
|
55
|
+
let clone = await spawnPromise({ command: "git", args: [...gitConfig, "clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath] });
|
|
60
56
|
if (clone.status !== 0) {
|
|
61
57
|
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
62
58
|
throw new Error(
|
|
@@ -64,18 +60,63 @@ export async function readRemoteRevocations(config: { sourceURL: string }) {
|
|
|
64
60
|
+ `${(clone.stdout + clone.stderr).trim()}`
|
|
65
61
|
);
|
|
66
62
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
return { temporaryDirectory, repoPath, gitConfig };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function listRevocationFiles(repoPath: string) {
|
|
70
67
|
try {
|
|
71
|
-
|
|
68
|
+
return (await fs.readdir(path.join(repoPath, REVOCATIONS_DIR))).filter(name => name.endsWith(".json")).sort();
|
|
72
69
|
} catch (e) {
|
|
73
70
|
// No revocations directory at all just means nothing has ever been revoked.
|
|
71
|
+
return [];
|
|
74
72
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Deletes the revocation files in the revoke repo that are not JSON at all, and pushes that.
|
|
76
|
+
Nothing is lost: a file that does not parse names no key and no address, so every machine
|
|
77
|
+
already ignores it, and all it does is print the same complaint on every read forever.
|
|
78
|
+
|
|
79
|
+
Judged on a fresh clone, never on a machine's own checkout, so a file only goes if the repo
|
|
80
|
+
itself holds it broken. */
|
|
81
|
+
export async function removeUnreadableRevocations(config: { sourceURL: string }) {
|
|
82
|
+
let { sourceURL } = config;
|
|
83
|
+
let { temporaryDirectory, repoPath, gitConfig } = await cloneRevokeRepo({ sourceURL });
|
|
84
|
+
try {
|
|
85
|
+
let removed: string[] = [];
|
|
86
|
+
for (let name of await listRevocationFiles(repoPath)) {
|
|
87
|
+
let relativePath = `${REVOCATIONS_DIR}/${name}`;
|
|
88
|
+
let contents = await fs.readFile(path.join(repoPath, REVOCATIONS_DIR, name), "utf8");
|
|
89
|
+
try {
|
|
90
|
+
JSON.parse(contents);
|
|
91
|
+
continue;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.log(`Removing unreadable revocation ${relativePath} from ${revokeRepoURL(sourceURL)}, ${contents.length} characters: ${JSON.stringify(contents.slice(0, MAX_LOGGED_CONTENTS))}. ${e}`);
|
|
94
|
+
}
|
|
95
|
+
await fs.rm(path.join(repoPath, REVOCATIONS_DIR, name));
|
|
96
|
+
removed.push(relativePath);
|
|
78
97
|
}
|
|
98
|
+
if (!removed.length) return;
|
|
99
|
+
let git = async (args: string[]) => {
|
|
100
|
+
let result = await spawnPromise({ command: "git", args: [...gitConfig, ...args], cwd: repoPath });
|
|
101
|
+
if (result.status !== 0) {
|
|
102
|
+
throw new Error(`Expected git ${args.join(" ")} to succeed in a clone of ${revokeRepoURL(sourceURL)}, exited ${result.status}.\n${(result.stdout + result.stderr).trim()}`);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
await git(["add", "-A"]);
|
|
106
|
+
await git(["-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", `remove ${removed.length} unreadable revocation(s)`]);
|
|
107
|
+
await git(["push", "origin", "HEAD"]);
|
|
108
|
+
console.log(`Removed ${removed.length} unreadable revocation(s) from ${revokeRepoURL(sourceURL)}: ${removed.join(", ")}`);
|
|
109
|
+
} finally {
|
|
110
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function readRemoteRevocations(config: { sourceURL: string }) {
|
|
115
|
+
let { sourceURL } = config;
|
|
116
|
+
let { temporaryDirectory, repoPath } = await cloneRevokeRepo({ sourceURL });
|
|
117
|
+
let revocations: Revocation[] = [];
|
|
118
|
+
let directory = path.join(repoPath, REVOCATIONS_DIR);
|
|
119
|
+
for (let name of await listRevocationFiles(repoPath)) {
|
|
79
120
|
try {
|
|
80
121
|
revocations.push(JSON.parse(await fs.readFile(path.join(directory, name), "utf8")));
|
|
81
122
|
} catch (e) {
|
|
@@ -6,13 +6,13 @@ import { revokeRepoURL } from "./revokeSource";
|
|
|
6
6
|
import { signRepo } from "../signedFiles/signFiles";
|
|
7
7
|
import { getMachines, setMachines } from "../machines/machines";
|
|
8
8
|
import { resolveKeysRepo } from "./keysRepo";
|
|
9
|
-
import { readRemoteRevocations, readUnrevokedInRepo, Revocation, revocationIdentity, revocationIP, stillRevokedBy, UNREVOKES_DIR } from "./unrevoke";
|
|
9
|
+
import { readRemoteRevocations, readUnrevokedInRepo, removeUnreadableRevocations,Revocation, revocationIdentity, revocationIP, stillRevokedBy, UNREVOKES_DIR } from "./unrevoke";
|
|
10
10
|
|
|
11
11
|
const GIT_KEYWORD = "git";
|
|
12
12
|
const COMMIT_MESSAGE = "unrevoke keys";
|
|
13
13
|
const USAGE = `Usage: yarn unrevoke [keys-repo] [${GIT_KEYWORD}]
|
|
14
14
|
|
|
15
|
-
Run this in a keys repo, or name one. It
|
|
15
|
+
Run this in a keys repo, or name one. It deletes any revocation file in that repo's revoke repo that is not valid JSON, pushing that straight away. It reads the revoke repo and writes one unrevoke file
|
|
16
16
|
naming every revocation in it, so the keys are accepted again as each machine picks it up. It signs
|
|
17
17
|
the result, and with ${GIT_KEYWORD} it commits and pushes it too.
|
|
18
18
|
|
|
@@ -98,6 +98,12 @@ async function main() {
|
|
|
98
98
|
}
|
|
99
99
|
let { repoPath, sourceURL: originURL } = await resolveKeysRepo(positional[0]);
|
|
100
100
|
|
|
101
|
+
try {
|
|
102
|
+
await removeUnreadableRevocations({ sourceURL: originURL });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.log(`Could not remove the unreadable revocations from ${revokeRepoURL(originURL)}, unrevoking anyway.`, (e as Error).stack ?? e);
|
|
105
|
+
}
|
|
106
|
+
|
|
101
107
|
let revocations = await readRemoteRevocations({ sourceURL: originURL });
|
|
102
108
|
if (!revocations.length) {
|
|
103
109
|
console.log(`${revokeRepoURL(originURL)} lists no revocations, so there is nothing to undo.`);
|
|
@@ -16,6 +16,7 @@ import { describeHost, runOverSSH, writeRemoteFile } from "../helpers/remoteSSH"
|
|
|
16
16
|
import { lazy } from "socket-function/src/caching";
|
|
17
17
|
import { runInfinitePoll, runInSerial } from "socket-function/src/batching";
|
|
18
18
|
import { spawnPromise } from "../helpers/spawn";
|
|
19
|
+
import { formatTime } from "socket-function/src/formatting/format";
|
|
19
20
|
|
|
20
21
|
// Which machines this system talks to, kept in the same repo as the ssh keys. That repo is already
|
|
21
22
|
// signed, already distributed to every machine, and already has somewhere to record a rejection,
|
|
@@ -26,6 +27,9 @@ const REVOCATION_REASON = "a machine talked to us from an unapproved IP";
|
|
|
26
27
|
// The revoke repo is pulled at most this often. A check happens per request, and a request must
|
|
27
28
|
// not cost a round trip to github.
|
|
28
29
|
const REVOKE_SYNC_INTERVAL = 60 * 1000;
|
|
30
|
+
const SLOW_REVOKE_SYNC_TIME = 10 * 1000;
|
|
31
|
+
const REVOKE_SYNC_RETRY_INTERVAL = 5 * 60 * 1000;
|
|
32
|
+
const ACCEPTED_SYNC_WAIT = 5 * 1000;
|
|
29
33
|
// And the machine list is re-read from the checkout on this interval, in the background. The
|
|
30
34
|
// checkout itself only moves when the daemon pulls it, which is on the same sort of interval, so
|
|
31
35
|
// reading more often than this would only re-read the same bytes.
|
|
@@ -235,19 +239,56 @@ async function readMachines(repoPath: string) {
|
|
|
235
239
|
return machines;
|
|
236
240
|
}
|
|
237
241
|
|
|
238
|
-
let
|
|
242
|
+
let nextRevokeSyncTime = 0;
|
|
243
|
+
let revokeSyncFailing = false;
|
|
244
|
+
let revokeSyncInFlight: Promise<boolean> | undefined;
|
|
239
245
|
|
|
240
246
|
/** Pulls the keys repo and its revoke repo, at most once a REVOKE_SYNC_INTERVAL, because this is
|
|
241
247
|
asked per request. Both, since either changing changes the answer: the keys repo carries the
|
|
242
248
|
machines and unrevokes, the revoke repo carries the revocations. A sync that fails leaves the
|
|
243
249
|
checkouts we already have. */
|
|
244
|
-
|
|
245
|
-
if (Date.now()
|
|
246
|
-
return;
|
|
250
|
+
function startRevocationSync(sourceURL: string) {
|
|
251
|
+
if (revokeSyncInFlight || Date.now() < nextRevokeSyncTime) {
|
|
252
|
+
return revokeSyncInFlight;
|
|
247
253
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
254
|
+
nextRevokeSyncTime = Date.now() + REVOKE_SYNC_INTERVAL;
|
|
255
|
+
revokeSyncInFlight = (async () => {
|
|
256
|
+
let start = Date.now();
|
|
257
|
+
let succeeded = true;
|
|
258
|
+
await syncRepo(sourceURL).catch(e => {
|
|
259
|
+
succeeded = false;
|
|
260
|
+
console.log(`[pid ${process.pid}] Could not sync ${sourceURL}, using the checkout already here. ${(e as Error).stack ?? e}`);
|
|
261
|
+
});
|
|
262
|
+
await syncRepoFiles(revokeRepo(sourceURL)).catch(e => {
|
|
263
|
+
succeeded = false;
|
|
264
|
+
console.log(`[pid ${process.pid}] Could not sync ${revokeRepoURL(sourceURL)}, using the revocations already absorbed. ${(e as Error).stack ?? e}`);
|
|
265
|
+
});
|
|
266
|
+
let elapsed = Date.now() - start;
|
|
267
|
+
if (elapsed > SLOW_REVOKE_SYNC_TIME) {
|
|
268
|
+
console.log(`[pid ${process.pid}] Syncing ${sourceURL} and ${revokeRepoURL(sourceURL)} took ${formatTime(elapsed)}, over ${formatTime(SLOW_REVOKE_SYNC_TIME)}`);
|
|
269
|
+
}
|
|
270
|
+
if (!succeeded) {
|
|
271
|
+
nextRevokeSyncTime = Date.now() + REVOKE_SYNC_RETRY_INTERVAL;
|
|
272
|
+
if (!revokeSyncFailing) {
|
|
273
|
+
console.log(`[pid ${process.pid}] Syncing ${sourceURL} failed, retrying every ${formatTime(REVOKE_SYNC_RETRY_INTERVAL)} instead of every ${formatTime(REVOKE_SYNC_INTERVAL)} until it works`);
|
|
274
|
+
}
|
|
275
|
+
revokeSyncFailing = true;
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
if (revokeSyncFailing) {
|
|
279
|
+
console.log(`[pid ${process.pid}] Syncing ${sourceURL} works again, back to every ${formatTime(REVOKE_SYNC_INTERVAL)}`);
|
|
280
|
+
}
|
|
281
|
+
revokeSyncFailing = false;
|
|
282
|
+
try {
|
|
283
|
+
trustedMachines.set(Promise.resolve(await readTrustedMachines()));
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.log(`[pid ${process.pid}] Could not re-read the trusted machines after syncing, keeping the ones we have. ${(e as Error).stack ?? e}`);
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
})().finally(() => {
|
|
289
|
+
revokeSyncInFlight = undefined;
|
|
290
|
+
});
|
|
291
|
+
return revokeSyncInFlight;
|
|
251
292
|
}
|
|
252
293
|
|
|
253
294
|
/** What to run to trust a machine that has just been refused.
|
|
@@ -390,12 +431,34 @@ export async function isMachineAccepted(config: {
|
|
|
390
431
|
// there is nothing to gain from looking again. Any other rejection might just be a stale view -
|
|
391
432
|
// a machine added seconds ago that our cached list has not caught up to - so it is worth one
|
|
392
433
|
// fresh look, which is what the retry below does.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
434
|
+
if (LOOPBACK.includes(ip) && !!domain && machineId === getOwnMachineId(domain)) {
|
|
435
|
+
return { accepted: true, reason: "" };
|
|
436
|
+
}
|
|
437
|
+
let { sourceURL } = await keysRepo();
|
|
438
|
+
let syncing = startRevocationSync(sourceURL);
|
|
439
|
+
if (syncing) {
|
|
440
|
+
let checkouts = await Promise.all([sourceRepoPath(sourceURL), revokeRepoPath(sourceURL)].map(repoPath =>
|
|
441
|
+
fs.access(path.join(repoPath, ".git")).then(() => true, () => false)
|
|
442
|
+
));
|
|
443
|
+
let acceptedNow = false;
|
|
444
|
+
if (checkouts.every(exists => exists)) {
|
|
445
|
+
let listed = (await getTrustedMachines()).find(entry => entry.machineId === machineId);
|
|
446
|
+
acceptedNow = !!listed && (listed.ips.includes(ip) || LOOPBACK.includes(ip));
|
|
447
|
+
}
|
|
448
|
+
if (!acceptedNow) {
|
|
449
|
+
await syncing;
|
|
450
|
+
} else {
|
|
451
|
+
let synced = await Promise.race([
|
|
452
|
+
syncing,
|
|
453
|
+
new Promise<boolean>(resolve => setTimeout(() => resolve(false), ACCEPTED_SYNC_WAIT)),
|
|
454
|
+
]);
|
|
455
|
+
if (!synced) {
|
|
456
|
+
return { accepted: true, reason: "" };
|
|
457
|
+
}
|
|
396
458
|
}
|
|
397
|
-
|
|
398
|
-
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let evaluate = async (): Promise<{ verdict: MachineVerdict; froze: boolean }> => {
|
|
399
462
|
// The list has revocations already applied - readSignedRepo drops frozen machines - so a
|
|
400
463
|
// machine in it is trusted, and a machine missing from it is either unknown or frozen.
|
|
401
464
|
let machine = (await getTrustedMachines()).find(entry => entry.machineId === machineId);
|
|
@@ -456,7 +519,7 @@ export async function isMachineAccepted(config: {
|
|
|
456
519
|
// final.
|
|
457
520
|
if (Date.now() - lastMachinesInvalidation > MACHINES_INVALIDATION_INTERVAL) {
|
|
458
521
|
lastMachinesInvalidation = Date.now();
|
|
459
|
-
|
|
522
|
+
await revokeSyncInFlight;
|
|
460
523
|
await syncRepo(sourceURL, { forceUpdate: true }).catch(e => console.log(`Could not force update ${sourceURL}. ${e}`));
|
|
461
524
|
trustedMachines.reset();
|
|
462
525
|
}
|