sliftutils 1.7.124 → 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 (50) hide show
  1. package/CLAUDE.md +3 -1
  2. package/bin/derivekey.js +10 -0
  3. package/bin/portsecuredaemon.js +13 -0
  4. package/bin/securessh.js +10 -0
  5. package/bin/setupnotify.js +9 -0
  6. package/bin/signfiles.js +10 -0
  7. package/bin/unrevoke.js +9 -0
  8. package/package.json +14 -3
  9. package/security/README.md +141 -0
  10. package/security/authorizedKeys/authorizedKeys.ts +66 -0
  11. package/security/authorizedKeys/daemon/authLog.ts +117 -0
  12. package/security/authorizedKeys/daemon/daemon.ts +308 -0
  13. package/security/authorizedKeys/daemon/git.ts +119 -0
  14. package/security/authorizedKeys/daemon/notify.ts +25 -0
  15. package/security/authorizedKeys/daemon/paths.ts +26 -0
  16. package/security/authorizedKeys/daemon/portsecure.service +19 -0
  17. package/security/authorizedKeys/daemon/revocation.ts +306 -0
  18. package/security/authorizedKeys/daemon/rootKeys.ts +135 -0
  19. package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
  20. package/security/authorizedKeys/daemon/state.ts +108 -0
  21. package/security/authorizedKeys/daemon/trust.ts +291 -0
  22. package/security/authorizedKeys/daemon/userKeys.ts +76 -0
  23. package/security/authorizedKeys/dist/authorizedKeys.ts.cache +73 -0
  24. package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
  25. package/security/authorizedKeys/dist/secureSSH.ts.cache +552 -0
  26. package/security/authorizedKeys/dist/sources.ts.cache +24 -0
  27. package/security/authorizedKeys/dist/unrevoke.ts.cache +145 -0
  28. package/security/authorizedKeys/revokeSource.ts +40 -0
  29. package/security/authorizedKeys/secureSSH.ts +613 -0
  30. package/security/authorizedKeys/sources.ts +20 -0
  31. package/security/authorizedKeys/unrevoke.ts +149 -0
  32. package/security/helpers/dist/paths.ts.cache +28 -0
  33. package/security/helpers/dist/remoteSSH.ts.cache +90 -0
  34. package/security/helpers/dist/spawn.ts.cache +34 -0
  35. package/security/helpers/paths.ts +20 -0
  36. package/security/helpers/remoteSSH.ts +95 -0
  37. package/security/helpers/spawn.ts +36 -0
  38. package/security/keys/deriveKey.ts +72 -0
  39. package/security/keys/dist/deriveKey.ts.cache +72 -0
  40. package/security/keys/dist/sshKeyFile.ts.cache +153 -0
  41. package/security/keys/sshKeyFile.ts +156 -0
  42. package/security/notifications/discord.ts +190 -0
  43. package/security/notifications/dist/discord.ts.cache +180 -0
  44. package/security/notifications/remoteWebhook.ts +85 -0
  45. package/security/notifications/setupNotify.ts +29 -0
  46. package/security/signedFiles/dist/manifest.ts.cache +68 -0
  47. package/security/signedFiles/dist/signFiles.ts.cache +146 -0
  48. package/security/signedFiles/manifest.ts +69 -0
  49. package/security/signedFiles/signFiles.ts +151 -0
  50. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +17 -20
