diff --git a/README.md b/README.md index 3048eb8..81ed969 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 0000000..20c258f --- /dev/null +++ b/addons/smtp/action.ts @@ -0,0 +1,588 @@ +import { Database } from "bun:sqlite"; +import { chmodSync, existsSync, lstatSync, readFileSync, rmSync } 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, 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-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_tls_policy_maps", + "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 (["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); + } + 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-setup") { + next.relay = relayFromRequest(body.relay, policy.relay); + next.defaultRule = parseRule(body.rule); + } else 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; tls: 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)}`); + const destinations = new Set([relayDestination(policy.relay), ...Object.values(policy.relayOverrides).map(relayDestination)]); + const tls = [...destinations].sort().map((destination) => + `/^${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 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; 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"), + }; +} + +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); + 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, + owner: { uid: stat.uid, gid: stat.gid }, mtimeMs: stat.mtimeMs }; + }); +} + +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, owner: owner!, 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); + 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) { + 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. + 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}`]); + } +} + +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), + [managed.tls]: maps.tls, + }; + const oldDbPath = `${managed.senders}.db`; + 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; + 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", + 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_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 mapsChanged = Object.entries(mapContents).some(([path, content]) => before[path] !== content); + const configChanged = Object.entries(settings).some(([key, value]) => current[key] !== value); + 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. + 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) { + restoreManagedFiles(fileSnapshot); + 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 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, + }); + 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 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)); + 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.tls, `${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 }; + } + 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); + 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 if (firstSetupFiles) { + updatePools(paths, sites, false, run); + try { restorePostfix(paths, run); } + finally { restoreManagedFiles(firstSetupFiles); } + } + } 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 0000000..d129d87 --- /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 0000000..0a028cf --- /dev/null +++ b/addons/smtp/app/index.ts @@ -0,0 +1,35 @@ +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/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) + : 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 0000000..ff4c179 --- /dev/null +++ b/addons/smtp/app/service.ts @@ -0,0 +1,18 @@ +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"), + 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), + 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 0000000..99669c7 --- /dev/null +++ b/addons/smtp/app/views.client.js @@ -0,0 +1,121 @@ +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 smtpRelayPayload(fields) { + return { host: fields.host, port: Number(fields.port), username: fields.username, password: fields.password }; +} + +function smtpSaveSetup(event) { + event.preventDefault(); + const fields = smtpFields(event.currentTarget); + 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) { + 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'); + 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); + 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; + 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: smtpRelayPayload(fields) }); +} + +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 0000000..6f59b5c --- /dev/null +++ b/addons/smtp/app/views.css @@ -0,0 +1,17 @@ +.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; } +.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-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%); } +.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 0000000..ff980b8 --- /dev/null +++ b/addons/smtp/app/views.ts @@ -0,0 +1,73 @@ +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"}${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)} + + `).join(""); + return ` +

SMTP relay

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

+
+

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.

+
+
+
+
+
+

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}
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 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 new file mode 100644 index 0000000..5696386 --- /dev/null +++ b/addons/smtp/config.ts @@ -0,0 +1,119 @@ +/** 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: rule.mode === "allow" ? [...new Set(rule.domains.map(smtpDomain))].sort() : [], + addresses: rule.mode === "allow" ? [...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 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); + 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 new file mode 100644 index 0000000..fb639aa --- /dev/null +++ b/addons/smtp/submit.ts @@ -0,0 +1,127 @@ +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`; +export 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); +} + +/** 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"); + 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 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}`); + } + 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 ?? 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, + }); + 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 f4667b8..854c6e8 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 1879214..8264753 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 a6e8eb5..58ba6a9 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 7a6d331..b988bad 100644 --- a/cli/provision.ts +++ b/cli/provision.ts @@ -254,19 +254,52 @@ 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, 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 = 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 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 (!active && !commands.tryRun("systemctl", ["enable", "--now", "postfix"]).ok) { + fatal("Postfix could not be started"); + } + log.ok("Postfix and SMTP authentication ready"); + 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 a17c8c3..cf2910e 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 3bd4792..ba14658 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"); } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6a5f48d..2b235bc 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 0000000..ff0c35d --- /dev/null +++ b/docs/decisions/smtp.md @@ -0,0 +1,86 @@ +# 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 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 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. 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. + +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 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 +it owns. Updates replace managed files atomically, run `postfix check`, then +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 +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. +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. + +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 0000000..448c3cf --- /dev/null +++ b/docs/smtp-relay.md @@ -0,0 +1,65 @@ +# 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 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. 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. 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 + mailboxes, authorize senders at the provider, or set DNS records. +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 +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. 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 +`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 a61e8ad..4cad545 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 53dbff1..6085e6c 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 2149e86..317435e 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-setup", "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 572fe2e..55b3150 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 9576035..c516710 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 cf32b03..5a22e26 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-provision.test.ts b/tests/test-provision.test.ts index f8ab361..7afde08 100644 --- a/tests/test-provision.test.ts +++ b/tests/test-provision.test.ts @@ -216,12 +216,17 @@ function requiredUnitsProbe(options: { downloadOk?: boolean; 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) { @@ -234,7 +239,12 @@ 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: "" }; }, }; @@ -303,6 +313,63 @@ 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).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); + 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 new file mode 100644 index 0000000..d2dfa5f --- /dev/null +++ b/tests/test-smtp.test.ts @@ -0,0 +1,241 @@ +import { afterEach, expect, test } from "bun:test"; +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"; +import { emptySmtpPolicy, parseRule, type SmtpSubmissionSite } from "../addons/smtp/config"; +import { MAX_MESSAGE_BYTES, prepareSubmission, readBoundedSubmission } 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); + 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", () => { + 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("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" }; + 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"); + 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", () => { + 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(); + 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(" ")}`); + 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 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"); + 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", "")); + 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}"); + 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`); + 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); + 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"); + 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", + } }) }); + 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: [] }, + }) })).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.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"'); +}); diff --git a/tests/test-uninstall.test.ts b/tests/test-uninstall.test.ts index b8a713b..cabdbaa 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 }); diff --git a/tools/preview-ui.ts b/tools/preview-ui.ts index a12b624..6ec5d3f 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/") {