diff --git a/internal/api/cert_handlers.go b/internal/api/cert_handlers.go new file mode 100644 index 0000000..7daa30f --- /dev/null +++ b/internal/api/cert_handlers.go @@ -0,0 +1,365 @@ +package api + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + certDir = "/etc/ssl/archivmail" + certPath = "/etc/ssl/archivmail/archivmail.crt" + keyPath = "/etc/ssl/archivmail/archivmail.key" +) + +// certInfoResponse holds the parsed certificate metadata returned by GET /api/admin/cert/info. +type certInfoResponse struct { + Exists bool `json:"exists"` + Subject string `json:"subject,omitempty"` + Issuer string `json:"issuer,omitempty"` + NotBefore time.Time `json:"not_before,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + DNSNames []string `json:"dns_names,omitempty"` + IPAddresses []string `json:"ip_addresses,omitempty"` + FingerprintSHA256 string `json:"fingerprint_sha256,omitempty"` + IsSelfSigned bool `json:"is_self_signed,omitempty"` + DaysRemaining int `json:"days_remaining,omitempty"` +} + +// handleCertInfo returns metadata about the currently installed TLS certificate. +// GET /api/admin/cert/info +func (s *Server) handleCertInfo(w http.ResponseWriter, r *http.Request) { + info, err := readCertInfo() + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read certificate: %v", err)) + return + } + writeJSON(w, http.StatusOK, info) +} + +// readCertInfo reads and parses the certificate file at certPath. +// Returns {"exists": false} when the file is absent; an error only on read/parse failures. +func readCertInfo() (*certInfoResponse, error) { + raw, err := os.ReadFile(certPath) + if os.IsNotExist(err) { + return &certInfoResponse{Exists: false}, nil + } + if err != nil { + return nil, fmt.Errorf("read cert file: %w", err) + } + + block, _ := pem.Decode(raw) + if block == nil { + return nil, fmt.Errorf("no PEM block found in certificate file") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + + ips := make([]string, 0, len(cert.IPAddresses)) + for _, ip := range cert.IPAddresses { + ips = append(ips, ip.String()) + } + + sum := sha256.Sum256(cert.Raw) + parts := make([]string, 32) + for i, b := range sum { + parts[i] = fmt.Sprintf("%02X", b) + } + fingerprint := strings.Join(parts, ":") + + isSelfSigned := cert.Subject.String() == cert.Issuer.String() + daysRemaining := int(time.Until(cert.NotAfter).Hours() / 24) + + dnsNames := cert.DNSNames + if dnsNames == nil { + dnsNames = []string{} + } + if ips == nil { + ips = []string{} + } + + return &certInfoResponse{ + Exists: true, + Subject: cert.Subject.String(), + Issuer: cert.Issuer.String(), + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + DNSNames: dnsNames, + IPAddresses: ips, + FingerprintSHA256: fingerprint, + IsSelfSigned: isSelfSigned, + DaysRemaining: daysRemaining, + }, nil +} + +// handleCertUpload validates and installs an uploaded PEM certificate + key pair. +// POST /api/admin/cert/upload (multipart/form-data fields: cert, key) +func (s *Server) handleCertUpload(w http.ResponseWriter, r *http.Request) { + // Limit to 4 MB to prevent abuse; real PEM files are a few KB at most. + if err := r.ParseMultipartForm(4 << 20); err != nil { + writeError(w, http.StatusBadRequest, "failed to parse multipart form") + return + } + + certPEM, err := readMultipartField(r, "cert") + if err != nil { + writeError(w, http.StatusBadRequest, "missing or unreadable 'cert' field") + return + } + keyPEM, err := readMultipartField(r, "key") + if err != nil { + writeError(w, http.StatusBadRequest, "missing or unreadable 'key' field") + return + } + + // Validate that cert and key form a valid pair before touching disk. + if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("certificate and key do not match: %v", err)) + return + } + + if err := writeCertFiles(certPEM, keyPEM); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write certificate files: %v", err)) + return + } + + if out, err := reloadNginx(); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, out)) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "message": "Zertifikat gespeichert und nginx neu geladen", + }) +} + +// handleCertSelfSigned generates a new self-signed RSA-4096 certificate using Go's crypto/x509. +// POST /api/admin/cert/self-signed +func (s *Server) handleCertSelfSigned(w http.ResponseWriter, r *http.Request) { + var req struct { + CommonName string `json:"common_name"` + DNSNames []string `json:"dns_names"` + IPAddresses []string `json:"ip_addresses"` + ValidityYears int `json:"validity_years"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.CommonName == "" { + writeError(w, http.StatusBadRequest, "common_name is required") + return + } + if req.ValidityYears <= 0 || req.ValidityYears > 30 { + req.ValidityYears = 10 + } + + // Parse and validate IP addresses. + ips := make([]net.IP, 0, len(req.IPAddresses)) + for _, addr := range req.IPAddresses { + ip := net.ParseIP(addr) + if ip == nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid IP address: %s", addr)) + return + } + ips = append(ips, ip) + } + + // Generate RSA-4096 private key. + privKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("key generation failed: %v", err)) + return + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + writeError(w, http.StatusInternalServerError, "serial number generation failed") + return + } + + notBefore := time.Now().UTC() + notAfter := notBefore.AddDate(req.ValidityYears, 0, 0) + + dnsNames := req.DNSNames + if dnsNames == nil { + dnsNames = []string{} + } + + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: req.CommonName, + }, + DNSNames: dnsNames, + IPAddresses: ips, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &privKey.PublicKey, privKey) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("certificate creation failed: %v", err)) + return + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privKey)}) + + if err := writeCertFiles(certPEM, keyPEM); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write certificate files: %v", err)) + return + } + + if out, err := reloadNginx(); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, out)) + return + } + + info, err := readCertInfo() + if err != nil { + writeError(w, http.StatusInternalServerError, "certificate written but could not read back info") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "cert": info, + }) +} + +// handleCertACME runs certbot to obtain a Let's Encrypt certificate via the NGINX plugin. +// POST /api/admin/cert/acme +func (s *Server) handleCertACME(w http.ResponseWriter, r *http.Request) { + var req struct { + Domain string `json:"domain"` + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Domain == "" { + writeError(w, http.StatusBadRequest, "domain is required") + return + } + if req.Email == "" { + writeError(w, http.StatusBadRequest, "email is required") + return + } + + // Verify certbot is available before attempting anything. + certbotPath, err := exec.LookPath("certbot") + if err != nil { + writeError(w, http.StatusInternalServerError, "certbot is not installed on this server") + return + } + + cmd := exec.Command(certbotPath, + "certonly", "--nginx", + "-d", req.Domain, + "--non-interactive", + "--agree-tos", + "-m", req.Email, + ) + out, err := cmd.CombinedOutput() + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot failed: %v\n%s", err, string(out))) + return + } + + // Copy the issued certificates from /etc/letsencrypt/live// to certDir. + lePath := filepath.Join("/etc/letsencrypt/live", req.Domain) + certPEM, err := os.ReadFile(filepath.Join(lePath, "fullchain.pem")) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot succeeded but could not read issued cert: %v", err)) + return + } + keyPEM, err := os.ReadFile(filepath.Join(lePath, "privkey.pem")) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot succeeded but could not read issued key: %v", err)) + return + } + + if err := writeCertFiles(certPEM, keyPEM); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to copy certificates: %v", err)) + return + } + + if nginxOut, err := reloadNginx(); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, nginxOut)) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "output": string(out), + }) +} + +// --- internal helpers --- + +// writeCertFiles creates certDir if necessary and writes cert (0644) + key (0600). +func writeCertFiles(certPEM, keyPEM []byte) error { + if err := os.MkdirAll(certDir, 0755); err != nil { + return fmt.Errorf("create cert dir: %w", err) + } + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { + return fmt.Errorf("write cert: %w", err) + } + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + return fmt.Errorf("write key: %w", err) + } + return nil +} + +// reloadNginx tests nginx config validity, then reloads the service. +// Returns combined stdout+stderr output and any error. +func reloadNginx() (string, error) { + testCmd := exec.Command("nginx", "-t") + testOut, err := testCmd.CombinedOutput() + if err != nil { + return string(testOut), fmt.Errorf("nginx config test failed: %w", err) + } + + reloadCmd := exec.Command("systemctl", "reload", "nginx") + reloadOut, err := reloadCmd.CombinedOutput() + if err != nil { + return string(reloadOut), fmt.Errorf("systemctl reload nginx: %w", err) + } + + return string(testOut) + string(reloadOut), nil +} + +// readMultipartField reads all bytes from a named file field in a parsed multipart form. +func readMultipartField(r *http.Request, field string) ([]byte, error) { + f, _, err := r.FormFile(field) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(f) +} diff --git a/internal/api/server.go b/internal/api/server.go index c2ed688..d8218c3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -198,6 +198,12 @@ func (s *Server) routes() { s.mux.HandleFunc("DELETE /api/auth/totp", s.auth(s.handleTOTPDisable)) s.mux.HandleFunc("POST /api/auth/totp", s.handleTOTPLogin) // no auth middleware — uses pending token s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset)) + + // Certificate management routes (superadmin only) + s.mux.HandleFunc("GET /api/admin/cert/info", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertInfo))) + s.mux.HandleFunc("POST /api/admin/cert/upload", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertUpload))) + s.mux.HandleFunc("POST /api/admin/cert/self-signed", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertSelfSigned))) + s.mux.HandleFunc("POST /api/admin/cert/acme", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertACME))) } // ServeHTTP implements http.Handler. diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 11ce79f..3a4be3a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -60,6 +60,11 @@ import { type TenantDomain, type MailLabel, type LabelRule, + getCertInfo, + uploadCert, + generateSelfSignedCert, + requestACMECert, + type CertInfo, } from "@/lib/api"; import { Navbar } from "@/components/navbar"; import { Button } from "@/components/ui/button"; @@ -247,6 +252,27 @@ export default function AdminPage() { const [newRuleLabelId, setNewRuleLabelId] = useState(null); const [ruleCreating, setRuleCreating] = useState(false); + // Certificate state + const [certInfo, setCertInfo] = useState(null); + const [certLoading, setCertLoading] = useState(false); + const [certError, setCertError] = useState(""); + const [certSuccess, setCertSuccess] = useState(""); + // Upload state + const [certFile, setCertFile] = useState(null); + const [keyFile, setKeyFile] = useState(null); + const [certUploadLoading, setCertUploadLoading] = useState(false); + // Self-signed state + const [selfSignedCN, setSelfSignedCN] = useState("archivmail"); + const [selfSignedDNS, setSelfSignedDNS] = useState("archivmail"); + const [selfSignedIPs, setSelfSignedIPs] = useState("192.168.1.131"); + const [selfSignedYears, setSelfSignedYears] = useState("10"); + const [selfSignedLoading, setSelfSignedLoading] = useState(false); + // ACME state + const [acmeDomain, setAcmeDomain] = useState(""); + const [acmeEmail, setAcmeEmail] = useState(""); + const [acmeLoading, setAcmeLoading] = useState(false); + const [acmeOutput, setAcmeOutput] = useState(""); + const loadDashboard = useCallback(async () => { setDashLoading(true); try { @@ -306,6 +332,19 @@ export default function AdminPage() { } }, []); + const loadCert = useCallback(async () => { + setCertLoading(true); + setCertError(""); + try { + const info = await getCertInfo(); + setCertInfo(info); + } catch (e) { + setCertError(String(e)); + } finally { + setCertLoading(false); + } + }, []); + async function handleUploadFiles(files: File[]) { const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox")); if (valid.length === 0) { @@ -836,6 +875,7 @@ export default function AdminPage() { )} {isSuperAdmin && Labels} {isSuperAdmin && Security} + {isSuperAdmin && Zertifikat} {isSuperAdmin && Mandanten} {isSuperAdmin && Module} @@ -2742,6 +2782,178 @@ export default function AdminPage() { + {/* ── Zertifikat ── */} + {isSuperAdmin && ( + + {/* Aktuelles Zertifikat */} + + +
+

