Commit 1bf9f985 authored by jan.koester's avatar jan.koester
Browse files

eab support zerossl

parent f77bb03a
Loading
Loading
Loading
Loading
+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"
@@ -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);
    }

@@ -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,
@@ -71,6 +103,8 @@ public:

private:
    std::string _stateDir;
    std::string _manualEabKid;
    std::string _manualEabHmacKey;
};

} // namespace
+54 −0
Changes for src/api/JsonApiServer.cpp: 54 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
@@ -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
+5 −1
Changes for src/main.cpp: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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;
            }
+3 −2
Changes for src/plugin/PluginManager.cpp: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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);
@@ -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);
+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>
@@ -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