diff --git a/src/confighttp.cpp b/src/confighttp.cpp index f718b190..1372a594 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -7,10 +7,18 @@ #define BOOST_BIND_GLOBAL_PLACEHOLDERS // standard includes +#include +#include +#include +#include #include #include #include +#include #include +#include +#include +#include // lib includes #include @@ -19,6 +27,8 @@ #include #include #include +#include +#include #ifdef _WIN32 #include "platform/windows/misc.h" @@ -55,6 +65,119 @@ namespace confighttp { using resp_https_t = std::shared_ptr::Response>; using req_https_t = std::shared_ptr::Request>; + namespace { + struct ws_client_t { + std::unique_ptr socket; + std::mutex write_mutex; + std::atomic_bool alive {true}; + }; + + constexpr std::chrono::seconds WS_TOKEN_TTL {30}; + constexpr std::chrono::milliseconds WS_PUSH_INTERVAL {250}; + constexpr std::string_view WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + std::mutex ws_clients_mutex; + std::vector> ws_clients; + + std::mutex ws_tokens_mutex; + std::unordered_map ws_tokens; + + void cleanup_ws_tokens(const std::chrono::steady_clock::time_point &now) { + for (auto it = ws_tokens.begin(); it != ws_tokens.end();) { + if (it->second <= now) { + it = ws_tokens.erase(it); + } else { + ++it; + } + } + } + + std::string create_ws_token() { + auto now = std::chrono::steady_clock::now(); + auto token = uuid_util::uuid_t::generate().string(); + + std::lock_guard lg {ws_tokens_mutex}; + cleanup_ws_tokens(now); + ws_tokens[token] = now + WS_TOKEN_TTL; + + return token; + } + + bool consume_ws_token(std::string_view token) { + auto now = std::chrono::steady_clock::now(); + + std::lock_guard lg {ws_tokens_mutex}; + cleanup_ws_tokens(now); + + auto it = ws_tokens.find(std::string(token)); + if (it == ws_tokens.end()) { + return false; + } + + ws_tokens.erase(it); + return true; + } + + std::string websocket_accept_key(std::string_view key) { + std::string input; + input.reserve(key.size() + WS_GUID.size()); + input.append(key); + input.append(WS_GUID); + + std::array digest {}; + SHA1(reinterpret_cast(input.data()), input.size(), digest.data()); + + std::array encoded {}; + auto len = EVP_EncodeBlock(encoded.data(), digest.data(), digest.size()); + + return std::string(reinterpret_cast(encoded.data()), len); + } + + std::string websocket_text_frame(const std::string &payload) { + std::string frame; + frame.reserve(payload.size() + 10); + frame.push_back(static_cast(0x81)); + + auto len = payload.size(); + if (len <= 125) { + frame.push_back(static_cast(len)); + } else if (len <= 0xFFFF) { + frame.push_back(static_cast(126)); + frame.push_back(static_cast((len >> 8) & 0xFF)); + frame.push_back(static_cast(len & 0xFF)); + } else { + frame.push_back(static_cast(127)); + for (int i = 7; i >= 0; --i) { + frame.push_back(static_cast((len >> (i * 8)) & 0xFF)); + } + } + + frame.append(payload); + return frame; + } + + void ws_write_http_response(std::unique_ptr &socket, SimpleWeb::StatusCode code, std::string_view body) { + if (!socket) { + return; + } + + std::string response; + response.reserve(256 + body.size()); + response.append("HTTP/1.1 "); + response.append(SimpleWeb::status_code(code)); + response.append("\r\nContent-Type: application/json\r\n"); + response.append("Connection: close\r\n"); + response.append("Content-Length: "); + response.append(std::to_string(body.size())); + response.append("\r\n\r\n"); + response.append(body); + + boost::system::error_code ec; + boost::asio::write(*socket, boost::asio::buffer(response), ec); + socket->lowest_layer().close(ec); + } + } + enum class op_e { ADD, ///< Add client REMOVE ///< Remove client @@ -137,6 +260,24 @@ namespace confighttp { response->write(SimpleWeb::StatusCode::redirection_temporary_redirect, headers); } + bool authenticate_header(std::string_view raw_auth) { + if (raw_auth.size() < "Basic "sv.size() || raw_auth.substr(0, "Basic "sv.size()) != "Basic "sv) { + return false; + } + + auto auth_data = SimpleWeb::Crypto::Base64::decode(std::string(raw_auth.substr("Basic "sv.length()))); + auto index = (int) auth_data.find(':'); + if (index >= auth_data.size() - 1) { + return false; + } + + auto username = auth_data.substr(0, index); + auto password = auth_data.substr(index + 1); + auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); + + return boost::iequals(username, config::sunshine.username) && hash == config::sunshine.password; + } + /** * @brief Authenticate the user. * @param response The HTTP response object. @@ -168,19 +309,7 @@ namespace confighttp { return false; } - auto &rawAuth = auth->second; - auto authData = SimpleWeb::Crypto::Base64::decode(rawAuth.substr("Basic "sv.length())); - - auto index = (int) authData.find(':'); - if (index >= authData.size() - 1) { - return false; - } - - auto username = authData.substr(0, index); - auto password = authData.substr(index + 1); - auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); - - if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) { + if (!authenticate_header(auth->second)) { return false; } @@ -819,6 +948,19 @@ namespace confighttp { send_response(response, output_tree); } + void getActiveSessionsWsToken(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + output_tree["token"] = create_ws_token(); + output_tree["status"] = true; + send_response(response, output_tree); + } + /** * @brief Unpair a client. * @param response The HTTP response object. @@ -1469,12 +1611,72 @@ namespace confighttp { server.resource["^/api/clients/list$"]["GET"] = getClients; server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/sessions/active$"]["GET"] = getActiveSessions; + server.resource["^/api/sessions/ws-token$"]["GET"] = getActiveSessionsWsToken; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/covers/upload$"]["POST"] = uploadCover; server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover; server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage; server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; + + server.on_upgrade = [](std::unique_ptr &socket, req_https_t request) { + if (!socket || request->path != "/api/sessions/active/ws") { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_not_found, R"({"status":false})"); + return; + } + + auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); + if (net::from_address(address) > http::origin_web_ui_allowed) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_forbidden, R"({"status":false})"); + return; + } + + if (config::sunshine.username.empty()) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_unauthorized, R"({"status":false})"); + return; + } + + auto auth = request->header.find("Authorization"); + if (auth != request->header.end() && !authenticate_header(auth->second)) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_unauthorized, R"({"status":false})"); + return; + } + + auto query = request->parse_query_string(); + auto token_it = query.find("token"); + if (token_it == query.end() || !consume_ws_token(token_it->second)) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_forbidden, R"({"status":false})"); + return; + } + + auto key_it = request->header.find("Sec-WebSocket-Key"); + if (key_it == request->header.end()) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_bad_request, R"({"status":false})"); + return; + } + + std::string response; + response.reserve(256); + response.append("HTTP/1.1 101 Switching Protocols\r\n"); + response.append("Upgrade: websocket\r\n"); + response.append("Connection: Upgrade\r\n"); + response.append("Sec-WebSocket-Accept: "); + response.append(websocket_accept_key(key_it->second)); + response.append("\r\n\r\n"); + + boost::system::error_code ec; + boost::asio::write(*socket, boost::asio::buffer(response), ec); + if (ec) { + socket->lowest_layer().close(ec); + return; + } + + auto client = std::make_shared(); + client->socket = std::move(socket); + + std::lock_guard lg {ws_clients_mutex}; + ws_clients.emplace_back(std::move(client)); + }; server.config.reuse_address = true; server.config.address = net::get_bind_address(address_family); server.config.port = port_https; @@ -1498,11 +1700,58 @@ namespace confighttp { }; std::thread tcp {accept_and_run, &server}; + std::thread ws_broadcast {[&] { + platf::set_thread_name("confighttp::ws"); + + while (!shutdown_event->peek()) { + std::vector> clients; + { + std::lock_guard lg {ws_clients_mutex}; + clients = ws_clients; + } + + if (!clients.empty()) { + nlohmann::json output_tree; + output_tree["sessions"] = stream::get_active_sessions_info(); + output_tree["status"] = true; + auto frame = websocket_text_frame(output_tree.dump()); + + for (auto &client : clients) { + if (!client->alive.load(std::memory_order_relaxed)) { + continue; + } + + std::lock_guard write_lg {client->write_mutex}; + if (!client->socket) { + client->alive.store(false, std::memory_order_relaxed); + continue; + } + + boost::system::error_code ec; + boost::asio::write(*client->socket, boost::asio::buffer(frame), ec); + if (ec) { + client->alive.store(false, std::memory_order_relaxed); + client->socket->lowest_layer().close(ec); + } + } + + std::lock_guard lg {ws_clients_mutex}; + ws_clients.erase(std::remove_if(std::begin(ws_clients), std::end(ws_clients), [](const auto &client) { + return !client->alive.load(std::memory_order_relaxed); + }), std::end(ws_clients)); + } + + std::this_thread::sleep_for(WS_PUSH_INTERVAL); + } + }}; + // Wait for any event shutdown_event->view(); server.stop(); + ws_broadcast.join(); + tcp.join(); } } // namespace confighttp diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index 5def6e7f..e0cc53c0 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -282,6 +282,8 @@ logFilter: null, logInterval: null, sessionInterval: null, + sessionSocket: null, + sessionReconnectTimer: null, restartPressed: false, showApplyMessage: false, platform: "", @@ -440,13 +442,69 @@ this.refreshLogs(); this.refreshClients(); this.refreshActiveSessions(); + this.connectActiveSessionsSocket(); }, beforeDestroy() { clearInterval(this.logInterval); clearInterval(this.sessionInterval); + clearTimeout(this.sessionReconnectTimer); + if (this.sessionSocket) { + this.sessionSocket.onclose = null; + this.sessionSocket.close(); + this.sessionSocket = null; + } }, methods: { + connectActiveSessionsSocket() { + fetch("./api/sessions/ws-token") + .then((r) => r.json()) + .then((r) => { + if (!r || r.status !== true || !r.token) { + throw new Error("No websocket token"); + } + + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const wsUrl = `${protocol}://${window.location.host}/api/sessions/active/ws?token=${encodeURIComponent(r.token)}`; + + this.sessionSocket = new WebSocket(wsUrl); + + this.sessionSocket.onmessage = (event) => { + try { + const payload = JSON.parse(event.data); + if (payload && payload.status === true && payload.sessions) { + this.activeSessions = payload.sessions; + } + } catch (_e) { + return; + } + }; + + this.sessionSocket.onclose = () => { + this.sessionSocket = null; + clearTimeout(this.sessionReconnectTimer); + this.sessionReconnectTimer = setTimeout(() => { + this.connectActiveSessionsSocket(); + }, 1000); + }; + + this.sessionSocket.onerror = () => { + if (this.sessionSocket) { + this.sessionSocket.close(); + } + }; + }) + .catch(() => { + clearTimeout(this.sessionReconnectTimer); + this.sessionReconnectTimer = setTimeout(() => { + this.connectActiveSessionsSocket(); + }, 1000); + }); + }, refreshActiveSessions() { + if (this.sessionSocket && this.sessionSocket.readyState === WebSocket.OPEN) { + return; + } + fetch("./api/sessions/active") .then((r) => r.json()) .then((r) => {