ref:ccf97e38796be6cfcbff0ef248a684d39e181eba

Merge commit from fork

SHA: ccf97e38796be6cfcbff0ef248a684d39e181eba
Author: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com>
Date: 2026-09-02 23:48
Parents: 1583e7c
9 files changed +897 -85
Type
src/confighttp.cpp +87 −10
@@ -1704,18 +1704,86 @@
}
/**
* @brief List client pairing requests that are waiting for PIN approval.
*
* @api_examples{/api/pin| GET| null}
*/
void getPendingPairings(const resp_https_t &response, const req_https_t &request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
nlohmann::json output_tree;
output_tree["pairings"] = nlohmann::json::array();
for (const auto &pairing : nvhttp::get_pending_pairings()) {
output_tree["pairings"].push_back({
{"id", pairing.id},
{"name", pairing.name},
{"address", pairing.address},
});
}
send_response(response, output_tree);
}
/**
* @brief Cancel a client pairing request that is waiting for PIN approval.
* The body for the delete request should be JSON serialized in the following format:
* @code{.json}
* {
* "pairing_id": "<pairing_id>"
* }
* @endcode
*
* @api_examples{/api/pin| DELETE| {"pairing_id":"0123456789abcdef0123456789abcdef"}}
*/
void cancelPairing(const resp_https_t &response, const req_https_t &request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
const std::string client_id = get_client_id(request);
if (!validate_csrf_token(response, request, client_id)) {
return;
}
print_req(request);
* @brief Send a pin code to the host. The pin is generated from the Moonlight client during the pairing process.
* @param response The HTTP response object.
* @param request The HTTP request object.
std::stringstream ss;
ss << request->content.rdbuf();
try {
const nlohmann::json input_tree = nlohmann::json::parse(ss);
const std::string pairing_id = input_tree.value("pairing_id", "");
if (!nvhttp::is_valid_pairing_id(pairing_id)) {
bad_request(response, request, "pairing_id must contain exactly 32 hexadecimal characters");
return;
}
nlohmann::json output_tree;
output_tree["status"] = nvhttp::cancel_pairing(pairing_id);
send_response(response, output_tree);
} catch (std::exception &e) {
BOOST_LOG(warning) << "CancelPairing: "sv << e.what();
bad_request(response, request, e.what());
}
}
/**
* @brief Send a PIN code to the explicitly selected pairing request.
* The body for the post request should be JSON serialized in the following format:
* @code{.json}
* {
* "pairing_id": "<pairing_id>",
* "pin": "<pin>",
* "name": "Friendly Client Name"
* }
* @endcode
*
* @api_examples{/api/pin| POST| {"pairing_id":"0123456789abcdef0123456789abcdef","pin":"1234","name":"My PC"}}
* @api_examples{/api/pin| POST| {"pin":"1234","name":"My PC"}}
*/
void savePin(const resp_https_t &response, const req_https_t &request) {
if (!check_content_type(response, request, "application/json")) {
@@ -1738,15 +1806,22 @@
nlohmann::json output_tree;
nlohmann::json input_tree = nlohmann::json::parse(ss);
const std::string name = input_tree.value("name", "");
const std::string pairing_id = input_tree.value("pairing_id", "");
const std::string pin = input_tree.value("pin", "");
int _pin = 0;
_pin = std::stoi(pin);
if (_pin < 0 || _pin > 9999) {
bad_request(response, request, "PIN must be between 0000 and 9999");
if (!nvhttp::is_valid_pairing_id(pairing_id)) {
bad_request(response, request, "pairing_id must contain exactly 32 hexadecimal characters");
return;
}
if (!nvhttp::is_valid_pairing_pin(pin)) {
bad_request(response, request, "PIN must contain exactly 4 numeric digits");
return;
}
if (!nvhttp::is_valid_pairing_name(name)) {
bad_request(response, request, "Client name must contain between 1 and 128 bytes");
return;
}
output_tree["status"] = nvhttp::pin(pin, name);
output_tree["status"] = nvhttp::pin(pairing_id, pin, name);
send_response(response, output_tree);
} catch (std::exception &e) {
BOOST_LOG(warning) << "SavePin: "sv << e.what();
@@ -2217,6 +2292,8 @@
server.resource["^/api/covers/upload$"]["POST"] = uploadCover;
server.resource["^/api/csrf-token$"]["GET"] = getCSRFToken;
server.resource["^/api/password$"]["POST"] = savePassword;
server.resource["^/api/pin$"]["DELETE"] = cancelPairing;
server.resource["^/api/pin$"]["GET"] = getPendingPairings;
server.resource["^/api/pin$"]["POST"] = savePin;
server.resource["^/api/logs$"]["GET"] = getLogs;
server.resource["^/api/reset-display-device-persistence$"]["POST"] = resetDisplayDevicePersistence;
src/confighttp.h +24 −0
@@ -79,6 +79,30 @@
void getCSRFToken(const resp_https_t &response, const req_https_t &request);
/**
* @brief List authenticated pairing requests awaiting operator approval.
*
* @param response HTTP response object to populate.
* @param request HTTP request data from the client.
*/
void getPendingPairings(const resp_https_t &response, const req_https_t &request);
/**
* @brief Cancel an explicitly selected pairing request.
*
* @param response HTTP response object to populate.
* @param request HTTP request data from the client.
*/
void cancelPairing(const resp_https_t &response, const req_https_t &request);
/**
* @brief Apply a PIN to an explicitly selected pairing request.
*
* @param response HTTP response object to populate.
* @param request HTTP request data from the client.
*/
void savePin(const resp_https_t &response, const req_https_t &request);
/**
* @brief Check whether a detected driver version satisfies a minimum version.
*
* Empty minimum versions accept any detected version. Non-empty minimum versions
src/nvhttp.cpp +216 −47
@@ -6,7 +6,10 @@
#define BOOST_BIND_GLOBAL_PLACEHOLDERS
// standard includes
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <format>
#include <mutex>
#include <string>
#include <utility>
@@ -171,5 +174,6 @@
// uniqueID, session
std::unordered_map<std::string, pair_session_t> map_id_sess; ///< Pairing sessions keyed by temporary unique ID.
std::recursive_mutex map_id_sess_mutex; ///< Mutex protecting pairing-session storage and lifecycle transitions.
client_t client_root; ///< In-memory representation of the paired-client database.
std::atomic<uint32_t> session_id_counter; ///< Monotonic counter used to allocate GameStream session IDs.
@@ -421,6 +425,152 @@
return launch_session;
}
/**
* @brief Write a completed response to the client waiting for PIN approval.
*
* @param sess Pairing session that owns the waiting response.
* @param tree XML response body to write.
* @return `true` when a live response was available.
*/
bool write_pairing_response(pair_session_t &sess, const pt::ptree &tree) {
std::ostringstream data;
pt::write_xml(data, tree);
auto &response = sess.async_insert_pin.response;
if (response.has_left() && response.left()) {
response.left()->close_connection_after_response = true;
response.left()->write(data.str());
} else if (response.has_right() && response.right()) {
response.right()->close_connection_after_response = true;
response.right()->write(data.str());
} else {
return false;
}
response = std::monostate {};
return true;
}
/**
* @brief Expire stale pairing sessions while the session mutex is held.
*
* @param now Monotonic time used to evaluate session deadlines.
*/
void expire_pair_sessions_unlocked(const std::chrono::steady_clock::time_point now) {
for (auto it = map_id_sess.begin(); it != map_id_sess.end();) {
if (it->second.async_insert_pin.expires_at > now) {
++it;
continue;
}
pt::ptree tree;
tree.put("root.paired", 0);
tree.put("root.<xmlattr>.status_code", 408);
tree.put("root.<xmlattr>.status_message", "Pairing session expired");
write_pairing_response(it->second, tree);
it = map_id_sess.erase(it);
}
}
pair_session_insert_e insert_pair_session(pair_session_t sess, std::string &pairing_id) {
std::scoped_lock lock(map_id_sess_mutex);
const auto now = std::chrono::steady_clock::now();
expire_pair_sessions_unlocked(now);
pairing_id.clear();
if (map_id_sess.contains(sess.client.uniqueID)) {
return pair_session_insert_e::ALREADY_EXISTS;
}
if (map_id_sess.size() >= MAX_PENDING_PAIRING_SESSIONS) {
return pair_session_insert_e::FULL;
}
do {
pairing_id = util::hex_vec(crypto::rand(PAIRING_ID_SIZE / 2), true);
} while (std::ranges::any_of(map_id_sess, [&](const auto &entry) {
return entry.second.async_insert_pin.id == pairing_id;
}));
sess.async_insert_pin.id = pairing_id;
sess.async_insert_pin.expires_at = now + PAIRING_SESSION_TIMEOUT;
map_id_sess.emplace(sess.client.uniqueID, std::move(sess));
return pair_session_insert_e::ADDED;
}
bool is_valid_pairing_id(const std::string_view pairing_id) {
return pairing_id.size() == PAIRING_ID_SIZE && std::ranges::all_of(pairing_id, [](const char character) {
return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F');
});
}
bool is_valid_pairing_pin(const std::string_view pin) {
return pin.size() == 4 && std::ranges::all_of(pin, [](const char character) {
return character >= '0' && character <= '9';
});
}
bool is_valid_pairing_name(const std::string_view name) {
return !name.empty() && name.size() <= MAX_PAIRING_CLIENT_NAME_SIZE;
}
void expire_pair_sessions(const std::chrono::steady_clock::time_point now) {
std::scoped_lock lock(map_id_sess_mutex);
expire_pair_sessions_unlocked(now);
}
std::vector<pending_pairing_t> get_pending_pairings() {
std::scoped_lock lock(map_id_sess_mutex);
expire_pair_sessions_unlocked(std::chrono::steady_clock::now());
std::vector<const pair_session_t *> pending_sessions;
pending_sessions.reserve(map_id_sess.size());
for (const auto &entry : map_id_sess) {
const auto &sess = entry.second;
if (sess.last_phase == PAIR_PHASE::NONE) {
pending_sessions.push_back(&sess);
}
}
std::ranges::sort(pending_sessions, {}, [](const pair_session_t *sess) {
return sess->async_insert_pin.expires_at;
});
std::vector<pending_pairing_t> result;
result.reserve(pending_sessions.size());
for (const auto *sess : pending_sessions) {
result.push_back({
.id = sess->async_insert_pin.id,
.name = sess->async_insert_pin.device_name,
.address = sess->async_insert_pin.address,
});
}
return result;
}
bool cancel_pairing(const std::string_view pairing_id) {
if (!is_valid_pairing_id(pairing_id)) {
return false;
}
std::scoped_lock lock(map_id_sess_mutex);
expire_pair_sessions_unlocked(std::chrono::steady_clock::now());
const auto sess_it = std::ranges::find_if(map_id_sess, [&](const auto &entry) {
return entry.second.last_phase == PAIR_PHASE::NONE && entry.second.async_insert_pin.id == pairing_id;
});
if (sess_it == map_id_sess.end()) {
return false;
}
pt::ptree tree;
tree.put("root.paired", 0);
tree.put("root.<xmlattr>.status_code", 400);
tree.put("root.<xmlattr>.status_message", "Pairing request cancelled by operator");
write_pairing_response(sess_it->second, tree);
map_id_sess.erase(sess_it);
return true;
}
void remove_session(const pair_session_t &sess) {
std::scoped_lock lock(map_id_sess_mutex);
map_id_sess.erase(sess.client.uniqueID);
}
@@ -436,7 +586,7 @@
tree.put("root.paired", 0);
tree.put("root.<xmlattr>.status_code", 400);
tree.put("root.<xmlattr>.status_message", status_msg);
remove_session(sess); // Security measure, delete the session when something went wrong and force a re-pair
sess.failed = true;
}
/**
@@ -610,6 +760,5 @@
tree.put("root.paired", 0);
}
remove_session(sess);
tree.put("root.<xmlattr>.status_code", 200);
}
@@ -719,27 +868,59 @@
if (it->second == "getservercert"sv) {
pair_session_t sess;
sess.client.uniqueID = std::move(uniqID);
sess.client.uniqueID = uniqID;
sess.client.cert = util::from_hex_vec(get_arg(args, "clientcert"), true);
sess.async_insert_pin.salt = get_arg(args, "salt");
sess.async_insert_pin.device_name = get_arg(args, "devicename");
sess.async_insert_pin.address = net::addr_to_normalized_string(request->remote_endpoint().address());
BOOST_LOG(debug) << sess.client.cert;
auto ptr = map_id_sess.emplace(sess.client.uniqueID, std::move(sess)).first;
const bool pin_stdin = config::sunshine.flags[config::flag::PIN_STDIN];
if (!pin_stdin) {
sess.async_insert_pin.response = response;
}
ptr->second.async_insert_pin.salt = std::move(get_arg(args, "salt"));
if (config::sunshine.flags[config::flag::PIN_STDIN]) {
std::string pairing_id;
switch (insert_pair_session(std::move(sess), pairing_id)) {
case pair_session_insert_e::ALREADY_EXISTS:
tree.put("root.paired", 0);
tree.put("root.<xmlattr>.status_code", 409);
tree.put("root.<xmlattr>.status_message", "A pairing session with this uniqueid already exists");
return;
case pair_session_insert_e::FULL:
tree.put("root.paired", 0);
tree.put("root.<xmlattr>.status_code", 503);
tree.put("root.<xmlattr>.status_message", "Too many pending pairing sessions");
return;
case pair_session_insert_e::ADDED:
break;
}
if (pin_stdin) {
std::string pin;
std::cout << "Please insert pin: "sv;
std::getline(std::cin, pin);
getservercert(ptr->second, tree, pin);
std::scoped_lock lock(map_id_sess_mutex);
expire_pair_sessions_unlocked(std::chrono::steady_clock::now());
const auto sess_it = map_id_sess.find(uniqID);
if (sess_it == map_id_sess.end() || sess_it->second.async_insert_pin.id != pairing_id) {
tree.put("root.paired", 0);
tree.put("root.<xmlattr>.status_code", 408);
tree.put("root.<xmlattr>.status_message", "Pairing session expired");
return;
}
getservercert(sess_it->second, tree, pin);
if (sess_it->second.failed) {
map_id_sess.erase(sess_it);
}
return;
} else {
#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1
system_tray::update_tray_require_pin();
#endif
ptr->second.async_insert_pin.response = std::move(response);
fg.disable();
return;
}
@@ -750,6 +931,8 @@
}
}
std::scoped_lock lock(map_id_sess_mutex);
expire_pair_sessions_unlocked(std::chrono::steady_clock::now());
auto sess_it = map_id_sess.find(uniqID);
if (sess_it == std::end(map_id_sess)) {
tree.put("root.<xmlattr>.status_code", 400);
@@ -758,6 +941,7 @@
return;
}
bool pairing_complete = false;
if (it = args.find("clientchallenge"); it != std::end(args)) {
auto challenge = util::from_hex_vec(it->second, true);
clientchallenge(sess_it->second, tree, challenge);
@@ -767,58 +951,43 @@
} else if (it = args.find("clientpairingsecret"); it != std::end(args)) {
auto pairingsecret = util::from_hex_vec(it->second, true);
clientpairingsecret(sess_it->second, add_cert, tree, pairingsecret);
pairing_complete = true;
} else {
tree.put("root.<xmlattr>.status_code", 404);
tree.put("root.<xmlattr>.status_message", "Invalid pairing request");
fail_pair(sess_it->second, tree, "Invalid pairing request");
}
}
bool pin(std::string pin, std::string name) {
pt::ptree tree;
if (map_id_sess.empty()) {
return false;
if (pairing_complete || sess_it->second.failed) {
map_id_sess.erase(sess_it);
}
}
bool pin(const std::string_view pairing_id, std::string pin, std::string name) {
// ensure pin is 4 digits
if (pin.size() != 4) {
tree.put("root.paired", 0);
tree.put("root.<xmlattr>.status_code", 400);
tree.put(
"root.<xmlattr>.status_message",
std::format("Pin must be 4 digits, {} provided", pin.size())
);
if (!is_valid_pairing_id(pairing_id) || !is_valid_pairing_pin(pin) || !is_valid_pairing_name(name)) {
return false;
}
// ensure all pin characters are numeric
std::scoped_lock lock(map_id_sess_mutex);
expire_pair_sessions_unlocked(std::chrono::steady_clock::now());
const auto sess_it = std::ranges::find_if(map_id_sess, [&](const auto &entry) {
return entry.second.last_phase == PAIR_PHASE::NONE && entry.second.async_insert_pin.id == pairing_id;
});
if (sess_it == map_id_sess.end()) {
if (!std::all_of(pin.begin(), pin.end(), ::isdigit)) {
tree.put("root.paired", 0);
tree.put("root.<xmlattr>.status_code", 400);
tree.put("root.<xmlattr>.status_message", "Pin must be numeric");
return false;
}
auto &sess = std::begin(map_id_sess)->second;
auto &sess = sess_it->second;
pt::ptree tree;
getservercert(sess, tree, pin);
sess.client.name = name;
// response to the request for pin
std::ostringstream data;
pt::write_xml(data, tree);
auto &async_response = sess.async_insert_pin.response;
if (async_response.has_left() && async_response.left()) {
async_response.left()->write(data.str());
} else if (async_response.has_right() && async_response.right()) {
async_response.right()->write(data.str());
} else {
return false;
if (!sess.failed) {
sess.client.name = std::move(name);
}
// reset async_response
async_response = std::decay_t<decltype(async_response.left())>();
// response to the current request
return true;
const bool response_written = write_pairing_response(sess, tree);
const bool success = response_written && !sess.failed;
if (!response_written || sess.failed) {
map_id_sess.erase(sess_it);
}
return success;
}
/**
src/nvhttp.h +106 −3
@@ -6,7 +6,11 @@
#pragma once
// standard includes
#include <chrono>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
// lib includes
#include <boost/property_tree/ptree.hpp>
@@ -43,8 +47,28 @@
* @brief The HTTPS port, as a difference from the config port.
*/
constexpr auto PORT_HTTPS = -5;
/**
* @brief Maximum number of pairing sessions retained at one time.
*/
constexpr std::size_t MAX_PENDING_PAIRING_SESSIONS = 32;
/**
* @brief Number of hexadecimal characters in an operator approval identifier.
*/
constexpr std::size_t PAIRING_ID_SIZE = 32;
/**
* @brief Maximum number of bytes accepted for an operator-supplied paired-client name.
*/
constexpr std::size_t MAX_PAIRING_CLIENT_NAME_SIZE = 128;
/**
* @brief Lifetime of an incomplete pairing session.
*/
constexpr auto PAIRING_SESSION_TIMEOUT = std::chrono::minutes {5};
/**
* @brief Start the nvhttp server.
* @examples
* nvhttp::start();
@@ -114,15 +138,93 @@
std::shared_ptr<typename SimpleWeb::ServerBase<SunshineHTTPS>::Response>>
response;
std::string salt = {};
std::string id = {}; ///< Unguessable identifier used by the Web UI to approve this session.
std::string device_name = {}; ///< Untrusted device name reported by the pairing client.
std::string address = {}; ///< Network address from which the pairing request originated.
std::chrono::steady_clock::time_point expires_at = std::chrono::steady_clock::time_point::max(); ///< Deadline for completing this pairing session.
} async_insert_pin; ///< Async insert pin.
/**
* @brief used as a security measure to prevent out of order calls
*/
PAIR_PHASE last_phase = PAIR_PHASE::NONE;
bool failed = false; ///< Whether protocol validation failed and the session must be removed.
};
/**
* @brief Operator-visible context for a pairing request awaiting PIN approval.
*/
struct pending_pairing_t {
std::string id; ///< Unguessable approval identifier.
std::string name; ///< Untrusted device name reported by the pairing client.
std::string address; ///< Network address from which the request originated.
};
/**
* @brief Result of inserting a new pairing session into bounded pending storage.
*/
enum class pair_session_insert_e {
ADDED, ///< The session was inserted successfully.
ALREADY_EXISTS, ///< A session with the same client unique ID is already active.
FULL ///< The bounded session store has reached its limit.
};
/**
* @brief Validate an operator approval identifier received from the REST API.
*
* @param pairing_id Approval identifier to validate.
* @return `true` when the value has the exact hexadecimal format Sunshine generates.
*/
bool is_valid_pairing_id(std::string_view pairing_id);
/**
* @brief Validate a Moonlight pairing PIN received from the REST API.
*
* @param pin PIN to validate.
* @return `true` when the value contains exactly four ASCII digits.
*/
bool is_valid_pairing_pin(std::string_view pin);
/**
* @brief Validate an operator-supplied paired-client name received from the REST API.
*
* @param name Client name to validate.
* @return `true` when the name is non-empty and within the storage limit.
*/
bool is_valid_pairing_name(std::string_view name);
/**
* @brief Insert a newly created pairing session into bounded pending storage.
*
* @param sess Pairing session to insert.
* @param pairing_id Receives the unguessable approval identifier on success.
* @return Status describing whether the session was inserted.
*/
pair_session_insert_e insert_pair_session(pair_session_t sess, std::string &pairing_id);
/**
* @brief Remove pairing sessions whose completion deadline has passed.
*
* @param now Monotonic time used to evaluate session deadlines.
*/
void expire_pair_sessions(std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now());
/**
* @brief List pairing requests that are waiting for operator PIN approval.
*
* @return Pending requests ordered from oldest to newest.
*/
std::vector<pending_pairing_t> get_pending_pairings();
/**
* @brief Cancel a pairing request that is waiting for operator approval.
*
* @param pairing_id Unguessable approval identifier returned by @ref get_pending_pairings.
* @return `true` if the pending request was found and cancelled.
*/
bool cancel_pairing(std::string_view pairing_id);
/**
* @brief removes the temporary pairing session
* @param sess
*/
@@ -193,15 +295,16 @@
void clientpairingsecret(pair_session_t &sess, std::shared_ptr<safe::queue_t<crypto::x509_t>> &add_cert, boost::property_tree::ptree &tree, const std::string &client_pairing_secret);
/**
* @brief Apply the user supplied PIN to the explicitly selected pairing request.
* @brief Compare the user supplied pin to the Moonlight pin.
* @param pairing_id Unguessable identifier of the pairing request to approve.
* @param pin The user supplied pin.
* @param name The user supplied name.
* @return `true` if the pin is correct, `false` otherwise.
* @examples
* bool pin_status = nvhttp::pin("1234", "laptop");
* bool pin_status = nvhttp::pin("0123456789abcdef0123456789abcdef", "1234", "laptop");
* @examples_end
*/
bool pin(std::string_view pairing_id, std::string pin, std::string name);
bool pin(std::string pin, std::string name);
/**
* @brief Remove single client.
src_assets/common/assets/web/pin.html +116 −25
@@ -13,15 +13,37 @@
<div class="card flex-column d-flex p-4 mb-4">
<div class="input-group mt-2">
<span class="input-group-text">
<user-round-search :size="18" class="icon"></user-round-search>
</span>
<select v-model="selectedPairingId" class="form-select" id="pairing-input" required>
<option disabled value="">
{{ pendingPairings.length ? $t('pin.select_pairing') : $t('pin.no_pending_pairings') }}
</option>
<option v-for="pairing in pendingPairings" :key="pairing.id" :value="pairing.id">
{{ pairing.name || $t('pin.unknown_device') }} — {{ pairing.address || $t('pin.unknown_address') }}
</option>
</select>
<button
type="button"
class="btn btn-outline-danger"
:disabled="!selectedPairingId"
:title="$t('pin.cancel_pairing')"
@click="cancelSelectedPairing"
>
<x :size="18" class="icon"></x>
</button>
</div>
<div class="input-group mt-2">
<span class="input-group-text">
<hash :size="18" class="icon"></hash>
</span>
<input v-model="pin" type="text" pattern="\d{4}" maxlength="4" inputmode="numeric" :placeholder="`${$t('navbar.pin')}`" autofocus id="pin-input" class="form-control" required />
<input type="text" pattern="\d*" :placeholder="`${$t('navbar.pin')}`" autofocus id="pin-input" class="form-control" required />
</div>
<div class="input-group my-4">
<span class="input-group-text">
<monitor :size="18" class="icon"></monitor>
</span>
<input type="text" :placeholder="`${$t('pin.device_name')}`" id="name-input" class="form-control" required />
<input v-model="name" type="text" :placeholder="`${$t('pin.device_name')}`" id="name-input" class="form-control" required />
</div>
<button class="btn btn-primary">
<forward :size="18" class="icon"></forward>
@@ -31,7 +53,7 @@
<div class="alert alert-warning">
<b>{{ $t('_common.warning') }}</b> {{ $t('pin.warning_msg') }}
</div>
<div id="status"></div>
<div v-if="status" :class="`alert alert-${status.type}`" role="alert">{{ status.message }}</div>
</form>
</div>
</body>
@@ -45,6 +67,8 @@
Forward,
Hash,
Monitor,
UserRoundSearch,
X,
} from '@lucide/vue'
let app = createApp({
@@ -53,35 +77,102 @@
Forward,
Hash,
Monitor,
UserRoundSearch,
X,
},
inject: ['i18n'],
data() {
return {
name: '',
pendingPairings: [],
pin: '',
refreshTimer: null,
selectedPairingId: '',
status: null,
};
},
mounted() {
this.loadPendingPairings();
this.refreshTimer = window.setInterval(() => this.loadPendingPairings(), 2000);
},
beforeUnmount() {
window.clearInterval(this.refreshTimer);
},
methods: {
/**
* Refresh the authenticated list of pairing requests awaiting approval.
*/
async loadPendingPairings() {
try {
const response = await apiFetch('./api/pin', {method: 'GET'});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body = await response.json();
this.pendingPairings = body.pairings || [];
if (!this.pendingPairings.some((pairing) => pairing.id === this.selectedPairingId)) {
this.selectedPairingId = this.pendingPairings.length === 1 ? this.pendingPairings[0].id : '';
}
} catch (error) {
console.error('Failed to load pending pairing requests', error);
this.status = {type: 'danger', message: this.i18n.t('pin.load_failure')};
}
},
/**
registerDevice(e) {
let pin = document.querySelector("#pin-input").value;
let name = document.querySelector("#name-input").value;
* Apply the entered PIN only to the pairing request selected by the operator.
*/
async registerDevice() {
this.status = null;
if (!this.selectedPairingId) {
this.status = {type: 'danger', message: this.i18n.t('pin.select_pairing_required')};
return;
}
const body = JSON.stringify({
pairing_id: this.selectedPairingId,
pin: this.pin,
name: this.name,
});
const response = await apiFetch('./api/pin', {
document.querySelector("#status").innerHTML = "";
let b = JSON.stringify({pin: pin, name: name});
apiFetch("./api/pin", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: b
})
.then((response) => response.json())
.then((response) => {
if (response.status === true) {
document.querySelector(
"#status"
).innerHTML = `<div class="alert alert-success" role="alert">${this.i18n.t('pin.pair_success')}</div>`;
document.querySelector("#pin-input").value = "";
document.querySelector("#name-input").value = "";
} else {
document.querySelector(
"#status"
).innerHTML = `<div class="alert alert-danger" role="alert">${this.i18n.t('pin.pair_failure')}</div>`;
}
});
body,
});
const result = await response.json();
if (result.status === true) {
this.status = {type: 'success', message: this.i18n.t('pin.pair_success')};
this.pin = '';
this.name = '';
} else {
this.status = {type: 'danger', message: this.i18n.t('pin.pair_failure')};
}
await this.loadPendingPairings();
},
/**
* Cancel the selected pending request without affecting other clients.
*/
async cancelSelectedPairing() {
if (!this.selectedPairingId) {
return;
}
const response = await apiFetch('./api/pin', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({pairing_id: this.selectedPairingId}),
});
const result = await response.json();
this.status = result.status === true
? {type: 'success', message: this.i18n.t('pin.cancel_success')}
: {type: 'danger', message: this.i18n.t('pin.cancel_failure')};
await this.loadPendingPairings();
}
}
});
src_assets/common/assets/web/public/assets/locale/en.json +9 −0
@@ -515,11 +515,20 @@
"success_msg": "Password has been changed successfully! This page will reload soon, your browser will ask you for the new credentials."
},
"pin": {
"cancel_failure": "The pairing request could not be cancelled",
"cancel_pairing": "Cancel selected pairing request",
"cancel_success": "Pairing request cancelled",
"device_name": "Device Name",
"load_failure": "Pending pairing requests could not be loaded",
"no_pending_pairings": "No devices are waiting to pair",
"pair_failure": "Pairing Failed: Check if the PIN is typed correctly",
"pair_success": "Success! Please check Moonlight to continue",
"pin_pairing": "PIN Pairing",
"select_pairing": "Select the device requesting approval",
"select_pairing_required": "Select the device you intend to pair",
"send": "Send",
"unknown_address": "Unknown address",
"unknown_device": "Unknown device",
"warning_msg": "Make sure you have access to the client you are pairing with. This software can give total control to your computer, so be careful!"
},
"resource_card": {
src_assets/common/assets/web/sunshine.css +5 −0
@@ -1464,6 +1464,11 @@
transition: all var(--transition-fast);
}
/* Keep option text clear of the select's dropdown indicator. */
.form-select {
padding-right: 2.25rem;
}
.form-control:focus,
.form-select:focus {
border-color: var(--color-primary);
tests/unit/test_confighttp.cpp +131 −0
@@ -31,6 +31,7 @@
#include <src/crypto.h>
#include <src/httpcommon.h>
#include <src/network.h>
#include <src/nvhttp.h>
#include <src/utility.h>
using namespace std::literals;
@@ -110,6 +111,7 @@
void SetUp() override {
BaseTest::SetUp();
nvhttp::expire_pair_sessions(std::chrono::steady_clock::time_point::max());
confighttp::set_virtual_input_license_status_provider_for_testing([]() {
lvh::LicenseStatus license;
license.service_available = true;
@@ -323,6 +325,9 @@
server->resource["^/virtual-input-status-test$"]["GET"] = confighttp::getVirtualInputStatus;
server->resource["^/virtual-input-license-test$"]["GET"] = confighttp::getVirtualInputLicense;
server->resource["^/virtual-input-license-test$"]["POST"] = confighttp::updateVirtualInputLicense;
server->resource["^/pairing-test$"]["DELETE"] = confighttp::cancelPairing;
server->resource["^/pairing-test$"]["GET"] = confighttp::getPendingPairings;
server->resource["^/pairing-test$"]["POST"] = confighttp::savePin;
// Start server
server_thread = std::jthread([this]() {
@@ -362,6 +367,7 @@
config::sunshine.salt = saved_salt;
config::sunshine.locale = saved_locale;
config::sunshine.csrf_allowed_origins = saved_csrf_allowed_origins;
nvhttp::expire_pair_sessions(std::chrono::steady_clock::time_point::max());
// Clean up test HTML file from WEB_DIR
if (std::filesystem::exists(web_dir_test_file)) {
@@ -378,6 +384,23 @@
return "Basic " + SimpleWeb::Crypto::Base64::encode(username + ":" + password);
}
/**
* @brief Insert a test pairing request into the production pending-session registry.
*
* @return Unguessable approval identifier assigned to the request.
*/
static std::string insert_pending_pairing() {
nvhttp::pair_session_t session;
session.client.uniqueID = "rest-api-client";
session.async_insert_pin.salt = "ff5dc6eda99339a8a0793e216c4257c4";
session.async_insert_pin.device_name = "REST client";
session.async_insert_pin.address = "192.0.2.30";
std::string pairing_id;
EXPECT_EQ(nvhttp::insert_pair_session(std::move(session), pairing_id), nvhttp::pair_session_insert_e::ADDED);
return pairing_id;
}
static void assert_security_headers(const std::shared_ptr<SimpleWeb::Client<SimpleWeb::HTTPS>::Response> &response) {
const auto x_frame = response->header.find("X-Frame-Options");
ASSERT_NE(x_frame, response->header.end());
@@ -471,6 +494,7 @@
endpoint_request_t {"Page", "GET", "/page-test", ""},
endpoint_request_t {"CsrfToken", "GET", "/csrf-token-test", ""},
endpoint_request_t {"BrowseDirectory", "GET", "/browse-test", ""},
endpoint_request_t {"PairingList", "GET", "/pairing-test", ""},
endpoint_request_t {"VirtualInputStatus", "GET", "/virtual-input-status-test", ""},
endpoint_request_t {"VirtualInputLicense", "GET", "/virtual-input-license-test", ""},
endpoint_request_t {"VirtualInputLicenseUpdate", "POST", "/virtual-input-license-test", R"({"action":"validate"})"}
@@ -520,6 +544,113 @@
),
invalid_license_request_name
);
TEST_F(ConfigHttpTest, PairingMutationsRejectUnauthenticatedRestRequests) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json");
const auto post_response = client->request(
"POST",
"/pairing-test",
R"({"pairing_id":"0123456789abcdef0123456789abcdef","pin":"1234","name":"Client"})",
headers
);
EXPECT_EQ(post_response->status_code, "401 Unauthorized");
const auto delete_response = client->request(
"DELETE",
"/pairing-test",
R"({"pairing_id":"0123456789abcdef0123456789abcdef"})",
headers
);
EXPECT_EQ(delete_response->status_code, "401 Unauthorized");
}
TEST_F(ConfigHttpTest, PairingMutationsRejectCrossOriginRestRequestsWithoutCsrfToken) {
const std::string pairing_id = insert_pending_pairing();
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
headers.emplace("Content-Type", "application/json");
headers.emplace("Origin", "https://example.invalid");
const auto post_response = client->request(
"POST",
"/pairing-test",
nlohmann::json {
{"pairing_id", pairing_id},
{"pin", "1234"},
{"name", "Client"},
}
.dump(),
headers
);
EXPECT_EQ(post_response->status_code, "400 Bad Request");
EXPECT_TRUE(post_response->content.string().contains("Missing CSRF token"));
const auto delete_response = client->request(
"DELETE",
"/pairing-test",
nlohmann::json {{"pairing_id", pairing_id}}.dump(),
headers
);
EXPECT_EQ(delete_response->status_code, "400 Bad Request");
EXPECT_TRUE(delete_response->content.string().contains("Missing CSRF token"));
EXPECT_EQ(nvhttp::get_pending_pairings().size(), 1);
}
TEST_F(ConfigHttpTest, PairingRestApiListsAuthenticatedPendingRequests) {
const std::string pairing_id = insert_pending_pairing();
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", "/pairing-test", "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const auto body = nlohmann::json::parse(response->content.string());
ASSERT_EQ(body.at("pairings").size(), 1);
EXPECT_EQ(body.at("pairings").front().at("id"), pairing_id);
EXPECT_EQ(body.at("pairings").front().at("name"), "REST client");
EXPECT_EQ(body.at("pairings").front().at("address"), "192.0.2.30");
}
TEST_F(ConfigHttpTest, PairingRestApiRejectsMalformedFieldsWithoutConsumingRequest) {
const std::string pairing_id = insert_pending_pairing();
const std::array invalid_requests {
nlohmann::json {{"pairing_id", "not-a-pairing-id"}, {"pin", "1234"}, {"name", "Client"}},
nlohmann::json {{"pairing_id", pairing_id}, {"pin", "123"}, {"name", "Client"}},
nlohmann::json {{"pairing_id", pairing_id}, {"pin", "12a4"}, {"name", "Client"}},
nlohmann::json {{"pairing_id", pairing_id}, {"pin", "1234"}, {"name", ""}},
nlohmann::json {{"pairing_id", pairing_id}, {"pin", "1234"}, {"name", std::string(nvhttp::MAX_PAIRING_CLIENT_NAME_SIZE + 1, 'a')}},
};
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
headers.emplace("Content-Type", "application/json");
for (const auto &request : invalid_requests) {
const auto response = client->request("POST", "/pairing-test", request.dump(), headers);
EXPECT_EQ(response->status_code, "400 Bad Request");
}
const auto pending = nvhttp::get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, pairing_id);
}
TEST_F(ConfigHttpTest, PairingRestApiCancelsOnlyExplicitRequest) {
const std::string pairing_id = insert_pending_pairing();
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
headers.emplace("Content-Type", "application/json");
const auto response = client->request(
"DELETE",
"/pairing-test",
nlohmann::json {{"pairing_id", pairing_id}}.dump(),
headers
);
ASSERT_EQ(response->status_code, "200 OK");
EXPECT_TRUE(nlohmann::json::parse(response->content.string()).at("status").get<bool>());
EXPECT_TRUE(nvhttp::get_pending_pairings().empty());
}
// Test: confighttp::authenticate() rejects requests without auth header
TEST_F(ConfigHttpTest, AuthenticateRejectsNoAuth) {
tests/unit/test_http_pairing.cpp +203 −0
@@ -6,5 +6,12 @@
// test includes
#include "../tests_common.h"
// standard includes
#include <atomic>
#include <chrono>
#include <format>
#include <thread>
#include <vector>
// local includes
#include <src/nvhttp.h>
@@ -266,4 +273,200 @@
// Calling it again should fail
getservercert(sess, tree, "test");
ASSERT_FALSE(tree.get<int>("root.paired") == 1);
}
namespace {
/**
* @brief Build a pairing session that is awaiting operator approval.
*
* @param unique_id Client-controlled GameStream unique identifier.
* @param device_name Client-reported device name.
* @param address Network address associated with the request.
* @return Pairing session ready for insertion into pending storage.
*/
pair_session_t pending_session(std::string unique_id, std::string device_name, std::string address) {
return {
.client = {
.uniqueID = std::move(unique_id),
},
.async_insert_pin = {
.salt = "ff5dc6eda99339a8a0793e216c4257c4",
.device_name = std::move(device_name),
.address = std::move(address),
},
};
}
/**
* @brief Fixture that isolates tests using global pending-pairing storage.
*/
class PairingSessionRegistryTest: public BaseTest {
protected:
void SetUp() override {
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
}
void TearDown() override {
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
}
};
} // namespace
TEST_F(PairingSessionRegistryTest, PinApprovalTargetsExplicitPairingId) {
std::string legitimate_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("legitimate", "Living Room", "192.0.2.10"), legitimate_pairing_id),
pair_session_insert_e::ADDED
);
std::string attacker_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("attacker", "Living Room", "192.0.2.20"), attacker_pairing_id),
pair_session_insert_e::ADDED
);
ASSERT_NE(legitimate_pairing_id, attacker_pairing_id);
// Unit-test sessions have no parked HTTP response, so pin() returns false after
// consuming the explicitly selected session. The competing request must remain untouched.
EXPECT_FALSE(pin(legitimate_pairing_id, "1234", "Approved client"));
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, attacker_pairing_id);
EXPECT_EQ(pending.front().address, "192.0.2.20");
}
TEST_F(PairingSessionRegistryTest, InvalidApprovalCannotConsumePendingSession) {
std::string pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("legitimate", "Laptop", "192.0.2.10"), pairing_id),
pair_session_insert_e::ADDED
);
EXPECT_FALSE(pin("unknown-approval", "1234", "Laptop"));
EXPECT_FALSE(pin(pairing_id, "123", "Laptop"));
EXPECT_FALSE(pin(pairing_id, "12a4", "Laptop"));
EXPECT_FALSE(pin(pairing_id, "1234", ""));
EXPECT_FALSE(pin(pairing_id, "1234", std::string(MAX_PAIRING_CLIENT_NAME_SIZE + 1, 'a')));
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, pairing_id);
}
TEST_F(PairingSessionRegistryTest, RestApiPairingFieldsAreValidatedServerSide) {
EXPECT_TRUE(is_valid_pairing_id(std::string(PAIRING_ID_SIZE, 'a')));
EXPECT_TRUE(is_valid_pairing_id("0123456789ABCDEF0123456789abcdef"));
EXPECT_FALSE(is_valid_pairing_id(std::string(PAIRING_ID_SIZE - 1, 'a')));
EXPECT_FALSE(is_valid_pairing_id(std::string(PAIRING_ID_SIZE, 'g')));
EXPECT_TRUE(is_valid_pairing_pin("0000"));
EXPECT_TRUE(is_valid_pairing_pin("9999"));
EXPECT_FALSE(is_valid_pairing_pin("123"));
EXPECT_FALSE(is_valid_pairing_pin("12a4"));
EXPECT_TRUE(is_valid_pairing_name("Client"));
EXPECT_TRUE(is_valid_pairing_name(std::string(MAX_PAIRING_CLIENT_NAME_SIZE, 'a')));
EXPECT_FALSE(is_valid_pairing_name(""));
EXPECT_FALSE(is_valid_pairing_name(std::string(MAX_PAIRING_CLIENT_NAME_SIZE + 1, 'a')));
}
TEST_F(PairingSessionRegistryTest, DuplicateUniqueIdIsRejectedWithoutReplacingOriginal) {
std::string original_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("duplicate", "Original", "192.0.2.10"), original_pairing_id),
pair_session_insert_e::ADDED
);
std::string rejected_pairing_id;
EXPECT_EQ(
insert_pair_session(pending_session("duplicate", "Replacement", "192.0.2.20"), rejected_pairing_id),
pair_session_insert_e::ALREADY_EXISTS
);
EXPECT_TRUE(rejected_pairing_id.empty());
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, original_pairing_id);
EXPECT_EQ(pending.front().name, "Original");
}
TEST_F(PairingSessionRegistryTest, PendingSessionStorageIsBounded) {
for (std::size_t index = 0; index < MAX_PENDING_PAIRING_SESSIONS; ++index) {
std::string pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session(std::format("client-{}", index), "Client", "192.0.2.10"), pairing_id),
pair_session_insert_e::ADDED
);
EXPECT_FALSE(pairing_id.empty());
}
std::string overflow_pairing_id;
EXPECT_EQ(
insert_pair_session(pending_session("overflow", "Overflow", "192.0.2.20"), overflow_pairing_id),
pair_session_insert_e::FULL
);
EXPECT_EQ(get_pending_pairings().size(), MAX_PENDING_PAIRING_SESSIONS);
}
TEST_F(PairingSessionRegistryTest, ExpiredApprovalCannotConsumeLaterSession) {
const auto expiration_check = std::chrono::steady_clock::now() + PAIRING_SESSION_TIMEOUT + std::chrono::seconds {1};
std::string expired_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("expired", "Old client", "192.0.2.10"), expired_pairing_id),
pair_session_insert_e::ADDED
);
expire_pair_sessions(expiration_check);
EXPECT_TRUE(get_pending_pairings().empty());
std::string current_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("current", "Current client", "192.0.2.20"), current_pairing_id),
pair_session_insert_e::ADDED
);
EXPECT_FALSE(pin(expired_pairing_id, "1234", "Old client"));
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, current_pairing_id);
}
TEST_F(PairingSessionRegistryTest, CancellationRemovesOnlySelectedPendingSession) {
std::string first_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("first", "First", "192.0.2.10"), first_pairing_id),
pair_session_insert_e::ADDED
);
std::string second_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("second", "Second", "192.0.2.20"), second_pairing_id),
pair_session_insert_e::ADDED
);
EXPECT_TRUE(cancel_pairing(first_pairing_id));
EXPECT_FALSE(cancel_pairing(first_pairing_id));
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, second_pairing_id);
}
TEST_F(PairingSessionRegistryTest, ConcurrentInsertionAndCancellationLeaveConsistentState) {
constexpr std::size_t session_count = 16;
std::atomic<std::size_t> successful_cancellations {0};
std::vector<std::jthread> workers;
workers.reserve(session_count);
for (std::size_t index = 0; index < session_count; ++index) {
workers.emplace_back([index, &successful_cancellations]() {
std::string pairing_id;
if (insert_pair_session(pending_session(std::format("concurrent-{}", index), "Client", "192.0.2.10"), pairing_id) == pair_session_insert_e::ADDED && cancel_pairing(pairing_id)) {
++successful_cancellations;
}
});
}
workers.clear();
EXPECT_EQ(successful_cancellations, session_count);
EXPECT_TRUE(get_pending_pairings().empty());
}