Commit 5700f8da authored by jan.koester's avatar jan.koester
Browse files

test

parent 1e00c859
Loading
Loading
Loading
Loading
+78 −1
Changes for admin/static/app.js: 78 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -99,8 +99,37 @@ const ProxyPlusAdmin = (() => {
    }
  }

  async function refreshTlsProfiles() {
    const profiles = await api("GET", "/api/v1/tls/profiles");
    const body = document.querySelector("#tls-profiles tbody");
    body.innerHTML = "";
    for (const p of profiles) {
      const tr = document.createElement("tr");
      tr.innerHTML = `<td>${p.profile}</td><td>${(p.domains || []).join(", ")}</td><td>${p.acme_enabled ? "ja" : "nein"}</td><td>${p.acme_email}</td><td>${p.ca_plugin}</td>`;
      body.appendChild(tr);
    }
  }

  async function refreshDkimKeys() {
    const keys = await api("GET", "/api/v1/security/dkim-keys");
    const body = document.querySelector("#dkim-keys tbody");
    body.innerHTML = "";
    for (const k of keys) {
      const tr = document.createElement("tr");
      tr.innerHTML = `<td>${k.domain}</td><td>${k.selector}</td><td>${k.key_path}</td><td>${k.enabled ? "ja" : "nein"}</td>`;
      body.appendChild(tr);
    }
  }

  async function refreshAll() {
    await Promise.all([refreshStatus(), refreshRoutes(), refreshRelays(), refreshPools()]);
    await Promise.all([
      refreshStatus(),
      refreshRoutes(),
      refreshRelays(),
      refreshPools(),
      refreshTlsProfiles(),
      refreshDkimKeys(),
    ]);
  }

  function showNewRouteForm() {
@@ -143,6 +172,50 @@ const ProxyPlusAdmin = (() => {
    }
  }

  function showNewTlsProfileForm() {
    document.getElementById("new-tls-profile-form").hidden = false;
  }

  async function createTlsProfile() {
    document.getElementById("tls-profile-error").textContent = "";
    try {
      const domains = document.getElementById("tls-profile-domains").value
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean);
      await api("POST", "/api/v1/tls/profiles", {
        profile: document.getElementById("tls-profile-id").value,
        domains,
        acme_enabled: document.getElementById("tls-profile-acme").checked,
        acme_email: document.getElementById("tls-profile-email").value,
        acme_directory_url: document.getElementById("tls-profile-directory").value,
        ca_plugin: document.getElementById("tls-profile-ca-plugin").value,
      });
      await refreshTlsProfiles();
    } catch (e) {
      document.getElementById("tls-profile-error").textContent = e.message;
    }
  }

  function showNewDkimKeyForm() {
    document.getElementById("new-dkim-key-form").hidden = false;
  }

  async function createDkimKey() {
    document.getElementById("dkim-key-error").textContent = "";
    try {
      await api("POST", "/api/v1/security/dkim-keys", {
        domain: document.getElementById("dkim-domain").value,
        selector: document.getElementById("dkim-selector").value,
        key_path: document.getElementById("dkim-keypath").value,
        enabled: document.getElementById("dkim-enabled").checked,
      });
      await refreshDkimKeys();
    } catch (e) {
      document.getElementById("dkim-key-error").textContent = e.message;
    }
  }

  function showNewPoolForm() {
    document.getElementById("new-pool-form").hidden = false;
  }
