sliftutils 1.7.123 → 1.7.125
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -1
- package/bin/securessh.js +10 -0
- package/bin/setupnotify.js +9 -0
- package/bin/signfiles.js +10 -0
- package/package.json +9 -3
- package/security/README.md +77 -0
- package/security/authorizedKeys/authorizedKeys.ts +43 -0
- package/security/authorizedKeys/daemon/portsecure.service +18 -0
- package/security/authorizedKeys/daemon/portsecureDaemon.js +1032 -0
- package/security/authorizedKeys/secureSSH.ts +427 -0
- package/security/authorizedKeys/sources.ts +20 -0
- package/security/helpers/paths.ts +20 -0
- package/security/helpers/remoteSSH.ts +94 -0
- package/security/helpers/spawn.ts +32 -0
- package/security/notifications/discord.ts +190 -0
- package/security/notifications/remoteWebhook.ts +85 -0
- package/security/notifications/setupNotify.ts +29 -0
- package/security/signedFiles/manifest.ts +56 -0
- package/security/signedFiles/signFiles.ts +116 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +16 -20
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +17 -20
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
|
|
3
|
+
// PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js embeds a hand port of this file in plain JS, so the
|
|
4
|
+
// daemon can run with no dependencies. The two are expected to behave identically - if you change
|
|
5
|
+
// one, make the matching change in the other.
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_WEBHOOK_FILE_PATH = "/etc/portsecure/discord-webhook";
|
|
8
|
+
const DEFAULT_CHECK_INTERVAL = 5 * 60 * 1000;
|
|
9
|
+
const VALID_WEBHOOK_PREFIXES = [
|
|
10
|
+
"https://discord.com/api/webhooks/",
|
|
11
|
+
"https://discordapp.com/api/webhooks/",
|
|
12
|
+
"https://canary.discord.com/api/webhooks/",
|
|
13
|
+
];
|
|
14
|
+
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
15
|
+
const MAX_ERROR_BODY_LENGTH = 500;
|
|
16
|
+
const MAX_SEND_ATTEMPTS = 3;
|
|
17
|
+
const REDACTED_TOKEN_VISIBLE = 8;
|
|
18
|
+
const DEFAULT_RATE_LIMIT_WAIT = 2 * 1000;
|
|
19
|
+
|
|
20
|
+
let notificationState: {
|
|
21
|
+
filePath: string;
|
|
22
|
+
webhookURL: string;
|
|
23
|
+
checkTimer: NodeJS.Timeout;
|
|
24
|
+
} | undefined;
|
|
25
|
+
|
|
26
|
+
// Discord webhooks are rate limited (roughly 5 requests per 2 seconds), so every send
|
|
27
|
+
// goes through one chain instead of racing.
|
|
28
|
+
let sendChain: Promise<unknown> = Promise.resolve();
|
|
29
|
+
|
|
30
|
+
/** Pulls the webhook URL out of webhook file contents. `sourceName` only appears in errors, so
|
|
31
|
+
callers can name a remote path. */
|
|
32
|
+
export function parseWebhookFile(config: { contents: string; sourceName: string }) {
|
|
33
|
+
let { contents, sourceName } = config;
|
|
34
|
+
let lines = contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
35
|
+
let webhookURL = lines[0];
|
|
36
|
+
if (!webhookURL) {
|
|
37
|
+
throw new Error(`Expected a Discord webhook URL in ${sourceName}, the file has no usable lines`);
|
|
38
|
+
}
|
|
39
|
+
if (!VALID_WEBHOOK_PREFIXES.some(prefix => webhookURL.startsWith(prefix))) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Expected a Discord webhook URL starting with one of ${VALID_WEBHOOK_PREFIXES.join(", ")}, `
|
|
42
|
+
+ `was ${webhookURL.slice(0, MAX_ERROR_BODY_LENGTH)} (in ${sourceName})`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return webhookURL;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Keeps the id and both ends of the token, so a reader can confirm which webhook replaced theirs
|
|
49
|
+
without receiving one they could post to. */
|
|
50
|
+
export function redactWebhookURL(webhookURL: string) {
|
|
51
|
+
let separatorIndex = webhookURL.lastIndexOf("/");
|
|
52
|
+
let base = webhookURL.slice(0, separatorIndex + 1);
|
|
53
|
+
let token = webhookURL.slice(separatorIndex + 1);
|
|
54
|
+
if (token.length <= REDACTED_TOKEN_VISIBLE * 2) {
|
|
55
|
+
// Too short to show both ends without giving away the whole token.
|
|
56
|
+
return `${base}${token.slice(0, REDACTED_TOKEN_VISIBLE)}...`;
|
|
57
|
+
}
|
|
58
|
+
return `${base}${token.slice(0, REDACTED_TOKEN_VISIBLE)}...${token.slice(-REDACTED_TOKEN_VISIBLE)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatWebhookFile(webhookURL: string) {
|
|
62
|
+
return `# portsecure Discord webhook. First non-comment line is used.\n${webhookURL}\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readWebhookFile(filePath: string) {
|
|
66
|
+
let contents;
|
|
67
|
+
try {
|
|
68
|
+
contents = await fs.readFile(filePath, "utf8");
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new Error(`Expected a readable Discord webhook file at ${filePath}, ${e}`);
|
|
71
|
+
}
|
|
72
|
+
return parseWebhookFile({ contents, sourceName: filePath });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function postToWebhook(webhookURL: string, message: string) {
|
|
76
|
+
let content = message;
|
|
77
|
+
if (content.length > DISCORD_MESSAGE_LIMIT) {
|
|
78
|
+
content = content.slice(0, DISCORD_MESSAGE_LIMIT - 3) + "...";
|
|
79
|
+
}
|
|
80
|
+
for (let attempt = 1; attempt <= MAX_SEND_ATTEMPTS; attempt++) {
|
|
81
|
+
let response = await fetch(webhookURL, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify({ content }),
|
|
85
|
+
});
|
|
86
|
+
if (response.ok) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let body = (await response.text()).slice(0, MAX_ERROR_BODY_LENGTH);
|
|
90
|
+
if (response.status === 429 && attempt < MAX_SEND_ATTEMPTS) {
|
|
91
|
+
let retryAfter = Number(response.headers.get("retry-after"));
|
|
92
|
+
let waitTime = retryAfter && retryAfter * 1000 || DEFAULT_RATE_LIMIT_WAIT;
|
|
93
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Expected a 2xx response from the Discord webhook, was ${response.status} ${response.statusText}, body ${body}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function queueSend(webhookURL: string, message: string) {
|
|
103
|
+
let send = () => postToWebhook(webhookURL, message);
|
|
104
|
+
let result = sendChain.then(send, send);
|
|
105
|
+
sendChain = result.catch(() => undefined);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Sends to an explicit webhook URL, for tooling that acts on a webhook before (or instead of)
|
|
110
|
+
configureDiscordNotifications - setup, migrations, connectivity checks. */
|
|
111
|
+
export function sendToWebhookURL(config: { webhookURL: string; message: string }) {
|
|
112
|
+
return queueSend(config.webhookURL, config.message);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function checkWebhookFileChanged() {
|
|
116
|
+
let state = notificationState;
|
|
117
|
+
if (!state) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
let newWebhookURL;
|
|
121
|
+
try {
|
|
122
|
+
newWebhookURL = await readWebhookFile(state.filePath);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.error(`portsecure: Discord webhook file is no longer readable, still using the loaded webhook. ${e}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (newWebhookURL === state.webhookURL) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Warn the old channel first, with the new webhook redacted, so a stolen old webhook does not
|
|
131
|
+
// hand the attacker a usable new one.
|
|
132
|
+
try {
|
|
133
|
+
await queueSend(
|
|
134
|
+
state.webhookURL,
|
|
135
|
+
`**portsecure**: the Discord webhook in \`${state.filePath}\` changed to`
|
|
136
|
+
+ ` \`${redactWebhookURL(newWebhookURL)}\`.`
|
|
137
|
+
+ ` Notifications are moving to the new webhook and this channel will stop receiving them.`
|
|
138
|
+
);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.error(`portsecure: failed to warn the old Discord webhook about the change. ${e}`);
|
|
141
|
+
}
|
|
142
|
+
state.webhookURL = newWebhookURL;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Must be called once on startup, before any notification is sent. Aborts the process if the
|
|
146
|
+
webhook file is missing or invalid, then re-checks the file on an interval and warns the old
|
|
147
|
+
webhook whenever it changes. */
|
|
148
|
+
export async function configureDiscordNotifications(config?: {
|
|
149
|
+
filePath?: string;
|
|
150
|
+
checkInterval?: number;
|
|
151
|
+
}) {
|
|
152
|
+
if (notificationState) {
|
|
153
|
+
throw new Error(`Expected configureDiscordNotifications to be called once, was called again (already using ${notificationState.filePath})`);
|
|
154
|
+
}
|
|
155
|
+
let filePath = config?.filePath || DEFAULT_WEBHOOK_FILE_PATH;
|
|
156
|
+
let checkInterval = config?.checkInterval || DEFAULT_CHECK_INTERVAL;
|
|
157
|
+
let webhookURL;
|
|
158
|
+
try {
|
|
159
|
+
webhookURL = await readWebhookFile(filePath);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.error(`portsecure: refusing to start without a valid Discord webhook file.\n${e}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
let checkTimer = setInterval(() => {
|
|
165
|
+
checkWebhookFileChanged().catch(e => console.error(`portsecure: Discord webhook file check failed. ${e}`));
|
|
166
|
+
}, checkInterval);
|
|
167
|
+
// The check should never be the reason the process stays alive.
|
|
168
|
+
checkTimer.unref();
|
|
169
|
+
notificationState = { filePath, webhookURL, checkTimer };
|
|
170
|
+
return { filePath, checkInterval };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// DO NOT add new calls to this. Every message goes to a real Discord server someone reads, so a
|
|
174
|
+
// notification is only ever added when the user explicitly asks for that specific case. Startup,
|
|
175
|
+
// success, errors, retries and recoveries all belong in a log instead.
|
|
176
|
+
export async function sendDiscordNotification(message: string) {
|
|
177
|
+
let state = notificationState;
|
|
178
|
+
if (!state) {
|
|
179
|
+
throw new Error(`Expected configureDiscordNotifications to be called before sending, was called with message ${message.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
180
|
+
}
|
|
181
|
+
await queueSend(state.webhookURL, message);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function stopDiscordNotifications() {
|
|
185
|
+
if (!notificationState) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
clearInterval(notificationState.checkTimer);
|
|
189
|
+
notificationState = undefined;
|
|
190
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DEFAULT_WEBHOOK_FILE_PATH, formatWebhookFile, parseWebhookFile, redactWebhookURL, sendToWebhookURL } from "./discord";
|
|
2
|
+
import { readRemoteFile, writeRemoteFile } from "../helpers/remoteSSH";
|
|
3
|
+
|
|
4
|
+
export const REPLACE_KEYWORD = "replace";
|
|
5
|
+
const UNPARSEABLE_LABEL = "(unparseable)";
|
|
6
|
+
|
|
7
|
+
/** Puts a webhook on a remote host, refusing to clobber a different existing one unless asked.
|
|
8
|
+
Returns what happened, so callers can decide whether to keep going. */
|
|
9
|
+
export async function ensureRemoteWebhook(config: {
|
|
10
|
+
host: string;
|
|
11
|
+
webhookURL: string;
|
|
12
|
+
replace: boolean;
|
|
13
|
+
filePath?: string;
|
|
14
|
+
}) {
|
|
15
|
+
let { host, webhookURL, replace } = config;
|
|
16
|
+
let filePath = config.filePath || DEFAULT_WEBHOOK_FILE_PATH;
|
|
17
|
+
|
|
18
|
+
let replacedURL = "";
|
|
19
|
+
let existingContents = await readRemoteFile({ host, filePath });
|
|
20
|
+
if (existingContents) {
|
|
21
|
+
// A corrupt existing file still counts as a conflict, so "replace" can repair it instead
|
|
22
|
+
// of the setup dead ending on a parse error.
|
|
23
|
+
let existingURL = UNPARSEABLE_LABEL;
|
|
24
|
+
try {
|
|
25
|
+
existingURL = parseWebhookFile({ contents: existingContents, sourceName: `${host}:${filePath}` });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(`${host}:${filePath} exists but could not be parsed. ${e}`);
|
|
28
|
+
}
|
|
29
|
+
if (existingURL === webhookURL) {
|
|
30
|
+
return { outcome: "unchanged" as const, filePath };
|
|
31
|
+
}
|
|
32
|
+
if (!replace) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Expected no conflicting webhook on ${host}, but ${filePath} already holds a different one.\n`
|
|
35
|
+
+ `Existing: ${existingURL}\n`
|
|
36
|
+
+ `New: ${webhookURL}\n`
|
|
37
|
+
+ `Pass "${REPLACE_KEYWORD}" to overwrite it.`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
replacedURL = existingURL;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await writeRemoteFile({
|
|
44
|
+
host,
|
|
45
|
+
filePath,
|
|
46
|
+
contents: formatWebhookFile(webhookURL),
|
|
47
|
+
fileMode: "600",
|
|
48
|
+
directoryMode: "700",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let writtenContents = await readRemoteFile({ host, filePath });
|
|
52
|
+
if (!writtenContents) {
|
|
53
|
+
throw new Error(`Expected ${filePath} to exist on ${host} after writing, no such file exists`);
|
|
54
|
+
}
|
|
55
|
+
let writtenURL = parseWebhookFile({ contents: writtenContents, sourceName: `${host}:${filePath}` });
|
|
56
|
+
if (writtenURL !== webhookURL) {
|
|
57
|
+
throw new Error(`Expected ${host}:${filePath} to hold the new webhook, was ${writtenURL}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (replacedURL && replacedURL !== UNPARSEABLE_LABEL) {
|
|
61
|
+
// The new webhook is redacted, so this channel can identify the replacement without
|
|
62
|
+
// receiving a webhook it could post to.
|
|
63
|
+
try {
|
|
64
|
+
await sendToWebhookURL({
|
|
65
|
+
webhookURL: replacedURL,
|
|
66
|
+
message: `**portsecure**: the Discord webhook for \`${host}\` was replaced with`
|
|
67
|
+
+ ` \`${redactWebhookURL(webhookURL)}\`.`
|
|
68
|
+
+ ` This channel will stop receiving notifications for that host.`,
|
|
69
|
+
});
|
|
70
|
+
} catch (e) {
|
|
71
|
+
// The replacement already happened, so a dead old webhook must not fail the setup.
|
|
72
|
+
console.error(`Could not notify the old webhook that it was replaced. ${e}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Failing here means the file is in place but the webhook itself does not work, which is
|
|
77
|
+
// exactly what the operator needs to hear about.
|
|
78
|
+
await sendToWebhookURL({
|
|
79
|
+
webhookURL,
|
|
80
|
+
message: `**portsecure**: notifications are now configured for \`${host}\`.`
|
|
81
|
+
+ ` This channel will receive its security notifications.`,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return { outcome: replacedURL && "replaced" as const || "created" as const, filePath };
|
|
85
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { parseWebhookFile } from "./discord";
|
|
2
|
+
import { ensureRemoteWebhook, REPLACE_KEYWORD } from "./remoteWebhook";
|
|
3
|
+
|
|
4
|
+
const USAGE = `Usage: yarn setupnotify <host> <discord-webhook-url> [${REPLACE_KEYWORD}]`;
|
|
5
|
+
|
|
6
|
+
function parseArgs(argv: string[]) {
|
|
7
|
+
let replace = argv.includes(REPLACE_KEYWORD);
|
|
8
|
+
let positional = argv.filter(arg => arg !== REPLACE_KEYWORD);
|
|
9
|
+
if (positional.length !== 2) {
|
|
10
|
+
throw new Error(`Expected a host and a webhook URL, was ${positional.length} argument(s): ${positional.join(" ") || "(none)"}\n${USAGE}`);
|
|
11
|
+
}
|
|
12
|
+
let [host, webhookURL] = positional;
|
|
13
|
+
// Validates the URL shape up front, so we never ssh anywhere with a bad webhook.
|
|
14
|
+
parseWebhookFile({ contents: webhookURL, sourceName: "the command line" });
|
|
15
|
+
return { host, webhookURL, replace };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function main() {
|
|
19
|
+
let { host, webhookURL, replace } = parseArgs(process.argv.slice(2));
|
|
20
|
+
let { outcome, filePath } = await ensureRemoteWebhook({ host, webhookURL, replace });
|
|
21
|
+
if (outcome === "unchanged") {
|
|
22
|
+
console.log(`${host} already has this exact webhook in ${filePath}, nothing to do.`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (outcome === "replaced") {
|
|
26
|
+
console.log(`Replaced the webhook on ${host} and notified the old one.`);
|
|
27
|
+
}
|
|
28
|
+
console.log(`Wrote ${filePath} on ${host} (mode 600), and confirmed on the new webhook.`);
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { spawnPromise } from "../helpers/spawn";
|
|
5
|
+
|
|
6
|
+
// PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js contains a plain JS port of the
|
|
7
|
+
// verifying half of this file, so it can check a signature with no dependencies. Both sides must
|
|
8
|
+
// agree on the manifest shape and on which files it covers - if you change one, make the matching
|
|
9
|
+
// change in the other.
|
|
10
|
+
|
|
11
|
+
export const MANIFEST_NAME = "signedfiles.json";
|
|
12
|
+
export const SIGNATURE_NAME = "signedfiles.json.sig";
|
|
13
|
+
// ssh signatures are namespaced, so a signature made for one purpose cannot be replayed as another.
|
|
14
|
+
export const SIGN_NAMESPACE = "signfiles";
|
|
15
|
+
export const MANIFEST_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
export type Manifest = {
|
|
18
|
+
version: number;
|
|
19
|
+
files: { path: string; size: number; sha256: string }[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Everything in the repo that is not ignored, which is exactly what a clone of it will contain.
|
|
23
|
+
The manifest and its signature are left out, since they cannot describe themselves. */
|
|
24
|
+
export async function listRepoFiles(repoPath: string) {
|
|
25
|
+
let result = await spawnPromise({
|
|
26
|
+
command: "git",
|
|
27
|
+
args: ["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
28
|
+
cwd: repoPath,
|
|
29
|
+
});
|
|
30
|
+
if (result.status !== 0) {
|
|
31
|
+
throw new Error(`Expected to list the files in ${repoPath}, git ls-files exited ${result.status}. ${result.stderr}`);
|
|
32
|
+
}
|
|
33
|
+
return result.stdout.split("\n")
|
|
34
|
+
.map(line => line.trim())
|
|
35
|
+
.filter(line => line && line !== MANIFEST_NAME && line !== SIGNATURE_NAME)
|
|
36
|
+
.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function hashFile(filePath: string) {
|
|
40
|
+
return crypto.createHash("sha256").update(await fs.readFile(filePath)).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function buildManifest(repoPath: string) {
|
|
44
|
+
let files: Manifest["files"] = [];
|
|
45
|
+
for (let relativePath of await listRepoFiles(repoPath)) {
|
|
46
|
+
let fullPath = path.join(repoPath, relativePath);
|
|
47
|
+
let stats = await fs.stat(fullPath);
|
|
48
|
+
files.push({ path: relativePath, size: stats.size, sha256: await hashFile(fullPath) });
|
|
49
|
+
}
|
|
50
|
+
return { version: MANIFEST_VERSION, files };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Sorted keys and a trailing newline, so the same tree always produces the same bytes to sign. */
|
|
54
|
+
export function formatManifest(manifest: Manifest) {
|
|
55
|
+
return JSON.stringify(manifest, undefined, 4) + "\n";
|
|
56
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { expandHome } from "../helpers/paths";
|
|
5
|
+
import { spawnPromise } from "../helpers/spawn";
|
|
6
|
+
import { buildManifest, formatManifest, MANIFEST_NAME, SIGNATURE_NAME, SIGN_NAMESPACE } from "./manifest";
|
|
7
|
+
|
|
8
|
+
// A hardware backed key is the entire point. A key sitting on disk is compromised the moment the
|
|
9
|
+
// machine is, and then the signature proves nothing, so this is what we make when asked to make one.
|
|
10
|
+
const DEFAULT_KEY_TYPE = "ed25519-sk";
|
|
11
|
+
const DEFAULT_KEY_PATH = "~/.ssh/signfiles_ed25519_sk";
|
|
12
|
+
const GIT_KEYWORD = "git";
|
|
13
|
+
const COMMIT_MESSAGE = "deploying signed files";
|
|
14
|
+
const MAX_ERROR_BODY_LENGTH = 500;
|
|
15
|
+
const USAGE = `Usage: yarn signfiles [signing-key] [${GIT_KEYWORD}]
|
|
16
|
+
|
|
17
|
+
Signs the files of the repo in the current directory. With no key, a hardware backed
|
|
18
|
+
${DEFAULT_KEY_TYPE} key at ${DEFAULT_KEY_PATH} is used, and created if it does not exist.
|
|
19
|
+
Pass ${GIT_KEYWORD} to also commit and push the result.`;
|
|
20
|
+
|
|
21
|
+
async function pathExists(filePath: string) {
|
|
22
|
+
try {
|
|
23
|
+
await fs.access(filePath);
|
|
24
|
+
return true;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function run(config: { command: string; args: string[]; cwd?: string; interactive?: boolean }) {
|
|
31
|
+
let { command, args, cwd, interactive } = config;
|
|
32
|
+
let result = await spawnPromise({ command, args, cwd, inheritStderr: interactive });
|
|
33
|
+
if (result.error) {
|
|
34
|
+
throw new Error(`Expected ${command} to run, failed with ${result.error.message}`);
|
|
35
|
+
}
|
|
36
|
+
if (result.status !== 0) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Expected ${command} ${args.join(" ")} to exit 0, was ${result.status}. `
|
|
39
|
+
+ `${(result.stderr || "").slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Creating this needs the security key plugged in, and a touch, so its output goes straight to
|
|
46
|
+
the terminal rather than being captured. */
|
|
47
|
+
async function ensureDefaultKey() {
|
|
48
|
+
let keyPath = expandHome(DEFAULT_KEY_PATH);
|
|
49
|
+
if (await pathExists(keyPath)) {
|
|
50
|
+
return keyPath;
|
|
51
|
+
}
|
|
52
|
+
console.log(`No signing key at ${keyPath}, creating an ${DEFAULT_KEY_TYPE} one.`);
|
|
53
|
+
console.log(`Plug your security key in - you will be asked to touch it.`);
|
|
54
|
+
await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
55
|
+
await run({
|
|
56
|
+
command: "ssh-keygen",
|
|
57
|
+
args: ["-t", DEFAULT_KEY_TYPE, "-f", keyPath, "-N", "", "-C", "signfiles"],
|
|
58
|
+
interactive: true,
|
|
59
|
+
});
|
|
60
|
+
return keyPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fingerprintOf(keyPath: string) {
|
|
64
|
+
let result = await run({ command: "ssh-keygen", args: ["-lf", `${keyPath}.pub`] });
|
|
65
|
+
let match = result.stdout.match(/(SHA256:[A-Za-z0-9+/=]+)/);
|
|
66
|
+
if (!match) {
|
|
67
|
+
throw new Error(`Expected a fingerprint for ${keyPath}.pub, was ${result.stdout.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
68
|
+
}
|
|
69
|
+
return match[1];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseArgs(argv: string[]) {
|
|
73
|
+
let pushToGit = argv.includes(GIT_KEYWORD);
|
|
74
|
+
let positional = argv.filter(arg => arg !== GIT_KEYWORD);
|
|
75
|
+
if (positional.length > 1) {
|
|
76
|
+
throw new Error(`Expected at most a signing key, was ${positional.length} argument(s): ${positional.join(" ")}\n${USAGE}`);
|
|
77
|
+
}
|
|
78
|
+
return { keyPath: positional[0], pushToGit };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function main() {
|
|
82
|
+
let { keyPath, pushToGit } = parseArgs(process.argv.slice(2));
|
|
83
|
+
|
|
84
|
+
let topLevel = await spawnPromise({ command: "git", args: ["rev-parse", "--show-toplevel"] });
|
|
85
|
+
if (topLevel.status !== 0) {
|
|
86
|
+
throw new Error(`Expected the current directory to be inside a git repo, it is not.\n${USAGE}`);
|
|
87
|
+
}
|
|
88
|
+
let repoPath = topLevel.stdout.trim();
|
|
89
|
+
|
|
90
|
+
let signingKey = keyPath && expandHome(keyPath) || await ensureDefaultKey();
|
|
91
|
+
if (!await pathExists(signingKey)) {
|
|
92
|
+
throw new Error(`Expected a signing key at ${signingKey}, no such file exists`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let manifest = await buildManifest(repoPath);
|
|
96
|
+
let manifestPath = path.join(repoPath, MANIFEST_NAME);
|
|
97
|
+
await fs.writeFile(manifestPath, formatManifest(manifest));
|
|
98
|
+
console.log(`${MANIFEST_NAME} covers ${manifest.files.length} file(s) in ${repoPath}`);
|
|
99
|
+
|
|
100
|
+
// Signing happens before any git work, so a failed push never costs a second touch of the key.
|
|
101
|
+
await run({
|
|
102
|
+
command: "ssh-keygen",
|
|
103
|
+
args: ["-Y", "sign", "-f", signingKey, "-n", SIGN_NAMESPACE, manifestPath],
|
|
104
|
+
interactive: true,
|
|
105
|
+
});
|
|
106
|
+
console.log(`Signed with ${await fingerprintOf(signingKey)}`);
|
|
107
|
+
|
|
108
|
+
if (!pushToGit) {
|
|
109
|
+
console.log(`Commit and push ${MANIFEST_NAME} and ${SIGNATURE_NAME} for anything to see them.`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await run({ command: "git", args: ["add", "-A"], cwd: repoPath, interactive: true });
|
|
113
|
+
await run({ command: "git", args: ["commit", "-m", COMMIT_MESSAGE], cwd: repoPath, interactive: true });
|
|
114
|
+
await run({ command: "git", args: ["push"], cwd: repoPath, interactive: true });
|
|
115
|
+
console.log(`Committed and pushed.`);
|
|
116
|
+
}
|
|
@@ -40,6 +40,10 @@ const ROLLOVER_BYTES = 5 * 1024 * 1024;
|
|
|
40
40
|
const MEMORY_WATCHDOG_INTERVAL_MS = 60 * 1000;
|
|
41
41
|
const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
|
|
42
42
|
const MAX_INDEX_RELOAD_ATTEMPTS = 3;
|
|
43
|
+
// A bulk file under this is "loose" - still worth rolling up into a bigger one. Half the target file size, because that is exactly where combining stops paying: merging two files that are each over half the target just splits back into two files again, so the file count (and with it the per-file key list every read holds in memory) does not drop. Under half, any two inputs fit in one output, so phase 2 always makes the count go down.
|
|
44
|
+
//
|
|
45
|
+
// NOT the target size itself. runPlannedMerge cuts a chunk BEFORE adding the key that would exceed the target, so every file it writes is under TARGET_FILE_BYTES - testing against the target would classify the entire collection as loose and re-merge all of it, forever.
|
|
46
|
+
const LOOSE_BULK_MAX_BYTES = TARGET_FILE_BYTES / 2;
|
|
43
47
|
const KEY_GROUP_BYTES = 800 * 1024 * 1024;
|
|
44
48
|
const DUP_THRESHOLD = 0.4;
|
|
45
49
|
// Whole-tier dedup short-circuit (start of phase 3): when the combined tier is over this size AND the overall key duplication fraction is over this threshold, fold every combined file in one merge instead of the per-key-group walk (which spaces merges 5 min apart — 16 h for 200 groups).
|
|
@@ -363,16 +367,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
363
367
|
return this.streamBytesOnDisk > bulkDatabase2Timing.streamFoldTriggerBytes;
|
|
364
368
|
}
|
|
365
369
|
|
|
366
|
-
// Stream files nobody can still be appending to,
|
|
367
|
-
// retirable - a fold may delete these the moment it has consumed them, instead of waiting out streamSealAgeMs. Includes our own sealed files and merge-carry output.
|
|
368
|
-
// ownerGone - nobody is left to fold these (a closed tab's leftovers, or a legacy name with no owner stamp), and until one is folded every reader has to decode all of it just to build the index. Worth folding at any size. Our own files are deliberately NOT in here: every merge pass seals ours, so counting them would fold on every tick; the streamFileMaxBytes rollover and the streamFoldTriggerBytes gate cover ours instead.
|
|
370
|
+
// Stream files nobody can still be appending to: our own sealed files, merge-carry output, and files whose owner is gone (a closed tab's leftovers, or a legacy name with no owner stamp). A fold may delete these the moment it has consumed them, instead of waiting out streamSealAgeMs.
|
|
369
371
|
//
|
|
370
372
|
// Foreign owners are probed over the sync channel. In Node there is no channel, so we cannot know - every foreign owner is assumed alive and the streamSealAgeMs rule stands.
|
|
371
373
|
//
|
|
372
374
|
// assumeSealed is for planning: a merge pass broadcasts a seal before it starts, so by the time it merges our current file IS final. The planner passes isSyncSupported() to predict that; anything deciding a real deletion passes false and goes by streamFileName as it actually stands.
|
|
373
|
-
private async findAbandonedStreams(streamFiles: StreamFileInfo[], assumeSealed: boolean): Promise<
|
|
375
|
+
private async findAbandonedStreams(streamFiles: StreamFileInfo[], assumeSealed: boolean): Promise<Set<string>> {
|
|
374
376
|
const retirable = new Set<string>();
|
|
375
|
-
const ownerGone = new Set<string>();
|
|
376
377
|
const hasForeignOwner = streamFiles.some(f => f.ownerId && f.ownerId !== writerId && f.ownerId !== MERGE_OUTPUT_OWNER);
|
|
377
378
|
const live = hasForeignOwner && await queryLiveWriters(this.name, bulkDatabase2Timing.liveWriterProbeMs) || undefined;
|
|
378
379
|
for (const f of streamFiles) {
|
|
@@ -382,12 +383,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
382
383
|
if (assumeSealed || f.fileName !== this.streamFileName) retirable.add(f.fileName);
|
|
383
384
|
continue;
|
|
384
385
|
}
|
|
385
|
-
if (!f.ownerId || live && !live.has(f.ownerId))
|
|
386
|
-
retirable.add(f.fileName);
|
|
387
|
-
ownerGone.add(f.fileName);
|
|
388
|
-
}
|
|
386
|
+
if (!f.ownerId || live && !live.has(f.ownerId)) retirable.add(f.fileName);
|
|
389
387
|
}
|
|
390
|
-
return
|
|
388
|
+
return retirable;
|
|
391
389
|
}
|
|
392
390
|
|
|
393
391
|
private async automaticCompactionAllowed(): Promise<boolean> {
|
|
@@ -858,7 +856,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
858
856
|
const streamReader = ordered.length ? streamReaderFromEntries(ordered, 0).reader : undefined;
|
|
859
857
|
|
|
860
858
|
// An abandoned stream that yielded no entries (zero bytes, or nothing but torn bytes) holds no data and has no writer left. Retire it here: the merge below never lists it as a used source, so the normal retirement path would skip it and it would re-trigger the abandoned-stream fold on every pass. replacedBy is empty because nothing supersedes it - the marker hides it from reads immediately and processMarkers deletes it once aged.
|
|
861
|
-
const
|
|
859
|
+
const retirableStreams = await this.findAbandonedStreams(streamFiles, false);
|
|
862
860
|
const contributingStreams = new Set(streamData.entries.map(e => e.fileName));
|
|
863
861
|
const emptyAbandoned = streamFiles.filter(f => retirableStreams.has(f.fileName) && !contributingStreams.has(f.fileName)).map(f => f.fileName);
|
|
864
862
|
if (emptyAbandoned.length) await writeDeleteMarker(storage, { deleteFiles: emptyAbandoned, replacedBy: [] });
|
|
@@ -966,7 +964,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
966
964
|
return true;
|
|
967
965
|
}
|
|
968
966
|
|
|
969
|
-
// Splits the bulk tier
|
|
967
|
+
// Splits the bulk tier into files still worth rolling up ("loose", under LOOSE_BULK_MAX_BYTES - a single stream fold, or the tail end of an earlier merge) and files that are done growing ("combined", which only phase 3 touches again).
|
|
970
968
|
//
|
|
971
969
|
// A file whose size won't read is reported as combined: phase 2 can't consume one (its reader won't load, so the merge won't retire it), and calling it loose would re-trigger phase 2 on every pass until handleUnreadableFile finally deletes it.
|
|
972
970
|
private async splitBulkTier(bulkFiles: BulkFileInfo[]): Promise<{ loose: BulkFileInfo[]; looseBytes: number; combined: BulkFileInfo[]; sizes: Map<string, number> }> {
|
|
@@ -978,7 +976,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
978
976
|
for (let i = 0; i < bulkFiles.length; i++) {
|
|
979
977
|
const bytes = logicalSizes[i];
|
|
980
978
|
sizes.set(bulkFiles[i].fileName, bytes ?? 0);
|
|
981
|
-
if (bytes === undefined || bytes >=
|
|
979
|
+
if (bytes === undefined || bytes >= LOOSE_BULK_MAX_BYTES) {
|
|
982
980
|
combined.push(bulkFiles[i]);
|
|
983
981
|
continue;
|
|
984
982
|
}
|
|
@@ -1064,28 +1062,26 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1064
1062
|
});
|
|
1065
1063
|
|
|
1066
1064
|
// A pass seals before it merges, so by then our own current file is final too - predict that rather than reporting it as still-open.
|
|
1067
|
-
const
|
|
1065
|
+
const retirable = await this.findAbandonedStreams(streamFiles, isSyncSupported());
|
|
1068
1066
|
// Only fold what we can also retire. Folding a stream a live foreign owner may still append to would copy it into bulk without removing it, so the bytes would stay in memory and just get re-folded next pass; that owner rolls its own file over at streamFileMaxBytes instead.
|
|
1069
1067
|
// Aged past streamSealAgeMs counts as retirable too (canDeleteStream's own first rule): no writer appends past the seal age, and this is the only thing that frees the tier in Node, where liveness cannot be probed at all.
|
|
1070
1068
|
const foldable = streamFiles.filter(f => f.ownerId !== MERGE_OUTPUT_OWNER
|
|
1071
1069
|
&& (retirable.has(f.fileName) || time - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs));
|
|
1072
1070
|
// Merge-carry files hold nothing but tombstones and are never a REASON to fold - folding one alone would just rewrite it into another carry file, forever. They ride along whenever something else folds, which collapses however many have piled up into one.
|
|
1073
1071
|
const carry = streamFiles.filter(f => f.ownerId === MERGE_OUTPUT_OWNER);
|
|
1074
|
-
//
|
|
1075
|
-
const abandoned = foldable.filter(f => ownerGone.has(f.fileName) && streamSizes.get(f.fileName));
|
|
1072
|
+
// Size is the only reason to fold. An abandoned stream is NOT one on its own: it is already retirable, so it is counted here and gets swept up the moment the tier is worth folding, and until then it is bounded by this very threshold. Triggering on one would fold on essentially every pass - a browser leaves a file behind whose writer never answers the liveness probe on every reload - which mints a small bulk file each time and pushes the fragmentation into phase 2.
|
|
1076
1073
|
const foldTriggers = [
|
|
1077
1074
|
makeTrigger({ name: "foldableBytes", value: streamBytes(foldable), threshold: bulkDatabase2Timing.streamFoldTriggerBytes, unit: "bytes" }),
|
|
1078
|
-
makeTrigger({ name: "abandonedFiles", value: abandoned.length, threshold: 1, unit: "count" }),
|
|
1079
1075
|
];
|
|
1080
1076
|
steps.push({
|
|
1081
|
-
phase: 1, kind: "streamFold", requires: "
|
|
1077
|
+
phase: 1, kind: "streamFold", requires: "all", triggers: foldTriggers,
|
|
1082
1078
|
// Skipped when the hard limit already folds everything this would have.
|
|
1083
|
-
ready: !hardLimit.met && foldTriggers.
|
|
1079
|
+
ready: !hardLimit.met && foldTriggers.every(t => t.met),
|
|
1084
1080
|
bulkFiles: [], streamFiles: [...foldable, ...carry], bytes: streamBytes([...foldable, ...carry]),
|
|
1085
1081
|
});
|
|
1086
1082
|
|
|
1087
1083
|
// ── Phase 2: loose bulk -> combined bulk ─────────────────────────────────────────────────────
|
|
1088
|
-
// Phase 1 emits one small bulk file per fold. Each is cheap to read (index only) but holds its whole key list in memory and joins into every read, so they have to be rolled up. Merging just the loose ones also dedupes them for free - a rewrite-heavy workload collapses a gigabyte of near-identical folds into almost nothing - and it always terminates
|
|
1084
|
+
// Phase 1 emits one small bulk file per fold. Each is cheap to read (index only) but holds its whole key list in memory and joins into every read, so they have to be rolled up. Merging just the loose ones also dedupes them for free - a rewrite-heavy workload collapses a gigabyte of near-identical folds into almost nothing - and it always terminates: a chunk is only cut once the next key would take it past TARGET_FILE_BYTES, so every output but the last is over half the target and lands in the combined tier, leaving at most one loose file behind.
|
|
1089
1085
|
const { loose, looseBytes, combined, sizes } = await this.splitBulkTier(bulkFiles);
|
|
1090
1086
|
const looseTriggers = [
|
|
1091
1087
|
makeTrigger({ name: "looseBytes", value: looseBytes, threshold: bulkDatabase2Timing.looseBulkTriggerBytes, unit: "bytes" }),
|