Loading src/http/HttpProxyServer.cpp +4 −5 Changes for src/http/HttpProxyServer.cpp: 4 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -393,7 +393,7 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int } std::string upstreamBase; std::string pickedTarget; // only set (and only release()d) when a pool picked it ScopedLbRelease lbGuard; // inert unless a pool actually picks a target below if (!matched.upstreamPool.empty()) { auto picked = _loadBalancer.pick(matched.upstreamPool, sourceIp); if (!picked) { Loading @@ -401,7 +401,7 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int "upstream pool '" + matched.upstreamPool + "' has no available targets"); return; } pickedTarget = *picked; lbGuard.arm(_loadBalancer, matched.upstreamPool, *picked); upstreamBase = *picked; } else { upstreamBase = matched.upstream; Loading @@ -418,9 +418,8 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int // WebSocketProxy.h for why this always speaks H1.1 to the upstream // regardless of what protocol curreq itself arrived over. if (method == "GET" && isWebSocketUpgrade(curreq)) { bool ok = handleWebSocketUpgrade(curreq, sourceIp, upstreamBase, forwardPath, lbGuard.ok = handleWebSocketUpgrade(curreq, sourceIp, upstreamBase, forwardPath, trustPolicyFor(matched)); if (!pickedTarget.empty()) _loadBalancer.release(matched.upstreamPool, pickedTarget, ok); return; } Loading Loading @@ -553,7 +552,7 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int sendError(curreq, HTTP502, std::string("upstream error: ") + e.what()); } if (!pickedTarget.empty()) _loadBalancer.release(matched.upstreamPool, pickedTarget, upstreamOk); lbGuard.ok = upstreamOk; } catch (const libhttppp::HTTPException &e) { std::cerr << "[http] request handling failed: " << e.what() << std::endl; } Loading src/http/WebSocketProxy.cpp +59 −8 Changes for src/http/WebSocketProxy.cpp: 59 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ #include <atomic> #include <cctype> #include <cstring> #include <ctime> #include <iostream> #include <map> #include <set> Loading @@ -11,6 +12,8 @@ #include <thread> #include <vector> #include <sys/socket.h> #include <netplus/connection.h> #include <netplus/eventapi.h> #include <netplus/exception.h> Loading Loading @@ -104,6 +107,36 @@ std::unique_ptr<netplus::socket> connectUpstream(const libhttppp::HttpUrl &upstr return tcpsock; } // Bounds every blocking send/recv this tunnel's sockets ever do -- both during the handshake // (sendAll()/readUntilHeadersEnd() below, which run inline on a shared event-loop worker // thread, *before* ScopedTunnelSlot even applies) and for the tunnel's entire relayed lifetime // (relayLoop(), once it's off on its own dedicated thread). Without this, a peer that accepts a // connection but stops draining/filling its side -- TCP window closes, or the path is silently // blackholed by a NAT/middlebox without RST/FIN -- hangs the blocking call forever, and during // the handshake phase that risks stalling the shared worker pool, not just leaking fds (the // same failure shape as project_proxyplus_h3_thread_starvation). SO_KEEPALIVE additionally lets // the OS notice a genuinely vanished peer even while both sides are legitimately idle -- WS // pings alone don't cover a network path that goes quiet without a clean close (phone screen // lock, laptop sleep, NAT rebind). // // Known gap: this only protects the *read* side end-to-end. netplus::ssl's send path // (tls::flush_out) currently swallows a timeout-driven EAGAIN as if the write had fully // succeeded rather than propagating it (see project_proxyplus_fd_limit_exhaustion in memory) -- // so for a wss:// client or https:// upstream specifically, a stalled *write* still won't be // caught by this timeout alone. relayLoop()'s own idle-activity check below is what actually // catches that case, just later (after the idle window elapses) rather than immediately. constexpr int kWsSocketTimeoutSeconds = 30; // See relayLoop()'s idle-tracking comment for what this actually guards against (a // connection that's alive-but-silent forever, as opposed to a stalled send/recv). constexpr int kWsIdleTimeoutSeconds = 300; void hardenTunnelSocket(netplus::socket &sock) { sock.setTimeout(kWsSocketTimeoutSeconds * 1000); int keepalive = 1; ::setsockopt(sock.fd(), SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); } void sendAll(netplus::socket &sock, const std::string &data) { size_t sent = 0; while (sent < data.size()) { Loading Loading @@ -172,13 +205,13 @@ void relayLoop(std::unique_ptr<netplus::socket> client, std::string upstreamLeftover, ScopedTunnelSlot slot) { try { // This thread owns both sockets exclusively for its whole lifetime // (nothing else will ever touch client's fd again -- see // detachConnection()'s contract), so there's no reason to deal with // EAGAIN/Note here: plain blocking reads/writes are simplest and // correct. client arrives non-blocking (the event loop always runs // non-blocking sockets); upstream is fresh from connectUpstream() and // likely already blocking, but set explicitly rather than assume. // This thread owns both sockets exclusively for its whole lifetime (nothing else will // ever touch client's fd again -- see detachConnection()'s contract), so plain blocking // reads/writes are simplest and correct -- a hardenTunnelSocket()-induced EAGAIN/Note // from a timed-out call is just another way this loop ends, caught below like any other // disconnect. client arrives non-blocking (the event loop always runs non-blocking // sockets); upstream is fresh from connectUpstream() and likely already blocking, but // set explicitly rather than assume. client->setBlock(); upstream->setBlock(); Loading @@ -189,21 +222,37 @@ void relayLoop(std::unique_ptr<netplus::socket> client, std::vector<netplus::socket *> socks{client.get(), upstream.get()}; std::vector<bool> ready; // Belt-and-suspenders alongside hardenTunnelSocket()'s SO_RCVTIMEO/SO_SNDTIMEO/ // SO_KEEPALIVE: those bound any *single* blocking call, but a peer that's still // technically reachable (each individual send/recv succeeds) yet never actually sends // anything meaningful again -- e.g. a client that opens the tunnel and the network path // then goes quiet without ever tearing down the TCP connection -- would otherwise pin // this thread and its 3 fds (client + upstream sockets, sw's own kqueue/epoll fd) // forever. kWsIdleTimeoutSeconds is generous headroom above typical WS ping/pong // intervals (Home Assistant, browsers: 20-30s) so it only ever fires on a connection // that's genuinely been abandoned, not a quiet-but-alive one. time_t lastActivity = std::time(nullptr); for (;;) { int n = sw.waitReadMulti(socks, ready, /*timeout_ms=*/60000); if (n <= 0) continue; // timeout -- keep waiting, WebSocket pings keep this alive if (n <= 0) { if (std::time(nullptr) - lastActivity > kWsIdleTimeoutSeconds) break; continue; // timeout -- keep waiting, WebSocket pings keep this alive } if (ready[0]) { netplus::buffer buf(16384); size_t r = client->recvData(buf, 0); if (r == 0) break; // client closed sendAll(*upstream, std::string(buf.data.buf, r)); lastActivity = std::time(nullptr); } if (ready[1]) { netplus::buffer buf(16384); size_t r = upstream->recvData(buf, 0); if (r == 0) break; // upstream closed sendAll(*client, std::string(buf.data.buf, r)); lastActivity = std::time(nullptr); } } } catch (const netplus::NetException &) { Loading Loading @@ -232,6 +281,7 @@ bool handleWebSocketUpgrade(libhttppp::HttpRequest &curreq, try { libhttppp::HttpUrl upstream(upstreamBase + forwardPath); auto upstreamSock = connectUpstream(upstream, trustPolicy, /*timeoutSec=*/30); hardenTunnelSocket(*upstreamSock); // Build the raw HTTP/1.1 request text. Unlike RequestEvent's normal // proxying path, Connection/Upgrade (and the Sec-WebSocket-* set) Loading Loading @@ -302,6 +352,7 @@ bool handleWebSocketUpgrade(libhttppp::HttpRequest &curreq, upstreamSock->close(); return true; } hardenTunnelSocket(*clientSock); std::vector<char> leftoverVec(curreq.RecvData.begin(), curreq.RecvData.end()); std::string clientLeftover(leftoverVec.begin(), leftoverVec.end()); Loading Loading
src/http/HttpProxyServer.cpp +4 −5 Changes for src/http/HttpProxyServer.cpp: 4 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -393,7 +393,7 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int } std::string upstreamBase; std::string pickedTarget; // only set (and only release()d) when a pool picked it ScopedLbRelease lbGuard; // inert unless a pool actually picks a target below if (!matched.upstreamPool.empty()) { auto picked = _loadBalancer.pick(matched.upstreamPool, sourceIp); if (!picked) { Loading @@ -401,7 +401,7 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int "upstream pool '" + matched.upstreamPool + "' has no available targets"); return; } pickedTarget = *picked; lbGuard.arm(_loadBalancer, matched.upstreamPool, *picked); upstreamBase = *picked; } else { upstreamBase = matched.upstream; Loading @@ -418,9 +418,8 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int // WebSocketProxy.h for why this always speaks H1.1 to the upstream // regardless of what protocol curreq itself arrived over. if (method == "GET" && isWebSocketUpgrade(curreq)) { bool ok = handleWebSocketUpgrade(curreq, sourceIp, upstreamBase, forwardPath, lbGuard.ok = handleWebSocketUpgrade(curreq, sourceIp, upstreamBase, forwardPath, trustPolicyFor(matched)); if (!pickedTarget.empty()) _loadBalancer.release(matched.upstreamPool, pickedTarget, ok); return; } Loading Loading @@ -553,7 +552,7 @@ void HttpProxyController::RequestEvent(libhttppp::HttpRequest &curreq, const int sendError(curreq, HTTP502, std::string("upstream error: ") + e.what()); } if (!pickedTarget.empty()) _loadBalancer.release(matched.upstreamPool, pickedTarget, upstreamOk); lbGuard.ok = upstreamOk; } catch (const libhttppp::HTTPException &e) { std::cerr << "[http] request handling failed: " << e.what() << std::endl; } Loading
src/http/WebSocketProxy.cpp +59 −8 Changes for src/http/WebSocketProxy.cpp: 59 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ #include <atomic> #include <cctype> #include <cstring> #include <ctime> #include <iostream> #include <map> #include <set> Loading @@ -11,6 +12,8 @@ #include <thread> #include <vector> #include <sys/socket.h> #include <netplus/connection.h> #include <netplus/eventapi.h> #include <netplus/exception.h> Loading Loading @@ -104,6 +107,36 @@ std::unique_ptr<netplus::socket> connectUpstream(const libhttppp::HttpUrl &upstr return tcpsock; } // Bounds every blocking send/recv this tunnel's sockets ever do -- both during the handshake // (sendAll()/readUntilHeadersEnd() below, which run inline on a shared event-loop worker // thread, *before* ScopedTunnelSlot even applies) and for the tunnel's entire relayed lifetime // (relayLoop(), once it's off on its own dedicated thread). Without this, a peer that accepts a // connection but stops draining/filling its side -- TCP window closes, or the path is silently // blackholed by a NAT/middlebox without RST/FIN -- hangs the blocking call forever, and during // the handshake phase that risks stalling the shared worker pool, not just leaking fds (the // same failure shape as project_proxyplus_h3_thread_starvation). SO_KEEPALIVE additionally lets // the OS notice a genuinely vanished peer even while both sides are legitimately idle -- WS // pings alone don't cover a network path that goes quiet without a clean close (phone screen // lock, laptop sleep, NAT rebind). // // Known gap: this only protects the *read* side end-to-end. netplus::ssl's send path // (tls::flush_out) currently swallows a timeout-driven EAGAIN as if the write had fully // succeeded rather than propagating it (see project_proxyplus_fd_limit_exhaustion in memory) -- // so for a wss:// client or https:// upstream specifically, a stalled *write* still won't be // caught by this timeout alone. relayLoop()'s own idle-activity check below is what actually // catches that case, just later (after the idle window elapses) rather than immediately. constexpr int kWsSocketTimeoutSeconds = 30; // See relayLoop()'s idle-tracking comment for what this actually guards against (a // connection that's alive-but-silent forever, as opposed to a stalled send/recv). constexpr int kWsIdleTimeoutSeconds = 300; void hardenTunnelSocket(netplus::socket &sock) { sock.setTimeout(kWsSocketTimeoutSeconds * 1000); int keepalive = 1; ::setsockopt(sock.fd(), SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); } void sendAll(netplus::socket &sock, const std::string &data) { size_t sent = 0; while (sent < data.size()) { Loading Loading @@ -172,13 +205,13 @@ void relayLoop(std::unique_ptr<netplus::socket> client, std::string upstreamLeftover, ScopedTunnelSlot slot) { try { // This thread owns both sockets exclusively for its whole lifetime // (nothing else will ever touch client's fd again -- see // detachConnection()'s contract), so there's no reason to deal with // EAGAIN/Note here: plain blocking reads/writes are simplest and // correct. client arrives non-blocking (the event loop always runs // non-blocking sockets); upstream is fresh from connectUpstream() and // likely already blocking, but set explicitly rather than assume. // This thread owns both sockets exclusively for its whole lifetime (nothing else will // ever touch client's fd again -- see detachConnection()'s contract), so plain blocking // reads/writes are simplest and correct -- a hardenTunnelSocket()-induced EAGAIN/Note // from a timed-out call is just another way this loop ends, caught below like any other // disconnect. client arrives non-blocking (the event loop always runs non-blocking // sockets); upstream is fresh from connectUpstream() and likely already blocking, but // set explicitly rather than assume. client->setBlock(); upstream->setBlock(); Loading @@ -189,21 +222,37 @@ void relayLoop(std::unique_ptr<netplus::socket> client, std::vector<netplus::socket *> socks{client.get(), upstream.get()}; std::vector<bool> ready; // Belt-and-suspenders alongside hardenTunnelSocket()'s SO_RCVTIMEO/SO_SNDTIMEO/ // SO_KEEPALIVE: those bound any *single* blocking call, but a peer that's still // technically reachable (each individual send/recv succeeds) yet never actually sends // anything meaningful again -- e.g. a client that opens the tunnel and the network path // then goes quiet without ever tearing down the TCP connection -- would otherwise pin // this thread and its 3 fds (client + upstream sockets, sw's own kqueue/epoll fd) // forever. kWsIdleTimeoutSeconds is generous headroom above typical WS ping/pong // intervals (Home Assistant, browsers: 20-30s) so it only ever fires on a connection // that's genuinely been abandoned, not a quiet-but-alive one. time_t lastActivity = std::time(nullptr); for (;;) { int n = sw.waitReadMulti(socks, ready, /*timeout_ms=*/60000); if (n <= 0) continue; // timeout -- keep waiting, WebSocket pings keep this alive if (n <= 0) { if (std::time(nullptr) - lastActivity > kWsIdleTimeoutSeconds) break; continue; // timeout -- keep waiting, WebSocket pings keep this alive } if (ready[0]) { netplus::buffer buf(16384); size_t r = client->recvData(buf, 0); if (r == 0) break; // client closed sendAll(*upstream, std::string(buf.data.buf, r)); lastActivity = std::time(nullptr); } if (ready[1]) { netplus::buffer buf(16384); size_t r = upstream->recvData(buf, 0); if (r == 0) break; // upstream closed sendAll(*client, std::string(buf.data.buf, r)); lastActivity = std::time(nullptr); } } } catch (const netplus::NetException &) { Loading Loading @@ -232,6 +281,7 @@ bool handleWebSocketUpgrade(libhttppp::HttpRequest &curreq, try { libhttppp::HttpUrl upstream(upstreamBase + forwardPath); auto upstreamSock = connectUpstream(upstream, trustPolicy, /*timeoutSec=*/30); hardenTunnelSocket(*upstreamSock); // Build the raw HTTP/1.1 request text. Unlike RequestEvent's normal // proxying path, Connection/Upgrade (and the Sec-WebSocket-* set) Loading Loading @@ -302,6 +352,7 @@ bool handleWebSocketUpgrade(libhttppp::HttpRequest &curreq, upstreamSock->close(); return true; } hardenTunnelSocket(*clientSock); std::vector<char> leftoverVec(curreq.RecvData.begin(), curreq.RecvData.end()); std::string clientLeftover(leftoverVec.begin(), leftoverVec.end()); Loading