From 6c99a00019705c26dbfdf29ec7e8da788a85e6bd Mon Sep 17 00:00:00 2001 From: Filip Date: Fri, 18 Sep 2026 16:08:12 +0200 Subject: [PATCH 1/5] feat: enforce expiring managed access for direct streams --- .github/workflows/managed-build.yml | 63 ++++ cmake/compile_definitions/common.cmake | 2 + src/config.cpp | 13 + src/config.h | 2 + src/input.cpp | 92 +++++ src/managed_access.cpp | 155 +++++++++ src/managed_access.h | 15 + src/nvhttp.cpp | 39 +++ src/rtsp.cpp | 29 +- src/stream.cpp | 35 +- tests/managed_access/CMakeLists.txt | 32 ++ tests/managed_access/README.md | 15 + tests/managed_access/managed_access_tests.cpp | 329 ++++++++++++++++++ 13 files changed, 815 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/managed-build.yml create mode 100644 src/managed_access.cpp create mode 100644 src/managed_access.h create mode 100644 tests/managed_access/CMakeLists.txt create mode 100644 tests/managed_access/README.md create mode 100644 tests/managed_access/managed_access_tests.cpp diff --git a/.github/workflows/managed-build.yml b/.github/workflows/managed-build.yml new file mode 100644 index 0000000000..057d708a5f --- /dev/null +++ b/.github/workflows/managed-build.yml @@ -0,0 +1,63 @@ +name: Managed host build +on: + push: + branches: [master, codex/managed-access] + pull_request: + branches: [master] + workflow_dispatch: +permissions: + contents: read +jobs: + windows: + runs-on: windows-2025 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + path-type: inherit + install: >- + git + mingw-w64-ucrt-x86_64-boost + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-cppwinrt + mingw-w64-ucrt-x86_64-curl-winssl + mingw-w64-ucrt-x86_64-MinHook + mingw-w64-ucrt-x86_64-miniupnpc + mingw-w64-ucrt-x86_64-nsis + mingw-w64-ucrt-x86_64-onevpl + mingw-w64-ucrt-x86_64-openssl + mingw-w64-ucrt-x86_64-opus + mingw-w64-ucrt-x86_64-toolchain + mingw-w64-ucrt-x86_64-nlohmann_json + mingw-w64-ucrt-x86_64-ninja + - name: Configure + shell: msys2 {0} + run: cmake -B build -G Ninja -S . -DBUILD_TESTS=OFF -DBUILD_DOCS=OFF -DSUNSHINE_PUBLISHER_NAME=mikulicf -DSUNSHINE_PUBLISHER_WEBSITE=https://github.com/mikulicf/apollo-managed -DSUNSHINE_PUBLISHER_ISSUE_URL=https://github.com/mikulicf/apollo-managed/issues + - name: Build + shell: msys2 {0} + run: cmake --build build --parallel 3 + - name: Package + shell: msys2 {0} + run: cpack -G ZIP --config build/CPackConfig.cmake + - uses: actions/upload-artifact@v4 + with: + name: Apollo-Managed-Windows-x64 + path: '*.zip' + if-no-files-found: error + policy-tests: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - run: sudo apt-get update && sudo apt-get install -y g++-14 cmake ninja-build libssl-dev nlohmann-json3-dev libboost-dev + - run: cmake -S tests/managed_access -B build-policy -G Ninja -DCMAKE_CXX_COMPILER=g++-14 && cmake --build build-policy && ctest --test-dir build-policy --output-on-failure diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index f38733054e..5a0192a51e 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -80,6 +80,8 @@ set(SUNSHINE_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/crypto.cpp" "${CMAKE_SOURCE_DIR}/src/crypto.h" "${CMAKE_SOURCE_DIR}/src/nvhttp.cpp" + "${CMAKE_SOURCE_DIR}/src/managed_access.cpp" + "${CMAKE_SOURCE_DIR}/src/managed_access.h" "${CMAKE_SOURCE_DIR}/src/nvhttp.h" "${CMAKE_SOURCE_DIR}/src/httpcommon.cpp" "${CMAKE_SOURCE_DIR}/src/httpcommon.h" diff --git a/src/config.cpp b/src/config.cpp index 6892e3029c..9b7ca19063 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -578,6 +578,8 @@ namespace config { true, // forward_rumble }; + std::string managed_policy_file; + sunshine_t sunshine { false, // hide_tray_controls true, // enable_pairing @@ -1315,6 +1317,17 @@ namespace config { config::sunshine.flags[config::flag::UPNP].flip(); } + string_f(vars, "managed_policy_file", managed_policy_file); + if (!managed_policy_file.empty()) { + // Managed hosts never fall back to PIN pairing or unencrypted streaming. + sunshine.enable_pairing = false; + sunshine.enable_discovery = false; + sunshine.flags[config::flag::UPNP] = false; + nvhttp.origin_web_ui_allowed = "pc"; + stream.lan_encryption_mode = ENCRYPTION_MODE_MANDATORY; + stream.wan_encryption_mode = ENCRYPTION_MODE_MANDATORY; + } + string_restricted_f(vars, "locale", config::sunshine.locale, { "bg"sv, // Bulgarian "cs"sv, // Czech diff --git a/src/config.h b/src/config.h index a6d7c83386..f37554030c 100644 --- a/src/config.h +++ b/src/config.h @@ -296,6 +296,8 @@ namespace config { extern nvhttp_t nvhttp; extern input_t input; extern sunshine_t sunshine; + // A private, atomically replaced policy file supplied by the management agent. + extern std::string managed_policy_file; int parse(int argc, char *argv[]); std::unordered_map parse_config(const std::string_view &file_content); diff --git a/src/input.cpp b/src/input.cpp index 6c03d0d334..a2a9e12774 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -12,7 +12,9 @@ extern "C" { #include #include #include +#include #include +#include #include #include @@ -1193,6 +1195,82 @@ namespace input { gamepad.gamepad_state = gamepad_state; } + /** + * @brief Validate the declared and available sizes of a fixed-size input packet. + * + * @tparam Packet Protocol packet structure. + * @param packet Raw packet bytes. + * @param declared_size Packet size declared after the size field. + * @return True when both sizes safely contain the fixed packet structure. + */ + template + bool validate_fixed_input_packet(std::span packet, std::uint32_t declared_size) { + constexpr auto expected_size = static_cast(sizeof(Packet) - sizeof(std::uint32_t)); + if (declared_size != expected_size) { + return false; + } + return packet.size() >= sizeof(Packet); + } + + /** + * @brief Validate an input packet before any typed access or batching. + * + * @param packet Raw packet bytes. + * @param parsed_header Optional destination for the safely copied packet header. + * @return True when the packet is large enough for its declared and protocol-specific fields. + */ + bool validate_input_packet(std::span packet, NV_INPUT_HEADER *parsed_header = nullptr) { + if (packet.size() < sizeof(NV_INPUT_HEADER)) { + return false; + } + + NV_INPUT_HEADER header {}; + std::memcpy(&header, packet.data(), sizeof(header)); + if (parsed_header) { + *parsed_header = header; + } + + const auto declared_size = util::endian::big(header.size); + if (declared_size < sizeof(header.magic) || declared_size > packet.size() - sizeof(header.size)) { + return false; + } + + switch (util::endian::little(header.magic)) { + case MOUSE_MOVE_REL_MAGIC_GEN5: + return validate_fixed_input_packet(packet, declared_size); + case MOUSE_MOVE_ABS_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + case MOUSE_BUTTON_DOWN_EVENT_MAGIC_GEN5: + case MOUSE_BUTTON_UP_EVENT_MAGIC_GEN5: + return validate_fixed_input_packet(packet, declared_size); + case SCROLL_MAGIC_GEN5: + return validate_fixed_input_packet(packet, declared_size); + case SS_HSCROLL_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + case KEY_DOWN_EVENT_MAGIC: + case KEY_UP_EVENT_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + case UTF8_TEXT_EVENT_MAGIC: + return declared_size - sizeof(header.magic) <= UTF8_TEXT_EVENT_MAX_COUNT; + case MULTI_CONTROLLER_MAGIC_GEN5: + return validate_fixed_input_packet(packet, declared_size); + case SS_TOUCH_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + case SS_PEN_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + case SS_CONTROLLER_ARRIVAL_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + case SS_CONTROLLER_TOUCH_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + case SS_CONTROLLER_MOTION_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + case SS_CONTROLLER_BATTERY_MAGIC: + return validate_fixed_input_packet(packet, declared_size); + default: + return true; + } + } + enum class batch_result_e { batched, ///< This entry was batched with the source entry not_batchable, ///< Not eligible to batch but continue attempts to batch @@ -1582,6 +1660,20 @@ namespace input { return; } + NV_INPUT_HEADER header {}; + if (!validate_input_packet(input_data, &header)) { + if (input_data.size() >= sizeof(header)) { + BOOST_LOG(warning) + << "Dropping malformed input packet type ["sv + << util::hex(util::endian::little(header.magic)).to_string_view() + << "] with declared payload size ["sv << util::endian::big(header.size) + << "] and actual size ["sv << input_data.size() << ']'; + } else { + BOOST_LOG(warning) << "Dropping malformed input packet with actual size ["sv << input_data.size() << ']'; + } + return; + } + // Have some input permission // Otherwise have all input permission if ((permission & crypto::PERM::_all_inputs) != crypto::PERM::_all_inputs) { diff --git a/src/managed_access.cpp b/src/managed_access.cpp new file mode 100644 index 0000000000..253dd8c2cd --- /dev/null +++ b/src/managed_access.cpp @@ -0,0 +1,155 @@ +/** + * @file src/managed_access.cpp + * @brief Host-side enforcement of short-lived management policy. + */ +#include "managed_access.h" +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace managed_access { + namespace { + using clock = std::chrono::steady_clock; + struct entry_t { + crypto::p_named_cert_t client; + clock::time_point deadline; + }; + std::mutex mutex; + std::map entries; + std::string previous_document; + clock::time_point next_read {}; + + std::string fingerprint(X509 *certificate) { + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int length = 0; + if (!certificate || X509_digest(certificate, EVP_sha256(), digest, &length) != 1 || length != 32) { + return {}; + } + static constexpr char hex[] = "0123456789abcdef"; + std::string result; + for (unsigned int i = 0; i < length; ++i) { + result += hex[digest[i] >> 4]; + result += hex[digest[i] & 15]; + } + return result; + } + + std::string client_uuid(const std::string &fingerprint) { + // Stable UUID for Windows display identity; authorization still uses all 256 bits. + return fingerprint.substr(0, 8) + "-" + fingerprint.substr(8, 4) + "-" + + fingerprint.substr(12, 4) + "-" + fingerprint.substr(16, 4) + "-" + fingerprint.substr(20, 12); + } + + void refresh_locked() { + const auto steady_now = clock::now(); + if (steady_now < next_read) { + return; + } + next_read = steady_now + std::chrono::milliseconds(500); + try { + std::ifstream stream(config::managed_policy_file, std::ios::binary); + if (!stream) { + // Missing policy is an explicit fail-closed state, including after restart. + entries.clear(); + previous_document.clear(); + return; + } + std::string document(512 * 1024 + 1, '\0'); + stream.read(document.data(), document.size()); + document.resize(static_cast(stream.gcount())); + if (document.empty() || document.size() > 512 * 1024) { + throw std::runtime_error("Invalid policy size"); + } + if (document == previous_document) { + // Reading the same file never extends monotonic lease deadlines. + return; + } + const auto policy = nlohmann::json::parse(document); + const auto now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + const auto issued = policy.at("server_time").get(); + const auto expires = policy.at("valid_until").get(); + if (policy.at("protocol").get() != 1 || issued > now + 30 || issued < now - 120 || + expires <= now || expires > issued + 90 || !policy.at("leases").is_array() || policy.at("leases").size() > 64) { + throw std::runtime_error("Invalid policy validity"); + } + std::map updated; + for (const auto &lease : policy.at("leases")) { + const auto until = std::min(lease.at("expires_at").get(), expires); + if (until <= now) { + continue; + } + const auto pem = lease.at("certificate").get(); + if (pem.size() > 8192) { + throw std::runtime_error("Invalid certificate size"); + } + auto bio = std::unique_ptr(BIO_new_mem_buf(pem.data(), static_cast(pem.size())), &BIO_free); + auto cert = std::unique_ptr(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), &X509_free); + auto hash = fingerprint(cert.get()); + if (hash.empty() || hash != lease.at("fingerprint").get() || + X509_cmp_current_time(X509_get0_notBefore(cert.get())) >= 0 || X509_cmp_current_time(X509_get0_notAfter(cert.get())) <= 0) { + throw std::runtime_error("Invalid policy certificate"); + } + auto client = std::make_shared(); + client->name = lease.at("username").get(); + client->uuid = client_uuid(hash); + client->cert = pem; + client->perm = static_cast(static_cast(crypto::PERM::_all_inputs) | + static_cast(crypto::PERM::_all_actions) | static_cast(crypto::PERM::clipboard_read) | + static_cast(crypto::PERM::clipboard_set)); + client->enable_legacy_ordering = false; + client->allow_client_commands = false; + client->always_use_virtual_display = false; + const auto deadline = steady_now + std::chrono::seconds(std::min(until - now, 90)); + auto existing = updated.find(hash); + if (existing == updated.end() || existing->second.deadline < deadline) { + updated[hash] = {std::move(client), deadline}; + } + } + entries = std::move(updated); + previous_document = std::move(document); + } catch (...) { + entries.clear(); + previous_document.clear(); + } + } + } // namespace + + bool enabled() { + return !config::managed_policy_file.empty(); + } + + void refresh() { + if (!enabled()) { + return; + } + std::lock_guard lock(mutex); + refresh_locked(); + } + + crypto::p_named_cert_t authorize(X509 *certificate) { + std::lock_guard lock(mutex); + refresh_locked(); + const auto found = entries.find(fingerprint(certificate)); + if (found == entries.end() || clock::now() >= found->second.deadline) { + return {}; + } + return found->second.client; + } + + bool allowed(const std::string &uuid) { + if (!enabled()) { + return true; + } + std::lock_guard lock(mutex); + refresh_locked(); + return std::any_of(entries.begin(), entries.end(), [&](const auto &item) { + return item.second.client->uuid == uuid && clock::now() < item.second.deadline; + }); + } +} // namespace managed_access diff --git a/src/managed_access.h b/src/managed_access.h new file mode 100644 index 0000000000..678fcd01a1 --- /dev/null +++ b/src/managed_access.h @@ -0,0 +1,15 @@ +/** + * @file src/managed_access.h + * @brief Expiring, exact-certificate authorization for centrally managed hosts. + */ +#pragma once + +#include "crypto.h" + +namespace managed_access { + // An empty policy path leaves normal Apollo operation unchanged. + bool enabled(); + crypto::p_named_cert_t authorize(X509 *certificate); + bool allowed(const std::string &client_uuid); + void refresh(); +} // namespace managed_access diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index ef5f6fe885..498c245bcd 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -27,6 +27,7 @@ #include "globals.h" #include "httpcommon.h" #include "logging.h" +#include "managed_access.h" #include "network.h" #include "nvhttp.h" #include "platform/common.h" @@ -881,6 +882,13 @@ namespace nvhttp { template void serverinfo(std::shared_ptr::Response> response, std::shared_ptr::Request> request) { + if constexpr (std::is_same_v) { + if (managed_access::enabled() && !request->remote_endpoint().address().is_loopback()) { + response->write(SimpleWeb::StatusCode::client_error_forbidden); + response->close_connection_after_response = true; + return; + } + } print_req(request); int pair_status = 0; @@ -903,6 +911,7 @@ namespace nvhttp { tree.put("root.appversion", VERSION); tree.put("root.GfeVersion", GFE_VERSION); tree.put("root.uniqueid", http::unique_id); + tree.put("root.ManagedAccessProtocol", managed_access::enabled() ? 1 : 0); tree.put("root.HttpsPort", net::map_port(PORT_HTTPS)); tree.put("root.ExternalPort", net::map_port(PORT_HTTP)); tree.put("root.MaxLumaPixelsHEVC", video::active_hevc_mode > 1 ? "1869449984" : "0"); @@ -1671,6 +1680,12 @@ namespace nvhttp { return false; } + if (managed_access::enabled()) { + auto client = managed_access::authorize(x509.get()); + req->userp = client; + return client != nullptr; + } + bool verified = false; p_named_cert_t named_cert_p; @@ -1729,6 +1744,30 @@ namespace nvhttp { https_server.resource["^/actions/clipboard$"]["GET"] = getClipboard; https_server.resource["^/actions/clipboard$"]["POST"] = setClipboard; + // TLS authentication alone is insufficient: keep-alive connections must + // lose authorization as soon as the agent removes a lease or it expires. + if (managed_access::enabled()) { + for (auto &[path, methods] : https_server.resource) { + for (auto &[method, handler] : methods) { + auto original = handler; + handler = [original](auto response, auto request) { + auto client = get_verified_cert(request); + if (!client || !managed_access::allowed(client->uuid)) { + response->write(SimpleWeb::StatusCode::client_error_unauthorized); + response->close_connection_after_response = true; + return; + } + original(response, request); + }; + } + } + } + + https_server.config.max_request_streambuf_size = 32 * 1024; + https_server.config.timeout_request = 10; + http_server.config.max_request_streambuf_size = 8 * 1024; + http_server.config.timeout_request = 5; + https_server.config.reuse_address = true; https_server.config.address = net::af_to_any_address_string(address_family); https_server.config.port = port_https; diff --git a/src/rtsp.cpp b/src/rtsp.cpp index 9f08916a06..d230a6e7c2 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -23,6 +23,7 @@ extern "C" { // local includes #include "config.h" +#include "managed_access.h" #include "globals.h" #include "input.h" #include "logging.h" @@ -434,6 +435,11 @@ namespace rtsp_stream { } void handle_msg(tcp::socket &sock, launch_session_t &session, msg_t &&req) { + if (!managed_access::allowed(session.unique_id)) { + boost::system::error_code ec; + sock.close(ec); + return; + } auto func = _map_cmd_cb.find(req->message.request.command); if (func != std::end(_map_cmd_cb)) { func->second(this, sock, session, std::move(req)); @@ -457,7 +463,7 @@ namespace rtsp_stream { auto socket = std::move(next_socket); auto launch_session {launch_event.view(0s)}; - if (launch_session) { + if (launch_session && managed_access::allowed(launch_session->unique_id)) { // Associate the current RTSP session with this socket and start reading socket->session = launch_session; socket->read(); @@ -490,6 +496,9 @@ namespace rtsp_stream { * @param launch_session Streaming session information. */ void session_raise(std::shared_ptr launch_session) { + if (!managed_access::allowed(launch_session->unique_id)) { + return; + } // If a launch event is still pending, don't overwrite it. if (launch_event.view(0s)) { return; @@ -585,6 +594,20 @@ namespace rtsp_stream { * @brief Runs an iteration of the RTSP server loop */ void iterate() { + if (managed_access::enabled()) { + managed_access::refresh(); + auto pending = launch_event.view(0s); + if (pending && !managed_access::allowed(pending->unique_id)) { + session_clear(pending->id); + } + // Revoke every session for the identity, including concurrent streams. + auto slots_lock = _session_slots.lock(); + for (const auto &slot : *_session_slots) { + if (slot && !managed_access::allowed(stream::session::uuid(*slot))) { + stream::session::graceful_stop(*slot); + } + } + } // If we have a session, we will return to the server loop every // 500ms to allow session cleanup to happen. if (session_count() > 0) { @@ -1156,6 +1179,10 @@ namespace rtsp_stream { return; } + if (!managed_access::allowed(session.unique_id)) { + respond(sock, session, &option, 403, "Forbidden", req->sequenceNumber, {}); + return; + } auto stream_session = stream::session::alloc(config, session); server->insert(stream_session); diff --git a/src/stream.cpp b/src/stream.cpp index bceeb93d3c..865b98651b 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -4,9 +4,12 @@ */ // standard includes +#include #include #include +#include #include +#include // lib includes #include @@ -487,6 +490,29 @@ namespace stream { static auto broadcast = safe::make_shared(start_broadcast, end_broadcast); + /** + * @brief Parse a control message from an ENet packet. + * + * @param packet ENet packet containing the control message type and payload. + * @return The message type and payload view, or `std::nullopt` when the packet lacks a complete type. + */ + std::optional> parse_control_packet(const ENetPacket &packet) { + if (packet.data == nullptr || packet.dataLength < sizeof(std::uint16_t)) { + return std::nullopt; + } + + std::uint16_t type; + std::memcpy(&type, packet.data, sizeof(type)); + + return std::pair { + type, + std::string_view { + reinterpret_cast(packet.data) + sizeof(type), + packet.dataLength - sizeof(type), + }, + }; + } + session_t *control_server_t::get_session(const net::peer_t peer, uint32_t connect_data) { { // Fast path - look up existing session by peer @@ -592,11 +618,10 @@ namespace stream { case ENET_EVENT_TYPE_RECEIVE: { net::packet_t packet {event.packet}; - - auto type = *(std::uint16_t *) packet->data; - std::string_view payload {(char *) packet->data + sizeof(type), packet->dataLength - sizeof(type)}; - - call(type, session, payload, false); + auto message = parse_control_packet(*packet); + if (message) { + call(message->first, session, message->second, false); + } } break; case ENET_EVENT_TYPE_CONNECT: diff --git a/tests/managed_access/CMakeLists.txt b/tests/managed_access/CMakeLists.txt new file mode 100644 index 0000000000..7f29436beb --- /dev/null +++ b/tests/managed_access/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.20) + +project(apollo_managed_access_tests LANGUAGES CXX) + +enable_testing() + +find_package(OpenSSL REQUIRED) +find_package(nlohmann_json 3.11 REQUIRED) +find_package(Threads REQUIRED) + +set(APOLLO_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +add_executable(managed_access_tests + managed_access_tests.cpp + "${APOLLO_ROOT}/src/managed_access.cpp" +) + +target_compile_features(managed_access_tests PRIVATE cxx_std_23) +target_compile_options(managed_access_tests PRIVATE + $<$:-Wall> + $<$:-Wextra> + $<$:-Wpedantic> + $<$:-Werror> +) +target_include_directories(managed_access_tests PRIVATE "${APOLLO_ROOT}/src") +target_link_libraries(managed_access_tests PRIVATE + OpenSSL::Crypto + Threads::Threads + nlohmann_json::nlohmann_json +) + +add_test(NAME managed_access_policy COMMAND managed_access_tests) diff --git a/tests/managed_access/README.md b/tests/managed_access/README.md new file mode 100644 index 0000000000..08be254b0a --- /dev/null +++ b/tests/managed_access/README.md @@ -0,0 +1,15 @@ +# Managed access policy tests + +This standalone target compiles Apollo's production `src/managed_access.cpp` directly and exercises its public API. It intentionally does not depend on the full Apollo build. + +On Ubuntu, install the small dependency set and run: + +```sh +sudo apt-get update +sudo apt-get install -y cmake g++ libssl-dev nlohmann-json3-dev +cmake -S tests/managed_access -B build/managed-access-tests +cmake --build build/managed-access-tests --parallel +ctest --test-dir build/managed-access-tests --output-on-failure +``` + +The policy reader rate-limits filesystem reads to 500 ms. Mutation tests wait 550 ms before refreshing so they verify the public production behavior without a test-only clock or production hook. diff --git a/tests/managed_access/managed_access_tests.cpp b/tests/managed_access/managed_access_tests.cpp new file mode 100644 index 0000000000..74948407fa --- /dev/null +++ b/tests/managed_access/managed_access_tests.cpp @@ -0,0 +1,329 @@ +#include "managed_access.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace config { + std::string managed_policy_file; +} + +namespace { + using namespace std::chrono_literals; + using json = nlohmann::json; + using bio_ptr = std::unique_ptr; + using key_ptr = std::unique_ptr; + using key_context_ptr = std::unique_ptr; + using x509_ptr = std::unique_ptr; + + constexpr auto refresh_interval = 550ms; + int certificate_serial = 1; + + struct certificate_t { + key_ptr key {nullptr, &EVP_PKEY_free}; + x509_ptr cert {nullptr, &X509_free}; + std::string pem; + std::string fingerprint; + }; + + [[noreturn]] void fail(const std::string &message) { + throw std::runtime_error(message); + } + + void require(bool condition, const std::string &message) { + if (!condition) { + fail(message); + } + } + + std::int64_t unix_time() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ) + .count(); + } + + key_ptr generate_key() { + key_context_ptr context {EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr), &EVP_PKEY_CTX_free}; + require(context != nullptr, "failed to allocate key context"); + require(EVP_PKEY_keygen_init(context.get()) == 1, "failed to initialize key generation"); + require(EVP_PKEY_CTX_set_rsa_keygen_bits(context.get(), 2048) == 1, "failed to select RSA key size"); + EVP_PKEY *raw_key = nullptr; + require(EVP_PKEY_keygen(context.get(), &raw_key) == 1, "failed to generate key"); + return key_ptr {raw_key, &EVP_PKEY_free}; + } + + std::string certificate_pem(X509 *certificate) { + bio_ptr output {BIO_new(BIO_s_mem()), &BIO_free}; + require(output != nullptr, "failed to allocate certificate output buffer"); + require(PEM_write_bio_X509(output.get(), certificate) == 1, "failed to encode certificate"); + BUF_MEM *memory = nullptr; + BIO_get_mem_ptr(output.get(), &memory); + require(memory != nullptr, "failed to read encoded certificate"); + return {memory->data, memory->length}; + } + + std::string certificate_fingerprint(X509 *certificate) { + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int length = 0; + require(X509_digest(certificate, EVP_sha256(), digest, &length) == 1 && length == 32, "failed to fingerprint certificate"); + static constexpr char hex[] = "0123456789abcdef"; + std::string result; + result.reserve(length * 2); + for (unsigned int i = 0; i < length; ++i) { + result += hex[digest[i] >> 4]; + result += hex[digest[i] & 15]; + } + return result; + } + + certificate_t issue_certificate( + std::string common_name, + X509 *issuer_certificate = nullptr, + EVP_PKEY *issuer_key = nullptr, + long not_before_seconds = -60, + long not_after_seconds = 3600 + ) { + auto key = generate_key(); + x509_ptr certificate {X509_new(), &X509_free}; + require(certificate != nullptr, "failed to allocate certificate"); + require(X509_set_version(certificate.get(), 2) == 1, "failed to set certificate version"); + require(ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), certificate_serial++) == 1, "failed to set certificate serial"); + require(X509_gmtime_adj(X509_getm_notBefore(certificate.get()), not_before_seconds) != nullptr, "failed to set certificate start time"); + require(X509_gmtime_adj(X509_getm_notAfter(certificate.get()), not_after_seconds) != nullptr, "failed to set certificate end time"); + require(X509_set_pubkey(certificate.get(), key.get()) == 1, "failed to set certificate key"); + + auto *subject = X509_get_subject_name(certificate.get()); + require(subject != nullptr, "failed to access certificate subject"); + require(X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_ASC, reinterpret_cast(common_name.c_str()), -1, -1, 0) == 1, "failed to set certificate common name"); + + auto *issuer_name = issuer_certificate ? X509_get_subject_name(issuer_certificate) : subject; + require(X509_set_issuer_name(certificate.get(), issuer_name) == 1, "failed to set certificate issuer"); + require(X509_sign(certificate.get(), issuer_key ? issuer_key : key.get(), EVP_sha256()) > 0, "failed to sign certificate"); + + auto pem = certificate_pem(certificate.get()); + auto fingerprint = certificate_fingerprint(certificate.get()); + return {std::move(key), std::move(certificate), std::move(pem), std::move(fingerprint)}; + } + + json lease(const certificate_t &certificate, std::int64_t expires_at, std::string username = "alice") { + return { + {"expires_at", expires_at}, + {"certificate", certificate.pem}, + {"fingerprint", certificate.fingerprint}, + {"username", std::move(username)}, + }; + } + + json policy(std::int64_t issued, std::int64_t valid_until, json leases) { + return { + {"protocol", 1}, + {"server_time", issued}, + {"valid_until", valid_until}, + {"leases", std::move(leases)}, + }; + } + + void wait_for_refresh_window() { + std::this_thread::sleep_for(refresh_interval); + } + + void write_document(const std::filesystem::path &path, const std::string &document, bool wait = true) { + if (wait) { + wait_for_refresh_window(); + } + std::ofstream output(path, std::ios::binary | std::ios::trunc); + require(static_cast(output), "failed to open test policy file"); + output << document; + output.close(); + require(static_cast(output), "failed to write test policy file"); + managed_access::refresh(); + } + + void write_policy(const std::filesystem::path &path, const json &document, bool wait = true) { + write_document(path, document.dump(), wait); + } + + void remove_policy(const std::filesystem::path &path) { + wait_for_refresh_window(); + std::error_code error; + std::filesystem::remove(path, error); + require(!error, "failed to remove test policy file: " + error.message()); + managed_access::refresh(); + } + + bool has_all_permissions(crypto::PERM granted, crypto::PERM requested) { + return static_cast(granted & requested) == static_cast(requested); + } + + void test_exact_certificate_and_permissions( + const std::filesystem::path &path, + const certificate_t &authorized, + const certificate_t &unknown, + const certificate_t &derived + ) { + const auto now = unix_time(); + write_policy(path, policy(now, now + 20, json::array({lease(authorized, now + 20)})), false); + + auto client = managed_access::authorize(authorized.cert.get()); + require(client != nullptr, "the exact leased certificate was denied"); + require(client->name == "alice", "the policy username was not retained"); + require(managed_access::authorize(unknown.cert.get()) == nullptr, "an unknown certificate was authorized"); + require(managed_access::authorize(derived.cert.get()) == nullptr, "a certificate derived from the leased signer was authorized"); + require(managed_access::authorize(nullptr) == nullptr, "a null certificate was authorized"); + require(managed_access::allowed(client->uuid), "the authorized client's UUID was denied"); + + require(has_all_permissions(client->perm, crypto::PERM::_all_inputs), "input permissions were not granted"); + require(has_all_permissions(client->perm, crypto::PERM::_all_actions), "action permissions were not granted"); + require(has_all_permissions(client->perm, crypto::PERM::clipboard_read), "clipboard read was not granted"); + require(has_all_permissions(client->perm, crypto::PERM::clipboard_set), "clipboard set was not granted"); + require(!has_all_permissions(client->perm, crypto::PERM::server_cmd), "server command permission was granted"); + require(!has_all_permissions(client->perm, crypto::PERM::file_upload), "file upload permission was granted"); + require(!has_all_permissions(client->perm, crypto::PERM::file_dwnload), "file download permission was granted"); + require(!client->allow_client_commands, "client commands were enabled"); + } + + void test_fail_closed_documents(const std::filesystem::path &path, const certificate_t &authorized) { + auto install_valid_policy = [&]() { + const auto now = unix_time(); + write_policy(path, policy(now, now + 20, json::array({lease(authorized, now + 20)}))); + require(managed_access::authorize(authorized.cert.get()) != nullptr, "valid setup policy was denied"); + }; + + install_valid_policy(); + remove_policy(path); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "missing policy retained authorization"); + + install_valid_policy(); + write_document(path, "{not-json"); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "invalid JSON retained authorization"); + + install_valid_policy(); + const auto now = unix_time(); + write_policy(path, policy(now - 10, now - 1, json::array({lease(authorized, now + 20)}))); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "expired policy retained authorization"); + + install_valid_policy(); + auto bad_lease = lease(authorized, now + 20); + bad_lease["fingerprint"] = std::string(64, '0'); + write_policy(path, policy(now, now + 20, json::array({bad_lease}))); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "mismatched certificate fingerprint retained authorization"); + } + + void test_expiry_and_renewal(const std::filesystem::path &path, const certificate_t &authorized) { + wait_for_refresh_window(); + auto now = unix_time(); + write_policy(path, policy(now, now + 1, json::array({lease(authorized, now + 1)})), false); + require(managed_access::authorize(authorized.cert.get()) != nullptr, "short lease was denied before expiry"); + std::this_thread::sleep_for(1100ms); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "expired lease remained authorized"); + + wait_for_refresh_window(); + now = unix_time(); + write_policy(path, policy(now, now + 1, json::array({lease(authorized, now + 1, "before-renewal")})), false); + auto original = managed_access::authorize(authorized.cert.get()); + require(original != nullptr, "renewal setup lease was denied"); + + wait_for_refresh_window(); + now = unix_time(); + write_policy(path, policy(now, now + 3, json::array({lease(authorized, now + 3, "after-renewal")})), false); + auto renewed = managed_access::authorize(authorized.cert.get()); + require(renewed != nullptr && renewed->name == "after-renewal", "updated lease was not loaded"); + std::this_thread::sleep_for(600ms); + renewed = managed_access::authorize(authorized.cert.get()); + require(renewed != nullptr, "renewed lease expired at its original deadline"); + } + + void test_removal_invalidates_lookup_but_not_existing_value( + const std::filesystem::path &path, + const certificate_t &authorized + ) { + const auto now = unix_time(); + write_policy(path, policy(now, now + 20, json::array({lease(authorized, now + 20, "retained-object")}))); + auto existing = managed_access::authorize(authorized.cert.get()); + require(existing != nullptr, "removal setup lease was denied"); + const auto uuid = existing->uuid; + + const auto update_time = unix_time(); + write_policy(path, policy(update_time, update_time + 20, json::array())); + require(existing->name == "retained-object", "existing named certificate value became invalid memory"); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "removed certificate remained authorized"); + require(!managed_access::allowed(uuid), "removed UUID remained allowed through an existing named certificate pointer"); + } + + void test_policy_bounds(const std::filesystem::path &path, const certificate_t &authorized) { + auto now = unix_time(); + write_policy(path, policy(now + 60, now + 80, json::array({lease(authorized, now + 80)}))); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "policy issued too far in the future was accepted"); + + now = unix_time(); + write_policy(path, policy(now, now + 91, json::array({lease(authorized, now + 91)}))); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "policy exceeding the 90-second TTL was accepted"); + + now = unix_time(); + json sixty_four = json::array(); + for (int index = 0; index < 64; ++index) { + sixty_four.push_back(lease(authorized, now + 20, "lease-" + std::to_string(index))); + } + write_policy(path, policy(now, now + 20, std::move(sixty_four))); + require(managed_access::authorize(authorized.cert.get()) != nullptr, "64-lease boundary policy was denied"); + + now = unix_time(); + json sixty_five = json::array(); + for (int index = 0; index < 65; ++index) { + sixty_five.push_back(lease(authorized, now + 20, "lease-" + std::to_string(index))); + } + write_policy(path, policy(now, now + 20, std::move(sixty_five))); + require(managed_access::authorize(authorized.cert.get()) == nullptr, "65-lease policy was accepted"); + + now = unix_time(); + write_policy(path, policy(now + 30, now + 120, json::array({lease(authorized, now + 120)}))); + require(managed_access::authorize(authorized.cert.get()) != nullptr, "future-issued policy at both accepted boundaries was denied"); + } + + void run_tests(const std::filesystem::path &path) { + config::managed_policy_file = path.string(); + require(managed_access::enabled(), "managed access did not enable for a configured policy path"); + + auto authorized = issue_certificate("authorized-client"); + auto unknown = issue_certificate("unknown-client"); + auto derived = issue_certificate("derived-client", authorized.cert.get(), authorized.key.get()); + + test_exact_certificate_and_permissions(path, authorized, unknown, derived); + test_fail_closed_documents(path, authorized); + test_expiry_and_renewal(path, authorized); + test_removal_invalidates_lookup_but_not_existing_value(path, authorized); + test_policy_bounds(path, authorized); + } +} // namespace + +int main() { + const auto path = std::filesystem::temp_directory_path() / + ("apollo-managed-access-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + + ".json"); + try { + run_tests(path); + std::error_code ignored; + std::filesystem::remove(path, ignored); + std::cout << "managed access policy tests passed\n"; + return 0; + } catch (const std::exception &error) { + std::error_code ignored; + std::filesystem::remove(path, ignored); + std::cerr << "managed access policy tests failed: " << error.what() << '\n'; + return 1; + } +} From e6515a626020a30c436827b98bece40d1774f7fb Mon Sep 17 00:00:00 2001 From: Filip Date: Fri, 18 Sep 2026 16:26:27 +0200 Subject: [PATCH 2/5] fix: harden managed host enforcement and restore Windows build --- src/config.cpp | 19 ++++++++++--------- src/confighttp.cpp | 4 +++- src/crypto.cpp | 11 +++++++++++ src/managed_access.cpp | 8 +++++--- src/video.cpp | 7 +++---- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index 9b7ca19063..29359f7e16 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1318,15 +1318,6 @@ namespace config { } string_f(vars, "managed_policy_file", managed_policy_file); - if (!managed_policy_file.empty()) { - // Managed hosts never fall back to PIN pairing or unencrypted streaming. - sunshine.enable_pairing = false; - sunshine.enable_discovery = false; - sunshine.flags[config::flag::UPNP] = false; - nvhttp.origin_web_ui_allowed = "pc"; - stream.lan_encryption_mode = ENCRYPTION_MODE_MANDATORY; - stream.wan_encryption_mode = ENCRYPTION_MODE_MANDATORY; - } string_restricted_f(vars, "locale", config::sunshine.locale, { "bg"sv, // Bulgarian @@ -1387,6 +1378,16 @@ namespace config { vars.erase(it); } + if (!managed_policy_file.empty()) { + // Apply invariants last: flags and legacy options cannot weaken managed access. + sunshine.enable_pairing = false; + sunshine.enable_discovery = false; + sunshine.flags[config::flag::UPNP] = false; + nvhttp.origin_web_ui_allowed = "pc"; + stream.lan_encryption_mode = ENCRYPTION_MODE_MANDATORY; + stream.wan_encryption_mode = ENCRYPTION_MODE_MANDATORY; + } + if (sunshine.min_log_level <= 3) { for (auto &[var, _] : vars) { std::cout << "Warning: Unrecognized configurable option ["sv << var << ']' << std::endl; diff --git a/src/confighttp.cpp b/src/confighttp.cpp index d53ac65372..be524b2bfb 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1557,7 +1557,9 @@ namespace confighttp { server.resource["^/images/logo-apollo-45.png$"]["GET"] = getApolloLogoImage; server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; server.config.reuse_address = true; - server.config.address = net::af_to_any_address_string(address_family); + // Bind locally even before an administrator is created. Route-level login + // checks alone do not cover first-run credential setup. + server.config.address = config::managed_policy_file.empty() ? net::af_to_any_address_string(address_family) : "127.0.0.1"; server.config.port = port_https; auto accept_and_run = [&](auto *server) { diff --git a/src/crypto.cpp b/src/crypto.cpp index 97acd805bf..517f6275e8 100644 --- a/src/crypto.cpp +++ b/src/crypto.cpp @@ -5,17 +5,21 @@ // lib includes #include #include +#include // local includes #include "crypto.h" namespace crypto { + // Client authentication also runs concurrently with administrative pairing updates. + static std::mutex certificate_mutex; using asn1_string_t = util::safe_ptr; cert_chain_t::cert_chain_t(): _certs {}, _cert_ctx { X509_STORE_CTX_new() } { } void cert_chain_t::add(p_named_cert_t& named_cert_p) { + std::lock_guard lock(certificate_mutex); x509_store_t x509_store { X509_STORE_new() }; X509_STORE_add_cert(x509_store.get(), x509(named_cert_p->cert).get()); @@ -23,6 +27,7 @@ namespace crypto { } void cert_chain_t::clear() { + std::lock_guard lock(certificate_mutex); _certs.clear(); } @@ -53,8 +58,14 @@ namespace crypto { * @return nullptr if the certificate is valid, otherwise an error string. */ const char * cert_chain_t::verify(x509_t::element_type *cert, p_named_cert_t& named_cert_out) { + std::lock_guard lock(certificate_mutex); int err_code = 0; for (auto &[named_cert_p, x509_store] : _certs) { + auto paired_certificate = x509(named_cert_p->cert); + if (!paired_certificate || X509_cmp(cert, paired_certificate.get()) != 0) { + // Pairing authorizes this exact leaf, never other certificates it signs. + continue; + } auto fg = util::fail_guard([this]() { X509_STORE_CTX_cleanup(_cert_ctx.get()); }); diff --git a/src/managed_access.cpp b/src/managed_access.cpp index 253dd8c2cd..549af467a5 100644 --- a/src/managed_access.cpp +++ b/src/managed_access.cpp @@ -3,6 +3,7 @@ * @brief Host-side enforcement of short-lived management policy. */ #include "managed_access.h" + #include "config.h" #include @@ -12,14 +13,17 @@ #include #include #include +#include namespace managed_access { namespace { using clock = std::chrono::steady_clock; + struct entry_t { crypto::p_named_cert_t client; clock::time_point deadline; }; + std::mutex mutex; std::map entries; std::string previous_document; @@ -99,9 +103,7 @@ namespace managed_access { client->name = lease.at("username").get(); client->uuid = client_uuid(hash); client->cert = pem; - client->perm = static_cast(static_cast(crypto::PERM::_all_inputs) | - static_cast(crypto::PERM::_all_actions) | static_cast(crypto::PERM::clipboard_read) | - static_cast(crypto::PERM::clipboard_set)); + client->perm = static_cast(static_cast(crypto::PERM::_all_inputs) | static_cast(crypto::PERM::_all_actions) | static_cast(crypto::PERM::clipboard_read) | static_cast(crypto::PERM::clipboard_set)); client->enable_legacy_ordering = false; client->allow_client_commands = false; client->always_use_virtual_display = false; diff --git a/src/video.cpp b/src/video.cpp index fc0b0efe0d..eb755d020c 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -826,10 +826,9 @@ namespace video { }, { // SDR-specific options - {"profile"s, [](const config_t &cfg) { - if (cfg.profile == 66) return "baseline"s; - if (cfg.profile == 77) return "main"s; - return "high"s; + {"profile"s, [](const config_t &) { + // CAVLC is only valid with the Baseline profile. Auto and CABAC use High. + return config::video.amd.amd_coder == 2 ? "baseline"s : "high"s; }}, }, {}, // HDR-specific options From 5664d84c399c98c9406dc58b3f6d01d14149cb6b Mon Sep 17 00:00:00 2001 From: Filip Date: Fri, 18 Sep 2026 16:32:15 +0200 Subject: [PATCH 3/5] docs: describe managed host deployment and trust boundaries --- README.md | 6 +++++- docs/managed-access.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 docs/managed-access.md diff --git a/README.md b/README.md index 2011e40d4e..d220238480 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Apollo +# Apollo Managed + +This fork adds host enforcement for [Moonlight V+ managed access](https://github.com/mikulicf/moonlight-vplus/tree/master/control). The management backend authenticates users and assigns machines; streams connect directly to this host. No backend URL, hostname, account, or enrollment secret is built in. + +See [managed host setup and security](docs/managed-access.md) before enabling managed mode. The upstream Apollo description and documentation follow below. Apollo is a self-hosted desktop stream host for [Artemis(Moonlight Noir)](https://github.com/ClassicOldSong/moonlight-android). Offering low latency, native client resolution, cloud gaming server capabilities with support for AMD, Intel, and Nvidia GPUs for hardware encoding. Software encoding is also available. A web UI is provided to allow configuration and client pairing from your favorite web browser. Pair from the local server or any mobile device. diff --git a/docs/managed-access.md b/docs/managed-access.md new file mode 100644 index 0000000000..a684011b82 --- /dev/null +++ b/docs/managed-access.md @@ -0,0 +1,40 @@ +# Managed access + +This host works with the optional [Moonlight V+ backend and host agent](https://github.com/mikulicf/moonlight-vplus/tree/master/control). Supply deployment addresses and credentials at installation time. The repository includes none. + +## Enabling the host policy + +Build this repository or obtain the Windows ZIP artifact from its **Managed host build** workflow. Preserve the existing Apollo configuration, certificates, application list, and state when updating the host. The package requires the same GPU drivers and virtual-display driver as upstream Apollo. + +Follow the backend's enrollment instructions and configure: + +```ini +managed_policy_file = +``` + +The agent and Apollo must use the same policy path. The agent authenticates outbound to the backend over HTTPS and writes current grants atomically. Only the agent, trusted host services, and administrators may modify this file or its parent directory. Interactive Windows users receiving remote desktop access should not be local administrators; full desktop access is not an operating-system sandbox. Apollo's supported Windows service runs as LocalSystem, which remains a trusted principal. + +With a nonempty policy path, Apollo: + +- Accepts only the exact client certificates in unexpired backend leases, including on reused HTTPS connections. +- Ignores legacy device pairings and disables PIN pairing, automatic port mapping, and discovery. +- Requires encrypted RTSP, video, audio, and input according to the GameStream protocol. +- Checks authorization before starting a stream and terminates revoked or expired streams. +- Binds its administrative web listener to IPv4 loopback, including first-run account setup. +- Exposes managed protocol version 1 in loopback server information for the agent. + +There is no fallback to paired-device access while managed mode is enabled. A missing, malformed, stale, or expired policy denies access. The normal polling interval is one second, and Apollo reloads policy and checks active sessions every 500 ms. During a backend outage, authorization lasts at most the remaining 90-second lease. Synchronize clocks on the backend and host. + +The policy grants streaming, application launch/stop, input, and clipboard permission. It does not grant host command execution or file-transfer extensions. Users authorized for the same host share its Windows desktop; this does not create separate Windows sessions or provide exclusive ownership of a workstation. + +## Networking + +The backend does not relay media or arrange NAT traversal. Provide client-reachable streaming addresses and ports for each deployment. Keep Apollo administration and OS management ports off public networks. The management backend needs its own HTTPS endpoint; configure certificate issuance, router rules, and firewall restrictions for the actual environment. + +An empty `managed_policy_file` retains regular Apollo operation. Removing managed mode is a deliberate administrator action that restores the usual pairing and configuration rules. Stopping the agent or deleting its policy alone leaves managed mode enabled and access denied. + +## Validation and upstream maintenance + +CI builds the Windows host and runs production policy-parser tests covering exact certificate matching, derived and unknown certificates, revoked/expired grants, malformed policy, validity bounds, and permission restrictions. + +This fork builds on Apollo master and includes fixes from the upstream security work: strict paired-certificate identity checks and serialized legacy certificate-store access, plus the [input bounds repair](https://github.com/LizardByte/Sunshine/commit/1583e7c4) and [control packet bounds repair](https://github.com/LizardByte/Sunshine/commit/82bccdf6). Review [Sunshine security advisories](https://github.com/LizardByte/Sunshine/security/advisories) when updating inherited protocol code. Automated tests and a successful build do not establish suitability for every public-network deployment. From 01346c973739c1bc8e874097bc297d39d7a091c9 Mon Sep 17 00:00:00 2001 From: Filip Date: Fri, 18 Sep 2026 16:42:19 +0200 Subject: [PATCH 4/5] ci: upload packaged Windows artifact --- .github/workflows/managed-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/managed-build.yml b/.github/workflows/managed-build.yml index 057d708a5f..dc655820d8 100644 --- a/.github/workflows/managed-build.yml +++ b/.github/workflows/managed-build.yml @@ -51,7 +51,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: Apollo-Managed-Windows-x64 - path: '*.zip' + path: 'build/cpack_artifacts/*.zip' if-no-files-found: error policy-tests: runs-on: ubuntu-24.04 From f0309d8ab3b14b766adda2b0b456e54924d32ab9 Mon Sep 17 00:00:00 2001 From: Filip Date: Fri, 18 Sep 2026 18:04:23 +0200 Subject: [PATCH 5/5] fix: restore FFmpeg 8 dependencies for AMD encoder teardown --- docs/managed-access.md | 2 ++ third-party/build-deps | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/managed-access.md b/docs/managed-access.md index a684011b82..7337fe8953 100644 --- a/docs/managed-access.md +++ b/docs/managed-access.md @@ -35,6 +35,8 @@ An empty `managed_policy_file` retains regular Apollo operation. Removing manage ## Validation and upstream maintenance +Keep `third-party/build-deps` at the restored FFmpeg 8 revision `c38829d6` or a reviewed compatible successor. Apollo's encoder teardown drains FFmpeg before releasing the device; the earlier `a9a7f863` dependency pin supplies FFmpeg 7 and can hang indefinitely during AMD AMF probing. This fork restores the dependency revision used before the upstream merge regression described in [Apollo issue #1588](https://github.com/ClassicOldSong/Apollo/issues/1588). Validate hardware encoder startup and stream teardown when updating these dependencies. + CI builds the Windows host and runs production policy-parser tests covering exact certificate matching, derived and unknown certificates, revoked/expired grants, malformed policy, validity bounds, and permission restrictions. This fork builds on Apollo master and includes fixes from the upstream security work: strict paired-certificate identity checks and serialized legacy certificate-store access, plus the [input bounds repair](https://github.com/LizardByte/Sunshine/commit/1583e7c4) and [control packet bounds repair](https://github.com/LizardByte/Sunshine/commit/82bccdf6). Review [Sunshine security advisories](https://github.com/LizardByte/Sunshine/security/advisories) when updating inherited protocol code. Automated tests and a successful build do not establish suitability for every public-network deployment. diff --git a/third-party/build-deps b/third-party/build-deps index a9a7f86328..c38829d6b8 160000 --- a/third-party/build-deps +++ b/third-party/build-deps @@ -1 +1 @@ -Subproject commit a9a7f86328963837e30e21c8901faba00fe81542 +Subproject commit c38829d6b8c35b64b3ff7a569f51978ed72c4292