From f816565f529fd2906e9cc29890b3eea79e3e29fe Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:56:39 +0000 Subject: [PATCH 1/5] Add SMTP relay addon for PHP mail --- README.md | 6 +- addons/smtp/action.ts | 508 +++++++++++++++++++++++++++++++ addons/smtp/addon.ts | 21 ++ addons/smtp/app/index.ts | 34 +++ addons/smtp/app/service.ts | 17 ++ addons/smtp/app/views.client.js | 110 +++++++ addons/smtp/app/views.css | 15 + addons/smtp/app/views.ts | 76 +++++ addons/smtp/config.ts | 110 +++++++ addons/smtp/submit.ts | 105 +++++++ cli/addon-catalog.ts | 4 + cli/auth-action.ts | 2 + cli/index.ts | 4 + cli/provision.ts | 24 +- cli/toggle.ts | 1 + cli/uninstall.ts | 1 + docs/DECISIONS.md | 1 + docs/decisions/smtp.md | 69 +++++ docs/smtp-relay.md | 54 ++++ install.sh | 2 +- lib/gateway-client.ts | 2 +- lib/gateway-protocol.ts | 5 + tests/test-addon-catalog.test.ts | 7 +- tests/test-gateway-verbs.test.ts | 7 +- tests/test-mount.test.ts | 2 +- tests/test-smtp.test.ts | 119 ++++++++ tools/preview-ui.ts | 22 +- 27 files changed, 1312 insertions(+), 16 deletions(-) create mode 100644 addons/smtp/action.ts create mode 100644 addons/smtp/addon.ts create mode 100644 addons/smtp/app/index.ts create mode 100644 addons/smtp/app/service.ts create mode 100644 addons/smtp/app/views.client.js create mode 100644 addons/smtp/app/views.css create mode 100644 addons/smtp/app/views.ts create mode 100644 addons/smtp/config.ts create mode 100644 addons/smtp/submit.ts create mode 100644 docs/decisions/smtp.md create mode 100644 docs/smtp-relay.md create mode 100644 tests/test-smtp.test.ts diff --git a/README.md b/README.md index 3048eb81..81ed9693 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ curl -fsSL https://github.com/7heMech/cloudpanel-addons/releases/latest/download ``` The installer asks which addons you want, checks that the download is genuine, -and installs Docker if Instatic needs it. Requires an x86-64 CloudPanel host. +and installs Docker or Postfix when a selected addon needs it. Requires an +x86-64 CloudPanel host. When it finishes, open the new **Addons** tab in CloudPanel. @@ -28,6 +29,7 @@ When it finishes, open the new **Addons** tab in CloudPanel. | Stager | Staging copies of WordPress, PHP, static and Instatic sites, and promotion back to live. Based on [clp-stager](https://github.com/7heMech/clp-stager). | | Maintenance Mode | A customizable 503 page per site, with instant toggles and IP bypasses. | | PHP Resources | Categories of PHP-FPM worker limits, assigned in bulk and to new sites. | +| [SMTP Relay](docs/smtp-relay.md) | Send PHP and WordPress mail through a shared or per-domain SMTP relay. | | Git Deploy | Deploys from a Git remote, from the panel or from a push. | | Panel Tweaks | Site search and columns, mobile layouts and theme improvements. | | WordPress Sign-In | One-click sign-in to any WordPress on the server, no password needed. | @@ -48,7 +50,7 @@ Sites page for their own sites, and nothing else. | `clp-addons uninstall --yes` | Remove an addon and keep its data. | Addon names are `cloudflare-ips`, `instatic`, `stager`, `maintenance`, -`php-resources`, `git`, `panel-tweaks`, and `wp-login`. Run +`php-resources`, `git`, `panel-tweaks`, `wp-login`, and `smtp`. Run `clp-addons --help` for version selection and data removal options. See [Instatic backup and restore](docs/instatic-backups.md) for CloudPanel Remote diff --git a/addons/smtp/action.ts b/addons/smtp/action.ts new file mode 100644 index 00000000..0555a02b --- /dev/null +++ b/addons/smtp/action.ts @@ -0,0 +1,508 @@ +import { Database } from "bun:sqlite"; +import { chmodSync, existsSync, lstatSync, readFileSync, rmSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { + ActionFailure, emitActionError, emitActionOk, failAction, runCommand, withFileLock, + type CommandResult, +} from "../../cli/action-common"; +import { CLI_BIN, PANEL_DB, STATE_DIR } from "../../cli/paths"; +import { writeFileAtomic } from "../../lib/atomic-write"; +import { + emptySmtpPolicy, parseRelay, parseRule, senderFor, smtpAddress, smtpDomain, + type SmtpPolicy, type SmtpRelay, type SmtpSiteRule, type SmtpSubmissionPolicy, +} from "./config"; +import { SUBMISSION_POLICY_PATH } from "./submit"; + +const MANAGED_POOL_LINE = `php_admin_value[sendmail_path] = ${CLI_BIN} smtp-submit -t -i`; +const MANAGED_POOL_MARKER = "; clp-addons smtp relay"; +type SmtpVerb = "list" | "save-relay" | "save-default" | "save-site" | "clear-site" | + "save-domain-relay" | "clear-domain-relay" | "test" | "reconcile" | "deactivate"; +const POSTFIX_KEYS = [ + "relayhost", "smtp_sasl_auth_enable", "smtp_sender_dependent_authentication", + "smtp_sasl_password_maps", "sender_dependent_relayhost_maps", "smtp_tls_security_level", + "smtp_sasl_security_options", "smtp_sasl_tls_security_options", "local_login_sender_maps", +] as const; + +interface SiteRow { domain: string; user: string; phpVersion: string; uid: number } +interface OriginalPostfix { version: 1; values: Record } +export interface SmtpSiteView { + domain: string; + user: string; + phpVersion: string; + rule: SmtpSiteRule; + overridden: boolean; + senderPreview: string; +} +export interface SmtpState { + configured: boolean; + relay: Omit & { hasPassword: boolean } | null; + relayOverrides: Record & { hasPassword: boolean }>; + defaultRule: SmtpSiteRule; + sites: SmtpSiteView[]; +} + +export interface SmtpPaths { + panelDb: string; + phpRoot: string; + stateFile: string; + originalFile: string; + lockFile: string; + submissionFile: string; + postfixDir: string; + sendmail: string; + rootUid: number; +} +export interface SmtpActionOptions { + paths?: Partial; + input?: string; + emitReply?: boolean; + run?: (command: string, args: string[]) => CommandResult; + processUid?: number; + /** Test fixtures may provide sites without a CloudPanel database. */ + sites?: SiteRow[]; +} +export const DEFAULT_SMTP_PATHS: SmtpPaths = { + panelDb: PANEL_DB, + phpRoot: "/etc/php", + stateFile: `${STATE_DIR}/smtp/config.json`, + originalFile: `${STATE_DIR}/smtp/postfix-original.json`, + lockFile: "/run/lock/clp-addons/smtp.lock", + submissionFile: SUBMISSION_POLICY_PATH, + postfixDir: "/etc/postfix", + sendmail: "/usr/sbin/sendmail", + rootUid: 0, +}; + +const reason = (error: unknown): string => error instanceof Error ? error.message : String(error); +const commandError = (result: CommandResult): string => result.stderr.trim() || result.stdout.trim() || "command failed"; +function runChecked(run: (command: string, args: string[]) => CommandResult, command: string, args: string[]): string { + const result = run(command, args); + if (!result.ok) failAction(`${command}: ${commandError(result)}`); + return result.stdout.trim(); +} + +function trustedRead(path: string, uid: number): string | null { + if (!existsSync(path)) return null; + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== uid || (stat.mode & 0o022) !== 0) { + failAction(`refusing untrusted SMTP file: ${path}`); + } + return readFileSync(path, "utf8"); +} + +function parseStoredPolicy(raw: string | null): SmtpPolicy { + if (raw === null) return emptySmtpPolicy(); + let value: unknown; + try { value = JSON.parse(raw); } catch { failAction("SMTP configuration is malformed"); } + if (!value || typeof value !== "object" || Array.isArray(value)) failAction("SMTP configuration is malformed"); + const source = value as Record; + if (source.version !== 1 || !source.defaultRule || !source.siteRules || !source.relayOverrides) { + failAction("SMTP configuration is malformed"); + } + const siteRules: Record = {}; + const relayOverrides: Record = {}; + for (const [domain, rule] of Object.entries(source.siteRules as Record)) { + siteRules[smtpDomain(domain)] = parseRule(rule); + } + for (const [domain, relay] of Object.entries(source.relayOverrides as Record)) { + relayOverrides[smtpDomain(domain)] = parseRelay(relay); + } + return { + version: 1, + relay: source.relay == null ? null : parseRelay(source.relay), + relayOverrides, + defaultRule: parseRule(source.defaultRule), + siteRules, + }; +} + +function readPolicy(paths: SmtpPaths): SmtpPolicy { + return parseStoredPolicy(trustedRead(paths.stateFile, paths.rootUid)); +} + +function writePolicy(paths: SmtpPaths, policy: SmtpPolicy): void { + trustedRead(paths.stateFile, paths.rootUid); + writeFileAtomic(paths.stateFile, JSON.stringify(policy, null, 2) + "\n", { mode: 0o600, createParent: true }); +} + +function panelSites(paths: SmtpPaths, fixture?: SiteRow[]): SiteRow[] { + if (fixture) return fixture; + const db = new Database(paths.panelDb, { readonly: true }); + try { + const passwd = readFileSync("/etc/passwd", "utf8").split("\n"); + const rows = db.query<{ domain_name: string; user: string; php_version: string }, []>( + `SELECT site.domain_name, site.user, php_settings.php_version + FROM site JOIN php_settings ON php_settings.site_id = site.id + ORDER BY site.domain_name`, + ).all(); + return rows.map((row) => { + const domain = smtpDomain(row.domain_name); + const user = String(row.user); + if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(user)) failAction(`invalid site user for ${domain}`); + const entry = passwd.find((line) => line.startsWith(`${user}:`)); + if (!entry) failAction(`site user ${user} is missing`); + const uid = Number(entry.split(":")[2]); + if (!Number.isInteger(uid) || uid < 1) failAction(`invalid site UID for ${domain}`); + return { domain, user, phpVersion: String(row.php_version), uid }; + }); + } finally { db.close(); } +} + +function validatedSites(sites: SiteRow[]): SiteRow[] { + const uids = new Set(); + for (const site of sites) { + smtpDomain(site.domain); + if (!/^[0-9]+\.[0-9]+$/.test(site.phpVersion)) failAction(`invalid PHP version for ${site.domain}`); + if (uids.has(site.uid)) failAction(`multiple sites share Unix UID ${site.uid}; SMTP cannot identify their sender safely`); + uids.add(site.uid); + } + return sites; +} + +function effectiveRule(policy: SmtpPolicy, domain: string): SmtpSiteRule { + return policy.siteRules[domain] ?? policy.defaultRule; +} + +function stateOf(policy: SmtpPolicy, sites: SiteRow[]): SmtpState { + const publicRelay = (relay: SmtpRelay) => ({ host: relay.host, port: relay.port, username: relay.username, hasPassword: true }); + return { + configured: policy.relay !== null, + relay: policy.relay ? publicRelay(policy.relay) : null, + relayOverrides: Object.fromEntries(Object.entries(policy.relayOverrides).map(([domain, relay]) => [domain, publicRelay(relay)])), + defaultRule: policy.defaultRule, + sites: sites.map((site) => { + const rule = effectiveRule(policy, site.domain); + return { + domain: site.domain, user: site.user, phpVersion: site.phpVersion, rule, + overridden: Boolean(policy.siteRules[site.domain]), + senderPreview: senderFor(rule.sender, site.domain), + }; + }), + }; +} + +function relayFromRequest(value: unknown, old: SmtpRelay | null): SmtpRelay { + if (!value || typeof value !== "object" || Array.isArray(value)) failAction("relay must be an object"); + const raw = value as Record; + return parseRelay({ ...raw, password: raw.password === "" ? old?.password : raw.password }); +} + +function replacePolicy(policy: SmtpPolicy, verb: string, body: Record, sites: SiteRow[]): SmtpPolicy { + const next: SmtpPolicy = { + ...policy, siteRules: { ...policy.siteRules }, relayOverrides: { ...policy.relayOverrides }, + }; + if (verb === "save-relay") { + next.relay = relayFromRequest(body.relay, policy.relay); + } else if (verb === "save-default") { + next.defaultRule = parseRule(body.rule); + } else if (verb === "save-site" || verb === "clear-site") { + const domain = smtpDomain(body.domain); + if (!sites.some((site) => site.domain === domain)) failAction(`CloudPanel has no PHP site ${domain}`); + if (verb === "clear-site") delete next.siteRules[domain]; + else next.siteRules[domain] = parseRule(body.rule); + } else if (verb === "save-domain-relay" || verb === "clear-domain-relay") { + const domain = smtpDomain(body.domain); + if (verb === "clear-domain-relay") delete next.relayOverrides[domain]; + else next.relayOverrides[domain] = relayFromRequest(body.relay, policy.relayOverrides[domain] ?? null); + } + return next; +} + +function relayDestination(relay: SmtpRelay): string { + return `[${relay.host}]:${relay.port}`; +} +function regexDomain(domain: string): string { + return domain.replaceAll(".", "\\."); +} +function regexpResult(value: string): string { + // Postfix regexp tables interpret $n in lookup results as a capture reference. + // A literal dollar sign in a provider username or password must be doubled. + return value.split("$").join("$$"); +} +export function postfixMaps(policy: SmtpPolicy): { credentials: string; routes: string } { + if (!policy.relay) throw new Error("global relay is not configured"); + const credentials: string[] = []; + const routes: string[] = []; + for (const [domain, relay] of Object.entries(policy.relayOverrides).sort(([a], [b]) => a.localeCompare(b))) { + const pattern = `/@${regexDomain(domain)}$/`; + credentials.push(`${pattern} ${regexpResult(relay.username)}:${regexpResult(relay.password)}`); + routes.push(`${pattern} ${relayDestination(relay)}`); + } + credentials.push(`/.*/ ${regexpResult(policy.relay.username)}:${regexpResult(policy.relay.password)}`); + return { credentials: credentials.join("\n") + "\n", routes: routes.join("\n") + "\n" }; +} + +function localSenderMap(policy: SmtpPolicy, sites: SiteRow[]): string { + const entries = ["root *", "postfix *"]; + for (const site of sites) { + const rule = effectiveRule(policy, site.domain); + const patterns = rule.mode === "force" + ? [senderFor(rule.sender, site.domain)] + : [senderFor(rule.sender, site.domain), `@${site.domain}`, ...rule.domains.map((d) => `@${d}`), ...rule.addresses]; + entries.push(`${site.user} ${[...new Set(patterns)].join(" ")}`); + } + return entries.join("\n") + "\n"; +} + +function managedPaths(paths: SmtpPaths): { credentials: string; routes: string; senders: string } { + return { + credentials: join(paths.postfixDir, "clp-addons-sasl"), + routes: join(paths.postfixDir, "clp-addons-relays"), + senders: join(paths.postfixDir, "clp-addons-local-senders"), + }; +} + +function capturePostfix(paths: SmtpPaths, run: (command: string, args: string[]) => CommandResult): void { + if (trustedRead(paths.originalFile, paths.rootUid) !== null) return; + const values = currentPostfixSettings(run); + writeFileAtomic(paths.originalFile, JSON.stringify({ version: 1, values }, null, 2) + "\n", { mode: 0o600, createParent: true }); +} + +function currentPostfixSettings(run: (command: string, args: string[]) => CommandResult): Record { + const explicit = runChecked(run, "postconf", ["-n"]); + const values: Record = {}; + for (const key of POSTFIX_KEYS) { + const match = explicit.match(new RegExp(`(?:^|\\n)${key}\\s*=\\s*([^\\n]*)`)); + values[key] = match ? match[1]!.trim() : null; + } + return values; +} + +function applyPostfixSettings(values: Record, run: (command: string, args: string[]) => CommandResult): void { + for (const key of POSTFIX_KEYS) { + const value = values[key]; + if (value === null || value === undefined) runChecked(run, "postconf", ["-X", key]); + else runChecked(run, "postconf", ["-e", `${key}=${value}`]); + } +} + +function restorePostfix(paths: SmtpPaths, run: (command: string, args: string[]) => CommandResult): void { + const raw = trustedRead(paths.originalFile, paths.rootUid); + if (!raw) return; + const backup = JSON.parse(raw) as OriginalPostfix; + if (backup.version !== 1 || !backup.values) failAction("Postfix backup is malformed"); + applyPostfixSettings(backup.values, run); + runChecked(run, "postfix", ["check"]); + runChecked(run, "systemctl", ["reload", "postfix"]); + rmSync(paths.originalFile); +} + +function updatePostfix(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], run: (command: string, args: string[]) => CommandResult): void { + if (!policy.relay) return; + const managed = managedPaths(paths); + const maps = postfixMaps(policy); + const mapContents: Record = { + [managed.credentials]: maps.credentials, + [managed.routes]: maps.routes, + [managed.senders]: localSenderMap(policy, sites), + }; + const before = Object.fromEntries(Object.keys(mapContents).map((path) => [path, trustedRead(path, paths.rootUid)])); + const oldDbPath = `${managed.senders}.db`; + const oldDbStat = existsSync(oldDbPath) ? lstatSync(oldDbPath) : null; + if (oldDbStat && (!oldDbStat.isFile() || oldDbStat.isSymbolicLink() || oldDbStat.uid !== paths.rootUid || (oldDbStat.mode & 0o022) !== 0)) { + failAction(`refusing untrusted SMTP file: ${oldDbPath}`); + } + const oldDb = oldDbStat ? readFileSync(oldDbPath) : null; + const settings: Record = { + relayhost: relayDestination(policy.relay), + smtp_sasl_auth_enable: "yes", + smtp_sender_dependent_authentication: "yes", + smtp_sasl_password_maps: `regexp:${managed.credentials}`, + sender_dependent_relayhost_maps: `regexp:${managed.routes}`, + smtp_tls_security_level: "secure", + smtp_sasl_security_options: "noanonymous", + smtp_sasl_tls_security_options: "noanonymous", + local_login_sender_maps: `hash:${managed.senders}`, + }; + const current = currentPostfixSettings(run); + const mapsChanged = Object.entries(mapContents).some(([path, content]) => before[path] !== content); + const configChanged = Object.entries(settings).some(([key, value]) => current[key] !== value); + const dbStale = oldDb === null || (existsSync(managed.senders) && statSync(managed.senders).mtimeMs > oldDbStat!.mtimeMs); + if (!mapsChanged && !configChanged && !dbStale) return; + // Older Postfix cannot enforce local Unix login sender maps. Refuse the + // configuration instead of silently offering a sender policy it cannot keep. + if (!/^local_login_sender_maps\s*=/.test(runChecked(run, "postconf", ["-d", "local_login_sender_maps"]))) { + failAction("Postfix 3.6 or newer is required for local sender restrictions"); + } + capturePostfix(paths, run); + try { + for (const [path, content] of Object.entries(mapContents)) { + if (before[path] !== content) writeFileAtomic(path, content, { mode: path === managed.senders ? 0o644 : 0o600, createParent: true }); + } + if (before[managed.senders] !== mapContents[managed.senders] || dbStale) { + runChecked(run, "postmap", [`hash:${managed.senders}`]); + chmodSync(`${managed.senders}.db`, 0o644); + } + for (const [key, value] of Object.entries(settings)) { + if (current[key] !== value) runChecked(run, "postconf", ["-e", `${key}=${value}`]); + } + runChecked(run, "postfix", ["check"]); + runChecked(run, "systemctl", ["reload", "postfix"]); + } catch (error) { + for (const [path, content] of Object.entries(before)) { + if (content === null) rmSync(path, { force: true }); + else writeFileAtomic(path, content, { mode: path === managed.senders ? 0o644 : 0o600 }); + } + if (oldDb === null) rmSync(oldDbPath, { force: true }); + else writeFileAtomic(oldDbPath, oldDb, { mode: oldDbStat!.mode & 0o777 }); + applyPostfixSettings(current, run); + runChecked(run, "postfix", ["check"]); + runChecked(run, "systemctl", ["reload", "postfix"]); + throw error; + } +} + +function poolPath(paths: SmtpPaths, site: SiteRow): string { + return join(paths.phpRoot, site.phpVersion, "fpm/pool.d", `${site.domain}.conf`); +} +function updatePools(paths: SmtpPaths, sites: SiteRow[], enabled: boolean, run: (command: string, args: string[]) => CommandResult): number { + const changedVersions = new Set(); + const changes: { path: string; before: string; after: string; mode: number; uid: number; gid: number }[] = []; + for (const site of sites) { + const path = poolPath(paths, site); + if (!existsSync(path)) { + if (!enabled) continue; + failAction(`PHP-FPM pool is missing for ${site.domain}`); + } + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== paths.rootUid || (stat.mode & 0o022) !== 0) { + failAction(`untrusted PHP-FPM pool for ${site.domain}`); + } + const original = readFileSync(path, "utf8"); + const lines = original.split("\n").filter((line) => line !== MANAGED_POOL_LINE && line !== MANAGED_POOL_MARKER); + if (enabled && lines.some((line) => /^\s*php_(?:admin_)?value\[sendmail_path\]/i.test(line))) { + failAction(`${site.domain} already configures sendmail_path; resolve that conflict first`); + } + const next = lines.join("\n").replace(/\n*$/, "\n") + (enabled ? `${MANAGED_POOL_MARKER}\n${MANAGED_POOL_LINE}\n` : ""); + if (next === original) continue; + changes.push({ path, before: original, after: next, mode: stat.mode & 0o777, uid: stat.uid, gid: stat.gid }); + changedVersions.add(site.phpVersion); + } + try { + for (const change of changes) writeFileAtomic(change.path, change.after, { mode: change.mode, owner: { uid: change.uid, gid: change.gid } }); + for (const version of changedVersions) { + runChecked(run, `/usr/sbin/php-fpm${version}`, ["-t"]); + runChecked(run, "systemctl", ["reload", `php${version}-fpm`]); + } + } catch (error) { + for (const change of changes) writeFileAtomic(change.path, change.before, { mode: change.mode, owner: { uid: change.uid, gid: change.gid } }); + for (const version of changedVersions) run("systemctl", ["reload", `php${version}-fpm`]); + throw error; + } + return changes.length; +} + +function writeSubmissionPolicy(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[]): void { + const submission: SmtpSubmissionPolicy = { + version: 1, + sites: sites.map((site) => ({ domain: site.domain, uid: site.uid, user: site.user, rule: effectiveRule(policy, site.domain) })), + }; + trustedRead(paths.submissionFile, paths.rootUid); + writeFileAtomic(paths.submissionFile, JSON.stringify(submission, null, 2) + "\n", { mode: 0o644, createParent: true }); +} + +function applyConfiguration(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], run: (command: string, args: string[]) => CommandResult): number { + if (!policy.relay) return 0; + updatePostfix(paths, policy, sites, run); + writeSubmissionPolicy(paths, policy, sites); + return updatePools(paths, sites, true, run); +} + +async function inputBody(options: SmtpActionOptions): Promise> { + const raw = options.input ?? await Bun.stdin.text(); + if (Buffer.byteLength(raw) > 128 * 1024) failAction("SMTP request is too large"); + let value: unknown; + try { value = JSON.parse(raw); } catch { failAction("SMTP request must be JSON"); } + if (!value || typeof value !== "object" || Array.isArray(value)) failAction("SMTP request must be an object"); + return value as Record; +} + +function sendTest(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], body: Record): { queued: true; sender: string; recipient: string } { + if (!policy.relay) failAction("configure the global SMTP relay first"); + const domain = smtpDomain(body.domain); + const site = sites.find((item) => item.domain === domain); + if (!site) failAction(`CloudPanel has no PHP site ${domain}`); + const recipient = smtpAddress(body.recipient); + const sender = senderFor(effectiveRule(policy, domain).sender, domain); + const message = `To: ${recipient}\nFrom: ${sender}\nSubject: CloudPanel SMTP relay test for ${domain}\n\nThis message was submitted through the CloudPanel Addons Postfix relay.\n`; + const result = Bun.spawnSync([paths.sendmail, "-t", "-i", "-f", sender], { + stdin: Buffer.from(message), stdout: "pipe", stderr: "pipe", maxBuffer: 64 * 1024, + }); + if (!result.success) failAction(`Postfix did not queue the test: ${Buffer.from(result.stderr).toString("utf8").trim() || "sendmail failed"}`); + return { queued: true, sender, recipient }; +} + +export async function executeSmtpAction(argv: string[], options: SmtpActionOptions = {}): Promise { + if ((options.processUid ?? process.getuid?.()) !== 0) failAction("SMTP actions must run as root"); + const paths = { ...DEFAULT_SMTP_PATHS, ...options.paths }; + const run = options.run ?? runCommand; + const verb = argv[0] as SmtpVerb | undefined; + if (argv.length !== 1 || !["list", "save-relay", "save-default", "save-site", "clear-site", "save-domain-relay", "clear-domain-relay", "test", "reconcile", "deactivate"].includes(verb ?? "")) { + failAction("usage: clp-addons action smtp {list|save-relay|save-default|save-site|clear-site|save-domain-relay|clear-domain-relay|test|reconcile|deactivate}"); + } + return withFileLock(paths.lockFile, 30, "SMTP configuration is busy", async () => { + const policy = readPolicy(paths); + const sites = validatedSites(panelSites(paths, options.sites)); + if (verb === "list") return stateOf(policy, sites); + if (verb === "deactivate") { + updatePools(paths, sites, false, run); + rmSync(paths.submissionFile, { force: true }); + restorePostfix(paths, run); + const managed = managedPaths(paths); + for (const path of [managed.credentials, managed.routes, managed.senders, `${managed.senders}.db`]) { + trustedRead(path, paths.rootUid); + rmSync(path, { force: true }); + } + return { deactivated: true }; + } + if (verb === "reconcile") { + const repaired = applyConfiguration(paths, policy, sites, run); + return { repaired }; + } + const body = await inputBody(options); + if (verb === "test") return sendTest(paths, policy, sites, body); + const next = replacePolicy(policy, verb!, body, sites); + try { + if (next.relay) applyConfiguration(paths, next, sites, run); + writePolicy(paths, next); + } catch (error) { + // The saved policy still describes the old state. Restore its maps and + // submission rules if a later pool validation or file write failed. + try { + if (policy.relay) { + // updatePools rolls back its own files on failure. A pre-existing + // sendmail_path conflict would still exist, so restoring the old + // Postfix/submission state must not rerun pool validation. + updatePostfix(paths, policy, sites, run); + writeSubmissionPolicy(paths, policy, sites); + } + else { + updatePools(paths, sites, false, run); + rmSync(paths.submissionFile, { force: true }); + restorePostfix(paths, run); + } + } catch (rollbackError) { + failAction(`SMTP update failed (${reason(error)}); rollback also failed: ${reason(rollbackError)}`); + } + throw error; + } + return stateOf(next, sites); + }); +} + +export async function runSmtpAction(argv: string[], options: SmtpActionOptions = {}): Promise { + try { + const result = await executeSmtpAction(argv, options); + if (options.emitReply !== false) emitActionOk(result); + return 0; + } catch (error) { + if (options.emitReply !== false) emitActionError(reason(error), error instanceof ActionFailure ? error.data : undefined, "smtp"); + return 1; + } +} + +/** Called by disable/uninstall after the addon is no longer available to the UI. */ +export function deactivateSmtp(): void { + const result = runCommand(CLI_BIN, ["action", "smtp", "deactivate"]); + if (!result.ok) failAction(`SMTP could not be deactivated: ${commandError(result)}`); +} diff --git a/addons/smtp/addon.ts b/addons/smtp/addon.ts new file mode 100644 index 00000000..d129d877 --- /dev/null +++ b/addons/smtp/addon.ts @@ -0,0 +1,21 @@ +import type { AddonDefinition } from "../../cli/addon-catalog"; +import { deactivateSmtp, executeSmtpAction, runSmtpAction, type SmtpActionOptions } from "./action"; +import { handle } from "./app/index"; + +export const SMTP_ADDON: AddonDefinition = { + name: "smtp", + title: "SMTP Relay", + description: "Relay PHP mail through Postfix with sender rules for each site.", + requiresUnits: ["postfix"], + targets: [], + handler: handle, + action: runSmtpAction, + deactivate: deactivateSmtp, + maintenance: { + label: "SMTP relay", + run: async (options) => { + const result = await executeSmtpAction(["reconcile"], (options ?? {}) as SmtpActionOptions) as { repaired: number }; + return result.repaired > 0 ? `${result.repaired} PHP mail pool${result.repaired === 1 ? "" : "s"} restored` : null; + }, + }, +}; diff --git a/addons/smtp/app/index.ts b/addons/smtp/app/index.ts new file mode 100644 index 00000000..6dc66b5f --- /dev/null +++ b/addons/smtp/app/index.ts @@ -0,0 +1,34 @@ +import { bodyErrorResponse, guardMutation, htmlResponse, jsonResponse, newCsrfToken, readJsonObject } from "../../../lib/app-http"; +import { smtpService } from "./service"; +import { dashboardView, layout } from "./views"; + +export async function handle(req: Request, path: string, notice?: { current: string; latest: string } | null): Promise { + if (req.method === "GET" && path === "/") { + const csrf = newCsrfToken(); + const result = await smtpService.state(); + if (!result.ok || !result.data) { + return htmlResponse(layout("SMTP Relay", ``, notice), { status: 500, csrf }); + } + return htmlResponse(layout("SMTP Relay", dashboardView(result.data), notice), { csrf }); + } + if (req.method === "GET" && path === "/api/state") { + const result = await smtpService.state(); + return jsonResponse(result, { status: result.ok ? 200 : 500 }); + } + if (req.method === "POST") { + const denied = guardMutation(req); + if (denied) return denied; + let body: Record; + try { body = await readJsonObject(req); } catch (error) { return bodyErrorResponse(error); } + const result = path === "/api/relay" ? await smtpService.saveRelay(body) + : path === "/api/default" ? await smtpService.saveDefault(body) + : path === "/api/site" ? await smtpService.saveSite(body) + : path === "/api/site/clear" ? await smtpService.clearSite(body) + : path === "/api/domain-relay" ? await smtpService.saveDomainRelay(body) + : path === "/api/domain-relay/clear" ? await smtpService.clearDomainRelay(body) + : path === "/api/test" ? await smtpService.test(body) + : null; + if (result) return jsonResponse(result, { status: result.ok ? 200 : 400 }); + } + return jsonResponse({ ok: false, error: "not found" }, { status: 404 }); +} diff --git a/addons/smtp/app/service.ts b/addons/smtp/app/service.ts new file mode 100644 index 00000000..b169dddb --- /dev/null +++ b/addons/smtp/app/service.ts @@ -0,0 +1,17 @@ +import { callGatewayAction, type ActionResult } from "../../../lib/gateway-client"; +import type { SmtpState } from "../action"; + +const call = (verb: string, body?: unknown): Promise> => + callGatewayAction("smtp", verb, [], body === undefined ? undefined : JSON.stringify(body)); + +export const smtpService = { + state: () => call("list"), + saveRelay: (body: unknown) => call("save-relay", body), + saveDefault: (body: unknown) => call("save-default", body), + saveSite: (body: unknown) => call("save-site", body), + clearSite: (body: unknown) => call("clear-site", body), + saveDomainRelay: (body: unknown) => call("save-domain-relay", body), + clearDomainRelay: (body: unknown) => call("clear-domain-relay", body), + test: (body: unknown): Promise> => + callGatewayAction("smtp", "test", [], JSON.stringify(body)), +}; diff --git a/addons/smtp/app/views.client.js b/addons/smtp/app/views.client.js new file mode 100644 index 00000000..cbf4da1b --- /dev/null +++ b/addons/smtp/app/views.client.js @@ -0,0 +1,110 @@ +const smtpState = JSON.parse(document.getElementById('smtp-state')?.textContent || '{}'); + +function smtpFields(form) { + return Object.fromEntries(new FormData(form).entries()); +} + +async function smtpPost(path, body) { + busy(true); + try { + await call(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + location.reload(); + } catch (error) { + notify(error.message, 'error'); + } finally { + busy(false); + } +} + +function smtpSaveRelay(event) { + event.preventDefault(); + const fields = smtpFields(event.currentTarget); + smtpPost('/api/relay', { relay: { + host: fields.host, port: Number(fields.port), username: fields.username, password: fields.password, + } }); +} + +function smtpSaveDefault(event) { + event.preventDefault(); + const fields = smtpFields(event.currentTarget); + smtpPost('/api/default', { rule: { mode: fields.mode, sender: fields.sender, domains: [], addresses: [] } }); +} + +async function smtpSendTest(event) { + event.preventDefault(); + const fields = smtpFields(event.currentTarget); + busy(true); + try { + const result = await call('/api/test', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain: fields.domain, recipient: fields.recipient }), + }); + notify('Postfix queued a test from ' + result.data.sender + ' to ' + result.data.recipient + '.', 'ok'); + } catch (error) { + notify(error.message, 'error'); + } finally { + busy(false); + } +} + +function smtpList(value) { + return String(value || '').split(/[\s,]+/).map(function (part) { return part.trim(); }).filter(Boolean); +} + +function smtpEditSite(domain) { + const site = smtpState.sites.find(function (item) { return item.domain === domain; }); + if (!site) return; + const form = document.getElementById('smtp-site-form'); + form.elements.namedItem('domain').value = domain; + form.elements.namedItem('mode').value = site.rule.mode; + form.elements.namedItem('sender').value = site.rule.sender; + form.elements.namedItem('domains').value = site.rule.domains.join('\n'); + form.elements.namedItem('addresses').value = site.rule.addresses.join('\n'); + document.getElementById('smtp-site-heading').textContent = 'Sender policy for ' + domain; + document.getElementById('smtp-clear-site').hidden = !site.overridden; + document.getElementById('smtp-site-dialog').showModal(); +} + +function smtpSaveSite(event) { + event.preventDefault(); + const fields = smtpFields(event.currentTarget); + smtpPost('/api/site', { domain: fields.domain, rule: { + mode: fields.mode, sender: fields.sender, + domains: smtpList(fields.domains), addresses: smtpList(fields.addresses), + } }); +} + +function smtpClearSite() { + const domain = document.getElementById('smtp-site-form').elements.namedItem('domain').value; + smtpPost('/api/site/clear', { domain: domain }); +} + +function smtpEditDomain(domain) { + const old = domain && smtpState.relayOverrides[domain]; + const form = document.getElementById('smtp-domain-form'); + form.elements.namedItem('domain').value = domain || ''; + form.elements.namedItem('domain').readOnly = Boolean(old); + form.elements.namedItem('host').value = old ? old.host : ''; + form.elements.namedItem('port').value = old ? old.port : 587; + form.elements.namedItem('username').value = old ? old.username : ''; + form.elements.namedItem('password').value = ''; + form.elements.namedItem('password').required = !old; + document.getElementById('smtp-domain-dialog').showModal(); +} + +function smtpSaveDomain(event) { + event.preventDefault(); + const fields = smtpFields(event.currentTarget); + smtpPost('/api/domain-relay', { domain: fields.domain, relay: { + host: fields.host, port: Number(fields.port), username: fields.username, password: fields.password, + } }); +} + +async function smtpClearDomain(domain) { + if (!await confirmAction({ title: 'Remove SMTP override?', text: domain + ' will use the global relay credential again.', confirmLabel: 'Remove' })) return; + smtpPost('/api/domain-relay/clear', { domain: domain }); +} diff --git a/addons/smtp/app/views.css b/addons/smtp/app/views.css new file mode 100644 index 00000000..047df9bf --- /dev/null +++ b/addons/smtp/app/views.css @@ -0,0 +1,15 @@ +.smtp-status { display:flex; align-items:center; justify-content:space-between; gap:16px; } +.smtp-status p { margin:0; } +.smtp-form h2 { margin-bottom:6px; } +.smtp-form > .hint { margin-top:0; } +.smtp-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; } +.smtp-form label,.smtp-dialog label { display:flex; flex-direction:column; gap:6px; font-weight:600; margin:12px 0; } +.smtp-form input,.smtp-form select,.smtp-dialog input,.smtp-dialog select,.smtp-dialog textarea { width:100%; box-sizing:border-box; padding:9px 10px; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text); font:inherit; font-weight:400; } +.smtp-form .actions { margin-top:12px; } +.smtp-site-table .hint { display:block; } +.smtp-site-table code { overflow-wrap:anywhere; } +.smtp-domain-table .actions { white-space:nowrap; } +.smtp-dialog { width:min(650px,calc(100% - 24px)); max-height:calc(100dvh - 24px); overflow:auto; padding:24px; border:1px solid var(--border); border-radius:12px; background:var(--surface); color:var(--text); } +.smtp-dialog::backdrop { background:rgb(10 20 30 / 55%); } +.smtp-dialog h2 { margin-top:0; } +@media (max-width:760px) { .smtp-grid { grid-template-columns:1fr; gap:0; } .smtp-status { align-items:flex-start; } } diff --git a/addons/smtp/app/views.ts b/addons/smtp/app/views.ts new file mode 100644 index 00000000..8981fb3f --- /dev/null +++ b/addons/smtp/app/views.ts @@ -0,0 +1,76 @@ +import CLIENT from "./views.client.js" with { type: "text" }; +import CSS from "./views.css" with { type: "text" }; +import { esc } from "../../../lib/app-http"; +import { renderLayout } from "../../../lib/app-ui"; +import { mountPath } from "../../../lib/mount"; +import type { SmtpState } from "../action"; + +const BASE = mountPath("smtp"); + +export function layout(title: string, content: string, notice?: { current: string; latest: string } | null): string { + return renderLayout(title, content, { brand: "SMTP Relay", base: BASE, nav: [], css: CSS, script: CLIENT, updateNotice: notice }); +} + +function modeOptions(mode: string): string { + return ``; +} + +export function dashboardView(state: SmtpState): string { + const data = JSON.stringify(state).replaceAll("<", "\\u003c"); + const relay = state.relay; + const sites = state.sites.map((site) => ` + ${esc(site.domain)}${esc(site.user)} + ${site.rule.mode === "force" ? "Force" : "Allow listed"} + ${esc(site.senderPreview)}${site.overridden ? ' Override' : ""} + + `).join(""); + const overrides = Object.entries(state.relayOverrides).map(([domain, item]) => ` + ${esc(domain)}${esc(item.host)}:${item.port}${esc(item.username)} + + `).join(""); + return ` +

