const express = require("express"); const session = require("express-session"); const { execFile } = require("child_process"); const fs = require("fs"); const fsp = require("fs/promises"); const path = require("path"); const util = require("util"); const execFileAsync = util.promisify(execFile); const app = express(); const PORT = Number(process.env.PORT || 3359); const MAILLOG_PATH = process.env.MAILLOG_PATH || "/var/log/maillog"; const POSTFIX_CONFIG_DIR = process.env.POSTFIX_CONFIG_DIR || path.join(__dirname, "postfix"); const APP_PASSWORD = process.env.APP_PASSWORD || "postfix-console-pass"; const SESSION_SECRET = process.env.SESSION_SECRET || "postfix-console-session-secret"; const AUTO_POSTFIX_RELOAD = process.env.AUTO_POSTFIX_RELOAD !== "false"; const POSTFIX_COMMAND = process.env.POSTFIX_COMMAND || "postfix"; const POSTMAP_COMMAND = process.env.POSTMAP_COMMAND || "postmap"; const MANAGED_POSTFIX_FILES = [ { id: "allowedRcptDomains", label: "1) 宛先許可ドメイン (policy/allowed_rcpt_domains.regexp)", fileName: path.join("policy", "allowed_rcpt_domains.regexp"), mapType: "regexp", description: "宛先ドメインが一致したら最優先で許可するルールです。", }, { id: "headerAudit", label: "2) ヘッダ From 判定 (policy/header_audit.pcre)", fileName: path.join("policy", "header_audit.pcre"), mapType: "pcre", description: "ヘッダ From の条件に一致した場合に WARN/REJECT を返すヘッダ判定ルールです。", }, ]; app.use(express.json({ limit: "1mb" })); app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: "lax", }, })); app.use(express.static(path.join(__dirname, "public"))); function requireAuth(req, res, next) { if (req.session?.authenticated) { return next(); } return res.status(401).json({ message: "認証が必要です。" }); } function getManagedPostfixFile(id) { return MANAGED_POSTFIX_FILES.find((entry) => entry.id === id) || null; } function classifyFileReadError(error) { if (error?.code === "ENOENT") { return { status: 404, message: "対象ファイルが見つかりません。" }; } if (error?.code === "EACCES" || error?.code === "EPERM") { return { status: 403, message: "対象ファイルの読み取り権限がありません。" }; } if (error?.code === "EISDIR") { return { status: 400, message: "対象パスがファイルではありません。" }; } return { status: 500, message: "対象ファイルの読み込みに失敗しました。" }; } function resolveManagedPostfixPath(fileName) { return path.join(POSTFIX_CONFIG_DIR, fileName); } async function readManagedPostfixFile(fileName) { const filePath = resolveManagedPostfixPath(fileName); const content = await fsp.readFile(filePath, "utf8"); return { filePath, content }; } async function runCommand(command, args) { try { const { stdout, stderr } = await execFileAsync(command, args, { windowsHide: true, timeout: 10000, }); return { ok: true, stdout, stderr }; } catch (error) { return { ok: false, stdout: error.stdout || "", stderr: error.stderr || error.message || "", }; } } async function applyPostfixReloadForManagedFile(managed, filePath) { if (!AUTO_POSTFIX_RELOAD) { return { reloaded: false, message: "AUTO_POSTFIX_RELOAD=false のため再読込はスキップしました。", }; } if (managed.mapType === "hash") { const postmapResult = await runCommand(POSTMAP_COMMAND, [filePath]); if (!postmapResult.ok) { throw new Error(`postmap 失敗: ${postmapResult.stderr}`); } } const checkResult = await runCommand(POSTFIX_COMMAND, ["check"]); if (!checkResult.ok) { throw new Error(`postfix check 失敗: ${checkResult.stderr}`); } const reloadResult = await runCommand(POSTFIX_COMMAND, ["reload"]); if (!reloadResult.ok) { throw new Error(`postfix reload 失敗: ${reloadResult.stderr}`); } return { reloaded: true, message: "postfix check / reload を実行しました。", }; } app.get("/api/auth/status", (req, res) => { res.json({ authenticated: Boolean(req.session?.authenticated) }); }); app.post("/api/auth/login", (req, res) => { const password = String(req.body?.password || ""); if (password !== APP_PASSWORD) { return res.status(401).json({ message: "パスワードが正しくありません。" }); } req.session.authenticated = true; return res.json({ message: "認証に成功しました。" }); }); app.post("/api/auth/logout", (req, res) => { req.session.destroy(() => { res.json({ message: "ログアウトしました。" }); }); }); app.get("/api/postfix-settings", requireAuth, async (req, res) => { try { const items = await Promise.all(MANAGED_POSTFIX_FILES.map(async (entry) => { const filePath = resolveManagedPostfixPath(entry.fileName); try { const content = await fsp.readFile(filePath, "utf8"); const lines = content === "" ? 0 : content.split(/\r?\n/).length; return { id: entry.id, label: entry.label, description: entry.description || "", fileName: entry.fileName, filePath, exists: true, lines, }; } catch (error) { if (error.code === "ENOENT") { return { id: entry.id, label: entry.label, description: entry.description || "", fileName: entry.fileName, filePath, exists: false, lines: 0, }; } const classified = classifyFileReadError(error); return { id: entry.id, label: entry.label, description: entry.description || "", fileName: entry.fileName, filePath, exists: true, lines: 0, readError: { code: error.code || "UNKNOWN", status: classified.status, message: classified.message, }, }; } })); return res.json({ baseDir: POSTFIX_CONFIG_DIR, items, }); } catch (error) { return res.status(500).json({ message: "Postfix設定一覧の読み込みに失敗しました。" }); } }); app.get("/api/postfix-settings/:id", requireAuth, async (req, res) => { try { const managed = getManagedPostfixFile(req.params.id); if (!managed) { return res.status(404).json({ message: "対象の設定ファイルが見つかりません。" }); } const { filePath, content } = await readManagedPostfixFile(managed.fileName); return res.json({ id: managed.id, label: managed.label, description: managed.description || "", fileName: managed.fileName, filePath, content, lines: content === "" ? 0 : content.split(/\r?\n/).length, }); } catch (error) { const classified = classifyFileReadError(error); const message = classified.status === 404 ? "設定ファイルが見つかりません。" : `設定ファイルの読み込みに失敗しました。${classified.message}`; return res.status(classified.status).json({ message, code: error.code || "UNKNOWN", }); } }); app.post("/api/postfix-settings/:id", requireAuth, async (req, res) => { try { const managed = getManagedPostfixFile(req.params.id); if (!managed) { return res.status(404).json({ message: "対象の設定ファイルが見つかりません。" }); } const content = req.body?.content; if (typeof content !== "string") { return res.status(400).json({ message: "content文字列を指定してください。" }); } if (Buffer.byteLength(content, "utf8") > 1024 * 1024) { return res.status(400).json({ message: "設定内容が大きすぎます (最大1MB)。" }); } const filePath = resolveManagedPostfixPath(managed.fileName); let previousContent = ""; let existedBefore = true; try { previousContent = await fsp.readFile(filePath, "utf8"); } catch (error) { if (error.code === "ENOENT") { existedBefore = false; } else { throw error; } } await fsp.mkdir(path.dirname(filePath), { recursive: true }); await fsp.writeFile(filePath, content, "utf8"); let reloadResult; try { reloadResult = await applyPostfixReloadForManagedFile(managed, filePath); } catch (error) { // 反映失敗時はファイルを元に戻して、壊れた設定を残さない if (existedBefore) { await fsp.writeFile(filePath, previousContent, "utf8"); if (managed.mapType === "hash" && AUTO_POSTFIX_RELOAD) { await runCommand(POSTMAP_COMMAND, [filePath]); } } else { await fsp.unlink(filePath).catch(() => { }); if (managed.mapType === "hash" && AUTO_POSTFIX_RELOAD) { await fsp.unlink(`${filePath}.db`).catch(() => { }); } } return res.status(400).json({ message: `保存はロールバックされました。${error.message}`, }); } return res.json({ message: "設定を保存しました。", id: managed.id, filePath, lines: content === "" ? 0 : content.split(/\r?\n/).length, reload: reloadResult, }); } catch (error) { return res.status(500).json({ message: "設定ファイルの保存に失敗しました。" }); } }); app.get("/api/maillog", requireAuth, async (req, res) => { try { const requestedLines = Number(req.query.lines || 100); const lines = Number.isFinite(requestedLines) && requestedLines > 0 ? Math.min(requestedLines, 1000) : 100; const content = await fsp.readFile(MAILLOG_PATH, "utf8"); const allLines = content.split(/\r?\n/); const nonTrailing = allLines[allLines.length - 1] === "" ? allLines.slice(0, -1) : allLines; const latest = nonTrailing.slice(-lines); return res.json({ maillogPath: MAILLOG_PATH, lines: latest, count: latest.length, }); } catch (error) { const classified = classifyFileReadError(error); const message = classified.status === 404 ? "maillogファイルが見つかりません。" : `maillogの読み込みに失敗しました。${classified.message}`; return res.status(classified.status).json({ message, maillogPath: MAILLOG_PATH, code: error.code || "UNKNOWN", }); } }); app.get("/health", (req, res) => { res.json({ status: "ok" }); }); app.listen(PORT, () => { const postfixConfigDirExists = fs.existsSync(POSTFIX_CONFIG_DIR); const maillogExists = fs.existsSync(MAILLOG_PATH); console.log(`[postfixConsole] listening on port ${PORT}`); console.log(`[postfixConsole] postfix config dir: ${POSTFIX_CONFIG_DIR} (${postfixConfigDirExists ? "exists" : "missing"})`); console.log(`[postfixConsole] maillog: ${MAILLOG_PATH} (${maillogExists ? "exists" : "missing"})`); });