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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// mailserver.js — abgesicherte Bridge zum docker-mailserver Container
|
||||
//
|
||||
// Läuft NUR über einen docker-socket-proxy (nur exec freigegeben) und führt
|
||||
// ausschließlich WHITELISTED Kommandos im Mailserver-Container aus. Argumente
|
||||
// werden als argv-Array übergeben (keine Shell -> keine Injection).
|
||||
// ---------------------------------------------------------------------------
|
||||
import Docker from 'dockerode';
|
||||
import { httpErr } from './store.js';
|
||||
|
||||
const MAILSERVER = process.env.MAILSERVER_CONTAINER || 'mailserver';
|
||||
// DOCKER_PROXY = "host:port" (docker-socket-proxy), z.B. socket-proxy:2375
|
||||
const [proxyHost, proxyPort] = (process.env.DOCKER_PROXY || 'socket-proxy:2375').split(':');
|
||||
const docker = new Docker({ host: proxyHost, port: Number(proxyPort) || 2375 });
|
||||
|
||||
const isEmail = (s) => /^@?[^@\s]*@?[^@\s]+\.[^@\s]+$/.test(s); // erlaubt auch @domain
|
||||
const isDomain = (s) => /^[^@\s]+\.[^@\s]+$/.test(s);
|
||||
|
||||
// --- whitelisted exec: nimmt ein argv-Array, gibt stdout zurück ------------
|
||||
async function exec(cmd) {
|
||||
const container = docker.getContainer(MAILSERVER);
|
||||
const ex = await container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true });
|
||||
const stream = await ex.start({ hijack: true, stdin: false });
|
||||
const out = [];
|
||||
const err = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
container.modem.demuxStream(
|
||||
stream,
|
||||
{ write: (d) => out.push(d) },
|
||||
{ write: (d) => err.push(d) },
|
||||
);
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
const info = await ex.inspect();
|
||||
if (info.ExitCode && info.ExitCode !== 0) {
|
||||
throw httpErr(502, `Mailserver-Kommando fehlgeschlagen: ${Buffer.concat(err).toString() || 'Exit ' + info.ExitCode}`);
|
||||
}
|
||||
return Buffer.concat(out).toString('utf8');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// QUOTA-Auslastung (doveadm quota get -A)
|
||||
// ===========================================================================
|
||||
export async function quotaUsage() {
|
||||
const raw = await exec(['doveadm', 'quota', 'get', '-A']);
|
||||
const rows = {};
|
||||
for (const line of raw.split('\n')) {
|
||||
// Username ... STORAGE <value KB> <limit KB|-> <%>
|
||||
const m = line.match(/^(\S+)\s+User quota\s+STORAGE\s+(\d+)\s+(\d+|-)\s+(\d+|-)/);
|
||||
if (m) {
|
||||
const [, user, valueKB, limitKB, pct] = m;
|
||||
rows[user] = {
|
||||
id: user,
|
||||
user,
|
||||
usedBytes: Number(valueKB) * 1024,
|
||||
limitBytes: limitKB === '-' ? null : Number(limitKB) * 1024,
|
||||
percent: pct === '-' ? null : Number(pct),
|
||||
};
|
||||
}
|
||||
}
|
||||
return Object.values(rows);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Mail-Queue (postqueue -p)
|
||||
// ===========================================================================
|
||||
export async function queue() {
|
||||
const raw = await exec(['postqueue', '-p']);
|
||||
const empty = /Mail queue is empty/.test(raw);
|
||||
// letzte Zeile: "-- N Kbytes in M Requests."
|
||||
const m = raw.match(/in (\d+) Request/i);
|
||||
return { empty, count: empty ? 0 : (m ? Number(m[1]) : null), raw: raw.trim() };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Aktive Logins (doveadm who)
|
||||
// ===========================================================================
|
||||
export async function who() {
|
||||
const raw = await exec(['doveadm', 'who']);
|
||||
const lines = raw.trim().split('\n').slice(1).filter(Boolean); // ohne Header
|
||||
return {
|
||||
sessions: lines.map((l) => {
|
||||
const [username, count, proto] = l.split(/\s+/);
|
||||
return { username, count: Number(count) || count, proto };
|
||||
}),
|
||||
raw: raw.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// DKIM-Schlüssel pro Domain erzeugen (setup config dkim ...)
|
||||
// ===========================================================================
|
||||
export async function generateDkim(domain) {
|
||||
if (!isDomain(domain)) throw httpErr(400, 'Ungültige Domain.');
|
||||
await exec(['setup', 'config', 'dkim', 'keysize', '2048', 'domain', domain]);
|
||||
return { ok: true, domain };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Übersicht
|
||||
// ===========================================================================
|
||||
export async function overview() {
|
||||
const [q, qu, w] = await Promise.all([queue(), quotaUsage(), who()]);
|
||||
return { queue: q, quota: qu, who: w };
|
||||
}
|
||||
Reference in New Issue
Block a user