Files
karim 1d3818e725 docker-mailserver LXC für Proxmox: Stack + Admin-UI + Webmail + Hardening
- 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>
2026-06-02 02:26:28 +02:00

230 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// 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;
}