ken_nogi/NodeJS/postfixConsole/server.js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

352 lines
12 KiB
JavaScript

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"})`);
});