From bb88dc1f33f64b42bfcdf65df11c9af367126a65 Mon Sep 17 00:00:00 2001 From: neatnoise Date: Tue, 24 Feb 2026 15:26:04 +0100 Subject: [PATCH 1/6] feat: add client enable/disable access control Add ability to enable/disable paired clients via web UI and API. Disabled clients are rejected at TLS verification level. - Add enabled field to named_cert_t struct - Add /api/clients/update POST endpoint - Add enable/disable toggle in troubleshooting page - Persist enabled state in client credentials file --- src/confighttp.cpp | 44 +++++++++++++++++++ src/nvhttp.cpp | 35 +++++++++++++++ src/nvhttp.h | 8 ++++ .../common/assets/web/troubleshooting.html | 23 +++++++++- 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 4a0bb128016..413bf4d55f7 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -843,6 +843,41 @@ namespace confighttp { send_response(response, output_tree); } + /** + * @brief Enable or disable a client. + * @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} + * { + * "uuid": "", + * "enabled": true + * } + * @endcode + */ + void updateClient(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + try { + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + nlohmann::json output_tree; + std::string uuid = input_tree.value("uuid", ""); + bool enabled = input_tree.value("enabled", true); + output_tree["status"] = nvhttp::set_client_enabled(uuid, enabled); + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "Update Client: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + /** * @brief Unpair a client. * @param response The HTTP response object. @@ -1729,6 +1764,15 @@ namespace confighttp { server.resource["^/api/restart$"]["POST"] = restart; server.resource["^/api/vigembus/status$"]["GET"] = getViGEmBusStatus; server.resource["^/api/vigembus/install$"]["POST"] = installViGEmBus; + server.resource["^/api/password$"]["POST"] = savePassword; + server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp; + server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; + server.resource["^/api/clients/list$"]["GET"] = getClients; + server.resource["^/api/clients/update$"]["POST"] = updateClient; + server.resource["^/api/clients/unpair$"]["POST"] = unpair; + server.resource["^/api/apps/close$"]["POST"] = closeApp; + server.resource["^/api/covers/upload$"]["POST"] = uploadCover; + server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover; // static/dynamic resources server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index b4f721a4c93..ecd9d2f960e 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -132,6 +132,7 @@ namespace nvhttp { std::string name; std::string uuid; std::string cert; + bool enabled = true; }; struct client_t { @@ -190,6 +191,7 @@ namespace nvhttp { named_cert_node.put("name"s, named_cert.name); named_cert_node.put("cert"s, named_cert.cert); named_cert_node.put("uuid"s, named_cert.uuid); + named_cert_node.put("enabled"s, named_cert.enabled); named_cert_nodes.push_back(std::make_pair(""s, named_cert_node)); } root.add_child("root.named_devices"s, named_cert_nodes); @@ -253,6 +255,7 @@ namespace nvhttp { named_cert.name = el.get_child("name").get_value(); named_cert.cert = el.get_child("cert").get_value(); named_cert.uuid = el.get_child("uuid").get_value(); + named_cert.enabled = el.get("enabled", true); client.named_devices.emplace_back(named_cert); } } @@ -777,6 +780,7 @@ namespace nvhttp { nlohmann::json named_cert_node; named_cert_node["name"] = named_cert.name; named_cert_node["uuid"] = named_cert.uuid; + named_cert_node["enabled"] = named_cert.enabled; named_cert_nodes.push_back(named_cert_node); } @@ -1056,6 +1060,8 @@ namespace nvhttp { conf_intern.servercert = cert; } + bool is_client_enabled(const std::string_view cert_pem); + void start() { platf::set_thread_name("nvhttp"); auto shutdown_event = mail::man->event(mail::shutdown); @@ -1124,6 +1130,13 @@ namespace nvhttp { return verified; } + // Check if this client is enabled + auto pem = crypto::pem(x509); + if (!is_client_enabled(pem)) { + BOOST_LOG(info) << "Client is disabled -- denied"sv; + return verified; + } + verified = 1; return verified; @@ -1225,4 +1238,26 @@ namespace nvhttp { load_state(); return removed; } + + bool set_client_enabled(const std::string_view uuid, bool enabled) { + client_t &client = client_root; + for (auto &named_cert : client.named_devices) { + if (named_cert.uuid == uuid) { + named_cert.enabled = enabled; + save_state(); + return true; + } + } + return false; + } + + bool is_client_enabled(const std::string_view cert_pem) { + client_t &client = client_root; + for (auto &named_cert : client.named_devices) { + if (named_cert.cert == cert_pem) { + return named_cert.enabled; + } + } + return true; + } } // namespace nvhttp diff --git a/src/nvhttp.h b/src/nvhttp.h index 636337071b9..c24d018c969 100644 --- a/src/nvhttp.h +++ b/src/nvhttp.h @@ -184,6 +184,14 @@ namespace nvhttp { */ bool unpair_client(std::string_view uuid); + /** + * @brief Enable or disable a client. + * @param uuid The UUID of the client. + * @param enabled Whether the client should be enabled. + * @return true if the client was found and updated. + */ + bool set_client_enabled(std::string_view uuid, bool enabled); + /** * @brief Get all paired clients. * @return The list of all paired clients. diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index d74bb204f53..fd7877902e3 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -135,8 +135,16 @@

{{ $t('troubleshooting.unpair_title') }}

  • -
    {{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}
    - +
  • @@ -455,6 +463,17 @@

    {{ $t('troubleshooting.logs') }}

    this.refreshClients(); }); }, + toggleClient(uuid, enabled) { + fetch("./api/clients/update", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ uuid, enabled }) + }).then(() => { + this.refreshClients(); + }); + }, refreshClients() { fetch("./api/clients/list") .then((response) => response.json()) From 25e5769cf475fa21d73b5bad91bd1b0f54a25f26 Mon Sep 17 00:00:00 2001 From: neatnoise Date: Tue, 17 Mar 2026 22:24:01 +0100 Subject: [PATCH 2/6] fix: address SonarCloud code smells - confighttp.cpp: catch nlohmann::json::exception instead of std::exception - nvhttp.cpp: make client and named_cert const references in is_client_enabled --- src/confighttp.cpp | 2 +- src/nvhttp.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 413bf4d55f7..d03b190369b 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -872,7 +872,7 @@ namespace confighttp { bool enabled = input_tree.value("enabled", true); output_tree["status"] = nvhttp::set_client_enabled(uuid, enabled); send_response(response, output_tree); - } catch (std::exception &e) { + } catch (nlohmann::json::exception &e) { BOOST_LOG(warning) << "Update Client: "sv << e.what(); bad_request(response, request, e.what()); } diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index ecd9d2f960e..03c1e26aaa8 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -1252,8 +1252,8 @@ namespace nvhttp { } bool is_client_enabled(const std::string_view cert_pem) { - client_t &client = client_root; - for (auto &named_cert : client.named_devices) { + const client_t &client = client_root; + for (const auto &named_cert : client.named_devices) { if (named_cert.cert == cert_pem) { return named_cert.enabled; } From 7d46d8d34639be55b3da7188b249df29fd215f6d Mon Sep 17 00:00:00 2001 From: neatnoise Date: Thu, 26 Mar 2026 16:48:48 +0100 Subject: [PATCH 3/6] fix: address review feedback for access-control - Replace enable/disable button with Bootstrap switch toggle - Split auth checks and add CSRF validation per new pattern - Add @api_examples for updateClient endpoint - Remove blank doc comment line --- docs/api.md | 3 +++ src/confighttp.cpp | 12 ++++++++++-- src_assets/common/assets/web/troubleshooting.html | 12 ++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/api.md b/docs/api.md index b41699f77dc..c62545b6edb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -61,6 +61,9 @@ curl -u user:pass -H "X-CSRF-Token: your_token_here" \ ## POST /api/clients/unpair-all @copydoc confighttp::unpairAll() +## POST /api/clients/update +@copydoc confighttp::updateClient() + ## GET /api/config @copydoc confighttp::getConfig() diff --git a/src/confighttp.cpp b/src/confighttp.cpp index d03b190369b..441f79359d8 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -847,7 +847,6 @@ namespace confighttp { * @brief Enable or disable a client. * @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} * { @@ -855,9 +854,18 @@ namespace confighttp { * "enabled": true * } * @endcode + * + * @api_examples{/api/clients/update| POST| {"uuid":"","enabled":true}} */ void updateClient(resp_https_t response, req_https_t request) { - if (!check_content_type(response, request, "application/json") || !authenticate(response, request)) { + if (!check_content_type(response, request, "application/json")) { + return; + } + if (!authenticate(response, request)) { + return; + } + std::string client_id = get_client_id(request); + if (!validate_csrf_token(response, request, client_id)) { return; } diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index fd7877902e3..5f2348ee261 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -137,13 +137,13 @@

    {{ $t('troubleshooting.unpair_title') }}

  • {{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }} - - {{ client.enabled ? 'Enabled' : 'Disabled' }} -
    - +
    + +
    From 9f11b0aa0a7344d09cef6e23a5eb40df1faae38c Mon Sep 17 00:00:00 2001 From: neatnoise Date: Sat, 28 Mar 2026 17:24:08 +0100 Subject: [PATCH 4/6] fix: remove duplicated route registrations and add aria-checked to switch - Remove 8 duplicated route registrations in confighttp.cpp from rebase - Move /api/clients/update route to group with other client routes - Add required aria-checked attribute for role=switch accessibility --- src/confighttp.cpp | 10 +--------- src_assets/common/assets/web/troubleshooting.html | 1 + 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 441f79359d8..2f2bbf66bc5 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1759,6 +1759,7 @@ namespace confighttp { server.resource["^/api/clients/list$"]["GET"] = getClients; server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; + server.resource["^/api/clients/update$"]["POST"] = updateClient; server.resource["^/api/config$"]["GET"] = getConfig; server.resource["^/api/config$"]["POST"] = saveConfig; server.resource["^/api/configLocale$"]["GET"] = getLocale; @@ -1772,15 +1773,6 @@ namespace confighttp { server.resource["^/api/restart$"]["POST"] = restart; server.resource["^/api/vigembus/status$"]["GET"] = getViGEmBusStatus; server.resource["^/api/vigembus/install$"]["POST"] = installViGEmBus; - server.resource["^/api/password$"]["POST"] = savePassword; - server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp; - server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; - server.resource["^/api/clients/list$"]["GET"] = getClients; - server.resource["^/api/clients/update$"]["POST"] = updateClient; - server.resource["^/api/clients/unpair$"]["POST"] = unpair; - server.resource["^/api/apps/close$"]["POST"] = closeApp; - server.resource["^/api/covers/upload$"]["POST"] = uploadCover; - server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover; // static/dynamic resources server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index 5f2348ee261..33d79c934ce 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -142,6 +142,7 @@

    {{ $t('troubleshooting.unpair_title') }}