@@ -186,6 +259,10 @@ const ProxyPlusAdmin = (() => {
    createRoute,
    showNewRelayForm,
    createRelay,
    showNewTlsProfileForm,
    createTlsProfile,
    showNewDkimKeyForm,
    createDkimKey,
    showNewPoolForm,
    createPool,
  };
+30 −0
Changes for admin/static/index.html: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -49,6 +49,36 @@
    <div id="relay-error" class="error"></div>
  </div>

  <h2>TLS-Profile / Zertifikate</h2>
  <table id="tls-profiles"><thead><tr><th>Profil</th><th>Domains</th><th>ACME</th><th>E-Mail</th><th>CA-Plugin</th></tr></thead><tbody></tbody></table>
  <button onclick="ProxyPlusAdmin.showNewTlsProfileForm()">Neues Profil</button>
  <div id="new-tls-profile-form" hidden>
    <input id="tls-profile-id" placeholder="Profil-ID, z.B. www">
    <input id="tls-profile-domains" placeholder="Domains, kommagetrennt, erste = Subject CN">
    <label><input type="checkbox" id="tls-profile-acme"> ACME aktiv (Let's Encrypt/ZeroSSL)</label>
    <input id="tls-profile-email" placeholder="ACME E-Mail (bei ACME aktiv erforderlich)">
    <input id="tls-profile-directory" placeholder="ACME Directory-URL (leer = Plugin-Standard)">
    <select id="tls-profile-ca-plugin">
      <option value="">(automatisch, nur bei genau einem geladenen Plugin)</option>
      <option value="letsencrypt">letsencrypt</option>
      <option value="zerossl">zerossl</option>
    </select>
    <button onclick="ProxyPlusAdmin.createTlsProfile()">Anlegen</button>
    <div id="tls-profile-error" class="error"></div>
  </div>

  <h2>DKIM-Schluessel</h2>
  <table id="dkim-keys"><thead><tr><th>Domain</th><th>Selector</th><th>Key-Pfad</th><th>Aktiv</th></tr></thead><tbody></tbody></table>
  <button onclick="ProxyPlusAdmin.showNewDkimKeyForm()">Neuer Schluessel</button>
  <div id="new-dkim-key-form" hidden>
    <input id="dkim-domain" placeholder="Signierende Domain, z.B. example.com">
    <input id="dkim-selector" placeholder="Selector, z.B. default">
    <input id="dkim-keypath" placeholder="Pfad zum PEM-Private-Key">
    <label><input type="checkbox" id="dkim-enabled" checked> Aktiv</label>
    <button onclick="ProxyPlusAdmin.createDkimKey()">Anlegen</button>
    <div id="dkim-key-error" class="error"></div>
  </div>

  <h2>Load-Balancer-Pools</h2>
  <table id="lb-pools"><thead><tr><th>ID</th><th>Art</th><th>Algorithmus</th><th>Ziele (Status)</th></tr></thead><tbody></tbody></table>
  <button onclick="ProxyPlusAdmin.showNewPoolForm()">Neuer Pool</button>
+34 −9
Changes for docs/ARCHITECTURE.md: 34 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -130,8 +130,30 @@ Real und einsatzbereit: `SmtpD`/`SmtpEvent` (Server), `SmtpClient` (Relay-Client
vorhanden. **Lücke:** nur `AUTH_PLAIN`/`AUTH_LOGIN` (kein CRAM-MD5 — der Auftrag sagt selbst
"sofern unterstützt", also entfällt es explizit). **Kein DKIM/Filter-Hook** — `DataEvent()`
liefert ein `const SmtpEnvelope&`; zum Signieren/Modifizieren baut ProxyPlus für den
Relay-Schritt eine **eigene** `SmtpEnvelope` für den ausgehenden `SmtpClient`, in die z. B. ein
DKIM-Signatur-Header vor dem restlichen Body eingefügt wird.
Relay-Schritt eine **eigene** `SmtpEnvelope` für den ausgehenden `SmtpClient`, in die ein
DKIM-Signatur-Header vor dem restlichen Body eingefügt wird (siehe §6c).

## 6c. DKIM/DMARC-Verdrahtung (2026-08-07)

`SmtpProxyController::DataEvent` (`src/smtp/SmtpProxyServer.cpp`) verdrahtet
`DkimAdapter`/`DmarcAdapter` (§8) an zwei Stellen:

- **Ausgehend (Signieren).** Ein neuer Record-Typ `DkimKeyRecord` (`src/store/Records.h`,
  `RecordTable::DkimKey`) ordnet einer signierenden Domain Selector + PEM-Key-Pfad zu, verwaltet
  über `GET/POST /api/v1/security/dkim-keys` (`security.dkim.read`/`security.dkim.write`, siehe
  `ROLES.md`) und die Admin-GUI. Trägt die MAIL-FROM-Domain einer Nachricht einen passenden,
  aktivierten Schlüssel, signiert `DataEvent` sie vor der Weiterleitung — außer sie trägt
  bereits eine `DKIM-Signature` (kein Doppel-Signieren beim reinen Durchreichen).
- **Eingehend (Verifizieren) — nur beratend.** Trägt eine durchgereichte Nachricht bereits eine
  `DKIM-Signature`, verifiziert `DataEvent` sie (`DkimAdapter::verify()` holt den Public Key
  selbst per DNS, braucht keine Konfiguration) und wertet das Ergebnis per `DmarcAdapter`
  gegen die `_dmarc.<domain>`-Policy aus. Das Ergebnis wird als Standard-`Authentication-Results`-
  Header (RFC 8601) angehängt, **nicht** zum Zurückweisen/Quarantänisieren verwendet: ProxyPlus
  ist an dieser Stelle ein Relay-Hop, kein finaler Zustellagent, und die Auswertung hat zwei
  bekannte Lücken (kein SPF — `spfPass` ist immer `false`, siehe §8; `fromDomain` wird aus der
  Umschlag-MAIL-FROM-Adresse angenähert statt aus einem geparsten RFC5322.From-Header). Eine
  spätere Durchsetzung (Reject/Quarantäne nach Policy) bräuchte zuerst eine echte
  SPF-Implementierung und RFC5322.From-Parsing, um keine legitime Post fälschlich abzulehnen.

## 6a. Let's Encrypt / ZeroSSL / ACME (TLS-Zertifikatsbeschaffung)

@@ -278,13 +300,16 @@ proxyplus/
  Split-Brain-Schutz bräuchte es eine Membership-/Write-Ordering-Schicht.
- DkimAdapter/DmarcAdapter sind eine eigene RFC-6376/7489-Implementierung (kein
  libopendkim/libopendmarc mehr — deren Header hatten je nach System unterschiedliche
  Include-Layouts und dkim.h ist ohne Weiteres nicht C++-kompatibel, `_Bool`). Bekannte
  Lücken gegenüber dem vollen RFC: kein Fallback auf die Organisationsdomain bei DMARC
  (RFC 7489 §6.6.3), relaxed-Alignment approximiert einen Public-Suffix-List-Abgleich nur
  über einen Suffix-Vergleich, `pct=` wird geparst aber nicht gesampelt (Policy wird immer
  vollständig angewendet), SPF-Alignment vertraut dem `spfPass`-Flag des Callers ohne
  eigenen Domain-Vergleich (das Interface bekommt keine separate SPF-authentifizierte
  Domain).
  Include-Layouts und dkim.h ist ohne Weiteres nicht C++-kompatibel, `_Bool`). Seit 2026-08-07
  in `SmtpProxyServer::DataEvent` verdrahtet (§6c) — ausgehendes Signieren pro konfigurierter
  Domain, eingehendes Verifizieren nur beratend (`Authentication-Results`-Header, keine
  Durchsetzung). Bekannte Lücken gegenüber dem vollen RFC: kein Fallback auf die
  Organisationsdomain bei DMARC (RFC 7489 §6.6.3), relaxed-Alignment approximiert einen
  Public-Suffix-List-Abgleich nur über einen Suffix-Vergleich, `pct=` wird geparst aber nicht
  gesampelt (Policy wird immer vollständig angewendet), **keine SPF-Implementierung** —
  `spfPass` wird von `DataEvent` deshalb immer als `false` übergeben, SPF-Alignment kann also
  nie zum DMARC-Pass beitragen (nur DKIM-Alignment kann), und `fromDomain` ist die
  Umschlag-MAIL-FROM-Domain, kein geparster RFC5322.From-Header.
- Gruppen-Anlage (`addGroup`) fehlt aktuell im `authdb::client`-API (nur `listGroups()` lesend,
  `GPOadd` schreibend) — `scripts/authdb-bootstrap.sh` dokumentiert das explizit als
  authdb-seitige Lücke statt sie zu umgehen.
+5 −0
Changes for docs/ROLES.md: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -60,6 +60,11 @@ load balancer section) -- pools are shared between HTTP routes and SMTP relay ru
TLS profiles they get their own resource class rather than living under `http.*`/`smtp.*`:
`lb.pools.read`, `lb.pools.write`.

Plus two more for managing per-domain DKIM signing keys (`/api/v1/security/dkim-keys`, see
`ARCHITECTURE.md` §6c) -- grouped under `security.*` alongside `smtp.quarantine.manage` rather
than `smtp.*` since it's `proxyplus.security_admin`'s resource, not `proxyplus.smtp_admin`'s:
`security.dkim.read`, `security.dkim.write`.

`scripts/authdb-bootstrap.sh` legt für jede dieser Berechtigungen eine GPO an und trägt die
Standardrollen-Gruppen als `allowed`-Mitglieder ein (z. B. `proxyplus.super_admin` in allen GPOs,
`proxyplus.smtp_admin` in allen `smtp.*`-GPOs).
+36 −0
Changes for src/api/JsonApiServer.cpp: 36 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -403,6 +403,42 @@ void JsonApiController::registerRoutes() {
             json_object_object_add(resp, "status", json_object_new_string("created"));
             return json_object_to_json_string(resp);
         }});

    // Per-domain DKIM signing keys, consulted by SmtpProxyServer::DataEvent for outbound mail
    // (see docs/ARCHITECTURE.md §6). Verifying an *incoming* signature needs no config here --
    // DkimAdapter::verify() resolves the signer's public key via DNS itself.
    _routes.push_back(
        {"GET", "/api/v1/security/dkim-keys", "security.dkim.read", [this](RouteContext &) -> std::string {
             JsonGuard arr(json_object_new_array());
             for (const auto &k : _configStore.listDkimKeys()) {
                 JsonGuard item(json_object_new_object());
                 json_object_object_add(item, "domain", json_object_new_string(k.domain.c_str()));
                 json_object_object_add(item, "selector", json_object_new_string(k.selector.c_str()));
                 json_object_object_add(item, "key_path", json_object_new_string(k.keyPath.c_str()));
                 json_object_object_add(item, "enabled", json_object_new_boolean(k.enabled));
                 json_object_get(item.obj);
                 json_object_array_add(arr.obj, item.obj);
             }
             return json_object_to_json_string(arr);
         }});

    _routes.push_back(
        {"POST", "/api/v1/security/dkim-keys", "security.dkim.write", [this](RouteContext &ctx) -> std::string {
             JsonGuard body(json_tokener_parse(ctx.body.c_str()));
             if (!body.obj) return jsonError("invalid JSON body");
             store::DkimKeyRecord rec;
             rec.domain = getStringField(body, "domain");
             rec.selector = getStringField(body, "selector");
             rec.keyPath = getStringField(body, "key_path");
             rec.enabled = getBoolField(body, "enabled", true);
             if (rec.domain.empty() || rec.selector.empty() || rec.keyPath.empty()) {
                 return jsonError("domain, selector and key_path are required");
             }
             _configStore.putDkimKey(rec);
             JsonGuard resp(json_object_new_object());
             json_object_object_add(resp, "status", json_object_new_string("created"));
             return json_object_to_json_string(resp);
         }});
}

namespace {
Loading