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:
2026-06-02 02:26:28 +02:00
commit 1d3818e725
36 changed files with 5523 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# ============================================================================
# Stack-Konfiguration (docker compose liest diese Datei automatisch)
# Kopiere zu .env und passe die Werte an. -> cp .env.example .env
# (Beim LXC-Deploy werden diese Werte über den Dialog gesetzt.)
# ============================================================================
# --- Mail ---
MAIL_FQDN=mail.example.com # FQDN des Mailservers (= Container-hostname, ein Host für ALLE Domains)
MAIL_DOMAIN=example.com # primäre Domain (postmaster@, Defaults)
# ALLE Mail-Domains (Leerzeichen-getrennt). Ein Mailserver bedient mehrere Domains.
# Nur die Erst-Befüllung — danach in der Admin-UI unter „Einstellungen" editierbar:
MAIL_DOMAINS=example.com
DMS_TAG=latest # Image-Tag von docker-mailserver
# --- Branding / Web-Domains (Erst-Befüllung, in der Admin-UI editierbar) ---
BRAND=example.com # Anzeigename im Admin-Dashboard
WEBMAIL_FQDN=mail.example.com # Webmail-Domain (NPM-Proxy-Host)
ADMIN_FQDN=admin.example.com # Admin-UI-Domain (NPM-Proxy-Host)
# --- Veröffentlichte Web-Ports (Nginx Proxy Manager zeigt hierauf) ---
ADMIN_PORT=8080 # Admin-UI (React-Admin)
WEBMAIL_PORT=8888 # SnappyMail Webmail
RSPAMD_PORT=11334 # Rspamd Web-UI
# --- Admin-API ---
# Liste der E-Mails, die sich im Admin anmelden dürfen (Komma-getrennt)
ADMIN_ALLOWED_EMAILS=admin@example.com
# --- Supabase (Auth für die Admin-UI) ---
SUPABASE_URL=https://YOUR-PROJECT.supabase.co
SUPABASE_ANON_KEY=YOUR-ANON-KEY
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
npm-debug.log
+16
View File
@@ -0,0 +1,16 @@
# ---- Build-Stage: React-Admin mit Vite bauen ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# ---- Serve-Stage: nginx ----
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
# Laufzeit-Config-Generator (nginx-Image führt /docker-entrypoint.d/*.sh aus)
COPY docker-entrypoint.d/40-config.sh /docker-entrypoint.d/40-config.sh
RUN chmod +x /docker-entrypoint.d/40-config.sh
EXPOSE 80
@@ -0,0 +1,13 @@
#!/bin/sh
# Erzeugt zur Laufzeit /config.js aus den Umgebungsvariablen, damit die
# React-App Supabase-URL/Key ohne Neu-Build erhält (window.__CONFIG__).
# Das nginx-Image führt Skripte in /docker-entrypoint.d/ vor dem Start aus.
set -e
cat > /usr/share/nginx/html/config.js <<EOF
window.__CONFIG__ = {
SUPABASE_URL: "${SUPABASE_URL}",
SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY}",
AUTH_DISABLED: "${AUTH_DISABLED:-false}"
};
EOF
echo "[entrypoint] config.js erzeugt (SUPABASE_URL=${SUPABASE_URL}, AUTH_DISABLED=${AUTH_DISABLED:-false})"
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mailserver Admin</title>
<!-- Fonts wie OPENBUREAU (bunny.net, datenschutzfreundlich) -->
<link rel="preconnect" href="https://fonts.bunny.net" />
<link href="https://fonts.bunny.net/css?family=newsreader:400,500,600,700|inter:400,500,600|space-grotesk:500,700|ibm-plex-mono:400,500" rel="stylesheet" />
<!-- Laufzeit-Konfiguration (von nginx aus ENV erzeugt; im Dev leer) -->
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API an den admin-api Container weiterreichen ( /api/accounts -> /accounts )
location /api/ {
proxy_pass http://admin-api:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Authorization $http_authorization;
}
# config.js nicht cachen (enthält Laufzeit-Konfiguration)
location = /config.js {
add_header Cache-Control "no-store";
}
# SPA-Fallback für react-admin Routing
location / {
try_files $uri $uri/ /index.html;
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "dms-admin-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "React-Admin Oberfläche für docker-mailserver (Stil: OPENBUREAU).",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.45.0",
"ra-data-simple-rest": "^5.4.0",
"react": "^18.3.1",
"react-admin": "^5.4.0",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11"
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Admin, Resource, CustomRoutes } from 'react-admin';
import { Route } from 'react-router-dom';
import EmailIcon from '@mui/icons-material/Email';
import AltRouteIcon from '@mui/icons-material/AltRoute';
import { dataProvider } from './dataProvider';
import { authProvider } from './authProvider';
import { AccountList, AccountCreate, AccountEdit } from './resources/accounts';
import { AliasList, AliasCreate, AliasEdit } from './resources/aliases';
import { StatusPage } from './Status';
import { ServerPage } from './Server';
import { SettingsPage } from './Settings';
import { Layout } from './Layout';
import { Dashboard } from './Dashboard';
import { openbureauTheme } from './theme';
const App = () => (
<Admin
title="Mail Admin"
dataProvider={dataProvider}
authProvider={authProvider}
layout={Layout}
dashboard={Dashboard}
theme={openbureauTheme}
requireAuth
>
<Resource
name="accounts"
options={{ label: 'Postfächer' }}
icon={EmailIcon}
list={AccountList}
create={AccountCreate}
edit={AccountEdit}
/>
<Resource
name="aliases"
options={{ label: 'Aliase' }}
icon={AltRouteIcon}
list={AliasList}
create={AliasCreate}
edit={AliasEdit}
/>
<CustomRoutes>
<Route path="/status" element={<StatusPage />} />
<Route path="/server" element={<ServerPage />} />
<Route path="/settings" element={<SettingsPage />} />
</CustomRoutes>
</Admin>
);
export default App;
+65
View File
@@ -0,0 +1,65 @@
import { useGetOne, useGetList, Link } from 'react-admin';
import { Card, CardContent, Typography, Box, Stack, Chip, Button } from '@mui/material';
import EmailIcon from '@mui/icons-material/Email';
import AltRouteIcon from '@mui/icons-material/AltRoute';
import DnsIcon from '@mui/icons-material/Dns';
import LanguageIcon from '@mui/icons-material/Language';
const Stat = ({ icon, value, label, color }) => (
<Card sx={{ flex: 1, minWidth: 160 }}>
<CardContent>
<Stack direction="row" spacing={1.5} alignItems="center">
<Box sx={{ color: color || 'primary.main', display: 'flex' }}>{icon}</Box>
<Box>
<Typography variant="h4" sx={{ lineHeight: 1 }}>{value ?? ''}</Typography>
<Typography variant="body2" color="text.secondary">{label}</Typography>
</Box>
</Stack>
</CardContent>
</Card>
);
export const Dashboard = () => {
const { data: status } = useGetOne('status', { id: 'status' });
const { total: accountsTotal } = useGetList('accounts', { pagination: { page: 1, perPage: 1 } });
const { total: aliasesTotal } = useGetList('aliases', { pagination: { page: 1, perPage: 1 } });
const domains = status?.domains || [];
return (
<Box sx={{ p: { xs: 1, sm: 2 } }}>
<Typography variant="h3" sx={{ mb: 0.5 }}>{status?.brand || 'Mailserver'}</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{status?.fqdn ? `Host ${status.fqdn}` : 'Übersicht'}
</Typography>
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 2 }}>
<Stat icon={<EmailIcon />} value={accountsTotal ?? status?.accounts} label="Postfächer" />
<Stat icon={<AltRouteIcon />} value={aliasesTotal ?? status?.aliases} label="Aliase" color="secondary.main" />
<Stat icon={<LanguageIcon />} value={domains.length} label="Domains" color="success.main" />
</Stack>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>Domains</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
{domains.length
? domains.map((d) => <Chip key={d} label={d} variant="outlined" />)
: <Typography color="text.secondary">Keine Domains konfiguriert.</Typography>}
</Stack>
</CardContent>
</Card>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', gap: 1 }}>
<Button component={Link} to="/accounts" variant="contained" startIcon={<EmailIcon />}>
Postfächer verwalten
</Button>
<Button component={Link} to="/aliases" variant="outlined" startIcon={<AltRouteIcon />}>
Aliase
</Button>
<Button component={Link} to="/status" variant="outlined" startIcon={<DnsIcon />}>
Status & DNS
</Button>
</Stack>
</Box>
);
};
+16
View File
@@ -0,0 +1,16 @@
import { Layout as RaLayout, Menu } from 'react-admin';
import DnsIcon from '@mui/icons-material/Dns';
import StorageIcon from '@mui/icons-material/Storage';
import SettingsIcon from '@mui/icons-material/Settings';
// Eigenes Menü: Standard-Ressourcen + Status-, Server- und Einstellungen-Seite.
const AppMenu = () => (
<Menu>
<Menu.ResourceItems />
<Menu.Item to="/status" primaryText="Status & DNS" leftIcon={<DnsIcon />} />
<Menu.Item to="/server" primaryText="Server" leftIcon={<StorageIcon />} />
<Menu.Item to="/settings" primaryText="Einstellungen" leftIcon={<SettingsIcon />} />
</Menu>
);
export const Layout = (props) => <RaLayout {...props} menu={AppMenu} />;
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState, useCallback } from 'react';
import { Title } from 'react-admin';
import {
Card, CardContent, Typography, Box, Stack, Chip, LinearProgress,
Table, TableHead, TableRow, TableCell, TableBody, Button, Alert,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import { apiFetch } from './dataProvider';
const fmtBytes = (b) => {
if (b == null) return '';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0; let n = b;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
};
export const ServerPage = () => {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
apiFetch('/mailserver/overview')
.then((r) => { setData(r.json); setError(null); })
.catch((e) => setError(e?.body?.error || e?.message || 'Bridge nicht erreichbar'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
return (
<Box sx={{ p: { xs: 1, sm: 2 } }}>
<Title title="Server" />
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h3">Server</Typography>
<Button onClick={load} startIcon={<RefreshIcon />} variant="outlined">Aktualisieren</Button>
</Stack>
{error && (
<Alert severity="warning" sx={{ mb: 2 }}>
Mailserver-Bridge nicht erreichbar: {error}
</Alert>
)}
{loading && <LinearProgress sx={{ mb: 2 }} />}
{data && (
<>
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 2 }}>
<Card sx={{ flex: 1, minWidth: 200 }}>
<CardContent>
<Typography variant="h6" gutterBottom>Mail-Queue</Typography>
{data.queue?.empty
? <Chip color="success" label="Leer" />
: <Chip color="warning" label={`${data.queue?.count ?? '?'} in Warteschlange`} />}
</CardContent>
</Card>
<Card sx={{ flex: 1, minWidth: 200 }}>
<CardContent>
<Typography variant="h6" gutterBottom>Aktive Sessions</Typography>
<Typography variant="h4">{data.who?.sessions?.length ?? 0}</Typography>
<Typography variant="body2" color="text.secondary">IMAP/POP-Verbindungen gerade</Typography>
</CardContent>
</Card>
</Stack>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Quota-Auslastung</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Postfach</TableCell>
<TableCell align="right">Belegt</TableCell>
<TableCell align="right">Limit</TableCell>
<TableCell sx={{ width: 180 }}>Auslastung</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(data.quota || []).map((q) => (
<TableRow key={q.user}>
<TableCell>{q.user}</TableCell>
<TableCell align="right">{fmtBytes(q.usedBytes)}</TableCell>
<TableCell align="right">{q.limitBytes == null ? '∞' : fmtBytes(q.limitBytes)}</TableCell>
<TableCell>
{q.limitBytes == null
? <Typography variant="body2" color="text.secondary">unbegrenzt</Typography>
: <Stack direction="row" spacing={1} alignItems="center">
<LinearProgress variant="determinate" value={Math.min(q.percent || 0, 100)}
sx={{ flex: 1, height: 8, borderRadius: 4 }} />
<Typography variant="caption">{q.percent ?? 0}%</Typography>
</Stack>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)}
</Box>
);
};
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState, useCallback } from 'react';
import { Title, useNotify } from 'react-admin';
import {
Card, CardContent, Typography, Box, Stack, TextField, Button, Chip,
Divider, InputAdornment,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import SaveIcon from '@mui/icons-material/Save';
import { apiFetch } from './dataProvider';
export const SettingsPage = () => {
const notify = useNotify();
const [s, setS] = useState(null);
const [newDomain, setNewDomain] = useState('');
const load = useCallback(() => {
apiFetch('/settings').then((r) => setS(r.json)).catch(() => notify('Einstellungen nicht ladbar', { type: 'warning' }));
}, [notify]);
useEffect(() => { load(); }, [load]);
const set = (k) => (e) => setS((v) => ({ ...v, [k]: e.target.value }));
const save = () => {
apiFetch('/settings/settings', { method: 'PUT', body: JSON.stringify(s) })
.then((r) => { setS(r.json); notify('Gespeichert', { type: 'success' }); })
.catch((e) => notify(e?.body?.error || 'Speichern fehlgeschlagen', { type: 'warning' }));
};
const addDomain = () => {
const d = newDomain.trim().toLowerCase();
if (!d) return;
apiFetch('/settings/domains', { method: 'POST', body: JSON.stringify({ domain: d }) })
.then((r) => { setS(r.json); setNewDomain(''); notify(`${d} hinzugefügt DKIM wird erzeugt`, { type: 'success' }); })
.catch((e) => notify(e?.body?.error || 'Domain konnte nicht hinzugefügt werden', { type: 'warning' }));
};
const removeDomain = (d) => {
apiFetch(`/settings/domains/${encodeURIComponent(d)}`, { method: 'DELETE' })
.then((r) => { setS(r.json); notify(`${d} entfernt`, { type: 'info' }); })
.catch(() => notify('Entfernen fehlgeschlagen', { type: 'warning' }));
};
if (!s) return <Card><CardContent>Lädt </CardContent></Card>;
return (
<Box sx={{ p: { xs: 1, sm: 2 }, maxWidth: 760 }}>
<Title title="Einstellungen" />
<Typography variant="h3" sx={{ mb: 2 }}>Einstellungen</Typography>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>Mail-Domains</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Domains, für die Postfächer/Adressen angelegt werden können. Hinzufügen erzeugt direkt den DKIM-Schlüssel.
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 2 }}>
{(s.domains || []).map((d) => (
<Chip key={d} label={d} onDelete={() => removeDomain(d)}
color={d === s.primaryDomain ? 'primary' : 'default'}
variant={d === s.primaryDomain ? 'filled' : 'outlined'} />
))}
</Stack>
<Stack direction="row" spacing={1}>
<TextField size="small" placeholder="neue-domain.tld" value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
InputProps={{ startAdornment: <InputAdornment position="start">@</InputAdornment> }} />
<Button onClick={addDomain} startIcon={<AddIcon />} variant="contained">Hinzufügen</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Allgemein</Typography>
<Stack spacing={2} sx={{ maxWidth: 480 }}>
<TextField label="Anzeigename / Brand" value={s.brand || ''} onChange={set('brand')} fullWidth />
<TextField label="Mailserver-FQDN" value={s.fqdn || ''} onChange={set('fqdn')} fullWidth
helperText="Host, zu dem sich Clients/Webmail verbinden (= MX-Ziel)" />
<TextField label="Primäre Domain" value={s.primaryDomain || ''} onChange={set('primaryDomain')} fullWidth />
<Divider />
<TextField label="Webmail-Domain (NPM)" value={s.webmailFqdn || ''} onChange={set('webmailFqdn')} fullWidth />
<TextField label="Admin-UI-Domain (NPM)" value={s.adminFqdn || ''} onChange={set('adminFqdn')} fullWidth />
</Stack>
<Box sx={{ mt: 2 }}>
<Button onClick={save} startIcon={<SaveIcon />} variant="contained">Speichern</Button>
</Box>
</CardContent>
</Card>
</Box>
);
};
+72
View File
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { useGetOne, Title, useNotify } from 'react-admin';
import { Card, CardContent, Typography, Box, Chip, Stack, Divider, Button } from '@mui/material';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import { apiFetch } from './dataProvider';
const Mono = ({ children }) => (
<Box
component="pre"
sx={{
background: '#1e1e1e', color: '#e0e0e0', p: 1.5, borderRadius: 1, m: 0,
overflowX: 'auto', fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}
>
{children}
</Box>
);
const DkimButton = ({ domain, onDone }) => {
const [busy, setBusy] = useState(false);
const notify = useNotify();
const gen = () => {
setBusy(true);
apiFetch('/mailserver/dkim', { method: 'POST', body: JSON.stringify({ domain }) })
.then(() => { notify(`DKIM für ${domain} erzeugt`, { type: 'success' }); onDone?.(); })
.catch((e) => notify(e?.body?.error || 'DKIM-Erzeugung fehlgeschlagen', { type: 'warning' }))
.finally(() => setBusy(false));
};
return (
<Button size="small" startIcon={<VpnKeyIcon />} onClick={gen} disabled={busy} sx={{ mt: 1 }}>
{busy ? 'Erzeuge …' : 'DKIM erzeugen / erneuern'}
</Button>
);
};
export const StatusPage = () => {
const { data, isLoading, error, refetch } = useGetOne('status', { id: 'status' });
if (isLoading) return <Card><CardContent>Lädt </CardContent></Card>;
if (error) return <Card><CardContent>Fehler beim Laden des Status.</CardContent></Card>;
return (
<Card>
<Title title="Status & DNS" />
<CardContent>
<Typography variant="h6" gutterBottom>Mailserver</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Chip label={`Host: ${data.fqdn}`} />
<Chip color="primary" label={`${data.accounts} Postfächer`} />
<Chip color="secondary" label={`${data.aliases} Aliase`} />
{(data.domains || []).map((d) => <Chip key={d} variant="outlined" label={d} />)}
</Stack>
<Typography variant="h6" gutterBottom>DNS Mailhost (einmalig)</Typography>
<Mono>{[data.host?.a, data.host?.ptr].filter(Boolean).join('\n')}</Mono>
{(data.records || []).map((r) => (
<Box key={r.domain} sx={{ mt: 3 }}>
<Divider sx={{ mb: 1 }} />
<Typography variant="h6" gutterBottom>{r.domain}</Typography>
<Mono>{[r.mx, r.spf, r.dmarc].join('\n')}</Mono>
<Typography variant="subtitle2" sx={{ mt: 1 }}>DKIM (TXT)</Typography>
{r.dkim
? <Mono>{r.dkim}</Mono>
: <Typography color="text.secondary" variant="body2">Noch kein DKIM-Schlüssel.</Typography>}
<DkimButton domain={r.domain} onDone={refetch} />
</Box>
))}
</CardContent>
</Card>
);
};
+47
View File
@@ -0,0 +1,47 @@
import { supabase } from './supabaseClient';
// Dev-Bypass: nur lokal (config.js / window.__CONFIG__.AUTH_DISABLED), nie im Deploy.
const cfg = (typeof window !== 'undefined' && window.__CONFIG__) || {};
const AUTH_DISABLED = String(cfg.AUTH_DISABLED) === 'true';
// AuthProvider auf Basis von Supabase (wie OPENBUREAU).
export const authProvider = {
async login({ username, password }) {
if (AUTH_DISABLED) return;
const { error } = await supabase.auth.signInWithPassword({
email: username,
password,
});
if (error) throw new Error(error.message);
},
async logout() {
if (!AUTH_DISABLED && supabase) await supabase.auth.signOut();
},
async checkAuth() {
if (AUTH_DISABLED) return;
const { data } = await supabase.auth.getSession();
if (!data?.session) throw new Error('Nicht angemeldet');
},
async checkError(error) {
if (AUTH_DISABLED) return;
const status = error?.status;
if (status === 401 || status === 403) {
await supabase.auth.signOut();
throw new Error('Sitzung abgelaufen');
}
},
async getIdentity() {
if (AUTH_DISABLED) return { id: 'dev', fullName: 'Dev (Auth aus)' };
const { data } = await supabase.auth.getUser();
const user = data?.user;
return { id: user?.id || 'me', fullName: user?.email || 'Admin' };
},
async getPermissions() {
return 'admin';
},
};
+20
View File
@@ -0,0 +1,20 @@
import simpleRestProvider from 'ra-data-simple-rest';
import { fetchUtils } from 'react-admin';
import { supabase } from './supabaseClient';
// HTTP-Client, der das Supabase-Access-Token als Bearer mitschickt.
const httpClient = async (url, options = {}) => {
const headers = new Headers(options.headers || { Accept: 'application/json' });
if (supabase) {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
if (token) headers.set('Authorization', `Bearer ${token}`);
}
return fetchUtils.fetchJson(url, { ...options, headers });
};
// nginx proxyt /api -> admin-api (Prod); Vite proxyt /api -> :3000 (Dev).
export const dataProvider = simpleRestProvider('/api', httpClient);
// Helfer für eigene Endpunkte (Mailserver-Bridge): apiFetch('/mailserver/overview')
export const apiFetch = (path, options) => httpClient('/api' + path, options);
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+48
View File
@@ -0,0 +1,48 @@
import {
List, Datagrid, TextField, EmailField,
Create, Edit, SimpleForm, TextInput, PasswordInput,
required, email, EditButton, DeleteButton, SearchInput,
} from 'react-admin';
const accountFilters = [<SearchInput key="q" source="q" alwaysOn />];
// Validatoren
const quotaValidate = (v) =>
!v || /^\d+\s*[KMGT]?$/i.test(v) ? undefined : 'Format z.B. 5G, 500M (leer = unbegrenzt)';
const min8 = (v) => (v && v.length >= 8 ? undefined : 'Mindestens 8 Zeichen');
const min8Optional = (v) => (!v || v.length >= 8 ? undefined : 'Mindestens 8 Zeichen');
export const AccountList = () => (
<List filters={accountFilters} sort={{ field: 'email', order: 'ASC' }} perPage={25}>
<Datagrid rowClick="edit" bulkActionButtons={false}>
<EmailField source="email" label="Postfach" />
<TextField source="quota" label="Quota" emptyText="∞" />
<EditButton />
<DeleteButton />
</Datagrid>
</List>
);
export const AccountCreate = () => (
<Create redirect="list">
<SimpleForm>
<TextInput source="email" label="E-Mail-Adresse" validate={[required(), email()]} fullWidth
helperText="z.B. max@example.com" />
<PasswordInput source="password" label="Passwort" validate={[required(), min8]}
helperText="Mindestens 8 Zeichen" />
<TextInput source="quota" label="Quota" validate={quotaValidate}
helperText="z.B. 5G, 500M leer = unbegrenzt" />
</SimpleForm>
</Create>
);
export const AccountEdit = () => (
<Edit redirect="list">
<SimpleForm>
<TextInput source="email" label="E-Mail-Adresse" disabled fullWidth />
<PasswordInput source="password" label="Neues Passwort (leer = unverändert)" validate={min8Optional} />
<TextInput source="quota" label="Quota" validate={quotaValidate}
helperText="z.B. 5G, 500M leer = unbegrenzt" />
</SimpleForm>
</Edit>
);
+44
View File
@@ -0,0 +1,44 @@
import {
List, Datagrid, TextField,
Create, Edit, SimpleForm, TextInput,
required, EditButton, DeleteButton, SearchInput,
} from 'react-admin';
const aliasFilters = [<SearchInput key="q" source="q" alwaysOn />];
// Quelle: vollständige E-Mail ODER @domain.tld (Catch-all)
const aliasSource = (v) =>
/^([^@\s]+)?@[^@\s]+\.[^@\s]+$/.test(v || '') ? undefined
: 'Vollständige E-Mail oder @domain.tld (Catch-all)';
export const AliasList = () => (
<List filters={aliasFilters} sort={{ field: 'source', order: 'ASC' }} perPage={25}>
<Datagrid rowClick="edit" bulkActionButtons={false}>
<TextField source="source" label="Alias" />
<TextField source="destination" label="Ziel(e)" />
<EditButton />
<DeleteButton />
</Datagrid>
</List>
);
export const AliasCreate = () => (
<Create redirect="list">
<SimpleForm>
<TextInput source="source" label="Alias-Adresse" validate={[required(), aliasSource]} fullWidth
helperText="z.B. info@example.com — oder @example.com als Catch-all (fängt alle unbekannten Adressen der Domain)" />
<TextInput source="destination" label="Ziel(e)" validate={required()} fullWidth
helperText="Eine oder mehrere Zieladressen, mit Komma getrennt" />
</SimpleForm>
</Create>
);
export const AliasEdit = () => (
<Edit redirect="list">
<SimpleForm>
<TextInput source="source" label="Alias-Adresse" disabled fullWidth />
<TextInput source="destination" label="Ziel(e)" validate={required()} fullWidth
helperText="Mehrere mit Komma getrennt" />
</SimpleForm>
</Edit>
);
+17
View File
@@ -0,0 +1,17 @@
import { createClient } from '@supabase/supabase-js';
// Konfiguration kommt zur Laufzeit aus /config.js (window.__CONFIG__),
// im Dev-Modus aus den VITE_* Umgebungsvariablen.
const cfg = (typeof window !== 'undefined' && window.__CONFIG__) || {};
const url = cfg.SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL || '';
const key = cfg.SUPABASE_ANON_KEY || import.meta.env.VITE_SUPABASE_ANON_KEY || '';
if (!url || !key) {
// eslint-disable-next-line no-console
console.warn('[admin] Supabase URL/Key fehlen — Login wird nicht funktionieren.');
}
// Kein Client, wenn keine Konfiguration vorhanden ist (z.B. lokaler Dev-Bypass).
export const supabase = (url && key)
? createClient(url, key, { auth: { persistSession: true, autoRefreshToken: true } })
: null;
+138
View File
@@ -0,0 +1,138 @@
// ============================================================
// Admin-Theme im OPENBUREAU-Look (warmes Cream-Design-System)
// Akzent: petrol #7BA89B (auf Wunsch, statt OPENBUREAU-Terrakotta)
// ============================================================
const accent = '#7BA89B'; // petrol
const accentDeep = '#557A6D';
const accentRing = 'rgba(123,168,155,0.20)';
const fonts = {
sans: '"Inter", system-ui, -apple-system, sans-serif',
serif: '"Newsreader", Georgia, serif',
display: '"Space Grotesk", "Inter", sans-serif',
mono: '"IBM Plex Mono", ui-monospace, monospace',
};
const BG = 'hsl(35, 14%, 96%)'; // warmes Off-White
const PANEL = '#fffdf9';
const PANEL2 = 'hsl(35, 14%, 93%)';
const LINE = 'hsl(35, 14%, 86%)';
const TEXT = 'hsl(25, 18%, 12%)';
const MUTED = 'hsl(25, 8%, 42%)';
const DARK = '#191919';
const SHADOW = '0 10px 34px -22px rgba(40,20,10,.5)';
export const openbureauTheme = {
palette: {
mode: 'light',
primary: { main: accent, dark: accentDeep, contrastText: '#ffffff' },
secondary: { main: accentDeep, contrastText: '#ffffff' },
background: { default: BG, paper: PANEL },
text: { primary: TEXT, secondary: MUTED },
divider: LINE,
success: { main: '#5d7d4b' },
warning: { main: '#b8902f' },
error: { main: '#b54a2c' },
},
shape: { borderRadius: 11 },
typography: {
fontFamily: fonts.sans,
fontWeightMedium: 500,
h1: { fontFamily: fonts.serif, fontWeight: 600 },
h2: { fontFamily: fonts.serif, fontWeight: 600 },
h3: { fontFamily: fonts.serif, fontWeight: 600 },
h4: { fontFamily: fonts.serif, fontWeight: 600 },
h5: { fontFamily: fonts.serif, fontWeight: 600 },
h6: { fontFamily: fonts.serif, fontWeight: 600 },
},
components: {
// dunkle Topbar wie OPENBUREAU
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: DARK,
color: '#f0f0f0',
boxShadow: 'none',
backgroundImage: 'none',
borderBottom: 'none',
},
},
},
RaAppBar: {
styleOverrides: {
root: {
'& .RaAppBar-title': {
fontFamily: fonts.display,
fontWeight: 700,
letterSpacing: '0.12em',
},
},
},
},
// Pill-Buttons (22px), keine Versalien
MuiButton: {
styleOverrides: {
root: { borderRadius: 22, textTransform: 'none', fontWeight: 500, paddingLeft: 16, paddingRight: 16 },
containedPrimary: { color: '#fff', '&:hover': { backgroundColor: accentDeep } },
outlined: { borderColor: LINE, backgroundColor: PANEL },
},
},
MuiPaper: { styleOverrides: { root: { backgroundImage: 'none' } } },
MuiCard: {
styleOverrides: {
root: { border: `1px solid ${LINE}`, boxShadow: SHADOW, borderRadius: 11, backgroundColor: PANEL },
},
},
// Inputs: Radius 9, Petrol-Fokusring
MuiOutlinedInput: {
styleOverrides: {
root: {
borderRadius: 9,
backgroundColor: PANEL,
'& .MuiOutlinedInput-notchedOutline': { borderColor: LINE },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: accent, borderWidth: 1 },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${accentRing}` },
},
},
},
// Sidebar warm, aktives Item mit Petrol-Kante
RaSidebar: {
styleOverrides: {
root: { backgroundColor: PANEL2, borderRight: `1px solid ${LINE}` },
},
},
RaMenuItemLink: {
styleOverrides: {
root: {
borderRadius: 11,
margin: '3px 6px',
paddingLeft: 10,
paddingRight: 10,
color: MUTED,
'&:hover': { backgroundColor: 'rgba(0,0,0,0.05)' },
// klarer Petrol-Akzent im aktiven Zustand (auch eingeklappt sichtbar)
'&.RaMenuItemLink-active': {
backgroundColor: accentRing,
color: accentDeep,
fontWeight: 600,
},
// Icon in der eingeklappten Sidebar mittig
'& .RaMenuItemLink-icon': {
color: 'inherit',
minWidth: 32,
justifyContent: 'center',
},
},
},
},
RaLayout: { styleOverrides: { root: { backgroundColor: BG } } },
RaDatagrid: {
styleOverrides: {
root: {
'& .RaDatagrid-headerCell': { fontWeight: 600, color: MUTED, backgroundColor: 'transparent' },
'& .RaDatagrid-rowCell': { borderBottomColor: LINE },
},
},
},
},
};
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// In der Produktion serviert nginx die statischen Dateien und proxyt /api
// an den admin-api Container. Im Dev-Modus proxyen wir /api lokal.
export default defineConfig({
plugins: [react()],
base: '/',
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
});
+3
View File
@@ -0,0 +1,3 @@
node_modules
npm-debug.log
.env
+17
View File
@@ -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"]
+60
View File
@@ -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 });
}
}
+106
View File
@@ -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 };
}
+77
View File
@@ -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 };
});
}
+229
View File
@@ -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;
}
+20
View File
@@ -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"
}
}
+163
View File
@@ -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}`));
+114
View File
@@ -0,0 +1,114 @@
# ============================================================================
# docker-mailserver Stack
# mailserver Postfix/Dovecot/Rspamd (docker-mailserver)
# admin-api Node.js API (Supabase-Auth) verwaltet DMS-Config-Dateien
# admin-ui React-Admin Oberfläche (nginx, proxyt /api -> admin-api)
# snappymail schlankes Webmail für die Mitarbeiter
#
# TLS/HTTPS für admin-ui & snappymail macht der Nginx Proxy Manager
# (separater Stack), der auf ADMIN_PORT / WEBMAIL_PORT dieses Hosts zeigt.
# ============================================================================
services:
mailserver:
image: ghcr.io/docker-mailserver/docker-mailserver:${DMS_TAG:-latest}
container_name: mailserver
hostname: ${MAIL_FQDN}
env_file: mailserver.env
environment:
- OVERRIDE_HOSTNAME=${MAIL_FQDN}
- POSTMASTER_ADDRESS=postmaster@${MAIL_DOMAIN}
ports:
- "25:25" # SMTP (eingehender MX-Verkehr)
- "143:143" # IMAP (STARTTLS)
- "465:465" # SMTP Submission (implicit TLS)
- "587:587" # SMTP Submission (STARTTLS)
- "993:993" # IMAP (implicit TLS)
- "${RSPAMD_PORT:-11334}:11334" # Rspamd Web-UI — NUR über NPM/Firewall öffnen!
volumes:
- ./docker-data/dms/mail-data/:/var/mail/
- ./docker-data/dms/mail-state/:/var/mail-state/
- ./docker-data/dms/mail-logs/:/var/log/mail/
- ./docker-data/dms/config/:/tmp/docker-mailserver/
- ./docker-data/certs/:/etc/letsencrypt/:ro # Zertifikate (NPM DNS-Challenge)
- /etc/localtime:/etc/localtime:ro
restart: always
stop_grace_period: 1m
cap_add:
- NET_ADMIN # für Fail2ban
healthcheck:
test: "ss --listening --tcp | grep -P 'LISTEN.+:smtp' || exit 1"
timeout: 3s
retries: 0
# docker-socket-proxy: gibt der Admin-API NUR exec frei (kein create/delete/...)
socket-proxy:
image: tecnativa/docker-socket-proxy:latest
container_name: dms-socket-proxy
restart: always
security_opt:
- no-new-privileges:true
environment:
- CONTAINERS=1
- EXEC=1
- POST=1
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
admin-api:
build: ./api
container_name: dms-admin-api
restart: always
security_opt:
- no-new-privileges:true
environment:
- CONFIG_DIR=/config
- MAIL_DOMAIN=${MAIL_DOMAIN}
- MAIL_DOMAINS=${MAIL_DOMAINS}
- MAIL_FQDN=${MAIL_FQDN}
- BRAND=${BRAND}
- WEBMAIL_FQDN=${WEBMAIL_FQDN}
- ADMIN_FQDN=${ADMIN_FQDN}
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
- ADMIN_ALLOWED_EMAILS=${ADMIN_ALLOWED_EMAILS}
# Bridge zum Mailserver: nur exec über den socket-proxy, Whitelist in der API
- DOCKER_PROXY=socket-proxy:2375
- MAILSERVER_CONTAINER=mailserver
depends_on:
- socket-proxy
volumes:
# Schreibzugriff auf die DMS-Config-Dateien (kein direkter Docker-Socket!)
- ./docker-data/dms/config/:/config/
expose:
- "3000"
admin-ui:
build:
context: ./admin
args:
# Werden zur Laufzeit via /config.js injiziert (siehe entrypoint)
- VITE_SUPABASE_URL=${SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
container_name: dms-admin-ui
restart: always
security_opt:
- no-new-privileges:true
depends_on:
- admin-api
environment:
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
ports:
- "${ADMIN_PORT:-8080}:80"
snappymail:
image: djmaze/snappymail:latest
container_name: snappymail
restart: always
security_opt:
- no-new-privileges:true
ports:
- "${WEBMAIL_PORT:-8888}:8888"
volumes:
- ./docker-data/snappymail/:/var/lib/snappymail/ # echter Datenpfad der djmaze-Image
- ./snappymail-theme/:/snappymail/themes/:ro # KGVA "Shibui"-Theme
+32
View File
@@ -0,0 +1,32 @@
# ============================================================================
# docker-mailserver — statische Feature-Flags
# (Hostname/Domain kommen über docker-compose aus der .env)
# Vollständige Referenz: https://docker-mailserver.github.io/docker-mailserver/latest/config/environment/
# ============================================================================
TZ=Europe/Zurich
LOG_LEVEL=info
# --- TLS ---
# Fester Cert-Pfad (gemountet aus ./docker-data/certs nach /etc/letsencrypt).
# Das Setup-Skript legt dort beim ersten Start ein SELF-SIGNED-Zertifikat ab,
# damit STARTTLS sofort funktioniert. Für ein echtes Zertifikat einfach diese
# zwei Dateien durch das NPM-Let's-Encrypt-Cert ersetzen (cert.pem=fullchain,
# key.pem=privkey) und `docker compose restart mailserver` — KEINE Änderung hier.
# Siehe README, Abschnitt "TLS mit Nginx Proxy Manager".
SSL_TYPE=manual
SSL_CERT_PATH=/etc/letsencrypt/cert.pem
SSL_KEY_PATH=/etc/letsencrypt/key.pem
# --- Spam / Antivirus ---
ENABLE_RSPAMD=1
RSPAMD_GREYLISTING=1
ENABLE_OPENDKIM=0
ENABLE_OPENDMARC=0
ENABLE_CLAMAV=0
ENABLE_FAIL2BAN=1
# --- Sicherheit / Submission ---
ONE_DIR=1
PERMIT_DOCKER=none
SPOOF_PROTECTION=1
File diff suppressed because it is too large Load Diff