368 lines
13 KiB
JavaScript
368 lines
13 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 || path.join(__dirname, "data", "maillog.log");
|
|
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: "allowedExternalSenders", label: "外部送信許可送信元 (allowed_external_senders)", fileName: "allowed_external_senders", mapType: "hash" },
|
|
{ id: "allowedRcptDomains", label: "許可宛先ドメイン (allowed_rcpt_domains.regexp)", fileName: "allowed_rcpt_domains.regexp", mapType: "regexp" },
|
|
{ id: "rcptDomainPolicy", label: "受信許可ドメインポリシー (rcpt_domain_policy.regexp)", fileName: "rcpt_domain_policy.regexp", mapType: "regexp" },
|
|
{ id: "rcptAccess", label: "受信許可宛先アドレス (rcpt_access)", fileName: "rcpt_access", mapType: "hash" },
|
|
{ id: "senderDomainPolicy", label: "送信元ドメイン分岐 (sender_domain_policy.regexp)", fileName: "sender_domain_policy.regexp", mapType: "regexp" },
|
|
{ id: "lineworksClientPolicy", label: "LINEWORKS 送信元IP (lineworks_client_policy.cidr)", fileName: "lineworks_client_policy.cidr", mapType: "cidr" },
|
|
];
|
|
|
|
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 classifyFileWriteError(error) {
|
|
if (error?.code === "ENOENT") {
|
|
return { status: 404, message: "対象ファイルまたはディレクトリが見つかりません。" };
|
|
}
|
|
|
|
if (error?.code === "EACCES" || error?.code === "EPERM") {
|
|
return { status: 403, message: "対象ファイルの書き込み権限がありません。" };
|
|
}
|
|
|
|
if (error?.code === "ENOSPC") {
|
|
return { status: 507, 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,
|
|
fileName: entry.fileName,
|
|
filePath,
|
|
exists: true,
|
|
lines,
|
|
};
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
return {
|
|
id: entry.id,
|
|
label: entry.label,
|
|
fileName: entry.fileName,
|
|
filePath,
|
|
exists: false,
|
|
lines: 0,
|
|
};
|
|
}
|
|
|
|
const classified = classifyFileReadError(error);
|
|
return {
|
|
id: entry.id,
|
|
label: entry.label,
|
|
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,
|
|
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) {
|
|
// 反映失敗時はファイルを元に戻して、壊れた設定を残さない
|
|
try {
|
|
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(() => { });
|
|
}
|
|
}
|
|
} catch (rollbackError) {
|
|
console.error("[postfixConsole] rollback failed", rollbackError);
|
|
}
|
|
|
|
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) {
|
|
const classified = classifyFileWriteError(error);
|
|
return res.status(classified.status).json({
|
|
message: `設定ファイルの保存に失敗しました。${classified.message}`,
|
|
code: error.code || "UNKNOWN",
|
|
});
|
|
}
|
|
});
|
|
|
|
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"})`);
|
|
});
|