Commit 02eace44 authored by jan.koester's avatar jan.koester
Browse files

test

parent ac3e4d71
Loading
Loading
Loading
Loading
+15 −12
Original line number Diff line number Diff line
@@ -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"
@@ -35,8 +35,6 @@ BLOGI:
    SSL: False
    TIMEOUT: 10
  com: "/tmp/blogi"
  COOKIE:
    DOMAIN: "localhost"
  SMTP:
    ADDR: "10.1.2.203"
    PORT: 587
@@ -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:
@@ -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"
@@ -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"
@@ -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"
@@ -93,5 +98,3 @@ BLOGI:
        CLIENTSECRET: "anothersecret"
      MEDIADB:
        URL: "https://media.another.org:8442"
      COOKIE:
        DOMAIN: ".another.org"
+2 −6
Original line number Diff line number Diff line
@@ -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%"
@@ -18,10 +18,6 @@ BLOGI:
  MEDIADB:
    URL: "%MEDIADBURL%"
  TMPDIR: "%TMPDIR%"
  COOKIE:
    DOMAIN: "%COOKIEDOMAIN%"
  DOMAIN:
    NAME: "%COOKIE%"
  SSL:
    CERT: "%SSLCERT%"
    KEY: "%SSLKEY%"
+1 −0
Original line number Diff line number Diff line
@@ -11,3 +11,4 @@ add_subdirectory(contact)
add_subdirectory(survey)
add_subdirectory(gtm)
add_subdirectory(social)
add_subdirectory(letsencrypt)
+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)
+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