Aktuelles Zertifikat

+ +
+ {certLoading &&
Lade...
} + {certError && {certError}} + {certSuccess && {certSuccess}} + {certInfo && !certLoading && ( + certInfo.exists ? ( +
+
+ Aussteller + {certInfo.issuer} + Subject + {certInfo.subject} + Gueltig bis + + {certInfo.not_after ? new Date(certInfo.not_after).toLocaleDateString("de-DE") : "--"} + {" "}({certInfo.days_remaining} Tage) + + DNS-Namen + {certInfo.dns_names?.join(", ") || "--"} + IP-Adressen + {certInfo.ip_addresses?.join(", ") || "--"} + Typ + {certInfo.is_self_signed ? "Selbstsigniert" : "CA-signiert"} + SHA-256 + {certInfo.fingerprint_sha256} +
+
+ ) : ( +
Kein Zertifikat gefunden unter /etc/ssl/archivmail/
+ ) + )} +
+
+ + {/* Zertifikat hochladen */} + + +

Zertifikat hochladen

+

Eigenes CA-signiertes oder Let's Encrypt Zertifikat hochladen.

+
+
+ + setCertFile(e.target.files?.[0] ?? null)} /> +
+
+ + setKeyFile(e.target.files?.[0] ?? null)} /> +
+ +
+
+
+ + {/* Self-Signed generieren */} + + +

