ref:ffd1212170d2666e237dbddd2aa45b0edba8c7a2

fix(api): return the final result of pairing (#5680)

SHA: ffd1212170d2666e237dbddd2aa45b0edba8c7a2
Author: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com>
Date: 2026-09-11 01:57
Parents: 9051ae8
5 files changed +180 -22
Type
src/confighttp.cpp +5 −1
@@ -1775,7 +1775,11 @@
}
/**
* @brief Send a PIN code to the explicitly selected pairing request.
* @brief Submit a PIN and return whether the selected client completes pairing.
*
* The request remains open for up to the configured `ping_timeout` while
* Moonlight completes the cryptographic handshake. A wrong PIN, protocol
* failure, cancellation, or timeout returns `{"status":false}`.
* The body for the post request should be JSON serialized in the following format:
* @code{.json}
* {
src/nvhttp.cpp +84 −20
@@ -553,6 +553,23 @@
}
/**
* @brief Publish the final result of a pairing handshake.
*
* @param sess Pairing session whose waiter should be notified.
* @param success Whether the client completed the authenticated handshake.
*/
void complete_pairing(pair_session_t &sess, const bool success) {
{
std::lock_guard lock {sess.completion->mutex};
if (sess.completion->result.has_value()) {
return;
}
sess.completion->result = success;
}
sess.completion->condition.notify_all();
}
/**
* @brief Expire stale pairing sessions while the session mutex is held.
*
* @param now Monotonic time used to evaluate session deadlines.
@@ -569,6 +586,7 @@
tree.put("root.<xmlattr>.status_code", 408);
tree.put("root.<xmlattr>.status_message", "Pairing session expired");
write_pairing_response(it->second, tree);
complete_pairing(it->second, false);
it = map_id_sess.erase(it);
}
}
@@ -666,13 +684,17 @@
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);
complete_pairing(sess_it->second, false);
map_id_sess.erase(sess_it);
return true;
}
void remove_session(const pair_session_t &sess) {
std::scoped_lock lock {map_id_sess_mutex()};
if (const auto sess_it = map_id_sess.find(sess.client.uniqueID); sess_it != map_id_sess.end()) {
map_id_sess.erase(sess.client.uniqueID);
complete_pairing(sess_it->second, false);
map_id_sess.erase(sess_it);
}
}
/**
@@ -687,6 +709,7 @@
tree.put("root.<xmlattr>.status_code", 400);
tree.put("root.<xmlattr>.status_message", status_msg);
sess.failed = true;
complete_pairing(sess, false);
}
/**
@@ -849,14 +872,15 @@
// if hash not correct, probably MITM
bool same_hash = hash.size() == sess.clienthash.size() && std::equal(hash.begin(), hash.end(), sess.clienthash.begin());
auto verify = crypto::verify256(crypto::x509(client.cert), secret, sign);
bool paired = false;
if (same_hash && verify) {
// The client is now successfully paired and will be authorized to connect
tree.put("root.paired", add_authorized_client(client.name, std::move(client.cert)).empty() ? 0 : 1);
} else {
tree.put("root.paired", 0);
paired = !add_authorized_client(client.name, std::move(client.cert)).empty();
}
tree.put("root.paired", paired ? 1 : 0);
tree.put("root.<xmlattr>.status_code", 200);
complete_pairing(sess, paired);
}
template<class T>
@@ -1085,28 +1109,54 @@
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;
std::shared_ptr<pairing_completion_t> completion;
auto completion_deadline = std::chrono::steady_clock::time_point::min();
{
std::scoped_lock lock {map_id_sess_mutex()};
const auto now = std::chrono::steady_clock::now();
expire_pair_sessions_unlocked(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;
}
auto &sess = sess_it->second;
pt::ptree tree;
getservercert(sess, tree, pin);
if (!sess.failed) {
sess.client.name = std::move(name);
}
if (!write_pairing_response(sess, tree) || sess.failed) {
complete_pairing(sess, false);
map_id_sess.erase(sess_it);
return false;
}
completion = sess.completion;
completion_deadline = std::min(sess.async_insert_pin.expires_at, now + config::stream.ping_timeout);
}
{
std::unique_lock lock {completion->mutex};
if (completion->condition.wait_until(lock, completion_deadline, [&completion]() {
return completion->result.has_value();
})) {
auto &sess = sess_it->second;
pt::ptree tree;
getservercert(sess, tree, pin);
if (!sess.failed) {
sess.client.name = std::move(name);
return *completion->result;
}
}
std::scoped_lock lock {map_id_sess_mutex()};
if (const auto sess_it = std::ranges::find_if(map_id_sess, [&](const auto &entry) {
return entry.second.async_insert_pin.id == pairing_id && entry.second.completion == completion;
});
sess_it != map_id_sess.end()) {
complete_pairing(sess_it->second, false);
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;
return false;
}
/**
@@ -1790,6 +1840,20 @@
std::lock_guard lock {client_auth_mutex()};
return verify_client_certificate(certificate.get()) == nullptr;
}
bool complete_pairing(const std::string_view pairing_id, const bool success) {
std::scoped_lock lock {map_id_sess_mutex()};
const auto sess_it = std::ranges::find_if(map_id_sess, [&](const auto &entry) {
return entry.second.async_insert_pin.id == pairing_id;
});
if (sess_it == map_id_sess.end()) {
return false;
}
nvhttp::complete_pairing(sess_it->second, success);
map_id_sess.erase(sess_it);
return true;
}
void reload_client_state() {
src/nvhttp.h +25 −1
@@ -7,7 +7,11 @@
// standard includes
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -113,6 +117,15 @@
CLIENTCHALLENGE, ///< Sunshine is in the client challenge phase
SERVERCHALLENGERESP, ///< Sunshine is in the server challenge response phase
CLIENTPAIRINGSECRET ///< Sunshine is in the client pairing secret phase
};
/**
* @brief Shared result used to return the completed pairing outcome to a PIN submitter.
*/
struct pairing_completion_t {
std::condition_variable condition; ///< Wakes the REST request when pairing finishes.
std::mutex mutex; ///< Protects the completion result.
std::optional<bool> result; ///< Final pairing result, or no value while the handshake is pending.
};
/**
@@ -130,6 +143,7 @@
std::string serversecret = {}; ///< Server pairing secret.
std::string serverchallenge = {}; ///< Server challenge sent during pairing.
std::shared_ptr<pairing_completion_t> completion = std::make_shared<pairing_completion_t>(); ///< Result shared with the REST request waiting for the handshake.
struct {
util::Either<
@@ -297,7 +311,8 @@
* @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.
* @return `true` if Moonlight proves the PIN by completing the handshake, `false` otherwise.
* @note The handshake wait uses the configured `ping_timeout` and never exceeds the pairing session deadline.
* @examples
* bool pin_status = nvhttp::pin("0123456789abcdef0123456789abcdef", "1234", "laptop");
* @examples_end
@@ -385,6 +400,15 @@
* @return `true` when the exact certificate belongs to one enabled paired client.
*/
bool authorize_client_certificate(std::string_view cert);
/**
* @brief Complete and remove a pairing session without running the protocol phases.
*
* @param pairing_id Operator approval identifier of the test session.
* @param success Pairing result delivered to the waiting PIN submitter.
* @return `true` when the session was found and completed.
*/
bool complete_pairing(std::string_view pairing_id, bool success);
/**
* @brief Reload paired-client authorization state from the configured state file.
tests/unit/test_confighttp.cpp +22 −0
@@ -652,6 +652,28 @@
EXPECT_TRUE(nvhttp::get_pending_pairings().empty());
}
TEST_F(ConfigHttpTest, PairingRestApiReportsIncompleteHandshakeAsFailure) {
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(
"POST",
"/pairing-test",
nlohmann::json {
{"pairing_id", pairing_id},
{"pin", "9875"},
{"name", "Client"},
}
.dump(),
headers
);
ASSERT_EQ(response->status_code, "200 OK");
EXPECT_FALSE(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) {
const auto response = client->request("GET", "/auth-test");
tests/unit/test_http_pairing.cpp +44 −0
@@ -151,6 +151,8 @@
auto input_client_cert = input.session->client.cert; // Will be moved
clientpairingsecret(*input.session, tree, input.client_pairing_secret);
ASSERT_EQ(tree.get<int>("root.paired") == 1, expected.phase_4_success);
ASSERT_TRUE(input.session->completion->result.has_value());
EXPECT_EQ(*input.session->completion->result, expected.phase_4_success);
if (expected.phase_4_success) {
ASSERT_TRUE(nvhttp::test_support::authorize_client_certificate(input_client_cert));
@@ -416,6 +418,7 @@
BaseTest::SetUp();
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
original_pin_stdin_ = config::sunshine.flags[config::flag::PIN_STDIN];
original_ping_timeout_ = config::stream.ping_timeout;
config::sunshine.flags[config::flag::PIN_STDIN] = false;
server_ = std::make_unique<SimpleWeb::Server<SimpleWeb::HTTP>>();
@@ -453,5 +456,6 @@
client_.reset();
server_.reset();
config::sunshine.flags[config::flag::PIN_STDIN] = original_pin_stdin_;
config::stream.ping_timeout = original_ping_timeout_;
BaseTest::TearDown();
}
@@ -503,5 +507,6 @@
std::jthread server_thread_; ///< Thread running the local server event loop.
std::atomic<unsigned short> port_ {0}; ///< Ephemeral port assigned to the local server.
bool original_pin_stdin_; ///< Console-PIN flag restored after each test.
std::chrono::milliseconds original_ping_timeout_; ///< Configured client timeout restored after each test.
};
} // namespace
@@ -527,6 +532,45 @@
ASSERT_FALSE(pairing_id.empty());
EXPECT_TRUE(cancel_pairing(pairing_id));
EXPECT_NE(response.get().find("cancelled by operator"), std::string::npos);
}
TEST_F(PairingHttpHandlerTest, PinReturnsCompletedHandshakeResult) {
for (const bool expected_result : {false, true}) {
const auto unique_id = expected_result ? "successful-result"sv : "failed-result"sv;
std::packaged_task<std::string()> request_task {[this, unique_id]() {
return request(server_certificate_target(unique_id));
}};
auto client_response = request_task.get_future();
std::jthread request_thread {std::move(request_task)};
const auto pairing_id = wait_for_pending_pairing();
ASSERT_FALSE(pairing_id.empty());
config::stream.ping_timeout = std::chrono::seconds {2};
std::packaged_task<bool()> pin_task {[&pairing_id]() {
return pin(pairing_id, "5338", "Test client");
}};
auto pin_result = pin_task.get_future();
std::jthread pin_thread {std::move(pin_task)};
EXPECT_NE(client_response.get().find("status_code=\"200\""), std::string::npos);
EXPECT_TRUE(nvhttp::test_support::complete_pairing(pairing_id, expected_result));
EXPECT_EQ(pin_result.get(), expected_result);
}
}
TEST_F(PairingHttpHandlerTest, PinReturnsFalseWhenClientDoesNotCompleteHandshake) {
std::packaged_task<std::string()> request_task {[this]() {
return request(server_certificate_target("wrong-pin"));
}};
auto client_response = request_task.get_future();
std::jthread request_thread {std::move(request_task)};
const auto pairing_id = wait_for_pending_pairing();
ASSERT_FALSE(pairing_id.empty());
config::stream.ping_timeout = std::chrono::milliseconds {50};
EXPECT_FALSE(pin(pairing_id, "0000", "Test client"));
EXPECT_NE(client_response.get().find("status_code=\"200\""), std::string::npos);
EXPECT_TRUE(get_pending_pairings().empty());
}
TEST_F(PairingHttpHandlerTest, DuplicateAndCapacityErrorsReturnImmediately) {