@stacksjs/buddy 0.70.228 → 0.70.230
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/dist/commands/build.js +4 -2
- package/dist/commands/create.js +28 -10
- package/dist/commands/deploy.js +8 -1
- package/dist/commands/email.js +2 -8
- package/dist/commands/features.d.ts +3 -3
- package/dist/commands/features.js +20 -2
- package/dist/commands/mail.js +766 -2
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +28 -5
- package/package.json +1 -1
package/dist/commands/build.js
CHANGED
|
@@ -96,7 +96,8 @@ export function build(buddy) {
|
|
|
96
96
|
});
|
|
97
97
|
buddy.command("build:components", "Automagically build component libraries for production use & npm/CDN distribution").alias("prod:components").option("-c, --components", descriptions.components, { default: !0 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
|
|
98
98
|
log.debug("Running `buddy build:components` ...", options);
|
|
99
|
-
await
|
|
99
|
+
if (!await runBuildAction(Action.BuildComponentLibs, "component libraries"))
|
|
100
|
+
process.exit(ExitCode.FatalError);
|
|
100
101
|
});
|
|
101
102
|
buddy.command("build:cli", descriptions.cli).alias("prod:cli").option("-b, --buddy", descriptions.buddy, { default: !0 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
|
|
102
103
|
log.debug("Running `buddy build:cli` ...", options);
|
|
@@ -114,7 +115,8 @@ export function build(buddy) {
|
|
|
114
115
|
default: !0
|
|
115
116
|
}).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
|
|
116
117
|
log.debug("Running `buddy build:web-components` ...", options);
|
|
117
|
-
await
|
|
118
|
+
if (!await runBuildAction(Action.BuildWebComponentLib, "web component library"))
|
|
119
|
+
process.exit(ExitCode.FatalError);
|
|
118
120
|
});
|
|
119
121
|
buddy.command("build:frontend", descriptions.pages).alias("build:pages").alias("build:views").alias("prod:frontend").option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
|
|
120
122
|
log.debug("Running `buddy build:frontend` ...", options);
|
package/dist/commands/create.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { runAction } from "@stacksjs/actions";
|
|
4
4
|
import { bold, cyan, dim, intro, log, onUnknownSubcommand, runCommand } from "@stacksjs/cli";
|
|
@@ -24,9 +24,10 @@ export function create(buddy) {
|
|
|
24
24
|
email: "Do you need email?",
|
|
25
25
|
project: "Target a specific project",
|
|
26
26
|
minimal: "Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) \u2014 bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",
|
|
27
|
+
withCore: "Keep the framework vendored in `storage/framework/core` as a Bun workspace, for working ON Stacks. Apps that only work WITH Stacks want the default, which resolves every @stacksjs/* package from npm.",
|
|
27
28
|
verbose: "Enable verbose output"
|
|
28
29
|
};
|
|
29
|
-
buddy.command("new [name]", descriptions.command).alias("create [name]").option("-n, --name [name]", descriptions.name, { default: !1 }).option("-u, --ui", descriptions.ui, { default: !0 }).option("-c, --components", descriptions.components, { default: !0 }).option("-w, --web-components", descriptions.webComponents, { default: !0 }).option("-p, --views", descriptions.views, { default: !0 }).option("-f, --functions", descriptions.functions, { default: !0 }).option("-a, --api", descriptions.api, { default: !0 }).option("-d, --database", descriptions.database, { default: !0 }).option("-ca, --cache", descriptions.cache, { default: !1 }).option("-e, --email", descriptions.email, { default: !1 }).option("-P, --project [project]", descriptions.project, { default: !1 }).option("-m, --minimal", descriptions.minimal, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (name, options) => {
|
|
30
|
+
buddy.command("new [name]", descriptions.command).alias("create [name]").option("-n, --name [name]", descriptions.name, { default: !1 }).option("-u, --ui", descriptions.ui, { default: !0 }).option("-c, --components", descriptions.components, { default: !0 }).option("-w, --web-components", descriptions.webComponents, { default: !0 }).option("-p, --views", descriptions.views, { default: !0 }).option("-f, --functions", descriptions.functions, { default: !0 }).option("-a, --api", descriptions.api, { default: !0 }).option("-d, --database", descriptions.database, { default: !0 }).option("-ca, --cache", descriptions.cache, { default: !1 }).option("-e, --email", descriptions.email, { default: !1 }).option("-P, --project [project]", descriptions.project, { default: !1 }).option("-m, --minimal", descriptions.minimal, { default: !1 }).option("--with-core", descriptions.withCore, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (name, options) => {
|
|
30
31
|
log.debug("Running `buddy new <name>` ...", options);
|
|
31
32
|
const startTime = await intro("buddy new");
|
|
32
33
|
name = name ?? options.name;
|
|
@@ -41,6 +42,8 @@ export function create(buddy) {
|
|
|
41
42
|
ensureExecutableScripts(path);
|
|
42
43
|
await ensureEnv(path, options);
|
|
43
44
|
await install(path, options);
|
|
45
|
+
if (!options.withCore)
|
|
46
|
+
await unvendorCore(path, options);
|
|
44
47
|
if (options.minimal)
|
|
45
48
|
await stripFeatures(path);
|
|
46
49
|
if (startTime) {
|
|
@@ -56,10 +59,12 @@ export function create(buddy) {
|
|
|
56
59
|
onUnknownSubcommand(buddy, "new");
|
|
57
60
|
}
|
|
58
61
|
function isFolderCheck(path) {
|
|
59
|
-
if (isFolder(path))
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
if (!isFolder(path))
|
|
63
|
+
return;
|
|
64
|
+
if (readdirSync(path).filter((entry) => entry !== ".git").length === 0)
|
|
65
|
+
return;
|
|
66
|
+
log.error(`Path ${path} already exists`);
|
|
67
|
+
process.exit(ExitCode.FatalError);
|
|
63
68
|
}
|
|
64
69
|
async function onlineCheck() {
|
|
65
70
|
if (await isOnline())
|
|
@@ -82,7 +87,7 @@ async function download(name, path, _options) {
|
|
|
82
87
|
log.info("Setting up your stack.");
|
|
83
88
|
try {
|
|
84
89
|
const { downloadTemplate } = await import("@stacksjs/gitit");
|
|
85
|
-
await downloadTemplate("gh:stacksjs/stacks", { dir: name });
|
|
90
|
+
await downloadTemplate("gh:stacksjs/stacks", { dir: name, force: !0 });
|
|
86
91
|
log.success(`Successfully scaffolded your project at ${cyan(path)}`);
|
|
87
92
|
return { isErr: !1 };
|
|
88
93
|
} catch (error) {
|
|
@@ -119,13 +124,26 @@ async function install(path, options) {
|
|
|
119
124
|
log.error(keyResult.error);
|
|
120
125
|
process.exit(ExitCode.FatalError);
|
|
121
126
|
}
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
if (existsSync(resolve(path, ".git")))
|
|
128
|
+
log.info("Existing git repository detected, skipping git init");
|
|
129
|
+
else {
|
|
130
|
+
log.info("Initializing git repository...");
|
|
131
|
+
result = await runCommand("git init", { ...options, cwd: path });
|
|
132
|
+
if (result.isErr) {
|
|
133
|
+
log.error(result.error);
|
|
134
|
+
process.exit(ExitCode.FatalError);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
log.success("Installed & set-up \uD83D\uDE80");
|
|
138
|
+
}
|
|
139
|
+
async function unvendorCore(path, options) {
|
|
140
|
+
log.info("Resolving the framework from npm (pass --with-core to keep it vendored)...");
|
|
141
|
+
const result = await runCommand("./buddy unpublish:core --all --force", { ...options, cwd: path });
|
|
124
142
|
if (result.isErr) {
|
|
125
143
|
log.error(result.error);
|
|
126
144
|
process.exit(ExitCode.FatalError);
|
|
127
145
|
}
|
|
128
|
-
log.success("
|
|
146
|
+
log.success("Framework resolved from npm");
|
|
129
147
|
}
|
|
130
148
|
async function stripFeatures(path) {
|
|
131
149
|
log.info("Stripping optional feature bundles (--minimal)...");
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1163,7 +1163,8 @@ if [ -n "$DOMAIN" ] && [ -f "$CERTFILE" ] && [ -d /opt/tlsx ]; then
|
|
|
1163
1163
|
;;
|
|
1164
1164
|
esac
|
|
1165
1165
|
fi
|
|
1166
|
-
# 4) Merge auto-forward rules
|
|
1166
|
+
# 4) Merge auto-forward rules and compile the canonical RFC 5228 runtime script
|
|
1167
|
+
# (live-reloaded; no restart).
|
|
1167
1168
|
if [ -n "$FWD_B64" ] && [ -x /usr/local/bin/bun ]; then
|
|
1168
1169
|
echo "$FWD_B64" | base64 -d > /tmp/.mailtenant-fwd.json
|
|
1169
1170
|
echo "$README_B64" | base64 -d > /tmp/.mailtenant-readme.txt
|
|
@@ -1190,6 +1191,12 @@ if [ -s /tmp/.mailtenant-err ]; then echo "FWDERR:$(head -c 200 /tmp/.mailtenant
|
|
|
1190
1191
|
rm -f /tmp/.mailtenant-err
|
|
1191
1192
|
chown mail-server:mail-server "$FJSON" 2>/dev/null || true
|
|
1192
1193
|
chmod 644 "$FJSON" 2>/dev/null || true
|
|
1194
|
+
if [ -x /usr/local/sbin/mail-forward-compile ]; then
|
|
1195
|
+
if ! /usr/local/sbin/mail-forward-compile "$FJSON" /opt/mail/forwards.sieve 2>/tmp/.mailtenant-sieve-err; then
|
|
1196
|
+
echo "SIEVEERR:$(head -c 200 /tmp/.mailtenant-sieve-err | tr '\\n' ' ')"
|
|
1197
|
+
fi
|
|
1198
|
+
rm -f /tmp/.mailtenant-sieve-err
|
|
1199
|
+
fi
|
|
1193
1200
|
rm -f /tmp/.mailtenant-fwd.json /tmp/.mailtenant-readme.txt
|
|
1194
1201
|
fi
|
|
1195
1202
|
FWD_STATE=nochange; grep -q FWDCHANGED /tmp/.mailtenant-res 2>/dev/null && FWD_STATE=updated; rm -f /tmp/.mailtenant-res
|
package/dist/commands/email.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { email as emailConfig } from "@stacksjs/config";
|
|
4
|
+
import { extractEmailPreview } from "@stacksjs/email";
|
|
4
5
|
import { ExitCode } from "@stacksjs/types";
|
|
5
6
|
import { onUnknownSubcommand } from "@stacksjs/cli";
|
|
6
7
|
import { getErrorMessage } from "@stacksjs/utils";
|
|
@@ -412,14 +413,7 @@ Configuration:`);
|
|
|
412
413
|
const rawEmail = await withTimeout(s3.getObject(bucketName, obj.Key), 15000);
|
|
413
414
|
if (!rawEmail)
|
|
414
415
|
continue;
|
|
415
|
-
const headers = parseRawEmailHeaders(rawEmail), messageId = obj.Key.split("/").pop() || obj.Key, from = extractEmailAddress(headers.from || ""), fromName = extractEmailName(headers.from || ""), toList = (headers.to || "").split(",").map((s) => s.trim()).filter(Boolean), subject = headers.subject || "No Subject", date = headers.date || (obj.LastModified ? new Date(obj.LastModified).toISOString() : new Date().toISOString());
|
|
416
|
-
let preview = "";
|
|
417
|
-
const bodyStart = rawEmail.indexOf(`\r
|
|
418
|
-
\r
|
|
419
|
-
`);
|
|
420
|
-
if (bodyStart > 0)
|
|
421
|
-
preview = rawEmail.substring(bodyStart + 4, bodyStart + 500).replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim().substring(0, 200);
|
|
422
|
-
const recipients = toList.length > 0 ? toList : [`unknown@${domain}`];
|
|
416
|
+
const headers = parseRawEmailHeaders(rawEmail), messageId = obj.Key.split("/").pop() || obj.Key, from = extractEmailAddress(headers.from || ""), fromName = extractEmailName(headers.from || ""), toList = (headers.to || "").split(",").map((s) => s.trim()).filter(Boolean), subject = headers.subject || "No Subject", date = headers.date || (obj.LastModified ? new Date(obj.LastModified).toISOString() : new Date().toISOString()), preview = extractEmailPreview(rawEmail), recipients = toList.length > 0 ? toList : [`unknown@${domain}`];
|
|
423
417
|
for (const recipient of recipients) {
|
|
424
418
|
const recipientEmail = extractEmailAddress(recipient);
|
|
425
419
|
if (!recipientEmail.endsWith(`@${domain}`))
|
|
@@ -163,10 +163,10 @@ export declare const FEATURE_FILES: Record<FeatureName, readonly string[]>;
|
|
|
163
163
|
* @defaultValue
|
|
164
164
|
* ```ts
|
|
165
165
|
* {
|
|
166
|
-
* cms: ['posts', 'pages', 'comments', 'tags', 'authors', 'categories'],
|
|
167
|
-
* commerce: [ 'products', 'product_variants', 'product_units', 'manufacturers', 'orders', 'order_items', 'carts', 'cart_items', 'payments', 'payment_methods', 'payment_products', 'payment_transactions', 'customers', 'subscribers', 'subscriber_emails', 'subscriptions', 'gift_cards', 'coupons', 'transactions', 'reviews', 'drivers', 'delivery_routes', 'digital_deliveries', 'shipping_methods', 'shipping_rates', 'shipping_zones', 'license_keys', 'loyalty_points', 'loyalty_rewards', 'print_devices', 'receipts', 'tax_rates', 'waitlist_products', 'waitlist_restaurants', ],
|
|
166
|
+
* cms: [ 'posts', 'pages', 'comments', 'tags', 'authors', 'categories', 'taggable_models', 'categorizable_models', 'commentables', ],
|
|
167
|
+
* commerce: [ 'products', 'product_variants', 'product_units', 'manufacturers', 'orders', 'order_items', 'order_idempotency', 'carts', 'cart_items', 'payments', 'payment_methods', 'payment_products', 'payment_transactions', 'customers', 'subscribers', 'subscriber_emails', 'subscriptions', 'gift_cards', 'coupons', 'transactions', 'reviews', 'drivers', 'delivery_routes', 'digital_deliveries', 'shipping_methods', 'shipping_rates', 'shipping_zones', 'license_keys', 'loyalty_points', 'loyalty_rewards', 'print_devices', 'receipts', 'tax_rates', 'waitlist_products', 'waitlist_restaurants', ],
|
|
168
168
|
* dashboard: [ 'boards', 'board_columns', 'cards', 'card_labels', 'card_assignees', 'card_comments', 'labels', 'ci_run_states', 'ci_runner_samples', 'ci_runner_alert_states', 'requests', 'logs', ],
|
|
169
|
-
* marketing: [],
|
|
169
|
+
* marketing: [ 'campaigns', 'campaign_sends', 'email_lists', 'email_list_subscribers', 'social_posts', 'mail_preferences', ],
|
|
170
170
|
* monitoring: ['errors'],
|
|
171
171
|
* realtime: ['websockets'],
|
|
172
172
|
* queue: ['jobs', 'failed_jobs']
|
|
@@ -72,7 +72,17 @@ export const FEATURE_NAMES = [
|
|
|
72
72
|
"resources/views/dashboard/jobs/"
|
|
73
73
|
]
|
|
74
74
|
}, FEATURE_TABLES = {
|
|
75
|
-
cms: [
|
|
75
|
+
cms: [
|
|
76
|
+
"posts",
|
|
77
|
+
"pages",
|
|
78
|
+
"comments",
|
|
79
|
+
"tags",
|
|
80
|
+
"authors",
|
|
81
|
+
"categories",
|
|
82
|
+
"taggable_models",
|
|
83
|
+
"categorizable_models",
|
|
84
|
+
"commentables"
|
|
85
|
+
],
|
|
76
86
|
commerce: [
|
|
77
87
|
"products",
|
|
78
88
|
"product_variants",
|
|
@@ -80,6 +90,7 @@ export const FEATURE_NAMES = [
|
|
|
80
90
|
"manufacturers",
|
|
81
91
|
"orders",
|
|
82
92
|
"order_items",
|
|
93
|
+
"order_idempotency",
|
|
83
94
|
"carts",
|
|
84
95
|
"cart_items",
|
|
85
96
|
"payments",
|
|
@@ -123,7 +134,14 @@ export const FEATURE_NAMES = [
|
|
|
123
134
|
"requests",
|
|
124
135
|
"logs"
|
|
125
136
|
],
|
|
126
|
-
marketing: [
|
|
137
|
+
marketing: [
|
|
138
|
+
"campaigns",
|
|
139
|
+
"campaign_sends",
|
|
140
|
+
"email_lists",
|
|
141
|
+
"email_list_subscribers",
|
|
142
|
+
"social_posts",
|
|
143
|
+
"mail_preferences"
|
|
144
|
+
],
|
|
127
145
|
monitoring: ["errors"],
|
|
128
146
|
realtime: ["websockets"],
|
|
129
147
|
queue: ["jobs", "failed_jobs"]
|
package/dist/commands/mail.js
CHANGED
|
@@ -2,9 +2,9 @@ var {require}=import.meta;import { log, runCommand } from "@stacksjs/cli";
|
|
|
2
2
|
import { ExitCode } from "@stacksjs/types";
|
|
3
3
|
import { getErrorMessage } from "@stacksjs/utils";
|
|
4
4
|
import { CLI } from "@stacksjs/clapp";
|
|
5
|
-
import { createHash, createHmac, randomBytes } from "crypto";
|
|
5
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
6
6
|
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
-
import { execSync } from "node:child_process";
|
|
7
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
8
8
|
export function resolveDirectMailHost(env = process.env) {
|
|
9
9
|
let configured = env.MAIL_SERVER_HOST || (env.HCLOUD_TOKEN || env.HETZNER_API_TOKEN ? env.MAIL_HOST : void 0);
|
|
10
10
|
if (configured && ["127.0.0.1", "localhost", "mailpit"].includes(configured))
|
|
@@ -40,6 +40,410 @@ async function resolveOperationalMailHost() {
|
|
|
40
40
|
const { email } = await import("@stacksjs/config");
|
|
41
41
|
return email?.domain ? `mail.${email.domain}` : void 0;
|
|
42
42
|
}
|
|
43
|
+
const mailStorageRemote = "/usr/local/sbin/mail-storage-external";
|
|
44
|
+
function loadMailStorageAws(options) {
|
|
45
|
+
const profile = options.profile || process.env.AWS_PROFILE || "default", region = options.region || process.env.AWS_REGION || "us-east-1";
|
|
46
|
+
process.env.AWS_PROFILE = profile;
|
|
47
|
+
loadEnvFiles();
|
|
48
|
+
if (existsSync(".env.production"))
|
|
49
|
+
try {
|
|
50
|
+
const { loadEnv } = require("@stacksjs/env");
|
|
51
|
+
loadEnv({ path: ".env.production", env: "production", keysFile: ".env.keys", overload: !0, quiet: !0 });
|
|
52
|
+
} catch {}
|
|
53
|
+
const credentialsPath = `${process.env.HOME || process.env.USERPROFILE || ""}/.aws/credentials`;
|
|
54
|
+
if ((!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) && existsSync(credentialsPath)) {
|
|
55
|
+
const values = {};
|
|
56
|
+
let inProfile = !1;
|
|
57
|
+
for (const line of readFileSync(credentialsPath, "utf8").split(`
|
|
58
|
+
`)) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (trimmed.startsWith("[")) {
|
|
61
|
+
inProfile = trimmed === `[${profile}]`;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!inProfile)
|
|
65
|
+
continue;
|
|
66
|
+
const separator = trimmed.indexOf("=");
|
|
67
|
+
if (separator > 0)
|
|
68
|
+
values[trimmed.slice(0, separator).trim()] = trimmed.slice(separator + 1).trim();
|
|
69
|
+
}
|
|
70
|
+
if (values.aws_access_key_id && values.aws_secret_access_key) {
|
|
71
|
+
process.env.AWS_ACCESS_KEY_ID = values.aws_access_key_id;
|
|
72
|
+
process.env.AWS_SECRET_ACCESS_KEY = values.aws_secret_access_key;
|
|
73
|
+
if (values.aws_session_token)
|
|
74
|
+
process.env.AWS_SESSION_TOKEN = values.aws_session_token;
|
|
75
|
+
else
|
|
76
|
+
delete process.env.AWS_SESSION_TOKEN;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { profile, region };
|
|
80
|
+
}
|
|
81
|
+
function mailStorageSecretId(options) {
|
|
82
|
+
if (options.secret)
|
|
83
|
+
return options.secret;
|
|
84
|
+
if (process.env.MAIL_STORAGE_SECRET_ID)
|
|
85
|
+
return process.env.MAIL_STORAGE_SECRET_ID;
|
|
86
|
+
const app = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-"), environment = (process.env.APP_ENV || "production").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
87
|
+
return `${app}/${environment}/mail-storage-key`;
|
|
88
|
+
}
|
|
89
|
+
async function mailStorageHost(options) {
|
|
90
|
+
const host = options.host || await resolveOperationalMailHost();
|
|
91
|
+
if (!host)
|
|
92
|
+
throw Error("Could not resolve the mail host. Pass --host <hostname-or-ip>.");
|
|
93
|
+
if (!/^[a-zA-Z0-9._:-]+$/.test(host))
|
|
94
|
+
throw Error(`Unsafe mail host: ${host}`);
|
|
95
|
+
return host;
|
|
96
|
+
}
|
|
97
|
+
function runMailStorageSsh(host, command, input) {
|
|
98
|
+
const result = spawnSync("ssh", [
|
|
99
|
+
"-o",
|
|
100
|
+
"BatchMode=yes",
|
|
101
|
+
"-o",
|
|
102
|
+
"ConnectTimeout=15",
|
|
103
|
+
`root@${host}`,
|
|
104
|
+
command
|
|
105
|
+
], {
|
|
106
|
+
input,
|
|
107
|
+
maxBuffer: 33554432
|
|
108
|
+
});
|
|
109
|
+
if (result.status !== 0) {
|
|
110
|
+
const stderr = Buffer.from(result.stderr || []).toString("utf8").trim();
|
|
111
|
+
throw Error(stderr || `SSH command failed with status ${result.status}`);
|
|
112
|
+
}
|
|
113
|
+
return Buffer.from(result.stdout || []);
|
|
114
|
+
}
|
|
115
|
+
function readMailRecoveryKey() {
|
|
116
|
+
const result = spawnSync("/usr/bin/security", [
|
|
117
|
+
"find-generic-password",
|
|
118
|
+
"-a",
|
|
119
|
+
"mail-storage-recovery",
|
|
120
|
+
"-s",
|
|
121
|
+
"com.stacks.production.mail.luks",
|
|
122
|
+
"-w"
|
|
123
|
+
], {
|
|
124
|
+
maxBuffer: 1048576,
|
|
125
|
+
timeout: 30000
|
|
126
|
+
});
|
|
127
|
+
if (result.status !== 0)
|
|
128
|
+
throw Error("macOS Keychain recovery-key read failed");
|
|
129
|
+
const raw = Buffer.from(result.stdout || []), text = raw.toString("ascii").trim(), key = /^[0-9a-f]{128}$/i.test(text) ? Buffer.from(text, "hex") : raw.length === 65 && raw[64] === 10 ? Buffer.from(raw.subarray(0, 64)) : Buffer.from(raw);
|
|
130
|
+
raw.fill(0);
|
|
131
|
+
if (key.length !== 64)
|
|
132
|
+
throw Error("macOS Keychain returned an invalid recovery key");
|
|
133
|
+
return key;
|
|
134
|
+
}
|
|
135
|
+
async function waitForHetznerAction(token, actionId) {
|
|
136
|
+
const deadline = Date.now() + 120000;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
const response = await fetch(`https://api.hetzner.cloud/v1/actions/${actionId}`, {
|
|
139
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
140
|
+
}), data = await response.json();
|
|
141
|
+
if (!response.ok)
|
|
142
|
+
throw Error(data?.error?.message || `Hetzner action ${actionId} lookup failed`);
|
|
143
|
+
if (data.action?.status === "success")
|
|
144
|
+
return;
|
|
145
|
+
if (data.action?.status === "error")
|
|
146
|
+
throw Error(data.action?.error?.message || `Hetzner action ${actionId} failed`);
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
148
|
+
}
|
|
149
|
+
throw Error(`Hetzner action ${actionId} timed out`);
|
|
150
|
+
}
|
|
151
|
+
async function ensureMailStorageVolume(options) {
|
|
152
|
+
loadEnvFiles();
|
|
153
|
+
const token = process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
|
|
154
|
+
if (!token)
|
|
155
|
+
throw Error("HCLOUD_TOKEN or HETZNER_API_TOKEN is required.");
|
|
156
|
+
const headers = {
|
|
157
|
+
Authorization: `Bearer ${token}`,
|
|
158
|
+
"Content-Type": "application/json"
|
|
159
|
+
}, host = await mailStorageHost(options), server = (await (await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent("stacks-production-app")}`, { headers })).json()).servers?.[0];
|
|
160
|
+
if (!server)
|
|
161
|
+
throw Error("Hetzner server stacks-production-app was not found.");
|
|
162
|
+
const app = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-"), name = `${app}-production-mail`, requestedSize = Number.parseInt(options.size || "20", 10);
|
|
163
|
+
if (!Number.isFinite(requestedSize) || requestedSize < 10 || requestedSize > 1024)
|
|
164
|
+
throw Error("Mail volume size must be between 10 and 1024 GiB.");
|
|
165
|
+
let volume = (await (await fetch(`https://api.hetzner.cloud/v1/volumes?name=${encodeURIComponent(name)}`, { headers })).json()).volumes?.[0];
|
|
166
|
+
if (!volume) {
|
|
167
|
+
const createResponse = await fetch("https://api.hetzner.cloud/v1/volumes", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers,
|
|
170
|
+
body: JSON.stringify({
|
|
171
|
+
name,
|
|
172
|
+
size: requestedSize,
|
|
173
|
+
server: server.id,
|
|
174
|
+
automount: !1,
|
|
175
|
+
labels: {
|
|
176
|
+
application: app,
|
|
177
|
+
environment: "production",
|
|
178
|
+
purpose: "mail-storage"
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
}), createData = await createResponse.json();
|
|
182
|
+
if (!createResponse.ok)
|
|
183
|
+
throw Error(createData?.error?.message || "Hetzner volume creation failed.");
|
|
184
|
+
volume = createData.volume;
|
|
185
|
+
if (createData.action?.id)
|
|
186
|
+
await waitForHetznerAction(token, createData.action.id);
|
|
187
|
+
} else if (!volume.server) {
|
|
188
|
+
const attachResponse = await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}/actions/attach`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers,
|
|
191
|
+
body: JSON.stringify({ server: server.id, automount: !1 })
|
|
192
|
+
}), attachData = await attachResponse.json();
|
|
193
|
+
if (!attachResponse.ok)
|
|
194
|
+
throw Error(attachData?.error?.message || "Hetzner volume attach failed.");
|
|
195
|
+
await waitForHetznerAction(token, attachData.action.id);
|
|
196
|
+
} else if (volume.server !== server.id)
|
|
197
|
+
throw Error(`Hetzner volume ${name} is attached to a different server.`);
|
|
198
|
+
volume = (await (await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}`, { headers })).json()).volume || volume;
|
|
199
|
+
if (!volume.linux_device)
|
|
200
|
+
throw Error(`Hetzner volume ${name} has no Linux device path yet.`);
|
|
201
|
+
return { device: volume.linux_device, host, id: volume.id, name, size: volume.size };
|
|
202
|
+
}
|
|
203
|
+
async function ensureMailKmsKey(options) {
|
|
204
|
+
const { region } = loadMailStorageAws(options), { AWSClient } = await import("../../../cloud/src/imap/client"), { S3Client } = await import("../../../cloud/src/imap/s3"), { SecretsManagerClient } = await import("../../../cloud/src/imap/secrets-manager"), client = new AWSClient, request = async (target, body) => client.request({
|
|
205
|
+
service: "kms",
|
|
206
|
+
region,
|
|
207
|
+
method: "POST",
|
|
208
|
+
path: "/",
|
|
209
|
+
headers: {
|
|
210
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
211
|
+
"X-Amz-Target": `TrentService.${target}`
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify(body)
|
|
214
|
+
}), alias = "alias/stacks-production-mail";
|
|
215
|
+
let keyId = (await request("ListAliases", { Limit: 100 })).Aliases?.find((item) => item.AliasName === alias)?.TargetKeyId;
|
|
216
|
+
if (!keyId) {
|
|
217
|
+
keyId = (await request("CreateKey", {
|
|
218
|
+
Description: "Customer-managed key for production mail secrets and backups",
|
|
219
|
+
KeyUsage: "ENCRYPT_DECRYPT",
|
|
220
|
+
Origin: "AWS_KMS",
|
|
221
|
+
Tags: [
|
|
222
|
+
{ TagKey: "Application", TagValue: "stacks" },
|
|
223
|
+
{ TagKey: "Environment", TagValue: "production" },
|
|
224
|
+
{ TagKey: "Purpose", TagValue: "mail-storage" }
|
|
225
|
+
]
|
|
226
|
+
})).KeyMetadata?.KeyId;
|
|
227
|
+
if (!keyId)
|
|
228
|
+
throw Error("AWS KMS did not return a key ID.");
|
|
229
|
+
await request("CreateAlias", { AliasName: alias, TargetKeyId: keyId });
|
|
230
|
+
await request("EnableKeyRotation", { KeyId: keyId, RotationPeriodInDays: 90 });
|
|
231
|
+
}
|
|
232
|
+
const arn = (await request("DescribeKey", { KeyId: keyId })).KeyMetadata?.Arn;
|
|
233
|
+
if (!arn)
|
|
234
|
+
throw Error("AWS KMS did not return a key ARN.");
|
|
235
|
+
const secrets = new SecretsManagerClient(region);
|
|
236
|
+
for (const secretId of ["stacks/production/mail-storage-key", "stacks/production/mail-restic"])
|
|
237
|
+
try {
|
|
238
|
+
await secrets.updateSecret({ SecretId: secretId, KmsKeyId: arn });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error?.code !== "ResourceNotFoundException")
|
|
241
|
+
throw Error(`Could not re-encrypt secret ${secretId}: ${getErrorMessage(error)}`);
|
|
242
|
+
}
|
|
243
|
+
const s3 = new S3Client(region);
|
|
244
|
+
for (const bucket of ["stacks-production-s3-backups", "stacks-production-s3-email", "stacks-production-email"])
|
|
245
|
+
try {
|
|
246
|
+
await s3.putBucketEncryption(bucket, "aws:kms", arn);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
throw Error(`Could not apply KMS encryption to ${bucket}: ${getErrorMessage(error)}`);
|
|
249
|
+
}
|
|
250
|
+
return { alias, arn };
|
|
251
|
+
}
|
|
252
|
+
async function configureMailRestic(options) {
|
|
253
|
+
const { region } = loadMailStorageAws(options), host = await mailStorageHost(options), bucket = options.bucket || "stacks-production-s3-backups";
|
|
254
|
+
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))
|
|
255
|
+
throw Error(`Unsafe S3 bucket name: ${bucket}`);
|
|
256
|
+
const kms = await ensureMailKmsKey(options), { AWSClient } = await import("../../../cloud/src/imap/client"), { SecretsManagerClient } = await import("../../../cloud/src/imap/secrets-manager"), iam = new AWSClient, iamRequest = async (action, params = {}) => iam.request({
|
|
257
|
+
service: "iam",
|
|
258
|
+
region: "us-east-1",
|
|
259
|
+
method: "POST",
|
|
260
|
+
path: "/",
|
|
261
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
262
|
+
body: new URLSearchParams({
|
|
263
|
+
Action: action,
|
|
264
|
+
Version: "2010-05-08",
|
|
265
|
+
...params
|
|
266
|
+
}).toString()
|
|
267
|
+
}), userName = "stacks-production-mail-backup";
|
|
268
|
+
try {
|
|
269
|
+
await iamRequest("GetUser", { UserName: userName });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error?.code !== "NoSuchEntity")
|
|
272
|
+
throw error;
|
|
273
|
+
await iamRequest("CreateUser", {
|
|
274
|
+
UserName: userName,
|
|
275
|
+
"Tags.member.1.Key": "Application",
|
|
276
|
+
"Tags.member.1.Value": "stacks",
|
|
277
|
+
"Tags.member.2.Key": "Environment",
|
|
278
|
+
"Tags.member.2.Value": "production",
|
|
279
|
+
"Tags.member.3.Key": "Purpose",
|
|
280
|
+
"Tags.member.3.Value": "mail-backup"
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
await iamRequest("PutUserPolicy", {
|
|
284
|
+
UserName: userName,
|
|
285
|
+
PolicyName: "mail-restic-backup",
|
|
286
|
+
PolicyDocument: JSON.stringify({
|
|
287
|
+
Version: "2012-10-17",
|
|
288
|
+
Statement: [
|
|
289
|
+
{
|
|
290
|
+
Effect: "Allow",
|
|
291
|
+
Action: ["s3:ListBucket", "s3:GetBucketLocation"],
|
|
292
|
+
Resource: `arn:aws:s3:::${bucket}`,
|
|
293
|
+
Condition: { StringLike: { "s3:prefix": ["restic-mail", "restic-mail/*"] } }
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
Effect: "Allow",
|
|
297
|
+
Action: [
|
|
298
|
+
"s3:GetObject",
|
|
299
|
+
"s3:PutObject",
|
|
300
|
+
"s3:DeleteObject",
|
|
301
|
+
"s3:AbortMultipartUpload",
|
|
302
|
+
"s3:ListMultipartUploadParts"
|
|
303
|
+
],
|
|
304
|
+
Resource: `arn:aws:s3:::${bucket}/restic-mail/*`
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
Effect: "Allow",
|
|
308
|
+
Action: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
|
|
309
|
+
Resource: kms.arn
|
|
310
|
+
}
|
|
311
|
+
]
|
|
312
|
+
})
|
|
313
|
+
});
|
|
314
|
+
const listedMembers = (await iamRequest("ListAccessKeys", { UserName: userName })).ListAccessKeysResult?.AccessKeyMetadata?.member, oldKeys = listedMembers ? Array.isArray(listedMembers) ? listedMembers : [listedMembers] : [];
|
|
315
|
+
if (oldKeys.length > 0 && !options.force)
|
|
316
|
+
throw Error(`IAM user ${userName} already has a key. Pass --force to rotate it.`);
|
|
317
|
+
if (oldKeys.length >= 2)
|
|
318
|
+
for (const old of oldKeys)
|
|
319
|
+
await iamRequest("DeleteAccessKey", { UserName: userName, AccessKeyId: old.AccessKeyId });
|
|
320
|
+
const access = (await iamRequest("CreateAccessKey", { UserName: userName })).CreateAccessKeyResult?.AccessKey;
|
|
321
|
+
if (!access?.AccessKeyId || !access?.SecretAccessKey)
|
|
322
|
+
throw Error("AWS IAM did not return the Restic access key.");
|
|
323
|
+
const password = randomBytes(48).toString("base64url"), repository = `s3:s3.amazonaws.com/${bucket}/restic-mail`, secretId = "stacks/production/mail-restic", secretPayload = JSON.stringify({
|
|
324
|
+
version: 1,
|
|
325
|
+
repository,
|
|
326
|
+
password,
|
|
327
|
+
accessKeyId: access.AccessKeyId,
|
|
328
|
+
secretAccessKey: access.SecretAccessKey,
|
|
329
|
+
kmsKeyArn: kms.arn,
|
|
330
|
+
createdAt: new Date().toISOString()
|
|
331
|
+
}), secrets = new SecretsManagerClient(region);
|
|
332
|
+
try {
|
|
333
|
+
await secrets.createSecret({
|
|
334
|
+
Name: secretId,
|
|
335
|
+
Description: "Restic credentials for production mail backups",
|
|
336
|
+
KmsKeyId: kms.arn,
|
|
337
|
+
SecretString: secretPayload,
|
|
338
|
+
Tags: [
|
|
339
|
+
{ Key: "Application", Value: "stacks" },
|
|
340
|
+
{ Key: "Environment", Value: "production" },
|
|
341
|
+
{ Key: "Purpose", Value: "mail-backup" }
|
|
342
|
+
]
|
|
343
|
+
});
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error?.code !== "ResourceExistsException")
|
|
346
|
+
throw error;
|
|
347
|
+
await secrets.updateSecret({ SecretId: secretId, KmsKeyId: kms.arn, SecretString: secretPayload });
|
|
348
|
+
}
|
|
349
|
+
const configDir = "/var/lib/mail-storage/data/restic", passwordPath = `${configDir}/password`, envPayload = [
|
|
350
|
+
`RESTIC_REPOSITORY=${repository}`,
|
|
351
|
+
`RESTIC_PASSWORD_FILE=${passwordPath}`,
|
|
352
|
+
`AWS_ACCESS_KEY_ID=${access.AccessKeyId}`,
|
|
353
|
+
`AWS_SECRET_ACCESS_KEY=${access.SecretAccessKey}`,
|
|
354
|
+
`AWS_DEFAULT_REGION=${region}`,
|
|
355
|
+
""
|
|
356
|
+
].join(`
|
|
357
|
+
`);
|
|
358
|
+
runMailStorageSsh(host, `install -d -m 0700 ${configDir}; umask 077; dd of=${configDir}/restic.env status=none`, Buffer.from(envPayload));
|
|
359
|
+
runMailStorageSsh(host, `umask 077; dd of=${passwordPath} status=none`, Buffer.from(`${password}
|
|
360
|
+
`));
|
|
361
|
+
runMailStorageSsh(host, `set -a; . ${configDir}/restic.env; set +a; restic snapshots >/dev/null 2>&1 || restic init`);
|
|
362
|
+
runMailStorageSsh(host, "systemctl enable --now mail-restic-backup.timer mail-storage-health.timer");
|
|
363
|
+
runMailStorageSsh(host, "systemctl start mail-restic-backup.service");
|
|
364
|
+
runMailStorageSsh(host, "/usr/local/sbin/mail-restic-restore-check");
|
|
365
|
+
for (const old of oldKeys)
|
|
366
|
+
if (old.AccessKeyId !== access.AccessKeyId)
|
|
367
|
+
await iamRequest("DeleteAccessKey", { UserName: userName, AccessKeyId: old.AccessKeyId });
|
|
368
|
+
return { repository, secretId };
|
|
369
|
+
}
|
|
370
|
+
function decodeMailStorageSecret(secretString) {
|
|
371
|
+
if (!secretString)
|
|
372
|
+
throw Error("The mail storage secret has no SecretString value.");
|
|
373
|
+
let parsed;
|
|
374
|
+
try {
|
|
375
|
+
parsed = JSON.parse(secretString);
|
|
376
|
+
} catch {
|
|
377
|
+
throw Error("The mail storage secret is not valid JSON.");
|
|
378
|
+
}
|
|
379
|
+
if (parsed.version !== 1 || parsed.encoding !== "base64" || typeof parsed.key !== "string")
|
|
380
|
+
throw Error("The mail storage secret uses an unsupported format.");
|
|
381
|
+
const key = Buffer.from(parsed.key, "base64");
|
|
382
|
+
if (key.length < 32)
|
|
383
|
+
throw Error("The mail storage key is unexpectedly short.");
|
|
384
|
+
return key;
|
|
385
|
+
}
|
|
386
|
+
async function getMailStorageSecret(options) {
|
|
387
|
+
const { profile, region } = loadMailStorageAws(options), secretId = mailStorageSecretId(options), { SecretsManagerClient } = await import("../../../cloud/src/imap/secrets-manager"), value = await new SecretsManagerClient(region, profile).getSecretValue({ SecretId: secretId });
|
|
388
|
+
return { key: decodeMailStorageSecret(value.SecretString), secretId };
|
|
389
|
+
}
|
|
390
|
+
async function escrowMailStorageKey(options) {
|
|
391
|
+
const { profile, region } = loadMailStorageAws(options), host = await mailStorageHost(options), secretId = mailStorageSecretId(options), { SecretsManagerClient } = await import("../../../cloud/src/imap/secrets-manager"), secrets = new SecretsManagerClient(region, profile), key = runMailStorageSsh(host, "systemd-creds decrypt --name=mail-storage-key /etc/credstore.encrypted/mail-storage-key.cred -");
|
|
392
|
+
if (key.length < 32)
|
|
393
|
+
throw Error("The machine-bound mail storage key is unexpectedly short.");
|
|
394
|
+
let existing;
|
|
395
|
+
try {
|
|
396
|
+
const value = await secrets.getSecretValue({ SecretId: secretId });
|
|
397
|
+
existing = decodeMailStorageSecret(value.SecretString);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error?.code !== "ResourceNotFoundException")
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
if (existing && !timingSafeEqual(existing, key)) {
|
|
403
|
+
existing.fill(0);
|
|
404
|
+
if (!options.force) {
|
|
405
|
+
key.fill(0);
|
|
406
|
+
throw Error(`Secret ${secretId} already contains a different key. Pass --force to replace it.`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
existing?.fill(0);
|
|
410
|
+
const payload = {
|
|
411
|
+
version: 1,
|
|
412
|
+
encoding: "base64",
|
|
413
|
+
key: key.toString("base64"),
|
|
414
|
+
image: "/var/lib/mail-storage.luks",
|
|
415
|
+
mapper: "mail-storage",
|
|
416
|
+
createdAt: new Date().toISOString()
|
|
417
|
+
};
|
|
418
|
+
if (existing)
|
|
419
|
+
await secrets.putSecretValue({ SecretId: secretId, SecretString: JSON.stringify(payload) });
|
|
420
|
+
else
|
|
421
|
+
try {
|
|
422
|
+
await secrets.createSecret({
|
|
423
|
+
Name: secretId,
|
|
424
|
+
Description: `Externally escrowed LUKS2 key for ${host}`,
|
|
425
|
+
SecretString: JSON.stringify(payload),
|
|
426
|
+
Tags: [
|
|
427
|
+
{ Key: "Application", Value: process.env.APP_NAME || "stacks" },
|
|
428
|
+
{ Key: "Environment", Value: process.env.APP_ENV || "production" },
|
|
429
|
+
{ Key: "Purpose", Value: "mail-storage" }
|
|
430
|
+
]
|
|
431
|
+
});
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (error?.code !== "ResourceExistsException")
|
|
434
|
+
throw error;
|
|
435
|
+
await secrets.putSecretValue({ SecretId: secretId, SecretString: JSON.stringify(payload) });
|
|
436
|
+
}
|
|
437
|
+
const verify = await secrets.getSecretValue({ SecretId: secretId }), verifiedKey = decodeMailStorageSecret(verify.SecretString), matches = verifiedKey.length === key.length && timingSafeEqual(verifiedKey, key);
|
|
438
|
+
verifiedKey.fill(0);
|
|
439
|
+
key.fill(0);
|
|
440
|
+
if (!matches)
|
|
441
|
+
throw Error(`Secret ${secretId} did not verify after writing.`);
|
|
442
|
+
return { secretId, host };
|
|
443
|
+
}
|
|
444
|
+
function addMailStorageOptions(command) {
|
|
445
|
+
return command.option("--host <host>", "Mail server hostname or IP").option("--secret <id>", "AWS Secrets Manager secret ID").option("--region <region>", "AWS region", { default: "us-east-1" }).option("--profile <profile>", "AWS credential profile", { default: "default" });
|
|
446
|
+
}
|
|
43
447
|
export function mailCommands(buddy) {
|
|
44
448
|
buddy.command("mail:provision", "Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>", "Mail server IP (defaults to the A record of config.email.domain)").action(async (options) => {
|
|
45
449
|
const environment = options.env || process.env.APP_ENV || "production";
|
|
@@ -73,6 +477,366 @@ export function mailCommands(buddy) {
|
|
|
73
477
|
log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);
|
|
74
478
|
process.exit(ExitCode.Success);
|
|
75
479
|
});
|
|
480
|
+
addMailStorageOptions(buddy.command("mail:storage:status", "Show encrypted mail storage and external-key status")).action(async (options) => {
|
|
481
|
+
loadEnvFiles();
|
|
482
|
+
try {
|
|
483
|
+
const host = await mailStorageHost(options), output = runMailStorageSsh(host, `${mailStorageRemote} status`).toString("utf8").trim();
|
|
484
|
+
console.log(output);
|
|
485
|
+
process.exit(ExitCode.Success);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
console.error(`Failed to read mail storage status: ${getErrorMessage(error)}`);
|
|
488
|
+
process.exit(ExitCode.FatalError);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
addMailStorageOptions(buddy.command("mail:storage:kms:ensure", "Create and apply the customer-managed production mail KMS key")).action(async (options) => {
|
|
492
|
+
try {
|
|
493
|
+
const kms = await ensureMailKmsKey(options);
|
|
494
|
+
console.log(`alias=${kms.alias}`);
|
|
495
|
+
console.log(`arn=${kms.arn}`);
|
|
496
|
+
process.exit(ExitCode.Success);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
console.error(`Failed to configure mail KMS: ${getErrorMessage(error)}`);
|
|
499
|
+
process.exit(ExitCode.FatalError);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
addMailStorageOptions(buddy.command("mail:storage:volume:migrate", "Create a dedicated Hetzner volume and migrate encrypted mail storage")).option("--size <gib>", "Hetzner volume size in GiB", { default: "20" }).action(async (options) => {
|
|
503
|
+
let key, prepareAttempted = !1;
|
|
504
|
+
try {
|
|
505
|
+
const volume = await ensureMailStorageVolume(options);
|
|
506
|
+
key = (await getMailStorageSecret(options)).key;
|
|
507
|
+
runMailStorageSsh(volume.host, `for i in $(seq 1 60); do test -b ${shellQuote(volume.device)} && exit 0; sleep 1; done; exit 1`);
|
|
508
|
+
prepareAttempted = !0;
|
|
509
|
+
runMailStorageSsh(volume.host, [
|
|
510
|
+
"umask 077",
|
|
511
|
+
"key_file=$(mktemp /run/mail-volume-migrate-key.XXXXXX)",
|
|
512
|
+
`trap 'shred -u "$key_file" 2>/dev/null || rm -f "$key_file"' EXIT`,
|
|
513
|
+
'dd of="$key_file" bs=64 count=1 iflag=fullblock status=none',
|
|
514
|
+
`MAIL_ENCRYPTED_IMAGE_SIZE=16G /usr/local/sbin/mail-volume-migrate prepare ${shellQuote(volume.device)}`,
|
|
515
|
+
`${mailStorageRemote} unlock < "$key_file"`,
|
|
516
|
+
"/usr/local/sbin/mail-volume-migrate finalize"
|
|
517
|
+
].join(" && "), key);
|
|
518
|
+
const status = runMailStorageSsh(volume.host, `${mailStorageRemote} status`).toString("utf8").trim();
|
|
519
|
+
console.log(`volume=${volume.name}`);
|
|
520
|
+
console.log(`volume_id=${volume.id}`);
|
|
521
|
+
console.log(`volume_size_gib=${volume.size}`);
|
|
522
|
+
console.log(status);
|
|
523
|
+
process.exit(ExitCode.Success);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (prepareAttempted && key) {
|
|
526
|
+
const host = await mailStorageHost(options);
|
|
527
|
+
try {
|
|
528
|
+
runMailStorageSsh(host, "/usr/local/sbin/mail-volume-migrate rollback");
|
|
529
|
+
} catch (rollbackError) {
|
|
530
|
+
console.error(`Dedicated-volume rollback step failed: ${getErrorMessage(rollbackError)}`);
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
runMailStorageSsh(host, `${mailStorageRemote} unlock`, key);
|
|
534
|
+
} catch (unlockError) {
|
|
535
|
+
console.error(`Mail storage re-unlock failed: ${getErrorMessage(unlockError)}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
console.error(`Failed to migrate mail storage volume: ${getErrorMessage(error)}`);
|
|
539
|
+
process.exit(ExitCode.FatalError);
|
|
540
|
+
} finally {
|
|
541
|
+
key?.fill(0);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
addMailStorageOptions(buddy.command("mail:storage:key:recovery:add", "Add an independent LUKS recovery key to macOS Keychain")).action(async (options) => {
|
|
545
|
+
let currentKey, recoveryKey;
|
|
546
|
+
try {
|
|
547
|
+
const host = await mailStorageHost(options);
|
|
548
|
+
currentKey = (await getMailStorageSecret(options)).key;
|
|
549
|
+
recoveryKey = randomBytes(64);
|
|
550
|
+
runMailStorageSsh(host, `${mailStorageRemote} add-recovery-key`, Buffer.concat([currentKey, recoveryKey]));
|
|
551
|
+
const account = "mail-storage-recovery", service = "com.stacks.production.mail.luks", encoded = recoveryKey.toString("base64"), keychainWriter = `
|
|
552
|
+
import Foundation
|
|
553
|
+
import Security
|
|
554
|
+
|
|
555
|
+
let input = FileHandle.standardInput.readDataToEndOfFile()
|
|
556
|
+
guard let encoded = String(data: input, encoding: .utf8)?
|
|
557
|
+
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
558
|
+
let secret = Data(base64Encoded: encoded),
|
|
559
|
+
secret.count == 64
|
|
560
|
+
else { exit(2) }
|
|
561
|
+
|
|
562
|
+
let query: [CFString: Any] = [
|
|
563
|
+
kSecClass: kSecClassGenericPassword,
|
|
564
|
+
kSecAttrAccount: "${account}",
|
|
565
|
+
kSecAttrService: "${service}",
|
|
566
|
+
]
|
|
567
|
+
var trustedApplications: [SecTrustedApplication] = []
|
|
568
|
+
for path in ["/usr/bin/security", "/Library/Developer/CommandLineTools/usr/bin/swift-frontend"] {
|
|
569
|
+
var application: SecTrustedApplication?
|
|
570
|
+
guard SecTrustedApplicationCreateFromPath(path, &application) == errSecSuccess,
|
|
571
|
+
let application
|
|
572
|
+
else { exit(4) }
|
|
573
|
+
trustedApplications.append(application)
|
|
574
|
+
}
|
|
575
|
+
var access: SecAccess?
|
|
576
|
+
guard SecAccessCreate(
|
|
577
|
+
"Stacks production mail LUKS recovery key" as CFString,
|
|
578
|
+
trustedApplications as CFArray,
|
|
579
|
+
&access
|
|
580
|
+
) == errSecSuccess, let access
|
|
581
|
+
else { exit(5) }
|
|
582
|
+
|
|
583
|
+
let attributes: [CFString: Any] = [
|
|
584
|
+
kSecValueData: secret,
|
|
585
|
+
kSecAttrLabel: "Stacks production mail LUKS recovery key",
|
|
586
|
+
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
|
587
|
+
kSecAttrSynchronizable: false,
|
|
588
|
+
kSecAttrAccess: access,
|
|
589
|
+
]
|
|
590
|
+
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
591
|
+
if status == errSecItemNotFound {
|
|
592
|
+
var item = query
|
|
593
|
+
for (key, value) in attributes { item[key] = value }
|
|
594
|
+
status = SecItemAdd(item as CFDictionary, nil)
|
|
595
|
+
}
|
|
596
|
+
guard status == errSecSuccess else { exit(Int32(status)) }
|
|
597
|
+
|
|
598
|
+
var verificationQuery = query
|
|
599
|
+
verificationQuery[kSecReturnData] = true
|
|
600
|
+
verificationQuery[kSecMatchLimit] = kSecMatchLimitOne
|
|
601
|
+
var result: CFTypeRef?
|
|
602
|
+
status = SecItemCopyMatching(verificationQuery as CFDictionary, &result)
|
|
603
|
+
guard status == errSecSuccess, let stored = result as? Data, stored == secret
|
|
604
|
+
else { exit(3) }
|
|
605
|
+
`;
|
|
606
|
+
if (spawnSync("/usr/bin/swift", ["-e", keychainWriter], {
|
|
607
|
+
input: `${encoded}
|
|
608
|
+
`,
|
|
609
|
+
maxBuffer: 1048576
|
|
610
|
+
}).status !== 0)
|
|
611
|
+
throw Error("macOS Keychain write failed");
|
|
612
|
+
console.log(`keychain_service=${service}`);
|
|
613
|
+
console.log("recovery_keyslot=verified");
|
|
614
|
+
process.exit(ExitCode.Success);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
console.error(`Failed to add recovery key: ${getErrorMessage(error)}`);
|
|
617
|
+
process.exit(ExitCode.FatalError);
|
|
618
|
+
} finally {
|
|
619
|
+
currentKey?.fill(0);
|
|
620
|
+
recoveryKey?.fill(0);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
addMailStorageOptions(buddy.command("mail:storage:key:recovery:verify", "Verify the Keychain recovery key against the live LUKS header")).action(async (options) => {
|
|
624
|
+
let key;
|
|
625
|
+
try {
|
|
626
|
+
const host = await mailStorageHost(options);
|
|
627
|
+
key = readMailRecoveryKey();
|
|
628
|
+
runMailStorageSsh(host, "cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks", key);
|
|
629
|
+
console.log("Keychain recovery key unlocks the live mail volume.");
|
|
630
|
+
process.exit(ExitCode.Success);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
console.error(`Recovery key verification failed: ${getErrorMessage(error)}`);
|
|
633
|
+
process.exit(ExitCode.FatalError);
|
|
634
|
+
} finally {
|
|
635
|
+
key?.fill(0);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
addMailStorageOptions(buddy.command("mail:storage:key:slot:remove", "Remove an unusable non-primary LUKS keyslot")).option("--slot <number>", "LUKS keyslot number to remove").action(async (options) => {
|
|
639
|
+
let key;
|
|
640
|
+
try {
|
|
641
|
+
const slot = Number.parseInt(options.slot || "", 10);
|
|
642
|
+
if (!Number.isInteger(slot) || slot < 1 || slot > 31)
|
|
643
|
+
throw Error("Pass a non-primary keyslot from 1 through 31.");
|
|
644
|
+
const host = await mailStorageHost(options);
|
|
645
|
+
key = (await getMailStorageSecret(options)).key;
|
|
646
|
+
const output = runMailStorageSsh(host, `${mailStorageRemote} remove-keyslot ${slot}`, key).toString("utf8").trim();
|
|
647
|
+
console.log(output);
|
|
648
|
+
process.exit(ExitCode.Success);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
console.error(`Failed to remove LUKS keyslot: ${getErrorMessage(error)}`);
|
|
651
|
+
process.exit(ExitCode.FatalError);
|
|
652
|
+
} finally {
|
|
653
|
+
key?.fill(0);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
addMailStorageOptions(buddy.command("mail:storage:restic:configure", "Configure client-encrypted Restic backups to S3")).option("--force", "Rotate an existing dedicated IAM access key", { default: !1 }).action(async (options) => {
|
|
657
|
+
try {
|
|
658
|
+
const result = await configureMailRestic(options);
|
|
659
|
+
console.log(`repository=${result.repository}`);
|
|
660
|
+
console.log(`recovery_secret=${result.secretId}`);
|
|
661
|
+
console.log("restore_check=ok");
|
|
662
|
+
process.exit(ExitCode.Success);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
console.error(`Failed to configure Restic mail backups: ${getErrorMessage(error)}`);
|
|
665
|
+
process.exit(ExitCode.FatalError);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
addMailStorageOptions(buddy.command("mail:storage:restore:check", "Restore and validate the newest off-host mail snapshot")).action(async (options) => {
|
|
669
|
+
try {
|
|
670
|
+
const host = await mailStorageHost(options), output = runMailStorageSsh(host, "/usr/local/sbin/mail-restic-restore-check").toString("utf8").trim();
|
|
671
|
+
console.log(output);
|
|
672
|
+
process.exit(ExitCode.Success);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
console.error(`Mail restore check failed: ${getErrorMessage(error)}`);
|
|
675
|
+
process.exit(ExitCode.FatalError);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
addMailStorageOptions(buddy.command("mail:storage:maildir:migrate", "Migrate live mailboxes to canonical Maildir++ layout")).action(async (options) => {
|
|
679
|
+
try {
|
|
680
|
+
const host = await mailStorageHost(options), output = runMailStorageSsh(host, "/usr/local/sbin/maildir-migrate migrate").toString("utf8").trim();
|
|
681
|
+
console.log(output);
|
|
682
|
+
const status = runMailStorageSsh(host, "/usr/local/sbin/maildir-migrate status").toString("utf8").trim();
|
|
683
|
+
console.log(status);
|
|
684
|
+
process.exit(ExitCode.Success);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
console.error(`Maildir++ migration failed: ${getErrorMessage(error)}`);
|
|
687
|
+
process.exit(ExitCode.FatalError);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
addMailStorageOptions(buddy.command("mail:forward:compile", "Compile and validate the live RFC 5228 forwarding script")).action(async (options) => {
|
|
691
|
+
try {
|
|
692
|
+
const host = await mailStorageHost(options), output = runMailStorageSsh(host, '/usr/local/sbin/mail-forward-compile /opt/mail/forwards.json /opt/mail/forwards.sieve && sed -n "1,160p" /opt/mail/forwards.sieve').toString("utf8").trim();
|
|
693
|
+
console.log(output);
|
|
694
|
+
process.exit(ExitCode.Success);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
console.error(`Forwarding script compilation failed: ${getErrorMessage(error)}`);
|
|
697
|
+
process.exit(ExitCode.FatalError);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
addMailStorageOptions(buddy.command("mail:storage:escrow", "Copy the machine-bound LUKS key to AWS Secrets Manager")).option("--force", "Replace a different existing secret", { default: !1 }).action(async (options) => {
|
|
701
|
+
try {
|
|
702
|
+
const result = await escrowMailStorageKey(options);
|
|
703
|
+
console.log(`Mail storage key verified in AWS Secrets Manager: ${result.secretId}`);
|
|
704
|
+
console.log(`Host: ${result.host}`);
|
|
705
|
+
process.exit(ExitCode.Success);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
console.error(`Failed to escrow mail storage key: ${getErrorMessage(error)}`);
|
|
708
|
+
process.exit(ExitCode.FatalError);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
addMailStorageOptions(buddy.command("mail:storage:key:verify", "Verify the external key against the LUKS2 header")).action(async (options) => {
|
|
712
|
+
let key;
|
|
713
|
+
try {
|
|
714
|
+
const host = await mailStorageHost(options), secret = await getMailStorageSecret(options);
|
|
715
|
+
key = secret.key;
|
|
716
|
+
runMailStorageSsh(host, "cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks", key);
|
|
717
|
+
console.log(`External key ${secret.secretId} unlocks the mail volume.`);
|
|
718
|
+
process.exit(ExitCode.Success);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
console.error(`Mail storage key verification failed: ${getErrorMessage(error)}`);
|
|
721
|
+
process.exit(ExitCode.FatalError);
|
|
722
|
+
} finally {
|
|
723
|
+
key?.fill(0);
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
addMailStorageOptions(buddy.command("mail:storage:unlock", "Unlock external mail storage and start mail")).action(async (options) => {
|
|
727
|
+
let key;
|
|
728
|
+
try {
|
|
729
|
+
const host = await mailStorageHost(options), secret = await getMailStorageSecret(options);
|
|
730
|
+
key = secret.key;
|
|
731
|
+
const output = runMailStorageSsh(host, `${mailStorageRemote} unlock`, key).toString("utf8").trim();
|
|
732
|
+
console.log(output);
|
|
733
|
+
console.log(`External key: ${secret.secretId}`);
|
|
734
|
+
process.exit(ExitCode.Success);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
console.error(`Failed to unlock mail storage: ${getErrorMessage(error)}`);
|
|
737
|
+
process.exit(ExitCode.FatalError);
|
|
738
|
+
} finally {
|
|
739
|
+
key?.fill(0);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
addMailStorageOptions(buddy.command("mail:storage:lock", "Stop mail and lock external mail storage")).action(async (options) => {
|
|
743
|
+
loadEnvFiles();
|
|
744
|
+
try {
|
|
745
|
+
const host = await mailStorageHost(options), output = runMailStorageSsh(host, `${mailStorageRemote} lock`).toString("utf8").trim();
|
|
746
|
+
console.log(output);
|
|
747
|
+
process.exit(ExitCode.Success);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
console.error(`Failed to lock mail storage: ${getErrorMessage(error)}`);
|
|
750
|
+
process.exit(ExitCode.FatalError);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
addMailStorageOptions(buddy.command("mail:storage:backup", "Create and verify an encrypted local mail backup")).action(async (options) => {
|
|
754
|
+
loadEnvFiles();
|
|
755
|
+
try {
|
|
756
|
+
const host = await mailStorageHost(options), output = runMailStorageSsh(host, [
|
|
757
|
+
"set -eu",
|
|
758
|
+
"systemctl reset-failed mail-backup.service 2>/dev/null || true",
|
|
759
|
+
"systemctl start mail-backup.service",
|
|
760
|
+
'latest=$(find -L /opt/mail/backups -maxdepth 1 -type f -name "mail-backup-*.tgz" -printf "%T@ %p\\n" | sort -nr | head -1 | cut -d" " -f2-)',
|
|
761
|
+
'test -n "$latest"',
|
|
762
|
+
'tar -tzf "$latest" >/dev/null',
|
|
763
|
+
'printf "backup=%s\\n" "$latest"',
|
|
764
|
+
'stat -c "mode=%a owner=%U:%G size=%s" "$latest"'
|
|
765
|
+
].join("; ")).toString("utf8").trim();
|
|
766
|
+
console.log(output);
|
|
767
|
+
process.exit(ExitCode.Success);
|
|
768
|
+
} catch (error) {
|
|
769
|
+
console.error(`Failed to create mail backup: ${getErrorMessage(error)}`);
|
|
770
|
+
process.exit(ExitCode.FatalError);
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
addMailStorageOptions(buddy.command("mail:storage:header:backup", "Stream a LUKS header backup to S3 with KMS encryption")).option("--bucket <bucket>", "Recovery bucket (defaults to <app>-<env>-s3-backups)").action(async (options) => {
|
|
774
|
+
try {
|
|
775
|
+
const { region } = loadMailStorageAws(options), host = await mailStorageHost(options), app = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-"), environment = (process.env.APP_ENV || "production").toLowerCase().replace(/[^a-z0-9-]/g, "-"), bucket = options.bucket || `${app}-${environment}-s3-backups`;
|
|
776
|
+
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))
|
|
777
|
+
throw Error(`Unsafe S3 bucket name: ${bucket}`);
|
|
778
|
+
const objectKey = `luks-headers/mail-storage-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}.header`, { AWSClient } = await import("../../../cloud/src/imap/client"), { S3Client } = await import("../../../cloud/src/imap/s3"), kms = await ensureMailKmsKey(options), s3 = new S3Client(region);
|
|
779
|
+
await s3.putBucketEncryption(bucket, "aws:kms", kms.arn);
|
|
780
|
+
const uploadUrl = s3.generatePresignedPutUrl(bucket, objectKey, "application/octet-stream", 600), remoteCommand = [
|
|
781
|
+
"set -eu",
|
|
782
|
+
"tmp_dir=$(mktemp -d /run/mail-storage-header.XXXXXX)",
|
|
783
|
+
'tmp="$tmp_dir/header"',
|
|
784
|
+
`trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"; rmdir "$tmp_dir"' EXIT`,
|
|
785
|
+
'cryptsetup luksHeaderBackup /var/lib/mail-storage.luks --header-backup-file "$tmp"',
|
|
786
|
+
`curl --fail-with-body --silent --show-error -H 'Content-Type: application/octet-stream' --upload-file "$tmp" ${shellQuote(uploadUrl)} 1>&2`
|
|
787
|
+
].join("; ");
|
|
788
|
+
runMailStorageSsh(host, remoteCommand);
|
|
789
|
+
const object = await s3.headObject(bucket, objectKey);
|
|
790
|
+
if (!object?.ContentLength || object.ContentLength < 1048576)
|
|
791
|
+
throw Error("The uploaded LUKS header size did not verify.");
|
|
792
|
+
const head = await new AWSClient().request({
|
|
793
|
+
service: "s3",
|
|
794
|
+
region,
|
|
795
|
+
method: "HEAD",
|
|
796
|
+
path: `/${bucket}/${objectKey}`,
|
|
797
|
+
returnHeaders: !0
|
|
798
|
+
}), encryption = head?.headers?.["x-amz-server-side-encryption"];
|
|
799
|
+
if (encryption !== "aws:kms")
|
|
800
|
+
throw Error(`The LUKS header object reported unexpected encryption: ${encryption || "none"}`);
|
|
801
|
+
const keyId = head?.headers?.["x-amz-server-side-encryption-aws-kms-key-id"];
|
|
802
|
+
if (keyId !== kms.arn)
|
|
803
|
+
throw Error(`The LUKS header object used an unexpected KMS key: ${keyId || "none"}`);
|
|
804
|
+
console.log(`header=s3://${bucket}/${objectKey}`);
|
|
805
|
+
console.log("encryption=aws:kms");
|
|
806
|
+
console.log(`kms_key=${kms.arn}`);
|
|
807
|
+
process.exit(ExitCode.Success);
|
|
808
|
+
} catch (error) {
|
|
809
|
+
console.error(`Failed to back up the LUKS header: ${getErrorMessage(error)}`);
|
|
810
|
+
process.exit(ExitCode.FatalError);
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
addMailStorageOptions(buddy.command("mail:storage:externalize", "Move the LUKS key off-host and require external unlocks")).option("--force", "Replace a different existing secret", { default: !1 }).action(async (options) => {
|
|
814
|
+
let configured = !1, key;
|
|
815
|
+
try {
|
|
816
|
+
const escrow = await escrowMailStorageKey(options);
|
|
817
|
+
key = (await getMailStorageSecret(options)).key;
|
|
818
|
+
runMailStorageSsh(escrow.host, `${mailStorageRemote} configure-external`);
|
|
819
|
+
configured = !0;
|
|
820
|
+
runMailStorageSsh(escrow.host, `${mailStorageRemote} unlock`, key);
|
|
821
|
+
runMailStorageSsh(escrow.host, `${mailStorageRemote} finalize-external`);
|
|
822
|
+
const status = runMailStorageSsh(escrow.host, `${mailStorageRemote} status`).toString("utf8").trim();
|
|
823
|
+
console.log(status);
|
|
824
|
+
console.log(`External key: ${escrow.secretId}`);
|
|
825
|
+
process.exit(ExitCode.Success);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
if (configured)
|
|
828
|
+
try {
|
|
829
|
+
const host = await mailStorageHost(options);
|
|
830
|
+
runMailStorageSsh(host, `${mailStorageRemote} rollback-machine`);
|
|
831
|
+
} catch (rollbackError) {
|
|
832
|
+
console.error(`Automatic rollback also failed: ${getErrorMessage(rollbackError)}`);
|
|
833
|
+
}
|
|
834
|
+
console.error(`Failed to externalize mail storage: ${getErrorMessage(error)}`);
|
|
835
|
+
process.exit(ExitCode.FatalError);
|
|
836
|
+
} finally {
|
|
837
|
+
key?.fill(0);
|
|
838
|
+
}
|
|
839
|
+
});
|
|
76
840
|
buddy.command("mail:user:add <email>", "Add a mail user").option("--password <password>", "User password (generated if not provided)").action(async (email, options) => {
|
|
77
841
|
log.info(`Adding mail user: ${email}`);
|
|
78
842
|
try {
|
package/dist/commands/setup.d.ts
CHANGED
package/dist/commands/setup.js
CHANGED
|
@@ -211,10 +211,13 @@ export function ensureIdeSettings(cwd) {
|
|
|
211
211
|
log.success("Installed project VS Code settings");
|
|
212
212
|
}
|
|
213
213
|
const DB_CONNECTION_PACKAGES = {
|
|
214
|
-
postgres: { name: "postgresql.org", version: "^17.10" },
|
|
215
|
-
mysql: { name: "mysql.com", version: "*" },
|
|
214
|
+
postgres: { name: "postgresql.org", version: "^17.10", service: "postgres" },
|
|
215
|
+
mysql: { name: "mysql.com", version: "*", service: "mysql" },
|
|
216
216
|
sqlite: { name: "sqlite.org", version: "^3.47.2" }
|
|
217
217
|
};
|
|
218
|
+
function databaseAliases(connection, pkg) {
|
|
219
|
+
return [connection, pkg.name];
|
|
220
|
+
}
|
|
218
221
|
export function pantryDatabasePackage(connection) {
|
|
219
222
|
return DB_CONNECTION_PACKAGES[connection];
|
|
220
223
|
}
|
|
@@ -234,21 +237,36 @@ export async function optimizePantryDeps() {
|
|
|
234
237
|
log.debug("No config/deps.ts found, skipping dependency optimization");
|
|
235
238
|
return;
|
|
236
239
|
}
|
|
237
|
-
let configDeps = {};
|
|
240
|
+
let configDeps = {}, configServices = [];
|
|
238
241
|
try {
|
|
239
242
|
const mod = await import(depsConfigPath), config = mod.config || mod.default;
|
|
240
243
|
if (config?.dependencies)
|
|
241
244
|
configDeps = { ...config.dependencies };
|
|
245
|
+
if (Array.isArray(config?.services?.autoStart))
|
|
246
|
+
configServices = config.services.autoStart.filter((name) => typeof name === "string");
|
|
242
247
|
} catch (err) {
|
|
243
248
|
log.debug("Could not load config/deps.ts, skipping dependency optimization");
|
|
244
249
|
return;
|
|
245
250
|
}
|
|
246
|
-
const dbPackage = detectDbPackage(cwd);
|
|
251
|
+
const dbPackage = detectDbPackage(cwd), autoStart = [...configServices];
|
|
247
252
|
if (dbPackage) {
|
|
248
|
-
|
|
253
|
+
const selected = new Set(Object.entries(DB_CONNECTION_PACKAGES).filter(([, pkg]) => pkg.name === dbPackage.name).flatMap(([connection, pkg]) => databaseAliases(connection, pkg))), unused = new Set(Object.entries(DB_CONNECTION_PACKAGES).flatMap(([connection, pkg]) => databaseAliases(connection, pkg)).filter((alias) => !selected.has(alias)));
|
|
254
|
+
for (const pkg of Object.keys(configDeps)) {
|
|
255
|
+
const domain = pkg.split("/")[0];
|
|
256
|
+
if (unused.has(domain)) {
|
|
257
|
+
log.info(`DB_CONNECTION selects ${dbPackage.name}, dropping unused ${pkg}`);
|
|
258
|
+
delete configDeps[pkg];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!Object.keys(configDeps).some((key) => {
|
|
262
|
+
const domain = key.split("/")[0];
|
|
263
|
+
return selected.has(domain);
|
|
264
|
+
})) {
|
|
249
265
|
log.info(`Detected DB_CONNECTION requires ${dbPackage.name}, adding to dependencies`);
|
|
250
266
|
configDeps[dbPackage.name] = dbPackage.version;
|
|
251
267
|
}
|
|
268
|
+
if (dbPackage.service && !autoStart.includes(dbPackage.service))
|
|
269
|
+
autoStart.push(dbPackage.service);
|
|
252
270
|
}
|
|
253
271
|
const lines = [
|
|
254
272
|
"# Auto-generated from config/deps.ts and .env sniffing.",
|
|
@@ -261,6 +279,11 @@ export async function optimizePantryDeps() {
|
|
|
261
279
|
];
|
|
262
280
|
for (const [pkg, version] of Object.entries(configDeps))
|
|
263
281
|
lines.push(` ${pkg}: ${version}`);
|
|
282
|
+
if (autoStart.length > 0) {
|
|
283
|
+
lines.push("", "services:", " enabled: true", " autoStart:");
|
|
284
|
+
for (const service of autoStart)
|
|
285
|
+
lines.push(` - ${service}`);
|
|
286
|
+
}
|
|
264
287
|
const depsYamlPath = join(cwd, "deps.yaml");
|
|
265
288
|
writeFileSync(depsYamlPath, `${lines.join(`
|
|
266
289
|
`)}
|