Commit 416e23be authored by jan.koester's avatar jan.koester
Browse files

test

parent 74d81071
Loading
Loading
Loading
Loading
+11 −0
Changes for CMakeLists.txt: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -24,6 +24,14 @@ find_package(json-c REQUIRED)

find_package(Threads REQUIRED)

# --- Response compression (gzip/brotli, src/http/HttpCompression.cpp) ---
# No CMake config package for either upstream, and no bundled FindBrotli module -- zlib gets
# CMake's own FindZLIB, brotli goes through pkg-config (libbrotlienc/libbrotlidec, Debian
# package libbrotli-dev).
find_package(ZLIB REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(BROTLI REQUIRED IMPORTED_TARGET libbrotlienc libbrotlidec)

# --- DKIM / DMARC (see docs/ARCHITECTURE.md §8) ---
# Own RFC 6376 / RFC 7489 implementation on top of libnetplus's RSA/SHA256/
# base64 primitives and a small hand-rolled DNS TXT resolver -- no dependency
@@ -36,6 +44,7 @@ set(PROXYPLUS_CORE_SOURCES
    src/cluster/ClusterManager.cpp
    src/plugin/PluginManager.cpp
    src/http/HttpProxyServer.cpp
    src/http/HttpCompression.cpp
    src/smtp/SmtpProxyServer.cpp
    src/api/JsonApiServer.cpp
    src/security/DnsTxtResolver.cpp
@@ -61,6 +70,8 @@ target_link_libraries(proxyplus_core PUBLIC
    uuidp::uuidp
    json-c::json-c
    Threads::Threads
    ZLIB::ZLIB
    PkgConfig::BROTLI
    ${CMAKE_DL_LIBS}
)

+214 −0
Changes for src/http/HttpCompression.cpp: 214 added lines, 0 removed lines.
Original line number Diff line number Diff line
#include "HttpCompression.h"

#include <algorithm>
#include <array>
#include <cctype>
#include <cstdlib>
#include <set>

#include <zlib.h>

#include <brotli/decode.h>
#include <brotli/encode.h>

namespace proxyplus::httpproxy::compression {

namespace {

std::string toLower(const std::string &s) {
    std::string out(s);
    std::transform(out.begin(), out.end(), out.begin(),
                    [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
    return out;
}

std::string trim(const std::string &s) {
    size_t start = 0, end = s.size();
    while (start < end && std::isspace(static_cast<unsigned char>(s[start]))) ++start;
    while (end > start && std::isspace(static_cast<unsigned char>(s[end - 1]))) --end;
    return s.substr(start, end - start);
}

struct Preference {
    double q = 1.0;
    bool present = false;
};

} // namespace

Encoding fromToken(const std::string &contentEncoding) {
    std::string tok = trim(toLower(contentEncoding));
    if (tok.empty() || tok == "identity") return Encoding::None;
    if (tok == "gzip" || tok == "x-gzip") return Encoding::Gzip;
    if (tok == "br") return Encoding::Brotli;
    return Encoding::Other;
}

const char *toToken(Encoding enc) {
    switch (enc) {
        case Encoding::Gzip: return "gzip";
        case Encoding::Brotli: return "br";
        default: return "";
    }
}

Encoding negotiate(const std::string &acceptEncoding) {
    if (acceptEncoding.empty()) return Encoding::None;

    Preference br, gzip, star;
    size_t pos = 0;
    while (pos <= acceptEncoding.size()) {
        size_t comma = acceptEncoding.find(',', pos);
        std::string part = acceptEncoding.substr(pos, comma == std::string::npos
                                                            ? std::string::npos
                                                            : comma - pos);
        pos = (comma == std::string::npos) ? acceptEncoding.size() + 1 : comma + 1;
        if (part.empty()) continue;

        size_t semi = part.find(';');
        std::string name = toLower(trim(part.substr(0, semi)));
        double q = 1.0;
        if (semi != std::string::npos) {
            std::string qpart = trim(part.substr(semi + 1));
            size_t eq = qpart.find('=');
            if (eq != std::string::npos) {
                try {
                    q = std::stod(trim(qpart.substr(eq + 1)));
                } catch (const std::exception &) {
                    q = 1.0;
                }
            }
        }

        if (name == "br") {
            br = {q, true};
        } else if (name == "gzip" || name == "x-gzip") {
            gzip = {q, true};
        } else if (name == "*") {
            star = {q, true};
        }
        if (comma == std::string::npos) break;
    }

    // A token not explicitly listed falls back to "*"'s preference, if present, per RFC
    // 7231 §5.3.4.
    double brQ = br.present ? br.q : (star.present ? star.q : 0.0);
    double gzipQ = gzip.present ? gzip.q : (star.present ? star.q : 0.0);
    bool brOk = brQ > 0.0;
    bool gzipOk = gzipQ > 0.0;

    if (brOk && gzipOk) return brQ >= gzipQ ? Encoding::Brotli : Encoding::Gzip;
    if (brOk) return Encoding::Brotli;
    if (gzipOk) return Encoding::Gzip;
    return Encoding::None;
}

bool isCompressible(const std::string &contentType) {
    if (contentType.empty()) return false;
    if (contentType.rfind("text/", 0) == 0) return true;

    static const std::set<std::string> kAlso = {
        "application/json",
        "application/javascript",
        "application/x-javascript",
        "application/xml",
        "application/xhtml+xml",
        "application/rss+xml",
        "application/atom+xml",
        "image/svg+xml",
    };
    return kAlso.count(contentType) != 0;
}

bool gzipCompress(const std::vector<char> &in, std::vector<char> &out, int level) {
    z_stream strm{};
    // windowBits 15+16 asks zlib for a gzip (not raw-deflate/zlib) wrapper.
    if (deflateInit2(&strm, level, Z_DEFLATED, 15 + 16, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
        return false;
    }

    out.resize(deflateBound(&strm, static_cast<uLong>(in.size())));
    strm.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(in.data()));
    strm.avail_in = static_cast<uInt>(in.size());
    strm.next_out = reinterpret_cast<Bytef *>(out.data());
    strm.avail_out = static_cast<uInt>(out.size());

    bool ok = deflate(&strm, Z_FINISH) == Z_STREAM_END;
    out.resize(ok ? strm.total_out : 0);
    deflateEnd(&strm);
    return ok;
}

bool gzipDecompress(const std::vector<char> &in, std::vector<char> &out) {
    z_stream strm{};
    // windowBits 15+32 auto-detects a gzip or zlib header.
    if (inflateInit2(&strm, 15 + 32) != Z_OK) return false;

    strm.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(in.data()));
    strm.avail_in = static_cast<uInt>(in.size());

    out.clear();
    std::array<char, 64 * 1024> chunk;
    int ret = Z_OK;
    do {
        strm.next_out = reinterpret_cast<Bytef *>(chunk.data());
        strm.avail_out = static_cast<uInt>(chunk.size());
        ret = inflate(&strm, Z_NO_FLUSH);
        if (ret != Z_OK && ret != Z_STREAM_END && ret != Z_BUF_ERROR) {
            inflateEnd(&strm);
            return false;
        }
        out.insert(out.end(), chunk.data(), chunk.data() + (chunk.size() - strm.avail_out));
    } while (ret != Z_STREAM_END && strm.avail_in > 0);

    inflateEnd(&strm);
    return ret == Z_STREAM_END;
}

bool brotliCompress(const std::vector<char> &in, std::vector<char> &out, int quality) {
    size_t bound = BrotliEncoderMaxCompressedSize(in.size());
    if (bound == 0) bound = 64; // MaxCompressedSize documents 0 as "input too large to bound";
                                // never true here, but keep resize() from truncating to empty.
    out.resize(bound);
    size_t encodedSize = out.size();

    BROTLI_BOOL ok = BrotliEncoderCompress(
        quality, BROTLI_DEFAULT_WINDOW, BROTLI_MODE_GENERIC, in.size(),
        reinterpret_cast<const uint8_t *>(in.data()), &encodedSize,
        reinterpret_cast<uint8_t *>(out.data()));
    if (!ok) {
        out.clear();
        return false;
    }
    out.resize(encodedSize);
    return true;
}

bool brotliDecompress(const std::vector<char> &in, std::vector<char> &out) {
    BrotliDecoderState *state = BrotliDecoderCreateInstance(nullptr, nullptr, nullptr);
    if (!state) return false;

    out.clear();
    const uint8_t *nextIn = reinterpret_cast<const uint8_t *>(in.data());
    size_t availIn = in.size();
    std::array<uint8_t, 64 * 1024> chunk;
    BrotliDecoderResult result;
    do {
        uint8_t *nextOut = chunk.data();
        size_t availOut = chunk.size();
        result = BrotliDecoderDecompressStream(state, &availIn, &nextIn, &availOut, &nextOut,
                                                nullptr);
        out.insert(out.end(), reinterpret_cast<char *>(chunk.data()),
                   reinterpret_cast<char *>(chunk.data() + (chunk.size() - availOut)));
        if (result == BROTLI_DECODER_RESULT_ERROR) {
            BrotliDecoderDestroyInstance(state);
            return false;
        }
    } while (result == BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT);

    bool ok = result == BROTLI_DECODER_RESULT_SUCCESS;
    BrotliDecoderDestroyInstance(state);
    return ok;
}

} // namespace proxyplus::httpproxy::compression
+38 −0
Changes for src/http/HttpCompression.h: 38 added lines, 0 removed lines.
Original line number Diff line number Diff line
#pragma once

#include <string>
#include <vector>

namespace proxyplus::httpproxy::compression {

// None: no encoding (or "identity") -- send as-is.
// Gzip/Brotli: an encoding this proxy can itself produce and consume.
// Other: some encoding present that isn't gzip or brotli (e.g. "deflate", "zstd", or a
// comma-joined list) -- opaque to this module, callers must leave the body untouched.
enum class Encoding { None, Gzip, Brotli, Other };

// Maps a single Content-Encoding token (case-insensitive) to Encoding.
Encoding fromToken(const std::string &contentEncoding);

// Content-Encoding token this proxy would send for enc ("gzip"/"br"). Only valid for
// Gzip/Brotli.
const char *toToken(Encoding enc);

// Picks the best encoding this proxy can produce for a client's Accept-Encoding header,
// honoring q-values (q=0 excludes) and "*" per RFC 7231 §5.3.4. Prefers Brotli over gzip
// when both are acceptable with equal preference (better ratio at comparable CPU cost).
// Returns Encoding::None if the header is empty/absent or nothing acceptable remains.
Encoding negotiate(const std::string &acceptEncoding);

// True if contentType (as returned by HttpResponse::getContentType() -- already lowercased,
// parameters stripped) is worth spending CPU to compress. Binary/already-compressed formats
// (images, video, archives) are excluded.
bool isCompressible(const std::string &contentType);

bool gzipCompress(const std::vector<char> &in, std::vector<char> &out, int level = 6);
bool gzipDecompress(const std::vector<char> &in, std::vector<char> &out);

bool brotliCompress(const std::vector<char> &in, std::vector<char> &out, int quality = 5);
bool brotliDecompress(const std::vector<char> &in, std::vector<char> &out);

} // namespace proxyplus::httpproxy::compression
+70 −0
Changes for src/http/HttpProxyServer.cpp: 70 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -60,6 +60,70 @@ bool HttpProxyController::tryServeAcmeChallenge(libhttppp::HttpRequest &curreq,
    return true;
}

namespace {
// Below this, gzip/brotli framing overhead eats whatever the algorithm saves -- matches
// nginx's gzip_min_length default.
constexpr size_t kMinCompressibleBytes = 256;
} // namespace

void HttpProxyController::applyResponseCompression(libhttppp::HttpRequest &curreq,
                                                     const std::string &upstreamEncoding,
                                                     std::vector<char> &body,
                                                     libhttppp::HttpResponse &curres) {
    using namespace compression;

    std::string acceptEncoding;
    if (auto *hd = curreq.getHeaderData("accept-encoding")) {
        if (auto *v = hd->getfirstValue()) acceptEncoding = v->getvalue();
    }

    Encoding upstreamEnc = fromToken(upstreamEncoding);
    if (upstreamEnc == Encoding::Other) {
        // Some encoding we don't speak (deflate/zstd/a comma-joined list) -- leave body and
        // headers exactly as the upstream sent them rather than risk corrupting it.
        return;
    }

    Encoding wanted = negotiate(acceptEncoding);

    if (upstreamEnc != Encoding::None) {
        if (upstreamEnc == wanted) {
            // Client can consume this unchanged -- RequestEvent only copies status/
            // content-type from the upstream response above, so the encoding header
            // itself still needs to be carried forward here.
            curres.setHeaderData("content-encoding")->push_back(upstreamEncoding);
            curres.setHeaderData("vary")->push_back("Accept-Encoding");
            return;
        }

        std::vector<char> decoded;
        bool decodedOk = (upstreamEnc == Encoding::Gzip) ? gzipDecompress(body, decoded)
                                                           : brotliDecompress(body, decoded);
        if (!decodedOk) {
            // Can't transcode or strip it -- forward exactly what the upstream sent
            // rather than deliver encoded bytes mislabeled as plain content.
            curres.setHeaderData("content-encoding")->push_back(upstreamEncoding);
            return;
        }
        body.swap(decoded);
        // Falls through to (re)compress the now-decoded body into what the client asked
        // for, e.g. an upstream that always gzips gets transcoded to brotli on request.
    }

    if (wanted == Encoding::None) return;
    if (body.size() < kMinCompressibleBytes) return;
    if (!isCompressible(curres.getContentType())) return;

    std::vector<char> compressed;
    bool compressedOk = (wanted == Encoding::Brotli) ? brotliCompress(body, compressed)
                                                       : gzipCompress(body, compressed);
    if (!compressedOk) return;

    body.swap(compressed);
    curres.setHeaderData("content-encoding")->push_back(toToken(wanted));
    curres.setHeaderData("vary")->push_back("Accept-Encoding");
}

std::string HttpProxyController::sourceIpOf(libhttppp::HttpRequest &curreq) const {
    std::string addr;
    if (!curreq.slots.empty() && curreq.slots[0].csock) {
@@ -285,6 +349,12 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int
                curres.setState(reason.empty() ? std::to_string(status)
                                                : std::to_string(status) + " " + reason);
                curres.setContentType(last->getContentType());

                std::string upstreamEncoding;
                if (auto *hd = last->getHeaderData("content-encoding")) {
                    if (auto *v = hd->getfirstValue()) upstreamEncoding = v->getvalue();
                }
                applyResponseCompression(curreq, upstreamEncoding, body, curres);
            } else {
                curres.setState(HTTP200);
            }
+11 −0
Changes for src/http/HttpProxyServer.h: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -13,6 +13,7 @@
#include "../plugin/PluginManager.h"
#include "../store/ConfigStore.h"
#include "../tls/AcmeChallengeStore.h"
#include "HttpCompression.h"

namespace proxyplus::httpproxy {

@@ -47,6 +48,16 @@ private:

    bool tryServeAcmeChallenge(libhttppp::HttpRequest &curreq, const std::string &url);

    // Negotiates gzip/brotli against the client's Accept-Encoding and applies it to body
    // in place, setting Content-Encoding/Vary on curres to match. upstreamEncoding is the
    // upstream response's own Content-Encoding value (empty if none) -- an upstream that
    // already compressed the body is decoded first when the client can't consume that
    // encoding, so this doubles as gzip<->brotli transcoding, not just passthrough.
    void applyResponseCompression(libhttppp::HttpRequest &curreq,
                                   const std::string &upstreamEncoding,
                                   std::vector<char> &body,
                                   libhttppp::HttpResponse &curres);

    std::string _listenerLabel;
    bool _tlsActive;
    store::ConfigStore &_configStore;
Loading