Self-Signed Zertifikat ausstellen

+
+
+ + setSelfSignedCN(e.target.value)} placeholder="archivmail" /> +
+
+ + +
+
+ + setSelfSignedDNS(e.target.value)} placeholder="archivmail,mail.intern" /> +
+
+ + setSelfSignedIPs(e.target.value)} placeholder="192.168.1.131" /> +
+
+ +
+
+ + {/* ACME / Let's Encrypt */} + + +

Let's Encrypt / ACME

+

+ Oeffentlich erreichbare Domain erforderlich (Port 80 muss von aussen erreichbar sein). + certbot muss auf dem Server installiert sein. +

+
+
+ + setAcmeDomain(e.target.value)} placeholder="mail.example.com" /> +
+
+ + setAcmeEmail(e.target.value)} placeholder="admin@example.com" type="email" /> +
+
+ {acmeOutput && ( +
{acmeOutput}
+ )} + +
+
+
+ )} + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1ddaf56..3c40bf1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; export const metadata: Metadata = { @@ -12,9 +13,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + {children} + ); diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index fe1271b..3bcfd9e 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { UserNav } from "@/components/UserNav"; +import { ThemeToggle } from "@/components/theme-toggle"; interface NavbarProps { username: string; @@ -49,7 +50,10 @@ export function Navbar({ username, role }: NavbarProps) { )} - +
+ + +
); diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..1a08eb4 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,16 @@ +"use client" + +import { ThemeProvider as NextThemesProvider } from "next-themes" + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx new file mode 100644 index 0000000..155071d --- /dev/null +++ b/src/components/theme-toggle.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useTheme } from "next-themes" +import { Moon, Sun } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export function ThemeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + Hell + + setTheme("dark")}> + Dunkel + + setTheme("system")}> + System + + + + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 38765a8..a2993f7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -935,3 +935,64 @@ export async function createLabelRule( export async function deleteLabelRule(id: number): Promise { return request(`/api/admin/label-rules/${id}`, { method: "DELETE" }); } + +// ── Certificate Management ──────────────────────────────────────────────── + +export interface CertInfo { + exists: boolean; + subject?: string; + issuer?: string; + not_before?: string; + not_after?: string; + dns_names?: string[]; + ip_addresses?: string[]; + fingerprint_sha256?: string; + is_self_signed?: boolean; + days_remaining?: number; +} + +export interface SelfSignedRequest { + common_name: string; + dns_names: string[]; + ip_addresses: string[]; + validity_years: number; +} + +export interface ACMERequest { + domain: string; + email: string; +} + +export async function getCertInfo(): Promise { + return request("/api/admin/cert/info"); +} + +export async function uploadCert(cert: File, key: File): Promise<{ ok: boolean; message: string }> { + const form = new FormData(); + form.append("cert", cert); + form.append("key", key); + const res = await fetch(`${API_BASE}/api/admin/cert/upload`, { + method: "POST", + credentials: "include", + body: form, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `Upload failed: ${res.status}`); + } + return res.json(); +} + +export async function generateSelfSignedCert(req: SelfSignedRequest): Promise { + return request("/api/admin/cert/self-signed", { + method: "POST", + body: JSON.stringify(req), + }); +} + +export async function requestACMECert(req: ACMERequest): Promise<{ ok: boolean; output: string }> { + return request<{ ok: boolean; output: string }>("/api/admin/cert/acme", { + method: "POST", + body: JSON.stringify(req), + }); +}