sliftutils 1.7.125 → 1.7.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/bin/derivekey.js +10 -0
  2. package/bin/portsecuredaemon.js +13 -0
  3. package/bin/unrevoke.js +9 -0
  4. package/package.json +8 -3
  5. package/security/README.md +66 -2
  6. package/security/authorizedKeys/authorizedKeys.ts +23 -0
  7. package/security/authorizedKeys/daemon/authLog.ts +117 -0
  8. package/security/authorizedKeys/daemon/daemon.ts +308 -0
  9. package/security/authorizedKeys/daemon/git.ts +119 -0
  10. package/security/authorizedKeys/daemon/notify.ts +25 -0
  11. package/security/authorizedKeys/daemon/paths.ts +26 -0
  12. package/security/authorizedKeys/daemon/portsecure.service +3 -2
  13. package/security/authorizedKeys/daemon/revocation.ts +306 -0
  14. package/security/authorizedKeys/daemon/rootKeys.ts +135 -0
  15. package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
  16. package/security/authorizedKeys/daemon/state.ts +108 -0
  17. package/security/authorizedKeys/daemon/trust.ts +291 -0
  18. package/security/authorizedKeys/daemon/userKeys.ts +76 -0
  19. package/security/authorizedKeys/dist/authorizedKeys.ts.cache +73 -0
  20. package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
  21. package/security/authorizedKeys/dist/secureSSH.ts.cache +552 -0
  22. package/security/authorizedKeys/dist/sources.ts.cache +24 -0
  23. package/security/authorizedKeys/dist/unrevoke.ts.cache +145 -0
  24. package/security/authorizedKeys/revokeSource.ts +40 -0
  25. package/security/authorizedKeys/secureSSH.ts +201 -15
  26. package/security/authorizedKeys/unrevoke.ts +149 -0
  27. package/security/helpers/dist/paths.ts.cache +28 -0
  28. package/security/helpers/dist/remoteSSH.ts.cache +90 -0
  29. package/security/helpers/dist/spawn.ts.cache +34 -0
  30. package/security/helpers/remoteSSH.ts +3 -2
  31. package/security/helpers/spawn.ts +5 -1
  32. package/security/keys/deriveKey.ts +72 -0
  33. package/security/keys/dist/deriveKey.ts.cache +72 -0
  34. package/security/keys/dist/sshKeyFile.ts.cache +153 -0
  35. package/security/keys/sshKeyFile.ts +156 -0
  36. package/security/notifications/dist/discord.ts.cache +180 -0
  37. package/security/signedFiles/dist/manifest.ts.cache +68 -0
  38. package/security/signedFiles/dist/signFiles.ts.cache +146 -0
  39. package/security/signedFiles/manifest.ts +19 -6
  40. package/security/signedFiles/signFiles.ts +78 -43
  41. package/security/authorizedKeys/daemon/portsecureDaemon.js +0 -1032
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // Derives a second ed25519 key from an existing one, so one key can stand behind several
4
+ // identities without any of them being stored.
5
+ require("typenode");
6
+
7
+ require("../security/keys/deriveKey").main().catch(e => {
8
+ console.error(`${e}`);
9
+ process.exitCode = 1;
10
+ }).finally(() => process.exit());
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // The daemon that keeps a machine's root authorized_keys equal to its key repos. Installed and
4
+ // started by securessh, and normally only run by systemd.
5
+ //
6
+ // Deliberately without the usual finally(process.exit) of the other entry points: this one is
7
+ // meant to keep running after main resolves, and exiting there would stop it dead on startup.
8
+ require("typenode");
9
+
10
+ require("../security/authorizedKeys/daemon/daemon").main().catch(e => {
11
+ console.error(`portsecure: failed to start. ${e && e.stack || e}`);
12
+ process.exit(1);
13
+ });
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // Undoes revocations, by writing a file into the keys repo naming the ones to undo.
4
+ require("typenode");
5
+
6
+ require("../security/authorizedKeys/unrevoke").main().catch(e => {
7
+ console.error(`${e}`);
8
+ process.exitCode = 1;
9
+ }).finally(() => process.exit());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.7.125",
3
+ "version": "1.7.126",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -31,7 +31,9 @@
31
31
  "autohost": "node ./bin/autohost.js",
