@stacksjs/buddy 0.70.258 → 0.70.260
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/cli.d.ts +11 -1
- package/dist/cli.js +5 -164
- package/dist/commands/about.js +1 -35
- package/dist/commands/add.js +1 -25
- package/dist/commands/ai-context.js +4 -27
- package/dist/commands/auth.js +1 -68
- package/dist/commands/build.js +1 -221
- package/dist/commands/cd.js +8 -39
- package/dist/commands/changelog.js +1 -32
- package/dist/commands/clean.js +1 -44
- package/dist/commands/cloud.js +2 -519
- package/dist/commands/commit.js +1 -16
- package/dist/commands/completion.js +6 -42
- package/dist/commands/config-migrate.js +6 -94
- package/dist/commands/configure.js +2 -45
- package/dist/commands/create.js +1 -162
- package/dist/commands/deploy.js +13 -1676
- package/dist/commands/desktop-apple.js +12 -399
- package/dist/commands/dev.js +6 -1083
- package/dist/commands/dns.js +3 -103
- package/dist/commands/docs/buddy-commands.js +10 -132
- package/dist/commands/docs/buddy-commands.test.js +1 -38
- package/dist/commands/docs/framework-repo.js +1 -14
- package/dist/commands/docs/framework-repo.test.js +1 -68
- package/dist/commands/docs/generated-artifacts.js +3 -70
- package/dist/commands/docs/generated-artifacts.test.js +1 -19
- package/dist/commands/docs/links.js +2 -85
- package/dist/commands/docs/links.test.js +4 -66
- package/dist/commands/docs.js +1 -24
- package/dist/commands/doctor.js +2 -478
- package/dist/commands/domains.js +2 -152
- package/dist/commands/email.js +31 -503
- package/dist/commands/env.js +1 -312
- package/dist/commands/extension.js +1 -239
- package/dist/commands/features.js +9 -345
- package/dist/commands/fresh.js +1 -46
- package/dist/commands/generate.js +2 -152
- package/dist/commands/http.js +1 -33
- package/dist/commands/index.js +1 -57
- package/dist/commands/install.js +1 -22
- package/dist/commands/key.js +1 -23
- package/dist/commands/link.js +2 -147
- package/dist/commands/lint.js +2 -84
- package/dist/commands/list.js +1 -140
- package/dist/commands/mail.js +15 -1477
- package/dist/commands/maintenance.js +1 -141
- package/dist/commands/make.js +1 -392
- package/dist/commands/migrate-project.js +1 -49
- package/dist/commands/migrate.js +27 -625
- package/dist/commands/outdated.js +1 -19
- package/dist/commands/package.js +1 -17
- package/dist/commands/phone.js +17 -181
- package/dist/commands/ports.js +2 -118
- package/dist/commands/prepublish.js +1 -17
- package/dist/commands/projects.js +1 -29
- package/dist/commands/protocol/craft-evidence.js +4 -98
- package/dist/commands/protocol/craft-evidence.test.js +1 -20
- package/dist/commands/protocol/desktop-lifecycle-report.js +3 -57
- package/dist/commands/protocol/desktop-lifecycle-report.test.js +1 -32
- package/dist/commands/protocol/desktop-support.js +2 -32
- package/dist/commands/protocol/desktop-workflow.test.js +3 -35
- package/dist/commands/protocol/driver-registry.js +2 -71
- package/dist/commands/protocol/pantry-evidence.js +4 -144
- package/dist/commands/protocol/pantry-evidence.test.js +1 -29
- package/dist/commands/protocol/release-manifest.js +3 -102
- package/dist/commands/protocol/release-manifest.test.js +2 -34
- package/dist/commands/protocol/run-conformance.js +4 -346
- package/dist/commands/protocol/run-conformance.test.js +1 -52
- package/dist/commands/protocol/run-driver-contracts.js +4 -83
- package/dist/commands/protocol/run-driver-contracts.test.js +1 -18
- package/dist/commands/protocol/source-manifest.js +3 -113
- package/dist/commands/protocol/sync-suite.js +3 -136
- package/dist/commands/protocol/sync-suite.test.js +1 -44
- package/dist/commands/protocol.js +1 -60
- package/dist/commands/publish.js +8 -500
- package/dist/commands/queue.js +1 -248
- package/dist/commands/release.js +1 -42
- package/dist/commands/route.js +1 -21
- package/dist/commands/run-tool.js +1 -9
- package/dist/commands/saas.js +1 -25
- package/dist/commands/schedule.js +1 -61
- package/dist/commands/search.js +1 -84
- package/dist/commands/seed.js +1 -47
- package/dist/commands/serve.js +1 -55
- package/dist/commands/setup-ai.js +2 -121
- package/dist/commands/setup.js +2 -305
- package/dist/commands/share.js +1 -214
- package/dist/commands/sms.js +32 -319
- package/dist/commands/stacks.js +1 -72
- package/dist/commands/telemetry.js +1 -74
- package/dist/commands/test.js +1 -130
- package/dist/commands/tinker.js +1 -37
- package/dist/commands/types.js +1 -18
- package/dist/commands/upgrade.js +1 -98
- package/dist/commands/user.js +3 -84
- package/dist/commands/version.js +1 -14
- package/dist/config.js +1 -231
- package/dist/custom-cli.js +1 -23
- package/dist/database-preflight.js +1 -88
- package/dist/global-options.js +1 -4
- package/dist/index.js +1 -1
- package/dist/lazy-commands.js +1 -241
- package/dist/migrators/index.js +2 -62
- package/dist/migrators/laravel/index.js +1 -148
- package/dist/migrators/laravel/migrations.js +4 -231
- package/dist/migrators/laravel/models.js +8 -111
- package/dist/migrators/rails/index.js +1 -11
- package/dist/production-server.js +1 -257
- package/dist/project-setup.js +1 -36
- package/dist/serve-entry.js +1 -5
- package/dist/version-info.js +1 -5
- package/package.json +53 -46
package/dist/commands/mail.js
CHANGED
|
@@ -1,554 +1,7 @@
|
|
|
1
|
-
var {require}=import.meta;import {
|
|
2
|
-
import { ExitCode } from "@stacksjs/types";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
6
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
-
import { execSync, spawnSync } from "node:child_process";
|
|
8
|
-
export function resolveDirectMailHost(env = process.env) {
|
|
9
|
-
let configured = env.MAIL_SERVER_HOST || (env.HCLOUD_TOKEN || env.HETZNER_API_TOKEN ? env.MAIL_HOST : void 0);
|
|
10
|
-
if (configured && ["127.0.0.1", "localhost", "mailpit"].includes(configured))
|
|
11
|
-
configured = env.MAIL_DOMAIN ? `mail.${env.MAIL_DOMAIN}` : void 0;
|
|
12
|
-
if (!configured || configured === "127.0.0.1" || configured === "localhost")
|
|
13
|
-
return;
|
|
14
|
-
return configured.replace(/^https?:\/\//, "").split("/")[0]?.split(":")[0];
|
|
15
|
-
}
|
|
16
|
-
export function sanitizeLineCount(value) {
|
|
17
|
-
const count = Number.parseInt(value || "50", 10);
|
|
18
|
-
return String(Number.isFinite(count) ? Math.min(5000, Math.max(1, count)) : 50);
|
|
19
|
-
}
|
|
20
|
-
function shellQuote(value) {
|
|
21
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
22
|
-
}
|
|
23
|
-
async function resolveOperationalMailHost() {
|
|
24
|
-
if (process.env.MAIL_SERVER_HOST)
|
|
25
|
-
return resolveDirectMailHost();
|
|
26
|
-
const token = process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
|
|
27
|
-
if (!token)
|
|
28
|
-
return;
|
|
29
|
-
const appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
30
|
-
try {
|
|
31
|
-
const ip = (await (await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`, {
|
|
32
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
33
|
-
})).json()).servers?.[0]?.public_net?.ipv4?.ip;
|
|
34
|
-
if (ip)
|
|
35
|
-
return ip;
|
|
36
|
-
} catch {}
|
|
37
|
-
const configured = resolveDirectMailHost();
|
|
38
|
-
if (configured)
|
|
39
|
-
return configured;
|
|
40
|
-
const { email } = await import("@stacksjs/config");
|
|
41
|
-
return email?.domain ? `mail.${email.domain}` : void 0;
|
|
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
|
-
}
|
|
447
|
-
export function mailCommands(buddy) {
|
|
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) => {
|
|
449
|
-
const environment = options.env || process.env.APP_ENV || "production";
|
|
450
|
-
process.env.APP_ENV = environment;
|
|
451
|
-
const envFile = `.env.${environment}`;
|
|
452
|
-
if (existsSync(envFile))
|
|
453
|
-
try {
|
|
454
|
-
const { loadEnv } = await import("@stacksjs/env");
|
|
455
|
-
loadEnv({ path: envFile, env: environment, keysFile: ".env.keys", overload: !0, quiet: !0 });
|
|
456
|
-
} catch (err) {
|
|
457
|
-
log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`);
|
|
458
|
-
}
|
|
459
|
-
const { email: emailConfig } = await import("@stacksjs/config"), domain = emailConfig?.domain;
|
|
460
|
-
if (!domain) {
|
|
461
|
-
log.error("config/email.ts has no `domain` \u2014 set it (e.g. domain: 'bughq.org') and add `mailboxes`.");
|
|
462
|
-
process.exit(ExitCode.FatalError);
|
|
463
|
-
}
|
|
464
|
-
let ip = options.ip;
|
|
465
|
-
if (!ip)
|
|
466
|
-
try {
|
|
467
|
-
ip = (await (await import("node:dns")).promises.resolve4(domain))[0];
|
|
468
|
-
} catch {
|
|
469
|
-
log.error(`Could not resolve an IP for ${domain}. Deploy the site first, or pass --ip <mail-server-ip>.`);
|
|
470
|
-
process.exit(ExitCode.FatalError);
|
|
471
|
-
}
|
|
472
|
-
const { provisionMailTenant, reconcileMailDns } = await import("./deploy");
|
|
473
|
-
log.info(`Provisioning mail for ${domain} on ${ip}...`);
|
|
474
|
-
const res = await provisionMailTenant(ip, log);
|
|
475
|
-
if (res)
|
|
476
|
-
await reconcileMailDns(res, ip, log);
|
|
477
|
-
log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);
|
|
478
|
-
process.exit(ExitCode.Success);
|
|
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 = `
|
|
1
|
+
var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{getErrorMessage}from"@stacksjs/utils";import{CLI}from"@stacksjs/clapp";import{createHash,createHmac,randomBytes,timingSafeEqual}from"crypto";import{readFileSync,existsSync}from"node:fs";import{execSync,spawnSync}from"node:child_process";export function resolveDirectMailHost(env=process.env){let configured=env.MAIL_SERVER_HOST||(env.HCLOUD_TOKEN||env.HETZNER_API_TOKEN?env.MAIL_HOST:void 0);if(configured&&["127.0.0.1","localhost","mailpit"].includes(configured))configured=env.MAIL_DOMAIN?`mail.${env.MAIL_DOMAIN}`:void 0;if(!configured||configured==="127.0.0.1"||configured==="localhost")return;return configured.replace(/^https?:\/\//,"").split("/")[0]?.split(":")[0]}export function sanitizeLineCount(value){const count=Number.parseInt(value||"50",10);return String(Number.isFinite(count)?Math.min(5000,Math.max(1,count)):50)}function shellQuote(value){return`'${value.replace(/'/g,"'\\''")}'`}async function resolveOperationalMailHost(){if(process.env.MAIL_SERVER_HOST)return resolveDirectMailHost();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)return;const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const ip=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`,{headers:{Authorization:`Bearer ${token}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(ip)return ip}catch{}const configured=resolveDirectMailHost();if(configured)return configured;const{email}=await import("@stacksjs/config");return email?.domain?`mail.${email.domain}`:void 0}const mailStorageRemote="/usr/local/sbin/mail-storage-external";function loadMailStorageAws(options){const profile=options.profile||process.env.AWS_PROFILE||"default",region=options.region||process.env.AWS_REGION||"us-east-1";process.env.AWS_PROFILE=profile;loadEnvFiles();if(existsSync(".env.production"))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:".env.production",env:"production",keysFile:".env.keys",overload:!0,quiet:!0})}catch{}const credentialsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if((!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY)&&existsSync(credentialsPath)){const values={};let inProfile=!1;for(const line of readFileSync(credentialsPath,"utf8").split(`
|
|
2
|
+
`)){const trimmed=line.trim();if(trimmed.startsWith("[")){inProfile=trimmed===`[${profile}]`;continue}if(!inProfile)continue;const separator=trimmed.indexOf("=");if(separator>0)values[trimmed.slice(0,separator).trim()]=trimmed.slice(separator+1).trim()}if(values.aws_access_key_id&&values.aws_secret_access_key){process.env.AWS_ACCESS_KEY_ID=values.aws_access_key_id;process.env.AWS_SECRET_ACCESS_KEY=values.aws_secret_access_key;if(values.aws_session_token)process.env.AWS_SESSION_TOKEN=values.aws_session_token;else delete process.env.AWS_SESSION_TOKEN}}return{profile,region}}function mailStorageSecretId(options){if(options.secret)return options.secret;if(process.env.MAIL_STORAGE_SECRET_ID)return process.env.MAIL_STORAGE_SECRET_ID;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,"-");return`${app}/${environment}/mail-storage-key`}async function mailStorageHost(options){const host=options.host||await resolveOperationalMailHost();if(!host)throw Error("Could not resolve the mail host. Pass --host <hostname-or-ip>.");if(!/^[a-zA-Z0-9._:-]+$/.test(host))throw Error(`Unsafe mail host: ${host}`);return host}function runMailStorageSsh(host,command,input){const result=spawnSync("ssh",["-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${host}`,command],{input,maxBuffer:33554432});if(result.status!==0){const stderr=Buffer.from(result.stderr||[]).toString("utf8").trim();throw Error(stderr||`SSH command failed with status ${result.status}`)}return Buffer.from(result.stdout||[])}function readMailRecoveryKey(){const result=spawnSync("/usr/bin/security",["find-generic-password","-a","mail-storage-recovery","-s","com.stacks.production.mail.luks","-w"],{maxBuffer:1048576,timeout:30000});if(result.status!==0)throw Error("macOS Keychain recovery-key read failed");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);raw.fill(0);if(key.length!==64)throw Error("macOS Keychain returned an invalid recovery key");return key}async function waitForHetznerAction(token,actionId){const deadline=Date.now()+120000;while(Date.now()<deadline){const response=await fetch(`https://api.hetzner.cloud/v1/actions/${actionId}`,{headers:{Authorization:`Bearer ${token}`}}),data=await response.json();if(!response.ok)throw Error(data?.error?.message||`Hetzner action ${actionId} lookup failed`);if(data.action?.status==="success")return;if(data.action?.status==="error")throw Error(data.action?.error?.message||`Hetzner action ${actionId} failed`);await new Promise((resolve)=>setTimeout(resolve,1000))}throw Error(`Hetzner action ${actionId} timed out`)}async function ensureMailStorageVolume(options){loadEnvFiles();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)throw Error("HCLOUD_TOKEN or HETZNER_API_TOKEN is required.");const headers={Authorization:`Bearer ${token}`,"Content-Type":"application/json"},host=await mailStorageHost(options),server=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent("stacks-production-app")}`,{headers})).json()).servers?.[0];if(!server)throw Error("Hetzner server stacks-production-app was not found.");const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),name=`${app}-production-mail`,requestedSize=Number.parseInt(options.size||"20",10);if(!Number.isFinite(requestedSize)||requestedSize<10||requestedSize>1024)throw Error("Mail volume size must be between 10 and 1024 GiB.");let volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes?name=${encodeURIComponent(name)}`,{headers})).json()).volumes?.[0];if(!volume){const createResponse=await fetch("https://api.hetzner.cloud/v1/volumes",{method:"POST",headers,body:JSON.stringify({name,size:requestedSize,server:server.id,automount:!1,labels:{application:app,environment:"production",purpose:"mail-storage"}})}),createData=await createResponse.json();if(!createResponse.ok)throw Error(createData?.error?.message||"Hetzner volume creation failed.");volume=createData.volume;if(createData.action?.id)await waitForHetznerAction(token,createData.action.id)}else if(!volume.server){const attachResponse=await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}/actions/attach`,{method:"POST",headers,body:JSON.stringify({server:server.id,automount:!1})}),attachData=await attachResponse.json();if(!attachResponse.ok)throw Error(attachData?.error?.message||"Hetzner volume attach failed.");await waitForHetznerAction(token,attachData.action.id)}else if(volume.server!==server.id)throw Error(`Hetzner volume ${name} is attached to a different server.`);volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}`,{headers})).json()).volume||volume;if(!volume.linux_device)throw Error(`Hetzner volume ${name} has no Linux device path yet.`);return{device:volume.linux_device,host,id:volume.id,name,size:volume.size}}async function ensureMailKmsKey(options){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({service:"kms",region,method:"POST",path:"/",headers:{"Content-Type":"application/x-amz-json-1.1","X-Amz-Target":`TrentService.${target}`},body:JSON.stringify(body)}),alias="alias/stacks-production-mail";let keyId=(await request("ListAliases",{Limit:100})).Aliases?.find((item)=>item.AliasName===alias)?.TargetKeyId;if(!keyId){keyId=(await request("CreateKey",{Description:"Customer-managed key for production mail secrets and backups",KeyUsage:"ENCRYPT_DECRYPT",Origin:"AWS_KMS",Tags:[{TagKey:"Application",TagValue:"stacks"},{TagKey:"Environment",TagValue:"production"},{TagKey:"Purpose",TagValue:"mail-storage"}]})).KeyMetadata?.KeyId;if(!keyId)throw Error("AWS KMS did not return a key ID.");await request("CreateAlias",{AliasName:alias,TargetKeyId:keyId});await request("EnableKeyRotation",{KeyId:keyId,RotationPeriodInDays:90})}const arn=(await request("DescribeKey",{KeyId:keyId})).KeyMetadata?.Arn;if(!arn)throw Error("AWS KMS did not return a key ARN.");const secrets=new SecretsManagerClient(region);for(const secretId of["stacks/production/mail-storage-key","stacks/production/mail-restic"])try{await secrets.updateSecret({SecretId:secretId,KmsKeyId:arn})}catch(error){if(error?.code!=="ResourceNotFoundException")throw Error(`Could not re-encrypt secret ${secretId}: ${getErrorMessage(error)}`)}const s3=new S3Client(region);for(const bucket of["stacks-production-s3-backups","stacks-production-s3-email","stacks-production-email"])try{await s3.putBucketEncryption(bucket,"aws:kms",arn)}catch(error){throw Error(`Could not apply KMS encryption to ${bucket}: ${getErrorMessage(error)}`)}return{alias,arn}}async function configureMailRestic(options){const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),bucket=options.bucket||"stacks-production-s3-backups";if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);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({service:"iam",region:"us-east-1",method:"POST",path:"/",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({Action:action,Version:"2010-05-08",...params}).toString()}),userName="stacks-production-mail-backup";try{await iamRequest("GetUser",{UserName:userName})}catch(error){if(error?.code!=="NoSuchEntity")throw error;await iamRequest("CreateUser",{UserName:userName,"Tags.member.1.Key":"Application","Tags.member.1.Value":"stacks","Tags.member.2.Key":"Environment","Tags.member.2.Value":"production","Tags.member.3.Key":"Purpose","Tags.member.3.Value":"mail-backup"})}await iamRequest("PutUserPolicy",{UserName:userName,PolicyName:"mail-restic-backup",PolicyDocument:JSON.stringify({Version:"2012-10-17",Statement:[{Effect:"Allow",Action:["s3:ListBucket","s3:GetBucketLocation"],Resource:`arn:aws:s3:::${bucket}`,Condition:{StringLike:{"s3:prefix":["restic-mail","restic-mail/*"]}}},{Effect:"Allow",Action:["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],Resource:`arn:aws:s3:::${bucket}/restic-mail/*`},{Effect:"Allow",Action:["kms:Decrypt","kms:Encrypt","kms:GenerateDataKey","kms:DescribeKey"],Resource:kms.arn}]})});const listedMembers=(await iamRequest("ListAccessKeys",{UserName:userName})).ListAccessKeysResult?.AccessKeyMetadata?.member,oldKeys=listedMembers?Array.isArray(listedMembers)?listedMembers:[listedMembers]:[];if(oldKeys.length>0&&!options.force)throw Error(`IAM user ${userName} already has a key. Pass --force to rotate it.`);if(oldKeys.length>=2)for(const old of oldKeys)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});const access=(await iamRequest("CreateAccessKey",{UserName:userName})).CreateAccessKeyResult?.AccessKey;if(!access?.AccessKeyId||!access?.SecretAccessKey)throw Error("AWS IAM did not return the Restic access key.");const password=randomBytes(48).toString("base64url"),repository=`s3:s3.amazonaws.com/${bucket}/restic-mail`,secretId="stacks/production/mail-restic",secretPayload=JSON.stringify({version:1,repository,password,accessKeyId:access.AccessKeyId,secretAccessKey:access.SecretAccessKey,kmsKeyArn:kms.arn,createdAt:new Date().toISOString()}),secrets=new SecretsManagerClient(region);try{await secrets.createSecret({Name:secretId,Description:"Restic credentials for production mail backups",KmsKeyId:kms.arn,SecretString:secretPayload,Tags:[{Key:"Application",Value:"stacks"},{Key:"Environment",Value:"production"},{Key:"Purpose",Value:"mail-backup"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.updateSecret({SecretId:secretId,KmsKeyId:kms.arn,SecretString:secretPayload})}const configDir="/var/lib/mail-storage/data/restic",passwordPath=`${configDir}/password`,envPayload=[`RESTIC_REPOSITORY=${repository}`,`RESTIC_PASSWORD_FILE=${passwordPath}`,`AWS_ACCESS_KEY_ID=${access.AccessKeyId}`,`AWS_SECRET_ACCESS_KEY=${access.SecretAccessKey}`,`AWS_DEFAULT_REGION=${region}`,""].join(`
|
|
3
|
+
`);runMailStorageSsh(host,`install -d -m 0700 ${configDir}; umask 077; dd of=${configDir}/restic.env status=none`,Buffer.from(envPayload));runMailStorageSsh(host,`umask 077; dd of=${passwordPath} status=none`,Buffer.from(`${password}
|
|
4
|
+
`));runMailStorageSsh(host,`set -a; . ${configDir}/restic.env; set +a; restic snapshots >/dev/null 2>&1 || restic init`);runMailStorageSsh(host,"systemctl enable --now mail-restic-backup.timer mail-storage-health.timer");runMailStorageSsh(host,"systemctl start mail-restic-backup.service");runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check");for(const old of oldKeys)if(old.AccessKeyId!==access.AccessKeyId)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});return{repository,secretId}}function decodeMailStorageSecret(secretString){if(!secretString)throw Error("The mail storage secret has no SecretString value.");let parsed;try{parsed=JSON.parse(secretString)}catch{throw Error("The mail storage secret is not valid JSON.")}if(parsed.version!==1||parsed.encoding!=="base64"||typeof parsed.key!=="string")throw Error("The mail storage secret uses an unsupported format.");const key=Buffer.from(parsed.key,"base64");if(key.length<32)throw Error("The mail storage key is unexpectedly short.");return key}async function getMailStorageSecret(options){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});return{key:decodeMailStorageSecret(value.SecretString),secretId}}async function escrowMailStorageKey(options){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 -");if(key.length<32)throw Error("The machine-bound mail storage key is unexpectedly short.");let existing;try{const value=await secrets.getSecretValue({SecretId:secretId});existing=decodeMailStorageSecret(value.SecretString)}catch(error){if(error?.code!=="ResourceNotFoundException")throw error}if(existing&&!timingSafeEqual(existing,key)){existing.fill(0);if(!options.force){key.fill(0);throw Error(`Secret ${secretId} already contains a different key. Pass --force to replace it.`)}}existing?.fill(0);const payload={version:1,encoding:"base64",key:key.toString("base64"),image:"/var/lib/mail-storage.luks",mapper:"mail-storage",createdAt:new Date().toISOString()};if(existing)await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)});else try{await secrets.createSecret({Name:secretId,Description:`Externally escrowed LUKS2 key for ${host}`,SecretString:JSON.stringify(payload),Tags:[{Key:"Application",Value:process.env.APP_NAME||"stacks"},{Key:"Environment",Value:process.env.APP_ENV||"production"},{Key:"Purpose",Value:"mail-storage"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)})}const verify=await secrets.getSecretValue({SecretId:secretId}),verifiedKey=decodeMailStorageSecret(verify.SecretString),matches=verifiedKey.length===key.length&&timingSafeEqual(verifiedKey,key);verifiedKey.fill(0);key.fill(0);if(!matches)throw Error(`Secret ${secretId} did not verify after writing.`);return{secretId,host}}function addMailStorageOptions(command){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"})}export function mailCommands(buddy){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)=>{const environment=options.env||process.env.APP_ENV||"production";process.env.APP_ENV=environment;const envFile=`.env.${environment}`;if(existsSync(envFile))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:environment,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}const{email:emailConfig}=await import("@stacksjs/config"),domain=emailConfig?.domain;if(!domain){log.error("config/email.ts has no `domain` \u2014 set it (e.g. domain: 'bughq.org') and add `mailboxes`.");process.exit(ExitCode.FatalError)}let ip=options.ip;if(!ip)try{ip=(await(await import("node:dns")).promises.resolve4(domain))[0]}catch{log.error(`Could not resolve an IP for ${domain}. Deploy the site first, or pass --ip <mail-server-ip>.`);process.exit(ExitCode.FatalError)}const{provisionMailTenant,reconcileMailDns}=await import("./deploy");log.info(`Provisioning mail for ${domain} on ${ip}...`);const res=await provisionMailTenant(ip,log);if(res)await reconcileMailDns(res,ip,log);log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);process.exit(ExitCode.Success)});addMailStorageOptions(buddy.command("mail:storage:status","Show encrypted mail storage and external-key status")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to read mail storage status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:kms:ensure","Create and apply the customer-managed production mail KMS key")).action(async(options)=>{try{const kms=await ensureMailKmsKey(options);console.log(`alias=${kms.alias}`);console.log(`arn=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure mail KMS: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});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)=>{let key,prepareAttempted=!1;try{const volume=await ensureMailStorageVolume(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(volume.host,`for i in $(seq 1 60); do test -b ${shellQuote(volume.device)} && exit 0; sleep 1; done; exit 1`);prepareAttempted=!0;runMailStorageSsh(volume.host,["umask 077","key_file=$(mktemp /run/mail-volume-migrate-key.XXXXXX)",`trap 'shred -u "$key_file" 2>/dev/null || rm -f "$key_file"' EXIT`,'dd of="$key_file" bs=64 count=1 iflag=fullblock status=none',`MAIL_ENCRYPTED_IMAGE_SIZE=16G /usr/local/sbin/mail-volume-migrate prepare ${shellQuote(volume.device)}`,`${mailStorageRemote} unlock < "$key_file"`,"/usr/local/sbin/mail-volume-migrate finalize"].join(" && "),key);const status=runMailStorageSsh(volume.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(`volume=${volume.name}`);console.log(`volume_id=${volume.id}`);console.log(`volume_size_gib=${volume.size}`);console.log(status);process.exit(ExitCode.Success)}catch(error){if(prepareAttempted&&key){const host=await mailStorageHost(options);try{runMailStorageSsh(host,"/usr/local/sbin/mail-volume-migrate rollback")}catch(rollbackError){console.error(`Dedicated-volume rollback step failed: ${getErrorMessage(rollbackError)}`)}try{runMailStorageSsh(host,`${mailStorageRemote} unlock`,key)}catch(unlockError){console.error(`Mail storage re-unlock failed: ${getErrorMessage(unlockError)}`)}}console.error(`Failed to migrate mail storage volume: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:add","Add an independent LUKS recovery key to macOS Keychain")).action(async(options)=>{let currentKey,recoveryKey;try{const host=await mailStorageHost(options);currentKey=(await getMailStorageSecret(options)).key;recoveryKey=randomBytes(64);runMailStorageSsh(host,`${mailStorageRemote} add-recovery-key`,Buffer.concat([currentKey,recoveryKey]));const account="mail-storage-recovery",service="com.stacks.production.mail.luks",encoded=recoveryKey.toString("base64"),keychainWriter=`
|
|
552
5
|
import Foundation
|
|
553
6
|
import Security
|
|
554
7
|
|
|
@@ -602,931 +55,16 @@ export function mailCommands(buddy) {
|
|
|
602
55
|
status = SecItemCopyMatching(verificationQuery as CFDictionary, &result)
|
|
603
56
|
guard status == errSecSuccess, let stored = result as? Data, stored == secret
|
|
604
57
|
else { exit(3) }
|
|
605
|
-
`;
|
|
606
|
-
if (spawnSync("/usr/bin/swift", ["-e", keychainWriter], {
|
|
607
|
-
|
|
608
|
-
`,
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
});
|
|
840
|
-
buddy.command("mail:user:add <email>", "Add a mail user").option("--password <password>", "User password (generated if not provided)").action(async (email, options) => {
|
|
841
|
-
log.info(`Adding mail user: ${email}`);
|
|
842
|
-
try {
|
|
843
|
-
const password = options.password || randomBytes(16).toString("base64").replace(/[+/=]/g, "").substring(0, 16), passwordHash = createHash("sha256").update(password).digest("hex"), tableName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-mail-users`, { DynamoDBClient } = await import("@stacksjs/ts-cloud");
|
|
844
|
-
await new DynamoDBClient(process.env.AWS_REGION || "us-east-1").putItem({
|
|
845
|
-
TableName: tableName,
|
|
846
|
-
Item: {
|
|
847
|
-
email: { S: email.toLowerCase() },
|
|
848
|
-
passwordHash: { S: passwordHash },
|
|
849
|
-
createdAt: { S: new Date().toISOString() }
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
log.success(`User ${email} added successfully!`);
|
|
853
|
-
console.log("");
|
|
854
|
-
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
855
|
-
console.log(" MAIL CREDENTIALS");
|
|
856
|
-
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
857
|
-
console.log("");
|
|
858
|
-
console.log(` Email: ${email}`);
|
|
859
|
-
console.log(` Password: ${password}`);
|
|
860
|
-
console.log("");
|
|
861
|
-
console.log(" To connect with Mail.app, run:");
|
|
862
|
-
console.log(" buddy mail:proxy");
|
|
863
|
-
console.log("");
|
|
864
|
-
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
865
|
-
console.log("");
|
|
866
|
-
if (!options.password)
|
|
867
|
-
log.warn("Save this password! It will not be shown again.");
|
|
868
|
-
process.exit(ExitCode.Success);
|
|
869
|
-
} catch (error) {
|
|
870
|
-
log.error(`Failed to add user: ${getErrorMessage(error)}`);
|
|
871
|
-
process.exit(ExitCode.FatalError);
|
|
872
|
-
}
|
|
873
|
-
});
|
|
874
|
-
buddy.command("mail:user:list", "List mail users").action(async () => {
|
|
875
|
-
try {
|
|
876
|
-
const tableName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-mail-users`, { DynamoDBClient } = await import("@stacksjs/ts-cloud"), result = await new DynamoDBClient(process.env.AWS_REGION || "us-east-1").scan({ TableName: tableName });
|
|
877
|
-
console.log("");
|
|
878
|
-
console.log("Mail Users:");
|
|
879
|
-
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
880
|
-
if (!result.Items || result.Items.length === 0)
|
|
881
|
-
console.log(" No users found");
|
|
882
|
-
else
|
|
883
|
-
for (const item of result.Items) {
|
|
884
|
-
const email = item.email?.S || "unknown", createdAt = item.createdAt?.S || "unknown";
|
|
885
|
-
console.log(` \uD83D\uDCE7 ${email}`);
|
|
886
|
-
console.log(` Created: ${createdAt}`);
|
|
887
|
-
}
|
|
888
|
-
console.log("");
|
|
889
|
-
process.exit(ExitCode.Success);
|
|
890
|
-
} catch (error) {
|
|
891
|
-
log.error(`Failed to list users: ${getErrorMessage(error)}`);
|
|
892
|
-
process.exit(ExitCode.FatalError);
|
|
893
|
-
}
|
|
894
|
-
});
|
|
895
|
-
buddy.command("mail:user:delete <email>", "Delete a mail user").action(async (email) => {
|
|
896
|
-
try {
|
|
897
|
-
const tableName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-mail-users`, { DynamoDBClient } = await import("@stacksjs/ts-cloud");
|
|
898
|
-
await new DynamoDBClient(process.env.AWS_REGION || "us-east-1").deleteItem({
|
|
899
|
-
TableName: tableName,
|
|
900
|
-
Key: { email: { S: email.toLowerCase() } }
|
|
901
|
-
});
|
|
902
|
-
log.success(`User ${email} deleted`);
|
|
903
|
-
process.exit(ExitCode.Success);
|
|
904
|
-
} catch (error) {
|
|
905
|
-
log.error(`Failed to delete user: ${getErrorMessage(error)}`);
|
|
906
|
-
process.exit(ExitCode.FatalError);
|
|
907
|
-
}
|
|
908
|
-
});
|
|
909
|
-
buddy.command("mail:proxy", "Start local IMAP proxy for Mail.app").option("--port <port>", "IMAP proxy port", { default: "1993" }).option("--api <url>", "Mail API URL").action(async (options) => {
|
|
910
|
-
log.info("Starting IMAP proxy...");
|
|
911
|
-
try {
|
|
912
|
-
let apiUrl = options.api;
|
|
913
|
-
if (!apiUrl) {
|
|
914
|
-
const { AWSCloudFormationClient } = await import("@stacksjs/ts-cloud"), cfn = new AWSCloudFormationClient(process.env.AWS_REGION || "us-east-1"), stackName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-cloud`;
|
|
915
|
-
try {
|
|
916
|
-
apiUrl = ((await cfn.describeStacks({ stackName })).Stacks?.[0]?.Outputs || []).find((o) => o.OutputKey === "MailApiUrl")?.OutputValue;
|
|
917
|
-
} catch (e) {}
|
|
918
|
-
}
|
|
919
|
-
if (!apiUrl) {
|
|
920
|
-
log.error("Mail API URL not found. Run `buddy deploy` first to create the mail infrastructure.");
|
|
921
|
-
process.exit(ExitCode.FatalError);
|
|
922
|
-
}
|
|
923
|
-
console.log("");
|
|
924
|
-
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
925
|
-
console.log(" IMAP PROXY");
|
|
926
|
-
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
927
|
-
console.log("");
|
|
928
|
-
console.log(` API URL: ${apiUrl}`);
|
|
929
|
-
console.log(` Port: ${options.port}`);
|
|
930
|
-
console.log("");
|
|
931
|
-
console.log(" Configure Mail.app with:");
|
|
932
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
933
|
-
console.log(" Account Type: IMAP");
|
|
934
|
-
console.log(" Incoming Server: localhost");
|
|
935
|
-
console.log(` Incoming Port: ${options.port}`);
|
|
936
|
-
console.log(" SSL: No (local connection)");
|
|
937
|
-
console.log(" Outgoing Server: localhost");
|
|
938
|
-
console.log(` Outgoing Port: ${options.port}`);
|
|
939
|
-
console.log(" Username: your-email@stacksjs.com");
|
|
940
|
-
console.log(" Password: your-password");
|
|
941
|
-
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
942
|
-
console.log("");
|
|
943
|
-
process.env.MAIL_API_URL = apiUrl;
|
|
944
|
-
process.env.IMAP_PORT = options.port;
|
|
945
|
-
const { ImapProxy } = await import("../../mail-server/src/proxy/imap-proxy"), proxy = new ImapProxy(Number.parseInt(options.port, 10), apiUrl);
|
|
946
|
-
await proxy.start();
|
|
947
|
-
process.on("SIGINT", () => {
|
|
948
|
-
console.log(`
|
|
949
|
-
Shutting down proxy...`);
|
|
950
|
-
proxy.stop();
|
|
951
|
-
process.exit(0);
|
|
952
|
-
});
|
|
953
|
-
} catch (error) {
|
|
954
|
-
log.error(`Failed to start proxy: ${getErrorMessage(error)}`);
|
|
955
|
-
process.exit(ExitCode.FatalError);
|
|
956
|
-
}
|
|
957
|
-
});
|
|
958
|
-
buddy.command("mail:test", "Test mail API connection").action(async () => {
|
|
959
|
-
try {
|
|
960
|
-
const { AWSCloudFormationClient } = await import("@stacksjs/ts-cloud"), cfn = new AWSCloudFormationClient(process.env.AWS_REGION || "us-east-1"), stackName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-cloud`, mailApiOutput = ((await cfn.describeStacks({ stackName })).Stacks?.[0]?.Outputs || []).find((o) => o.OutputKey === "MailApiUrl");
|
|
961
|
-
if (!mailApiOutput?.OutputValue) {
|
|
962
|
-
log.error("Mail API not deployed. Run `buddy deploy` first.");
|
|
963
|
-
process.exit(ExitCode.FatalError);
|
|
964
|
-
}
|
|
965
|
-
const apiUrl = mailApiOutput.OutputValue;
|
|
966
|
-
log.info(`Testing Mail API at ${apiUrl}`);
|
|
967
|
-
const response = await fetch(`${apiUrl}/mailboxes`, {
|
|
968
|
-
headers: {
|
|
969
|
-
Authorization: `Basic ${Buffer.from("test@stacksjs.com:test").toString("base64")}`
|
|
970
|
-
}
|
|
971
|
-
});
|
|
972
|
-
if (response.status === 401)
|
|
973
|
-
log.success("Mail API is responding (auth required)");
|
|
974
|
-
else if (response.ok) {
|
|
975
|
-
log.success("Mail API is working!");
|
|
976
|
-
const data = await response.json();
|
|
977
|
-
console.log("Response:", JSON.stringify(data, null, 2));
|
|
978
|
-
} else
|
|
979
|
-
log.warn(`Mail API returned status ${response.status}`);
|
|
980
|
-
process.exit(ExitCode.Success);
|
|
981
|
-
} catch (error) {
|
|
982
|
-
log.error(`Failed to test API: ${getErrorMessage(error)}`);
|
|
983
|
-
process.exit(ExitCode.FatalError);
|
|
984
|
-
}
|
|
985
|
-
});
|
|
986
|
-
buddy.command("mail:credentials [email]", "Show SMTP credentials for email access").action(async (emailAddress) => {
|
|
987
|
-
loadEnvFiles();
|
|
988
|
-
const domain = process.env.APP_DOMAIN || process.env.MAIL_DOMAIN || "stacksjs.com", mailHost = `mail.${domain}`, email = emailAddress || `chris@${domain}`, localPart = email.includes("@") ? email.split("@")[0] : email, username = email, envKey = `MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`, rawPassword = process.env[envKey], isCiphertext = typeof rawPassword === "string" && rawPassword.replace(/^["']/, "").startsWith("encrypted:"), password = isCiphertext ? void 0 : rawPassword;
|
|
989
|
-
console.log("");
|
|
990
|
-
console.log("==========================================================");
|
|
991
|
-
console.log(` EMAIL CREDENTIALS \u2014 ${email}`);
|
|
992
|
-
console.log("==========================================================");
|
|
993
|
-
console.log("");
|
|
994
|
-
console.log(` Email: ${email}`);
|
|
995
|
-
console.log(` Host: ${mailHost}`);
|
|
996
|
-
console.log(" Port: 587");
|
|
997
|
-
console.log(" Security: STARTTLS");
|
|
998
|
-
console.log(` Username: ${username}`);
|
|
999
|
-
if (password)
|
|
1000
|
-
console.log(` Password: ${password}`);
|
|
1001
|
-
else if (isCiphertext)
|
|
1002
|
-
console.log(" Password: (encrypted \u2014 this machine has no private key for .env.production)");
|
|
1003
|
-
else
|
|
1004
|
-
console.log(` Password: (not set \u2014 add ${envKey} to .env.production)`);
|
|
1005
|
-
console.log("");
|
|
1006
|
-
console.log("==========================================================");
|
|
1007
|
-
console.log("");
|
|
1008
|
-
process.exit(ExitCode.Success);
|
|
1009
|
-
});
|
|
1010
|
-
buddy.command("mail:logs", "Show mail server logs from production").option("-n, --lines <count>", "Number of log lines to show", { default: "50" }).option("-f, --follow", "Follow log output (poll every 5s)").option("--filter <pattern>", "Filter logs by pattern (e.g. AUTH, LOGIN, error)").action(async (options) => {
|
|
1011
|
-
loadEnvFiles();
|
|
1012
|
-
const directHost = await resolveOperationalMailHost();
|
|
1013
|
-
if (directHost) {
|
|
1014
|
-
const lines = sanitizeLineCount(options.lines), filter = options.filter ? options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g, "") : "", command = `journalctl -u mail --no-pager -n ${lines}${filter ? ` | grep -iE ${shellQuote(filter)}` : ""}`;
|
|
1015
|
-
try {
|
|
1016
|
-
const output = execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`, { encoding: "utf8" });
|
|
1017
|
-
console.log(output);
|
|
1018
|
-
process.exit(ExitCode.Success);
|
|
1019
|
-
} catch (error) {
|
|
1020
|
-
log.error(`Failed to fetch logs from ${directHost}: ${getErrorMessage(error)}`);
|
|
1021
|
-
process.exit(ExitCode.FatalError);
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1025
|
-
try {
|
|
1026
|
-
const { EC2Client, SSMClient } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region), ssm = new SSMClient(region);
|
|
1027
|
-
log.info("Finding mail server...");
|
|
1028
|
-
const instanceId = (await ec2.describeInstances({
|
|
1029
|
-
Filters: [
|
|
1030
|
-
{ Name: "tag:Name", Values: [`${appName}-mail-server`] },
|
|
1031
|
-
{ Name: "instance-state-name", Values: ["running"] }
|
|
1032
|
-
]
|
|
1033
|
-
})).Reservations?.[0]?.Instances?.[0]?.InstanceId;
|
|
1034
|
-
if (!instanceId) {
|
|
1035
|
-
log.error("No running mail server found.");
|
|
1036
|
-
log.info(`Looked for instances tagged: ${appName}-mail-server`);
|
|
1037
|
-
process.exit(ExitCode.FatalError);
|
|
1038
|
-
}
|
|
1039
|
-
log.info(`Found instance: ${instanceId}`);
|
|
1040
|
-
const lines = sanitizeLineCount(options.lines), sanitizedFilter = options.filter ? options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g, "") : "", filterCmd = sanitizedFilter ? ` | grep -iE '${sanitizedFilter}'` : "", fetchLogs = async () => {
|
|
1041
|
-
const cmd = `journalctl -u smtp-server -u mail-server --no-pager -n ${lines} --since "10 minutes ago" 2>&1${filterCmd}`, result = await ssm.runShellCommand(instanceId, [cmd], {
|
|
1042
|
-
timeoutSeconds: 30,
|
|
1043
|
-
maxWaitMs: 30000,
|
|
1044
|
-
pollIntervalMs: 2000
|
|
1045
|
-
});
|
|
1046
|
-
if (!result.success) {
|
|
1047
|
-
log.error(`Failed to fetch logs: ${result.error || "Unknown error"}`);
|
|
1048
|
-
return !1;
|
|
1049
|
-
}
|
|
1050
|
-
if (result.output) {
|
|
1051
|
-
const logLines = result.output.trim().split(`
|
|
1052
|
-
`);
|
|
1053
|
-
for (const line of logLines)
|
|
1054
|
-
if (line.includes("error") || line.includes("Error") || line.includes("AUTH failed"))
|
|
1055
|
-
console.log(` \x1B[31m${line}\x1B[0m`);
|
|
1056
|
-
else if (line.includes("warning") || line.includes("Warning"))
|
|
1057
|
-
console.log(` \x1B[33m${line}\x1B[0m`);
|
|
1058
|
-
else if (line.includes("AUTH success") || line.includes("LOGIN completed") || line.includes("authenticated"))
|
|
1059
|
-
console.log(` \x1B[32m${line}\x1B[0m`);
|
|
1060
|
-
else
|
|
1061
|
-
console.log(` ${line}`);
|
|
1062
|
-
} else
|
|
1063
|
-
console.log(" No log entries found.");
|
|
1064
|
-
return !0;
|
|
1065
|
-
};
|
|
1066
|
-
console.log("");
|
|
1067
|
-
console.log("==========================================================");
|
|
1068
|
-
console.log(" MAIL SERVER LOGS");
|
|
1069
|
-
console.log("==========================================================");
|
|
1070
|
-
console.log("");
|
|
1071
|
-
await fetchLogs();
|
|
1072
|
-
if (options.follow) {
|
|
1073
|
-
console.log("");
|
|
1074
|
-
log.info("Following logs (Ctrl+C to stop)...");
|
|
1075
|
-
let polling = !1;
|
|
1076
|
-
const poll = setInterval(() => {
|
|
1077
|
-
if (polling)
|
|
1078
|
-
return;
|
|
1079
|
-
polling = !0;
|
|
1080
|
-
console.log("");
|
|
1081
|
-
fetchLogs().catch((e) => log.error("Log polling failed:", e)).finally(() => {
|
|
1082
|
-
polling = !1;
|
|
1083
|
-
});
|
|
1084
|
-
}, 5000);
|
|
1085
|
-
await new Promise((resolve) => {
|
|
1086
|
-
process.on("SIGINT", () => {
|
|
1087
|
-
clearInterval(poll);
|
|
1088
|
-
resolve();
|
|
1089
|
-
});
|
|
1090
|
-
});
|
|
1091
|
-
}
|
|
1092
|
-
console.log("");
|
|
1093
|
-
process.exit(ExitCode.Success);
|
|
1094
|
-
} catch (error) {
|
|
1095
|
-
log.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
|
|
1096
|
-
process.exit(ExitCode.FatalError);
|
|
1097
|
-
}
|
|
1098
|
-
});
|
|
1099
|
-
buddy.command("mail:status", "Show mail server status").action(async () => {
|
|
1100
|
-
loadEnvFiles();
|
|
1101
|
-
const directHost = await resolveOperationalMailHost();
|
|
1102
|
-
if (directHost) {
|
|
1103
|
-
const command = 'systemctl is-active mail; systemctl show mail -p NRestarts -p ActiveEnterTimestamp --value; ss -H -ltn | grep -E "(:(25|143|465|587|993))\\b"; systemctl is-enabled mail-health.timer; systemctl is-active mail-health.timer';
|
|
1104
|
-
try {
|
|
1105
|
-
const output = execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`, { encoding: "utf8" });
|
|
1106
|
-
console.log(output);
|
|
1107
|
-
process.exit(ExitCode.Success);
|
|
1108
|
-
} catch (error) {
|
|
1109
|
-
log.error(`Mail server ${directHost} is unhealthy: ${getErrorMessage(error)}`);
|
|
1110
|
-
process.exit(ExitCode.FatalError);
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1114
|
-
try {
|
|
1115
|
-
const { EC2Client, SSMClient } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region), ssm = new SSMClient(region), instanceId = (await ec2.describeInstances({
|
|
1116
|
-
Filters: [
|
|
1117
|
-
{ Name: "tag:Name", Values: [`${appName}-mail-server`] },
|
|
1118
|
-
{ Name: "instance-state-name", Values: ["running"] }
|
|
1119
|
-
]
|
|
1120
|
-
})).Reservations?.[0]?.Instances?.[0]?.InstanceId;
|
|
1121
|
-
if (!instanceId) {
|
|
1122
|
-
log.error("No running mail server found.");
|
|
1123
|
-
log.info(`Looked for instances tagged: ${appName}-mail-server`);
|
|
1124
|
-
process.exit(ExitCode.FatalError);
|
|
1125
|
-
}
|
|
1126
|
-
const result = await ssm.runShellCommand(instanceId, [
|
|
1127
|
-
"echo === Service ===",
|
|
1128
|
-
"(systemctl status smtp-server --no-pager 2>&1 || systemctl status mail-server --no-pager 2>&1) | head -15",
|
|
1129
|
-
"echo === Ports ===",
|
|
1130
|
-
'ss -tlnp 2>&1 | grep -E "(25|587|465|993|143|110|995)" || echo "No mail ports listening"',
|
|
1131
|
-
"echo === Memory ===",
|
|
1132
|
-
`ps aux | grep -E "(smtp-server|bun|mail)" | grep -v grep | awk '{print $6/1024 "MB", $11}'`
|
|
1133
|
-
], {
|
|
1134
|
-
timeoutSeconds: 30,
|
|
1135
|
-
maxWaitMs: 30000,
|
|
1136
|
-
pollIntervalMs: 2000
|
|
1137
|
-
});
|
|
1138
|
-
console.log("");
|
|
1139
|
-
console.log("==========================================================");
|
|
1140
|
-
console.log(" MAIL SERVER STATUS");
|
|
1141
|
-
console.log("==========================================================");
|
|
1142
|
-
console.log("");
|
|
1143
|
-
if (result.success && result.output)
|
|
1144
|
-
for (const line of result.output.trim().split(`
|
|
1145
|
-
`))
|
|
1146
|
-
console.log(` ${line}`);
|
|
1147
|
-
else
|
|
1148
|
-
log.error(`Failed: ${result.error}`);
|
|
1149
|
-
console.log("");
|
|
1150
|
-
process.exit(ExitCode.Success);
|
|
1151
|
-
} catch (error) {
|
|
1152
|
-
log.error(`Failed to get status: ${getErrorMessage(error)}`);
|
|
1153
|
-
process.exit(ExitCode.FatalError);
|
|
1154
|
-
}
|
|
1155
|
-
});
|
|
1156
|
-
buddy.command("mail:port25:request", "Request port 25 unblock for direct SMTP delivery").option("--provider <provider>", "Cloud provider: aws or hetzner", { default: "aws" }).option("--instance-id <id>", "AWS EC2 instance ID (auto-detected if not provided)").option("--server-id <id>", "Hetzner server ID (auto-detected if not provided)").option("--elastic-ip <ip>", "AWS Elastic IP address").option("--rdns <hostname>", "Reverse DNS hostname (e.g. mail.stacksjs.com)").option("--use-case <text>", "Description of email use case").action(async (options) => {
|
|
1157
|
-
loadEnvFiles();
|
|
1158
|
-
const domain = process.env.APP_DOMAIN || process.env.MAIL_DOMAIN || "stacksjs.com", provider = options.provider.toLowerCase();
|
|
1159
|
-
if (provider === "aws")
|
|
1160
|
-
await requestAwsPort25(options, domain);
|
|
1161
|
-
else if (provider === "hetzner")
|
|
1162
|
-
await requestHetznerPort25(options, domain);
|
|
1163
|
-
else {
|
|
1164
|
-
log.error(`Unknown provider: ${provider}. Use 'aws' or 'hetzner'.`);
|
|
1165
|
-
process.exit(ExitCode.FatalError);
|
|
1166
|
-
}
|
|
1167
|
-
});
|
|
1168
|
-
buddy.command("mail:port25:status", "Check outbound port 25 status on the mail server").option("--provider <provider>", "Cloud provider: aws or hetzner", { default: "aws" }).action(async (options) => {
|
|
1169
|
-
loadEnvFiles();
|
|
1170
|
-
const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1171
|
-
if (options.provider === "hetzner") {
|
|
1172
|
-
const hetznerToken = process.env.HETZNER_API_TOKEN;
|
|
1173
|
-
if (!hetznerToken) {
|
|
1174
|
-
log.error("HETZNER_API_TOKEN not set");
|
|
1175
|
-
process.exit(ExitCode.FatalError);
|
|
1176
|
-
}
|
|
1177
|
-
try {
|
|
1178
|
-
const ip = (await (await fetch("https://api.hetzner.cloud/v1/servers?label_selector=service=mail", {
|
|
1179
|
-
headers: { Authorization: `Bearer ${hetznerToken}` }
|
|
1180
|
-
})).json()).servers?.[0]?.public_net?.ipv4?.ip;
|
|
1181
|
-
if (!ip) {
|
|
1182
|
-
const mailServer = (await (await fetch("https://api.hetzner.cloud/v1/servers", {
|
|
1183
|
-
headers: { Authorization: `Bearer ${hetznerToken}` }
|
|
1184
|
-
})).json()).servers?.find((s) => s.name?.includes("mail"));
|
|
1185
|
-
if (mailServer) {
|
|
1186
|
-
const serverIp = mailServer.public_net?.ipv4?.ip;
|
|
1187
|
-
log.info(`Found Hetzner server: ${mailServer.name} (${serverIp})`);
|
|
1188
|
-
const result = execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${serverIp} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`, { encoding: "utf-8" });
|
|
1189
|
-
printPort25Status(result.trim(), "Hetzner", serverIp);
|
|
1190
|
-
} else
|
|
1191
|
-
log.error("No mail server found on Hetzner");
|
|
1192
|
-
} else {
|
|
1193
|
-
const result = execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${ip} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`, { encoding: "utf-8" });
|
|
1194
|
-
printPort25Status(result.trim(), "Hetzner", ip);
|
|
1195
|
-
}
|
|
1196
|
-
} catch (error) {
|
|
1197
|
-
log.error(`Failed: ${getErrorMessage(error)}`);
|
|
1198
|
-
}
|
|
1199
|
-
process.exit(ExitCode.Success);
|
|
1200
|
-
}
|
|
1201
|
-
try {
|
|
1202
|
-
const { EC2Client, SSMClient } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region), ssm = new SSMClient(region), instance = (await ec2.describeInstances({
|
|
1203
|
-
Filters: [
|
|
1204
|
-
{ Name: "tag:Name", Values: [`${appName}-mail-server`] },
|
|
1205
|
-
{ Name: "instance-state-name", Values: ["running"] }
|
|
1206
|
-
]
|
|
1207
|
-
})).Reservations?.[0]?.Instances?.[0], instanceId = instance?.InstanceId;
|
|
1208
|
-
if (!instanceId) {
|
|
1209
|
-
log.error("No running mail server found.");
|
|
1210
|
-
process.exit(ExitCode.FatalError);
|
|
1211
|
-
}
|
|
1212
|
-
const publicIp = instance?.PublicIpAddress || "unknown";
|
|
1213
|
-
log.info(`Testing port 25 on ${instanceId} (${publicIp})...`);
|
|
1214
|
-
const result = await ssm.runShellCommand(instanceId, [
|
|
1215
|
-
'timeout 5 bash -c "echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25" 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED'
|
|
1216
|
-
], { timeoutSeconds: 15, maxWaitMs: 20000, pollIntervalMs: 2000 });
|
|
1217
|
-
printPort25Status(result.output?.trim() || "PORT_25_BLOCKED", "AWS", publicIp);
|
|
1218
|
-
process.exit(ExitCode.Success);
|
|
1219
|
-
} catch (error) {
|
|
1220
|
-
log.error(`Failed: ${getErrorMessage(error)}`);
|
|
1221
|
-
process.exit(ExitCode.FatalError);
|
|
1222
|
-
}
|
|
1223
|
-
});
|
|
1224
|
-
buddy.command("mail:server", "Start SMTP relay server").option("--port <port>", "SMTP port", { default: "587" }).action(async (options) => {
|
|
1225
|
-
loadEnvFiles();
|
|
1226
|
-
const domain = process.env.APP_DOMAIN || process.env.MAIL_DOMAIN || "stacksjs.com", port = Number.parseInt(options.port, 10), mailboxes = ["chris", "blake", "glenn"], users = {}, generated = [];
|
|
1227
|
-
for (const name of mailboxes) {
|
|
1228
|
-
const envKey = `MAIL_PASSWORD_${name.toUpperCase()}`;
|
|
1229
|
-
let password = process.env[envKey];
|
|
1230
|
-
if (!password) {
|
|
1231
|
-
password = randomBytes(16).toString("base64url");
|
|
1232
|
-
generated.push(`${envKey}=${password}`);
|
|
1233
|
-
}
|
|
1234
|
-
users[name] = { password, email: `${name}@${domain}` };
|
|
1235
|
-
}
|
|
1236
|
-
if (generated.length > 0) {
|
|
1237
|
-
console.log("");
|
|
1238
|
-
log.warn("Generated missing passwords. Add these to .env.production:");
|
|
1239
|
-
for (const line of generated)
|
|
1240
|
-
console.log(` ${line}`);
|
|
1241
|
-
}
|
|
1242
|
-
console.log("");
|
|
1243
|
-
console.log("==========================================================");
|
|
1244
|
-
console.log(" SMTP RELAY SERVER");
|
|
1245
|
-
console.log("==========================================================");
|
|
1246
|
-
console.log("");
|
|
1247
|
-
console.log(` Host: mail.${domain}`);
|
|
1248
|
-
console.log(` Port: ${port}`);
|
|
1249
|
-
console.log(` Domain: ${domain}`);
|
|
1250
|
-
console.log("");
|
|
1251
|
-
console.log(" Mailboxes:");
|
|
1252
|
-
for (const [name, user] of Object.entries(users))
|
|
1253
|
-
console.log(` ${name} (${user.email})`);
|
|
1254
|
-
console.log("");
|
|
1255
|
-
console.log(" Relaying through AWS SES");
|
|
1256
|
-
console.log("==========================================================");
|
|
1257
|
-
console.log("");
|
|
1258
|
-
try {
|
|
1259
|
-
const { SmtpServer } = await import("../../../cloud/src/imap/smtp-server"), server = new SmtpServer({
|
|
1260
|
-
port,
|
|
1261
|
-
host: "0.0.0.0",
|
|
1262
|
-
domain,
|
|
1263
|
-
users,
|
|
1264
|
-
sentBucket: "stacks-production-email",
|
|
1265
|
-
sentPrefix: "sent/"
|
|
1266
|
-
});
|
|
1267
|
-
await server.start();
|
|
1268
|
-
await new Promise((resolve) => {
|
|
1269
|
-
process.on("SIGINT", async () => {
|
|
1270
|
-
console.log(`
|
|
1271
|
-
Shutting down SMTP server...`);
|
|
1272
|
-
await server.stop();
|
|
1273
|
-
resolve();
|
|
1274
|
-
});
|
|
1275
|
-
});
|
|
1276
|
-
process.exit(0);
|
|
1277
|
-
} catch (error) {
|
|
1278
|
-
log.error(`Failed to start SMTP server: ${getErrorMessage(error)}`);
|
|
1279
|
-
process.exit(ExitCode.FatalError);
|
|
1280
|
-
}
|
|
1281
|
-
});
|
|
1282
|
-
buddy.command("mail:preview", "Open the dev-only Mailable preview UI in your browser").option("--no-open", "Print the URL but skip the browser-open step", { default: !1 }).option("--port <port>", "Override the API port (default: from config.ports.api / PORT_API)").example("buddy mail:preview").example("buddy mail:preview --no-open").example("buddy mail:preview --port 4000").action(async (options) => {
|
|
1283
|
-
const { config } = await import("@stacksjs/config"), portFromOption = options.port ? Number(options.port) : void 0, portFromEnv = process.env.PORT_API ? Number(process.env.PORT_API) : void 0, url = `http://localhost:${portFromOption || portFromEnv || config.ports?.api || 3008}/_stacks/mail/preview`;
|
|
1284
|
-
log.info(`Mailable preview: ${url}`);
|
|
1285
|
-
log.info("(Make sure `./buddy dev:api` is running.)");
|
|
1286
|
-
if (options.open !== !1)
|
|
1287
|
-
try {
|
|
1288
|
-
execSync(`open "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null || true`, { encoding: "utf-8" });
|
|
1289
|
-
} catch {}
|
|
1290
|
-
process.exit(ExitCode.Success);
|
|
1291
|
-
});
|
|
1292
|
-
}
|
|
1293
|
-
function loadEnvFiles() {
|
|
1294
|
-
const envPath = ".env.production";
|
|
1295
|
-
if (existsSync(envPath))
|
|
1296
|
-
try {
|
|
1297
|
-
const { loadEnv } = require("@stacksjs/env");
|
|
1298
|
-
loadEnv({ path: envPath, env: "production", keysFile: ".env.keys", overload: !1, quiet: !0 });
|
|
1299
|
-
} catch {
|
|
1300
|
-
const content = readFileSync(envPath, "utf-8");
|
|
1301
|
-
for (const line of content.split(`
|
|
1302
|
-
`)) {
|
|
1303
|
-
const eq = line.indexOf("=");
|
|
1304
|
-
if (eq > 0 && !line.startsWith("#")) {
|
|
1305
|
-
const key = line.slice(0, eq).trim(), value = line.slice(eq + 1).trim();
|
|
1306
|
-
if (!process.env[key])
|
|
1307
|
-
process.env[key] = value;
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
|
|
1312
|
-
const awsCredsPath = `${process.env.HOME || process.env.USERPROFILE || ""}/.aws/credentials`;
|
|
1313
|
-
if (existsSync(awsCredsPath)) {
|
|
1314
|
-
const credsContent = readFileSync(awsCredsPath, "utf-8"), profile = process.env.AWS_PROFILE || "default", lines = credsContent.split(`
|
|
1315
|
-
`);
|
|
1316
|
-
let inProfile = !1;
|
|
1317
|
-
for (const line of lines) {
|
|
1318
|
-
const trimmed = line.trim();
|
|
1319
|
-
if (trimmed.startsWith("[")) {
|
|
1320
|
-
inProfile = trimmed === `[${profile}]`;
|
|
1321
|
-
continue;
|
|
1322
|
-
}
|
|
1323
|
-
if (inProfile && trimmed.includes("=")) {
|
|
1324
|
-
const eq = trimmed.indexOf("="), key = trimmed.slice(0, eq).trim(), value = trimmed.slice(eq + 1).trim();
|
|
1325
|
-
if (key === "aws_access_key_id")
|
|
1326
|
-
process.env.AWS_ACCESS_KEY_ID = value;
|
|
1327
|
-
if (key === "aws_secret_access_key")
|
|
1328
|
-
process.env.AWS_SECRET_ACCESS_KEY = value;
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
function printPort25Status(result, provider, ip) {
|
|
1335
|
-
console.log("");
|
|
1336
|
-
console.log("==========================================================");
|
|
1337
|
-
console.log(" PORT 25 STATUS");
|
|
1338
|
-
console.log("==========================================================");
|
|
1339
|
-
console.log("");
|
|
1340
|
-
console.log(` Provider: ${provider}`);
|
|
1341
|
-
console.log(` Server IP: ${ip}`);
|
|
1342
|
-
if (result.includes("PORT_25_OPEN")) {
|
|
1343
|
-
console.log(" Port 25: \x1B[32mOPEN\x1B[0m");
|
|
1344
|
-
console.log("");
|
|
1345
|
-
console.log(" Direct SMTP delivery is available!");
|
|
1346
|
-
console.log(" Set SMTP_DELIVERY_METHOD=direct in your server env.");
|
|
1347
|
-
} else {
|
|
1348
|
-
console.log(" Port 25: \x1B[31mBLOCKED\x1B[0m");
|
|
1349
|
-
console.log("");
|
|
1350
|
-
console.log(` Run: buddy mail:port25:request --provider ${provider.toLowerCase()}`);
|
|
1351
|
-
}
|
|
1352
|
-
console.log("");
|
|
1353
|
-
console.log("==========================================================");
|
|
1354
|
-
console.log("");
|
|
1355
|
-
}
|
|
1356
|
-
async function requestAwsPort25(options, domain) {
|
|
1357
|
-
const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1358
|
-
let { instanceId, elasticIp } = options;
|
|
1359
|
-
if (!instanceId || !elasticIp)
|
|
1360
|
-
try {
|
|
1361
|
-
const { EC2Client } = await import("@stacksjs/ts-cloud"), instance = (await new EC2Client(region).describeInstances({
|
|
1362
|
-
Filters: [
|
|
1363
|
-
{ Name: "tag:Name", Values: [`${appName}-mail-server`] },
|
|
1364
|
-
{ Name: "instance-state-name", Values: ["running"] }
|
|
1365
|
-
]
|
|
1366
|
-
})).Reservations?.[0]?.Instances?.[0];
|
|
1367
|
-
if (instance) {
|
|
1368
|
-
instanceId = instanceId || instance.InstanceId;
|
|
1369
|
-
elasticIp = elasticIp || instance.PublicIpAddress;
|
|
1370
|
-
}
|
|
1371
|
-
} catch {}
|
|
1372
|
-
const rdns = options.rdns || `mail.${domain}`, useCase = options.useCase || [
|
|
1373
|
-
`We run a self-hosted mail server (mail.${domain}) for our organization.`,
|
|
1374
|
-
"We send transactional emails (account notifications, password resets) and",
|
|
1375
|
-
"occasional team communication. Expected volume: <1000 emails/day.",
|
|
1376
|
-
"We have SPF, DKIM, and DMARC configured. We need outbound port 25",
|
|
1377
|
-
"to deliver email directly to recipient mail servers via MX records."
|
|
1378
|
-
].join(" ");
|
|
1379
|
-
console.log("");
|
|
1380
|
-
console.log("==========================================================");
|
|
1381
|
-
console.log(" AWS PORT 25 UNBLOCK REQUEST");
|
|
1382
|
-
console.log("==========================================================");
|
|
1383
|
-
console.log("");
|
|
1384
|
-
console.log(` Instance: ${instanceId || "(not detected)"}`);
|
|
1385
|
-
console.log(` Elastic IP: ${elasticIp || "(not detected)"}`);
|
|
1386
|
-
console.log(` Reverse DNS: ${rdns}`);
|
|
1387
|
-
console.log(` Region: ${region}`);
|
|
1388
|
-
console.log("");
|
|
1389
|
-
try {
|
|
1390
|
-
const result = execSync(`aws support create-case --subject "Request to remove email sending limitations" --communication-body "${useCase}
|
|
58
|
+
`;if(spawnSync("/usr/bin/swift",["-e",keychainWriter],{input:`${encoded}
|
|
59
|
+
`,maxBuffer:1048576}).status!==0)throw Error("macOS Keychain write failed");console.log(`keychain_service=${service}`);console.log("recovery_keyslot=verified");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to add recovery key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{currentKey?.fill(0);recoveryKey?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:verify","Verify the Keychain recovery key against the live LUKS header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options);key=readMailRecoveryKey();runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log("Keychain recovery key unlocks the live mail volume.");process.exit(ExitCode.Success)}catch(error){console.error(`Recovery key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});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)=>{let key;try{const slot=Number.parseInt(options.slot||"",10);if(!Number.isInteger(slot)||slot<1||slot>31)throw Error("Pass a non-primary keyslot from 1 through 31.");const host=await mailStorageHost(options);key=(await getMailStorageSecret(options)).key;const output=runMailStorageSsh(host,`${mailStorageRemote} remove-keyslot ${slot}`,key).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to remove LUKS keyslot: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});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)=>{try{const result=await configureMailRestic(options);console.log(`repository=${result.repository}`);console.log(`recovery_secret=${result.secretId}`);console.log("restore_check=ok");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure Restic mail backups: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:restore:check","Restore and validate the newest off-host mail snapshot")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check").toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Mail restore check failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:maildir:migrate","Migrate live mailboxes to canonical Maildir++ layout")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate migrate").toString("utf8").trim();console.log(output);const status=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate status").toString("utf8").trim();console.log(status);process.exit(ExitCode.Success)}catch(error){console.error(`Maildir++ migration failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:forward:compile","Compile and validate the live RFC 5228 forwarding script")).action(async(options)=>{try{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();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Forwarding script compilation failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});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)=>{try{const result=await escrowMailStorageKey(options);console.log(`Mail storage key verified in AWS Secrets Manager: ${result.secretId}`);console.log(`Host: ${result.host}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to escrow mail storage key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:key:verify","Verify the external key against the LUKS2 header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log(`External key ${secret.secretId} unlocks the mail volume.`);process.exit(ExitCode.Success)}catch(error){console.error(`Mail storage key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:unlock","Unlock external mail storage and start mail")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;const output=runMailStorageSsh(host,`${mailStorageRemote} unlock`,key).toString("utf8").trim();console.log(output);console.log(`External key: ${secret.secretId}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to unlock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:lock","Stop mail and lock external mail storage")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} lock`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to lock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:backup","Create and verify an encrypted local mail backup")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,["set -eu","systemctl reset-failed mail-backup.service 2>/dev/null || true","systemctl start mail-backup.service",'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-)','test -n "$latest"','tar -tzf "$latest" >/dev/null','printf "backup=%s\\n" "$latest"','stat -c "mode=%a owner=%U:%G size=%s" "$latest"'].join("; ")).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to create mail backup: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});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)=>{try{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`;if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);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);await s3.putBucketEncryption(bucket,"aws:kms",kms.arn);const uploadUrl=s3.generatePresignedPutUrl(bucket,objectKey,"application/octet-stream",600),remoteCommand=["set -eu","tmp_dir=$(mktemp -d /run/mail-storage-header.XXXXXX)",'tmp="$tmp_dir/header"',`trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"; rmdir "$tmp_dir"' EXIT`,'cryptsetup luksHeaderBackup /var/lib/mail-storage.luks --header-backup-file "$tmp"',`curl --fail-with-body --silent --show-error -H 'Content-Type: application/octet-stream' --upload-file "$tmp" ${shellQuote(uploadUrl)} 1>&2`].join("; ");runMailStorageSsh(host,remoteCommand);const object=await s3.headObject(bucket,objectKey);if(!object?.ContentLength||object.ContentLength<1048576)throw Error("The uploaded LUKS header size did not verify.");const head=await new AWSClient().request({service:"s3",region,method:"HEAD",path:`/${bucket}/${objectKey}`,returnHeaders:!0}),encryption=head?.headers?.["x-amz-server-side-encryption"];if(encryption!=="aws:kms")throw Error(`The LUKS header object reported unexpected encryption: ${encryption||"none"}`);const keyId=head?.headers?.["x-amz-server-side-encryption-aws-kms-key-id"];if(keyId!==kms.arn)throw Error(`The LUKS header object used an unexpected KMS key: ${keyId||"none"}`);console.log(`header=s3://${bucket}/${objectKey}`);console.log("encryption=aws:kms");console.log(`kms_key=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to back up the LUKS header: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});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)=>{let configured=!1,key;try{const escrow=await escrowMailStorageKey(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(escrow.host,`${mailStorageRemote} configure-external`);configured=!0;runMailStorageSsh(escrow.host,`${mailStorageRemote} unlock`,key);runMailStorageSsh(escrow.host,`${mailStorageRemote} finalize-external`);const status=runMailStorageSsh(escrow.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(status);console.log(`External key: ${escrow.secretId}`);process.exit(ExitCode.Success)}catch(error){if(configured)try{const host=await mailStorageHost(options);runMailStorageSsh(host,`${mailStorageRemote} rollback-machine`)}catch(rollbackError){console.error(`Automatic rollback also failed: ${getErrorMessage(rollbackError)}`)}console.error(`Failed to externalize mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});buddy.command("mail:user:add <email>","Add a mail user").option("--password <password>","User password (generated if not provided)").action(async(email,options)=>{log.info(`Adding mail user: ${email}`);try{const password=options.password||randomBytes(16).toString("base64").replace(/[+/=]/g,"").substring(0,16),passwordHash=createHash("sha256").update(password).digest("hex"),tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").putItem({TableName:tableName,Item:{email:{S:email.toLowerCase()},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()}}});log.success(`User ${email} added successfully!`);console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" MAIL CREDENTIALS");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` Email: ${email}`);console.log(` Password: ${password}`);console.log("");console.log(" To connect with Mail.app, run:");console.log(" buddy mail:proxy");console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");if(!options.password)log.warn("Save this password! It will not be shown again.");process.exit(ExitCode.Success)}catch(error){log.error(`Failed to add user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:list","List mail users").action(async()=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud"),result=await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").scan({TableName:tableName});console.log("");console.log("Mail Users:");console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");if(!result.Items||result.Items.length===0)console.log(" No users found");else for(const item of result.Items){const email=item.email?.S||"unknown",createdAt=item.createdAt?.S||"unknown";console.log(` \uD83D\uDCE7 ${email}`);console.log(` Created: ${createdAt}`)}console.log("");process.exit(ExitCode.Success)}catch(error){log.error(`Failed to list users: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:delete <email>","Delete a mail user").action(async(email)=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").deleteItem({TableName:tableName,Key:{email:{S:email.toLowerCase()}}});log.success(`User ${email} deleted`);process.exit(ExitCode.Success)}catch(error){log.error(`Failed to delete user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:proxy","Start local IMAP proxy for Mail.app").option("--port <port>","IMAP proxy port",{default:"1993"}).option("--api <url>","Mail API URL").action(async(options)=>{log.info("Starting IMAP proxy...");try{let apiUrl=options.api;if(!apiUrl){const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`;try{apiUrl=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl")?.OutputValue}catch(e){}}if(!apiUrl){log.error("Mail API URL not found. Run `buddy deploy` first to create the mail infrastructure.");process.exit(ExitCode.FatalError)}console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" IMAP PROXY");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` API URL: ${apiUrl}`);console.log(` Port: ${options.port}`);console.log("");console.log(" Configure Mail.app with:");console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log(" Account Type: IMAP");console.log(" Incoming Server: localhost");console.log(` Incoming Port: ${options.port}`);console.log(" SSL: No (local connection)");console.log(" Outgoing Server: localhost");console.log(` Outgoing Port: ${options.port}`);console.log(" Username: your-email@stacksjs.com");console.log(" Password: your-password");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");process.env.MAIL_API_URL=apiUrl;process.env.IMAP_PORT=options.port;const{ImapProxy}=await import("../../mail-server/src/proxy/imap-proxy"),proxy=new ImapProxy(Number.parseInt(options.port,10),apiUrl);await proxy.start();process.on("SIGINT",()=>{console.log(`
|
|
60
|
+
Shutting down proxy...`);proxy.stop();process.exit(0)})}catch(error){log.error(`Failed to start proxy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:test","Test mail API connection").action(async()=>{try{const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`,mailApiOutput=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl");if(!mailApiOutput?.OutputValue){log.error("Mail API not deployed. Run `buddy deploy` first.");process.exit(ExitCode.FatalError)}const apiUrl=mailApiOutput.OutputValue;log.info(`Testing Mail API at ${apiUrl}`);const response=await fetch(`${apiUrl}/mailboxes`,{headers:{Authorization:`Basic ${Buffer.from("test@stacksjs.com:test").toString("base64")}`}});if(response.status===401)log.success("Mail API is responding (auth required)");else if(response.ok){log.success("Mail API is working!");const data=await response.json();console.log("Response:",JSON.stringify(data,null,2))}else log.warn(`Mail API returned status ${response.status}`);process.exit(ExitCode.Success)}catch(error){log.error(`Failed to test API: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:credentials [email]","Show SMTP credentials for email access").action(async(emailAddress)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",mailHost=`mail.${domain}`,email=emailAddress||`chris@${domain}`,localPart=email.includes("@")?email.split("@")[0]:email,username=email,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,rawPassword=process.env[envKey],isCiphertext=typeof rawPassword==="string"&&rawPassword.replace(/^["']/,"").startsWith("encrypted:"),password=isCiphertext?void 0:rawPassword;console.log("");console.log("==========================================================");console.log(` EMAIL CREDENTIALS \u2014 ${email}`);console.log("==========================================================");console.log("");console.log(` Email: ${email}`);console.log(` Host: ${mailHost}`);console.log(" Port: 587");console.log(" Security: STARTTLS");console.log(` Username: ${username}`);if(password)console.log(` Password: ${password}`);else if(isCiphertext)console.log(" Password: (encrypted \u2014 this machine has no private key for .env.production)");else console.log(` Password: (not set \u2014 add ${envKey} to .env.production)`);console.log("");console.log("==========================================================");console.log("");process.exit(ExitCode.Success)});buddy.command("mail:logs","Show mail server logs from production").option("-n, --lines <count>","Number of log lines to show",{default:"50"}).option("-f, --follow","Follow log output (poll every 5s)").option("--filter <pattern>","Filter logs by pattern (e.g. AUTH, LOGIN, error)").action(async(options)=>{loadEnvFiles();const directHost=await resolveOperationalMailHost();if(directHost){const lines=sanitizeLineCount(options.lines),filter=options.filter?options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g,""):"",command=`journalctl -u mail --no-pager -n ${lines}${filter?` | grep -iE ${shellQuote(filter)}`:""}`;try{const output=execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`,{encoding:"utf8"});console.log(output);process.exit(ExitCode.Success)}catch(error){log.error(`Failed to fetch logs from ${directHost}: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}}const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region);log.info("Finding mail server...");const instanceId=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0]?.InstanceId;if(!instanceId){log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);process.exit(ExitCode.FatalError)}log.info(`Found instance: ${instanceId}`);const lines=sanitizeLineCount(options.lines),sanitizedFilter=options.filter?options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g,""):"",filterCmd=sanitizedFilter?` | grep -iE '${sanitizedFilter}'`:"",fetchLogs=async()=>{const cmd=`journalctl -u smtp-server -u mail-server --no-pager -n ${lines} --since "10 minutes ago" 2>&1${filterCmd}`,result=await ssm.runShellCommand(instanceId,[cmd],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});if(!result.success){log.error(`Failed to fetch logs: ${result.error||"Unknown error"}`);return!1}if(result.output){const logLines=result.output.trim().split(`
|
|
61
|
+
`);for(const line of logLines)if(line.includes("error")||line.includes("Error")||line.includes("AUTH failed"))console.log(` \x1B[31m${line}\x1B[0m`);else if(line.includes("warning")||line.includes("Warning"))console.log(` \x1B[33m${line}\x1B[0m`);else if(line.includes("AUTH success")||line.includes("LOGIN completed")||line.includes("authenticated"))console.log(` \x1B[32m${line}\x1B[0m`);else console.log(` ${line}`)}else console.log(" No log entries found.");return!0};console.log("");console.log("==========================================================");console.log(" MAIL SERVER LOGS");console.log("==========================================================");console.log("");await fetchLogs();if(options.follow){console.log("");log.info("Following logs (Ctrl+C to stop)...");let polling=!1;const poll=setInterval(()=>{if(polling)return;polling=!0;console.log("");fetchLogs().catch((e)=>log.error("Log polling failed:",e)).finally(()=>{polling=!1})},5000);await new Promise((resolve)=>{process.on("SIGINT",()=>{clearInterval(poll);resolve()})})}console.log("");process.exit(ExitCode.Success)}catch(error){log.error(`Failed to fetch logs: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:status","Show mail server status").action(async()=>{loadEnvFiles();const directHost=await resolveOperationalMailHost();if(directHost){const command='systemctl is-active mail; systemctl show mail -p NRestarts -p ActiveEnterTimestamp --value; ss -H -ltn | grep -E "(:(25|143|465|587|993))\\b"; systemctl is-enabled mail-health.timer; systemctl is-active mail-health.timer';try{const output=execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`,{encoding:"utf8"});console.log(output);process.exit(ExitCode.Success)}catch(error){log.error(`Mail server ${directHost} is unhealthy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}}const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region),instanceId=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0]?.InstanceId;if(!instanceId){log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);process.exit(ExitCode.FatalError)}const result=await ssm.runShellCommand(instanceId,["echo === Service ===","(systemctl status smtp-server --no-pager 2>&1 || systemctl status mail-server --no-pager 2>&1) | head -15","echo === Ports ===",'ss -tlnp 2>&1 | grep -E "(25|587|465|993|143|110|995)" || echo "No mail ports listening"',"echo === Memory ===",`ps aux | grep -E "(smtp-server|bun|mail)" | grep -v grep | awk '{print $6/1024 "MB", $11}'`],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});console.log("");console.log("==========================================================");console.log(" MAIL SERVER STATUS");console.log("==========================================================");console.log("");if(result.success&&result.output)for(const line of result.output.trim().split(`
|
|
62
|
+
`))console.log(` ${line}`);else log.error(`Failed: ${result.error}`);console.log("");process.exit(ExitCode.Success)}catch(error){log.error(`Failed to get status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:port25:request","Request port 25 unblock for direct SMTP delivery").option("--provider <provider>","Cloud provider: aws or hetzner",{default:"aws"}).option("--instance-id <id>","AWS EC2 instance ID (auto-detected if not provided)").option("--server-id <id>","Hetzner server ID (auto-detected if not provided)").option("--elastic-ip <ip>","AWS Elastic IP address").option("--rdns <hostname>","Reverse DNS hostname (e.g. mail.stacksjs.com)").option("--use-case <text>","Description of email use case").action(async(options)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",provider=options.provider.toLowerCase();if(provider==="aws")await requestAwsPort25(options,domain);else if(provider==="hetzner")await requestHetznerPort25(options,domain);else{log.error(`Unknown provider: ${provider}. Use 'aws' or 'hetzner'.`);process.exit(ExitCode.FatalError)}});buddy.command("mail:port25:status","Check outbound port 25 status on the mail server").option("--provider <provider>","Cloud provider: aws or hetzner",{default:"aws"}).action(async(options)=>{loadEnvFiles();const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");if(options.provider==="hetzner"){const hetznerToken=process.env.HETZNER_API_TOKEN;if(!hetznerToken){log.error("HETZNER_API_TOKEN not set");process.exit(ExitCode.FatalError)}try{const ip=(await(await fetch("https://api.hetzner.cloud/v1/servers?label_selector=service=mail",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(!ip){const mailServer=(await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json()).servers?.find((s)=>s.name?.includes("mail"));if(mailServer){const serverIp=mailServer.public_net?.ipv4?.ip;log.info(`Found Hetzner server: ${mailServer.name} (${serverIp})`);const result=execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${serverIp} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`,{encoding:"utf-8"});printPort25Status(result.trim(),"Hetzner",serverIp)}else log.error("No mail server found on Hetzner")}else{const result=execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${ip} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`,{encoding:"utf-8"});printPort25Status(result.trim(),"Hetzner",ip)}}catch(error){log.error(`Failed: ${getErrorMessage(error)}`)}process.exit(ExitCode.Success)}try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region),instance=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0],instanceId=instance?.InstanceId;if(!instanceId){log.error("No running mail server found.");process.exit(ExitCode.FatalError)}const publicIp=instance?.PublicIpAddress||"unknown";log.info(`Testing port 25 on ${instanceId} (${publicIp})...`);const result=await ssm.runShellCommand(instanceId,['timeout 5 bash -c "echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25" 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED'],{timeoutSeconds:15,maxWaitMs:20000,pollIntervalMs:2000});printPort25Status(result.output?.trim()||"PORT_25_BLOCKED","AWS",publicIp);process.exit(ExitCode.Success)}catch(error){log.error(`Failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:server","Start SMTP relay server").option("--port <port>","SMTP port",{default:"587"}).action(async(options)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",port=Number.parseInt(options.port,10),mailboxes=["chris","blake","glenn"],users={},generated=[];for(const name of mailboxes){const envKey=`MAIL_PASSWORD_${name.toUpperCase()}`;let password=process.env[envKey];if(!password){password=randomBytes(16).toString("base64url");generated.push(`${envKey}=${password}`)}users[name]={password,email:`${name}@${domain}`}}if(generated.length>0){console.log("");log.warn("Generated missing passwords. Add these to .env.production:");for(const line of generated)console.log(` ${line}`)}console.log("");console.log("==========================================================");console.log(" SMTP RELAY SERVER");console.log("==========================================================");console.log("");console.log(` Host: mail.${domain}`);console.log(` Port: ${port}`);console.log(` Domain: ${domain}`);console.log("");console.log(" Mailboxes:");for(const[name,user]of Object.entries(users))console.log(` ${name} (${user.email})`);console.log("");console.log(" Relaying through AWS SES");console.log("==========================================================");console.log("");try{const{SmtpServer}=await import("../../../cloud/src/imap/smtp-server"),server=new SmtpServer({port,host:"0.0.0.0",domain,users,sentBucket:"stacks-production-email",sentPrefix:"sent/"});await server.start();await new Promise((resolve)=>{process.on("SIGINT",async()=>{console.log(`
|
|
63
|
+
Shutting down SMTP server...`);await server.stop();resolve()})});process.exit(0)}catch(error){log.error(`Failed to start SMTP server: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:preview","Open the dev-only Mailable preview UI in your browser").option("--no-open","Print the URL but skip the browser-open step",{default:!1}).option("--port <port>","Override the API port (default: from config.ports.api / PORT_API)").example("buddy mail:preview").example("buddy mail:preview --no-open").example("buddy mail:preview --port 4000").action(async(options)=>{const{config}=await import("@stacksjs/config"),portFromOption=options.port?Number(options.port):void 0,portFromEnv=process.env.PORT_API?Number(process.env.PORT_API):void 0,url=`http://localhost:${portFromOption||portFromEnv||config.ports?.api||3008}/_stacks/mail/preview`;log.info(`Mailable preview: ${url}`);log.info("(Make sure `./buddy dev:api` is running.)");if(options.open!==!1)try{execSync(`open "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null || true`,{encoding:"utf-8"})}catch{}process.exit(ExitCode.Success)})}function loadEnvFiles(){const envPath=".env.production";if(existsSync(envPath))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:envPath,env:"production",keysFile:".env.keys",overload:!1,quiet:!0})}catch{const content=readFileSync(envPath,"utf-8");for(const line of content.split(`
|
|
64
|
+
`)){const eq=line.indexOf("=");if(eq>0&&!line.startsWith("#")){const key=line.slice(0,eq).trim(),value=line.slice(eq+1).trim();if(!process.env[key])process.env[key]=value}}}if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const awsCredsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if(existsSync(awsCredsPath)){const credsContent=readFileSync(awsCredsPath,"utf-8"),profile=process.env.AWS_PROFILE||"default",lines=credsContent.split(`
|
|
65
|
+
`);let inProfile=!1;for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("[")){inProfile=trimmed===`[${profile}]`;continue}if(inProfile&&trimmed.includes("=")){const eq=trimmed.indexOf("="),key=trimmed.slice(0,eq).trim(),value=trimmed.slice(eq+1).trim();if(key==="aws_access_key_id")process.env.AWS_ACCESS_KEY_ID=value;if(key==="aws_secret_access_key")process.env.AWS_SECRET_ACCESS_KEY=value}}}}}function printPort25Status(result,provider,ip){console.log("");console.log("==========================================================");console.log(" PORT 25 STATUS");console.log("==========================================================");console.log("");console.log(` Provider: ${provider}`);console.log(` Server IP: ${ip}`);if(result.includes("PORT_25_OPEN")){console.log(" Port 25: \x1B[32mOPEN\x1B[0m");console.log("");console.log(" Direct SMTP delivery is available!");console.log(" Set SMTP_DELIVERY_METHOD=direct in your server env.")}else{console.log(" Port 25: \x1B[31mBLOCKED\x1B[0m");console.log("");console.log(` Run: buddy mail:port25:request --provider ${provider.toLowerCase()}`)}console.log("");console.log("==========================================================");console.log("")}async function requestAwsPort25(options,domain){const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");let{instanceId,elasticIp}=options;if(!instanceId||!elasticIp)try{const{EC2Client}=await import("@stacksjs/ts-cloud"),instance=(await new EC2Client(region).describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0];if(instance){instanceId=instanceId||instance.InstanceId;elasticIp=elasticIp||instance.PublicIpAddress}}catch{}const rdns=options.rdns||`mail.${domain}`,useCase=options.useCase||[`We run a self-hosted mail server (mail.${domain}) for our organization.`,"We send transactional emails (account notifications, password resets) and","occasional team communication. Expected volume: <1000 emails/day.","We have SPF, DKIM, and DMARC configured. We need outbound port 25","to deliver email directly to recipient mail servers via MX records."].join(" ");console.log("");console.log("==========================================================");console.log(" AWS PORT 25 UNBLOCK REQUEST");console.log("==========================================================");console.log("");console.log(` Instance: ${instanceId||"(not detected)"}`);console.log(` Elastic IP: ${elasticIp||"(not detected)"}`);console.log(` Reverse DNS: ${rdns}`);console.log(` Region: ${region}`);console.log("");try{const result=execSync(`aws support create-case --subject "Request to remove email sending limitations" --communication-body "${useCase}
|
|
1391
66
|
|
|
1392
|
-
Elastic IP: ${elasticIp
|
|
1393
|
-
Instance ID: ${instanceId
|
|
67
|
+
Elastic IP: ${elasticIp||"N/A"}
|
|
68
|
+
Instance ID: ${instanceId||"N/A"}
|
|
1394
69
|
Reverse DNS: ${rdns}
|
|
1395
|
-
Region: ${region}" --service-code "amazon-ec2" --category-code "port-25-throttle" --severity-code "normal" --cc-email-addresses "" --language "en" --region us-east-1 2>&1`, { encoding: "utf-8" });
|
|
1396
|
-
if (result.includes("caseId"))
|
|
1397
|
-
try {
|
|
1398
|
-
const caseId = JSON.parse(result).caseId;
|
|
1399
|
-
log.success(`Support case created: ${caseId}`);
|
|
1400
|
-
log.info("");
|
|
1401
|
-
log.info(" AWS will process this within 24-48 hours.");
|
|
1402
|
-
log.info(" Check status: aws support describe-cases --region us-east-1");
|
|
1403
|
-
log.info("");
|
|
1404
|
-
process.exit(ExitCode.Success);
|
|
1405
|
-
} catch {
|
|
1406
|
-
log.error("Failed to parse AWS support case response");
|
|
1407
|
-
}
|
|
1408
|
-
} catch {}
|
|
1409
|
-
if (elasticIp) {
|
|
1410
|
-
log.info("Setting up reverse DNS (rDNS) for Elastic IP...");
|
|
1411
|
-
try {
|
|
1412
|
-
const allocResult = execSync(`aws ec2 describe-addresses --public-ips ${elasticIp} --region ${region} --query 'Addresses[0].AllocationId' --output text 2>&1`, { encoding: "utf-8" }).trim();
|
|
1413
|
-
if (allocResult && !allocResult.includes("error")) {
|
|
1414
|
-
execSync(`aws ec2 modify-address-attribute --allocation-id ${allocResult} --domain-name ${rdns} --region ${region} 2>&1`, { encoding: "utf-8" });
|
|
1415
|
-
log.success(`Reverse DNS requested: ${elasticIp} -> ${rdns}`);
|
|
1416
|
-
console.log(" (rDNS propagation may take a few minutes)");
|
|
1417
|
-
}
|
|
1418
|
-
} catch (error) {
|
|
1419
|
-
log.warn(`rDNS setup failed: ${getErrorMessage(error)}`);
|
|
1420
|
-
console.log(" You may need to set this up manually in the AWS console.");
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
console.log("");
|
|
1424
|
-
console.log(" The Support API requires a Business or Enterprise support plan.");
|
|
1425
|
-
console.log(" Opening the AWS web form instead...");
|
|
1426
|
-
console.log("");
|
|
1427
|
-
console.log(" Fill in these details:");
|
|
1428
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1429
|
-
console.log(` Email address: admin@${domain}`);
|
|
1430
|
-
console.log(" Use case: Sending outbound email from self-hosted mail server");
|
|
1431
|
-
console.log(` Elastic IP: ${elasticIp || "(enter your Elastic IP)"}`);
|
|
1432
|
-
console.log(` rDNS: ${rdns}`);
|
|
1433
|
-
console.log(` Region: ${region}`);
|
|
1434
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1435
|
-
console.log("");
|
|
1436
|
-
const formUrl = "https://console.aws.amazon.com/support/contacts#/rdns-limits";
|
|
1437
|
-
try {
|
|
1438
|
-
execSync(`open "${formUrl}" 2>/dev/null || xdg-open "${formUrl}" 2>/dev/null || echo ""`, { encoding: "utf-8" });
|
|
1439
|
-
log.info(`Opened: ${formUrl}`);
|
|
1440
|
-
} catch {
|
|
1441
|
-
console.log(" Open this URL in your browser:");
|
|
1442
|
-
console.log(` ${formUrl}`);
|
|
1443
|
-
}
|
|
1444
|
-
console.log("");
|
|
1445
|
-
console.log(" After AWS approves (24-48 hours), verify with:");
|
|
1446
|
-
console.log(" buddy mail:port25:status");
|
|
1447
|
-
console.log("");
|
|
1448
|
-
process.exit(ExitCode.Success);
|
|
1449
|
-
}
|
|
1450
|
-
async function requestHetznerPort25(options, domain) {
|
|
1451
|
-
const hetznerToken = process.env.HETZNER_API_TOKEN;
|
|
1452
|
-
if (!hetznerToken) {
|
|
1453
|
-
log.error("HETZNER_API_TOKEN not set. Add it to .env or set it as an environment variable.");
|
|
1454
|
-
process.exit(ExitCode.FatalError);
|
|
1455
|
-
}
|
|
1456
|
-
let serverId = options.serverId, serverIp = "", serverName = "";
|
|
1457
|
-
try {
|
|
1458
|
-
const data = await (await fetch("https://api.hetzner.cloud/v1/servers", {
|
|
1459
|
-
headers: { Authorization: `Bearer ${hetznerToken}` }
|
|
1460
|
-
})).json(), mailServer = data.servers?.find((s) => s.name?.includes("mail")) || data.servers?.[0];
|
|
1461
|
-
if (mailServer) {
|
|
1462
|
-
serverId = serverId || String(mailServer.id);
|
|
1463
|
-
serverIp = mailServer.public_net?.ipv4?.ip || "";
|
|
1464
|
-
serverName = mailServer.name || "";
|
|
1465
|
-
}
|
|
1466
|
-
} catch {}
|
|
1467
|
-
const rdns = options.rdns || `mail.${domain}`, useCase = options.useCase || [
|
|
1468
|
-
`Self-hosted mail server for ${domain}.`,
|
|
1469
|
-
"Transactional + team email, <1000 emails/day.",
|
|
1470
|
-
"SPF/DKIM/DMARC configured."
|
|
1471
|
-
].join(" ");
|
|
1472
|
-
console.log("");
|
|
1473
|
-
console.log("==========================================================");
|
|
1474
|
-
console.log(" HETZNER PORT 25 UNBLOCK REQUEST");
|
|
1475
|
-
console.log("==========================================================");
|
|
1476
|
-
console.log("");
|
|
1477
|
-
console.log(` Server: ${serverName} (ID: ${serverId || "unknown"})`);
|
|
1478
|
-
console.log(` Server IP: ${serverIp || "(not detected)"}`);
|
|
1479
|
-
console.log(` rDNS: ${rdns}`);
|
|
1480
|
-
console.log("");
|
|
1481
|
-
if (serverId && serverIp) {
|
|
1482
|
-
log.info("Setting reverse DNS...");
|
|
1483
|
-
try {
|
|
1484
|
-
const rdnsData = await (await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}/actions/change_dns_ptr`, {
|
|
1485
|
-
method: "POST",
|
|
1486
|
-
headers: {
|
|
1487
|
-
Authorization: `Bearer ${hetznerToken}`,
|
|
1488
|
-
"Content-Type": "application/json"
|
|
1489
|
-
},
|
|
1490
|
-
body: JSON.stringify({ ip: serverIp, dns_ptr: rdns })
|
|
1491
|
-
})).json();
|
|
1492
|
-
if (rdnsData.action?.status === "running" || rdnsData.action?.status === "success")
|
|
1493
|
-
log.success(`Reverse DNS set: ${serverIp} -> ${rdns}`);
|
|
1494
|
-
else
|
|
1495
|
-
log.warn(`rDNS response: ${JSON.stringify(rdnsData.error || rdnsData)}`);
|
|
1496
|
-
} catch (error) {
|
|
1497
|
-
log.warn(`rDNS failed: ${getErrorMessage(error)}`);
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
console.log("");
|
|
1501
|
-
console.log(" To request port 25 unblock from Hetzner:");
|
|
1502
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1503
|
-
console.log(" 1. Log in to https://console.hetzner.cloud");
|
|
1504
|
-
console.log(` 2. Select project containing server "${serverName}"`);
|
|
1505
|
-
console.log(" 3. Go to Support (left sidebar) -> New support request");
|
|
1506
|
-
console.log(" 4. Use this template:");
|
|
1507
|
-
console.log("");
|
|
1508
|
-
console.log(" Subject: Request to unblock outbound port 25 (SMTP)");
|
|
1509
|
-
console.log("");
|
|
1510
|
-
console.log(" Body:");
|
|
1511
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500");
|
|
1512
|
-
console.log(` Server: ${serverName} (ID: ${serverId}, IP: ${serverIp})`);
|
|
1513
|
-
console.log(` Use case: ${useCase}`);
|
|
1514
|
-
console.log(` Reverse DNS: ${serverIp} -> ${rdns}`);
|
|
1515
|
-
console.log(" We comply with all anti-spam regulations (CAN-SPAM, GDPR).");
|
|
1516
|
-
console.log(" Please unblock outbound ports 25 and 465 for this server.");
|
|
1517
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500");
|
|
1518
|
-
console.log("");
|
|
1519
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1520
|
-
const consoleUrl = "https://console.hetzner.cloud";
|
|
1521
|
-
try {
|
|
1522
|
-
execSync(`open "${consoleUrl}" 2>/dev/null || xdg-open "${consoleUrl}" 2>/dev/null || echo ""`, { encoding: "utf-8" });
|
|
1523
|
-
log.info(`Opened: ${consoleUrl}`);
|
|
1524
|
-
} catch {
|
|
1525
|
-
console.log(` Open: ${consoleUrl}`);
|
|
1526
|
-
}
|
|
1527
|
-
console.log("");
|
|
1528
|
-
console.log(" After Hetzner approves, verify with:");
|
|
1529
|
-
console.log(" buddy mail:port25:status --provider hetzner");
|
|
1530
|
-
console.log("");
|
|
1531
|
-
process.exit(ExitCode.Success);
|
|
1532
|
-
}
|
|
70
|
+
Region: ${region}" --service-code "amazon-ec2" --category-code "port-25-throttle" --severity-code "normal" --cc-email-addresses "" --language "en" --region us-east-1 2>&1`,{encoding:"utf-8"});if(result.includes("caseId"))try{const caseId=JSON.parse(result).caseId;log.success(`Support case created: ${caseId}`);log.info("");log.info(" AWS will process this within 24-48 hours.");log.info(" Check status: aws support describe-cases --region us-east-1");log.info("");process.exit(ExitCode.Success)}catch{log.error("Failed to parse AWS support case response")}}catch{}if(elasticIp){log.info("Setting up reverse DNS (rDNS) for Elastic IP...");try{const allocResult=execSync(`aws ec2 describe-addresses --public-ips ${elasticIp} --region ${region} --query 'Addresses[0].AllocationId' --output text 2>&1`,{encoding:"utf-8"}).trim();if(allocResult&&!allocResult.includes("error")){execSync(`aws ec2 modify-address-attribute --allocation-id ${allocResult} --domain-name ${rdns} --region ${region} 2>&1`,{encoding:"utf-8"});log.success(`Reverse DNS requested: ${elasticIp} -> ${rdns}`);console.log(" (rDNS propagation may take a few minutes)")}}catch(error){log.warn(`rDNS setup failed: ${getErrorMessage(error)}`);console.log(" You may need to set this up manually in the AWS console.")}}console.log("");console.log(" The Support API requires a Business or Enterprise support plan.");console.log(" Opening the AWS web form instead...");console.log("");console.log(" Fill in these details:");console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log(` Email address: admin@${domain}`);console.log(" Use case: Sending outbound email from self-hosted mail server");console.log(` Elastic IP: ${elasticIp||"(enter your Elastic IP)"}`);console.log(` rDNS: ${rdns}`);console.log(` Region: ${region}`);console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log("");const formUrl="https://console.aws.amazon.com/support/contacts#/rdns-limits";try{execSync(`open "${formUrl}" 2>/dev/null || xdg-open "${formUrl}" 2>/dev/null || echo ""`,{encoding:"utf-8"});log.info(`Opened: ${formUrl}`)}catch{console.log(" Open this URL in your browser:");console.log(` ${formUrl}`)}console.log("");console.log(" After AWS approves (24-48 hours), verify with:");console.log(" buddy mail:port25:status");console.log("");process.exit(ExitCode.Success)}async function requestHetznerPort25(options,domain){const hetznerToken=process.env.HETZNER_API_TOKEN;if(!hetznerToken){log.error("HETZNER_API_TOKEN not set. Add it to .env or set it as an environment variable.");process.exit(ExitCode.FatalError)}let serverId=options.serverId,serverIp="",serverName="";try{const data=await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json(),mailServer=data.servers?.find((s)=>s.name?.includes("mail"))||data.servers?.[0];if(mailServer){serverId=serverId||String(mailServer.id);serverIp=mailServer.public_net?.ipv4?.ip||"";serverName=mailServer.name||""}}catch{}const rdns=options.rdns||`mail.${domain}`,useCase=options.useCase||[`Self-hosted mail server for ${domain}.`,"Transactional + team email, <1000 emails/day.","SPF/DKIM/DMARC configured."].join(" ");console.log("");console.log("==========================================================");console.log(" HETZNER PORT 25 UNBLOCK REQUEST");console.log("==========================================================");console.log("");console.log(` Server: ${serverName} (ID: ${serverId||"unknown"})`);console.log(` Server IP: ${serverIp||"(not detected)"}`);console.log(` rDNS: ${rdns}`);console.log("");if(serverId&&serverIp){log.info("Setting reverse DNS...");try{const rdnsData=await(await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}/actions/change_dns_ptr`,{method:"POST",headers:{Authorization:`Bearer ${hetznerToken}`,"Content-Type":"application/json"},body:JSON.stringify({ip:serverIp,dns_ptr:rdns})})).json();if(rdnsData.action?.status==="running"||rdnsData.action?.status==="success")log.success(`Reverse DNS set: ${serverIp} -> ${rdns}`);else log.warn(`rDNS response: ${JSON.stringify(rdnsData.error||rdnsData)}`)}catch(error){log.warn(`rDNS failed: ${getErrorMessage(error)}`)}}console.log("");console.log(" To request port 25 unblock from Hetzner:");console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log(" 1. Log in to https://console.hetzner.cloud");console.log(` 2. Select project containing server "${serverName}"`);console.log(" 3. Go to Support (left sidebar) -> New support request");console.log(" 4. Use this template:");console.log("");console.log(" Subject: Request to unblock outbound port 25 (SMTP)");console.log("");console.log(" Body:");console.log(" \u2500\u2500\u2500\u2500\u2500");console.log(` Server: ${serverName} (ID: ${serverId}, IP: ${serverIp})`);console.log(` Use case: ${useCase}`);console.log(` Reverse DNS: ${serverIp} -> ${rdns}`);console.log(" We comply with all anti-spam regulations (CAN-SPAM, GDPR).");console.log(" Please unblock outbound ports 25 and 465 for this server.");console.log(" \u2500\u2500\u2500\u2500\u2500");console.log("");console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");const consoleUrl="https://console.hetzner.cloud";try{execSync(`open "${consoleUrl}" 2>/dev/null || xdg-open "${consoleUrl}" 2>/dev/null || echo ""`,{encoding:"utf-8"});log.info(`Opened: ${consoleUrl}`)}catch{console.log(` Open: ${consoleUrl}`)}console.log("");console.log(" After Hetzner approves, verify with:");console.log(" buddy mail:port25:status --provider hetzner");console.log("");process.exit(ExitCode.Success)}
|