#define WIN32_LEAN_AND_MEAN #include #include #include #include "oscquery_http.h" #include "daemon_log.h" #include #include #include static std::string BuildRootJson() { // Pattern 6: Minimum JSON — VRChat dispatches OSC to any subscriber whose // tree contains /avatar. We don't enumerate the 31 Backglow params (deferred // per Assumption A3). return "{" "\"DESCRIPTION\":\"Beyond Backglow LED bridge\"," "\"FULL_PATH\":\"/\"," "\"ACCESS\":0," "\"CONTENTS\":{" "\"avatar\":{" "\"FULL_PATH\":\"/avatar\"," "\"ACCESS\":0," "\"CONTENTS\":{" "\"parameters\":{" "\"FULL_PATH\":\"/avatar/parameters\"," "\"ACCESS\":0" "}" "}" "}" "}" "}"; } static std::string BuildHostInfoJson(uint16_t oscUdpPort, const char* name) { char buf[512]; std::snprintf(buf, sizeof(buf), "{" "\"NAME\":\"%s\"," "\"OSC_PORT\":%u," "\"OSC_TRANSPORT\":\"UDP\"," "\"OSC_IP\":\"127.0.0.1\"," "\"EXTENSIONS\":{\"ACCESS\":true,\"VALUE\":true,\"DESCRIPTION\":true}" "}", name, (unsigned)oscUdpPort); return std::string(buf); } static std::string WrapHttp200(const std::string& body) { char header[256]; std::snprintf(header, sizeof(header), "HTTP/1.0 200 OK\r\n" "Content-Type: application/json\r\n" "Content-Length: %zu\r\n" "Connection: close\r\n" "\r\n", body.size()); return std::string(header) + body; } static const char* kHttp405 = "HTTP/1.0 405 Method Not Allowed\r\n" "Allow: GET\r\n" "Content-Length: 0\r\n" "Connection: close\r\n" "\r\n"; static const char* kHttp404 = "HTTP/1.0 404 Not Found\r\n" "Content-Length: 0\r\n" "Connection: close\r\n" "\r\n"; uint16_t OscQueryHttpServer::Start(uint16_t oscUdpPort, const char* serviceName) { // Pre-build response strings at startup (Anti-pattern avoidance — no hot-path alloc). m_rootJson = WrapHttp200(BuildRootJson()); m_hostInfoJson = WrapHttp200(BuildHostInfoJson(oscUdpPort, serviceName)); SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s == INVALID_SOCKET) { DaemonLog("oscquery: TCP socket err=%d", WSAGetLastError()); return 0; } sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = 0; // OS-assigned (Open Q 2) if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) { // D-20 loopback closesocket(s); return 0; } if (bind(s, reinterpret_cast(&addr), sizeof(addr)) == SOCKET_ERROR) { DaemonLog("oscquery: bind err=%d", WSAGetLastError()); closesocket(s); return 0; } // Read back the OS-assigned port. sockaddr_in bound{}; int bl = sizeof(bound); if (getsockname(s, reinterpret_cast(&bound), &bl) == SOCKET_ERROR) { closesocket(s); return 0; } m_tcpPort = ntohs(bound.sin_port); if (listen(s, 4) == SOCKET_ERROR) { DaemonLog("oscquery: listen err=%d", WSAGetLastError()); closesocket(s); return 0; } m_listenSock = static_cast(s); m_stop.store(false); // WR-05 fix: std::thread ctor can throw std::system_error on thread- // creation failure. Without this guard the listen socket leaks and the // caller, seeing an exception unwind past Start(), has no handle to // reclaim it. try { m_thread = std::thread(&OscQueryHttpServer::AcceptLoop, this); } catch (...) { DaemonLog("oscquery: thread ctor threw — closing listen socket"); closesocket(static_cast(m_listenSock)); m_listenSock = (uintptr_t)-1; m_tcpPort = 0; throw; } DaemonLog("oscquery HTTP listening on 127.0.0.1:%u", (unsigned)m_tcpPort); return m_tcpPort; } void OscQueryHttpServer::AcceptLoop() { SOCKET listenSock = static_cast(m_listenSock); // Set a recv-timeout on accepted client sockets so slow clients can't hang us. while (!m_stop.load()) { fd_set rfds; FD_ZERO(&rfds); FD_SET(listenSock, &rfds); timeval tv{0, 250000}; // 250 ms int sel = select(0, &rfds, NULL, NULL, &tv); if (sel <= 0) continue; sockaddr_in cli{}; int cliLen = sizeof(cli); SOCKET c = accept(listenSock, reinterpret_cast(&cli), &cliLen); if (c == INVALID_SOCKET) { if (m_stop.load()) break; continue; } DWORD tvc = 1000; setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&tvc), sizeof(tvc)); setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&tvc), sizeof(tvc)); char req[2048]; int total = 0; while (total < (int)sizeof(req) - 1) { int n = recv(c, req + total, (int)sizeof(req) - 1 - total, 0); if (n <= 0) break; total += n; req[total] = '\0'; if (std::strstr(req, "\r\n\r\n")) break; } req[total < (int)sizeof(req) ? total : (int)sizeof(req) - 1] = '\0'; const char* resp = nullptr; size_t respLen = 0; if (std::strncmp(req, "GET ", 4) != 0) { resp = kHttp405; respLen = std::strlen(kHttp405); } else { // Extract path (between "GET " and " HTTP/") const char* pathStart = req + 4; const char* pathEnd = std::strchr(pathStart, ' '); if (!pathEnd) pathEnd = pathStart; std::string path(pathStart, pathEnd - pathStart); if (path == "/" || path.empty()) { resp = m_rootJson.data(); respLen = m_rootJson.size(); } else if (path == "/?HOST_INFO") { // IN-01 (iter-4) fix: tightened from `path.find("?HOST_INFO")` // to an exact-string match. VRChat's OSCQuery probe is // specifically `GET /?HOST_INFO HTTP/1.0`; the loose `find` // also matched pathological paths like `/evil?HOST_INFO` which // is harmless today (static JSON served) but disambiguates // future routes. Add targeted cases if another client ever // appends `?HOST_INFO` to a non-root path. resp = m_hostInfoJson.data(); respLen = m_hostInfoJson.size(); } else { resp = kHttp404; respLen = std::strlen(kHttp404); } } int sent = 0; while (sent < (int)respLen) { int w = send(c, resp + sent, (int)respLen - sent, 0); if (w <= 0) break; sent += w; } closesocket(c); } closesocket(listenSock); m_listenSock = (uintptr_t)-1; } void OscQueryHttpServer::StopAndJoin() { m_stop.store(true); if (m_thread.joinable()) m_thread.join(); }