32
32
  "setupnotify": "node ./bin/setupnotify.js",
33
33
  "securessh": "node ./bin/securessh.js",
34
- "signfiles": "node ./bin/signfiles.js"
34
+ "signfiles": "node ./bin/signfiles.js",
35
+ "derivekey": "node ./bin/derivekey.js",
36
+ "unrevoke": "node ./bin/unrevoke.js"
35
37
  },
36
38
  "bin": {
37
39
  "filehoster": "./bin/filehoster.js",
@@ -51,7 +53,10 @@
51
53
  "sliftsetup": "./builders/setupRun.js",
52
54
  "setupnotify": "./bin/setupnotify.js",
53
55
  "securessh": "./bin/securessh.js",
54
- "signfiles": "./bin/signfiles.js"
56
+ "signfiles": "./bin/signfiles.js",
57
+ "derivekey": "./bin/derivekey.js",
58
+ "portsecuredaemon": "./bin/portsecuredaemon.js",
59
+ "unrevoke": "./bin/unrevoke.js"
55
60
  },
56
61
  "dependencies": {
57
62
  "@types/chrome": "^0.0.237",
@@ -22,10 +22,18 @@ off every other way in.
22
22
  yarn securessh <host> add <repo-private-key> [repo-url]
23
23
  yarn securessh <host> remove [repo-url]
24
24
  yarn securessh <host> list
25
+ yarn securessh <host> update
25
26
 
26
27
  With no repo url, the repo you are standing in is used, as long as it holds keys and has an
27
28
  origin to clone from.
28
29
 
30
+ The daemon runs on the host, so `update` is how a host picks up a newer build of it. It changes
31
+ nothing about which keys that host trusts.
32
+
33
+ `update` installs the daemon from this checkout, not from anything the host fetches, so it pulls
34
+ this checkout first and stops if that pull does not fast forward. It never installs code it
35
+ cannot account for.
36
+
29
37
  Each source repo keeps its own deploy key. Both verbs refuse to run if the key you log in with
30
38
  would not survive the change, since that would lock you out of the host permanently.
31
39
 
@@ -67,10 +75,66 @@ When a source starts being signed by a different key, the daemon warns on Discor
67
75
  applying the keys it last accepted. It applies the new ones only after 24 hours of that same new
68
76
  signer. Any different signer restarts the wait, so publishing twice in a row gains nothing, and a
69
77
  return to the accepted signer cancels it. Losing a signature entirely counts as a change too, so
70
- stripping it does not get anything through faster.
78
+ stripping it does not get anything through faster. Going the other way, from unsigned to signed,
79
+ is only ever an improvement and applies right away.
80
+
81
+ Every one of those messages names the public key, in the same `type base64` form `signfiles`
82
+ prints, or `<no public key>` when there is none.
71
83
 
72
84
  A signature that does not verify, or a manifest that does not match the files on disk, is never
73
- treated as an identity - that content is ignored and the last accepted keys stay.
85
+ treated as an identity - that content is ignored and the last accepted keys stay. Which of the
86
+ two it is gets reported:
87
+
88
+ - the signature is byte for byte the one we already accepted, so the repo changed and nobody
89
+ re-signed it. The changes are ignored until someone runs `signfiles` again.
90
+ - the signature did change and does not hold up, so it is corrupt.
91
+
92
+ Either way it is reported once, not every time it is polled.
93
+
94
+ ## keys
95
+
96
+ Derives a second ed25519 key from an existing one, by mixing a label into the source key's secret.
97
+ The same label and source always give the same key, so a derived key is something you can work out
98
+ again rather than something you have to keep a backup of.
99
+
100
+ yarn derivekey <label> <source-key> <derived-key>
101
+ yarn derivekey revokegithubkey ~/authorized_keys_access/id_ed25519 ~/authorized_keys_access/id_ed25519_revoke
102
+
103
+ It writes an ordinary OpenSSH key pair, so the public key can be handed to anything that takes one.
104
+ `deriveEd25519Key` in `deriveKey.ts` is the part to import elsewhere, and `sshKeyFile.ts` reads and
105
+ writes the OpenSSH private key container that node itself cannot.
106
+
107
+ ### Revoking keys
108
+
109
+ A key that is used from an address its `from=` restriction does not allow is revoked everywhere,
110
+ not just refused. The daemon reads sshd's log for exactly that refusal, takes the fingerprint sshd
111
+ names for the same connection, and writes a revocation to the source's revoke repo.
112
+
113
+ The revoke repo is derived from the source: `…/authorized_keys.git` gets `…/authorized_keys_revoked.git`,
114
+ reached with a key derived from the source's deploy key under the label `revokegithubkey`. Github
115
+ will not take one public key on two repos, which is why it is derived rather than reused, and it
116
+ means nothing extra has to be configured or uploaded - anything holding the source key can work it
117
+ out. `securessh add` checks that repo exists and is writable, and prints the deploy key to add if
118
+ it is not.
119
+
120
+ - One revocation per key, ever. The file is named after the fingerprint, and the key is checked
121
+ against local state before any network work, so a flood of unknown keys cannot become a flood of
122
+ commits.
123
+ - A revocation is sticky once seen. Deleting it from the repo does not bring the key back: the key
124
+ that writes revocations is on every server, so whoever stole one could otherwise erase the record
125
+ that locked them out. Recovering from that means making a new key, which you would want anyway.
126
+ - Each machine says so on Discord when a revoked key actually leaves its authorized_keys, once.
127
+
128
+ yarn unrevoke [keys-repo]
129
+
130
+ Run in the keys repo, or name one. It uses whatever git credentials the machine already has, since
131
+ the derived deploy key is for servers rather than for people. It reads the revoke repo and writes
132
+ one file under `unrevoked/` naming the revocations to undo, which then needs signing and pushing. Machines report when they see it, hold
133
+ it for an hour, then report again when it takes effect - so a signing key that was itself stolen
134
+ cannot instantly undo the revocation that shut it out.
135
+
136
+ Deleting a revoked key from the repo is usually the right answer instead. Both `signfiles` and
137
+ `securessh` refuse while a repo still holds a revoked key, and say which one it is.
74
138
 
75
139
  ## helpers
76
140
 
@@ -1,3 +1,4 @@
1
+ import crypto from "crypto";
1
2
  import fs from "fs/promises";
2
3
  import path from "path";
3
4
 
@@ -10,6 +11,28 @@ export function normalizeKeys(contents: string) {
10
11
  return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
11
12
  }
12
13
 
14
+ /** The fingerprint ssh itself reports for a key, which is what the sshd log names and therefore
15
+ what a revocation is keyed by. Returns "" for a line that holds no key. */
16
+ export function keyFingerprint(keyLine: string) {
17
+ let parts = keyLine.trim().split(/\s+/);
18
+ let typeIndex = parts.findIndex(part => /^(ssh-|ecdsa-|sk-)/.test(part));
19
+ let blob = typeIndex >= 0 && parts[typeIndex + 1] || "";
20
+ if (!blob) {
21
+ return "";
22
+ }
23
+ return "SHA256:" + crypto.createHash("sha256").update(Buffer.from(blob, "base64")).digest("base64").replace(/=+$/, "");
24
+ }
25
+
26
+ /** The addresses a key may be used from, which is the part of an authorized_keys line that
27
+ decides how much a stolen key is worth. A key with no restriction says so loudly. */
28
+ export function keyRestriction(keyLine: string) {
29
+ let match = keyLine.match(/from="([^"]*)"/);
30
+ if (!match) {
31
+ return "ANY ADDRESS (no from= restriction)";
32
+ }
33
+ return match[1];
34
+ }
35
+
13
36
  /** Enough to recognise whose key this is without printing the whole blob. */
