Loading src/api/JsonApiServer.cpp +5 −0 Changes for src/api/JsonApiServer.cpp: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -334,6 +334,7 @@ void JsonApiController::registerRoutes() { json_object_object_add(item, "acme_directory_url", json_object_new_string(r.acmeDirectoryUrl.c_str())); json_object_object_add(item, "ca_plugin", json_object_new_string(r.caPlugin.c_str())); json_object_object_add(item, "self_signed", json_object_new_boolean(r.selfSigned)); json_object_get(item.obj); json_object_array_add(arr.obj, item.obj); } Loading @@ -355,6 +356,7 @@ void JsonApiController::registerRoutes() { rec.acmeEmail = getStringField(body, "acme_email"); rec.acmeDirectoryUrl = getStringField(body, "acme_directory_url"); rec.caPlugin = getStringField(body, "ca_plugin"); rec.selfSigned = getBoolField(body, "self_signed"); json_object *domainsArr = nullptr; if (json_object_object_get_ex(body, "domains", &domainsArr) && domainsArr) { int n = json_object_array_length(domainsArr); Loading @@ -364,6 +366,9 @@ void JsonApiController::registerRoutes() { } } if (rec.profile.empty()) return jsonError("profile is required"); if (rec.acmeEnabled && rec.selfSigned) { return jsonError("acme_enabled and self_signed are mutually exclusive"); } if (rec.acmeEnabled && (rec.domains.empty() || rec.acmeEmail.empty())) { return jsonError("acme_enabled requires at least one domain and acme_email"); } Loading src/main.cpp +5 −0 Changes for src/main.cpp: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -130,6 +130,11 @@ int main(int argc, char **argv) { proxyplus::tls::AcmeChallengeStore acmeChallenges; proxyplus::tls::CertificateManager certManager(cfg, configStore, pluginManager, acmeChallenges); // Self-signed profiles must have a cert on disk before any listener binds below -- // HttpD/SmtpD's constructor throws if tls_profile resolves to a missing file, and // (unlike ACME) there's no reason to defer this to the background checkLoop() since // it's a purely local, instant operation. certManager.provisionSelfSigned(); std::string staticDir = PROXYPLUS_DEFAULT_STATIC_DIR; Loading src/store/ConfigStore.cpp +2 −0 Changes for src/store/ConfigStore.cpp: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -194,6 +194,7 @@ void encodeTlsProfile(ByteWriter &w, const TlsProfileRecord &r) { w.str(r.acmeEmail); w.str(r.acmeDirectoryUrl); w.str(r.caPlugin); w.u8(r.selfSigned ? 1 : 0); } TlsProfileRecord decodeTlsProfile(ByteReader &r) { TlsProfileRecord rec; Loading @@ -204,6 +205,7 @@ TlsProfileRecord decodeTlsProfile(ByteReader &r) { rec.acmeEmail = r.str(); rec.acmeDirectoryUrl = r.str(); rec.caPlugin = r.str(); rec.selfSigned = r.u8() != 0; return rec; } Loading src/store/Records.h +5 −0 Changes for src/store/Records.h: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -98,6 +98,11 @@ struct TlsProfileRecord { // "letsencrypt" or "zerossl"). "" is only valid when exactly one such plugin is loaded -- // see CertificateManager::renew(), which refuses to guess when several are present. std::string caPlugin; // Locally generate (and, near expiry, regenerate) a self-signed certificate instead of // going through ACME -- for frontends with no public hostname to prove ownership of // (internal/LAN listeners, first-boot admin UI) where a browser TOFU warning is // acceptable. Mutually exclusive with acmeEnabled; see CertificateManager::provisionSelfSigned(). bool selfSigned = false; }; // A per-domain DKIM signing key, referenced by the MAIL FROM address's domain during outbound Loading src/tls/CertificateManager.cpp +48 −9 Changes for src/tls/CertificateManager.cpp: 48 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ #include <sys/stat.h> #include <netplus/crypto/x509.h> #include <netplus/utils/certgen.h> namespace proxyplus::tls { Loading Loading @@ -107,32 +108,70 @@ void CertificateManager::renew(const store::TlsProfileRecord &rec) { return; } writeCertKeyAndNotify(rec.profile, result.certificateChainPem, result.privateKeyPem); } // Writes a PEM cert+key pair to <dataPath>/tls/<profile>.{crt,key} and notifies every // listener registered for that profile -- the tail end of both renew() (ACME) and // generateSelfSigned() (below), which differ only in where the PEM data comes from. void CertificateManager::writeCertKeyAndNotify(const std::string &profile, const std::string &certPem, const std::string &keyPem) { std::string dir = _cfg.dataPath + "/tls"; ::mkdir(dir.c_str(), 0750); std::string certPath = dir + "/" + rec.profile + ".crt"; std::string keyPath = dir + "/" + rec.profile + ".key"; std::string certPath = dir + "/" + profile + ".crt"; std::string keyPath = dir + "/" + profile + ".key"; if (!writeFileAtomic(certPath, result.certificateChainPem) || !writeFileAtomic(keyPath, result.privateKeyPem)) { std::cerr << "[tls] failed to write renewed certificate for '" << rec.profile << "'" << std::endl; if (!writeFileAtomic(certPath, certPem) || !writeFileAtomic(keyPath, keyPem)) { std::cerr << "[tls] failed to write certificate for '" << profile << "'" << std::endl; return; } ::chmod(keyPath.c_str(), 0600); std::lock_guard lk(_callbackMutex); auto range = _reloadCallbacks.equal_range(rec.profile); auto range = _reloadCallbacks.equal_range(profile); size_t notified = 0; for (auto it = range.first; it != range.second; ++it, ++notified) it->second(); std::cerr << "[tls] renewed '" << rec.profile << "', notified " << notified << " listener(s)" std::cerr << "[tls] wrote certificate for '" << profile << "', notified " << notified << " listener(s)" << std::endl; } // Self-signed generation is local and instant (no network round-trip, no rate limit like // ACME), so unlike renew() it's also called synchronously at startup via // provisionSelfSigned() -- see that method and docs/ARCHITECTURE.md. void CertificateManager::generateSelfSigned(const store::TlsProfileRecord &rec) { netplus::CertGenConfig genCfg; genCfg.commonName = rec.domains.empty() ? rec.profile : rec.domains.front(); genCfg.subjectAltNames = rec.domains; genCfg.validDays = 397; // browser-enforced maximum leaf lifetime (Apple/Chrome policy) netplus::CertKeyPair kp; if (!netplus::generateSelfSignedCert(genCfg, kp)) { std::cerr << "[tls] self-signed generation failed for '" << rec.profile << "'" << std::endl; return; } std::cerr << "[tls] generated self-signed certificate for '" << rec.profile << "' (CN=" << genCfg.commonName << ")" << std::endl; writeCertKeyAndNotify(rec.profile, kp.certPem, kp.keyPem); } void CertificateManager::provisionSelfSigned() { for (const auto &rec : _configStore.listTlsProfiles()) { if (!rec.selfSigned) continue; if (needsRenewal(rec.profile)) generateSelfSigned(rec); } } void CertificateManager::checkLoop() { while (_running.load()) { for (const auto &rec : _configStore.listTlsProfiles()) { if (!rec.acmeEnabled) continue; if (rec.acmeEnabled) { if (needsRenewal(rec.profile)) renew(rec); } else if (rec.selfSigned) { if (needsRenewal(rec.profile)) generateSelfSigned(rec); } } for (int i = 0; i < kCheckIntervalSeconds && _running.load(); ++i) { std::this_thread::sleep_for(std::chrono::seconds(1)); Loading Loading
src/api/JsonApiServer.cpp +5 −0 Changes for src/api/JsonApiServer.cpp: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -334,6 +334,7 @@ void JsonApiController::registerRoutes() { json_object_object_add(item, "acme_directory_url", json_object_new_string(r.acmeDirectoryUrl.c_str())); json_object_object_add(item, "ca_plugin", json_object_new_string(r.caPlugin.c_str())); json_object_object_add(item, "self_signed", json_object_new_boolean(r.selfSigned)); json_object_get(item.obj); json_object_array_add(arr.obj, item.obj); } Loading @@ -355,6 +356,7 @@ void JsonApiController::registerRoutes() { rec.acmeEmail = getStringField(body, "acme_email"); rec.acmeDirectoryUrl = getStringField(body, "acme_directory_url"); rec.caPlugin = getStringField(body, "ca_plugin"); rec.selfSigned = getBoolField(body, "self_signed"); json_object *domainsArr = nullptr; if (json_object_object_get_ex(body, "domains", &domainsArr) && domainsArr) { int n = json_object_array_length(domainsArr); Loading @@ -364,6 +366,9 @@ void JsonApiController::registerRoutes() { } } if (rec.profile.empty()) return jsonError("profile is required"); if (rec.acmeEnabled && rec.selfSigned) { return jsonError("acme_enabled and self_signed are mutually exclusive"); } if (rec.acmeEnabled && (rec.domains.empty() || rec.acmeEmail.empty())) { return jsonError("acme_enabled requires at least one domain and acme_email"); } Loading
src/main.cpp +5 −0 Changes for src/main.cpp: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -130,6 +130,11 @@ int main(int argc, char **argv) { proxyplus::tls::AcmeChallengeStore acmeChallenges; proxyplus::tls::CertificateManager certManager(cfg, configStore, pluginManager, acmeChallenges); // Self-signed profiles must have a cert on disk before any listener binds below -- // HttpD/SmtpD's constructor throws if tls_profile resolves to a missing file, and // (unlike ACME) there's no reason to defer this to the background checkLoop() since // it's a purely local, instant operation. certManager.provisionSelfSigned(); std::string staticDir = PROXYPLUS_DEFAULT_STATIC_DIR; Loading
src/store/ConfigStore.cpp +2 −0 Changes for src/store/ConfigStore.cpp: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -194,6 +194,7 @@ void encodeTlsProfile(ByteWriter &w, const TlsProfileRecord &r) { w.str(r.acmeEmail); w.str(r.acmeDirectoryUrl); w.str(r.caPlugin); w.u8(r.selfSigned ? 1 : 0); } TlsProfileRecord decodeTlsProfile(ByteReader &r) { TlsProfileRecord rec; Loading @@ -204,6 +205,7 @@ TlsProfileRecord decodeTlsProfile(ByteReader &r) { rec.acmeEmail = r.str(); rec.acmeDirectoryUrl = r.str(); rec.caPlugin = r.str(); rec.selfSigned = r.u8() != 0; return rec; } Loading
src/store/Records.h +5 −0 Changes for src/store/Records.h: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -98,6 +98,11 @@ struct TlsProfileRecord { // "letsencrypt" or "zerossl"). "" is only valid when exactly one such plugin is loaded -- // see CertificateManager::renew(), which refuses to guess when several are present. std::string caPlugin; // Locally generate (and, near expiry, regenerate) a self-signed certificate instead of // going through ACME -- for frontends with no public hostname to prove ownership of // (internal/LAN listeners, first-boot admin UI) where a browser TOFU warning is // acceptable. Mutually exclusive with acmeEnabled; see CertificateManager::provisionSelfSigned(). bool selfSigned = false; }; // A per-domain DKIM signing key, referenced by the MAIL FROM address's domain during outbound Loading
src/tls/CertificateManager.cpp +48 −9 Changes for src/tls/CertificateManager.cpp: 48 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ #include <sys/stat.h> #include <netplus/crypto/x509.h> #include <netplus/utils/certgen.h> namespace proxyplus::tls { Loading Loading @@ -107,32 +108,70 @@ void CertificateManager::renew(const store::TlsProfileRecord &rec) { return; } writeCertKeyAndNotify(rec.profile, result.certificateChainPem, result.privateKeyPem); } // Writes a PEM cert+key pair to <dataPath>/tls/<profile>.{crt,key} and notifies every // listener registered for that profile -- the tail end of both renew() (ACME) and // generateSelfSigned() (below), which differ only in where the PEM data comes from. void CertificateManager::writeCertKeyAndNotify(const std::string &profile, const std::string &certPem, const std::string &keyPem) { std::string dir = _cfg.dataPath + "/tls"; ::mkdir(dir.c_str(), 0750); std::string certPath = dir + "/" + rec.profile + ".crt"; std::string keyPath = dir + "/" + rec.profile + ".key"; std::string certPath = dir + "/" + profile + ".crt"; std::string keyPath = dir + "/" + profile + ".key"; if (!writeFileAtomic(certPath, result.certificateChainPem) || !writeFileAtomic(keyPath, result.privateKeyPem)) { std::cerr << "[tls] failed to write renewed certificate for '" << rec.profile << "'" << std::endl; if (!writeFileAtomic(certPath, certPem) || !writeFileAtomic(keyPath, keyPem)) { std::cerr << "[tls] failed to write certificate for '" << profile << "'" << std::endl; return; } ::chmod(keyPath.c_str(), 0600); std::lock_guard lk(_callbackMutex); auto range = _reloadCallbacks.equal_range(rec.profile); auto range = _reloadCallbacks.equal_range(profile); size_t notified = 0; for (auto it = range.first; it != range.second; ++it, ++notified) it->second(); std::cerr << "[tls] renewed '" << rec.profile << "', notified " << notified << " listener(s)" std::cerr << "[tls] wrote certificate for '" << profile << "', notified " << notified << " listener(s)" << std::endl; } // Self-signed generation is local and instant (no network round-trip, no rate limit like // ACME), so unlike renew() it's also called synchronously at startup via // provisionSelfSigned() -- see that method and docs/ARCHITECTURE.md. void CertificateManager::generateSelfSigned(const store::TlsProfileRecord &rec) { netplus::CertGenConfig genCfg; genCfg.commonName = rec.domains.empty() ? rec.profile : rec.domains.front(); genCfg.subjectAltNames = rec.domains; genCfg.validDays = 397; // browser-enforced maximum leaf lifetime (Apple/Chrome policy) netplus::CertKeyPair kp; if (!netplus::generateSelfSignedCert(genCfg, kp)) { std::cerr << "[tls] self-signed generation failed for '" << rec.profile << "'" << std::endl; return; } std::cerr << "[tls] generated self-signed certificate for '" << rec.profile << "' (CN=" << genCfg.commonName << ")" << std::endl; writeCertKeyAndNotify(rec.profile, kp.certPem, kp.keyPem); } void CertificateManager::provisionSelfSigned() { for (const auto &rec : _configStore.listTlsProfiles()) { if (!rec.selfSigned) continue; if (needsRenewal(rec.profile)) generateSelfSigned(rec); } } void CertificateManager::checkLoop() { while (_running.load()) { for (const auto &rec : _configStore.listTlsProfiles()) { if (!rec.acmeEnabled) continue; if (rec.acmeEnabled) { if (needsRenewal(rec.profile)) renew(rec); } else if (rec.selfSigned) { if (needsRenewal(rec.profile)) generateSelfSigned(rec); } } for (int i = 0; i < kCheckIntervalSeconds && _running.load(); ++i) { std::this_thread::sleep_for(std::chrono::seconds(1)); Loading