Loading CMakeLists.txt +1 −0 Changes for CMakeLists.txt: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -41,6 +41,7 @@ set(PROXYPLUS_CORE_SOURCES src/security/DnsTxtResolver.cpp src/security/DkimAdapter.cpp src/security/DmarcAdapter.cpp src/security/SpfAdapter.cpp src/tls/CertificateManager.cpp src/lb/LoadBalancer.cpp ) Loading docs/ARCHITECTURE.md +30 −14 Changes for docs/ARCHITECTURE.md: 30 added lines, 14 removed lines. Original line number Diff line number Diff line Loading @@ -144,16 +144,32 @@ DKIM-Signatur-Header vor dem restlichen Body eingefügt wird (siehe §6c). `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. - **Eingehend (Verifizieren) — nur beratend.** Für jede Nachricht prüft `DataEvent` zuerst SPF (`SpfAdapter::check(domain, clientIp)`, eigene RFC-7208-Implementierung, siehe unten) gegen die MAIL-FROM-Domain (oder die HELO-Domain bei leerem MAIL FROM, RFC 7208 §2.4). Trägt die Nachricht bereits eine `DKIM-Signature`, verifiziert `DataEvent` sie zusätzlich (`DkimAdapter::verify()` holt den Public Key selbst per DNS, braucht keine Konfiguration). Beide Ergebnisse gehen in `DmarcAdapter::evaluate()` gegen die `_dmarc.<domain>`-Policy ein. Alles zusammen 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 DMARC-Auswertung hat weiterhin eine bekannte Lücke (`fromDomain` wird aus der Umschlag-MAIL-FROM-Adresse angenähert statt aus einem geparsten RFC5322.From-Header, siehe §8). Eine spätere Durchsetzung (Reject/Quarantäne nach Policy) bräuchte zuerst RFC5322.From-Parsing, um keine legitime Post fälschlich abzulehnen. ## 6d. SPF (2026-08-07) `SpfAdapter` (`src/security/SpfAdapter.{h,cpp}`) ist eine eigene RFC-7208-Implementierung auf Basis derselben hand-geschriebenen DNS-Abfrage (`DnsTxtResolver`, um A/AAAA/MX-Lookups erweitert) wie DKIM/DMARC — keine externe SPF-Bibliothek. Braucht **keine** eigene Konfiguration (rein DNS-getrieben wie DMARC), daher keine neue ConfigStore-Tabelle/API/GUI. Unterstützt `all`/`ip4`/`ip6`/`a`/`mx` (inkl. dual-cidr-length) sowie `include` (rekursiv) und den `redirect`-Modifier, mit dem von der RFC vorgeschriebenen 10-DNS-Lookup-Budget (§4.6.4). Bekannte Lücken: keine Makro-Expansion (`%{...}`) irgendwo — ein Domain-Spec mit `%` wird als nicht auswertbar übersprungen statt falsch interpretiert; `exists`/`ptr`-Mechanismen werden syntaktisch erkannt (kein PermError), zählen aber nie als Treffer (`exists` bräuchte Makro-Expansion, `ptr` ist von der RFC selbst als deprecated markiert, §5.5); der `exp=` Modifier wird geparst und ignoriert (rein kosmetischer Erklärtext). ## 6a. Let's Encrypt / ZeroSSL / ACME (TLS-Zertifikatsbeschaffung) Loading Loading @@ -306,10 +322,10 @@ proxyplus/ 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. gesampelt (Policy wird immer vollständig angewendet), und `fromDomain` ist weiterhin die Umschlag-MAIL-FROM-Domain, kein geparster RFC5322.From-Header. `spfPass` kommt seit 2026-08-07 von einer echten SPF-Implementierung (`SpfAdapter`, §6d) statt fest `false` zu sein -- Details/Lücken dort. - 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. src/security/SpfAdapter.cpp +15 −5 Changes for src/security/SpfAdapter.cpp: 15 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -161,19 +161,29 @@ AMxSpec parseAMxTail(const std::string &tail, const std::string ¤tDomain) return spec; } // Dual-cidr-length: ["/" ip4-cidr-length] ["//" ip6-cidr-length]. The two forms share a // leading '/', so "//64" (v6 prefix only) must be told apart from "/24" (v4 prefix, // optionally followed by its own "//64") before parsing digits out of either. if (!s.empty()) { size_t doubleSlash = s.find("//"); if (s.compare(0, 2, "//") == 0) { if (!parsePrefixLen(s.substr(2), 128, spec.v6Prefix)) { spec.malformed = true; return spec; } } else if (s[0] == '/') { size_t doubleSlash = s.find("//", 1); std::string v4part = doubleSlash == std::string::npos ? s.substr(1) : s.substr(1, doubleSlash - 1); if (!v4part.empty() && !parsePrefixLen(v4part, 32, spec.v4Prefix)) { if (v4part.empty() || !parsePrefixLen(v4part, 32, spec.v4Prefix)) { spec.malformed = true; return spec; } if (doubleSlash != std::string::npos) { std::string v6part = s.substr(doubleSlash + 2); if (!parsePrefixLen(v6part, 128, spec.v6Prefix)) { if (doubleSlash != std::string::npos && !parsePrefixLen(s.substr(doubleSlash + 2), 128, spec.v6Prefix)) { spec.malformed = true; return spec; } } else { spec.malformed = true; } } return spec; Loading src/smtp/SmtpProxyServer.cpp +33 −19 Changes for src/smtp/SmtpProxyServer.cpp: 33 added lines, 19 removed lines. Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ #include "../security/DkimAdapter.h" #include "../security/DmarcAdapter.h" #include "../security/SpfAdapter.h" namespace proxyplus::smtpproxy { Loading @@ -30,6 +31,18 @@ std::string dmarcResultTag(const security::DmarcResult &r) { } } std::string spfResultTag(security::SpfResult r) { switch (r) { case security::SpfResult::Pass: return "pass"; case security::SpfResult::Fail: return "fail"; case security::SpfResult::SoftFail: return "softfail"; case security::SpfResult::Neutral: return "neutral"; case security::SpfResult::TempError: return "temperror"; case security::SpfResult::PermError: return "permerror"; default: return "none"; } } } // namespace SmtpProxyController::SmtpProxyController(std::vector<netplus::socket *> serverSockets, Loading Loading @@ -173,31 +186,32 @@ bool SmtpProxyController::DataEvent(netplus::con &curcon, const libsmtppp::SmtpE } } // Inbound DKIM/DMARC evaluation: advisory only, tagged as a standard RFC 8601 // Inbound SPF/DKIM/DMARC evaluation: advisory only, tagged as a standard RFC 8601 // Authentication-Results header rather than used to reject/quarantine -- ProxyPlus is a // relay hop, not a final delivery agent, and this evaluation has two known gaps: no SPF // implementation exists yet (spfPass is always false, see docs/ARCHITECTURE.md §8), and // relay hop, not a final delivery agent, and the DMARC piece still has one known gap: // fromDomain is approximated from the envelope MAIL FROM rather than a parsed // RFC5322.From header. Also decides whether we still need to sign the message ourselves // below (skipped if it already carries a signature, to avoid double-signing). std::string authResultsHeader; bool alreadySigned = false; { // RFC5322.From header (see docs/ARCHITECTURE.md §8). Also decides whether we still need to // sign the message ourselves below (skipped if it already carries a signature, to avoid // double-signing). std::string spfCheckDomain = state.mailFrom.empty() ? state.heloName : domainOf(state.mailFrom); if (spfCheckDomain.empty()) spfCheckDomain = state.heloName; security::SpfResult spfResult = security::SpfAdapter{}.check(spfCheckDomain, state.sourceIp); bool spfPass = spfResult == security::SpfResult::Pass; security::DkimAdapter verifier("", "", ""); auto dkimResult = verifier.verify(rawMessage); alreadySigned = dkimResult.present; if (dkimResult.present) { bool alreadySigned = dkimResult.present; security::DmarcAdapter dmarc; auto dmarcResult = dmarc.evaluate(domainOf(state.mailFrom), /*spfPass=*/false, dkimResult.valid, dkimResult.signingDomain); authResultsHeader = "Authentication-Results: " + _listenerLabel + "; dkim=" + (dkimResult.valid ? "pass" : "fail") + (dkimResult.signingDomain.empty() ? "" : " header.d=" + dkimResult.signingDomain) + auto dmarcResult = dmarc.evaluate(spfCheckDomain, spfPass, dkimResult.valid, dkimResult.signingDomain); std::string authResultsHeader = "Authentication-Results: " + _listenerLabel + "; spf=" + spfResultTag(spfResult) + " smtp.mailfrom=" + spfCheckDomain + "; dkim=" + (dkimResult.present ? (dkimResult.valid ? "pass" : "fail") : "none") + (dkimResult.signingDomain.empty() ? "" : " header.d=" + dkimResult.signingDomain) + "; dmarc=" + dmarcResultTag(dmarcResult) + "\r\n"; } } // One smarthost per message, chosen from the first recipient's relay rule -- a message // with recipients spanning multiple relay rules with different smarthosts is a known Loading Loading
CMakeLists.txt +1 −0 Changes for CMakeLists.txt: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -41,6 +41,7 @@ set(PROXYPLUS_CORE_SOURCES src/security/DnsTxtResolver.cpp src/security/DkimAdapter.cpp src/security/DmarcAdapter.cpp src/security/SpfAdapter.cpp src/tls/CertificateManager.cpp src/lb/LoadBalancer.cpp ) Loading
docs/ARCHITECTURE.md +30 −14 Changes for docs/ARCHITECTURE.md: 30 added lines, 14 removed lines. Original line number Diff line number Diff line Loading @@ -144,16 +144,32 @@ DKIM-Signatur-Header vor dem restlichen Body eingefügt wird (siehe §6c). `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. - **Eingehend (Verifizieren) — nur beratend.** Für jede Nachricht prüft `DataEvent` zuerst SPF (`SpfAdapter::check(domain, clientIp)`, eigene RFC-7208-Implementierung, siehe unten) gegen die MAIL-FROM-Domain (oder die HELO-Domain bei leerem MAIL FROM, RFC 7208 §2.4). Trägt die Nachricht bereits eine `DKIM-Signature`, verifiziert `DataEvent` sie zusätzlich (`DkimAdapter::verify()` holt den Public Key selbst per DNS, braucht keine Konfiguration). Beide Ergebnisse gehen in `DmarcAdapter::evaluate()` gegen die `_dmarc.<domain>`-Policy ein. Alles zusammen 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 DMARC-Auswertung hat weiterhin eine bekannte Lücke (`fromDomain` wird aus der Umschlag-MAIL-FROM-Adresse angenähert statt aus einem geparsten RFC5322.From-Header, siehe §8). Eine spätere Durchsetzung (Reject/Quarantäne nach Policy) bräuchte zuerst RFC5322.From-Parsing, um keine legitime Post fälschlich abzulehnen. ## 6d. SPF (2026-08-07) `SpfAdapter` (`src/security/SpfAdapter.{h,cpp}`) ist eine eigene RFC-7208-Implementierung auf Basis derselben hand-geschriebenen DNS-Abfrage (`DnsTxtResolver`, um A/AAAA/MX-Lookups erweitert) wie DKIM/DMARC — keine externe SPF-Bibliothek. Braucht **keine** eigene Konfiguration (rein DNS-getrieben wie DMARC), daher keine neue ConfigStore-Tabelle/API/GUI. Unterstützt `all`/`ip4`/`ip6`/`a`/`mx` (inkl. dual-cidr-length) sowie `include` (rekursiv) und den `redirect`-Modifier, mit dem von der RFC vorgeschriebenen 10-DNS-Lookup-Budget (§4.6.4). Bekannte Lücken: keine Makro-Expansion (`%{...}`) irgendwo — ein Domain-Spec mit `%` wird als nicht auswertbar übersprungen statt falsch interpretiert; `exists`/`ptr`-Mechanismen werden syntaktisch erkannt (kein PermError), zählen aber nie als Treffer (`exists` bräuchte Makro-Expansion, `ptr` ist von der RFC selbst als deprecated markiert, §5.5); der `exp=` Modifier wird geparst und ignoriert (rein kosmetischer Erklärtext). ## 6a. Let's Encrypt / ZeroSSL / ACME (TLS-Zertifikatsbeschaffung) Loading Loading @@ -306,10 +322,10 @@ proxyplus/ 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. gesampelt (Policy wird immer vollständig angewendet), und `fromDomain` ist weiterhin die Umschlag-MAIL-FROM-Domain, kein geparster RFC5322.From-Header. `spfPass` kommt seit 2026-08-07 von einer echten SPF-Implementierung (`SpfAdapter`, §6d) statt fest `false` zu sein -- Details/Lücken dort. - 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.
src/security/SpfAdapter.cpp +15 −5 Changes for src/security/SpfAdapter.cpp: 15 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -161,19 +161,29 @@ AMxSpec parseAMxTail(const std::string &tail, const std::string ¤tDomain) return spec; } // Dual-cidr-length: ["/" ip4-cidr-length] ["//" ip6-cidr-length]. The two forms share a // leading '/', so "//64" (v6 prefix only) must be told apart from "/24" (v4 prefix, // optionally followed by its own "//64") before parsing digits out of either. if (!s.empty()) { size_t doubleSlash = s.find("//"); if (s.compare(0, 2, "//") == 0) { if (!parsePrefixLen(s.substr(2), 128, spec.v6Prefix)) { spec.malformed = true; return spec; } } else if (s[0] == '/') { size_t doubleSlash = s.find("//", 1); std::string v4part = doubleSlash == std::string::npos ? s.substr(1) : s.substr(1, doubleSlash - 1); if (!v4part.empty() && !parsePrefixLen(v4part, 32, spec.v4Prefix)) { if (v4part.empty() || !parsePrefixLen(v4part, 32, spec.v4Prefix)) { spec.malformed = true; return spec; } if (doubleSlash != std::string::npos) { std::string v6part = s.substr(doubleSlash + 2); if (!parsePrefixLen(v6part, 128, spec.v6Prefix)) { if (doubleSlash != std::string::npos && !parsePrefixLen(s.substr(doubleSlash + 2), 128, spec.v6Prefix)) { spec.malformed = true; return spec; } } else { spec.malformed = true; } } return spec; Loading
src/smtp/SmtpProxyServer.cpp +33 −19 Changes for src/smtp/SmtpProxyServer.cpp: 33 added lines, 19 removed lines. Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ #include "../security/DkimAdapter.h" #include "../security/DmarcAdapter.h" #include "../security/SpfAdapter.h" namespace proxyplus::smtpproxy { Loading @@ -30,6 +31,18 @@ std::string dmarcResultTag(const security::DmarcResult &r) { } } std::string spfResultTag(security::SpfResult r) { switch (r) { case security::SpfResult::Pass: return "pass"; case security::SpfResult::Fail: return "fail"; case security::SpfResult::SoftFail: return "softfail"; case security::SpfResult::Neutral: return "neutral"; case security::SpfResult::TempError: return "temperror"; case security::SpfResult::PermError: return "permerror"; default: return "none"; } } } // namespace SmtpProxyController::SmtpProxyController(std::vector<netplus::socket *> serverSockets, Loading Loading @@ -173,31 +186,32 @@ bool SmtpProxyController::DataEvent(netplus::con &curcon, const libsmtppp::SmtpE } } // Inbound DKIM/DMARC evaluation: advisory only, tagged as a standard RFC 8601 // Inbound SPF/DKIM/DMARC evaluation: advisory only, tagged as a standard RFC 8601 // Authentication-Results header rather than used to reject/quarantine -- ProxyPlus is a // relay hop, not a final delivery agent, and this evaluation has two known gaps: no SPF // implementation exists yet (spfPass is always false, see docs/ARCHITECTURE.md §8), and // relay hop, not a final delivery agent, and the DMARC piece still has one known gap: // fromDomain is approximated from the envelope MAIL FROM rather than a parsed // RFC5322.From header. Also decides whether we still need to sign the message ourselves // below (skipped if it already carries a signature, to avoid double-signing). std::string authResultsHeader; bool alreadySigned = false; { // RFC5322.From header (see docs/ARCHITECTURE.md §8). Also decides whether we still need to // sign the message ourselves below (skipped if it already carries a signature, to avoid // double-signing). std::string spfCheckDomain = state.mailFrom.empty() ? state.heloName : domainOf(state.mailFrom); if (spfCheckDomain.empty()) spfCheckDomain = state.heloName; security::SpfResult spfResult = security::SpfAdapter{}.check(spfCheckDomain, state.sourceIp); bool spfPass = spfResult == security::SpfResult::Pass; security::DkimAdapter verifier("", "", ""); auto dkimResult = verifier.verify(rawMessage); alreadySigned = dkimResult.present; if (dkimResult.present) { bool alreadySigned = dkimResult.present; security::DmarcAdapter dmarc; auto dmarcResult = dmarc.evaluate(domainOf(state.mailFrom), /*spfPass=*/false, dkimResult.valid, dkimResult.signingDomain); authResultsHeader = "Authentication-Results: " + _listenerLabel + "; dkim=" + (dkimResult.valid ? "pass" : "fail") + (dkimResult.signingDomain.empty() ? "" : " header.d=" + dkimResult.signingDomain) + auto dmarcResult = dmarc.evaluate(spfCheckDomain, spfPass, dkimResult.valid, dkimResult.signingDomain); std::string authResultsHeader = "Authentication-Results: " + _listenerLabel + "; spf=" + spfResultTag(spfResult) + " smtp.mailfrom=" + spfCheckDomain + "; dkim=" + (dkimResult.present ? (dkimResult.valid ? "pass" : "fail") : "none") + (dkimResult.signingDomain.empty() ? "" : " header.d=" + dkimResult.signingDomain) + "; dmarc=" + dmarcResultTag(dmarcResult) + "\r\n"; } } // One smarthost per message, chosen from the first recipient's relay rule -- a message // with recipients spanning multiple relay rules with different smarthosts is a known Loading