1d3818e725
- dms-lxc.sh: Proxmox-Host-Installer (unprivilegierter LXC, Debian 13, Docker), curl-Self-Download, Multi-Domain-DKIM, SnappyMail-Provisionierung, PVE-Firewall - Stack: docker-mailserver, Node-Admin-API (Supabase-Auth), React-Admin-UI (OPENBUREAU-Look), SnappyMail (Shibui-Theme), Rspamd-Web-UI, docker-socket-proxy - Admin: Postfächer/Aliase/Catch-all/Quota, editierbare Domains+Settings, Server (Quota/Queue über abgesicherte Bridge), Status & DNS - Hardening: no-new-privileges, Whitelisted exec-Bridge, Rspamd-Passwort, .env chmod 600, PVE-CT-Firewall, generisch/teilbar (keine festen Domains) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
8.7 KiB
JavaScript
230 lines
8.7 KiB
JavaScript
// ---------------------------------------------------------------------------
|
||
// 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;
|
||
}
|