Loading CMakeLists.txt +1 −0 Changes for CMakeLists.txt: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -39,6 +39,7 @@ set(PROXYPLUS_CORE_SOURCES src/plugin/PluginManager.cpp src/http/HttpProxyPath.cpp src/http/HttpProxyServer.cpp src/http/UpstreamConnectionPool.cpp src/http/WebSocketProxy.cpp src/smtp/SmtpProxyServer.cpp src/api/JsonApiServer.cpp Loading config/proxyplus.yaml +6 −0 Changes for config/proxyplus.yaml: 6 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -71,6 +71,12 @@ proxyplus: # fd-exhaustion incident max_open_files guards against). 0 = never time out idle # connections (the old, pre-fix behavior). idle_connection_timeout_seconds: 120 # Upstream connection pooling: idle already-open connections to a backend are reused # instead of paying a fresh TCP/TLS(/HTTP-3 probe) handshake per proxied request. # max_idle_per_origin caps how many idle connections stay open per upstream origin at # once; idle_timeout_seconds discards (rather than reuses) one that's sat idle longer. upstream_pool_max_idle_per_origin: 8 upstream_pool_idle_timeout_seconds: 60 pid_file: "/run/proxyplus.pid" recovery_mode: false Loading src/config/BootstrapConfig.cpp +6 −0 Changes for src/config/BootstrapConfig.cpp: 6 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -50,6 +50,8 @@ const std::set<std::string> &knownLeafPatterns() { "/proxyplus/process/group", "/proxyplus/process/max_open_files", "/proxyplus/process/idle_connection_timeout_seconds", "/proxyplus/process/upstream_pool_max_idle_per_origin", "/proxyplus/process/upstream_pool_idle_timeout_seconds", "/proxyplus/pid_file", "/proxyplus/recovery_mode", "/proxyplus/maintenance_mode", Loading Loading @@ -226,6 +228,10 @@ void BootstrapConfig::loadAndValidate() { cfg.maxOpenFiles = readInt(conf, "/proxyplus/process/max_open_files", 65536); cfg.idleConnectionTimeoutSeconds = readInt(conf, "/proxyplus/process/idle_connection_timeout_seconds", 120); cfg.upstreamPoolMaxIdlePerOrigin = readInt(conf, "/proxyplus/process/upstream_pool_max_idle_per_origin", 8); cfg.upstreamPoolIdleTimeoutSeconds = readInt(conf, "/proxyplus/process/upstream_pool_idle_timeout_seconds", 60); cfg.pidFile = readValue(conf, "/proxyplus/pid_file", ""); cfg.recoveryMode = readBool(conf, "/proxyplus/recovery_mode", false) ? RecoveryMode::Recover Loading src/config/BootstrapConfig.h +11 −0 Changes for src/config/BootstrapConfig.h: 11 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -99,6 +99,17 @@ struct AppConfig { // "disabled" for anyone who explicitly wants the old always-open behavior. int idleConnectionTimeoutSeconds = 120; // UpstreamConnectionPool sizing (see src/http/UpstreamConnectionPool.h) -- reusing already- // open upstream connections instead of a fresh TCP/TLS(/HTTP-3 probe) handshake per proxied // request. Positive by default for the same reason idleConnectionTimeoutSeconds above is: // this fixes a real production overload (bursty real traffic spiking upstream connect() to // 100+/sec against a steady handful of client connections/sec, starving whatever request -- // most visibly a wss:// upgrade -- queued up behind the burst) out of the box. // maxIdlePerOrigin caps how many idle connections stay open per upstream origin at once; // idleTimeoutSeconds discards (rather than reuses) one that's sat idle longer than that. int upstreamPoolMaxIdlePerOrigin = 8; int upstreamPoolIdleTimeoutSeconds = 60; RecoveryMode recoveryMode = RecoveryMode::Normal; MaintenanceMode maintenanceMode = MaintenanceMode::Off; }; Loading src/http/HttpProxyServer.cpp +51 −5 Changes for src/http/HttpProxyServer.cpp: 51 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -113,6 +113,7 @@ HttpProxyController::HttpProxyController(std::vector<netplus::socket *> serverSo auth::IAuthService &authService, plugin::PluginManager &pluginManager, lb::LoadBalancer &loadBalancer, http::UpstreamConnectionPool &connectionPool, tls::AcmeChallengeStore *acmeChallenges, int idleTimeoutSeconds) : libhttppp::HttpEvent(std::move(serverSockets), /*timeout=*/1000, /*h2OffloadThreads=*/0, Loading @@ -123,6 +124,7 @@ HttpProxyController::HttpProxyController(std::vector<netplus::socket *> serverSo , _authService(authService) , _pluginManager(pluginManager) , _loadBalancer(loadBalancer) , _connectionPool(connectionPool) , _acmeChallenges(acmeChallenges) {} bool HttpProxyController::tryServeAcmeChallenge(libhttppp::HttpRequest &curreq, Loading Loading @@ -177,6 +179,32 @@ struct ScopedLbRelease { if (lb) lb->release(pool, target, ok); } }; // Mirrors ScopedLbRelease above: inert until arm() is called (once acquire() below has // actually produced a connection to give back), then guarantees the checked-out HttpClient is // either returned to the pool (ok=true, set only once the whole request/response cycle // completed without throwing) or simply destroyed (ok=false, e.g. an exception unwound past // this point) -- never both and never neither, regardless of how RequestEvent exits. struct ScopedPoolRelease { proxyplus::http::UpstreamConnectionPool *pool = nullptr; libhttppp::HttpUrl origin; std::unique_ptr<libhttppp::HttpClient> client; bool ok = false; void arm(proxyplus::http::UpstreamConnectionPool &connectionPool, const libhttppp::HttpUrl &upstreamOrigin, std::unique_ptr<libhttppp::HttpClient> checkedOut) { pool = &connectionPool; origin = upstreamOrigin; client = std::move(checkedOut); } ~ScopedPoolRelease() { if (pool && client && ok) pool->release(std::move(client), origin); // else: client's own destructor runs here, closing the connection -- exactly what // happened before pooling existed. } }; } // namespace void HttpProxyController::applyResponseCompression(libhttppp::HttpRequest &curreq, Loading Loading @@ -426,12 +454,19 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int } bool upstreamOk = false; ScopedPoolRelease poolGuard; // inert unless acquire() below actually hands out a connection try { libhttppp::HttpUrl upstream(upstreamBase + forwardPath); // vers=3: try HTTP/3 first, falling back to HTTP/2 then HTTP/1.1 over // TLS (or straight to H1 for a cleartext upstream) -- see HttpClient // Reuse an already-open connection to this origin if the pool has an idle one // (see UpstreamConnectionPool -- this is what avoids paying a fresh TCP/TLS(/HTTP-3 // probe) handshake on every single proxied request); vers=3: try HTTP/3 first, // falling back to HTTP/2 then HTTP/1.1 over TLS (or straight to H1 for a cleartext // upstream) when a new connection actually needs constructing -- see HttpClient // ctor in libhttppp/src/http.cpp for the fallback ladder. libhttppp::HttpClient client(upstream, /*vers=*/3, /*timeoutSec=*/60, trustPolicyFor(matched)); auto clientPtr = _connectionPool.acquire(upstream, /*vers=*/3, /*timeoutSec=*/60, trustPolicyFor(matched)); libhttppp::HttpClient &client = *clientPtr; poolGuard.arm(_connectionPool, upstream, std::move(clientPtr)); // A reverse proxy must hand 3xx responses straight back to the real client // (which owns cookie storage/navigation), not chase them itself -- HttpClient // otherwise follows redirects like a browser and silently swallows the Loading Loading @@ -461,7 +496,12 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int } } outreq.setHeaderData("host")->push_back(upstream.getHost()); outreq.setHeaderData("connection")->push_back("close"); // Deliberately no "Connection: close" here (unlike before pooling existed) -- // forcing that on every request is exactly what defeated reuse; the upstream is now // allowed to keep the connection alive so UpstreamConnectionPool can hand it back // out for a later request. If the upstream itself sends "Connection: close" (or an // H2 GOAWAY) in its response, HttpClient::wantsClose() picks that up and the pool // won't reuse that instance regardless (see ScopedPoolRelease below). // Standard reverse-proxy hints for the upstream: X-Forwarded-For chains (append to // whatever a prior proxy already put there, via the copy the loop above already Loading Loading @@ -555,6 +595,7 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int } lbGuard.ok = upstreamOk; poolGuard.ok = upstreamOk; } catch (const libhttppp::HTTPException &e) { std::cerr << "[http] request handling failed: " << e.what() << std::endl; } Loading @@ -565,6 +606,7 @@ HttpProxyServer::HttpProxyServer(config::ListenerConfig listener, std::string ce auth::IAuthService &authService, plugin::PluginManager &pluginManager, lb::LoadBalancer &loadBalancer, http::UpstreamConnectionPool &connectionPool, tls::AcmeChallengeStore *acmeChallenges, int idleTimeoutSeconds) : _listener(listener) Loading @@ -575,6 +617,7 @@ HttpProxyServer::HttpProxyServer(config::ListenerConfig listener, std::string ce , _authService(authService) , _pluginManager(pluginManager) , _loadBalancer(loadBalancer) , _connectionPool(connectionPool) , _acmeChallenges(acmeChallenges) , _idleTimeoutSeconds(idleTimeoutSeconds) { bindAndListen(); Loading @@ -584,6 +627,7 @@ HttpProxyServer::HttpProxyServer(config::ListenerConfig listener, const std::map<std::string, netplus::ssl::CertificateBundle> &certsByHostname, store::ConfigStore &configStore, auth::IAuthService &authService, plugin::PluginManager &pluginManager, lb::LoadBalancer &loadBalancer, http::UpstreamConnectionPool &connectionPool, tls::AcmeChallengeStore *acmeChallenges, int idleTimeoutSeconds) : _listener(listener) Loading @@ -593,6 +637,7 @@ HttpProxyServer::HttpProxyServer(config::ListenerConfig listener, , _authService(authService) , _pluginManager(pluginManager) , _loadBalancer(loadBalancer) , _connectionPool(connectionPool) , _acmeChallenges(acmeChallenges) , _idleTimeoutSeconds(idleTimeoutSeconds) { bindAndListen(); Loading @@ -613,7 +658,8 @@ void HttpProxyServer::run() { HttpProxyController controller(_httpd->getServerSockets(), _listener.address + ":" + std::to_string(_listener.port), _tlsActive, _configStore, _authService, _pluginManager, _loadBalancer, _acmeChallenges, _idleTimeoutSeconds); _loadBalancer, _connectionPool, _acmeChallenges, _idleTimeoutSeconds); controller.runEventloop(); } Loading Loading
CMakeLists.txt +1 −0 Changes for CMakeLists.txt: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -39,6 +39,7 @@ set(PROXYPLUS_CORE_SOURCES src/plugin/PluginManager.cpp src/http/HttpProxyPath.cpp src/http/HttpProxyServer.cpp src/http/UpstreamConnectionPool.cpp src/http/WebSocketProxy.cpp src/smtp/SmtpProxyServer.cpp src/api/JsonApiServer.cpp Loading
config/proxyplus.yaml +6 −0 Changes for config/proxyplus.yaml: 6 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -71,6 +71,12 @@ proxyplus: # fd-exhaustion incident max_open_files guards against). 0 = never time out idle # connections (the old, pre-fix behavior). idle_connection_timeout_seconds: 120 # Upstream connection pooling: idle already-open connections to a backend are reused # instead of paying a fresh TCP/TLS(/HTTP-3 probe) handshake per proxied request. # max_idle_per_origin caps how many idle connections stay open per upstream origin at # once; idle_timeout_seconds discards (rather than reuses) one that's sat idle longer. upstream_pool_max_idle_per_origin: 8 upstream_pool_idle_timeout_seconds: 60 pid_file: "/run/proxyplus.pid" recovery_mode: false Loading
src/config/BootstrapConfig.cpp +6 −0 Changes for src/config/BootstrapConfig.cpp: 6 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -50,6 +50,8 @@ const std::set<std::string> &knownLeafPatterns() { "/proxyplus/process/group", "/proxyplus/process/max_open_files", "/proxyplus/process/idle_connection_timeout_seconds", "/proxyplus/process/upstream_pool_max_idle_per_origin", "/proxyplus/process/upstream_pool_idle_timeout_seconds", "/proxyplus/pid_file", "/proxyplus/recovery_mode", "/proxyplus/maintenance_mode", Loading Loading @@ -226,6 +228,10 @@ void BootstrapConfig::loadAndValidate() { cfg.maxOpenFiles = readInt(conf, "/proxyplus/process/max_open_files", 65536); cfg.idleConnectionTimeoutSeconds = readInt(conf, "/proxyplus/process/idle_connection_timeout_seconds", 120); cfg.upstreamPoolMaxIdlePerOrigin = readInt(conf, "/proxyplus/process/upstream_pool_max_idle_per_origin", 8); cfg.upstreamPoolIdleTimeoutSeconds = readInt(conf, "/proxyplus/process/upstream_pool_idle_timeout_seconds", 60); cfg.pidFile = readValue(conf, "/proxyplus/pid_file", ""); cfg.recoveryMode = readBool(conf, "/proxyplus/recovery_mode", false) ? RecoveryMode::Recover Loading
src/config/BootstrapConfig.h +11 −0 Changes for src/config/BootstrapConfig.h: 11 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -99,6 +99,17 @@ struct AppConfig { // "disabled" for anyone who explicitly wants the old always-open behavior. int idleConnectionTimeoutSeconds = 120; // UpstreamConnectionPool sizing (see src/http/UpstreamConnectionPool.h) -- reusing already- // open upstream connections instead of a fresh TCP/TLS(/HTTP-3 probe) handshake per proxied // request. Positive by default for the same reason idleConnectionTimeoutSeconds above is: // this fixes a real production overload (bursty real traffic spiking upstream connect() to // 100+/sec against a steady handful of client connections/sec, starving whatever request -- // most visibly a wss:// upgrade -- queued up behind the burst) out of the box. // maxIdlePerOrigin caps how many idle connections stay open per upstream origin at once; // idleTimeoutSeconds discards (rather than reuses) one that's sat idle longer than that. int upstreamPoolMaxIdlePerOrigin = 8; int upstreamPoolIdleTimeoutSeconds = 60; RecoveryMode recoveryMode = RecoveryMode::Normal; MaintenanceMode maintenanceMode = MaintenanceMode::Off; }; Loading
src/http/HttpProxyServer.cpp +51 −5 Changes for src/http/HttpProxyServer.cpp: 51 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -113,6 +113,7 @@ HttpProxyController::HttpProxyController(std::vector<netplus::socket *> serverSo auth::IAuthService &authService, plugin::PluginManager &pluginManager, lb::LoadBalancer &loadBalancer, http::UpstreamConnectionPool &connectionPool, tls::AcmeChallengeStore *acmeChallenges, int idleTimeoutSeconds) : libhttppp::HttpEvent(std::move(serverSockets), /*timeout=*/1000, /*h2OffloadThreads=*/0, Loading @@ -123,6 +124,7 @@ HttpProxyController::HttpProxyController(std::vector<netplus::socket *> serverSo , _authService(authService) , _pluginManager(pluginManager) , _loadBalancer(loadBalancer) , _connectionPool(connectionPool) , _acmeChallenges(acmeChallenges) {} bool HttpProxyController::tryServeAcmeChallenge(libhttppp::HttpRequest &curreq, Loading Loading @@ -177,6 +179,32 @@ struct ScopedLbRelease { if (lb) lb->release(pool, target, ok); } }; // Mirrors ScopedLbRelease above: inert until arm() is called (once acquire() below has // actually produced a connection to give back), then guarantees the checked-out HttpClient is // either returned to the pool (ok=true, set only once the whole request/response cycle // completed without throwing) or simply destroyed (ok=false, e.g. an exception unwound past // this point) -- never both and never neither, regardless of how RequestEvent exits. struct ScopedPoolRelease { proxyplus::http::UpstreamConnectionPool *pool = nullptr; libhttppp::HttpUrl origin; std::unique_ptr<libhttppp::HttpClient> client; bool ok = false; void arm(proxyplus::http::UpstreamConnectionPool &connectionPool, const libhttppp::HttpUrl &upstreamOrigin, std::unique_ptr<libhttppp::HttpClient> checkedOut) { pool = &connectionPool; origin = upstreamOrigin; client = std::move(checkedOut); } ~ScopedPoolRelease() { if (pool && client && ok) pool->release(std::move(client), origin); // else: client's own destructor runs here, closing the connection -- exactly what // happened before pooling existed. } }; } // namespace void HttpProxyController::applyResponseCompression(libhttppp::HttpRequest &curreq, Loading Loading @@ -426,12 +454,19 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int } bool upstreamOk = false; ScopedPoolRelease poolGuard; // inert unless acquire() below actually hands out a connection try { libhttppp::HttpUrl upstream(upstreamBase + forwardPath); // vers=3: try HTTP/3 first, falling back to HTTP/2 then HTTP/1.1 over // TLS (or straight to H1 for a cleartext upstream) -- see HttpClient // Reuse an already-open connection to this origin if the pool has an idle one // (see UpstreamConnectionPool -- this is what avoids paying a fresh TCP/TLS(/HTTP-3 // probe) handshake on every single proxied request); vers=3: try HTTP/3 first, // falling back to HTTP/2 then HTTP/1.1 over TLS (or straight to H1 for a cleartext // upstream) when a new connection actually needs constructing -- see HttpClient // ctor in libhttppp/src/http.cpp for the fallback ladder. libhttppp::HttpClient client(upstream, /*vers=*/3, /*timeoutSec=*/60, trustPolicyFor(matched)); auto clientPtr = _connectionPool.acquire(upstream, /*vers=*/3, /*timeoutSec=*/60, trustPolicyFor(matched)); libhttppp::HttpClient &client = *clientPtr; poolGuard.arm(_connectionPool, upstream, std::move(clientPtr)); // A reverse proxy must hand 3xx responses straight back to the real client // (which owns cookie storage/navigation), not chase them itself -- HttpClient // otherwise follows redirects like a browser and silently swallows the Loading Loading @@ -461,7 +496,12 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int } } outreq.setHeaderData("host")->push_back(upstream.getHost()); outreq.setHeaderData("connection")->push_back("close"); // Deliberately no "Connection: close" here (unlike before pooling existed) -- // forcing that on every request is exactly what defeated reuse; the upstream is now // allowed to keep the connection alive so UpstreamConnectionPool can hand it back // out for a later request. If the upstream itself sends "Connection: close" (or an // H2 GOAWAY) in its response, HttpClient::wantsClose() picks that up and the pool // won't reuse that instance regardless (see ScopedPoolRelease below). // Standard reverse-proxy hints for the upstream: X-Forwarded-For chains (append to // whatever a prior proxy already put there, via the copy the loop above already Loading Loading @@ -555,6 +595,7 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int } lbGuard.ok = upstreamOk; poolGuard.ok = upstreamOk; } catch (const libhttppp::HTTPException &e) { std::cerr << "[http] request handling failed: " << e.what() << std::endl; } Loading @@ -565,6 +606,7 @@ HttpProxyServer::HttpProxyServer(config::ListenerConfig listener, std::string ce auth::IAuthService &authService, plugin::PluginManager &pluginManager, lb::LoadBalancer &loadBalancer, http::UpstreamConnectionPool &connectionPool, tls::AcmeChallengeStore *acmeChallenges, int idleTimeoutSeconds) : _listener(listener) Loading @@ -575,6 +617,7 @@ HttpProxyServer::HttpProxyServer(config::ListenerConfig listener, std::string ce , _authService(authService) , _pluginManager(pluginManager) , _loadBalancer(loadBalancer) , _connectionPool(connectionPool) , _acmeChallenges(acmeChallenges) , _idleTimeoutSeconds(idleTimeoutSeconds) { bindAndListen(); Loading @@ -584,6 +627,7 @@ HttpProxyServer::HttpProxyServer(config::ListenerConfig listener, const std::map<std::string, netplus::ssl::CertificateBundle> &certsByHostname, store::ConfigStore &configStore, auth::IAuthService &authService, plugin::PluginManager &pluginManager, lb::LoadBalancer &loadBalancer, http::UpstreamConnectionPool &connectionPool, tls::AcmeChallengeStore *acmeChallenges, int idleTimeoutSeconds) : _listener(listener) Loading @@ -593,6 +637,7 @@ HttpProxyServer::HttpProxyServer(config::ListenerConfig listener, , _authService(authService) , _pluginManager(pluginManager) , _loadBalancer(loadBalancer) , _connectionPool(connectionPool) , _acmeChallenges(acmeChallenges) , _idleTimeoutSeconds(idleTimeoutSeconds) { bindAndListen(); Loading @@ -613,7 +658,8 @@ void HttpProxyServer::run() { HttpProxyController controller(_httpd->getServerSockets(), _listener.address + ":" + std::to_string(_listener.port), _tlsActive, _configStore, _authService, _pluginManager, _loadBalancer, _acmeChallenges, _idleTimeoutSeconds); _loadBalancer, _connectionPool, _acmeChallenges, _idleTimeoutSeconds); controller.runEventloop(); } Loading