Loading config.yaml +15 −12 Original line number Diff line number Diff line Loading @@ -6,8 +6,8 @@ BLOGI: - "/usr/local/lib/blogi/plugins" - "/var/lib/blog/plugins" HTTP: URL: "http://localhost" DOMAIN: "localhost" URL: - "http://localhost" PREFIX: "/blog" PORT: 8080 BIND: "0.0.0.0" Loading Loading @@ -35,8 +35,6 @@ BLOGI: SSL: False TIMEOUT: 10 com: "/tmp/blogi" COOKIE: DOMAIN: "localhost" SMTP: ADDR: "10.1.2.203" PORT: 587 Loading @@ -45,12 +43,18 @@ BLOGI: SENDER: "mail@example.com" CONTACT: RECEIVER: "mail@example.com" DOMAIN: NAME: "localhost" SSL: CERT: "" KEY: "" PASSWORD: "" LETSENCRYPT: EMAIL: "admin@example.com" DOMAIN: - "blog.example.com" - "www.example.com" - "blog.another.org" # CERTDIR: "/etc/blogi/certs" # ACMEURL: "https://acme-v02.api.letsencrypt.org/directory" # Multi-domain support: each domain gets its own DB, template, settings, etc. # If DOMAINS is not present, single-domain mode is used (backward compatible). DOMAINS: Loading @@ -64,7 +68,9 @@ BLOGI: TEMPLATE: "/usr/local/share/blogi/themes/default" STARTPAGE: "/blog/content/index" HTTP: URL: "https://blog.example.com" URL: - "https://blog.example.com" - "https://www.example.com" PREFIX: "/blog" PLUGINDIR: - "/usr/local/lib/blogi/plugins" Loading @@ -74,8 +80,6 @@ BLOGI: CLIENTSECRET: "clientsecret" MEDIADB: URL: "https://127.0.0.1:8442" COOKIE: DOMAIN: ".example.com" - NAME: "blog.another.org" DATABASE: DRIVER: "sqlite" Loading @@ -83,7 +87,8 @@ BLOGI: TEMPLATE: "/usr/local/share/blogi/themes/dark" STARTPAGE: "/content/index" HTTP: URL: "https://blog.another.org" URL: - "https://blog.another.org" PREFIX: "" PLUGINDIR: - "/usr/local/lib/blogi/plugins" Loading @@ -93,5 +98,3 @@ BLOGI: CLIENTSECRET: "anothersecret" MEDIADB: URL: "https://media.another.org:8442" COOKIE: DOMAIN: ".another.org" docker-config.yaml +2 −6 Original line number Diff line number Diff line Loading @@ -6,8 +6,8 @@ BLOGI: - "/usr/local/lib/blogi/plugins" - "/var/lib/blog/plugins" HTTP: URL: "%REMOTEURL%" DOMAIN: "%HOSTNAME%" URL: - "%REMOTEURL%" PREFIX: "%PREFIX%" PORT: "%PORT%" BIND: "%BINDADDR%" Loading @@ -18,10 +18,6 @@ BLOGI: MEDIADB: URL: "%MEDIADBURL%" TMPDIR: "%TMPDIR%" COOKIE: DOMAIN: "%COOKIEDOMAIN%" DOMAIN: NAME: "%COOKIE%" SSL: CERT: "%SSLCERT%" KEY: "%SSLKEY%" Loading plugins/CMakeLists.txt +1 −0 Original line number Diff line number Diff line Loading @@ -11,3 +11,4 @@ add_subdirectory(contact) add_subdirectory(survey) add_subdirectory(gtm) add_subdirectory(social) add_subdirectory(letsencrypt) plugins/letsencrypt/CMakeLists.txt 0 → 100644 +13 −0 Original line number Diff line number Diff line find_package(json-c REQUIRED) add_library(letsencrypt SHARED letsencrypt.cpp) add_dependencies(letsencrypt blogidev) if(${CMAKE_HOST_SYSTEM_NAME} STREQUAL "Windows") target_link_libraries(letsencrypt PUBLIC blogidev uuidp::uuidp kernel32.lib json-c::json-c) else() target_link_libraries(letsencrypt PUBLIC blogidev uuidp::uuidp dl blogidev json-c::json-c) endif() install (TARGETS letsencrypt DESTINATION lib/blogi/plugins) plugins/letsencrypt/letsencrypt.cpp 0 → 100644 +271 −0 Original line number Diff line number Diff line /******************************************************************************* * Copyright (c) 2026, Jan Koester jan.koester@gmx.net * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ #include <iostream> #include <string> #include <vector> #include <mutex> #include <map> #include <thread> #include <atomic> #include <chrono> #include <fstream> #include <sstream> #include <cstring> #include <filesystem> #include <json-c/json.h> #include <httppp/http.h> #include <htmlpp/html.h> #include <netplus/exception.h> #include "plugin.h" #include "conf.h" namespace blogi { // ACME challenge token store: token -> key_authorization static std::mutex s_challenge_mutex; static std::map<std::string, std::string> s_challenges; class LetsEncrypt : public PluginApi { public: LetsEncrypt() : _running(false) {} ~LetsEncrypt() { _running = false; if (_renewThread.joinable()) _renewThread.join(); } const std::string getName() { return "letsencrypt"; } const std::string getVersion() { return "1.0"; } const std::string getAuthor() { return "Jan Koester"; } void initPlugin() { // Read config: LETSENCRYPT->DOMAIN (array), LETSENCRYPT->EMAIL (string) try { auto *domKey = Args->config->getKey("/BLOGI/LETSENCRYPT/DOMAIN"); size_t count = Args->config->getElements(domKey); for (size_t i = 0; i < count; ++i) _domains.push_back(Args->config->getValue(domKey, i)); } catch (...) {} try { _email = Args->config->getValue(Args->config->getKey("/BLOGI/LETSENCRYPT/EMAIL"), 0); } catch (...) {} try { _certDir = Args->config->getValue(Args->config->getKey("/BLOGI/LETSENCRYPT/CERTDIR"), 0); } catch (...) { _certDir = "/etc/blogi/certs"; } try { _acmeUrl = Args->config->getValue(Args->config->getKey("/BLOGI/LETSENCRYPT/ACMEURL"), 0); } catch (...) { _acmeUrl = "https://acme-v02.api.letsencrypt.org/directory"; } if (_domains.empty() || _email.empty()) { std::cerr << "[letsencrypt] No domains or email configured, plugin inactive." << std::endl; return; } std::cerr << "[letsencrypt] Configured for " << _domains.size() << " domain(s), email: " << _email << std::endl; for (const auto &d : _domains) std::cerr << "[letsencrypt] - " << d << std::endl; // Start background renewal thread _running = true; _renewThread = std::thread(&LetsEncrypt::renewalLoop, this); } bool Response(const int tid, libhttppp::HttpRequest &req) { // Serve ACME HTTP-01 challenge responses std::string url = req.getRequestURL(); const std::string prefix = "/.well-known/acme-challenge/"; if (url.compare(0, prefix.size(), prefix) != 0) return false; std::string token = url.substr(prefix.size()); if (token.empty()) return false; std::lock_guard<std::mutex> lock(s_challenge_mutex); auto it = s_challenges.find(token); if (it == s_challenges.end()) return false; // Respond with key authorization libhttppp::HttpResponse resp; resp.setState(200); resp.setContentType("text/plain"); resp.send(req, it->second.c_str(), it->second.size()); return true; } // Register a challenge token (called by the ACME client logic) static void registerChallenge(const std::string &token, const std::string &keyAuth) { std::lock_guard<std::mutex> lock(s_challenge_mutex); s_challenges[token] = keyAuth; } static void removeChallenge(const std::string &token) { std::lock_guard<std::mutex> lock(s_challenge_mutex); s_challenges.erase(token); } bool haveSettings() { return true; } json_object* Settings(const int tid, libhttppp::HttpRequest &req, const std::string &sessionid) { json_object *jroot = json_object_new_object(); json_object_object_add(jroot, "title", json_object_new_string("Let's Encrypt")); json_object *jinfo = json_object_new_object(); json_object_object_add(jinfo, "email", json_object_new_string(_email.c_str())); json_object *jdomains = json_object_new_array(); for (const auto &d : _domains) json_object_array_add(jdomains, json_object_new_string(d.c_str())); json_object_object_add(jinfo, "domains", jdomains); json_object_object_add(jinfo, "certdir", json_object_new_string(_certDir.c_str())); json_object_object_add(jinfo, "running", json_object_new_boolean(_running.load())); json_object_object_add(jroot, "info", jinfo); return jroot; } private: std::vector<std::string> _domains; std::string _email; std::string _certDir; std::string _acmeUrl; std::atomic<bool> _running; std::thread _renewThread; void renewalLoop() { // Initial delay to let the server start std::this_thread::sleep_for(std::chrono::seconds(10)); while (_running) { for (const auto &domain : _domains) { if (!_running) break; try { if (needsRenewal(domain)) { std::cerr << "[letsencrypt] Requesting certificate for: " << domain << std::endl; requestCertificate(domain); } } catch (const std::exception &e) { std::cerr << "[letsencrypt] Error renewing " << domain << ": " << e.what() << std::endl; } } // Check every 12 hours for (int i = 0; i < 720 && _running; ++i) std::this_thread::sleep_for(std::chrono::minutes(1)); } } bool needsRenewal(const std::string &domain) { std::string certPath = _certDir + "/" + domain + "/fullchain.pem"; if (!std::filesystem::exists(certPath)) return true; // Check if cert expires within 30 days auto ftime = std::filesystem::last_write_time(certPath); auto sctp = std::chrono::clock_cast<std::chrono::system_clock>(ftime); auto age = std::chrono::system_clock::now() - sctp; // Let's Encrypt certs valid for 90 days, renew at 60 days return age > std::chrono::hours(60 * 24); } void requestCertificate(const std::string &domain) { // Ensure cert directory exists std::string domainDir = _certDir + "/" + domain; std::filesystem::create_directories(domainDir); // Generate account key (EC P-256) if not present std::string accountKeyPath = _certDir + "/account.key"; std::vector<uint8_t> privKey; if (std::filesystem::exists(accountKeyPath)) { std::ifstream f(accountKeyPath, std::ios::binary); privKey.assign(std::istreambuf_iterator<char>(f), {}); } else { privKey = generateP256Key(); std::ofstream f(accountKeyPath, std::ios::binary); f.write(reinterpret_cast<const char*>(privKey.data()), privKey.size()); // Set restrictive permissions std::filesystem::permissions(accountKeyPath, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write); } // ACME flow: // 1. GET directory // 2. POST newNonce // 3. POST newAccount // 4. POST newOrder (with domain) // 5. GET authorizations // 6. Respond to HTTP-01 challenge // 7. POST finalize (with CSR) // 8. GET certificate std::cerr << "[letsencrypt] ACME flow for " << domain << " (account: " << _email << ")" << std::endl; // The actual ACME protocol implementation uses the HTTP client // from libhttppp to communicate with the ACME server. // Challenge tokens are registered via registerChallenge() so the // Response() method can serve them to the ACME validator. // TODO: Full ACME v2 protocol implementation // For now, log that renewal was attempted std::cerr << "[letsencrypt] Certificate request for " << domain << " requires ACME v2 client implementation." << std::endl; } static std::vector<uint8_t> generateP256Key() { // Generate a random 32-byte private key for P-256 std::vector<uint8_t> key(32); std::ifstream urandom("/dev/urandom", std::ios::binary); urandom.read(reinterpret_cast<char*>(key.data()), 32); return key; } }; } EXPORT blogi::PluginApi* create(){ return new blogi::LetsEncrypt(); } EXPORT void destroy(blogi::PluginApi* p){ delete p; } Loading
config.yaml +15 −12 Original line number Diff line number Diff line Loading @@ -6,8 +6,8 @@ BLOGI: - "/usr/local/lib/blogi/plugins" - "/var/lib/blog/plugins" HTTP: URL: "http://localhost" DOMAIN: "localhost" URL: - "http://localhost" PREFIX: "/blog" PORT: 8080 BIND: "0.0.0.0" Loading Loading @@ -35,8 +35,6 @@ BLOGI: SSL: False TIMEOUT: 10 com: "/tmp/blogi" COOKIE: DOMAIN: "localhost" SMTP: ADDR: "10.1.2.203" PORT: 587 Loading @@ -45,12 +43,18 @@ BLOGI: SENDER: "mail@example.com" CONTACT: RECEIVER: "mail@example.com" DOMAIN: NAME: "localhost" SSL: CERT: "" KEY: "" PASSWORD: "" LETSENCRYPT: EMAIL: "admin@example.com" DOMAIN: - "blog.example.com" - "www.example.com" - "blog.another.org" # CERTDIR: "/etc/blogi/certs" # ACMEURL: "https://acme-v02.api.letsencrypt.org/directory" # Multi-domain support: each domain gets its own DB, template, settings, etc. # If DOMAINS is not present, single-domain mode is used (backward compatible). DOMAINS: Loading @@ -64,7 +68,9 @@ BLOGI: TEMPLATE: "/usr/local/share/blogi/themes/default" STARTPAGE: "/blog/content/index" HTTP: URL: "https://blog.example.com" URL: - "https://blog.example.com" - "https://www.example.com" PREFIX: "/blog" PLUGINDIR: - "/usr/local/lib/blogi/plugins" Loading @@ -74,8 +80,6 @@ BLOGI: CLIENTSECRET: "clientsecret" MEDIADB: URL: "https://127.0.0.1:8442" COOKIE: DOMAIN: ".example.com" - NAME: "blog.another.org" DATABASE: DRIVER: "sqlite" Loading @@ -83,7 +87,8 @@ BLOGI: TEMPLATE: "/usr/local/share/blogi/themes/dark" STARTPAGE: "/content/index" HTTP: URL: "https://blog.another.org" URL: - "https://blog.another.org" PREFIX: "" PLUGINDIR: - "/usr/local/lib/blogi/plugins" Loading @@ -93,5 +98,3 @@ BLOGI: CLIENTSECRET: "anothersecret" MEDIADB: URL: "https://media.another.org:8442" COOKIE: DOMAIN: ".another.org"
docker-config.yaml +2 −6 Original line number Diff line number Diff line Loading @@ -6,8 +6,8 @@ BLOGI: - "/usr/local/lib/blogi/plugins" - "/var/lib/blog/plugins" HTTP: URL: "%REMOTEURL%" DOMAIN: "%HOSTNAME%" URL: - "%REMOTEURL%" PREFIX: "%PREFIX%" PORT: "%PORT%" BIND: "%BINDADDR%" Loading @@ -18,10 +18,6 @@ BLOGI: MEDIADB: URL: "%MEDIADBURL%" TMPDIR: "%TMPDIR%" COOKIE: DOMAIN: "%COOKIEDOMAIN%" DOMAIN: NAME: "%COOKIE%" SSL: CERT: "%SSLCERT%" KEY: "%SSLKEY%" Loading
plugins/CMakeLists.txt +1 −0 Original line number Diff line number Diff line Loading @@ -11,3 +11,4 @@ add_subdirectory(contact) add_subdirectory(survey) add_subdirectory(gtm) add_subdirectory(social) add_subdirectory(letsencrypt)
plugins/letsencrypt/CMakeLists.txt 0 → 100644 +13 −0 Original line number Diff line number Diff line find_package(json-c REQUIRED) add_library(letsencrypt SHARED letsencrypt.cpp) add_dependencies(letsencrypt blogidev) if(${CMAKE_HOST_SYSTEM_NAME} STREQUAL "Windows") target_link_libraries(letsencrypt PUBLIC blogidev uuidp::uuidp kernel32.lib json-c::json-c) else() target_link_libraries(letsencrypt PUBLIC blogidev uuidp::uuidp dl blogidev json-c::json-c) endif() install (TARGETS letsencrypt DESTINATION lib/blogi/plugins)
plugins/letsencrypt/letsencrypt.cpp 0 → 100644 +271 −0 Original line number Diff line number Diff line /******************************************************************************* * Copyright (c) 2026, Jan Koester jan.koester@gmx.net * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ #include <iostream> #include <string> #include <vector> #include <mutex> #include <map> #include <thread> #include <atomic> #include <chrono> #include <fstream> #include <sstream> #include <cstring> #include <filesystem> #include <json-c/json.h> #include <httppp/http.h> #include <htmlpp/html.h> #include <netplus/exception.h> #include "plugin.h" #include "conf.h" namespace blogi { // ACME challenge token store: token -> key_authorization static std::mutex s_challenge_mutex; static std::map<std::string, std::string> s_challenges; class LetsEncrypt : public PluginApi { public: LetsEncrypt() : _running(false) {} ~LetsEncrypt() { _running = false; if (_renewThread.joinable()) _renewThread.join(); } const std::string getName() { return "letsencrypt"; } const std::string getVersion() { return "1.0"; } const std::string getAuthor() { return "Jan Koester"; } void initPlugin() { // Read config: LETSENCRYPT->DOMAIN (array), LETSENCRYPT->EMAIL (string) try { auto *domKey = Args->config->getKey("/BLOGI/LETSENCRYPT/DOMAIN"); size_t count = Args->config->getElements(domKey); for (size_t i = 0; i < count; ++i) _domains.push_back(Args->config->getValue(domKey, i)); } catch (...) {} try { _email = Args->config->getValue(Args->config->getKey("/BLOGI/LETSENCRYPT/EMAIL"), 0); } catch (...) {} try { _certDir = Args->config->getValue(Args->config->getKey("/BLOGI/LETSENCRYPT/CERTDIR"), 0); } catch (...) { _certDir = "/etc/blogi/certs"; } try { _acmeUrl = Args->config->getValue(Args->config->getKey("/BLOGI/LETSENCRYPT/ACMEURL"), 0); } catch (...) { _acmeUrl = "https://acme-v02.api.letsencrypt.org/directory"; } if (_domains.empty() || _email.empty()) { std::cerr << "[letsencrypt] No domains or email configured, plugin inactive." << std::endl; return; } std::cerr << "[letsencrypt] Configured for " << _domains.size() << " domain(s), email: " << _email << std::endl; for (const auto &d : _domains) std::cerr << "[letsencrypt] - " << d << std::endl; // Start background renewal thread _running = true; _renewThread = std::thread(&LetsEncrypt::renewalLoop, this); } bool Response(const int tid, libhttppp::HttpRequest &req) { // Serve ACME HTTP-01 challenge responses std::string url = req.getRequestURL(); const std::string prefix = "/.well-known/acme-challenge/"; if (url.compare(0, prefix.size(), prefix) != 0) return false; std::string token = url.substr(prefix.size()); if (token.empty()) return false; std::lock_guard<std::mutex> lock(s_challenge_mutex); auto it = s_challenges.find(token); if (it == s_challenges.end()) return false; // Respond with key authorization libhttppp::HttpResponse resp; resp.setState(200); resp.setContentType("text/plain"); resp.send(req, it->second.c_str(), it->second.size()); return true; } // Register a challenge token (called by the ACME client logic) static void registerChallenge(const std::string &token, const std::string &keyAuth) { std::lock_guard<std::mutex> lock(s_challenge_mutex); s_challenges[token] = keyAuth; } static void removeChallenge(const std::string &token) { std::lock_guard<std::mutex> lock(s_challenge_mutex); s_challenges.erase(token); } bool haveSettings() { return true; } json_object* Settings(const int tid, libhttppp::HttpRequest &req, const std::string &sessionid) { json_object *jroot = json_object_new_object(); json_object_object_add(jroot, "title", json_object_new_string("Let's Encrypt")); json_object *jinfo = json_object_new_object(); json_object_object_add(jinfo, "email", json_object_new_string(_email.c_str())); json_object *jdomains = json_object_new_array(); for (const auto &d : _domains) json_object_array_add(jdomains, json_object_new_string(d.c_str())); json_object_object_add(jinfo, "domains", jdomains); json_object_object_add(jinfo, "certdir", json_object_new_string(_certDir.c_str())); json_object_object_add(jinfo, "running", json_object_new_boolean(_running.load())); json_object_object_add(jroot, "info", jinfo); return jroot; } private: std::vector<std::string> _domains; std::string _email; std::string _certDir; std::string _acmeUrl; std::atomic<bool> _running; std::thread _renewThread; void renewalLoop() { // Initial delay to let the server start std::this_thread::sleep_for(std::chrono::seconds(10)); while (_running) { for (const auto &domain : _domains) { if (!_running) break; try { if (needsRenewal(domain)) { std::cerr << "[letsencrypt] Requesting certificate for: " << domain << std::endl; requestCertificate(domain); } } catch (const std::exception &e) { std::cerr << "[letsencrypt] Error renewing " << domain << ": " << e.what() << std::endl; } } // Check every 12 hours for (int i = 0; i < 720 && _running; ++i) std::this_thread::sleep_for(std::chrono::minutes(1)); } } bool needsRenewal(const std::string &domain) { std::string certPath = _certDir + "/" + domain + "/fullchain.pem"; if (!std::filesystem::exists(certPath)) return true; // Check if cert expires within 30 days auto ftime = std::filesystem::last_write_time(certPath); auto sctp = std::chrono::clock_cast<std::chrono::system_clock>(ftime); auto age = std::chrono::system_clock::now() - sctp; // Let's Encrypt certs valid for 90 days, renew at 60 days return age > std::chrono::hours(60 * 24); } void requestCertificate(const std::string &domain) { // Ensure cert directory exists std::string domainDir = _certDir + "/" + domain; std::filesystem::create_directories(domainDir); // Generate account key (EC P-256) if not present std::string accountKeyPath = _certDir + "/account.key"; std::vector<uint8_t> privKey; if (std::filesystem::exists(accountKeyPath)) { std::ifstream f(accountKeyPath, std::ios::binary); privKey.assign(std::istreambuf_iterator<char>(f), {}); } else { privKey = generateP256Key(); std::ofstream f(accountKeyPath, std::ios::binary); f.write(reinterpret_cast<const char*>(privKey.data()), privKey.size()); // Set restrictive permissions std::filesystem::permissions(accountKeyPath, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write); } // ACME flow: // 1. GET directory // 2. POST newNonce // 3. POST newAccount // 4. POST newOrder (with domain) // 5. GET authorizations // 6. Respond to HTTP-01 challenge // 7. POST finalize (with CSR) // 8. GET certificate std::cerr << "[letsencrypt] ACME flow for " << domain << " (account: " << _email << ")" << std::endl; // The actual ACME protocol implementation uses the HTTP client // from libhttppp to communicate with the ACME server. // Challenge tokens are registered via registerChallenge() so the // Response() method can serve them to the ACME validator. // TODO: Full ACME v2 protocol implementation // For now, log that renewal was attempted std::cerr << "[letsencrypt] Certificate request for " << domain << " requires ACME v2 client implementation." << std::endl; } static std::vector<uint8_t> generateP256Key() { // Generate a random 32-byte private key for P-256 std::vector<uint8_t> key(32); std::ifstream urandom("/dev/urandom", std::ios::binary); urandom.read(reinterpret_cast<char*>(key.data()), 32); return key; } }; } EXPORT blogi::PluginApi* create(){ return new blogi::LetsEncrypt(); } EXPORT void destroy(blogi::PluginApi* p){ delete p; }