SMTP relay

Send WordPress and other PHP mail through Postfix, with a sender policy for each site.

+

Relay status

${relay ? `Configured for ${esc(relay.host)}:${relay.port}.` : "Add a global relay before PHP mail is routed through this addon."}

${relay ? "Configured" : "Setup needed"}
+
+

Global SMTP relay

The fallback credential for sending domains without a relay override. Postfix queues messages and retries temporary failures.

+
+ + + + +
+
+
+

Default sender policy

{domain} is each CloudPanel site's domain. Force replaces the requested From address; allow listed preserves addresses on that site's approved domains.

+
+
+
+
+
+

Send a test

A successful result means Postfix accepted the message into its queue. Check the recipient inbox for delivery.

+
+
+
+
+

PHP sites

Edit a site to add sending domains or use a different address.

+ ${sites ? `${sites}
SiteModeSender
` : '
No PHP sites found.
'} +
+

Sending domain relays

Use a different SMTP account for a domain whose provider does not allow the global credential to send as it.

+ ${overrides ? `${overrides}
Sending domainSMTP hostUsername
` : '
All sending domains use the global relay.
'} +
+
+

Site sender

+ + + + +

The additional lists apply in “Allow site domains” mode. Only CloudPanel administrators can grant them.

+
+
+
+

Sending domain relay

+
+ +
+
`; +} diff --git a/addons/smtp/config.ts b/addons/smtp/config.ts new file mode 100644 index 00000000..6245df02 --- /dev/null +++ b/addons/smtp/config.ts @@ -0,0 +1,110 @@ +/** The public sender policy is separate from the root-only SMTP credentials. */ +export interface SmtpSiteRule { + mode: "force" | "allow"; + /** Used by force mode, and as the fallback when an allowed message has no From. */ + sender: string; + /** Extra sending domains explicitly granted to this CloudPanel site. */ + domains: string[]; + /** Exact additional addresses, for providers that authorize individual senders. */ + addresses: string[]; +} + +export interface SmtpRelay { + host: string; + port: number; + username: string; + password: string; +} + +export interface SmtpPolicy { + version: 1; + relay: SmtpRelay | null; + /** A sending domain can use a separate provider and credential. */ + relayOverrides: Record; + defaultRule: SmtpSiteRule; + siteRules: Record; +} + +export interface SmtpSubmissionSite { + domain: string; + uid: number; + user: string; + rule: SmtpSiteRule; +} + +export interface SmtpSubmissionPolicy { + version: 1; + sites: SmtpSubmissionSite[]; +} + +const DOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; +const ADDRESS = /^[A-Za-z0-9._%+-]+@([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)$/i; + +export const DEFAULT_RULE: SmtpSiteRule = { + mode: "force", sender: "noreply@{domain}", domains: [], addresses: [], +}; + +export function emptySmtpPolicy(): SmtpPolicy { + return { version: 1, relay: null, relayOverrides: {}, defaultRule: { ...DEFAULT_RULE }, siteRules: {} }; +} + +export function smtpDomain(value: unknown): string { + if (typeof value !== "string") throw new Error("domain must be a hostname"); + const domain = value.toLowerCase().replace(/\.$/, ""); + if (domain.length > 253 || !DOMAIN.test(domain)) throw new Error(`invalid domain: ${value}`); + return domain; +} + +export function smtpAddress(value: unknown): string { + if (typeof value !== "string" || value.length > 254 || !ADDRESS.test(value)) { + throw new Error("sender must be a single email address"); + } + return value.toLowerCase(); +} + +export function senderFor(template: string, domain: string): string { + if (typeof template !== "string" || template.length > 254 || + template.replaceAll("{domain}", "").includes("{")) { + throw new Error("sender template may contain only {domain}"); + } + return smtpAddress(template.replaceAll("{domain}", domain)); +} + +export function parseRule(value: unknown): SmtpSiteRule { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sender rule must be an object"); + const rule = value as Record; + if (rule.mode !== "force" && rule.mode !== "allow") throw new Error("sender mode must be force or allow"); + if (typeof rule.sender !== "string") throw new Error("sender template is required"); + senderFor(rule.sender, "example.com"); + if (!Array.isArray(rule.domains) || !Array.isArray(rule.addresses) || + rule.domains.length > 30 || rule.addresses.length > 100) throw new Error("too many allowed senders"); + return { + mode: rule.mode, + sender: rule.sender.toLowerCase(), + domains: [...new Set(rule.domains.map(smtpDomain))].sort(), + addresses: [...new Set(rule.addresses.map(smtpAddress))].sort(), + }; +} + +export function parseRelay(value: unknown): SmtpRelay { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("SMTP relay must be an object"); + const relay = value as Record; + const host = smtpDomain(relay.host); + if (!Number.isInteger(relay.port) || Number(relay.port) < 1 || Number(relay.port) > 65535) { + throw new Error("SMTP port must be 1–65535"); + } + if (typeof relay.username !== "string" || !relay.username || relay.username.length > 254 || /[\s\x00-\x1f:]/.test(relay.username)) { + throw new Error("SMTP username is invalid"); + } + if (typeof relay.password !== "string" || !relay.password || relay.password.length > 1024 || /[\s\x00-\x1f]/.test(relay.password)) { + throw new Error("SMTP password is invalid"); + } + return { host, port: Number(relay.port), username: relay.username, password: relay.password }; +} + +export function permittedSender(site: SmtpSubmissionSite, address: string): boolean { + const sender = smtpAddress(address); + const domain = sender.slice(sender.lastIndexOf("@") + 1); + return sender === senderFor(site.rule.sender, site.domain) || + site.rule.addresses.includes(sender) || domain === site.domain || site.rule.domains.includes(domain); +} diff --git a/addons/smtp/submit.ts b/addons/smtp/submit.ts new file mode 100644 index 00000000..44dccd92 --- /dev/null +++ b/addons/smtp/submit.ts @@ -0,0 +1,105 @@ +import { lstatSync, readFileSync } from "node:fs"; +import { CONFIG_DIR } from "../../cli/paths"; +import { permittedSender, senderFor, smtpAddress, type SmtpSubmissionPolicy, type SmtpSubmissionSite } from "./config"; + +export const SUBMISSION_POLICY_PATH = `${CONFIG_DIR}/smtp-submission.json`; +const MAX_MESSAGE_BYTES = 25 * 1024 * 1024; +const MAX_HEADER_BYTES = 64 * 1024; + +function trustedPolicy(path: string): SmtpSubmissionPolicy { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== 0 || (stat.mode & 0o022) !== 0) { + throw new Error("SMTP sender policy is not a trusted root-owned file"); + } + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!value || typeof value !== "object" || (value as SmtpSubmissionPolicy).version !== 1 || + !Array.isArray((value as SmtpSubmissionPolicy).sites)) throw new Error("SMTP sender policy is malformed"); + return value as SmtpSubmissionPolicy; +} + +function senderFromHeader(value: string): string { + const unfolded = value.replace(/\r?\n[ \t]+/g, " ").trim(); + const bracketed = unfolded.match(/<([^<>]+)>$/); + const address = bracketed ? bracketed[1] : unfolded; + if (!address || address.includes(",") || /[\r\n]/.test(address)) throw new Error("message has an invalid From address"); + return smtpAddress(address); +} + +/** Rewrites one message before it crosses the trusted local sendmail boundary. */ +export function prepareSubmission(message: Uint8Array, site: SmtpSubmissionSite): { message: Uint8Array; sender: string } { + if (message.byteLength > MAX_MESSAGE_BYTES) throw new Error("message exceeds the 25 MiB submission limit"); + const input = Buffer.from(message); + const lfBoundary = input.indexOf("\n\n"); + const crlfBoundary = input.indexOf("\r\n\r\n"); + const useCrlf = crlfBoundary >= 0 && (lfBoundary < 0 || crlfBoundary < lfBoundary); + const boundary = useCrlf ? crlfBoundary : lfBoundary; + if (boundary < 0 || boundary > MAX_HEADER_BYTES) throw new Error("message has no bounded header section"); + const separatorLength = useCrlf ? 4 : 2; + const newline = useCrlf ? "\r\n" : "\n"; + const headers = input.subarray(0, boundary).toString("utf8").split(/\r?\n/); + const kept: string[] = []; + let fromRaw: string | null = null; + let lastWasFrom = false; + let lastWasIgnored = false; + for (let i = 0; i < headers.length; i++) { + const line = headers[i]!; + if (/^[ \t]/.test(line)) { + if (lastWasFrom) fromRaw += `${newline}${line}`; + else if (lastWasIgnored) continue; + else if (kept.length > 0) kept[kept.length - 1] += `${newline}${line}`; + else throw new Error("message begins with a folded header"); + continue; + } + const colon = line.indexOf(":"); + if (colon < 1 || !/^[A-Za-z0-9-]+$/.test(line.slice(0, colon))) throw new Error("message has a malformed header"); + const name = line.slice(0, colon).toLowerCase(); + lastWasFrom = name === "from"; + lastWasIgnored = name === "sender" || name === "return-path"; + if (name === "from") { + if (fromRaw !== null) throw new Error("message has multiple From headers"); + fromRaw = line.slice(colon + 1); + } else if (name !== "sender" && name !== "return-path") { + kept.push(line); + } + } + const from = fromRaw === null ? null : senderFromHeader(fromRaw); + const configured = senderFor(site.rule.sender, site.domain); + const sender = site.rule.mode === "force" ? configured : (from ?? configured); + if (site.rule.mode === "allow" && !permittedSender(site, sender)) { + throw new Error(`sender ${sender} is not allowed for ${site.domain}`); + } + kept.unshift(`From: ${sender}`); + const head = Buffer.from(kept.join(newline) + newline + newline, "utf8"); + return { message: Buffer.concat([head, input.subarray(boundary + separatorLength)]), sender }; +} + +/** Invoked as the PHP-FPM pool's sendmail_path, never through the root gateway. */ +export async function runSmtpSubmit( + argv: string[], + options: { policyPath?: string; sendmailPath?: string; uid?: number; input?: Uint8Array } = {}, +): Promise { + try { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === "-t" || arg === "-i" || arg === "-oi") continue; + if (arg === "-f") { i++; if (!argv[i]) throw new Error("-f needs an address"); continue; } + if (arg.startsWith("-f") && arg.length > 2) continue; + throw new Error("unsupported sendmail option"); + } + const uid = options.uid ?? process.getuid?.(); + if (uid === undefined) throw new Error("cannot identify the sending site"); + const policy = trustedPolicy(options.policyPath ?? SUBMISSION_POLICY_PATH); + const matches = policy.sites.filter((site) => site.uid === uid); + if (matches.length !== 1) throw new Error("the sending Unix account has no unique CloudPanel site"); + const input = options.input ?? new Uint8Array(await Bun.stdin.arrayBuffer()); + const prepared = prepareSubmission(input, matches[0]!); + const result = Bun.spawnSync([options.sendmailPath ?? "/usr/sbin/sendmail", "-t", "-i", "-f", prepared.sender], { + stdin: prepared.message, stdout: "pipe", stderr: "pipe", maxBuffer: 64 * 1024, + }); + if (!result.success) throw new Error(Buffer.from(result.stderr).toString("utf8").trim() || "Postfix did not accept the message"); + return 0; + } catch (error) { + process.stderr.write(`[smtp] ${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } +} diff --git a/cli/addon-catalog.ts b/cli/addon-catalog.ts index f4667b89..854c6e8d 100644 --- a/cli/addon-catalog.ts +++ b/cli/addon-catalog.ts @@ -27,6 +27,7 @@ import { PANEL_TWEAKS_ADDON } from "../addons/panel-tweaks/addon"; import { WP_LOGIN_ADDON } from "../addons/wp-login/addon"; import { PHP_RESOURCES_ADDON } from "../addons/php-resources/addon"; import { STAGER_ADDON } from "../addons/stager/addon"; +import { SMTP_ADDON } from "../addons/smtp/addon"; export type { AddonTarget }; @@ -59,6 +60,8 @@ export interface AddonDefinition { handler?: AddonHandler; /** The privileged verbs this addon runs as root, if it has any. */ action?: (argv: string[], options?: Record) => Promise | number; + /** Withdraw changes outside addon state before disabling or uninstalling. */ + deactivate?: () => void; /** * Whether a `ROLE_SITE_MANAGER` session reaches this addon's routes. * @@ -91,6 +94,7 @@ const DEFINITIONS: AddonDefinition[] = [ GIT_ADDON, PANEL_TWEAKS_ADDON, WP_LOGIN_ADDON, + SMTP_ADDON, ]; function specOf(definition: AddonDefinition): AddonSpec { diff --git a/cli/auth-action.ts b/cli/auth-action.ts index 18792144..82647531 100644 --- a/cli/auth-action.ts +++ b/cli/auth-action.ts @@ -24,6 +24,7 @@ import { MAINTENANCE_ALLOWED_VERBS, GIT_ALLOWED_VERBS, CLOUDFLARE_IPS_ALLOWED_VERBS, + SMTP_ALLOWED_VERBS, PHP_RESOURCES_ALLOWED_VERBS, PANEL_TWEAKS_ALLOWED_VERBS, WP_LOGIN_ALLOWED_VERBS, @@ -44,6 +45,7 @@ const ALLOWED_VERBS = new Map>([ ["maintenance", MAINTENANCE_ALLOWED_VERBS], ["git", GIT_ALLOWED_VERBS], ["cloudflare-ips", CLOUDFLARE_IPS_ALLOWED_VERBS], + ["smtp", SMTP_ALLOWED_VERBS], ["php-resources", PHP_RESOURCES_ALLOWED_VERBS], ["panel-tweaks", PANEL_TWEAKS_ALLOWED_VERBS], ["wp-login", WP_LOGIN_ALLOWED_VERBS], diff --git a/cli/index.ts b/cli/index.ts index a6e8eb55..1832c822 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -21,6 +21,7 @@ import { cmdServe } from "../manager/server"; import { executeMaintenanceAction } from "../addons/maintenance/action"; import { runAuthActionStdin } from "./auth-action"; import { runManagerAction, type ManagerOps } from "./manager-action"; +import { runSmtpSubmit } from "../addons/smtp/submit"; /** * The privileged half of the three manager verbs. @@ -51,6 +52,7 @@ function usage(): void { clp-addons uninstall --yes [--purge] clp-addons maintenance [on|off|status] clp-addons action cloudflare-ips [options] + clp-addons action smtp clp-addons action instatic [options] clp-addons action stager [options] clp-addons action maintenance --domain= @@ -58,6 +60,7 @@ function usage(): void { clp-addons action manager [--addon=] [--id=] clp-addons action auth (session id on bounded stdin) clp-addons serve + clp-addons smtp-submit -t -i clp-addons --version Addons: ${ADDON_NAMES.join(", ")} @@ -135,6 +138,7 @@ async function main(): Promise { case "maintenance": await cmdMaintenance(rest); return 0; case "uninstall": cmdUninstall(rest); return 0; case "action": return await cmdAction(rest); + case "smtp-submit": return await runSmtpSubmit(rest); case "serve": return await cmdServe(); case "help": case "--help": diff --git a/cli/provision.ts b/cli/provision.ts index 7a6d3313..34a0c35b 100644 --- a/cli/provision.ts +++ b/cli/provision.ts @@ -254,12 +254,9 @@ function installDocker(commands: ProvisionCommandRunner): void { /** * `applyEnable`/`cmdInstall` gate on `requiresUnits` before touching any - * addon state. Docker is the only unit any addon currently requires, and the - * installer already knows how to bring it up on a fresh host - * (`install.sh --install-docker`, via get.docker.com). Enabling instatic - * later -- from the UI or `clp-addons install instatic` -- should follow the - * same path instead of just telling the operator to go run that installer's - * logic by hand. + * addon state. Docker and Postfix are installed only when their respective + * addons require them. Postfix is used as a local queue and authenticated + * outbound relay, not as a listening mail service for customer sites. */ export function ensureRequiredUnits( spec: AddonSpec, @@ -267,6 +264,21 @@ export function ensureRequiredUnits( ): void { for (const unit of spec.requiresUnits ?? []) { if (commands.tryRun("systemctl", ["is-active", unit]).ok) continue; + if (unit === "postfix") { + const loadState = commands.tryRun("systemctl", ["show", "postfix", "--property=LoadState", "--value"]); + if (loadState.out.trim() !== "loaded") { + log.step("installing Postfix and its SMTP authentication modules"); + const install = commands.tryRun("env", ["DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "postfix", "libsasl2-modules"]); + if (!install.ok) fatal(`Postfix installation failed: ${install.out || "apt-get failed"}`); + const outboundOnly = commands.tryRun("postconf", ["-e", "inet_interfaces=loopback-only"]); + if (!outboundOnly.ok) fatal(`Postfix could not be limited to local submissions: ${outboundOnly.out || "postconf failed"}`); + } + if (!commands.tryRun("systemctl", ["enable", "--now", "postfix"]).ok) { + fatal("Postfix could not be started"); + } + log.ok("Postfix installed and started"); + continue; + } if (unit !== "docker") { fatal(`${unit} is not active; install and start it before enabling ${spec.name}`); } diff --git a/cli/toggle.ts b/cli/toggle.ts index a17c8c38..cf2910e0 100644 --- a/cli/toggle.ts +++ b/cli/toggle.ts @@ -120,6 +120,7 @@ export function applyDisable(name: string): void { function disableAddon(spec: AddonSpec): void { const remaining = installedAddons().filter((item) => item.name !== spec.name); + spec.deactivate?.(); if (spec.name === "wp-login") withdrawWpLogin(); rmSync(spec.configFile, { force: true }); rmSync(`${spec.configFile}.new`, { force: true }); diff --git a/cli/uninstall.ts b/cli/uninstall.ts index 3bd4792b..7d51c10e 100644 --- a/cli/uninstall.ts +++ b/cli/uninstall.ts @@ -72,6 +72,7 @@ function applyUninstall(spec: AddonSpec, flags: Record): } } removeSudoers(); + spec.deactivate?.(); if (spec.name === "wp-login") withdrawWpLogin(); if (purge) rmSync(spec.stateDir, { recursive: true, force: true }); rmSync(spec.configFile, { force: true }); diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6a5f48dd..2b235bc0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8,6 +8,7 @@ - [Stager](decisions/stager.md) - [Maintenance Mode](decisions/maintenance.md) - [PHP Resources](decisions/php-resources.md) +- [SMTP Relay](decisions/smtp.md) - [Git Deploy](decisions/git.md) - [Panel Tweaks](decisions/panel-tweaks.md) - [WordPress Sign-In](decisions/wp-login.md) diff --git a/docs/decisions/smtp.md b/docs/decisions/smtp.md new file mode 100644 index 00000000..c016102e --- /dev/null +++ b/docs/decisions/smtp.md @@ -0,0 +1,69 @@ +# SMTP Relay + +## Scope + +The addon provides one server-level submission path for CloudPanel PHP sites. +It uses each site's PHP-FPM `sendmail_path` to call the installed +`clp-addons smtp-submit` command. That command reads a root-owned, world-readable +sender policy, identifies the site by the process's Unix UID, checks or rewrites +the message's From, and invokes Postfix's `/usr/sbin/sendmail` with the same +envelope sender. This covers default WordPress PHPMailer behavior and other PHP +`mail()` callers without changing application files. Applications with their +own SMTP transports remain outside this path. + +The default rule forces `noreply@{domain}`. A site may instead allow senders on +its own domain, additional administrator-approved domains, or exact addresses. +Forcing an address supports a relay account allowed to send as many domains; +allowing senders supports applications that need their own From identities. +The global SMTP credential is the fallback. Optional relay overrides are keyed +by *sending* domain, so a site can use a separate provider for mail it is +authorized to send from that domain. + +## Postfix and state + +Enabling requires Postfix and SASL modules. A newly installed Postfix is bound +to loopback for inbound SMTP; an existing Postfix installation keeps its +existing listener configuration. The addon sets `relayhost`, sender-dependent +relay routing, sender-dependent SASL authentication, authenticated SMTP client +settings, mandatory verified TLS, and `local_login_sender_maps`. The latter +limits envelope senders from known site Unix accounts even if they invoke +Postfix's sendmail command directly. The selected credential still has to be +authorized by its upstream provider. + +The global and per-domain credentials are stored in +`/var/lib/clp-addons/smtp/config.json` at mode `0600`. Postfix reads generated +`regexp:` credential and route maps under `/etc/postfix`; the credential map is +`0600`. Local Unix sender restrictions use a `hash:` map. The public submission +policy lives at `/etc/clp-addons/smtp-submission.json` and contains no SMTP +passwords. The manager receives only relay host, port, username, and a +has-password flag, never the saved password. + +Before changing Postfix, the action captures its explicit values for every key +it owns. Updates replace managed files atomically, run `postfix check`, then +reload Postfix. A failed update restores the prior managed files and settings. +Before changing PHP-FPM pools, it rejects conflicting sendmail settings, then +tests each affected PHP-FPM version and reloads it. The pool directive and +Postfix settings are withdrawn when the addon is disabled or uninstalled. The +saved addon policy remains for a later enable. + +## Security boundary and constraints + +The root gateway accepts only named SMTP verbs. The action validates a sending +domain against CloudPanel's PHP sites before changing a site rule, and validates +every relay host, address, template, and allowlist entry. It rejects multiple +sites sharing one Unix UID because their submissions could not be distinguished. +The submission command accepts only sendmail flags needed by PHP mail, ignores +an untrusted `-f` request, and caps messages at 25 MiB with a bounded header. + +This sender policy is enforced on PHP `mail()` submissions through the managed +pool. Direct Postfix submission from a site account is restricted at the +envelope only; a process with shell access can still write an arbitrary From +header, and another local SMTP listener can bypass the Unix account check. +This addon does not promise isolation against a hostile tenant with direct +process or network access. The test-mail action queues a message through +Postfix as root to test relay delivery; it does not exercise the PHP wrapper. + +The regular repair timer reapplies the policy to new or recreated PHP pools +every 15 minutes. Until then, a new site's mail may fail the Postfix local +sender check. Disabling removes the managed pool directives, submission policy, +Postfix settings, and generated maps. It does not remove Postfix itself. diff --git a/docs/smtp-relay.md b/docs/smtp-relay.md new file mode 100644 index 00000000..0a53832c --- /dev/null +++ b/docs/smtp-relay.md @@ -0,0 +1,54 @@ +# SMTP Relay + +SMTP Relay sends mail from CloudPanel PHP sites through the server's Postfix +queue and an authenticated SMTP server. WordPress uses PHPMailer by default, +which submits through PHP `mail()` unless the site changes its mailer. The addon +works with that default and with other PHP applications that call `mail()`; +there is no WordPress plugin to install. + +## Set up + +1. Enable **SMTP Relay** in Addons. The installer starts Postfix if needed. + An existing Postfix must be version 3.6 or newer. +2. Open **SMTP Relay** and enter the SMTP hostname, STARTTLS submission port + (normally 587), username, and password. Save the global relay. +3. Choose a default sender. `noreply@{domain}` becomes + `noreply@example.com` for the `example.com` site. **Force one address** + replaces an application's requested From address. **Allow site domains** + preserves From addresses on that site's domain and on any domains or exact + addresses explicitly granted in the site's editor. +4. For a sending domain that needs its own SMTP account, add a **Sending domain + relay**. All other senders use the global account. The relay's SMTP provider + must allow the resulting From address; configuring the addon does not create + mailboxes, authorize senders at the provider, or set DNS records. +5. Send a test to an inbox you control. The page confirms that Postfix queued + the message; check the inbox and, if needed, `/var/log/mail.log` and + `postqueue -p` for the delivery result. + +For Mailcow, one mailbox credential can be used as the global relay when that +mailbox is explicitly permitted to send as all intended domains. Otherwise use +separate SMTP credentials as sending domain relays. Keep ordinary mailboxes that +receive mail; the addon does not replace them. + +## Behavior and limits + +The sender rule applies to PHP `mail()` in CloudPanel's PHP-FPM site pools. In +force mode, the addon sets both the visible From and envelope sender to the +configured address. In allow mode, it rejects a requested From outside the +site's own domain and its approved senders. Only CloudPanel administrators can +grant additional domains or addresses. A site cannot use another site's domain +through this PHP mail path unless an administrator grants it. + +The addon does not alter applications that open their own SMTP connection. It +also does not filter arbitrary mail submitted directly to Postfix or another +local SMTP listener. Postfix restricts local envelope senders for known site +Unix accounts, but that check does not validate the message's visible From +header. Treat the site sender rule as a policy for PHP `mail()` and configure +untrusted shell or SMTP access separately. + +New PHP sites and pool changes are picked up by `clp-addons repair` and the +regular reconciliation timer (every 15 minutes). If a site has a conflicting +`sendmail_path` in its PHP-FPM pool, saving or repairing the relay reports the +conflict rather than replacing it. Disabling the addon restores the prior +Postfix settings and removes its PHP-FPM pool directives; saved relay settings +remain available when it is enabled again. diff --git a/install.sh b/install.sh index a61e8ad4..4cad545a 100755 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ CLI_ARTIFACT="clp-addons-linux-x64" ARTIFACTS=("${CLI_ARTIFACT}") CLI_TARGET="/usr/local/bin/clp-addons" GH_PRIVATE="/usr/local/libexec/clp-addons/gh" -AVAILABLE_ADDONS=("cloudflare-ips" "instatic" "stager" "maintenance" "php-resources" "git" "panel-tweaks" "wp-login") +AVAILABLE_ADDONS=("cloudflare-ips" "instatic" "stager" "maintenance" "php-resources" "git" "panel-tweaks" "wp-login" "smtp") VERSION="latest" SELECTED="" ASSUME_YES=0 diff --git a/lib/gateway-client.ts b/lib/gateway-client.ts index 53dbff17..6085e6c6 100644 --- a/lib/gateway-client.ts +++ b/lib/gateway-client.ts @@ -208,7 +208,7 @@ async function callGatewaySocket( * invokes directly. Otherwise dispatches securely through the root gateway daemon over UNIX socket. */ export async function callGatewayAction( - addon: "stager" | "instatic" | "cloudflare-ips" | "maintenance" | "php-resources" | "git" | "panel-tweaks" | "wp-login" | "manager", + addon: "stager" | "instatic" | "cloudflare-ips" | "maintenance" | "php-resources" | "git" | "panel-tweaks" | "wp-login" | "smtp" | "manager", verb: string, args: string[] = [], input?: string, diff --git a/lib/gateway-protocol.ts b/lib/gateway-protocol.ts index 2149e867..4fbfc693 100644 --- a/lib/gateway-protocol.ts +++ b/lib/gateway-protocol.ts @@ -106,6 +106,11 @@ export const CLOUDFLARE_IPS_ALLOWED_VERBS = new Set([ "policy", ]); +export const SMTP_ALLOWED_VERBS = new Set([ + "list", "save-relay", "save-default", "save-site", "clear-site", + "save-domain-relay", "clear-domain-relay", "test", +]); + /** * The manager's own privileged verbs: enabling and disabling an addon that * already ships inside this binary, and replacing the binary itself. diff --git a/tests/test-addon-catalog.test.ts b/tests/test-addon-catalog.test.ts index 572fe2e4..55b31505 100644 --- a/tests/test-addon-catalog.test.ts +++ b/tests/test-addon-catalog.test.ts @@ -44,7 +44,7 @@ test("every declared injection target is represented in the watch paths", () => }); test("every addon with privileged verbs declares them", () => { - for (const name of ["cloudflare-ips", "instatic", "stager", "maintenance", "php-resources", "git"]) { + for (const name of ["cloudflare-ips", "instatic", "stager", "maintenance", "php-resources", "git", "smtp"]) { expect(typeof ADDONS[name]!.action).toBe("function"); } // It is markup injected into the panel's login page and has nothing to do as @@ -53,14 +53,15 @@ test("every addon with privileged verbs declares them", () => { test("required systemd dependencies survive into the catalog", () => { expect(ADDONS.instatic!.requiresUnits).toEqual(["docker"]); - for (const name of ADDON_NAMES.filter((item) => item !== "instatic")) { + expect(ADDONS.smtp!.requiresUnits).toEqual(["postfix"]); + for (const name of ADDON_NAMES.filter((item) => item !== "instatic" && item !== "smtp")) { expect(ADDONS[name]!.requiresUnits ?? []).toEqual([]); } }); test("repair upkeep is the addons that ask for it, in catalog order", () => { const all = ADDON_NAMES.map((name) => ADDONS[name]!); - expect(addonMaintenance(all).map((spec) => spec.name)).toEqual(["instatic", "stager", "php-resources", "git", "panel-tweaks"]); + expect(addonMaintenance(all).map((spec) => spec.name)).toEqual(["instatic", "stager", "php-resources", "git", "panel-tweaks", "smtp"]); // Gated on being installed: repair passes the installed set, not every addon. expect(addonMaintenance([ADDONS.stager!]).map((spec) => spec.name)).toEqual(["stager"]); expect(addonMaintenance([ADDONS.maintenance!])).toEqual([]); diff --git a/tests/test-gateway-verbs.test.ts b/tests/test-gateway-verbs.test.ts index 95760357..c5167107 100644 --- a/tests/test-gateway-verbs.test.ts +++ b/tests/test-gateway-verbs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { CLOUDFLARE_IPS_ALLOWED_VERBS, GIT_ALLOWED_VERBS, INSTATIC_ALLOWED_VERBS, - MAINTENANCE_ALLOWED_VERBS, STAGER_ALLOWED_VERBS, + MAINTENANCE_ALLOWED_VERBS, SMTP_ALLOWED_VERBS, STAGER_ALLOWED_VERBS, } from "../lib/gateway-protocol"; /** @@ -37,6 +37,10 @@ const ROOT_ONLY: Record = { { verb: "run", why: "the deployment runner, started only by the transient unit the deploy path launches" }, { verb: "prune", why: "retention sweeping, run by the reconcile timer" }, ], + smtp: [ + { verb: "reconcile", why: "run by the regular repair timer" }, + { verb: "deactivate", why: "run locally during disable or uninstall" }, + ], }; const ADDONS = [ @@ -45,6 +49,7 @@ const ADDONS = [ { addon: "maintenance", file: "addons/maintenance/action.ts", union: "MaintenanceVerb", allowed: MAINTENANCE_ALLOWED_VERBS }, { addon: "cloudflare-ips", file: "addons/cloudflare-ips/action.ts", union: "CloudflareVerb", allowed: CLOUDFLARE_IPS_ALLOWED_VERBS }, { addon: "git", file: "addons/git/action.ts", union: "GitVerb", allowed: GIT_ALLOWED_VERBS }, + { addon: "smtp", file: "addons/smtp/action.ts", union: "SmtpVerb", allowed: SMTP_ALLOWED_VERBS }, ]; /** The string members of a `type XVerb = "a" | "b" | ...` declaration. */ diff --git a/tests/test-mount.test.ts b/tests/test-mount.test.ts index cf32b03b..5a22e26a 100644 --- a/tests/test-mount.test.ts +++ b/tests/test-mount.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test"; import { mountPath, splitMount } from "../lib/mount"; import { ADDON_NAMES } from "../cli/addon-catalog"; -const ALL = ["cloudflare-ips", "instatic", "stager", "maintenance", "php-resources", "git", "panel-tweaks", "wp-login"]; +const ALL = ["cloudflare-ips", "instatic", "stager", "maintenance", "php-resources", "git", "panel-tweaks", "wp-login", "smtp"]; function hit(path: string): string { const match = splitMount(path, ALL); diff --git a/tests/test-smtp.test.ts b/tests/test-smtp.test.ts new file mode 100644 index 00000000..78806742 --- /dev/null +++ b/tests/test-smtp.test.ts @@ -0,0 +1,119 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { executeSmtpAction, postfixMaps, type SmtpActionOptions, type SmtpState } from "../addons/smtp/action"; +import { emptySmtpPolicy, parseRule, type SmtpSubmissionSite } from "../addons/smtp/config"; +import { prepareSubmission } from "../addons/smtp/submit"; +import { dashboardView } from "../addons/smtp/app/views"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +const site: SmtpSubmissionSite = { + domain: "example.com", user: "example", uid: 1234, + rule: { mode: "force", sender: "noreply@{domain}", domains: [], addresses: [] }, +}; + +test("forced sender replaces a foreign From and sets the envelope identity", () => { + const raw = Buffer.from("To: user@recipient.test\r\nFrom: noreply@cool.com\r\nSubject: Reset\r\nReturn-Path: forged@cool.com\r\n\r\nHello", "utf8"); + const result = prepareSubmission(raw, site); + const output = Buffer.from(result.message).toString("utf8"); + expect(result.sender).toBe("noreply@example.com"); + expect(output).toContain("From: noreply@example.com\r\n"); + expect(output).not.toContain("cool.com"); + expect(output.endsWith("\r\n\r\nHello")).toBe(true); +}); + +test("allow-listed mode preserves own and approved domains but refuses another site", () => { + const allowed = { ...site, rule: parseRule({ mode: "allow", sender: "noreply@{domain}", domains: ["news.example.com"], addresses: ["billing@partner.test"] }) }; + for (const sender of ["wordpress@example.com", "edition@news.example.com", "billing@partner.test"]) { + const result = prepareSubmission(Buffer.from(`To: test@recipient.test\nFrom: ${sender}\n\nHi`), allowed); + expect(result.sender).toBe(sender); + } + expect(() => prepareSubmission(Buffer.from("To: test@recipient.test\nFrom: noreply@cool.com\n\nHi"), allowed)).toThrow("not allowed"); + expect(() => prepareSubmission(Buffer.from("From: a@example.com\nFrom: b@example.com\nTo: test@recipient.test\n\nHi"), allowed)).toThrow("multiple From"); +}); + +test("folded From is checked and ignored Sender fields cannot change it", () => { + const allowed = { ...site, rule: { ...site.rule, mode: "allow" as const } }; + const result = prepareSubmission(Buffer.from("To: test@recipient.test\nFrom: Example\n \nSender: forged@cool.com\n x: bogus\n\nHi"), allowed); + expect(result.sender).toBe("wordpress@example.com"); + expect(Buffer.from(result.message).toString()).not.toContain("forged@cool.com"); +}); + +test("domain relay map selects an override before the shared credential", () => { + const policy = emptySmtpPolicy(); + policy.relay = { host: "mail.example.com", port: 587, username: "shared@example.com", password: "secret-one" }; + policy.relayOverrides["cool.com"] = { host: "smtp.cool.com", port: 587, username: "noreply@cool.com", password: "secret-two" }; + const maps = postfixMaps(policy); + expect(maps.credentials.indexOf("noreply@cool.com:secret-two")).toBeLessThan(maps.credentials.indexOf("shared@example.com:secret-one")); + expect(maps.routes).toContain("[smtp.cool.com]:587"); + expect(maps.credentials).not.toContain("* shared"); +}); + +test("regexp credential maps keep dollar signs literal in SMTP passwords", () => { + const policy = emptySmtpPolicy(); + policy.relay = { host: "mail.example.com", port: 587, username: "relay@example.com", password: "pa$1ss" }; + expect(postfixMaps(policy).credentials).toContain("relay@example.com:pa$$1ss"); +}); + +test("configured relay applies to Postfix and site pool, then deactivates cleanly", async () => { + const dir = mkdtempSync(join(tmpdir(), "clp-smtp-")); + dirs.push(dir); + const poolDir = join(dir, "php", "8.2", "fpm", "pool.d"); + const postfixDir = join(dir, "postfix"); + mkdirSync(poolDir, { recursive: true }); + mkdirSync(postfixDir); + const pool = join(poolDir, "example.com.conf"); + writeFileSync(pool, "[example.com]\nuser = example\n", { mode: 0o644 }); + chmodSync(pool, 0o644); + const settings = new Map(); + const commands: string[] = []; + const run: NonNullable = (command, args) => { + commands.push(`${command} ${args.join(" ")}`); + if (command === "postconf" && args[0] === "-d") { + return { ok: true, stdout: "local_login_sender_maps = static:*", stderr: "" }; + } + if (command === "postconf" && args[0] === "-n") { + return { ok: true, stdout: [...settings].map(([key, value]) => `${key} = ${value}`).join("\n"), stderr: "" }; + } + if (command === "postconf" && args[0] === "-e") { + const text = args[1]!; const equal = text.indexOf("="); settings.set(text.slice(0, equal), text.slice(equal + 1)); + } + if (command === "postconf" && args[0] === "-X") settings.delete(args[1]!); + if (command === "postmap") writeFileSync(`${args[0]!.slice(5)}.db`, "indexed"); + return { ok: true, stdout: "", stderr: "" }; + }; + const options: SmtpActionOptions = { + processUid: 0, + paths: { + phpRoot: join(dir, "php"), postfixDir, + stateFile: join(dir, "state.json"), originalFile: join(dir, "original.json"), + submissionFile: join(dir, "submission.json"), lockFile: join(dir, "smtp.lock"), + rootUid: process.getuid?.() ?? 0, + }, + sites: [{ domain: "example.com", user: "example", phpVersion: "8.2", uid: 1234 }], + run, + }; + const body = { relay: { host: "mail.example.com", port: 587, username: "relay@example.com", password: "secret" } }; + await executeSmtpAction(["save-relay"], { ...options, input: JSON.stringify(body) }); + expect(readFileSync(pool, "utf8")).toContain("smtp-submit -t -i"); + expect(readFileSync(join(dir, "submission.json"), "utf8")).toContain("noreply@{domain}"); + expect(settings.get("local_login_sender_maps")).toContain("clp-addons-local-senders"); + expect(commands.some((item) => item.includes("php-fpm8.2 -t"))).toBe(true); + const publicState = await executeSmtpAction(["list"], options); + const html = dashboardView(publicState as SmtpState); + expect(JSON.stringify(publicState)).not.toContain("secret"); + expect(html).not.toContain("secret"); + writeFileSync(pool, readFileSync(pool, "utf8") + "php_admin_value[sendmail_path] = /other/sendmail\n"); + await expect(executeSmtpAction(["save-default"], { ...options, input: JSON.stringify({ + rule: { mode: "allow", sender: "noreply@{domain}", domains: [], addresses: [] }, + }) })).rejects.toThrow("already configures sendmail_path"); + expect((await executeSmtpAction(["list"], options) as SmtpState).defaultRule.mode).toBe("force"); + writeFileSync(pool, readFileSync(pool, "utf8").replace("php_admin_value[sendmail_path] = /other/sendmail\n", "")); + await executeSmtpAction(["deactivate"], options); + expect(readFileSync(pool, "utf8")).not.toContain("smtp-submit"); + expect(existsSync(join(dir, "submission.json"))).toBe(false); + expect(settings.has("relayhost")).toBe(false); +}); diff --git a/tools/preview-ui.ts b/tools/preview-ui.ts index a12b6249..6ec5d3f9 100644 --- a/tools/preview-ui.ts +++ b/tools/preview-ui.ts @@ -8,6 +8,8 @@ import { fleetView as gitFleetView, fragment as gitFragment, layout as gitLayout import type { GitSiteStatus } from "../addons/git/app/service"; import { fleetView as maintenanceFleetView, fragment as maintenanceFragment, layout as maintenanceLayout, siteView as maintenanceSiteView } from "../addons/maintenance/app/views"; import { dashboardView as phpResourcesDashboardView, layout as phpResourcesLayout } from "../addons/php-resources/app/views"; +import { dashboardView as smtpDashboardView, layout as smtpLayout } from "../addons/smtp/app/views"; +import type { SmtpState } from "../addons/smtp/action"; import { dashboardView as panelTweaksDashboardView, layout as panelTweaksLayout } from "../addons/panel-tweaks/app/views"; import { siteLayoutTarget } from "../lib/panel-nav"; import { sitesBlock } from "../addons/panel-tweaks/inject/targets"; @@ -47,6 +49,22 @@ const sites: SiteSummary[] = [ const siteVarnish: Record = { "www.example.com": true }; const PREVIEW_PUBLIC_IP = "203.0.113.10"; +function smtpPreviewState(url: URL): SmtpState { + const configured = !url.searchParams.has("setup"); + return { + configured, + relay: configured ? { host: "mail.example.com", port: 587, username: "cloudpanel-relay@example.com", hasPassword: true } : null, + relayOverrides: configured ? { "shop.example.com": { host: "smtp.provider.test", port: 587, username: "shop@example.com", hasPassword: true } } : {}, + defaultRule: { mode: "force", sender: "noreply@{domain}", domains: [], addresses: [] }, + sites: url.searchParams.has("empty") ? [] : [ + { domain: "www.example.com", user: "example", phpVersion: "8.3", overridden: false, + rule: { mode: "force", sender: "noreply@{domain}", domains: [], addresses: [] }, senderPreview: "noreply@www.example.com" }, + { domain: "shop.example.com", user: "shop", phpVersion: "8.3", overridden: true, + rule: { mode: "allow", sender: "noreply@{domain}", domains: ["news.shop.example.com"], addresses: [] }, senderPreview: "noreply@shop.example.com" }, + ], + }; +} + function gitPreviewLog(site: GitSiteStatus): string { if (!site.lastJob || !site.config || site.lastJob.state === "queued") return ""; return [ @@ -657,7 +675,7 @@ const server = Bun.serve({ } : null; const page = indexPage(enabled, notice, { - available: ["cloudflare-ips", "instatic", "stager", "maintenance", "php-resources", "git", "panel-tweaks", "wp-login"].filter((name) => !enabled.includes(name)), + available: ["cloudflare-ips", "instatic", "stager", "maintenance", "php-resources", "git", "panel-tweaks", "wp-login", "smtp"].filter((name) => !enabled.includes(name)), job: previewJob, csrf: "preview-csrf-token", }); @@ -710,6 +728,8 @@ const server = Bun.serve({ html = wpLoginLayout("WordPress Sign-In", wpLoginDashboardView(wpLoginPreviewSites(url)), notice); } else if (path === "/addons/php-resources/" || path === "/addons/php-resources") { html = phpResourcesLayout("PHP resources", phpResourcesDashboardView(phpResourcesPreviewState(url)), notice); + } else if (path === "/addons/smtp/" || path === "/addons/smtp") { + html = smtpLayout("SMTP Relay", smtpDashboardView(smtpPreviewState(url)), notice); } else if (path === "/addons/cloudflare-ips/" || path === "/addons/cloudflare-ips") { html = cloudflareLayout("Cloudflare IP access", cloudflareDashboardView(cloudflarePreviewState(url)), notice); } else if (path === "/addons/instatic/") { From f620c0bcf6c813081cf0d1ad92ecbd859e41861e Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:57:50 +0000 Subject: [PATCH 2/5] Address SMTP relay review findings --- addons/smtp/action.ts | 3 ++- addons/smtp/submit.ts | 2 +- cli/provision.ts | 3 +++ cli/uninstall.ts | 3 ++- docs/decisions/smtp.md | 9 ++++++--- docs/smtp-relay.md | 4 +++- tests/test-provision.test.ts | 17 +++++++++++++++++ tests/test-smtp.test.ts | 4 ++++ tests/test-uninstall.test.ts | 22 +++++++++++++++++++--- 9 files changed, 57 insertions(+), 10 deletions(-) diff --git a/addons/smtp/action.ts b/addons/smtp/action.ts index 0555a02b..146022a0 100644 --- a/addons/smtp/action.ts +++ b/addons/smtp/action.ts @@ -153,6 +153,7 @@ function validatedSites(sites: SiteRow[]): SiteRow[] { for (const site of sites) { smtpDomain(site.domain); if (!/^[0-9]+\.[0-9]+$/.test(site.phpVersion)) failAction(`invalid PHP version for ${site.domain}`); + if (["root", "postfix", "clp"].includes(site.user)) failAction(`reserved Unix account for ${site.domain}`); if (uids.has(site.uid)) failAction(`multiple sites share Unix UID ${site.uid}; SMTP cannot identify their sender safely`); uids.add(site.uid); } @@ -233,7 +234,7 @@ export function postfixMaps(policy: SmtpPolicy): { credentials: string; routes: } function localSenderMap(policy: SmtpPolicy, sites: SiteRow[]): string { - const entries = ["root *", "postfix *"]; + const entries = ["root *", "postfix *", "clp *"]; for (const site of sites) { const rule = effectiveRule(policy, site.domain); const patterns = rule.mode === "force" diff --git a/addons/smtp/submit.ts b/addons/smtp/submit.ts index 44dccd92..adab793b 100644 --- a/addons/smtp/submit.ts +++ b/addons/smtp/submit.ts @@ -62,8 +62,8 @@ export function prepareSubmission(message: Uint8Array, site: SmtpSubmissionSite) kept.push(line); } } - const from = fromRaw === null ? null : senderFromHeader(fromRaw); const configured = senderFor(site.rule.sender, site.domain); + const from = site.rule.mode === "allow" && fromRaw !== null ? senderFromHeader(fromRaw) : null; const sender = site.rule.mode === "force" ? configured : (from ?? configured); if (site.rule.mode === "allow" && !permittedSender(site, sender)) { throw new Error(`sender ${sender} is not allowed for ${site.domain}`); diff --git a/cli/provision.ts b/cli/provision.ts index 34a0c35b..47813b7f 100644 --- a/cli/provision.ts +++ b/cli/provision.ts @@ -272,6 +272,9 @@ export function ensureRequiredUnits( if (!install.ok) fatal(`Postfix installation failed: ${install.out || "apt-get failed"}`); const outboundOnly = commands.tryRun("postconf", ["-e", "inet_interfaces=loopback-only"]); if (!outboundOnly.ok) fatal(`Postfix could not be limited to local submissions: ${outboundOnly.out || "postconf failed"}`); + if (!commands.tryRun("systemctl", ["restart", "postfix"]).ok) { + fatal("Postfix could not be restarted on the loopback interface"); + } } if (!commands.tryRun("systemctl", ["enable", "--now", "postfix"]).ok) { fatal("Postfix could not be started"); diff --git a/cli/uninstall.ts b/cli/uninstall.ts index 7d51c10e..ba146589 100644 --- a/cli/uninstall.ts +++ b/cli/uninstall.ts @@ -48,6 +48,8 @@ function applyUninstall(spec: AddonSpec, flags: Record): ); } + spec.deactivate?.(); + if (!reconcileMaintenanceNginx(true, remaining.includes("maintenance"))) { fatal("could not safely update the Nginx maintenance check; no addon files were removed"); } @@ -72,7 +74,6 @@ function applyUninstall(spec: AddonSpec, flags: Record): } } removeSudoers(); - spec.deactivate?.(); if (spec.name === "wp-login") withdrawWpLogin(); if (purge) rmSync(spec.stateDir, { recursive: true, force: true }); rmSync(spec.configFile, { force: true }); diff --git a/docs/decisions/smtp.md b/docs/decisions/smtp.md index c016102e..001fe4b1 100644 --- a/docs/decisions/smtp.md +++ b/docs/decisions/smtp.md @@ -33,9 +33,12 @@ authorized by its upstream provider. The global and per-domain credentials are stored in `/var/lib/clp-addons/smtp/config.json` at mode `0600`. Postfix reads generated `regexp:` credential and route maps under `/etc/postfix`; the credential map is -`0600`. Local Unix sender restrictions use a `hash:` map. The public submission -policy lives at `/etc/clp-addons/smtp-submission.json` and contains no SMTP -passwords. The manager receives only relay host, port, username, and a +`0600`. Local Unix sender restrictions use a `hash:` map. The trusted `root`, +`postfix`, and CloudPanel `clp` accounts retain unrestricted local envelope +senders; site accounts get only their configured senders. Other local Unix +accounts are not granted Postfix sendmail access by this addon. The public +submission policy lives at `/etc/clp-addons/smtp-submission.json` and contains +no SMTP passwords. The manager receives only relay host, port, username, and a has-password flag, never the saved password. Before changing Postfix, the action captures its explicit values for every key diff --git a/docs/smtp-relay.md b/docs/smtp-relay.md index 0a53832c..dea1b55d 100644 --- a/docs/smtp-relay.md +++ b/docs/smtp-relay.md @@ -44,7 +44,9 @@ also does not filter arbitrary mail submitted directly to Postfix or another local SMTP listener. Postfix restricts local envelope senders for known site Unix accounts, but that check does not validate the message's visible From header. Treat the site sender rule as a policy for PHP `mail()` and configure -untrusted shell or SMTP access separately. +untrusted shell or SMTP access separately. Local submissions from other Unix +accounts are restricted, except for Postfix, root, and CloudPanel's `clp` +account. New PHP sites and pool changes are picked up by `clp-addons repair` and the regular reconciliation timer (every 15 minutes). If a site has a conflicting diff --git a/tests/test-provision.test.ts b/tests/test-provision.test.ts index f8ab361b..b9723d20 100644 --- a/tests/test-provision.test.ts +++ b/tests/test-provision.test.ts @@ -216,6 +216,7 @@ function requiredUnitsProbe(options: { downloadOk?: boolean; installOk?: boolean; enableOk?: boolean; + restartOk?: boolean; } = {}): { ok: boolean; error?: string; calls: Array<{ command: string; args: string[] }> } { const script = ` import { ensureRequiredUnits } from "./cli/provision.ts"; @@ -235,6 +236,7 @@ function requiredUnitsProbe(options: { if (command === "curl") return { ok: options.downloadOk ?? true, out: options.downloadOk === false ? "could not resolve host" : "" }; if (command === "sh") return { ok: options.installOk ?? true, out: options.installOk === false ? "get-docker.sh exited 1" : "" }; if (command === "systemctl" && args[0] === "enable") return { ok: options.enableOk ?? true, out: "" }; + if (command === "systemctl" && args[0] === "restart") return { ok: options.restartOk ?? true, out: "" }; return { ok: true, out: "" }; }, }; @@ -303,6 +305,21 @@ test("fails clearly when Docker still is not active after installing it", () => expect(result.error).toBe("docker is not active; installing it did not bring the service up"); }); +test("a fresh Postfix install restarts after binding to loopback", () => { + const result = requiredUnitsProbe({ unit: "postfix", active: false, dockerUnitLoaded: false }); + expect(result.ok).toBe(true); + const commands = result.calls.map(({ command, args }) => `${command} ${args.join(" ")}`); + expect(commands.indexOf("postconf -e inet_interfaces=loopback-only")).toBeLessThan(commands.indexOf("systemctl restart postfix")); + expect(commands.indexOf("systemctl restart postfix")).toBeLessThan(commands.indexOf("systemctl enable --now postfix")); +}); + +test("a failed Postfix restart aborts provisioning", () => { + const result = requiredUnitsProbe({ unit: "postfix", active: false, dockerUnitLoaded: false, restartOk: false }); + expect(result.ok).toBe(false); + expect(result.error).toBe("Postfix could not be restarted on the loopback interface"); + expect(result.calls).not.toContainEqual({ command: "systemctl", args: ["enable", "--now", "postfix"] }); +}); + test("a non-docker required unit still fails fast with no provisioning attempt", () => { const result = requiredUnitsProbe({ unit: "postgresql", active: false }); diff --git a/tests/test-smtp.test.ts b/tests/test-smtp.test.ts index 78806742..28f99341 100644 --- a/tests/test-smtp.test.ts +++ b/tests/test-smtp.test.ts @@ -23,6 +23,9 @@ test("forced sender replaces a foreign From and sets the envelope identity", () expect(output).toContain("From: noreply@example.com\r\n"); expect(output).not.toContain("cool.com"); expect(output.endsWith("\r\n\r\nHello")).toBe(true); + const invalidRequested = prepareSubmission(Buffer.from("To: user@recipient.test\nFrom: wordpress@example.com (WordPress)\n\nHello"), site); + expect(invalidRequested.sender).toBe("noreply@example.com"); + expect(Buffer.from(invalidRequested.message).toString()).not.toContain("(WordPress)"); }); test("allow-listed mode preserves own and approved domains but refuses another site", () => { @@ -101,6 +104,7 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl expect(readFileSync(pool, "utf8")).toContain("smtp-submit -t -i"); expect(readFileSync(join(dir, "submission.json"), "utf8")).toContain("noreply@{domain}"); expect(settings.get("local_login_sender_maps")).toContain("clp-addons-local-senders"); + expect(readFileSync(join(postfixDir, "clp-addons-local-senders"), "utf8")).toContain("clp *\n"); expect(commands.some((item) => item.includes("php-fpm8.2 -t"))).toBe(true); const publicState = await executeSmtpAction(["list"], options); const html = dashboardView(publicState as SmtpState); diff --git a/tests/test-uninstall.test.ts b/tests/test-uninstall.test.ts index b8a713bf..cabdbaac 100644 --- a/tests/test-uninstall.test.ts +++ b/tests/test-uninstall.test.ts @@ -12,6 +12,7 @@ const configFile = join(root, "instatic.conf"); const actionBin = join(root, "clp-addons"); const identityPath = join(root, "panel-identity.conf"); const callsPath = join(root, "calls"); +const stopsPath = join(root, "stops"); const identityReadsPath = join(root, "identity-reads"); const domains = ["alpha.example.test", "beta.example.test", "gamma.example.test"]; let failureDomain: string | undefined; @@ -37,7 +38,7 @@ printf '%s\n' '{"ok":true,"data":{"status":"deleted"}}' const childScript = String.raw` import { mock } from "bun:test"; -import { existsSync, rmSync } from "node:fs"; +import { existsSync, rmSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; const root = process.env.CLP_TEST_ROOT; @@ -46,6 +47,7 @@ const configFile = process.env.CLP_TEST_CONFIG; const actionBin = process.env.CLP_TEST_ACTION_BIN; const identityPath = process.env.CLP_TEST_IDENTITY; const legacyActionDir = process.env.CLP_TEST_LEGACY_ACTION_DIR; +const stopsPath = process.env.CLP_TEST_STOPS; if (!root || !stateDir || !configFile || !actionBin || !identityPath || !legacyActionDir) { throw new Error("test fixture environment is incomplete"); } @@ -58,6 +60,9 @@ const spec = { requiresUnits: [], stateDir, targets: [], + deactivate: () => { + if (process.env.CLP_TEST_DEACTIVATE_FAIL === "1") throw new Error("simulated SMTP cleanup failure"); + }, }; let removeSudoersCalls = 0; @@ -134,7 +139,7 @@ mock.module("./cli/provision.ts", () => ({ rmSync(identityPath, { force: true }); }, startUnits: () => {}, - stopUnits: () => {}, + stopUnits: () => { writeFileSync(stopsPath, "stopped"); }, unitActive: () => "inactive", unitPid: () => null, warnIfPanelSessionUnreadable: () => {}, @@ -202,11 +207,12 @@ function resetFixture(): void { writeFileSync(actionBin, action, { mode: 0o755 }); chmodSync(actionBin, 0o755); rmSync(callsPath, { force: true }); + rmSync(stopsPath, { force: true }); rmSync(identityReadsPath, { force: true }); failureDomain = undefined; } -function runUninstall(): { ok: boolean; error?: string; removeSudoersCalls: number } { +function runUninstall(deactivateFail = false): { ok: boolean; error?: string; removeSudoersCalls: number } { const result = spawnSync(process.execPath, ["-e", childScript], { cwd: repo, encoding: "utf8", @@ -220,6 +226,8 @@ function runUninstall(): { ok: boolean; error?: string; removeSudoersCalls: numb CLP_TEST_LEGACY_ACTION_DIR: legacyActionDir, CLP_TEST_FAIL_DOMAIN: failureDomain ?? "", CLP_TEST_CALLS: callsPath, + CLP_TEST_STOPS: stopsPath, + CLP_TEST_DEACTIVATE_FAIL: deactivateFail ? "1" : "0", CLP_TEST_IDENTITY_READS: identityReadsPath, }, }); @@ -231,6 +239,14 @@ function runUninstall(): { ok: boolean; error?: string; removeSudoersCalls: numb beforeEach(resetFixture); +test.serial("a failed deactivation stops uninstall before shared services change", () => { + const result = runUninstall(true); + expect(result).toEqual({ ok: false, error: "simulated SMTP cleanup failure", removeSudoersCalls: 0 }); + expect(existsSync(stopsPath)).toBe(false); + expect(existsSync(configFile)).toBe(true); + expect(existsSync(identityPath)).toBe(true); +}); + test.serial("purge deletes every instance before removing panel identity", () => { const result = runUninstall(); expect(result).toEqual({ ok: true, removeSudoersCalls: 1 }); From 517086ea7c5e1b18340e7aaad3819a2549fb6c18 Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:26:52 +0000 Subject: [PATCH 3/5] Restore empty Postfix settings exactly --- addons/smtp/action.ts | 2 +- tests/test-smtp.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/addons/smtp/action.ts b/addons/smtp/action.ts index 146022a0..c5b474bb 100644 --- a/addons/smtp/action.ts +++ b/addons/smtp/action.ts @@ -263,7 +263,7 @@ function currentPostfixSettings(run: (command: string, args: string[]) => Comman const explicit = runChecked(run, "postconf", ["-n"]); const values: Record = {}; for (const key of POSTFIX_KEYS) { - const match = explicit.match(new RegExp(`(?:^|\\n)${key}\\s*=\\s*([^\\n]*)`)); + const match = explicit.match(new RegExp(`(?:^|\\n)${key}[ \\t]*=[ \\t]*([^\\n]*)`)); values[key] = match ? match[1]!.trim() : null; } return values; diff --git a/tests/test-smtp.test.ts b/tests/test-smtp.test.ts index 28f99341..4194529f 100644 --- a/tests/test-smtp.test.ts +++ b/tests/test-smtp.test.ts @@ -72,6 +72,8 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl writeFileSync(pool, "[example.com]\nuser = example\n", { mode: 0o644 }); chmodSync(pool, 0o644); const settings = new Map(); + settings.set("relayhost", ""); + settings.set("smtp_tls_CApath", "/etc/ssl/certs"); const commands: string[] = []; const run: NonNullable = (command, args) => { commands.push(`${command} ${args.join(" ")}`); @@ -119,5 +121,6 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl await executeSmtpAction(["deactivate"], options); expect(readFileSync(pool, "utf8")).not.toContain("smtp-submit"); expect(existsSync(join(dir, "submission.json"))).toBe(false); - expect(settings.has("relayhost")).toBe(false); + expect(settings.get("relayhost")).toBe(""); + expect(settings.get("smtp_tls_CApath")).toBe("/etc/ssl/certs"); }); From 8014435f24702b99039e088a1b5daa92c156ae79 Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:31:53 +0000 Subject: [PATCH 4/5] Fix SMTP relay review findings --- addons/smtp/action.ts | 74 +++++++++++++++++++++++-------- addons/smtp/app/index.ts | 3 +- addons/smtp/app/service.ts | 1 + addons/smtp/app/views.client.js | 33 +++++++++----- addons/smtp/app/views.css | 2 + addons/smtp/app/views.ts | 41 ++++++++--------- addons/smtp/config.ts | 17 +++++-- addons/smtp/submit.ts | 26 ++++++++++- cli/index.ts | 2 +- cli/provision.ts | 32 +++++++++++--- docs/decisions/smtp.md | 18 +++++--- docs/smtp-relay.md | 21 +++++---- lib/gateway-protocol.ts | 2 +- tests/test-provision.test.ts | 50 +++++++++++++++++++++ tests/test-smtp.test.ts | 78 ++++++++++++++++++++++++++++++++- 15 files changed, 318 insertions(+), 82 deletions(-) diff --git a/addons/smtp/action.ts b/addons/smtp/action.ts index c5b474bb..5fe28125 100644 --- a/addons/smtp/action.ts +++ b/addons/smtp/action.ts @@ -8,18 +8,18 @@ import { import { CLI_BIN, PANEL_DB, STATE_DIR } from "../../cli/paths"; import { writeFileAtomic } from "../../lib/atomic-write"; import { - emptySmtpPolicy, parseRelay, parseRule, senderFor, smtpAddress, smtpDomain, + emptySmtpPolicy, parseRelay, parseRule, senderFor, senderGrants, smtpAddress, smtpDomain, type SmtpPolicy, type SmtpRelay, type SmtpSiteRule, type SmtpSubmissionPolicy, } from "./config"; import { SUBMISSION_POLICY_PATH } from "./submit"; const MANAGED_POOL_LINE = `php_admin_value[sendmail_path] = ${CLI_BIN} smtp-submit -t -i`; const MANAGED_POOL_MARKER = "; clp-addons smtp relay"; -type SmtpVerb = "list" | "save-relay" | "save-default" | "save-site" | "clear-site" | +type SmtpVerb = "list" | "save-setup" | "save-relay" | "save-default" | "save-site" | "clear-site" | "save-domain-relay" | "clear-domain-relay" | "test" | "reconcile" | "deactivate"; const POSTFIX_KEYS = [ "relayhost", "smtp_sasl_auth_enable", "smtp_sender_dependent_authentication", - "smtp_sasl_password_maps", "sender_dependent_relayhost_maps", "smtp_tls_security_level", + "smtp_sasl_password_maps", "sender_dependent_relayhost_maps", "smtp_tls_security_level", "smtp_tls_policy_maps", "smtp_sasl_security_options", "smtp_sasl_tls_security_options", "local_login_sender_maps", ] as const; @@ -192,7 +192,10 @@ function replacePolicy(policy: SmtpPolicy, verb: string, body: Record + `/^${destination.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$/ secure match=nexthop`).join("\n") + "\n"; + return { credentials: credentials.join("\n") + "\n", routes: routes.join("\n") + "\n", tls }; } function localSenderMap(policy: SmtpPolicy, sites: SiteRow[]): string { const entries = ["root *", "postfix *", "clp *"]; for (const site of sites) { const rule = effectiveRule(policy, site.domain); - const patterns = rule.mode === "force" - ? [senderFor(rule.sender, site.domain)] - : [senderFor(rule.sender, site.domain), `@${site.domain}`, ...rule.domains.map((d) => `@${d}`), ...rule.addresses]; - entries.push(`${site.user} ${[...new Set(patterns)].join(" ")}`); + const grants = senderGrants({ domain: site.domain, rule }); + entries.push(`${site.user} ${[...grants.addresses, ...grants.domains.map((domain) => `@${domain}`)].join(" ")}`); } return entries.join("\n") + "\n"; } -function managedPaths(paths: SmtpPaths): { credentials: string; routes: string; senders: string } { +function managedPaths(paths: SmtpPaths): { credentials: string; routes: string; senders: string; tls: string } { return { credentials: join(paths.postfixDir, "clp-addons-sasl"), routes: join(paths.postfixDir, "clp-addons-relays"), senders: join(paths.postfixDir, "clp-addons-local-senders"), + tls: join(paths.postfixDir, "clp-addons-tls-policy"), }; } +function managedFileSnapshot(paths: SmtpPaths): { path: string; content: Buffer | null; mode: number }[] { + const managed = managedPaths(paths); + return [managed.credentials, managed.routes, managed.senders, managed.tls, `${managed.senders}.db`, paths.submissionFile].map((path) => { + if (!existsSync(path)) return { path, content: null, mode: 0o600 }; + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== paths.rootUid || (stat.mode & 0o022) !== 0) { + failAction(`refusing untrusted SMTP file: ${path}`); + } + return { path, content: readFileSync(path), mode: stat.mode & 0o777 }; + }); +} + +function restoreManagedFiles(snapshot: ReturnType): void { + for (const { path, content, mode } of snapshot) { + if (content === null) rmSync(path, { force: true }); + else writeFileAtomic(path, content, { mode, createParent: true }); + } +} + function capturePostfix(paths: SmtpPaths, run: (command: string, args: string[]) => CommandResult): void { if (trustedRead(paths.originalFile, paths.rootUid) !== null) return; const values = currentPostfixSettings(run); @@ -271,6 +295,8 @@ function currentPostfixSettings(run: (command: string, args: string[]) => Comman function applyPostfixSettings(values: Record, run: (command: string, args: string[]) => CommandResult): void { for (const key of POSTFIX_KEYS) { + // Backups written before this key was managed must leave it untouched. + if (!(key in values)) continue; const value = values[key]; if (value === null || value === undefined) runChecked(run, "postconf", ["-X", key]); else runChecked(run, "postconf", ["-e", `${key}=${value}`]); @@ -296,6 +322,7 @@ function updatePostfix(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], r [managed.credentials]: maps.credentials, [managed.routes]: maps.routes, [managed.senders]: localSenderMap(policy, sites), + [managed.tls]: maps.tls, }; const before = Object.fromEntries(Object.keys(mapContents).map((path) => [path, trustedRead(path, paths.rootUid)])); const oldDbPath = `${managed.senders}.db`; @@ -304,6 +331,16 @@ function updatePostfix(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], r failAction(`refusing untrusted SMTP file: ${oldDbPath}`); } const oldDb = oldDbStat ? readFileSync(oldDbPath) : null; + const current = currentPostfixSettings(run); + const originalRaw = trustedRead(paths.originalFile, paths.rootUid); + const originalValues = originalRaw ? (JSON.parse(originalRaw) as OriginalPostfix).values : null; + if (originalValues && !("smtp_tls_policy_maps" in originalValues)) { + // Earlier installations did not manage TLS policy maps, so the current + // value is the operator's original value and belongs in the backup. + originalValues.smtp_tls_policy_maps = current.smtp_tls_policy_maps ?? null; + writeFileAtomic(paths.originalFile, JSON.stringify({ version: 1, values: originalValues }, null, 2) + "\n", { mode: 0o600 }); + } + const existingTlsMaps = originalValues ? originalValues.smtp_tls_policy_maps : current.smtp_tls_policy_maps; const settings: Record = { relayhost: relayDestination(policy.relay), smtp_sasl_auth_enable: "yes", @@ -311,11 +348,11 @@ function updatePostfix(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], r smtp_sasl_password_maps: `regexp:${managed.credentials}`, sender_dependent_relayhost_maps: `regexp:${managed.routes}`, smtp_tls_security_level: "secure", + smtp_tls_policy_maps: [`regexp:${managed.tls}`, existingTlsMaps].filter(Boolean).join(", "), smtp_sasl_security_options: "noanonymous", smtp_sasl_tls_security_options: "noanonymous", local_login_sender_maps: `hash:${managed.senders}`, }; - const current = currentPostfixSettings(run); const mapsChanged = Object.entries(mapContents).some(([path, content]) => before[path] !== content); const configChanged = Object.entries(settings).some(([key, value]) => current[key] !== value); const dbStale = oldDb === null || (existsSync(managed.senders) && statSync(managed.senders).mtimeMs > oldDbStat!.mtimeMs); @@ -438,8 +475,8 @@ export async function executeSmtpAction(argv: string[], options: SmtpActionOptio const paths = { ...DEFAULT_SMTP_PATHS, ...options.paths }; const run = options.run ?? runCommand; const verb = argv[0] as SmtpVerb | undefined; - if (argv.length !== 1 || !["list", "save-relay", "save-default", "save-site", "clear-site", "save-domain-relay", "clear-domain-relay", "test", "reconcile", "deactivate"].includes(verb ?? "")) { - failAction("usage: clp-addons action smtp {list|save-relay|save-default|save-site|clear-site|save-domain-relay|clear-domain-relay|test|reconcile|deactivate}"); + if (argv.length !== 1 || !["list", "save-setup", "save-relay", "save-default", "save-site", "clear-site", "save-domain-relay", "clear-domain-relay", "test", "reconcile", "deactivate"].includes(verb ?? "")) { + failAction("usage: clp-addons action smtp {list|save-setup|save-relay|save-default|save-site|clear-site|save-domain-relay|clear-domain-relay|test|reconcile|deactivate}"); } return withFileLock(paths.lockFile, 30, "SMTP configuration is busy", async () => { const policy = readPolicy(paths); @@ -450,7 +487,7 @@ export async function executeSmtpAction(argv: string[], options: SmtpActionOptio rmSync(paths.submissionFile, { force: true }); restorePostfix(paths, run); const managed = managedPaths(paths); - for (const path of [managed.credentials, managed.routes, managed.senders, `${managed.senders}.db`]) { + for (const path of [managed.credentials, managed.routes, managed.senders, managed.tls, `${managed.senders}.db`]) { trustedRead(path, paths.rootUid); rmSync(path, { force: true }); } @@ -463,6 +500,7 @@ export async function executeSmtpAction(argv: string[], options: SmtpActionOptio const body = await inputBody(options); if (verb === "test") return sendTest(paths, policy, sites, body); const next = replacePolicy(policy, verb!, body, sites); + const firstSetupFiles = !policy.relay && next.relay ? managedFileSnapshot(paths) : null; try { if (next.relay) applyConfiguration(paths, next, sites, run); writePolicy(paths, next); @@ -477,10 +515,10 @@ export async function executeSmtpAction(argv: string[], options: SmtpActionOptio updatePostfix(paths, policy, sites, run); writeSubmissionPolicy(paths, policy, sites); } - else { + else if (firstSetupFiles) { updatePools(paths, sites, false, run); - rmSync(paths.submissionFile, { force: true }); - restorePostfix(paths, run); + try { restorePostfix(paths, run); } + finally { restoreManagedFiles(firstSetupFiles); } } } catch (rollbackError) { failAction(`SMTP update failed (${reason(error)}); rollback also failed: ${reason(rollbackError)}`); diff --git a/addons/smtp/app/index.ts b/addons/smtp/app/index.ts index 6dc66b5f..0a028cff 100644 --- a/addons/smtp/app/index.ts +++ b/addons/smtp/app/index.ts @@ -20,7 +20,8 @@ export async function handle(req: Request, path: string, notice?: { current: str if (denied) return denied; let body: Record; try { body = await readJsonObject(req); } catch (error) { return bodyErrorResponse(error); } - const result = path === "/api/relay" ? await smtpService.saveRelay(body) + const result = path === "/api/setup" ? await smtpService.saveSetup(body) + : path === "/api/relay" ? await smtpService.saveRelay(body) : path === "/api/default" ? await smtpService.saveDefault(body) : path === "/api/site" ? await smtpService.saveSite(body) : path === "/api/site/clear" ? await smtpService.clearSite(body) diff --git a/addons/smtp/app/service.ts b/addons/smtp/app/service.ts index b169dddb..ff4c1797 100644 --- a/addons/smtp/app/service.ts +++ b/addons/smtp/app/service.ts @@ -6,6 +6,7 @@ const call = (verb: string, body?: unknown): Promise> => export const smtpService = { state: () => call("list"), + saveSetup: (body: unknown) => call("save-setup", body), saveRelay: (body: unknown) => call("save-relay", body), saveDefault: (body: unknown) => call("save-default", body), saveSite: (body: unknown) => call("save-site", body), diff --git a/addons/smtp/app/views.client.js b/addons/smtp/app/views.client.js index cbf4da1b..99669c7a 100644 --- a/addons/smtp/app/views.client.js +++ b/addons/smtp/app/views.client.js @@ -20,18 +20,18 @@ async function smtpPost(path, body) { } } -function smtpSaveRelay(event) { - event.preventDefault(); - const fields = smtpFields(event.currentTarget); - smtpPost('/api/relay', { relay: { - host: fields.host, port: Number(fields.port), username: fields.username, password: fields.password, - } }); +function smtpRelayPayload(fields) { + return { host: fields.host, port: Number(fields.port), username: fields.username, password: fields.password }; } -function smtpSaveDefault(event) { +function smtpSaveSetup(event) { event.preventDefault(); const fields = smtpFields(event.currentTarget); - smtpPost('/api/default', { rule: { mode: fields.mode, sender: fields.sender, domains: [], addresses: [] } }); + smtpPost('/api/setup', { + relay: smtpRelayPayload(fields), + rule: { mode: fields.mode, sender: fields.sender, + domains: smtpState.defaultRule.domains, addresses: smtpState.defaultRule.addresses }, + }); } async function smtpSendTest(event) { @@ -64,11 +64,23 @@ function smtpEditSite(domain) { form.elements.namedItem('sender').value = site.rule.sender; form.elements.namedItem('domains').value = site.rule.domains.join('\n'); form.elements.namedItem('addresses').value = site.rule.addresses.join('\n'); + smtpSyncSiteMode(); document.getElementById('smtp-site-heading').textContent = 'Sender policy for ' + domain; document.getElementById('smtp-clear-site').hidden = !site.overridden; document.getElementById('smtp-site-dialog').showModal(); } +function smtpSyncSiteMode() { + const form = document.getElementById('smtp-site-form'); + const allow = form.elements.namedItem('mode').value === 'allow'; + document.getElementById('smtp-site-allow-fields').hidden = !allow; + for (const name of ['domains', 'addresses']) { + const field = form.elements.namedItem(name); + if (!allow) field.value = ''; + field.disabled = !allow; + } +} + function smtpSaveSite(event) { event.preventDefault(); const fields = smtpFields(event.currentTarget); @@ -93,15 +105,14 @@ function smtpEditDomain(domain) { form.elements.namedItem('username').value = old ? old.username : ''; form.elements.namedItem('password').value = ''; form.elements.namedItem('password').required = !old; + form.elements.namedItem('password').placeholder = old ? 'Leave blank to keep saved password' : 'New SMTP password'; document.getElementById('smtp-domain-dialog').showModal(); } function smtpSaveDomain(event) { event.preventDefault(); const fields = smtpFields(event.currentTarget); - smtpPost('/api/domain-relay', { domain: fields.domain, relay: { - host: fields.host, port: Number(fields.port), username: fields.username, password: fields.password, - } }); + smtpPost('/api/domain-relay', { domain: fields.domain, relay: smtpRelayPayload(fields) }); } async function smtpClearDomain(domain) { diff --git a/addons/smtp/app/views.css b/addons/smtp/app/views.css index 047df9bf..6f59b5c5 100644 --- a/addons/smtp/app/views.css +++ b/addons/smtp/app/views.css @@ -1,6 +1,7 @@ .smtp-status { display:flex; align-items:center; justify-content:space-between; gap:16px; } .smtp-status p { margin:0; } .smtp-form h2 { margin-bottom:6px; } +.smtp-form h3 { margin:18px 0 4px; font-size:16px; } .smtp-form > .hint { margin-top:0; } .smtp-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; } .smtp-form label,.smtp-dialog label { display:flex; flex-direction:column; gap:6px; font-weight:600; margin:12px 0; } @@ -8,6 +9,7 @@ .smtp-form .actions { margin-top:12px; } .smtp-site-table .hint { display:block; } .smtp-site-table code { overflow-wrap:anywhere; } +.smtp-site-table .wide-cell { overflow-wrap:anywhere; } .smtp-domain-table .actions { white-space:nowrap; } .smtp-dialog { width:min(650px,calc(100% - 24px)); max-height:calc(100dvh - 24px); overflow:auto; padding:24px; border:1px solid var(--border); border-radius:12px; background:var(--surface); color:var(--text); } .smtp-dialog::backdrop { background:rgb(10 20 30 / 55%); } diff --git a/addons/smtp/app/views.ts b/addons/smtp/app/views.ts index 8981fb3f..ff980b8d 100644 --- a/addons/smtp/app/views.ts +++ b/addons/smtp/app/views.ts @@ -19,58 +19,55 @@ export function dashboardView(state: SmtpState): string { const data = JSON.stringify(state).replaceAll("<", "\\u003c"); const relay = state.relay; const sites = state.sites.map((site) => ` - ${esc(site.domain)}${esc(site.user)} - ${site.rule.mode === "force" ? "Force" : "Allow listed"} - ${esc(site.senderPreview)}${site.overridden ? ' Override' : ""} - + ${esc(site.domain)}${esc(site.user)} + ${site.rule.mode === "force" ? "Force" : "Allow listed"}${site.overridden ? ' Override' : ""} + ${esc(site.senderPreview)}${site.rule.mode === "allow" ? `Also allowed: ${[site.domain, ...site.rule.domains].map((domain) => `@${esc(domain)}`).concat(site.rule.addresses.map(esc)).join(", ")}` : ""} + `).join(""); const overrides = Object.entries(state.relayOverrides).map(([domain, item]) => ` - ${esc(domain)}${esc(item.host)}:${item.port}${esc(item.username)} - + ${esc(domain)}${esc(item.host)}:${item.port}${esc(item.username)} + `).join(""); return `

SMTP relay

Send WordPress and other PHP mail through Postfix, with a sender policy for each site.

-

Relay status

${relay ? `Configured for ${esc(relay.host)}:${relay.port}.` : "Add a global relay before PHP mail is routed through this addon."}

${relay ? "Configured" : "Setup needed"}
-
-

Global SMTP relay

The fallback credential for sending domains without a relay override. Postfix queues messages and retries temporary failures.

+ +

Relay and default sender

Set the fallback SMTP account and sender rule for every PHP site in one save.

${relay ? "Configured" : "Setup needed"}
+

Global SMTP relay

Used for sending domains without a relay override. Postfix queues messages and retries temporary failures.

-
-
-
-

Default sender policy

{domain} is each CloudPanel site's domain. Force replaces the requested From address; allow listed preserves addresses on that site's approved domains.

+

Default sender policy

{domain} is each CloudPanel site's domain. Force replaces the requested From address; allow listed preserves addresses on that site's approved domains.

-
+
-

Send a test

A successful result means Postfix accepted the message into its queue. Check the recipient inbox for delivery.

-
+

Send a test

Submits directly to Postfix as root using the selected site's configured sender. It does not test PHP mail or that site's sender permissions. A successful result means Postfix queued the message; check the inbox for delivery.

+

PHP sites

Edit a site to add sending domains or use a different address.

- ${sites ? `${sites}
SiteModeSender
` : '
No PHP sites found.
'} + ${sites ? `${sites}
SiteModeFrom policyActions
` : '
No PHP sites found.
'}

Sending domain relays

Use a different SMTP account for a domain whose provider does not allow the global credential to send as it.

- ${overrides ? `${overrides}
Sending domainSMTP hostUsername
` : '
All sending domains use the global relay.
'} + ${overrides ? `${overrides}
Sending domainSMTP hostUsernameActions
` : '
All sending domains use the global relay.
'}

Site sender

- + - +

Sending domain relay

- +
`; } diff --git a/addons/smtp/config.ts b/addons/smtp/config.ts index 6245df02..5696386a 100644 --- a/addons/smtp/config.ts +++ b/addons/smtp/config.ts @@ -81,8 +81,8 @@ export function parseRule(value: unknown): SmtpSiteRule { return { mode: rule.mode, sender: rule.sender.toLowerCase(), - domains: [...new Set(rule.domains.map(smtpDomain))].sort(), - addresses: [...new Set(rule.addresses.map(smtpAddress))].sort(), + domains: rule.mode === "allow" ? [...new Set(rule.domains.map(smtpDomain))].sort() : [], + addresses: rule.mode === "allow" ? [...new Set(rule.addresses.map(smtpAddress))].sort() : [], }; } @@ -102,9 +102,18 @@ export function parseRelay(value: unknown): SmtpRelay { return { host, port: Number(relay.port), username: relay.username, password: relay.password }; } +export function senderGrants(site: Pick): { addresses: string[]; domains: string[] } { + const addresses = [senderFor(site.rule.sender, site.domain)]; + if (site.rule.mode === "force") return { addresses, domains: [] }; + return { + addresses: [...new Set([...addresses, ...site.rule.addresses])], + domains: [...new Set([site.domain, ...site.rule.domains])], + }; +} + export function permittedSender(site: SmtpSubmissionSite, address: string): boolean { const sender = smtpAddress(address); const domain = sender.slice(sender.lastIndexOf("@") + 1); - return sender === senderFor(site.rule.sender, site.domain) || - site.rule.addresses.includes(sender) || domain === site.domain || site.rule.domains.includes(domain); + const grants = senderGrants(site); + return grants.addresses.includes(sender) || grants.domains.includes(domain); } diff --git a/addons/smtp/submit.ts b/addons/smtp/submit.ts index adab793b..fb639aaf 100644 --- a/addons/smtp/submit.ts +++ b/addons/smtp/submit.ts @@ -3,7 +3,7 @@ import { CONFIG_DIR } from "../../cli/paths"; import { permittedSender, senderFor, smtpAddress, type SmtpSubmissionPolicy, type SmtpSubmissionSite } from "./config"; export const SUBMISSION_POLICY_PATH = `${CONFIG_DIR}/smtp-submission.json`; -const MAX_MESSAGE_BYTES = 25 * 1024 * 1024; +export const MAX_MESSAGE_BYTES = 25 * 1024 * 1024; const MAX_HEADER_BYTES = 64 * 1024; function trustedPolicy(path: string): SmtpSubmissionPolicy { @@ -25,6 +25,28 @@ function senderFromHeader(value: string): string { return smtpAddress(address); } +/** Stops reading stdin at the first chunk that crosses the submission limit. */ +export async function readBoundedSubmission(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_MESSAGE_BYTES) throw new Error("message exceeds the 25 MiB submission limit"); + chunks.push(value); + } + return Buffer.concat(chunks, length); + } catch (error) { + await reader.cancel().catch(() => {}); + throw error; + } finally { + reader.releaseLock(); + } +} + /** Rewrites one message before it crosses the trusted local sendmail boundary. */ export function prepareSubmission(message: Uint8Array, site: SmtpSubmissionSite): { message: Uint8Array; sender: string } { if (message.byteLength > MAX_MESSAGE_BYTES) throw new Error("message exceeds the 25 MiB submission limit"); @@ -91,7 +113,7 @@ export async function runSmtpSubmit( const policy = trustedPolicy(options.policyPath ?? SUBMISSION_POLICY_PATH); const matches = policy.sites.filter((site) => site.uid === uid); if (matches.length !== 1) throw new Error("the sending Unix account has no unique CloudPanel site"); - const input = options.input ?? new Uint8Array(await Bun.stdin.arrayBuffer()); + const input = options.input ?? await readBoundedSubmission(Bun.stdin.stream()); const prepared = prepareSubmission(input, matches[0]!); const result = Bun.spawnSync([options.sendmailPath ?? "/usr/sbin/sendmail", "-t", "-i", "-f", prepared.sender], { stdin: prepared.message, stdout: "pipe", stderr: "pipe", maxBuffer: 64 * 1024, diff --git a/cli/index.ts b/cli/index.ts index 1832c822..58ba6a97 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -52,7 +52,7 @@ function usage(): void { clp-addons uninstall --yes [--purge] clp-addons maintenance [on|off|status] clp-addons action cloudflare-ips [options] - clp-addons action smtp + clp-addons action smtp clp-addons action instatic [options] clp-addons action stager [options] clp-addons action maintenance --domain= diff --git a/cli/provision.ts b/cli/provision.ts index 47813b7f..b988bade 100644 --- a/cli/provision.ts +++ b/cli/provision.ts @@ -263,23 +263,41 @@ export function ensureRequiredUnits( commands: ProvisionCommandRunner = { run, tryRun }, ): void { for (const unit of spec.requiresUnits ?? []) { - if (commands.tryRun("systemctl", ["is-active", unit]).ok) continue; + const active = commands.tryRun("systemctl", ["is-active", unit]).ok; + if (active && unit !== "postfix") continue; if (unit === "postfix") { - const loadState = commands.tryRun("systemctl", ["show", "postfix", "--property=LoadState", "--value"]); - if (loadState.out.trim() !== "loaded") { + const loadState = active ? null : commands.tryRun("systemctl", ["show", "postfix", "--property=LoadState", "--value"]); + const fresh = loadState !== null && loadState.out.trim() !== "loaded"; + const modules = commands.tryRun("dpkg-query", ["-W", "-f=${Status}", "libsasl2-modules"]); + if (fresh || !modules.ok || modules.out.trim() !== "install ok installed") { log.step("installing Postfix and its SMTP authentication modules"); - const install = commands.tryRun("env", ["DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "postfix", "libsasl2-modules"]); - if (!install.ok) fatal(`Postfix installation failed: ${install.out || "apt-get failed"}`); + const packages = fresh ? ["postfix", "libsasl2-modules"] : ["libsasl2-modules"]; + const install = commands.tryRun("env", ["DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", ...packages]); + if (!install.ok) fatal(`Postfix SMTP authentication modules could not be installed: ${install.out || "apt-get failed"}`); + } + const installedModules = commands.tryRun("dpkg-query", ["-W", "-f=${Status}", "libsasl2-modules"]); + if (!installedModules.ok || installedModules.out.trim() !== "install ok installed") { + fatal("Postfix SMTP authentication requires the libsasl2-modules package"); + } + const clients = commands.tryRun("postconf", ["-A"]); + if (!clients.ok || !clients.out.split(/\s+/).includes("cyrus")) { + fatal("Postfix SMTP client lacks Cyrus SASL support; install a Postfix build with Cyrus SASL client support"); + } + const saslType = commands.tryRun("postconf", ["-h", "smtp_sasl_type"]); + if (!saslType.ok || saslType.out.trim() !== "cyrus") { + fatal("Postfix smtp_sasl_type must be cyrus for authenticated SMTP relay"); + } + if (fresh) { const outboundOnly = commands.tryRun("postconf", ["-e", "inet_interfaces=loopback-only"]); if (!outboundOnly.ok) fatal(`Postfix could not be limited to local submissions: ${outboundOnly.out || "postconf failed"}`); if (!commands.tryRun("systemctl", ["restart", "postfix"]).ok) { fatal("Postfix could not be restarted on the loopback interface"); } } - if (!commands.tryRun("systemctl", ["enable", "--now", "postfix"]).ok) { + if (!active && !commands.tryRun("systemctl", ["enable", "--now", "postfix"]).ok) { fatal("Postfix could not be started"); } - log.ok("Postfix installed and started"); + log.ok("Postfix and SMTP authentication ready"); continue; } if (unit !== "docker") { diff --git a/docs/decisions/smtp.md b/docs/decisions/smtp.md index 001fe4b1..88b33c68 100644 --- a/docs/decisions/smtp.md +++ b/docs/decisions/smtp.md @@ -21,13 +21,20 @@ authorized to send from that domain. ## Postfix and state -Enabling requires Postfix and SASL modules. A newly installed Postfix is bound +Enabling requires a Postfix SMTP client with Cyrus SASL support and the +`libsasl2-modules` package. Provisioning checks active and inactive existing +Postfix installs as well as new installs, and installs missing modules. It +requires `smtp_sasl_type=cyrus`. A newly installed Postfix is bound to loopback for inbound SMTP; an existing Postfix installation keeps its existing listener configuration. The addon sets `relayhost`, sender-dependent relay routing, sender-dependent SASL authentication, authenticated SMTP client -settings, mandatory verified TLS, and `local_login_sender_maps`. The latter -limits envelope senders from known site Unix accounts even if they invoke -Postfix's sendmail command directly. The selected credential still has to be +settings and mandatory verified TLS. `local_login_sender_maps` limits envelope +senders from known site Unix accounts even if they invoke Postfix's sendmail +command directly. A generated TLS policy map puts `secure match=nexthop` first +for the global and domain relay destinations, ahead of existing operator TLS +maps, which are restored on +disable. A nonempty TLS policy map also supersedes the legacy +`smtp_tls_per_site` parameter. The selected credential still has to be authorized by its upstream provider. The global and per-domain credentials are stored in @@ -56,7 +63,8 @@ domain against CloudPanel's PHP sites before changing a site rule, and validates every relay host, address, template, and allowlist entry. It rejects multiple sites sharing one Unix UID because their submissions could not be distinguished. The submission command accepts only sendmail flags needed by PHP mail, ignores -an untrusted `-f` request, and caps messages at 25 MiB with a bounded header. +an untrusted `-f` request, and stops reading stdin when a message exceeds +25 MiB. It also requires a bounded header. This sender policy is enforced on PHP `mail()` submissions through the managed pool. Direct Postfix submission from a site account is restricted at the diff --git a/docs/smtp-relay.md b/docs/smtp-relay.md index dea1b55d..fed6d433 100644 --- a/docs/smtp-relay.md +++ b/docs/smtp-relay.md @@ -8,21 +8,26 @@ there is no WordPress plugin to install. ## Set up -1. Enable **SMTP Relay** in Addons. The installer starts Postfix if needed. - An existing Postfix must be version 3.6 or newer. +1. Enable **SMTP Relay** in Addons. The installer starts Postfix if needed and + checks SMTP authentication modules even when Postfix is already active. + An existing Postfix must be version 3.6 or newer with Cyrus SASL client + support. 2. Open **SMTP Relay** and enter the SMTP hostname, STARTTLS submission port - (normally 587), username, and password. Save the global relay. -3. Choose a default sender. `noreply@{domain}` becomes + (normally 587), username, and password. Choose a default sender in the same + form, then save both settings together. `noreply@{domain}` becomes `noreply@example.com` for the `example.com` site. **Force one address** replaces an application's requested From address. **Allow site domains** preserves From addresses on that site's domain and on any domains or exact - addresses explicitly granted in the site's editor. -4. For a sending domain that needs its own SMTP account, add a **Sending domain + addresses explicitly granted in the site's editor. Switching a site to + Force clears its additional grants. +3. For a sending domain that needs its own SMTP account, add a **Sending domain relay**. All other senders use the global account. The relay's SMTP provider must allow the resulting From address; configuring the addon does not create mailboxes, authorize senders at the provider, or set DNS records. -5. Send a test to an inbox you control. The page confirms that Postfix queued - the message; check the inbox and, if needed, `/var/log/mail.log` and +4. Send a test to an inbox you control. The test submits directly to Postfix + as root with the selected site's configured sender; it does not exercise + the site's PHP path. The page confirms that Postfix queued the message; + check the inbox and, if needed, `/var/log/mail.log` and `postqueue -p` for the delivery result. For Mailcow, one mailbox credential can be used as the global relay when that diff --git a/lib/gateway-protocol.ts b/lib/gateway-protocol.ts index 4fbfc693..317435ee 100644 --- a/lib/gateway-protocol.ts +++ b/lib/gateway-protocol.ts @@ -107,7 +107,7 @@ export const CLOUDFLARE_IPS_ALLOWED_VERBS = new Set([ ]); export const SMTP_ALLOWED_VERBS = new Set([ - "list", "save-relay", "save-default", "save-site", "clear-site", + "list", "save-setup", "save-relay", "save-default", "save-site", "clear-site", "save-domain-relay", "clear-domain-relay", "test", ]); diff --git a/tests/test-provision.test.ts b/tests/test-provision.test.ts index b9723d20..7afde08c 100644 --- a/tests/test-provision.test.ts +++ b/tests/test-provision.test.ts @@ -217,12 +217,16 @@ function requiredUnitsProbe(options: { installOk?: boolean; enableOk?: boolean; restartOk?: boolean; + modulesInstalled?: boolean; + cyrusClient?: boolean; + saslType?: string; } = {}): { ok: boolean; error?: string; calls: Array<{ command: string; args: string[] }> } { const script = ` import { ensureRequiredUnits } from "./cli/provision.ts"; const options = ${JSON.stringify(options)}; const spec = { name: "instatic", configFile: "", stateDir: "", targets: [], requiresUnits: [options.unit ?? "docker"] }; const calls = []; + let modulesInstalled = options.modulesInstalled ?? true; const runner = { run(command, args) { calls.push({ command, args: [...args] }); return ""; }, tryRun(command, args) { @@ -235,6 +239,10 @@ function requiredUnitsProbe(options: { } if (command === "curl") return { ok: options.downloadOk ?? true, out: options.downloadOk === false ? "could not resolve host" : "" }; if (command === "sh") return { ok: options.installOk ?? true, out: options.installOk === false ? "get-docker.sh exited 1" : "" }; + if (command === "dpkg-query") return { ok: modulesInstalled, out: modulesInstalled ? "install ok installed" : "" }; + if (command === "postconf" && args[0] === "-A") return { ok: true, out: options.cyrusClient === false ? "" : "cyrus" }; + if (command === "postconf" && args[0] === "-h") return { ok: true, out: options.saslType ?? "cyrus" }; + if (command === "env") { modulesInstalled = options.installOk ?? true; return { ok: options.installOk ?? true, out: options.installOk === false ? "apt-get exited 1" : "" }; } if (command === "systemctl" && args[0] === "enable") return { ok: options.enableOk ?? true, out: "" }; if (command === "systemctl" && args[0] === "restart") return { ok: options.restartOk ?? true, out: "" }; return { ok: true, out: "" }; @@ -309,10 +317,52 @@ test("a fresh Postfix install restarts after binding to loopback", () => { const result = requiredUnitsProbe({ unit: "postfix", active: false, dockerUnitLoaded: false }); expect(result.ok).toBe(true); const commands = result.calls.map(({ command, args }) => `${command} ${args.join(" ")}`); + expect(commands).toContain("env DEBIAN_FRONTEND=noninteractive apt-get install -y postfix libsasl2-modules"); expect(commands.indexOf("postconf -e inet_interfaces=loopback-only")).toBeLessThan(commands.indexOf("systemctl restart postfix")); expect(commands.indexOf("systemctl restart postfix")).toBeLessThan(commands.indexOf("systemctl enable --now postfix")); }); +test.each([true, false])("existing Postfix (active %p) gets missing SASL modules without changing its listener", (active) => { + const result = requiredUnitsProbe({ unit: "postfix", active, dockerUnitLoaded: true, modulesInstalled: false }); + expect(result.ok).toBe(true); + expect(result.calls).toContainEqual({ command: "env", args: ["DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "libsasl2-modules"] }); + expect(result.calls).not.toContainEqual({ command: "postconf", args: ["-e", "inet_interfaces=loopback-only"] }); + expect(result.calls).not.toContainEqual({ command: "systemctl", args: ["restart", "postfix"] }); + expect(result.calls.some(({ command, args }) => command === "systemctl" && args[0] === "enable")).toBe(!active); +}); + +test("active Postfix with SASL ready needs no install", () => { + const result = requiredUnitsProbe({ unit: "postfix", active: true }); + expect(result.ok).toBe(true); + expect(result.calls.some(({ command }) => command === "env")).toBe(false); + expect(result.calls).toContainEqual({ command: "postconf", args: ["-A"] }); +}); + +test("inactive Postfix with SASL ready starts without reinstalling", () => { + const result = requiredUnitsProbe({ unit: "postfix", active: false, dockerUnitLoaded: true }); + expect(result.ok).toBe(true); + expect(result.calls.some(({ command }) => command === "env")).toBe(false); + expect(result.calls).toContainEqual({ command: "systemctl", args: ["enable", "--now", "postfix"] }); +}); + +test("Postfix without Cyrus client support fails before activating the addon", () => { + const result = requiredUnitsProbe({ unit: "postfix", active: true, cyrusClient: false }); + expect(result.ok).toBe(false); + expect(result.error).toContain("lacks Cyrus SASL support"); +}); + +test("Postfix configured for another SASL client fails clearly", () => { + const result = requiredUnitsProbe({ unit: "postfix", active: true, saslType: "dovecot" }); + expect(result.ok).toBe(false); + expect(result.error).toContain("smtp_sasl_type must be cyrus"); +}); + +test("missing SASL modules report an installation error", () => { + const result = requiredUnitsProbe({ unit: "postfix", active: true, modulesInstalled: false, installOk: false }); + expect(result.ok).toBe(false); + expect(result.error).toContain("authentication modules could not be installed"); +}); + test("a failed Postfix restart aborts provisioning", () => { const result = requiredUnitsProbe({ unit: "postfix", active: false, dockerUnitLoaded: false, restartOk: false }); expect(result.ok).toBe(false); diff --git a/tests/test-smtp.test.ts b/tests/test-smtp.test.ts index 4194529f..619fd16f 100644 --- a/tests/test-smtp.test.ts +++ b/tests/test-smtp.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { executeSmtpAction, postfixMaps, type SmtpActionOptions, type SmtpState } from "../addons/smtp/action"; import { emptySmtpPolicy, parseRule, type SmtpSubmissionSite } from "../addons/smtp/config"; -import { prepareSubmission } from "../addons/smtp/submit"; +import { MAX_MESSAGE_BYTES, prepareSubmission, readBoundedSubmission } from "../addons/smtp/submit"; import { dashboardView } from "../addons/smtp/app/views"; const dirs: string[] = []; @@ -45,6 +45,23 @@ test("folded From is checked and ignored Sender fields cannot change it", () => expect(Buffer.from(result.message).toString()).not.toContain("forged@cool.com"); }); +test("stdin accepts exactly 25 MiB and cancels at the first byte over the limit", async () => { + const atLimit = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(MAX_MESSAGE_BYTES)); controller.close(); } }); + expect((await readBoundedSubmission(atLimit)).byteLength).toBe(MAX_MESSAGE_BYTES); + let pulls = 0; + let cancelled = false; + const oversized = new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(new Uint8Array(pulls === 1 ? MAX_MESSAGE_BYTES : 1)); + }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }); + await expect(readBoundedSubmission(oversized)).rejects.toThrow("25 MiB"); + expect(pulls).toBe(2); + expect(cancelled).toBe(true); +}); + test("domain relay map selects an override before the shared credential", () => { const policy = emptySmtpPolicy(); policy.relay = { host: "mail.example.com", port: 587, username: "shared@example.com", password: "secret-one" }; @@ -53,6 +70,8 @@ test("domain relay map selects an override before the shared credential", () => expect(maps.credentials.indexOf("noreply@cool.com:secret-two")).toBeLessThan(maps.credentials.indexOf("shared@example.com:secret-one")); expect(maps.routes).toContain("[smtp.cool.com]:587"); expect(maps.credentials).not.toContain("* shared"); + expect(maps.tls).toContain("/^\\[mail\\.example\\.com\\]:587$/ secure match=nexthop"); + expect(maps.tls).toContain("/^\\[smtp\\.cool\\.com\\]:587$/ secure match=nexthop"); }); test("regexp credential maps keep dollar signs literal in SMTP passwords", () => { @@ -74,6 +93,7 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl const settings = new Map(); settings.set("relayhost", ""); settings.set("smtp_tls_CApath", "/etc/ssl/certs"); + settings.set("smtp_tls_policy_maps", "hash:/etc/postfix/operator-tls"); const commands: string[] = []; const run: NonNullable = (command, args) => { commands.push(`${command} ${args.join(" ")}`); @@ -102,16 +122,51 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl run, }; const body = { relay: { host: "mail.example.com", port: 587, username: "relay@example.com", password: "secret" } }; - await executeSmtpAction(["save-relay"], { ...options, input: JSON.stringify(body) }); + await expect(executeSmtpAction(["save-setup"], { ...options, input: JSON.stringify({ ...body, rule: { mode: "invalid" } }) })).rejects.toThrow("sender mode"); + expect((await executeSmtpAction(["list"], options) as SmtpState).configured).toBe(false); + const submissionPath = join(dir, "submission.json"); + writeFileSync(submissionPath, "prior submission file\n"); + chmodSync(submissionPath, 0o644); + writeFileSync(pool, readFileSync(pool, "utf8") + "php_admin_value[sendmail_path] = /other/sendmail\n"); + await expect(executeSmtpAction(["save-setup"], { ...options, input: JSON.stringify({ ...body, rule: site.rule }) })).rejects.toThrow("already configures sendmail_path"); + expect((await executeSmtpAction(["list"], options) as SmtpState).configured).toBe(false); + for (const path of ["clp-addons-sasl", "clp-addons-relays", "clp-addons-local-senders", "clp-addons-local-senders.db", "clp-addons-tls-policy"]) { + expect(existsSync(join(postfixDir, path))).toBe(false); + } + expect(readFileSync(submissionPath, "utf8")).toBe("prior submission file\n"); + expect(settings.get("smtp_tls_policy_maps")).toBe("hash:/etc/postfix/operator-tls"); + writeFileSync(pool, readFileSync(pool, "utf8").replace("php_admin_value[sendmail_path] = /other/sendmail\n", "")); + await executeSmtpAction(["save-setup"], { ...options, input: JSON.stringify({ ...body, rule: site.rule }) }); expect(readFileSync(pool, "utf8")).toContain("smtp-submit -t -i"); expect(readFileSync(join(dir, "submission.json"), "utf8")).toContain("noreply@{domain}"); expect(settings.get("local_login_sender_maps")).toContain("clp-addons-local-senders"); expect(readFileSync(join(postfixDir, "clp-addons-local-senders"), "utf8")).toContain("clp *\n"); + expect(settings.get("smtp_tls_security_level")).toBe("secure"); + expect(settings.get("smtp_tls_policy_maps")).toBe(`regexp:${join(postfixDir, "clp-addons-tls-policy")}, hash:/etc/postfix/operator-tls`); + settings.set("smtp_tls_per_site", "hash:/etc/postfix/old-tls"); + await executeSmtpAction(["save-default"], { ...options, input: JSON.stringify({ rule: site.rule }) }); + expect(settings.get("smtp_tls_policy_maps")?.startsWith(`regexp:${join(postfixDir, "clp-addons-tls-policy")}`)).toBe(true); + settings.delete("smtp_tls_per_site"); + const originalPath = join(dir, "original.json"); + const priorBackup = JSON.parse(readFileSync(originalPath, "utf8")); + delete priorBackup.values.smtp_tls_policy_maps; + writeFileSync(originalPath, JSON.stringify(priorBackup)); + settings.set("smtp_tls_policy_maps", "hash:/etc/postfix/operator-tls"); + await executeSmtpAction(["save-default"], { ...options, input: JSON.stringify({ rule: site.rule }) }); + expect(JSON.parse(readFileSync(originalPath, "utf8")).values.smtp_tls_policy_maps).toBe("hash:/etc/postfix/operator-tls"); + expect(settings.get("smtp_tls_policy_maps")).toBe(`regexp:${join(postfixDir, "clp-addons-tls-policy")}, hash:/etc/postfix/operator-tls`); + expect(readFileSync(join(postfixDir, "clp-addons-tls-policy"), "utf8")).toContain("secure match=nexthop"); + await executeSmtpAction(["save-domain-relay"], { ...options, input: JSON.stringify({ domain: "cool.com", relay: { + host: "smtp.cool.com", port: 587, username: "cool@cool.com", password: "other-secret", + } }) }); + expect(readFileSync(join(postfixDir, "clp-addons-tls-policy"), "utf8")).toContain("smtp\\.cool\\.com"); + expect(settings.get("smtp_tls_policy_maps")).toBe(`regexp:${join(postfixDir, "clp-addons-tls-policy")}, hash:/etc/postfix/operator-tls`); expect(commands.some((item) => item.includes("php-fpm8.2 -t"))).toBe(true); const publicState = await executeSmtpAction(["list"], options); const html = dashboardView(publicState as SmtpState); expect(JSON.stringify(publicState)).not.toContain("secret"); expect(html).not.toContain("secret"); + expect(html).toContain('data-label="Forced From"'); writeFileSync(pool, readFileSync(pool, "utf8") + "php_admin_value[sendmail_path] = /other/sendmail\n"); await expect(executeSmtpAction(["save-default"], { ...options, input: JSON.stringify({ rule: { mode: "allow", sender: "noreply@{domain}", domains: [], addresses: [] }, @@ -123,4 +178,23 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl expect(existsSync(join(dir, "submission.json"))).toBe(false); expect(settings.get("relayhost")).toBe(""); expect(settings.get("smtp_tls_CApath")).toBe("/etc/ssl/certs"); + expect(settings.get("smtp_tls_policy_maps")).toBe("hash:/etc/postfix/operator-tls"); + expect(existsSync(join(postfixDir, "clp-addons-tls-policy"))).toBe(false); +}); + +test("force mode drops dormant allow-list grants", () => { + expect(parseRule({ mode: "force", sender: "noreply@{domain}", domains: ["other.test"], addresses: ["a@other.test"] })).toEqual(site.rule); +}); + +test("site table distinguishes an allow-mode fallback from its full grants", () => { + const policy = emptySmtpPolicy(); + const rule = parseRule({ mode: "allow", sender: "noreply@{domain}", domains: ["news.example.com"], addresses: ["billing@partner.test"] }); + const html = dashboardView({ + configured: false, relay: null, relayOverrides: {}, defaultRule: policy.defaultRule, + sites: [{ domain: "example.com", user: "example", phpVersion: "8.2", rule, overridden: true, senderPreview: "noreply@example.com" }], + }); + expect(html).toContain('data-label="Fallback From"'); + expect(html).toContain("@news.example.com"); + expect(html).toContain("billing@partner.test"); + expect(html).toContain('data-label="Actions"'); }); From 9477544b005325f35aa6960fd2abf78c83b10310 Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:52:23 +0000 Subject: [PATCH 5/5] Reject conflicting Postfix routing and validate SMTP actions early --- addons/smtp/action.ts | 95 +++++++++++++++++++++++++++++------------ docs/decisions/smtp.md | 16 ++++--- docs/smtp-relay.md | 4 ++ tests/test-smtp.test.ts | 43 ++++++++++++++++++- 4 files changed, 125 insertions(+), 33 deletions(-) diff --git a/addons/smtp/action.ts b/addons/smtp/action.ts index 5fe28125..20c258f0 100644 --- a/addons/smtp/action.ts +++ b/addons/smtp/action.ts @@ -1,5 +1,5 @@ import { Database } from "bun:sqlite"; -import { chmodSync, existsSync, lstatSync, readFileSync, rmSync, statSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { ActionFailure, emitActionError, emitActionOk, failAction, runCommand, withFileLock, @@ -258,22 +258,32 @@ function managedPaths(paths: SmtpPaths): { credentials: string; routes: string; }; } -function managedFileSnapshot(paths: SmtpPaths): { path: string; content: Buffer | null; mode: number }[] { +interface ManagedFileSnapshot { + path: string; + content: Buffer | null; + mode: number; + owner: { uid: number; gid: number } | null; + mtimeMs: number | null; +} + +function managedFileSnapshot(paths: SmtpPaths, filePaths?: string[]): ManagedFileSnapshot[] { const managed = managedPaths(paths); - return [managed.credentials, managed.routes, managed.senders, managed.tls, `${managed.senders}.db`, paths.submissionFile].map((path) => { - if (!existsSync(path)) return { path, content: null, mode: 0o600 }; + const tracked = filePaths ?? [managed.credentials, managed.routes, managed.senders, managed.tls, `${managed.senders}.db`, paths.submissionFile]; + return tracked.map((path) => { + if (!existsSync(path)) return { path, content: null, mode: 0o600, owner: null, mtimeMs: null }; const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== paths.rootUid || (stat.mode & 0o022) !== 0) { failAction(`refusing untrusted SMTP file: ${path}`); } - return { path, content: readFileSync(path), mode: stat.mode & 0o777 }; + return { path, content: readFileSync(path), mode: stat.mode & 0o777, + owner: { uid: stat.uid, gid: stat.gid }, mtimeMs: stat.mtimeMs }; }); } -function restoreManagedFiles(snapshot: ReturnType): void { - for (const { path, content, mode } of snapshot) { +function restoreManagedFiles(snapshot: ManagedFileSnapshot[]): void { + for (const { path, content, mode, owner } of snapshot) { if (content === null) rmSync(path, { force: true }); - else writeFileAtomic(path, content, { mode, createParent: true }); + else writeFileAtomic(path, content, { mode, owner: owner!, createParent: true }); } } @@ -283,16 +293,36 @@ function capturePostfix(paths: SmtpPaths, run: (command: string, args: string[]) writeFileAtomic(paths.originalFile, JSON.stringify({ version: 1, values }, null, 2) + "\n", { mode: 0o600, createParent: true }); } +function explicitPostfixValue(explicit: string, key: string): string | null { + const match = explicit.match(new RegExp(`(?:^|\\n)${key}[ \\t]*=[ \\t]*([^\\n]*)`)); + return match ? match[1]!.trim() : null; +} + function currentPostfixSettings(run: (command: string, args: string[]) => CommandResult): Record { const explicit = runChecked(run, "postconf", ["-n"]); const values: Record = {}; for (const key of POSTFIX_KEYS) { - const match = explicit.match(new RegExp(`(?:^|\\n)${key}[ \\t]*=[ \\t]*([^\\n]*)`)); - values[key] = match ? match[1]!.trim() : null; + values[key] = explicitPostfixValue(explicit, key); } return values; } +function requireUnambiguousRelayRouting(run: (command: string, args: string[]) => CommandResult): void { + const explicit = runChecked(run, "postconf", ["-n"]); + const allowed: Record = { + transport_maps: [], + sender_dependent_default_transport_maps: [], + default_transport: ["smtp", "smtp:"], + relay_transport: ["relay", "relay:"], + }; + for (const [key, defaults] of Object.entries(allowed)) { + const value = explicitPostfixValue(explicit, key); + if (value !== null && value !== "" && !defaults.includes(value)) { + failAction(`Postfix ${key} can override the configured SMTP relay; resolve this routing setting before enabling SMTP Relay`); + } + } +} + function applyPostfixSettings(values: Record, run: (command: string, args: string[]) => CommandResult): void { for (const key of POSTFIX_KEYS) { // Backups written before this key was managed must leave it untouched. @@ -324,13 +354,12 @@ function updatePostfix(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], r [managed.senders]: localSenderMap(policy, sites), [managed.tls]: maps.tls, }; - const before = Object.fromEntries(Object.keys(mapContents).map((path) => [path, trustedRead(path, paths.rootUid)])); const oldDbPath = `${managed.senders}.db`; - const oldDbStat = existsSync(oldDbPath) ? lstatSync(oldDbPath) : null; - if (oldDbStat && (!oldDbStat.isFile() || oldDbStat.isSymbolicLink() || oldDbStat.uid !== paths.rootUid || (oldDbStat.mode & 0o022) !== 0)) { - failAction(`refusing untrusted SMTP file: ${oldDbPath}`); - } - const oldDb = oldDbStat ? readFileSync(oldDbPath) : null; + const fileSnapshot = managedFileSnapshot(paths, [...Object.keys(mapContents), oldDbPath]); + const files = new Map(fileSnapshot.map((item) => [item.path, item])); + const before = Object.fromEntries(Object.keys(mapContents).map((path) => [path, files.get(path)!.content?.toString("utf8") ?? null])); + const oldDb = files.get(oldDbPath)!; + requireUnambiguousRelayRouting(run); const current = currentPostfixSettings(run); const originalRaw = trustedRead(paths.originalFile, paths.rootUid); const originalValues = originalRaw ? (JSON.parse(originalRaw) as OriginalPostfix).values : null; @@ -355,7 +384,7 @@ function updatePostfix(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], r }; const mapsChanged = Object.entries(mapContents).some(([path, content]) => before[path] !== content); const configChanged = Object.entries(settings).some(([key, value]) => current[key] !== value); - const dbStale = oldDb === null || (existsSync(managed.senders) && statSync(managed.senders).mtimeMs > oldDbStat!.mtimeMs); + const dbStale = oldDb.content === null || (files.get(managed.senders)!.mtimeMs ?? 0) > oldDb.mtimeMs!; if (!mapsChanged && !configChanged && !dbStale) return; // Older Postfix cannot enforce local Unix login sender maps. Refuse the // configuration instead of silently offering a sender policy it cannot keep. @@ -377,12 +406,7 @@ function updatePostfix(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], r runChecked(run, "postfix", ["check"]); runChecked(run, "systemctl", ["reload", "postfix"]); } catch (error) { - for (const [path, content] of Object.entries(before)) { - if (content === null) rmSync(path, { force: true }); - else writeFileAtomic(path, content, { mode: path === managed.senders ? 0o644 : 0o600 }); - } - if (oldDb === null) rmSync(oldDbPath, { force: true }); - else writeFileAtomic(oldDbPath, oldDb, { mode: oldDbStat!.mode & 0o777 }); + restoreManagedFiles(fileSnapshot); applyPostfixSettings(current, run); runChecked(run, "postfix", ["check"]); runChecked(run, "systemctl", ["reload", "postfix"]); @@ -455,13 +479,18 @@ async function inputBody(options: SmtpActionOptions): Promise; } -function sendTest(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], body: Record): { queued: true; sender: string; recipient: string } { +function testSubmission(policy: SmtpPolicy, sites: SiteRow[], body: Record): { domain: string; sender: string; recipient: string } { if (!policy.relay) failAction("configure the global SMTP relay first"); const domain = smtpDomain(body.domain); const site = sites.find((item) => item.domain === domain); if (!site) failAction(`CloudPanel has no PHP site ${domain}`); const recipient = smtpAddress(body.recipient); const sender = senderFor(effectiveRule(policy, domain).sender, domain); + return { domain, sender, recipient }; +} + +function sendTest(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], body: Record): { queued: true; sender: string; recipient: string } { + const { domain, sender, recipient } = testSubmission(policy, sites, body); const message = `To: ${recipient}\nFrom: ${sender}\nSubject: CloudPanel SMTP relay test for ${domain}\n\nThis message was submitted through the CloudPanel Addons Postfix relay.\n`; const result = Bun.spawnSync([paths.sendmail, "-t", "-i", "-f", sender], { stdin: Buffer.from(message), stdout: "pipe", stderr: "pipe", maxBuffer: 64 * 1024, @@ -472,12 +501,22 @@ function sendTest(paths: SmtpPaths, policy: SmtpPolicy, sites: SiteRow[], body: export async function executeSmtpAction(argv: string[], options: SmtpActionOptions = {}): Promise { if ((options.processUid ?? process.getuid?.()) !== 0) failAction("SMTP actions must run as root"); - const paths = { ...DEFAULT_SMTP_PATHS, ...options.paths }; - const run = options.run ?? runCommand; const verb = argv[0] as SmtpVerb | undefined; if (argv.length !== 1 || !["list", "save-setup", "save-relay", "save-default", "save-site", "clear-site", "save-domain-relay", "clear-domain-relay", "test", "reconcile", "deactivate"].includes(verb ?? "")) { failAction("usage: clp-addons action smtp {list|save-setup|save-relay|save-default|save-site|clear-site|save-domain-relay|clear-domain-relay|test|reconcile|deactivate}"); } + const hasBody = verb !== "list" && verb !== "deactivate" && verb !== "reconcile"; + const body = hasBody ? await inputBody(options) : {}; + const paths = { ...DEFAULT_SMTP_PATHS, ...options.paths }; + const run = options.run ?? runCommand; + if (hasBody) { + // Validate request data before locking. State-dependent checks run again + // under the lock so a concurrent update cannot invalidate this preflight. + const policy = readPolicy(paths); + const sites = validatedSites(panelSites(paths, options.sites)); + if (verb === "test") testSubmission(policy, sites, body); + else replacePolicy(policy, verb!, body, sites); + } return withFileLock(paths.lockFile, 30, "SMTP configuration is busy", async () => { const policy = readPolicy(paths); const sites = validatedSites(panelSites(paths, options.sites)); @@ -497,9 +536,11 @@ export async function executeSmtpAction(argv: string[], options: SmtpActionOptio const repaired = applyConfiguration(paths, policy, sites, run); return { repaired }; } - const body = await inputBody(options); if (verb === "test") return sendTest(paths, policy, sites, body); const next = replacePolicy(policy, verb!, body, sites); + // A routing conflict is a precondition failure, before any state changes + // that would need the rollback below. updatePostfix checks again as well. + if (next.relay) requireUnambiguousRelayRouting(run); const firstSetupFiles = !policy.relay && next.relay ? managedFileSnapshot(paths) : null; try { if (next.relay) applyConfiguration(paths, next, sites, run); diff --git a/docs/decisions/smtp.md b/docs/decisions/smtp.md index 88b33c68..ff0c35de 100644 --- a/docs/decisions/smtp.md +++ b/docs/decisions/smtp.md @@ -30,10 +30,13 @@ existing listener configuration. The addon sets `relayhost`, sender-dependent relay routing, sender-dependent SASL authentication, authenticated SMTP client settings and mandatory verified TLS. `local_login_sender_maps` limits envelope senders from known site Unix accounts even if they invoke Postfix's sendmail -command directly. A generated TLS policy map puts `secure match=nexthop` first -for the global and domain relay destinations, ahead of existing operator TLS -maps, which are restored on -disable. A nonempty TLS policy map also supersedes the legacy +command directly. Before applying or reconciling relay settings, it rejects +`transport_maps`, `sender_dependent_default_transport_maps`, and custom +`default_transport` or `relay_transport` values that could take precedence over +the configured global or sender-dependent relay. A generated TLS policy map +puts `secure match=nexthop` first for the global and domain relay destinations, +ahead of existing operator TLS maps, which are restored on disable. A nonempty +TLS policy map also supersedes the legacy `smtp_tls_per_site` parameter. The selected credential still has to be authorized by its upstream provider. @@ -50,7 +53,8 @@ has-password flag, never the saved password. Before changing Postfix, the action captures its explicit values for every key it owns. Updates replace managed files atomically, run `postfix check`, then -reload Postfix. A failed update restores the prior managed files and settings. +reload Postfix. A failed update restores the prior managed files, their +permissions and ownership, and Postfix settings. Before changing PHP-FPM pools, it rejects conflicting sendmail settings, then tests each affected PHP-FPM version and reloads it. The pool directive and Postfix settings are withdrawn when the addon is disabled or uninstalled. The @@ -62,6 +66,8 @@ The root gateway accepts only named SMTP verbs. The action validates a sending domain against CloudPanel's PHP sites before changing a site rule, and validates every relay host, address, template, and allowlist entry. It rejects multiple sites sharing one Unix UID because their submissions could not be distinguished. +Request data is checked before taking the SMTP lock and checked again against +current site and policy state under the lock. The submission command accepts only sendmail flags needed by PHP mail, ignores an untrusted `-f` request, and stops reading stdin when a message exceeds 25 MiB. It also requires a bounded header. diff --git a/docs/smtp-relay.md b/docs/smtp-relay.md index fed6d433..448c3cff 100644 --- a/docs/smtp-relay.md +++ b/docs/smtp-relay.md @@ -20,6 +20,10 @@ there is no WordPress plugin to install. preserves From addresses on that site's domain and on any domains or exact addresses explicitly granted in the site's editor. Switching a site to Force clears its additional grants. + If saving reports a Postfix routing conflict, resolve the named + `transport_maps`, `sender_dependent_default_transport_maps`, + `default_transport`, or `relay_transport` setting first. Those settings can + route mail around the selected SMTP relay; the addon leaves them untouched. 3. For a sending domain that needs its own SMTP account, add a **Sending domain relay**. All other senders use the global account. The relay's SMTP provider must allow the resulting From address; configuring the addon does not create diff --git a/tests/test-smtp.test.ts b/tests/test-smtp.test.ts index 619fd16f..d2dfa5fd 100644 --- a/tests/test-smtp.test.ts +++ b/tests/test-smtp.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { executeSmtpAction, postfixMaps, type SmtpActionOptions, type SmtpState } from "../addons/smtp/action"; @@ -123,6 +123,7 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl }; const body = { relay: { host: "mail.example.com", port: 587, username: "relay@example.com", password: "secret" } }; await expect(executeSmtpAction(["save-setup"], { ...options, input: JSON.stringify({ ...body, rule: { mode: "invalid" } }) })).rejects.toThrow("sender mode"); + expect(existsSync(join(dir, "smtp.lock"))).toBe(false); expect((await executeSmtpAction(["list"], options) as SmtpState).configured).toBe(false); const submissionPath = join(dir, "submission.json"); writeFileSync(submissionPath, "prior submission file\n"); @@ -136,6 +137,11 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl expect(readFileSync(submissionPath, "utf8")).toBe("prior submission file\n"); expect(settings.get("smtp_tls_policy_maps")).toBe("hash:/etc/postfix/operator-tls"); writeFileSync(pool, readFileSync(pool, "utf8").replace("php_admin_value[sendmail_path] = /other/sendmail\n", "")); + settings.set("transport_maps", "hash:/etc/postfix/transport"); + await expect(executeSmtpAction(["save-setup"], { ...options, input: JSON.stringify({ ...body, rule: site.rule }) })).rejects.toThrow("transport_maps"); + expect(existsSync(join(dir, "original.json"))).toBe(false); + expect(existsSync(join(postfixDir, "clp-addons-sasl"))).toBe(false); + settings.delete("transport_maps"); await executeSmtpAction(["save-setup"], { ...options, input: JSON.stringify({ ...body, rule: site.rule }) }); expect(readFileSync(pool, "utf8")).toContain("smtp-submit -t -i"); expect(readFileSync(join(dir, "submission.json"), "utf8")).toContain("noreply@{domain}"); @@ -143,6 +149,24 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl expect(readFileSync(join(postfixDir, "clp-addons-local-senders"), "utf8")).toContain("clp *\n"); expect(settings.get("smtp_tls_security_level")).toBe("secure"); expect(settings.get("smtp_tls_policy_maps")).toBe(`regexp:${join(postfixDir, "clp-addons-tls-policy")}, hash:/etc/postfix/operator-tls`); + for (const [key, value] of [ + ["transport_maps", "hash:/etc/postfix/transport"], + ["sender_dependent_default_transport_maps", "hash:/etc/postfix/sender-transport"], + ["default_transport", "smtp:[other.example.com]:587"], + ["relay_transport", "relay:[other.example.com]:587"], + ] as const) { + settings.set(key, value); + await expect(executeSmtpAction(["reconcile"], options)).rejects.toThrow(key); + settings.delete(key); + } + settings.set("transport_maps", "hash:/etc/postfix/transport"); + await expect(executeSmtpAction(["save-default"], { ...options, input: JSON.stringify({ rule: site.rule }) })).rejects.toThrow("Postfix transport_maps can override"); + settings.delete("transport_maps"); + settings.set("default_transport", "smtp:"); + settings.set("relay_transport", "relay:"); + await executeSmtpAction(["reconcile"], options); + settings.delete("default_transport"); + settings.delete("relay_transport"); settings.set("smtp_tls_per_site", "hash:/etc/postfix/old-tls"); await executeSmtpAction(["save-default"], { ...options, input: JSON.stringify({ rule: site.rule }) }); expect(settings.get("smtp_tls_policy_maps")?.startsWith(`regexp:${join(postfixDir, "clp-addons-tls-policy")}`)).toBe(true); @@ -156,6 +180,23 @@ test("configured relay applies to Postfix and site pool, then deactivates cleanl expect(JSON.parse(readFileSync(originalPath, "utf8")).values.smtp_tls_policy_maps).toBe("hash:/etc/postfix/operator-tls"); expect(settings.get("smtp_tls_policy_maps")).toBe(`regexp:${join(postfixDir, "clp-addons-tls-policy")}, hash:/etc/postfix/operator-tls`); expect(readFileSync(join(postfixDir, "clp-addons-tls-policy"), "utf8")).toContain("secure match=nexthop"); + const credentialsPath = join(postfixDir, "clp-addons-sasl"); + const credentialsBefore = readFileSync(credentialsPath); + chmodSync(credentialsPath, 0o640); + let failPostfixCheck = true; + const failingRun: NonNullable = (command, args) => { + if (command === "postfix" && args[0] === "check" && failPostfixCheck) { + failPostfixCheck = false; + return { ok: false, stdout: "", stderr: "test failure" }; + } + return run(command, args); + }; + const domainRelayInput = JSON.stringify({ domain: "cool.com", relay: { + host: "smtp.cool.com", port: 587, username: "cool@cool.com", password: "other-secret", + } }); + await expect(executeSmtpAction(["save-domain-relay"], { ...options, run: failingRun, input: domainRelayInput })).rejects.toThrow("test failure"); + expect(readFileSync(credentialsPath)).toEqual(credentialsBefore); + expect(statSync(credentialsPath).mode & 0o777).toBe(0o640); await executeSmtpAction(["save-domain-relay"], { ...options, input: JSON.stringify({ domain: "cool.com", relay: { host: "smtp.cool.com", port: 587, username: "cool@cool.com", password: "other-secret", } }) });