diff --git a/src/confighttp.cpp b/src/confighttp.cpp index b706d41023a..77145e3961d 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -33,6 +33,7 @@ #include "nvhttp.h" #include "platform/common.h" #include "process.h" +#include "stream.h" #include "utility.h" #include "uuid.h" @@ -337,6 +338,26 @@ namespace confighttp { response->write(content, headers); } + /** + * @brief Get the sessions page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void getSessionsPage(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::string content = file_handler::read_file(WEB_DIR "sessions.html"); + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "text/html; charset=utf-8"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + response->write(content, headers); + } + /** * @brief Get the configuration page. * @param response The HTTP response object. @@ -825,6 +846,88 @@ namespace confighttp { send_response(response, output_tree); } + /** + * @brief Get the list of active streaming sessions. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/sessions| GET| null} + */ + void getSessions(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + auto sessions = stream::session::get_all_sessions(); + + nlohmann::json sessions_json = nlohmann::json::array(); + auto now = std::chrono::steady_clock::now(); + + for (const auto &session : sessions) { + nlohmann::json session_json; + session_json["id"] = session.id; + session_json["client_name"] = session.client_name; + session_json["ip_address"] = session.ip_address; + + // Calculate duration in seconds + auto duration = std::chrono::duration_cast(now - session.start_time); + session_json["duration_seconds"] = duration.count(); + + sessions_json.push_back(session_json); + } + + nlohmann::json output_tree; + output_tree["sessions"] = sessions_json; + output_tree["status"] = true; + send_response(response, output_tree); + } + + /** + * @brief Disconnect an active streaming session. + * @param response The HTTP response object. + * @param request The HTTP request object. + * The body for the post request should be JSON serialized in the following format: + * @code{.json} + * { + * "session_id": "" + * } + * @endcode + * + * @api_examples{/api/sessions/disconnect| POST| {"session_id":"1234"}} + */ + void disconnectSession(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) { + return; + } + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + + try { + nlohmann::json output_tree; + const nlohmann::json input_tree = nlohmann::json::parse(ss); + const std::string session_id = input_tree.value("session_id", ""); + + if (session_id.empty()) { + bad_request(response, request, "Missing session_id"); + return; + } + + output_tree["status"] = stream::session::disconnect(session_id); + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "DisconnectSession: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + /** * @brief Get the configuration settings. * @param response The HTTP response object. @@ -1195,6 +1298,7 @@ namespace confighttp { server.resource["^/pin/?$"]["GET"] = getPinPage; server.resource["^/apps/?$"]["GET"] = getAppsPage; server.resource["^/clients/?$"]["GET"] = getClientsPage; + server.resource["^/sessions/?$"]["GET"] = getSessionsPage; server.resource["^/config/?$"]["GET"] = getConfigPage; server.resource["^/password/?$"]["GET"] = getPasswordPage; server.resource["^/welcome/?$"]["GET"] = getWelcomePage; @@ -1213,6 +1317,8 @@ namespace confighttp { server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; server.resource["^/api/clients/list$"]["GET"] = getClients; server.resource["^/api/clients/unpair$"]["POST"] = unpair; + server.resource["^/api/sessions$"]["GET"] = getSessions; + server.resource["^/api/sessions/disconnect$"]["POST"] = disconnectSession; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/covers/upload$"]["POST"] = uploadCover; server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index d4e5ba73f18..6a703d8f265 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -266,12 +266,13 @@ namespace nvhttp { client_root = client; } - void add_authorized_client(const std::string &name, std::string &&cert) { + void add_authorized_client(const std::string &name, std::string &&cert, const std::string &client_unique_id) { client_t &client = client_root; named_cert_t named_cert; named_cert.name = name; named_cert.cert = std::move(cert); - named_cert.uuid = uuid_util::uuid_t::generate().string(); + // Use the client's uniqueID so we can match it during session lookup + named_cert.uuid = client_unique_id; client.named_devices.emplace_back(named_cert); if (!config::sunshine.flags[config::flag::FRESH_STATE]) { @@ -485,7 +486,7 @@ namespace nvhttp { add_cert->raise(crypto::x509(client.cert)); // The client is now successfully paired and will be authorized to connect - add_authorized_client(client.name, std::move(client.cert)); + add_authorized_client(client.name, std::move(client.cert), client.uniqueID); } else { tree.put("root.paired", 0); } diff --git a/src/stream.cpp b/src/stream.cpp index 0a342082a32..240068e276e 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -26,6 +26,7 @@ extern "C" { #include "input.h" #include "logging.h" #include "network.h" +#include "nvhttp.h" #include "platform/common.h" #include "process.h" #include "stream.h" @@ -405,6 +406,8 @@ namespace stream { } control; std::uint32_t launch_session_id; + std::string client_unique_id; + std::chrono::steady_clock::time_point start_time; safe::mail_raw_t::event_t shutdown_event; safe::signal_t controlEnd; @@ -1979,6 +1982,7 @@ namespace stream { session.audio.peer.port(0); session.pingTimeout = std::chrono::steady_clock::now() + config::stream.ping_timeout; + session.start_time = std::chrono::steady_clock::now(); session.audioThread = std::thread {audioThread, &session}; session.videoThread = std::thread {videoThread, &session}; @@ -2003,6 +2007,7 @@ namespace stream { session->shutdown_event = mail->event(mail::shutdown); session->launch_session_id = launch_session.id; + session->client_unique_id = launch_session.unique_id; session->config = config; @@ -2067,5 +2072,115 @@ namespace stream { return session; } + + /** + * @brief Get information about all active streaming sessions. + * + * This function retrieves a list of all currently running streaming sessions, + * including client name, IP address, and session start time. It looks up + * friendly client names from the paired clients database. + * + * @return A vector of session_info_t structures describing each active session. + * Returns an empty vector if no sessions are active. + */ + std::vector get_all_sessions() { + std::vector result; + + // Check if any app is running before trying to access broadcast context + // This avoids triggering broadcast initialization when there's no active stream + if (proc::proc.running() == 0) { + return result; + } + + // Get the paired clients to look up names + auto clients_json = nvhttp::get_all_clients(); + + // Access the broadcast context to get sessions + auto ref = broadcast.ref(); + if (!ref) { + return result; + } + + auto lg = ref->control_server._sessions.lock(); + for (auto *session : *ref->control_server._sessions) { + if (session->state.load(std::memory_order_relaxed) != state_e::RUNNING) { + continue; + } + + session_info_t info; + + // Generate a unique ID from the session's launch_session_id + info.id = std::to_string(session->launch_session_id); + + // Look up client name from paired clients list + info.client_name = session->client_unique_id; // Default to unique_id + for (const auto &client : clients_json) { + if (client.contains("uuid") && client["uuid"] == session->client_unique_id) { + if (client.contains("name")) { + info.client_name = client["name"]; + } + break; + } + } + + // Get IP address from the control peer address + info.ip_address = session->control.expected_peer_address; + + // Get start time + info.start_time = session->start_time; + + result.push_back(std::move(info)); + } + + return result; + } + + /** + * @brief Disconnect an active streaming session by its ID. + * + * This function allows administrators to forcefully terminate a streaming + * session from the web UI. It finds the session by its launch_session_id + * and calls stop() to cleanly shut it down. + * + * @param session_id The session ID (as a string) to disconnect. + * @return true if the session was found and disconnected, false otherwise. + */ + bool disconnect(const std::string &session_id) { + // Check if any app is running before trying to access broadcast context + if (proc::proc.running() == 0) { + BOOST_LOG(warning) << "No active streaming session to disconnect"; + return false; + } + + // Convert session_id to launch_session_id + uint32_t launch_id; + try { + launch_id = std::stoul(session_id); + } catch (const std::exception &) { + BOOST_LOG(warning) << "Invalid session ID: " << session_id; + return false; + } + + // Access the broadcast context to find and stop the session + auto ref = broadcast.ref(); + if (!ref) { + return false; + } + + auto lg = ref->control_server._sessions.lock(); + for (auto *session : *ref->control_server._sessions) { + if (session->launch_session_id == launch_id) { + if (session->state.load(std::memory_order_relaxed) == state_e::RUNNING) { + BOOST_LOG(info) << "Disconnecting session " << session_id << " by admin request"; + stop(*session); + return true; + } + break; + } + } + + BOOST_LOG(warning) << "Session not found or not running: " << session_id; + return false; + } } // namespace session } // namespace stream diff --git a/src/stream.h b/src/stream.h index 53afff4fabe..fa0173e37d9 100644 --- a/src/stream.h +++ b/src/stream.h @@ -46,10 +46,33 @@ namespace stream { RUNNING, ///< The session is running }; + /** + * @brief Information about an active streaming session. + */ + struct session_info_t { + std::string id; ///< Unique session identifier + std::string client_name; ///< Name of the connected client + std::string ip_address; ///< Client's IP address + std::chrono::steady_clock::time_point start_time; ///< When the session started + }; + std::shared_ptr alloc(config_t &config, rtsp_stream::launch_session_t &launch_session); int start(session_t &session, const std::string &addr_string); void stop(session_t &session); void join(session_t &session); state_e state(session_t &session); + + /** + * @brief Get a list of all active streaming sessions. + * @return Vector of session information. + */ + std::vector get_all_sessions(); + + /** + * @brief Disconnect a session by its ID. + * @param session_id The unique session identifier. + * @return true if the session was found and stopped, false otherwise. + */ + bool disconnect(const std::string &session_id); } // namespace session } // namespace stream diff --git a/src_assets/common/assets/web/Navbar.vue b/src_assets/common/assets/web/Navbar.vue index 166398b9fa7..67252fc76a5 100644 --- a/src_assets/common/assets/web/Navbar.vue +++ b/src_assets/common/assets/web/Navbar.vue @@ -19,6 +19,9 @@ + diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 331dc63150f..e04b2d1cafa 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -396,6 +396,7 @@ "home": "Home", "password": "Change Password", "pin": "PIN", + "sessions": "Sessions", "theme_auto": "Auto", "theme_dark": "Dark", "theme_light": "Light", @@ -428,6 +429,22 @@ "resources_desc": "Resources for Sunshine!", "third_party_notice": "Third Party Notice" }, + "sessions": { + "actions": "Actions", + "auto_refresh": "Auto-refresh", + "cancel": "Cancel", + "client": "Client", + "confirm_disconnect": "Are you sure you want to disconnect", + "confirm_disconnect_title": "Confirm Disconnect", + "disconnect": "Disconnect", + "disconnect_error": "Failed to disconnect session", + "disconnected": "Session disconnected successfully", + "duration": "Duration", + "ip_address": "IP Address", + "no_sessions": "No active streaming sessions", + "refresh": "Refresh", + "title": "Active Sessions" + }, "troubleshooting": { "dd_reset": "Reset Persistent Display Device Settings", "dd_reset_desc": "If Sunshine is stuck trying to restore the changed display device settings, you can reset the settings and proceed to restore the display state manually.", diff --git a/src_assets/common/assets/web/sessions.html b/src_assets/common/assets/web/sessions.html new file mode 100644 index 00000000000..f26b95c26ba --- /dev/null +++ b/src_assets/common/assets/web/sessions.html @@ -0,0 +1,238 @@ + + + + + <%- header %> + + + + +
+
+

{{ $t('sessions.title') }}

+
+
+ + +
+ +
+
+ + +
+
+ Loading... +
+
+ + +
+ + {{ $t('sessions.no_sessions') }} +
+ + +
+
+ + + + + + + + + + + + + + + + + +
{{ $t('sessions.client') }}{{ $t('sessions.ip_address') }}{{ $t('sessions.duration') }}{{ $t('sessions.actions') }}
+ + {{ session.client_name }} + {{ session.ip_address }}{{ formatDuration(session.duration_seconds) }} + +
+
+
+ + + + + + + + + +
+ + + diff --git a/vite.config.js b/vite.config.js index 7bfc0cf85cb..f1178efa275 100644 --- a/vite.config.js +++ b/vite.config.js @@ -71,6 +71,7 @@ export default defineConfig({ index: resolve(assetsSrcPath, 'index.html'), password: resolve(assetsSrcPath, 'password.html'), pin: resolve(assetsSrcPath, 'pin.html'), + sessions: resolve(assetsSrcPath, 'sessions.html'), troubleshooting: resolve(assetsSrcPath, 'troubleshooting.html'), welcome: resolve(assetsSrcPath, 'welcome.html'), },