// --------------------------------------------------------------------------- // store.js — liest/schreibt die docker-mailserver Config-Dateien // // postfix-accounts.cf email|{SHA512-CRYPT}$6$... (ein Konto pro Zeile) // postfix-virtual.cf quelle@dom ziel1@dom,ziel2@dom (ein Alias pro Zeile) // dovecot-quotas.cf email:10G (eine Quota pro Zeile) // // DMS erkennt Dateiänderungen automatisch und lädt neu — kein Docker-Socket nötig. // --------------------------------------------------------------------------- import { promises as fs } from 'node:fs'; import path from 'node:path'; import { sha512crypt } from 'sha512crypt-node'; import { readSettings } from './settings.js'; const CONFIG_DIR = process.env.CONFIG_DIR || '/config'; const ACCOUNTS = path.join(CONFIG_DIR, 'postfix-accounts.cf'); const VIRTUAL = path.join(CONFIG_DIR, 'postfix-virtual.cf'); const QUOTAS = path.join(CONFIG_DIR, 'dovecot-quotas.cf'); // --- simpler Schreib-Mutex (Admin-Last ist niedrig) ----------------------- let chain = Promise.resolve(); function withLock(fn) { const run = chain.then(fn, fn); chain = run.catch(() => {}); return run; } // --- Datei-Helfer ---------------------------------------------------------- async function readLines(file) { try { const txt = await fs.readFile(file, 'utf8'); return txt.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } } async function writeLines(file, lines) { const tmp = `${file}.tmp-${process.pid}`; await fs.writeFile(tmp, lines.length ? lines.join('\n') + '\n' : '', 'utf8'); await fs.rename(tmp, file); // atomar } // --- Passwort-Hash (SHA512-CRYPT), Format wie doveadm pw ------------------ const SALTCHARS = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; function hashPassword(plain) { let salt = ''; for (let i = 0; i < 16; i++) salt += SALTCHARS[Math.floor(Math.random() * SALTCHARS.length)]; return '{SHA512-CRYPT}' + sha512crypt(plain, '$6$' + salt); } const isEmail = (s) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); const isCatchAll = (s) => /^@[^@\s]+\.[^@\s]+$/.test(s); // @domain.tld const isQuota = (q) => !q || /^\d+\s*[KMGT]?$/i.test(String(q).trim()); // 5G, 500M, leer // =========================================================================== // ACCOUNTS (+ Quota wird mit eingelesen/geschrieben) // =========================================================================== async function readQuotaMap() { const map = {}; for (const line of await readLines(QUOTAS)) { const idx = line.lastIndexOf(':'); if (idx > 0) map[line.slice(0, idx)] = line.slice(idx + 1); } return map; } export async function listAccounts() { const quotas = await readQuotaMap(); return (await readLines(ACCOUNTS)).map((line) => { const idx = line.indexOf('|'); const email = idx > 0 ? line.slice(0, idx) : line; return { id: email, email, quota: quotas[email] || '' }; }); } export async function getAccount(email) { return (await listAccounts()).find((a) => a.id === email) || null; } export function createAccount({ email, password, quota }) { return withLock(async () => { if (!isEmail(email)) throw httpErr(400, 'Ungültige E-Mail-Adresse.'); if (!password) throw httpErr(400, 'Passwort erforderlich.'); if (password.length < 8) throw httpErr(400, 'Passwort muss mindestens 8 Zeichen haben.'); if (!isQuota(quota)) throw httpErr(400, 'Ungültiges Quota-Format (z.B. 5G, 500M – leer = unbegrenzt).'); const lines = await readLines(ACCOUNTS); if (lines.some((l) => l.split('|')[0] === email)) throw httpErr(409, 'Konto existiert bereits.'); lines.push(`${email}|${hashPassword(password)}`); await writeLines(ACCOUNTS, lines); await setQuotaUnlocked(email, quota); return { id: email, email, quota: quota || '' }; }); } export function updateAccount(email, { password, quota }) { return withLock(async () => { const lines = await readLines(ACCOUNTS); const i = lines.findIndex((l) => l.split('|')[0] === email); if (i === -1) throw httpErr(404, 'Konto nicht gefunden.'); if (password && password.length < 8) throw httpErr(400, 'Passwort muss mindestens 8 Zeichen haben.'); if (quota !== undefined && !isQuota(quota)) throw httpErr(400, 'Ungültiges Quota-Format (z.B. 5G, 500M).'); if (password) lines[i] = `${email}|${hashPassword(password)}`; await writeLines(ACCOUNTS, lines); if (quota !== undefined) await setQuotaUnlocked(email, quota); return { id: email, email, quota: quota ?? (await readQuotaMap())[email] ?? '' }; }); } export function deleteAccount(email) { return withLock(async () => { const lines = await readLines(ACCOUNTS); await writeLines(ACCOUNTS, lines.filter((l) => l.split('|')[0] !== email)); await setQuotaUnlocked(email, ''); // Quota mit entfernen return { id: email }; }); } // --- Quota (innerhalb eines bestehenden Locks aufrufen) ------------------- async function setQuotaUnlocked(email, quota) { const lines = await readLines(QUOTAS); const rest = lines.filter((l) => l.slice(0, l.lastIndexOf(':')) !== email); if (quota) rest.push(`${email}:${quota}`); await writeLines(QUOTAS, rest); } // =========================================================================== // ALIASES // =========================================================================== export async function listAliases() { return (await readLines(VIRTUAL)).map((line) => { const m = line.split(/\s+/); const source = m.shift(); return { id: source, source, destination: m.join(' ').replace(/\s+/g, ',') }; }); } export async function getAlias(source) { return (await listAliases()).find((a) => a.id === source) || null; } export function createAlias({ source, destination }) { return withLock(async () => { if (!isEmail(source) && !isCatchAll(source)) { throw httpErr(400, 'Ungültige Alias-Adresse (vollständige E-Mail oder @domain.tld für Catch-all).'); } if (!destination) throw httpErr(400, 'Ziel erforderlich.'); const lines = await readLines(VIRTUAL); if (lines.some((l) => l.split(/\s+/)[0] === source)) throw httpErr(409, 'Alias existiert bereits.'); lines.push(`${source} ${destination.split(',').map((s) => s.trim()).filter(Boolean).join(',')}`); await writeLines(VIRTUAL, lines); return { id: source, source, destination }; }); } export function updateAlias(source, { destination }) { return withLock(async () => { const lines = await readLines(VIRTUAL); const i = lines.findIndex((l) => l.split(/\s+/)[0] === source); if (i === -1) throw httpErr(404, 'Alias nicht gefunden.'); lines[i] = `${source} ${destination.split(',').map((s) => s.trim()).filter(Boolean).join(',')}`; await writeLines(VIRTUAL, lines); return { id: source, source, destination }; }); } export function deleteAlias(source) { return withLock(async () => { const lines = await readLines(VIRTUAL); await writeLines(VIRTUAL, lines.filter((l) => l.split(/\s+/)[0] !== source)); return { id: source }; }); } // =========================================================================== // STATUS / DNS / DKIM // =========================================================================== export async function status() { const s = await readSettings(); const primary = s.primaryDomain; const fqdn = s.fqdn; const domains = s.domains; const accounts = await listAccounts(); const aliases = await listAliases(); // alle DKIM-DNS-Dateien einlesen (Dateiname enthält die Domain) const dkimFiles = []; try { const dkimDir = path.join(CONFIG_DIR, 'rspamd', 'dkim'); for (const f of await fs.readdir(dkimDir)) { if (f.endsWith('.dns.txt')) { dkimFiles.push({ f, txt: (await fs.readFile(path.join(dkimDir, f), 'utf8')).trim() }); } } } catch { /* noch kein DKIM erzeugt */ } const records = domains.map((d) => ({ domain: d, mx: `${d}. IN MX 10 ${fqdn}.`, spf: `${d}. IN TXT "v=spf1 mx ~all"`, dmarc: `_dmarc.${d}. IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@${d}"`, dkim: (dkimFiles.find((x) => x.f.includes(`-${d}.`) || x.f.includes(d)) || {}).txt || '', })); return { id: 'status', fqdn, domain: primary, brand: s.brand, webmailFqdn: s.webmailFqdn, adminFqdn: s.adminFqdn, domains, accounts: accounts.length, aliases: aliases.length, host: { a: `${fqdn}. IN A <öffentliche IP>`, ptr: `<öffentliche IP> -> ${fqdn} (PTR/rDNS beim Hoster setzen)`, }, records, }; } // --- kleiner HTTP-Fehler-Helfer ------------------------------------------- export function httpErr(status, message) { const e = new Error(message); e.status = status; return e; }