@@ -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
+ });
@@ -0,0 +1,119 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { spawnPromise } from "../../helpers/spawn";
4
+ import { sourceKeyPath, sourceRepoPath } from "../sources";
5
+ import { GIT_TIMEOUT, MAX_ERROR_BODY_LENGTH } from "./paths";
6
+ import { sourceState } from "./state";
7
+
8
+ async function pathExists(filePath: string) {
9
+ try {
10
+ await fs.access(filePath);
11
+ return true;
12
+ } catch (e) {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ /** core.sshCommand keeps the key selection with the command instead of in the environment. */
18
+ export async function runGit(config: { args: string[]; cwd?: string; keyPath: string; allowFailure?: boolean }) {
19
+ let { args, cwd, keyPath, allowFailure } = config;
20
+ let sshCommand = `ssh -i ${keyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
21
+ let result = await spawnPromise({
22
+ command: "git",
23
+ args: ["-c", `core.sshCommand=${sshCommand}`, ...args],
24
+ cwd,
25
+ timeoutTime: GIT_TIMEOUT,
26
+ });
27
+ if (result.error) {
28
+ throw new Error(`Expected git ${args.join(" ")} to run, failed with ${result.error.message}`);
29
+ }
30
+ if (result.status !== 0 && !allowFailure) {
31
+ throw new Error(
32
+ `Expected git ${args.join(" ")} to exit 0, was ${result.status}. `
33
+ + `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
34
+ );
35
+ }
36
+ return result;
37
+ }
38
+
39
+ export async function repoIsUsable(config: { repoPath: string; keyPath: string }) {
40
+ let { repoPath, keyPath } = config;
41
+ if (!await pathExists(path.join(repoPath, ".git"))) {
42
+ return false;
43
+ }
44
+ let result = await runGit({ args: ["rev-parse", "--git-dir"], cwd: repoPath, keyPath, allowFailure: true });
45
+ if (result.status !== 0) {
46
+ console.log(`Repo at ${repoPath} is not usable. ${(result.stdout + result.stderr).trim()}`);
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+
52
+ /** Cloned beside the old checkout and swapped in, so a clone that fails leaves the copy we are
53
+ already using untouched rather than deleting the only keys we have. */
54
+ export async function cloneRepo(config: { repoURL: string; repoPath: string; keyPath: string }) {
55
+ let { repoURL, repoPath, keyPath } = config;
56
+ let incomingPath = `${repoPath}.incoming`;
57
+ await fs.rm(incomingPath, { recursive: true, force: true });
58
+ await fs.mkdir(path.dirname(repoPath), { recursive: true });
59
+ await runGit({ args: ["clone", repoURL, incomingPath], keyPath });
60
+ await fs.rm(repoPath, { recursive: true, force: true });
61
+ await fs.rename(incomingPath, repoPath);
62
+ console.log(`Cloned ${repoURL} into ${repoPath}`);
63
+ }
64
+
65
+ export async function currentBranch(config: { repoPath: string; keyPath: string }) {
66
+ return (await runGit({ args: ["rev-parse", "--abbrev-ref", "HEAD"], ...config })).stdout.trim();
67
+ }
68
+
69
+ async function ensureSourceRepo(repoURL: string) {
70
+ let repoPath = sourceRepoPath(repoURL);
71
+ let keyPath = sourceKeyPath(repoURL);
72
+ if (!await repoIsUsable({ repoPath, keyPath })) {
73
+ await cloneRepo({ repoURL, repoPath, keyPath });
74
+ }
75
+ if (!sourceState(repoURL).branch) {
76
+ sourceState(repoURL).branch = await currentBranch({ repoPath, keyPath });
77
+ }
78
+ }
79
+
80
+ /** Returns what changed, so the caller can report it. A rewritten history is called out
81
+ separately - it means the remote no longer contains the commits we already had. */
82
+ export async function syncRepo(repoURL: string) {
83
+ await ensureSourceRepo(repoURL);
84
+ let repoPath = sourceRepoPath(repoURL);
85
+ let keyPath = sourceKeyPath(repoURL);
86
+ let branch = sourceState(repoURL).branch;
87
+ let localSha = (await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath, keyPath })).stdout.trim();
88
+
89
+ // A ref listing is a few hundred bytes and no objects, so the usual case of nothing having
90
+ // changed costs almost nothing and we only fetch when there is something to fetch.
91
+ let listing = (await runGit({ args: ["ls-remote", "origin", branch], cwd: repoPath, keyPath })).stdout;
92
+ let remoteSha = (listing.split(/\s+/)[0] || "").trim();
93
+ if (!remoteSha) {
94
+ throw new Error(`Expected origin to report a sha for ${branch}, listed ${listing.slice(0, MAX_ERROR_BODY_LENGTH)}`);
95
+ }
96
+ if (remoteSha === localSha && remoteSha === sourceState(repoURL).lastSha) {
97
+ return { changed: false, historyRewritten: false, remoteSha, previousSha: localSha };
98
+ }
99
+
100
+ await runGit({ args: ["fetch", "--prune", "origin", branch], cwd: repoPath, keyPath });
101
+ remoteSha = (await runGit({ args: ["rev-parse", `origin/${branch}`], cwd: repoPath, keyPath })).stdout.trim();
102
+
103
+ let previousSha = sourceState(repoURL).lastSha || localSha;
104
+ let historyRewritten = false;
105
+ if (previousSha && previousSha !== remoteSha) {
106
+ // If what we already had is no longer an ancestor of the remote tip, commits were removed
107
+ // or rewritten rather than added.
108
+ let ancestry = await runGit({
109
+ args: ["merge-base", "--is-ancestor", previousSha, remoteSha],
110
+ cwd: repoPath,
111
+ keyPath,
112
+ allowFailure: true,
113
+ });
114
+ historyRewritten = ancestry.status !== 0;
115
+ }
116
+ await runGit({ args: ["reset", "--hard", `origin/${branch}`], cwd: repoPath, keyPath });
117
+ await runGit({ args: ["clean", "-fdx"], cwd: repoPath, keyPath });
118
+ return { changed: true, historyRewritten, remoteSha, previousSha };
119
+ }
@@ -0,0 +1,25 @@
1
+ import os from "os";
2
+ import { sendDiscordNotification } from "../../notifications/discord";
3
+
4
+ let hostLabelValue = "";
5
+
6
+ export function setHostLabel(value: string) {
7
+ hostLabelValue = value;
8
+ }
9
+
10
+ // DO NOT add new calls to this. Every message goes to a real Discord server someone reads, so a
11
+ // notification is only ever added when the user explicitly asks for that specific case. Startup,
12
+ // success, errors, retries and recoveries all belong in console.log instead. The complete list of
13
+ // cases that are allowed to notify is at the top of daemon.ts.
14
+ export async function notify(message: string) {
15
+ let full = `**portsecure [${hostLabelValue || os.hostname()}]**: ${message}`;
16
+ // Logged before it is sent, and whether or not it arrives, so the journal is a complete record
17
+ // of what this machine had to say even when Discord is unreachable or the webhook is wrong.
18
+ console.log(`Discord: ${full}`);
19
+ try {
20
+ await sendDiscordNotification(full);
21
+ } catch (e) {
22
+ // A failed notification must never take the daemon down, the local log is the fallback.
23
+ console.log(`Failed to send the Discord notification above. ${e}`);
24
+ }
25
+ }
@@ -0,0 +1,26 @@
1
+ // Locations the daemon owns. Fixed rather than configurable, so every machine looks the same and
2
+ // the config file only carries what genuinely differs between them.
3
+
4
+ export const CONFIG_PATH = "/etc/portsecure/daemon.json";
5
+ export const STATE_PATH = "/var/lib/portsecure/state.json";
6
+ export const KEYS_HISTORY_PATH = "/var/lib/portsecure/authorized-keys-history";
7
+ export const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
8
+ export const SSHD_CONFIG_PATH = "/etc/ssh/sshd_config";
9
+ export const SSHD_DROPIN_DIR = "/etc/ssh/sshd_config.d";
10
+ export const SSHD_DROPIN_PATH = "/etc/ssh/sshd_config.d/00-portsecure.conf";
11
+ export const PASSWD_PATH = "/etc/passwd";
12
+ export const AUTH_LOG_PATH = "/var/log/auth.log";
13
+
14
+ export const CHECK_INTERVAL = 60 * 1000;
15
+ export const WEBHOOK_CHECK_INTERVAL = 5 * 60 * 1000;
16
+ export const GIT_TIMEOUT = 120 * 1000;
17
+ export const MAX_ERROR_BODY_LENGTH = 500;
18
+ // A source that starts being signed by someone new is held at arm's length for this long, so a
19
+ // stolen signing key cannot push keys onto a machine before anyone notices the warning.
20
+ export const SIGNER_CHANGE_DELAY = 24 * 60 * 60 * 1000;
21
+ // An unrevoke waits this long before taking effect, so a compromised signing key cannot instantly
22
+ // undo the revocation that locked it out.
23
+ export const UNREVOKE_DELAY = 60 * 60 * 1000;
24
+ // After this many consecutive failures a repo is thrown away and cloned from scratch, which
25
+ // recovers from corruption and interrupted fetches. Counted in checks, so about a quarter hour.
26
+ export const MAX_REPO_FAILURES_BEFORE_RECLONE = 15;
@@ -0,0 +1,19 @@
1
+ [Unit]
2
+ Description=portsecure authorized keys daemon
3
+ Documentation=https://github.com/sliftist/sliftutils
4
+ After=network-online.target
5
+ Wants=network-online.target
6
+
7
+ [Service]
8
+ Type=simple
9
+ ExecStart=/usr/bin/env node /opt/portsecure/sliftutils/bin/portsecuredaemon.js
10
+ WorkingDirectory=/opt/portsecure/sliftutils
11
+ User=root
12
+ # Availability is the point of this daemon, so it always comes back.
13
+ Restart=always
14
+ RestartSec=10
15
+ StandardOutput=journal
16
+ StandardError=journal
17
+
18
+ [Install]
19
+ WantedBy=multi-user.target