Loading plugins/zerossl/zerossl_plugin.cpp +41 −7 Changes for plugins/zerossl/zerossl_plugin.cpp: 41 added lines, 7 removed lines. Original line number Diff line number Diff line // ProxyPlus ZeroSSL plugin: implements ICertificateProviderPlugin (see // src/plugin/PluginApi.h) via the shared ACME v2 client in plugins/acme_common. Unlike // Let's Encrypt, ZeroSSL's ACME server requires External Account Binding on every // newAccount call (RFC 8555 §7.3.4) -- this plugin fetches EAB credentials for the // configured account email from ZeroSSL's free eab-credentials-email endpoint (eab.h) on // every renewal, so it needs nothing beyond an email address to work, same as Let's // Encrypt. See docs/ARCHITECTURE.md's Let's Encrypt/ACME section. // newAccount call (RFC 8555 §7.3.4). By default this plugin fetches EAB credentials for // the configured account email from ZeroSSL's free eab-credentials-email endpoint (eab.h) // on every renewal, so it needs nothing beyond an email address to work, same as Let's // Encrypt -- but that free endpoint is only available for ZeroSSL's free tier. A paid // account's EAB kid/hmac (from the ZeroSSL dashboard) can be supplied directly via // configBlob instead, see below. See docs/ARCHITECTURE.md's Let's Encrypt/ACME section. // // Config blob format: "<stateDir>", defaults to /var/lib/proxyplus/tls/acme-zerossl (kept // separate from plugins/letsencrypt's state dir since the two CAs use unrelated accounts). // Config blob format: "key=value" pairs, one per line, blank lines ignored: // state_dir=/var/lib/proxyplus/tls/acme-zerossl (optional, default shown) // eab_kid=... (optional, only with eab_hmac_key) // eab_hmac_key=... (optional, base64url; from the ZeroSSL // dashboard's "Verify Domain" -> API tab) // When eab_kid and eab_hmac_key are both set, they're used as-is instead of auto-fetching // via email -- required for accounts the free endpoint doesn't cover. // A bare, delimiter-free blob is still accepted as a legacy state_dir-only override. #include <chrono> #include <sys/stat.h> #include <sstream> #include "../../src/plugin/PluginApi.h" #include "../acme_common/acme_client.h" Loading @@ -26,7 +35,25 @@ public: const char *name() const override { return "zerossl"; } void init(proxyplus::plugin::IPluginAuthFacade & /*auth*/, const std::string &configBlob) override { _stateDir = configBlob.empty() ? "/var/lib/proxyplus/tls/acme-zerossl" : configBlob; _stateDir = "/var/lib/proxyplus/tls/acme-zerossl"; if (!configBlob.empty()) { if (configBlob.find('=') == std::string::npos) { // Legacy format: the whole blob was just a state-dir path. _stateDir = configBlob; } else { std::istringstream lines(configBlob); std::string line; while (std::getline(lines, line)) { size_t eq = line.find('='); if (eq == std::string::npos) continue; std::string key = line.substr(0, eq); std::string value = line.substr(eq + 1); if (key == "state_dir" && !value.empty()) _stateDir = value; else if (key == "eab_kid") _manualEabKid = value; else if (key == "eab_hmac_key") _manualEabHmacKey = value; } } } ::mkdir(_stateDir.c_str(), 0750); } Loading @@ -37,11 +64,16 @@ public: proxyplus::plugin::CertificateResult result; proxyplus::zerossl::EabCredentials eab; if (!_manualEabKid.empty() && !_manualEabHmacKey.empty()) { eab.kid = _manualEabKid; eab.hmacKeyBase64Url = _manualEabHmacKey; } else { std::string eabError; if (!proxyplus::zerossl::fetchEabCredentialsForEmail(accountEmail, eab, eabError)) { result.reason = "could not obtain ZeroSSL External Account Binding: " + eabError; return result; } } proxyplus::acme::AcmeClient client(directoryUrl.empty() ? kZeroSslDirectoryUrl : directoryUrl); if (!client.ensureAccount(_stateDir + "/account.key", accountEmail, eab.kid, Loading Loading @@ -71,6 +103,8 @@ public: private: std::string _stateDir; std::string _manualEabKid; std::string _manualEabHmacKey; }; } // namespace Loading src/api/JsonApiServer.cpp +54 −0 Changes for src/api/JsonApiServer.cpp: 54 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -249,6 +249,22 @@ std::string dkimKeyFromJson(json_object *body, store::DkimKeyRecord &rec) { return ""; } json_object *pluginConfigToJson(const store::PluginConfigRecord &r) { json_object *item = json_object_new_object(); json_object_object_add(item, "plugin_name", json_object_new_string(r.pluginName.c_str())); json_object_object_add(item, "config_blob", json_object_new_string(r.configBlob.c_str())); json_object_object_add(item, "enabled", json_object_new_boolean(r.enabled)); return item; } std::string pluginConfigFromJson(json_object *body, store::PluginConfigRecord &rec) { rec.pluginName = getStringField(body, "plugin_name"); rec.configBlob = getStringField(body, "config_blob"); rec.enabled = getBoolField(body, "enabled", true); if (rec.pluginName.empty()) return "plugin_name is required"; return ""; } // The routine listing shape (GET /api/v1/tls/profiles): flags whether a manual cert exists, // never dumps the cert/key bytes themselves. See tlsProfileToExportJson() below for the // full-fidelity shape used by /api/v1/config/export, which is gated on a far more privileged Loading Loading @@ -660,6 +676,44 @@ void JsonApiController::registerRoutes() { return json_object_to_json_string(resp); }}); // Per-plugin opaque config (see store::PluginConfigRecord / plugin::PluginManager) -- // e.g. the zerossl plugin's eab_kid/eab_hmac_key override for accounts its free // eab-credentials-email auto-fetch doesn't cover. Only takes effect on the next plugin // (re)load, i.e. process restart -- there's no live-reload of loaded .so config. _routes.push_back( {"GET", "/api/v1/plugins/config", "admin.plugins.read", [this](RouteContext &) -> std::string { JsonGuard arr(json_object_new_array()); for (const auto &c : _configStore.listPluginConfigs()) { json_object_array_add(arr.obj, pluginConfigToJson(c)); } return json_object_to_json_string(arr); }}); _routes.push_back( {"POST", "/api/v1/plugins/config", "admin.plugins.write", [this](RouteContext &ctx) -> std::string { JsonGuard body(json_tokener_parse(ctx.body.c_str())); if (!body.obj) return jsonError("invalid JSON body"); store::PluginConfigRecord rec; std::string err = pluginConfigFromJson(body, rec); if (!err.empty()) return jsonError(err); _configStore.putPluginConfig(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); }}); _routes.push_back( {"DELETE", "/api/v1/plugins/config", "admin.plugins.write", [this](RouteContext &ctx) -> std::string { JsonGuard body(json_tokener_parse(ctx.body.c_str())); if (!body.obj) return jsonError("invalid JSON body"); std::string pluginName = getStringField(body, "plugin_name"); if (pluginName.empty()) return jsonError("plugin_name is required"); _configStore.deletePluginConfig(pluginName); JsonGuard resp(json_object_new_object()); json_object_object_add(resp, "status", json_object_new_string("deleted")); return json_object_to_json_string(resp); }}); // Bulk config backup/restore, covering every resource type that already has its own // CRUD routes above (http_routes, smtp_relays, tls_profiles, pools, dkim_keys) -- // deliberately excludes the permission registry (Name->GPO-UUID), which is Loading src/main.cpp +5 −1 Changes for src/main.cpp: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -173,7 +173,11 @@ int main(int argc, char **argv) { proxyplus::plugin::AuthServicePluginFacade authFacade(authService); proxyplus::plugin::PluginManager pluginManager(authFacade); if (!cfg.pluginsPath.empty()) { pluginManager.loadDirectory(cfg.pluginsPath); std::map<std::string, std::string> pluginConfigByName; for (const auto &rec : configStore.listPluginConfigs()) { if (rec.enabled) pluginConfigByName[rec.pluginName] = rec.configBlob; } pluginManager.loadDirectory(cfg.pluginsPath, pluginConfigByName); for (const auto &err : pluginManager.loadErrors()) { std::cerr << "[plugin] " << err << std::endl; } Loading src/plugin/PluginManager.cpp +3 −2 Changes for src/plugin/PluginManager.cpp: 3 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -14,7 +14,7 @@ PluginManager::~PluginManager() { } } void PluginManager::loadDirectory(const std::string &dir) { void PluginManager::loadDirectory(const std::string &dir, const std::map<std::string, std::string> &configByName) { DIR *d = ::opendir(dir.c_str()); if (!d) { _errors.push_back("cannot open plugin directory: " + dir); Loading Loading @@ -57,7 +57,8 @@ void PluginManager::loadDirectory(const std::string &dir) { ::dlclose(handle); continue; } instance->init(_authFacade, ""); auto configIt = configByName.find(instance->name()); instance->init(_authFacade, configIt != configByName.end() ? configIt->second : ""); _plugins.push_back({handle, instance, destroyFn}); } ::closedir(d); Loading src/plugin/PluginManager.h +5 −1 Changes for src/plugin/PluginManager.h: 5 added lines, 1 removed line. Original line number Diff line number Diff line #pragma once #include <map> #include <memory> #include <string> #include <vector> Loading @@ -18,7 +19,10 @@ public: // Loads every plugin found in dir. Never throws -- a plugin that fails to load or // whose ABI version mismatches is skipped with a message appended to loadErrors(). void loadDirectory(const std::string &dir); // configByName looks up each plugin's configBlob by its IPlugin::name() (see // store::PluginConfigRecord) -- a plugin with no entry gets "", same as before this // parameter existed. void loadDirectory(const std::string &dir, const std::map<std::string, std::string> &configByName = {}); const std::vector<std::string> &loadErrors() const { return _errors; } Loading Loading
plugins/zerossl/zerossl_plugin.cpp +41 −7 Changes for plugins/zerossl/zerossl_plugin.cpp: 41 added lines, 7 removed lines. Original line number Diff line number Diff line // ProxyPlus ZeroSSL plugin: implements ICertificateProviderPlugin (see // src/plugin/PluginApi.h) via the shared ACME v2 client in plugins/acme_common. Unlike // Let's Encrypt, ZeroSSL's ACME server requires External Account Binding on every // newAccount call (RFC 8555 §7.3.4) -- this plugin fetches EAB credentials for the // configured account email from ZeroSSL's free eab-credentials-email endpoint (eab.h) on // every renewal, so it needs nothing beyond an email address to work, same as Let's // Encrypt. See docs/ARCHITECTURE.md's Let's Encrypt/ACME section. // newAccount call (RFC 8555 §7.3.4). By default this plugin fetches EAB credentials for // the configured account email from ZeroSSL's free eab-credentials-email endpoint (eab.h) // on every renewal, so it needs nothing beyond an email address to work, same as Let's // Encrypt -- but that free endpoint is only available for ZeroSSL's free tier. A paid // account's EAB kid/hmac (from the ZeroSSL dashboard) can be supplied directly via // configBlob instead, see below. See docs/ARCHITECTURE.md's Let's Encrypt/ACME section. // // Config blob format: "<stateDir>", defaults to /var/lib/proxyplus/tls/acme-zerossl (kept // separate from plugins/letsencrypt's state dir since the two CAs use unrelated accounts). // Config blob format: "key=value" pairs, one per line, blank lines ignored: // state_dir=/var/lib/proxyplus/tls/acme-zerossl (optional, default shown) // eab_kid=... (optional, only with eab_hmac_key) // eab_hmac_key=... (optional, base64url; from the ZeroSSL // dashboard's "Verify Domain" -> API tab) // When eab_kid and eab_hmac_key are both set, they're used as-is instead of auto-fetching // via email -- required for accounts the free endpoint doesn't cover. // A bare, delimiter-free blob is still accepted as a legacy state_dir-only override. #include <chrono> #include <sys/stat.h> #include <sstream> #include "../../src/plugin/PluginApi.h" #include "../acme_common/acme_client.h" Loading @@ -26,7 +35,25 @@ public: const char *name() const override { return "zerossl"; } void init(proxyplus::plugin::IPluginAuthFacade & /*auth*/, const std::string &configBlob) override { _stateDir = configBlob.empty() ? "/var/lib/proxyplus/tls/acme-zerossl" : configBlob; _stateDir = "/var/lib/proxyplus/tls/acme-zerossl"; if (!configBlob.empty()) { if (configBlob.find('=') == std::string::npos) { // Legacy format: the whole blob was just a state-dir path. _stateDir = configBlob; } else { std::istringstream lines(configBlob); std::string line; while (std::getline(lines, line)) { size_t eq = line.find('='); if (eq == std::string::npos) continue; std::string key = line.substr(0, eq); std::string value = line.substr(eq + 1); if (key == "state_dir" && !value.empty()) _stateDir = value; else if (key == "eab_kid") _manualEabKid = value; else if (key == "eab_hmac_key") _manualEabHmacKey = value; } } } ::mkdir(_stateDir.c_str(), 0750); } Loading @@ -37,11 +64,16 @@ public: proxyplus::plugin::CertificateResult result; proxyplus::zerossl::EabCredentials eab; if (!_manualEabKid.empty() && !_manualEabHmacKey.empty()) { eab.kid = _manualEabKid; eab.hmacKeyBase64Url = _manualEabHmacKey; } else { std::string eabError; if (!proxyplus::zerossl::fetchEabCredentialsForEmail(accountEmail, eab, eabError)) { result.reason = "could not obtain ZeroSSL External Account Binding: " + eabError; return result; } } proxyplus::acme::AcmeClient client(directoryUrl.empty() ? kZeroSslDirectoryUrl : directoryUrl); if (!client.ensureAccount(_stateDir + "/account.key", accountEmail, eab.kid, Loading Loading @@ -71,6 +103,8 @@ public: private: std::string _stateDir; std::string _manualEabKid; std::string _manualEabHmacKey; }; } // namespace Loading
src/api/JsonApiServer.cpp +54 −0 Changes for src/api/JsonApiServer.cpp: 54 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -249,6 +249,22 @@ std::string dkimKeyFromJson(json_object *body, store::DkimKeyRecord &rec) { return ""; } json_object *pluginConfigToJson(const store::PluginConfigRecord &r) { json_object *item = json_object_new_object(); json_object_object_add(item, "plugin_name", json_object_new_string(r.pluginName.c_str())); json_object_object_add(item, "config_blob", json_object_new_string(r.configBlob.c_str())); json_object_object_add(item, "enabled", json_object_new_boolean(r.enabled)); return item; } std::string pluginConfigFromJson(json_object *body, store::PluginConfigRecord &rec) { rec.pluginName = getStringField(body, "plugin_name"); rec.configBlob = getStringField(body, "config_blob"); rec.enabled = getBoolField(body, "enabled", true); if (rec.pluginName.empty()) return "plugin_name is required"; return ""; } // The routine listing shape (GET /api/v1/tls/profiles): flags whether a manual cert exists, // never dumps the cert/key bytes themselves. See tlsProfileToExportJson() below for the // full-fidelity shape used by /api/v1/config/export, which is gated on a far more privileged Loading Loading @@ -660,6 +676,44 @@ void JsonApiController::registerRoutes() { return json_object_to_json_string(resp); }}); // Per-plugin opaque config (see store::PluginConfigRecord / plugin::PluginManager) -- // e.g. the zerossl plugin's eab_kid/eab_hmac_key override for accounts its free // eab-credentials-email auto-fetch doesn't cover. Only takes effect on the next plugin // (re)load, i.e. process restart -- there's no live-reload of loaded .so config. _routes.push_back( {"GET", "/api/v1/plugins/config", "admin.plugins.read", [this](RouteContext &) -> std::string { JsonGuard arr(json_object_new_array()); for (const auto &c : _configStore.listPluginConfigs()) { json_object_array_add(arr.obj, pluginConfigToJson(c)); } return json_object_to_json_string(arr); }}); _routes.push_back( {"POST", "/api/v1/plugins/config", "admin.plugins.write", [this](RouteContext &ctx) -> std::string { JsonGuard body(json_tokener_parse(ctx.body.c_str())); if (!body.obj) return jsonError("invalid JSON body"); store::PluginConfigRecord rec; std::string err = pluginConfigFromJson(body, rec); if (!err.empty()) return jsonError(err); _configStore.putPluginConfig(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); }}); _routes.push_back( {"DELETE", "/api/v1/plugins/config", "admin.plugins.write", [this](RouteContext &ctx) -> std::string { JsonGuard body(json_tokener_parse(ctx.body.c_str())); if (!body.obj) return jsonError("invalid JSON body"); std::string pluginName = getStringField(body, "plugin_name"); if (pluginName.empty()) return jsonError("plugin_name is required"); _configStore.deletePluginConfig(pluginName); JsonGuard resp(json_object_new_object()); json_object_object_add(resp, "status", json_object_new_string("deleted")); return json_object_to_json_string(resp); }}); // Bulk config backup/restore, covering every resource type that already has its own // CRUD routes above (http_routes, smtp_relays, tls_profiles, pools, dkim_keys) -- // deliberately excludes the permission registry (Name->GPO-UUID), which is Loading
src/main.cpp +5 −1 Changes for src/main.cpp: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -173,7 +173,11 @@ int main(int argc, char **argv) { proxyplus::plugin::AuthServicePluginFacade authFacade(authService); proxyplus::plugin::PluginManager pluginManager(authFacade); if (!cfg.pluginsPath.empty()) { pluginManager.loadDirectory(cfg.pluginsPath); std::map<std::string, std::string> pluginConfigByName; for (const auto &rec : configStore.listPluginConfigs()) { if (rec.enabled) pluginConfigByName[rec.pluginName] = rec.configBlob; } pluginManager.loadDirectory(cfg.pluginsPath, pluginConfigByName); for (const auto &err : pluginManager.loadErrors()) { std::cerr << "[plugin] " << err << std::endl; } Loading
src/plugin/PluginManager.cpp +3 −2 Changes for src/plugin/PluginManager.cpp: 3 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -14,7 +14,7 @@ PluginManager::~PluginManager() { } } void PluginManager::loadDirectory(const std::string &dir) { void PluginManager::loadDirectory(const std::string &dir, const std::map<std::string, std::string> &configByName) { DIR *d = ::opendir(dir.c_str()); if (!d) { _errors.push_back("cannot open plugin directory: " + dir); Loading Loading @@ -57,7 +57,8 @@ void PluginManager::loadDirectory(const std::string &dir) { ::dlclose(handle); continue; } instance->init(_authFacade, ""); auto configIt = configByName.find(instance->name()); instance->init(_authFacade, configIt != configByName.end() ? configIt->second : ""); _plugins.push_back({handle, instance, destroyFn}); } ::closedir(d); Loading
src/plugin/PluginManager.h +5 −1 Changes for src/plugin/PluginManager.h: 5 added lines, 1 removed line. Original line number Diff line number Diff line #pragma once #include <map> #include <memory> #include <string> #include <vector> Loading @@ -18,7 +19,10 @@ public: // Loads every plugin found in dir. Never throws -- a plugin that fails to load or // whose ABI version mismatches is skipped with a message appended to loadErrors(). void loadDirectory(const std::string &dir); // configByName looks up each plugin's configBlob by its IPlugin::name() (see // store::PluginConfigRecord) -- a plugin with no entry gets "", same as before this // parameter existed. void loadDirectory(const std::string &dir, const std::map<std::string, std::string> &configByName = {}); const std::vector<std::string> &loadErrors() const { return _errors; } Loading