14
37
  export function summarizeKey(keyLine: string) {
15
38
  let parts = keyLine.trim().split(/\s+/);
@@ -0,0 +1,117 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import { spawnPromise } from "../../helpers/spawn";
4
+ import { AUTH_LOG_PATH } from "./paths";
5
+ import { getState, saveState } from "./state";
6
+ import { Attempt } from "./revocation";
7
+
8
+ // sshd says an attempt was refused in one line and names the key in another, both for the same
9
+ // connection, so they are tied together by the process id the log puts on every line.
10
+ const REFUSED = /Authentication tried for (\S+) with correct key but not from a permitted host \(host=([^,]*), ip=([^,]*), required=([^)]*)\)/;
11
+ const FAILED_KEY = /Failed publickey for (\S+) from (\S+) port (\d+) ssh2: \S+ (SHA256:[A-Za-z0-9+/=]+)/;
12
+ const PROCESS_ID = /(?:sshd|sshd-session)\[(\d+)\]/;
13
+ // Enough of the head of the file to notice it was rotated out from under us.
14
+ const SIGNATURE_LENGTH = 512;
15
+
16
+ export type RefusedAttempt = { fingerprint: string; attempt: Attempt };
17
+
18
+ /** Pairs each refusal with the fingerprint sshd logged for the same connection. A refusal we
19
+ cannot tie to a key is dropped: revoking the wrong key would lock out the wrong person. */
20
+ export function parseAuthLog(contents: string) {
21
+ let refusals = new Map<string, { user: string; ip: string; required: string; line: string }[]>();
22
+ let fingerprints = new Map<string, { fingerprint: string; port: string }>();
23
+ for (let line of contents.split("\n")) {
24
+ let processMatch = line.match(PROCESS_ID);
25
+ if (!processMatch) {
26
+ continue;
27
+ }
28
+ let processId = processMatch[1];
29
+ let refused = line.match(REFUSED);
30
+ if (refused) {
31
+ let existing = refusals.get(processId) || [];
32
+ existing.push({ user: refused[1], ip: refused[3], required: refused[4], line: line.trim() });
33
+ refusals.set(processId, existing);
34
+ continue;
35
+ }
36
+ let failed = line.match(FAILED_KEY);
37
+ if (failed) {
38
+ fingerprints.set(processId, { fingerprint: failed[4], port: failed[3] });
39
+ }
40
+ }
41
+
42
+ let attempts: RefusedAttempt[] = [];
43
+ for (let [processId, entries] of refusals) {
44
+ let key = fingerprints.get(processId);
45
+ if (!key) {
46
+ console.log(`A refused attempt named no key, so nothing is being revoked for it: ${entries[0].line}`);
47
+ continue;
48
+ }
49
+ for (let entry of entries) {
50
+ attempts.push({
51
+ fingerprint: key.fingerprint,
52
+ attempt: {
53
+ ip: entry.ip,
54
+ user: entry.user,
55
+ port: key.port,
56
+ required: entry.required,
57
+ line: entry.line,
58
+ },
59
+ });
60
+ }
61
+ }
62
+ return attempts;
63
+ }
64
+
65
+ /** Only what has been written since last time. A rotated file starts again from the beginning,
66
+ and a file that only grew is read from where we stopped. */
67
+ export async function readNewAuthLog() {
68
+ let state = getState();
69
+ let handle;
70
+ try {
71
+ handle = await fs.open(AUTH_LOG_PATH, "r");
72
+ } catch (e) {
73
+ // Some machines only keep this in the journal.
74
+ let result = await spawnPromise({
75
+ command: "journalctl",
76
+ args: ["-u", "ssh", "-u", "sshd", "--no-pager", "--since", "-10min"],
77
+ });
78
+ if (result.status !== 0) {
79
+ console.log(`No auth log to read: ${AUTH_LOG_PATH} is unreadable and journalctl exited ${result.status}`);
80
+ return "";
81
+ }
82
+ return result.stdout;
83
+ }
84
+ try {
85
+ let stats = await handle.stat();
86
+ let head = Buffer.alloc(Math.min(SIGNATURE_LENGTH, stats.size));
87
+ await handle.read(head, 0, head.length, 0);
88
+ let signature = crypto.createHash("sha256").update(head).digest("hex");
89
+
90
+ if (!state.authLogSignature) {
91
+ // First time we have ever looked. Start from the end: the log holds history from
92
+ // before this machine watched it, and revoking keys over refusals nobody was watching
93
+ // for could take away access that is still in use.
94
+ state.authLogOffset = stats.size;
95
+ state.authLogSignature = signature;
96
+ await saveState();
97
+ console.log(`Watching ${AUTH_LOG_PATH} from its current end, ${stats.size} bytes in`);
98
+ return "";
99
+ }
100
+ let offset = state.authLogOffset;
101
+ if (signature !== state.authLogSignature || stats.size < offset) {
102
+ // Rotated, or replaced. Everything in the new file is new.
103
+ offset = 0;
104
+ }
105
+ if (stats.size === offset) {
106
+ return "";
107
+ }
108
+ let contents = Buffer.alloc(stats.size - offset);
109
+ await handle.read(contents, 0, contents.length, offset);
110
+ state.authLogOffset = stats.size;
111
+ state.authLogSignature = signature;
112
+ await saveState();
113
+ return contents.toString("utf8");
114
+ } finally {
115
+ await handle.close();
116
+ }
117
+ }
@@ -0,0 +1,308 @@
1
+ import fs from "fs/promises";
2
+ import os from "os";
3
+ import { configureDiscordNotifications, DEFAULT_WEBHOOK_FILE_PATH } from "../../notifications/discord";
4
+ import { sourceKeyPath, sourceRepoPath } from "../sources";
5
+ import { cloneRepo, syncRepo } from "./git";
6
+ import {
7
+ CHECK_INTERVAL,
8
+ CONFIG_PATH,
9
+ MAX_REPO_FAILURES_BEFORE_RECLONE,
10
+ ROOT_AUTHORIZED_KEYS,
11
+ } from "./paths";
12
+ import { notify, setHostLabel } from "./notify";
13
+ import { enforceRootKeys } from "./rootKeys";
14
+ import { enforceSSHDConfig } from "./sshdConfig";
15
+ import { getState, loadState, saveState, sourceState } from "./state";
16
+ import { resolveSourceKeys } from "./trust";
17
+ import { parseAuthLog, readNewAuthLog } from "./authLog";
18
+ import { absorbRevocations, applyUnrevokes, recordRevocation, removeRevokedKeys, syncRevokeRepo } from "./revocation";
19
+ import { keyFingerprint } from "../authorizedKeys";
20
+ import { checkOtherUserKeys, seedUserKeys } from "./userKeys";
21
+
22
+ // portsecure authorized-keys daemon.
23
+ //
24
+ // It owns root's authorized_keys: the contents come from one or more git repos, anything else is
25
+ // reverted, and password authentication is turned off so those repos are the only way in.
26
+ //
27
+ // The complete list of things that send a Discord message. Nothing else may be added to it
28
+ // without the user asking for that specific case - everything else goes to the log.
29
+ // 1. root's authorized_keys was changed outside portsecure, and was reverted.
30
+ // 2. root's authorized_keys was updated because a source changed.
31
+ // 3. Another user's authorized_keys changed.
32
+ // 4. A source's history was rewritten.
33
+ // 5. A source started being signed by a different key, so its new keys are being held.
34
+ // 6. A source is now signed when it was not before, applied right away.
35
+ // 7. A source changed without its signature being updated, so the change is ignored.
36
+ // 8. A source has a corrupted signature, so its contents are ignored.
37
+ // 9. The webhook file itself changed, reported to the webhook being replaced.
38
+ // 10. A key was revoked here, after being used from an address it is not allowed from.
39
+ // 11. A revoked key was removed from root's authorized_keys, said once per key.
40
+ // 12. An unrevoke was published, and is being held for an hour.
41
+ // 13. An unrevoke was applied once that hour passed.
42
+
43
+ export type DaemonConfig = {
44
+ repoSources: string[];
45
+ hostLabel: string;
46
+ };
47
+
48
+ let config: DaemonConfig = { repoSources: [], hostLabel: "" };
49
+ let repoFailureCounts: { [repoURL: string]: number } = {};
50
+
51
+ export function getConfig() {
52
+ return config;
53
+ }
54
+
55
+ export function setConfig(value: DaemonConfig) {
56
+ config = value;
57
+ setHostLabel(value.hostLabel);
58
+ }
59
+
60
+ async function loadConfig(): Promise<DaemonConfig> {
61
+ let contents;
62
+ try {
63
+ contents = await fs.readFile(CONFIG_PATH, "utf8");
64
+ } catch (e) {
65
+ console.error(`portsecure: expected a config file at ${CONFIG_PATH}, ${e}`);
66
+ process.exit(1);
67
+ }
68
+ let parsed = JSON.parse(contents) as { repoSources?: string[]; hostLabel?: string };
69
+ if (!Array.isArray(parsed.repoSources)) {
70
+ console.error(`portsecure: expected a repoSources array in ${CONFIG_PATH}, was ${JSON.stringify(parsed.repoSources)}`);
71
+ process.exit(1);
72
+ }
73
+ for (let repoURL of parsed.repoSources) {
74
+ try {
75
+ await fs.access(sourceKeyPath(repoURL));
76
+ } catch (e) {
77
+ console.error(`portsecure: expected the private key for ${repoURL} at ${sourceKeyPath(repoURL)}, ${e}`);
78
+ process.exit(1);
79
+ }
80
+ }
81
+ return {
82
+ repoSources: parsed.repoSources,
83
+ // The machine knows its own name, the config only overrides it when a nicer label helps.
84
+ hostLabel: parsed.hostLabel || os.hostname(),
85
+ };
86
+ }
87
+
88
+ /** The union of every source, in source order, with duplicates dropped. A source that cannot be
89
+ read is skipped rather than emptying the merged set, so one broken repo cannot revoke the keys
90
+ that came from the others. */
91
+ export async function readAllowedKeys() {
92
+ let keys: string[] = [];
93
+ let seen = new Set<string>();
94
+ for (let repoURL of config.repoSources) {
95
+ let sourceKeys: string[];
96
+ try {
97
+ sourceKeys = await resolveSourceKeys(repoURL);
98
+ } catch (e) {
99
+ console.log(`Skipping ${repoURL}, its checkout could not be read. ${e}`);
100
+ continue;
101
+ }
102
+ for (let key of sourceKeys) {
103
+ if (seen.has(key)) {
104
+ continue;
105
+ }
106
+ seen.add(key);
107
+ keys.push(key);
108
+ }
109
+ }
110
+ return keys;
111
+ }
112
+
113
+ /** Which source contributed a key, so its revocation is written to that source's revoke repo. */
114
+ function sourceOfFingerprint(fingerprint: string) {
115
+ for (let repoURL of config.repoSources) {
116
+ if (sourceState(repoURL).acceptedKeys.some(key => keyFingerprint(key) === fingerprint)) {
117
+ return repoURL;
118
+ }
119
+ }
120
+ return config.repoSources[0] || "";
121
+ }
122
+
123
+ /** Anything sshd refused because of a from= restriction gets that key revoked everywhere. Reading
124
+ the log is cheap and local, and the fingerprint is checked against what we already revoked
125
+ before any network work happens.
126
+
127
+ A refusal is queued rather than acted on directly, because the log is read once and moves past:
128
+ if the revoke repo is unreachable at that moment, dropping the refusal would leave a key that
129
+ was misused accepted forever. The queue is retried until it is written down. */
130
+ async function queueRefusedKeys(allowedKeys: string[]) {
131
+ let contents = await readNewAuthLog();
132
+ if (!contents.trim()) {
133
+ return;
134
+ }
135
+ let state = getState();
136
+ for (let found of parseAuthLog(contents)) {
137
+ // One key is revoked once, no matter how many addresses it was tried from.
138
+ if (state.revocations[found.fingerprint]) {
139
+ continue;
140
+ }
141
+ if (state.pendingRevocations.some(pending => pending.fingerprint === found.fingerprint)) {
142
+ continue;
143
+ }
144
+ let sourceURL = sourceOfFingerprint(found.fingerprint);
145
+ if (!sourceURL) {
146
+ console.log(`Nowhere to record the revocation of ${found.fingerprint}, no sources are configured`);
147
+ continue;
148
+ }
149
+ console.log(`Queued the revocation of ${found.fingerprint}, used from ${found.attempt.ip}`);
150
+ state.pendingRevocations.push({
151
+ fingerprint: found.fingerprint,
152
+ keyLine: allowedKeys.find(key => keyFingerprint(key) === found.fingerprint) || "",
153
+ sourceURL,
154
+ attempt: found.attempt,
155
+ });
156
+ }
157
+ await saveState();
158
+ }
159
+
160
+ /** Writes down everything queued that we have not managed to write down yet. */
161
+ async function writeQueuedRevocations() {
162
+ let state = getState();
163
+ if (!state.pendingRevocations.length) {
164
+ return;
165
+ }
166
+ let remaining = [];
167
+ for (let pending of state.pendingRevocations) {
168
+ if (state.revocations[pending.fingerprint]) {
169
+ continue;
170
+ }
171
+ let recorded = await recordRevocation({ ...pending, hostLabel: config.hostLabel });
172
+ if (!recorded && !state.revocations[pending.fingerprint]) {
173
+ remaining.push(pending);
174
+ }
175
+ }
176
+ state.pendingRevocations = remaining;
177
+ await saveState();
178
+ }
179
+
180
+ /** Syncs one source. Returns whether the merged keys need reapplying. */
181
+ async function pollSource(repoURL: string) {
182
+ let result;
183
+ try {
184
+ result = await syncRepo(repoURL);
185
+ repoFailureCounts[repoURL] = 0;
186
+ } catch (e) {
187
+ let failures = (repoFailureCounts[repoURL] || 0) + 1;
188
+ repoFailureCounts[repoURL] = failures;
189
+ console.log(`Sync of ${repoURL} failed (${failures} in a row). ${e}`);
190
+ if (failures < MAX_REPO_FAILURES_BEFORE_RECLONE) {
191
+ return false;
192
+ }
193
+ // Availability over tidiness: throw the working copy away and start again.
194
+ console.log(`Discarding the checkout of ${repoURL} and cloning from scratch`);
195
+ try {
196
+ await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: sourceKeyPath(repoURL) });
197
+ repoFailureCounts[repoURL] = 0;
198
+ return true;
199
+ } catch (cloneError) {
200
+ console.log(`${repoURL} cannot be reached or cloned, its last known keys stay in place. ${cloneError}`);
201
+ return false;
202
+ }
203
+ }
204
+
205
+ if (result.historyRewritten) {
206
+ await notify(
207
+ `the history of \`${repoURL}\` was rewritten. Commit \`${result.previousSha.slice(0, 12)}\` is no`
208
+ + ` longer an ancestor of \`${result.remoteSha.slice(0, 12)}\`, so history was force pushed or`
209
+ + ` tampered with. The new state has been applied.`
210
+ );
211
+ }
212
+ if (!result.changed) {
213
+ return false;
214
+ }
215
+ sourceState(repoURL).lastSha = result.remoteSha;
216
+ return true;
217
+ }
218
+
219
+ async function everyCheck() {
220
+ let anyChanged = false;
221
+ for (let repoURL of config.repoSources) {
222
+ // One unreachable source must not stop the others from being checked.
223
+ try {
224
+ anyChanged = await pollSource(repoURL) || anyChanged;
225
+ } catch (e) {
226
+ console.log(`Polling ${repoURL} failed. ${e && (e as Error).stack || e}`);
227
+ }
228
+ }
229
+ if (anyChanged) {
230
+ await saveState();
231
+ }
232
+
233
+ // What other machines have revoked, and anything published to undo a revocation.
234
+ for (let repoURL of config.repoSources) {
235
+ await syncRevokeRepo(repoURL);
236
+ }
237
+ await absorbRevocations(config.repoSources);
238
+ await applyUnrevokes(config.repoSources);
239
+
240
+ let mergedKeys = await readAllowedKeys();
241
+ await queueRefusedKeys(mergedKeys);
242
+ await writeQueuedRevocations();
243
+
244
+ // The repo is checked first, so a change that came from it is reported as an update rather
245
+ // than as somebody having edited the file locally.
246
+ await enforceRootKeys({ keys: await removeRevokedKeys(mergedKeys), reason: anyChanged && "repo" || "manual" });
247
+ await checkOtherUserKeys();
248
+ await enforceSSHDConfig();
249
+ }
250
+
251
+ function startInterval(config: { intervalTime: number; run: () => Promise<void>; name: string }) {
252
+ let { intervalTime, run, name } = config;
253
+ let running = false;
254
+ let tick = async () => {
255
+ if (running) {
256
+ console.log(`Skipping ${name}, the previous run has not finished`);
257
+ return;
258
+ }
259
+ running = true;
260
+ try {
261
+ await run();
262
+ } catch (e) {
263
+ // Every scheduled job swallows its own errors, the daemon must outlive any single one.
264
+ console.log(`${name} failed. ${e && (e as Error).stack || e}`);
265
+ }
266
+ running = false;
267
+ };
268
+ setInterval(tick, intervalTime);
269
+ return tick;
270
+ }
271
+
272
+ export async function main() {
273
+ setConfig(await loadConfig());
274
+ await loadState();
275
+ await configureDiscordNotifications({ filePath: DEFAULT_WEBHOOK_FILE_PATH });
276
+
277
+ console.log(`Starting, ${config.repoSources.length} source(s), keys applied to ${ROOT_AUTHORIZED_KEYS}`);
278
+
279
+ // A first pass has to happen before the intervals, so a machine is correct immediately after
280
+ // boot rather than a minute later.
281
+ for (let repoURL of config.repoSources) {
282
+ try {
283
+ sourceState(repoURL).lastSha = (await syncRepo(repoURL)).remoteSha;
284
+ } catch (e) {
285
+ console.log(`Initial sync of ${repoURL} failed, continuing with whatever is on disk. ${e}`);
286
+ }
287
+ }
288
+ await saveState();
289
+ await seedUserKeys();
290
+
291
+ for (let repoURL of config.repoSources) {
292
+ await syncRevokeRepo(repoURL);
293
+ }
294
+ await absorbRevocations(config.repoSources);
295
+ await enforceRootKeys({ keys: await removeRevokedKeys(await readAllowedKeys()), reason: "repo" });
296
+ await enforceSSHDConfig();
297
+
298
+ // configureDiscordNotifications watches the webhook file on its own, so there is nothing to
299
+ // schedule for it here.
300
+ startInterval({ name: "check", intervalTime: CHECK_INTERVAL, run: everyCheck });
301
+ }
302
+
303
+ process.on("uncaughtException", e => console.log(`Uncaught exception, staying up. ${e && e.stack || e}`));
304
+ process.on("unhandledRejection", e => console.log(`Unhandled rejection, staying up. ${e}`));
305
+ process.on("SIGTERM", () => {
306
+ console.log("Received SIGTERM, exiting");
307
+ process.exit(0);
308
+ });