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,3 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Abhängigkeiten zuerst (besseres Layer-Caching)
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
# als non-root laufen
|
||||
USER node
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,60 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// auth.js — Supabase-Token-Prüfung (wie in OPENBUREAU)
|
||||
//
|
||||
// Die React-Admin-UI loggt sich per Supabase ein und schickt das Access-Token
|
||||
// als Authorization: Bearer <token>. Hier validieren wir es gegen Supabase
|
||||
// und prüfen, ob die E-Mail in ADMIN_ALLOWED_EMAILS steht.
|
||||
// ---------------------------------------------------------------------------
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;
|
||||
const ALLOWED = (process.env.ADMIN_ALLOWED_EMAILS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
// Nur für lokale Tests: hebt die Auth komplett auf. Standardmäßig AUS.
|
||||
// Wird ausschließlich im docker-compose.local.yml gesetzt, niemals im Deploy.
|
||||
const AUTH_DISABLED = process.env.AUTH_DISABLED === 'true';
|
||||
if (AUTH_DISABLED) {
|
||||
console.warn('[auth] ⚠ AUTH_DISABLED=true — KEINE Authentifizierung! Nur für lokale Tests verwenden.');
|
||||
}
|
||||
|
||||
// Supabase-Client nur erstellen, wenn Auth aktiv und konfiguriert ist
|
||||
// (createClient wirft bei leerer URL — würde sonst den Start verhindern).
|
||||
let supabase = null;
|
||||
if (!AUTH_DISABLED) {
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
console.warn('[auth] SUPABASE_URL / SUPABASE_ANON_KEY nicht gesetzt — Auth wird fehlschlagen.');
|
||||
} else {
|
||||
supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(req, res, next) {
|
||||
try {
|
||||
if (AUTH_DISABLED) {
|
||||
req.user = { email: 'dev@local.test' };
|
||||
return next();
|
||||
}
|
||||
if (!supabase) return res.status(500).json({ error: 'Auth nicht konfiguriert (SUPABASE_URL/ANON_KEY fehlen).' });
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: 'Kein Token.' });
|
||||
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
if (error || !data?.user) return res.status(401).json({ error: 'Token ungültig.' });
|
||||
|
||||
const email = (data.user.email || '').toLowerCase();
|
||||
if (ALLOWED.length && !ALLOWED.includes(email)) {
|
||||
return res.status(403).json({ error: 'Kein Admin-Zugriff für diese E-Mail.' });
|
||||
}
|
||||
req.user = { email };
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Auth-Fehler: ' + e.message });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// settings.js — editierbare Admin-Einstellungen (Domains, Webmail-Domain, Brand)
|
||||
//
|
||||
// Liegt als JSON in der DMS-Config (persistent). Beim ersten Start aus den
|
||||
// ENV-Variablen (Deploy-Dialog) geseedet, danach in der Admin-UI editierbar.
|
||||
// ---------------------------------------------------------------------------
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const CONFIG_DIR = process.env.CONFIG_DIR || '/config';
|
||||
const FILE = path.join(CONFIG_DIR, 'admin-settings.json');
|
||||
|
||||
const isDomain = (s) => /^[^@\s]+\.[^@\s]+$/.test(s);
|
||||
const envDomains = () =>
|
||||
(process.env.MAIL_DOMAINS || process.env.MAIL_DOMAIN || '').split(/[\s,]+/).filter(Boolean);
|
||||
|
||||
function seed() {
|
||||
const domains = envDomains();
|
||||
const primary = process.env.MAIL_DOMAIN || domains[0] || 'example.com';
|
||||
return {
|
||||
brand: process.env.BRAND || primary,
|
||||
fqdn: process.env.MAIL_FQDN || `mail.${primary}`,
|
||||
primaryDomain: primary,
|
||||
domains: domains.length ? domains : [primary],
|
||||
webmailFqdn: process.env.WEBMAIL_FQDN || `mail.${primary}`,
|
||||
adminFqdn: process.env.ADMIN_FQDN || `admin.${primary}`,
|
||||
};
|
||||
}
|
||||
|
||||
let chain = Promise.resolve();
|
||||
const withLock = (fn) => { const r = chain.then(fn, fn); chain = r.catch(() => {}); return r; };
|
||||
const save = (s) => fs.writeFile(FILE, JSON.stringify(s, null, 2) + '\n', 'utf8');
|
||||
|
||||
export async function readSettings() {
|
||||
try {
|
||||
return { id: 'settings', ...JSON.parse(await fs.readFile(FILE, 'utf8')) };
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') { const s = seed(); await save(s); return { id: 'settings', ...s }; }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSettings(patch) {
|
||||
return withLock(async () => {
|
||||
const { id, ...cur } = await readSettings();
|
||||
const next = { ...cur };
|
||||
// nur erlaubte Felder
|
||||
for (const k of ['brand', 'fqdn', 'primaryDomain', 'webmailFqdn', 'adminFqdn']) {
|
||||
if (typeof patch[k] === 'string' && patch[k].trim()) next[k] = patch[k].trim();
|
||||
}
|
||||
if (Array.isArray(patch.domains)) {
|
||||
next.domains = [...new Set(patch.domains.map((d) => String(d).trim()).filter(isDomain))];
|
||||
}
|
||||
await save(next);
|
||||
return { id: 'settings', ...next };
|
||||
});
|
||||
}
|
||||
|
||||
export function addDomain(domain) {
|
||||
return withLock(async () => {
|
||||
domain = String(domain || '').trim().toLowerCase();
|
||||
if (!isDomain(domain)) { const e = new Error('Ungültige Domain.'); e.status = 400; throw e; }
|
||||
const { id, ...cur } = await readSettings();
|
||||
if (!cur.domains.includes(domain)) cur.domains.push(domain);
|
||||
await save(cur);
|
||||
return { id: 'settings', ...cur };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeDomain(domain) {
|
||||
return withLock(async () => {
|
||||
const { id, ...cur } = await readSettings();
|
||||
cur.domains = cur.domains.filter((d) => d !== domain);
|
||||
await save(cur);
|
||||
return { id: 'settings', ...cur };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "dms-admin-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Admin API für docker-mailserver — verwaltet Konten/Aliase/Quotas über die DMS-Config-Dateien (Supabase-Auth).",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"dockerode": "^4.0.2",
|
||||
"express": "^4.19.2",
|
||||
"sha512crypt-node": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// server.js — Admin-API für docker-mailserver
|
||||
//
|
||||
// REST-Schnittstelle im Format von react-admin (ra-data-simple-rest):
|
||||
// GET /accounts Liste (mit Content-Range Header)
|
||||
// GET /accounts/:id einzelnes Konto
|
||||
// POST /accounts anlegen
|
||||
// PUT /accounts/:id ändern (Passwort/Quota)
|
||||
// DELETE /accounts/:id löschen
|
||||
// analog /aliases, sowie GET /status (DNS/DKIM-Infos).
|
||||
// ---------------------------------------------------------------------------
|
||||
import express from 'express';
|
||||
import { requireAdmin } from './lib/auth.js';
|
||||
import * as store from './lib/store.js';
|
||||
import * as ms from './lib/mailserver.js';
|
||||
import * as settings from './lib/settings.js';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// --- ungeschützter Health-Check -------------------------------------------
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// --- ab hier alles geschützt ----------------------------------------------
|
||||
app.use(requireAdmin);
|
||||
|
||||
// Hilfsfunktion: Liste sortieren/filtern/paginieren wie ra-data-simple-rest
|
||||
function sendList(res, resource, rows, query) {
|
||||
let data = rows;
|
||||
// filter
|
||||
if (query.filter) {
|
||||
try {
|
||||
const f = JSON.parse(query.filter);
|
||||
// getMany: { id: [...] }
|
||||
if (Array.isArray(f.id)) data = data.filter((r) => f.id.includes(r.id));
|
||||
// Volltext-Filter "q"
|
||||
if (f.q) {
|
||||
const q = String(f.q).toLowerCase();
|
||||
data = data.filter((r) => JSON.stringify(r).toLowerCase().includes(q));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// sort
|
||||
if (query.sort) {
|
||||
try {
|
||||
const [field, order] = JSON.parse(query.sort);
|
||||
data = [...data].sort((a, b) => String(a[field]).localeCompare(String(b[field])));
|
||||
if (order === 'DESC') data.reverse();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const total = data.length;
|
||||
// range
|
||||
let [start, end] = [0, total - 1];
|
||||
if (query.range) {
|
||||
try { [start, end] = JSON.parse(query.range); } catch { /* ignore */ }
|
||||
}
|
||||
const page = data.slice(start, end + 1);
|
||||
res.set('Content-Range', `${resource} ${start}-${start + page.length - 1}/${total}`);
|
||||
res.set('Access-Control-Expose-Headers', 'Content-Range');
|
||||
res.json(page);
|
||||
}
|
||||
|
||||
const id = (req) => decodeURIComponent(req.params.id);
|
||||
|
||||
// ============================ ACCOUNTS =====================================
|
||||
app.get('/accounts', async (req, res, next) => {
|
||||
try { sendList(res, 'accounts', await store.listAccounts(), req.query); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/accounts/:id', async (req, res, next) => {
|
||||
try {
|
||||
const a = await store.getAccount(id(req));
|
||||
if (!a) return res.status(404).json({ error: 'Konto nicht gefunden.' });
|
||||
res.json(a);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
app.post('/accounts', async (req, res, next) => {
|
||||
try { res.status(201).json(await store.createAccount(req.body)); } catch (e) { next(e); }
|
||||
});
|
||||
app.put('/accounts/:id', async (req, res, next) => {
|
||||
try { res.json(await store.updateAccount(id(req), req.body)); } catch (e) { next(e); }
|
||||
});
|
||||
app.delete('/accounts/:id', async (req, res, next) => {
|
||||
try { res.json(await store.deleteAccount(id(req))); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ============================ ALIASES ======================================
|
||||
app.get('/aliases', async (req, res, next) => {
|
||||
try { sendList(res, 'aliases', await store.listAliases(), req.query); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/aliases/:id', async (req, res, next) => {
|
||||
try {
|
||||
const a = await store.getAlias(id(req));
|
||||
if (!a) return res.status(404).json({ error: 'Alias nicht gefunden.' });
|
||||
res.json(a);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
app.post('/aliases', async (req, res, next) => {
|
||||
try { res.status(201).json(await store.createAlias(req.body)); } catch (e) { next(e); }
|
||||
});
|
||||
app.put('/aliases/:id', async (req, res, next) => {
|
||||
try { res.json(await store.updateAlias(id(req), req.body)); } catch (e) { next(e); }
|
||||
});
|
||||
app.delete('/aliases/:id', async (req, res, next) => {
|
||||
try { res.json(await store.deleteAlias(id(req))); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ============================ STATUS =======================================
|
||||
app.get('/status', async (_req, res, next) => {
|
||||
try { res.json(await store.status()); } catch (e) { next(e); }
|
||||
});
|
||||
// react-admin erwartet bei getOne(status) ggf. /status/status
|
||||
app.get('/status/:id', async (_req, res, next) => {
|
||||
try { res.json(await store.status()); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ============== MAILSERVER-BRIDGE (über docker-socket-proxy) ===============
|
||||
app.get('/mailserver/overview', async (_req, res, next) => {
|
||||
try { res.json(await ms.overview()); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/mailserver/quota', async (_req, res, next) => {
|
||||
try { res.json(await ms.quotaUsage()); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/mailserver/queue', async (_req, res, next) => {
|
||||
try { res.json(await ms.queue()); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/mailserver/who', async (_req, res, next) => {
|
||||
try { res.json(await ms.who()); } catch (e) { next(e); }
|
||||
});
|
||||
app.post('/mailserver/dkim', async (req, res, next) => {
|
||||
try { res.json(await ms.generateDkim((req.body || {}).domain)); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ============== EINSTELLUNGEN (Domains / Webmail-Domain / Brand) ============
|
||||
app.post('/settings/domains', async (req, res, next) => {
|
||||
try {
|
||||
const domain = (req.body || {}).domain;
|
||||
const result = await settings.addDomain(domain);
|
||||
ms.generateDkim(domain).catch(() => {}); // DKIM best-effort über die Bridge
|
||||
res.json(result);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
app.delete('/settings/domains/:domain', async (req, res, next) => {
|
||||
try { res.json(await settings.removeDomain(decodeURIComponent(req.params.domain))); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/settings', async (_req, res, next) => {
|
||||
try { res.json(await settings.readSettings()); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/settings/:id', async (_req, res, next) => {
|
||||
try { res.json(await settings.readSettings()); } catch (e) { next(e); }
|
||||
});
|
||||
app.put('/settings/:id', async (req, res, next) => {
|
||||
try { res.json(await settings.writeSettings(req.body || {})); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// --- Fehlerbehandlung ------------------------------------------------------
|
||||
app.use((err, _req, res, _next) => {
|
||||
const code = err.status || 500;
|
||||
if (code >= 500) console.error(err);
|
||||
res.status(code).json({ error: err.message || 'Serverfehler' });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`[dms-admin-api] läuft auf :${PORT}`));
|
||||
Reference in New Issue
Block a user