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>
164 lines
6.4 KiB
JavaScript
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}`));
|