Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/managed-build.yml
Original file line number Diff line number Diff line change
@@ -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: 'build/cpack_artifacts/*.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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 2 additions & 0 deletions cmake/compile_definitions/common.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 42 additions & 0 deletions docs/managed-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 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 = <absolute-path-to-protected-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

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.
14 changes: 14 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,8 @@ namespace config {
true, // forward_rumble
};

std::string managed_policy_file;

sunshine_t sunshine {
false, // hide_tray_controls
true, // enable_pairing
Expand Down Expand Up @@ -1315,6 +1317,8 @@ namespace config {
config::sunshine.flags[config::flag::UPNP].flip();
}

string_f(vars, "managed_policy_file", managed_policy_file);

string_restricted_f(vars, "locale", config::sunshine.locale, {
"bg"sv, // Bulgarian
"cs"sv, // Czech
Expand Down Expand Up @@ -1374,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;
Expand Down
2 changes: 2 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string, std::string> parse_config(const std::string_view &file_content);
Expand Down
4 changes: 3 additions & 1 deletion src/confighttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions src/crypto.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,29 @@
// lib includes
#include <openssl/pem.h>
#include <openssl/rsa.h>
#include <mutex>

// 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<ASN1_STRING, ASN1_STRING_free>;

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());
_certs.emplace_back(std::make_pair(named_cert_p, std::move(x509_store)));
}

void cert_chain_t::clear() {
std::lock_guard lock(certificate_mutex);
_certs.clear();
}

Expand Down Expand Up @@ -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());
});
Expand Down
92 changes: 92 additions & 0 deletions src/input.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ extern "C" {
#include <bitset>
#include <chrono>
#include <cmath>
#include <cstring>
#include <list>
#include <span>
#include <thread>
#include <unordered_map>

Expand Down Expand Up @@ -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<typename Packet>
bool validate_fixed_input_packet(std::span<const std::uint8_t> packet, std::uint32_t declared_size) {
constexpr auto expected_size = static_cast<std::uint32_t>(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<const std::uint8_t> 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<NV_REL_MOUSE_MOVE_PACKET>(packet, declared_size);
case MOUSE_MOVE_ABS_MAGIC:
return validate_fixed_input_packet<NV_ABS_MOUSE_MOVE_PACKET>(packet, declared_size);
case MOUSE_BUTTON_DOWN_EVENT_MAGIC_GEN5:
case MOUSE_BUTTON_UP_EVENT_MAGIC_GEN5:
return validate_fixed_input_packet<NV_MOUSE_BUTTON_PACKET>(packet, declared_size);
case SCROLL_MAGIC_GEN5:
return validate_fixed_input_packet<NV_SCROLL_PACKET>(packet, declared_size);
case SS_HSCROLL_MAGIC:
return validate_fixed_input_packet<SS_HSCROLL_PACKET>(packet, declared_size);
case KEY_DOWN_EVENT_MAGIC:
case KEY_UP_EVENT_MAGIC:
return validate_fixed_input_packet<NV_KEYBOARD_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<NV_MULTI_CONTROLLER_PACKET>(packet, declared_size);
case SS_TOUCH_MAGIC:
return validate_fixed_input_packet<SS_TOUCH_PACKET>(packet, declared_size);
case SS_PEN_MAGIC:
return validate_fixed_input_packet<SS_PEN_PACKET>(packet, declared_size);
case SS_CONTROLLER_ARRIVAL_MAGIC:
return validate_fixed_input_packet<SS_CONTROLLER_ARRIVAL_PACKET>(packet, declared_size);
case SS_CONTROLLER_TOUCH_MAGIC:
return validate_fixed_input_packet<SS_CONTROLLER_TOUCH_PACKET>(packet, declared_size);
case SS_CONTROLLER_MOTION_MAGIC:
return validate_fixed_input_packet<SS_CONTROLLER_MOTION_PACKET>(packet, declared_size);
case SS_CONTROLLER_BATTERY_MAGIC:
return validate_fixed_input_packet<SS_CONTROLLER_BATTERY_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
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading