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

164 lines
6.4 KiB
JavaScript

// ---------------------------------------------------------------------------
// 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}`));