#include "state_storage.h" #include "state_storage_policy.h" #include "config.h" #include "crypto.h" #include "paired_state_policy.h" #include "file_handler.h" #include "logging.h" #include "utility.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #include #include #endif using namespace std::literals; namespace statefile { namespace { namespace fs = std::filesystem; namespace pt = boost::property_tree; std::once_flag migration_once; using policy_load_result_e = policy::load_result_e; void write_json_atomic_direct(const std::string &path, const nlohmann::json &tree); /** * @brief Best-effort rename of an unparseable state file out of the way so a * fresh, valid file can replace it. Preserves the bad copy for forensics. * * Callers hold state_mutex() during their read/modify/write transaction, so this * runs serialized with respect to other writers of the same file. */ void quarantine_corrupt_state(const fs::path &path) { static std::atomic sequence {0}; std::error_code ec; const auto stamp = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch() ) .count(); fs::path quarantine = path; // Include a process-unique counter so two corrupt-detections in the same // wall-clock second cannot overwrite each other's forensic copy. quarantine += ".corrupt-" + std::to_string(stamp) + "-" + std::to_string(sequence.fetch_add(1)); fs::rename(path, quarantine, ec); if (ec) { // The subsequent atomic write replaces the file in place anyway, so this is non-fatal. BOOST_LOG(warning) << "statefile: could not quarantine corrupt file "sv << path.string() << " (will overwrite in place): "sv << ec.message(); } else { BOOST_LOG(warning) << "statefile: quarantined corrupt state file to "sv << quarantine.string(); } } pt::ptree &ensure_root(pt::ptree &tree) { auto it = tree.find("root"); if (it == tree.not_found()) { auto inserted = tree.insert(tree.end(), std::make_pair(std::string("root"), pt::ptree {})); return inserted->second; } return it->second; } policy::read_result_t read_state_file(const std::string &path_string) { const fs::path path(path_string); if (path.empty()) { BOOST_LOG(error) << "statefile: refusing to update empty state path"; return {policy::read_status_e::failed, {}}; } std::error_code ec; if (!fs::exists(path, ec)) { if (ec) { BOOST_LOG(error) << "statefile: unable to inspect "sv << path.string() << ": "sv << ec.message(); return {policy::read_status_e::failed, {}}; } return {policy::read_status_e::missing, {}}; } if (!fs::is_regular_file(path, ec) || ec) { BOOST_LOG(error) << "statefile: refusing to update non-file state path "sv << path.string(); return {policy::read_status_e::failed, {}}; } std::ifstream in(path, std::ios::binary); if (!in.is_open()) { BOOST_LOG(error) << "statefile: refusing to update "sv << path.string() << " - could not open it for reading"sv; return {policy::read_status_e::failed, {}}; } in.seekg(0, std::ios::end); const std::streamoff size = in.tellg(); if (size < 0) { BOOST_LOG(error) << "statefile: refusing to update "sv << path.string() << " - could not determine file size"sv; return {policy::read_status_e::failed, {}}; } std::string contents; if (size > 0) { in.seekg(0, std::ios::beg); contents.resize(static_cast(size)); in.read(contents.data(), size); if (in.bad() || in.gcount() != size) { BOOST_LOG(error) << "statefile: refusing to update "sv << path.string() << " - read error or short read"sv; return {policy::read_status_e::failed, {}}; } } return {policy::read_status_e::loaded, std::move(contents)}; } bool is_primary_state_path(const std::string &path) { return !path.empty() && (path == sunshine_state_path() || (path == vibeshine_state_path() && share_state_file())); } bool valid_primary_snapshot(const pt::ptree &tree, bool allow_bootstrap) { if (!nvhttp::state_policy::valid_primary_tree(tree, allow_bootstrap)) return false; std::set identities; const auto certificate = [&identities](const std::string &pem) { auto parsed = crypto::x509(pem); return parsed && identities.insert(crypto::pem(parsed)).second; }; if (const auto devices = tree.get_child_optional("root.named_devices")) { for (const auto &[key, device] : *devices) { if (!certificate(device.get("cert"))) return false; } } if (const auto devices = tree.get_child_optional("root.devices")) { for (const auto &[key, device] : *devices) { if (const auto certs = device.get_child_optional("certs")) { for (const auto &[cert_key, cert] : *certs) { if (!certificate(cert.get_value())) return false; } } } } return true; } bool is_auxiliary_state(const std::string &path) { return !path.empty() && path == vibeshine_state_path() && !share_state_file(); } policy_load_result_e load_auxiliary_state(const std::string &path, pt::ptree &out) { const auto result = policy::load_vibeshine_state( path, out, read_state_file, [](const std::string &target, const std::string &contents) { return file_handler::write_file(target.c_str(), contents) == 0; }); if (result == policy::load_result_e::failed) { BOOST_LOG(error) << "statefile: refusing to replace unavailable auxiliary state " << path; } return result; } policy_load_result_e load_tree_for_update(const fs::path &path, pt::ptree &out) { if (is_primary_state_path(path.string())) { return policy::load_primary_state_for_update(sunshine_state_path(), out, read_state_file, [](const std::string &target, const std::string &contents) { return file_handler::write_file(target.c_str(), contents) == 0; }, valid_primary_snapshot, nvhttp::state_policy::valid_primary_json); } if (is_auxiliary_state(path.string())) { return load_auxiliary_state(path.string(), out); } return policy::load_json_for_update( path.string(), out, read_state_file, [](const std::string &corrupt_path) { BOOST_LOG(error) << "statefile: "sv << corrupt_path << " contains malformed JSON; quarantining it and recreating from current state"sv; quarantine_corrupt_state(fs::path(corrupt_path)); }); } bool load_tree_if_exists(const fs::path &path, pt::ptree &out) { if (is_auxiliary_state(path.string())) { return load_auxiliary_state(path.string(), out) == policy::load_result_e::loaded; } if (!fs::exists(path)) { return false; } try { pt::read_json(path.string(), out); return true; } catch (const std::exception &e) { BOOST_LOG(warning) << "statefile: failed to read "sv << path.string() << ": "sv << e.what(); return false; } } void write_tree(const fs::path &path, const pt::ptree &tree) { if (path.string() == sunshine_state_path()) { write_sunshine_state_atomic(tree); } else { write_json_atomic(path.string(), tree); } } void write_json_atomic_direct(const std::string &path, const pt::ptree &tree) { policy::write_json_atomic( path, tree, [](const std::string &target, const std::string &contents) { return file_handler::write_file(target.c_str(), contents) == 0; }, read_state_file); } #ifdef _WIN32 bool path_exists_or_may_be_access_denied(const fs::path &path) { if (path.empty()) { return false; } const auto wide_path = path.wstring(); const DWORD attributes = GetFileAttributesW(wide_path.c_str()); if (attributes != INVALID_FILE_ATTRIBUTES) { return true; } switch (GetLastError()) { case ERROR_FILE_NOT_FOUND: case ERROR_PATH_NOT_FOUND: case ERROR_INVALID_NAME: return false; default: // Access-denied and sharing failures are exactly the cases this // repair path is meant to address. Try the ACL API anyway. return true; } } void clear_readonly_attribute(const fs::path &path) { if (path.empty()) { return; } const auto wide_path = path.wstring(); const DWORD attributes = GetFileAttributesW(wide_path.c_str()); if (attributes == INVALID_FILE_ATTRIBUTES || !(attributes & FILE_ATTRIBUTE_READONLY)) { return; } if (SetFileAttributesW(wide_path.c_str(), attributes & ~static_cast(FILE_ATTRIBUTE_READONLY))) { BOOST_LOG(debug) << "statefile: cleared read-only attribute on "sv << path.string(); } else { BOOST_LOG(warning) << "statefile: failed to clear read-only attribute on "sv << path.string() << " (error="sv << GetLastError() << ')'; } } bool set_token_privilege(LPCWSTR privilege_name, bool enable) { HANDLE raw_token = nullptr; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &raw_token)) { return false; } auto close_token = util::fail_guard([&raw_token]() { if (raw_token) { CloseHandle(raw_token); } }); LUID luid; if (!LookupPrivilegeValueW(nullptr, privilege_name, &luid)) { return false; } TOKEN_PRIVILEGES privileges {}; privileges.PrivilegeCount = 1; privileges.Privileges[0].Luid = luid; privileges.Privileges[0].Attributes = enable ? SE_PRIVILEGE_ENABLED : 0; if (!AdjustTokenPrivileges(raw_token, FALSE, &privileges, sizeof(privileges), nullptr, nullptr)) { return false; } return GetLastError() == ERROR_SUCCESS; } // Last-resort recovery: a botched upgrade can leave a config/state file owned by // a foreign account with a DACL the service cannot rewrite. The streaming host // runs as LocalSystem, so granting it ownership lets the inheritance reset retry // succeed. Best-effort and scoped to our own config paths. The required privileges // are enabled only for the duration of the ownership change, not process-wide. bool take_ownership_as_system(const fs::path &path) { const bool restore_enabled = set_token_privilege(L"SeRestorePrivilege", true); const bool take_enabled = set_token_privilege(L"SeTakeOwnershipPrivilege", true); auto disable_privileges = util::fail_guard([&]() { if (restore_enabled) { set_token_privilege(L"SeRestorePrivilege", false); } if (take_enabled) { set_token_privilege(L"SeTakeOwnershipPrivilege", false); } }); if (!restore_enabled && !take_enabled) { return false; } alignas(DWORD) BYTE sid_buffer[SECURITY_MAX_SID_SIZE]; DWORD sid_size = sizeof(sid_buffer); if (!CreateWellKnownSid(WinLocalSystemSid, nullptr, sid_buffer, &sid_size)) { return false; } const auto wide_path = path.wstring(); const DWORD status = SetNamedSecurityInfoW( const_cast(wide_path.c_str()), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, sid_buffer, nullptr, nullptr, nullptr ); if (status != ERROR_SUCCESS) { BOOST_LOG(warning) << "statefile: failed to take ownership of "sv << path.string() << " (error="sv << status << ')'; return false; } BOOST_LOG(info) << "statefile: took ownership of "sv << path.string() << " to repair permissions"sv; return true; } bool enable_acl_inheritance_once(const fs::path &path) { const auto wide_path = path.wstring(); PSECURITY_DESCRIPTOR security_descriptor = nullptr; PACL current_dacl = nullptr; DWORD status = GetNamedSecurityInfoW( const_cast(wide_path.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, ¤t_dacl, nullptr, &security_descriptor ); auto free_security_descriptor = util::fail_guard([&security_descriptor]() { if (security_descriptor) { LocalFree(security_descriptor); } }); if (status == ERROR_SUCCESS && current_dacl) { status = SetNamedSecurityInfoW( const_cast(wide_path.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, current_dacl, nullptr ); if (status == ERROR_SUCCESS) { BOOST_LOG(debug) << "statefile: restored inherited ACLs for "sv << path.string(); return true; } BOOST_LOG(warning) << "statefile: failed to restore inherited ACLs for "sv << path.string() << " (error="sv << status << ')'; return false; } if (status == ERROR_SUCCESS) { status = ERROR_INVALID_ACL; } // If the current DACL cannot be read, do not pass a null DACL together // with DACL_SECURITY_INFORMATION. Only try to clear the protected-DACL // bit; this avoids accidentally granting Everyone full access. const DWORD unprotect_status = SetNamedSecurityInfoW( const_cast(wide_path.c_str()), SE_FILE_OBJECT, UNPROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, nullptr, nullptr ); if (unprotect_status == ERROR_SUCCESS) { BOOST_LOG(debug) << "statefile: restored inherited ACLs for "sv << path.string(); return true; } BOOST_LOG(warning) << "statefile: failed to inspect/restore inherited ACLs for "sv << path.string() << " (get_error="sv << status << ", set_error="sv << unprotect_status << ')'; return false; } bool enable_acl_inheritance(const fs::path &path) { if (!path_exists_or_may_be_access_denied(path)) { return false; } // A read-only attribute blocks atomic replaces independently of the DACL, so // clear it whenever we touch a config path during repair. clear_readonly_attribute(path); if (enable_acl_inheritance_once(path)) { return true; } // The DACL could not be read or rewritten (likely foreign ownership). Take // ownership as SYSTEM and retry once before giving up. if (take_ownership_as_system(path) && enable_acl_inheritance_once(path)) { BOOST_LOG(info) << "statefile: repaired ACLs for "sv << path.string() << " after taking ownership"sv; return true; } return false; } std::wstring normalized_path_key(fs::path path) { path = fs::absolute(path).lexically_normal(); path.make_preferred(); auto key = path.wstring(); std::transform(key.begin(), key.end(), key.begin(), [](wchar_t ch) { return static_cast(std::towlower(ch)); }); return key; } bool path_is_at_or_under(const fs::path &candidate, const fs::path &root) { if (candidate.empty() || root.empty()) { return false; } try { auto candidate_key = normalized_path_key(candidate); auto root_key = normalized_path_key(root); while (root_key.size() > 3 && (root_key.back() == L'\\' || root_key.back() == L'/')) { root_key.pop_back(); } if (candidate_key == root_key) { return true; } if (candidate_key.size() <= root_key.size() || candidate_key.compare(0, root_key.size(), root_key) != 0) { return false; } const wchar_t separator = candidate_key[root_key.size()]; return separator == L'\\' || separator == L'/'; } catch (const std::exception &e) { BOOST_LOG(warning) << "statefile: failed to normalize ACL repair path " << candidate.string() << " under " << root.string() << ": "sv << e.what(); return false; } } void add_root_from_file(std::set &roots, const std::string &path) { if (path.empty()) { return; } fs::path candidate {path}; const auto parent = candidate.parent_path(); if (!parent.empty()) { roots.insert(parent); } } bool is_in_any_root(const fs::path &path, const std::set &roots) { return std::any_of(roots.begin(), roots.end(), [&](const fs::path &root) { return path_is_at_or_under(path, root); }); } void add_file_if_in_root(std::set &files, const std::set &roots, const std::string &path) { if (path.empty()) { return; } fs::path candidate {path}; if (is_in_any_root(candidate, roots)) { files.insert(candidate); } } #endif policy_load_result_e load_tree_for_read(const fs::path &path, pt::ptree &out) { if (is_auxiliary_state(path.string())) { return load_auxiliary_state(path.string(), out); } return policy::load_json_for_read(path.string(), out, read_state_file); } } // namespace std::mutex &state_mutex() { static std::mutex mutex; return mutex; } void write_json_atomic(const std::string &path, const pt::ptree &tree) { // All callers use this entry point for JSON updates, including credentials // and API-token persistence. Route the primary through the paired-state // writer so those updates refresh the recovery copy too. if (is_primary_state_path(path)) { write_sunshine_state_atomic(tree); return; } if (is_auxiliary_state(path)) { policy::write_vibeshine_state( path, tree, [](const std::string &target, const std::string &contents) { return file_handler::write_file(target.c_str(), contents) == 0; }, read_state_file); return; } write_json_atomic_direct(path, tree); if (!path.empty() && path == config::sunshine.credentials_file) { // A custom credential path must have its own recovery copy; never use // the host-state credentials when the administrator selected another file. try { write_json_atomic_direct(path + ".bak", tree); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to refresh custom credential backup: " << e.what(); } } } std::string sunshine_state_backup_path() { const auto &path = sunshine_state_path(); return path.empty() ? std::string {} : path + ".bak"; } void write_sunshine_state_atomic(const pt::ptree &tree) { const auto &path = sunshine_state_path(); pt::ptree backup; const auto backup_status = policy::load_json_for_read(sunshine_state_backup_path(), backup, read_state_file); if (!policy::primary_write_allowed(tree, backup_status, backup, valid_primary_snapshot)) { throw std::runtime_error("refusing to replace primary state with an invalid or partial snapshot"); } nlohmann::json typed; if (!nvhttp::state_policy::primary_tree_json(tree, typed)) { throw std::runtime_error("refusing to replace primary state with an invalid timestamp"); } write_json_atomic_direct(path, typed); const auto backup_path = sunshine_state_backup_path(); if (backup_path.empty()) { return; } try { write_json_atomic_direct(backup_path, typed); } catch (const std::exception &e) { // The primary snapshot is already durable. Keep serving it, but report // that the recovery copy could not be refreshed so the next save can // retry it. BOOST_LOG(error) << "statefile: failed to refresh ArtLight state backup "sv << backup_path << ": "sv << e.what(); } } json_load_result_e load_json(const std::string &path, pt::ptree &tree) { const auto result = load_tree_for_read(fs::path {path}, tree); switch (result) { case policy::load_result_e::loaded: return json_load_result_e::loaded; case policy::load_result_e::missing: return json_load_result_e::missing; case policy::load_result_e::corrupt: return json_load_result_e::corrupt; case policy::load_result_e::failed: return json_load_result_e::failed; } return json_load_result_e::failed; } json_load_result_e load_json(const std::string &path, nlohmann::json &tree) { tree = nlohmann::json::object(); const auto result = read_state_file(path); if (result.status == policy::read_status_e::missing) return json_load_result_e::missing; if (result.status == policy::read_status_e::failed) return json_load_result_e::failed; try { tree = nlohmann::json::parse(result.contents); return json_load_result_e::loaded; } catch (...) { tree = nlohmann::json::object(); return json_load_result_e::corrupt; } } namespace { void write_json_atomic_direct(const std::string &path, const nlohmann::json &tree) { if (path.empty() || file_handler::write_file(path.c_str(), tree.dump(4)) != 0) { throw std::runtime_error("atomic paired state write failed"); } nlohmann::json written; if (load_json(path, written) != json_load_result_e::loaded || written != tree) { throw std::runtime_error("atomic paired state write verification failed"); } } } void write_sunshine_state_atomic(const nlohmann::json &tree) { if (!nvhttp::state_policy::valid_primary_json(tree.dump())) { throw std::runtime_error("refusing to replace primary state with invalid client field types"); } pt::ptree candidate; std::istringstream input(tree.dump()); pt::read_json(input, candidate); pt::ptree backup; const auto backup_status = policy::load_json_for_read(sunshine_state_backup_path(), backup, read_state_file); if (!policy::primary_write_allowed(candidate, backup_status, backup, valid_primary_snapshot)) { throw std::runtime_error("refusing to replace primary state with an invalid or partial snapshot"); } write_json_atomic_direct(sunshine_state_path(), tree); try { write_json_atomic_direct(sunshine_state_backup_path(), tree); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to refresh ArtLight paired state backup: " << e.what(); } } void write_json_atomic(const std::string &path, const nlohmann::json &tree) { if (is_primary_state_path(path)) { write_sunshine_state_atomic(tree); } else { write_json_atomic_direct(path, tree); } } json_load_result_e load_primary_state(pt::ptree &tree) { const auto result = load_tree_for_update(fs::path {sunshine_state_path()}, tree); switch (result) { case policy::load_result_e::loaded: return json_load_result_e::loaded; case policy::load_result_e::missing: return json_load_result_e::missing; case policy::load_result_e::corrupt: return json_load_result_e::corrupt; case policy::load_result_e::failed: return json_load_result_e::failed; } return json_load_result_e::failed; } json_load_result_e load_primary_state(nlohmann::json &tree) { pt::ptree selected; const auto result = load_primary_state(selected); tree = nlohmann::json::object(); if (result != json_load_result_e::loaded) return result; // The selector restored/validated the canonical file while the caller // holds state_mutex(). Re-read those bytes to preserve Apollo JSON types. const auto loaded = load_json(sunshine_state_path(), tree); if (loaded == json_load_result_e::loaded && tree.contains("root") && tree["root"] == "") { tree["root"] = nlohmann::json::object(); // Historical empty ptree bootstrap. } return loaded; } bool load_json_for_update(const std::string &path, pt::ptree &tree) { return load_tree_for_update(fs::path(path), tree) != policy::load_result_e::failed; } const std::string &sunshine_state_path() { return config::nvhttp.file_state; } const std::string &vibeshine_state_path() { if (!config::nvhttp.vibeshine_file_state.empty()) { return config::nvhttp.vibeshine_file_state; } return config::nvhttp.file_state; } bool recover_credentials(const std::string &path) { std::lock_guard lock(state_mutex()); const auto result = policy::recover_credentials(path, read_state_file, [](const std::string &target, const std::string &contents) { return file_handler::write_file(target.c_str(), contents) == 0; }, !is_primary_state_path(path) && path != vibeshine_state_path()); // Managed state seeds its backup only after full pairing/auxiliary-state // validation. Custom credential files have no later startup validator. return result == policy::load_result_e::loaded || result == policy::load_result_e::missing; } bool secure_private_directory(const std::string &path) { #ifdef _WIN32 if (path.empty()) { return false; } fs::path dir(path); std::error_code ec; if (!fs::exists(dir, ec) || !fs::is_directory(dir, ec)) { return false; } const auto wide_path = dir.wstring(); const DWORD attributes = GetFileAttributesW(wide_path.c_str()); if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { return false; } clear_readonly_attribute(dir); // Build an explicit, NON-inherited DACL granting full control to LocalSystem and // the local Administrators group only. Applied with PROTECTED_DACL so no // %ProgramFiles% "Users:(RX)" ACE can cascade in and expose the TLS private key. alignas(DWORD) BYTE system_sid[SECURITY_MAX_SID_SIZE]; alignas(DWORD) BYTE admins_sid[SECURITY_MAX_SID_SIZE]; DWORD system_size = sizeof(system_sid); DWORD admins_size = sizeof(admins_sid); if (!CreateWellKnownSid(WinLocalSystemSid, nullptr, system_sid, &system_size) || !CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, admins_sid, &admins_size)) { return false; } // Always include the current process user so the running host never loses access // to its own credentials (e.g. sunshine.exe run directly as a non-admin user). When // running as the LocalSystem service this is the SYSTEM SID (already granted), so no // additional non-admin principal is added. alignas(TOKEN_USER) BYTE token_user_buf[SECURITY_MAX_SID_SIZE + sizeof(TOKEN_USER) + 16]; PSID user_sid = nullptr; HANDLE process_token = nullptr; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &process_token)) { return false; } DWORD token_len = 0; const bool token_user_ok = GetTokenInformation( process_token, TokenUser, token_user_buf, sizeof(token_user_buf), &token_len ); CloseHandle(process_token); if (!token_user_ok) { return false; } PSID candidate = reinterpret_cast(token_user_buf)->User.Sid; if (!candidate || !IsValidSid(candidate)) { return false; } if (!EqualSid(candidate, system_sid)) { user_sid = candidate; } EXPLICIT_ACCESS_W entries[3] {}; ULONG entry_count = 0; const auto add_full_control = [&](PSID sid, TRUSTEE_TYPE trustee_type) { entries[entry_count].grfAccessPermissions = FILE_ALL_ACCESS; entries[entry_count].grfAccessMode = SET_ACCESS; entries[entry_count].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT; entries[entry_count].Trustee.TrusteeForm = TRUSTEE_IS_SID; entries[entry_count].Trustee.TrusteeType = trustee_type; entries[entry_count].Trustee.ptstrName = reinterpret_cast(sid); ++entry_count; }; add_full_control(system_sid, TRUSTEE_IS_USER); add_full_control(admins_sid, TRUSTEE_IS_GROUP); if (user_sid) { add_full_control(user_sid, TRUSTEE_IS_UNKNOWN); } PACL new_dacl = nullptr; if (SetEntriesInAclW(entry_count, entries, nullptr, &new_dacl) != ERROR_SUCCESS || !new_dacl) { return false; } auto free_dacl = util::fail_guard([&new_dacl]() { if (new_dacl) { LocalFree(new_dacl); } }); const DWORD info = DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION; DWORD status = SetNamedSecurityInfoW( const_cast(wide_path.c_str()), SE_FILE_OBJECT, info, nullptr, nullptr, new_dacl, nullptr ); if (status != ERROR_SUCCESS && take_ownership_as_system(dir)) { status = SetNamedSecurityInfoW( const_cast(wide_path.c_str()), SE_FILE_OBJECT, info, nullptr, nullptr, new_dacl, nullptr ); } if (status != ERROR_SUCCESS) { BOOST_LOG(warning) << "statefile: failed to harden private directory "sv << dir.string() << " (error="sv << status << ')'; return false; } PSECURITY_DESCRIPTOR security_descriptor = nullptr; PACL actual_dacl = nullptr; const DWORD verify_status = GetNamedSecurityInfoW( const_cast(wide_path.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, &actual_dacl, nullptr, &security_descriptor ); auto free_security_descriptor = util::fail_guard([&security_descriptor]() { if (security_descriptor) { LocalFree(security_descriptor); } }); SECURITY_DESCRIPTOR_CONTROL control = 0; DWORD revision = 0; ACL_SIZE_INFORMATION acl_info {}; bool verified = verify_status == ERROR_SUCCESS && security_descriptor && actual_dacl && GetSecurityDescriptorControl(security_descriptor, &control, &revision) && (control & SE_DACL_PROTECTED) != 0 && GetAclInformation(actual_dacl, &acl_info, sizeof(acl_info), AclSizeInformation) && acl_info.AceCount == entry_count; if (verified) { PSID expected_sids[3] {system_sid, admins_sid, user_sid}; bool matched_sids[3] {}; constexpr BYTE expected_ace_flags = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; for (DWORD ace_index = 0; ace_index < acl_info.AceCount && verified; ++ace_index) { void *raw_ace = nullptr; if (!GetAce(actual_dacl, ace_index, &raw_ace) || !raw_ace) { verified = false; break; } const auto *allowed_ace = static_cast(raw_ace); if (allowed_ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE || allowed_ace->Header.AceFlags != expected_ace_flags || allowed_ace->Mask != FILE_ALL_ACCESS) { verified = false; break; } PSID ace_sid = const_cast(&allowed_ace->SidStart); if (!IsValidSid(ace_sid)) { verified = false; break; } bool matched = false; for (ULONG expected_index = 0; expected_index < entry_count; ++expected_index) { if (!matched_sids[expected_index] && expected_sids[expected_index] && EqualSid(ace_sid, expected_sids[expected_index])) { matched_sids[expected_index] = true; matched = true; break; } } verified = matched; } for (ULONG expected_index = 0; expected_index < entry_count && verified; ++expected_index) { verified = matched_sids[expected_index]; } } if (!verified) { BOOST_LOG(warning) << "statefile: private directory ACL verification failed for "sv << dir.string() << " (error="sv << verify_status << ')'; return false; } BOOST_LOG(debug) << "statefile: hardened private directory "sv << dir.string(); return true; #else (void) path; return true; #endif } void repair_config_permissions() { #ifdef _WIN32 std::set config_roots; std::set config_files; add_root_from_file(config_roots, config::nvhttp.file_state); add_root_from_file(config_roots, config::nvhttp.vibeshine_file_state); add_file_if_in_root(config_files, config_roots, config::sunshine.config_file); add_file_if_in_root(config_files, config_roots, config::stream.file_apps); add_file_if_in_root(config_files, config_roots, config::nvhttp.file_state); add_file_if_in_root(config_files, config_roots, config::nvhttp.vibeshine_file_state); if (!config::nvhttp.vibeshine_file_state.empty()) { add_file_if_in_root(config_files, config_roots, config::nvhttp.vibeshine_file_state + ".bak"); } add_file_if_in_root(config_files, config_roots, config::sunshine.credentials_file); static constexpr std::string_view known_config_files[] { "sunshine_state.json"sv, "sunshine_state.json.bak"sv, "vibeshine_state.json"sv, "vibeshine_state.json.bak"sv, "sunshine.conf"sv, "apps.json"sv, }; for (const auto &dir : config_roots) { enable_acl_inheritance(dir); for (const auto name : known_config_files) { config_files.insert(dir / std::string {name}); } } for (const auto &path : config_files) { enable_acl_inheritance(path); } // Re-harden the private credentials directory (host TLS key) so that re-enabling // inheritance above cannot cascade a Users:(RX) ACE into it, and so an upgrade // that skipped the installer's icacls hardening is still secured at startup. for (const auto &dir : config_roots) { (void) secure_private_directory((dir / "credentials").string()); } #endif } void migrate_recent_state_keys() { std::call_once(migration_once, [] { const fs::path old_path = sunshine_state_path(); const fs::path new_path = vibeshine_state_path(); if (old_path.empty() || new_path.empty() || old_path == new_path) { return; } std::lock_guard guard(state_mutex()); pt::ptree old_tree; // Recover the primary before moving shared keys, without quarantining it // or ever turning malformed host state into an empty metadata snapshot. const auto old_load_result = load_tree_for_update(old_path, old_tree); if (old_load_result == policy::load_result_e::failed) { return; } pt::ptree new_tree; const auto new_load_result = load_tree_for_read(new_path, new_tree); if (new_load_result == policy::load_result_e::failed) { return; } bool old_modified = false; bool new_modified = false; if (old_load_result == policy::load_result_e::loaded) { auto old_root_it = old_tree.find("root"); if (old_root_it != old_tree.not_found()) { auto &old_root = old_root_it->second; auto move_child = [&](const std::string &key) { auto child_it = old_root.find(key); if (child_it == old_root.not_found()) { return; } auto &new_root = ensure_root(new_tree); if (new_root.find(key) == new_root.not_found()) { new_root.put_child(key, child_it->second); new_modified = true; } old_root.erase(old_root.to_iterator(child_it)); old_modified = true; }; move_child("api_tokens"); move_child("session_tokens"); if (auto last = old_root.get_optional("last_notified_version")) { auto &new_root = ensure_root(new_tree); if (!new_root.get_optional("last_notified_version")) { new_root.put("last_notified_version", *last); new_modified = true; } old_root.erase("last_notified_version"); old_modified = true; } } } if (new_modified) { try { write_tree(new_path, new_tree); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to write "sv << new_path.string() << ": "sv << e.what(); } } if (old_modified) { try { write_tree(old_path, old_tree); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to write "sv << old_path.string() << ": "sv << e.what(); } } }); } bool share_state_file() { const auto &sunshine = sunshine_state_path(); const auto &vibeshine = vibeshine_state_path(); if (sunshine.empty() || vibeshine.empty()) { return false; } if (sunshine == vibeshine) { return true; } try { const fs::path sunshine_path {sunshine}; const fs::path vibeshine_path {vibeshine}; if ( fs::exists(sunshine_path) && fs::exists(vibeshine_path) && fs::equivalent(sunshine_path, vibeshine_path) ) { return true; } #ifdef _WIN32 auto normalize_case = [](std::wstring value) { std::transform(value.begin(), value.end(), value.begin(), [](wchar_t ch) { return static_cast(std::towlower(ch)); }); return value; }; auto normalized_sunshine = normalize_case(sunshine_path.lexically_normal().native()); auto normalized_vibeshine = normalize_case(vibeshine_path.lexically_normal().native()); if (normalized_sunshine == normalized_vibeshine) { return true; } #else if (sunshine_path.lexically_normal() == vibeshine_path.lexically_normal()) { return true; } #endif } catch (const std::exception &) { } return false; } void save_snapshot_exclude_devices(const std::vector &devices) { migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { BOOST_LOG(warning) << "statefile: cannot save snapshot exclusions - vibeshine state path is empty"; return; } std::lock_guard guard(state_mutex()); const fs::path path(path_str); pt::ptree root; if (load_tree_for_update(path, root) == policy::load_result_e::failed) { return; } // Build the exclusion list as a property tree array pt::ptree exclusions_pt; for (const auto &device_id : devices) { if (!device_id.empty()) { pt::ptree item; item.put_value(device_id); exclusions_pt.push_back({"", item}); } } auto &root_node = ensure_root(root); root_node.put_child("snapshot_exclude_devices", exclusions_pt); try { write_tree(path, root); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to write "sv << path.string() << ": "sv << e.what(); return; } BOOST_LOG(info) << "statefile: persisted " << devices.size() << " snapshot exclusion device(s) to vibeshine state"; } std::vector load_snapshot_exclude_devices() { migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return {}; } std::lock_guard guard(state_mutex()); const fs::path path(path_str); pt::ptree root; if (!load_tree_if_exists(path, root)) { return {}; } std::vector devices; try { auto root_node_opt = root.get_child_optional("root"); if (!root_node_opt) { return {}; } auto exclusions_opt = root_node_opt->get_child_optional("snapshot_exclude_devices"); if (!exclusions_opt) { return {}; } for (const auto &item : *exclusions_opt) { const auto device_id = item.second.get_value(""); if (!device_id.empty()) { devices.push_back(device_id); } } } catch (const std::exception &e) { BOOST_LOG(warning) << "statefile: failed to parse snapshot exclusions: " << e.what(); } return devices; } void remember_virtual_display_device(const std::string &device_id) { if (device_id.empty()) { return; } migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return; } std::lock_guard guard(state_mutex()); const fs::path path(path_str); pt::ptree root; if (load_tree_for_update(path, root) == policy::load_result_e::failed) { return; } auto &root_node = ensure_root(root); std::vector devices; if (auto devices_opt = root_node.get_child_optional("virtual_display_devices")) { for (const auto &item : *devices_opt) { const auto id = item.second.get_value(""); if (!id.empty()) { devices.push_back(id); } } } const auto ascii_lower = [](char ch) { return (ch >= 'A' && ch <= 'Z') ? static_cast(ch - 'A' + 'a') : ch; }; const auto equals_ci = [&](const std::string &lhs, const std::string &rhs) { return lhs.size() == rhs.size() && std::equal(lhs.begin(), lhs.end(), rhs.begin(), [&](char a, char b) { return ascii_lower(a) == ascii_lower(b); }); }; for (const auto &existing : devices) { if (equals_ci(existing, device_id)) { return; // already remembered; avoid rewriting the state file } } devices.push_back(device_id); constexpr size_t kMaxRememberedVirtualDisplays = 16; if (devices.size() > kMaxRememberedVirtualDisplays) { devices.erase(devices.begin(), devices.end() - kMaxRememberedVirtualDisplays); } pt::ptree devices_pt; for (const auto &id : devices) { pt::ptree item; item.put_value(id); devices_pt.push_back({"", item}); } root_node.put_child("virtual_display_devices", devices_pt); try { write_tree(path, root); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to write "sv << path.string() << ": "sv << e.what(); return; } BOOST_LOG(info) << "statefile: remembered virtual display device " << device_id << " (" << devices.size() << " total) in vibeshine state"; } std::vector load_virtual_display_devices() { migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return {}; } std::lock_guard guard(state_mutex()); const fs::path path(path_str); pt::ptree root; if (!load_tree_if_exists(path, root)) { return {}; } std::vector devices; try { auto root_node_opt = root.get_child_optional("root"); if (!root_node_opt) { return {}; } auto devices_opt = root_node_opt->get_child_optional("virtual_display_devices"); if (!devices_opt) { return {}; } for (const auto &item : *devices_opt) { const auto device_id = item.second.get_value(""); if (!device_id.empty()) { devices.push_back(device_id); } } } catch (const std::exception &e) { BOOST_LOG(warning) << "statefile: failed to parse virtual display devices: " << e.what(); } return devices; } void save_display_helper_engine(const std::string &engine) { if (engine.empty()) { return; } migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return; } std::lock_guard guard(state_mutex()); const fs::path path(path_str); pt::ptree root; if (load_tree_for_update(path, root) == policy::load_result_e::failed) { return; } auto &root_node = ensure_root(root); if (root_node.get("display_helper_engine", "") == engine) { return; // unchanged; avoid rewriting the state file } root_node.put("display_helper_engine", engine); try { write_tree(path, root); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to write "sv << path.string() << ": "sv << e.what(); return; } BOOST_LOG(info) << "statefile: persisted display helper engine '" << engine << "' to vibeshine state"; } namespace { constexpr std::size_t kMaxVirtualDisplayScales = 32; bool valid_virtual_display_scale(const double scale) { return std::isfinite(scale) && scale >= 0.25 && scale <= 5.0; } } // namespace void save_virtual_display_scale(const std::string &identity, const double scale) { if (identity.empty() || !valid_virtual_display_scale(scale)) { return; } migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return; } std::lock_guard guard(state_mutex()); const fs::path path(path_str); pt::ptree root; if (load_tree_for_update(path, root) == policy::load_result_e::failed) { return; } auto &root_node = ensure_root(root); std::vector> scales; if (auto scales_node = root_node.get_child_optional("virtual_display_scales")) { for (const auto &item : *scales_node) { const auto saved_identity = item.second.get_optional("identity"); const auto saved_scale = item.second.get_optional("scale"); if (saved_identity && saved_scale && !saved_identity->empty() && valid_virtual_display_scale(*saved_scale) && *saved_identity != identity) { scales.emplace_back(*saved_identity, *saved_scale); } } } scales.emplace_back(identity, scale); if (scales.size() > kMaxVirtualDisplayScales) { scales.erase(scales.begin(), scales.end() - kMaxVirtualDisplayScales); } pt::ptree scales_node; for (const auto &[saved_identity, saved_scale] : scales) { pt::ptree item; item.put("identity", saved_identity); item.put("scale", saved_scale); scales_node.push_back({"", item}); } root_node.put_child("virtual_display_scales", scales_node); try { write_tree(path, root); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to persist virtual display scale: " << e.what(); } } std::optional load_virtual_display_scale(const std::string &identity) { if (identity.empty()) { return std::nullopt; } migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return std::nullopt; } std::lock_guard guard(state_mutex()); pt::ptree root; if (!load_tree_if_exists(fs::path(path_str), root)) { return std::nullopt; } try { const auto scales_node = root.get_child_optional("root.virtual_display_scales"); if (!scales_node) { return std::nullopt; } for (auto item = scales_node->rbegin(); item != scales_node->rend(); ++item) { const auto saved_identity = item->second.get_optional("identity"); if (!saved_identity || *saved_identity != identity) { continue; } const auto scale = item->second.get_optional("scale"); return scale && valid_virtual_display_scale(*scale) ? std::make_optional(*scale) : std::nullopt; } } catch (const std::exception &e) { BOOST_LOG(warning) << "statefile: failed to read virtual display scale: " << e.what(); } return std::nullopt; } void clear_virtual_display_scales() { migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return; } std::lock_guard guard(state_mutex()); const fs::path path(path_str); pt::ptree root; if (load_tree_for_update(path, root) == policy::load_result_e::failed) { return; } auto &root_node = ensure_root(root); root_node.erase("virtual_display_scales"); try { write_tree(path, root); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to clear virtual display scales: " << e.what(); } } namespace { constexpr std::size_t kMaxVirtualDisplayPositions = 32; // A virtual desktop coordinate is an int, but nothing sane lives past this and // a corrupt file should not be able to park a display a billion pixels away. bool valid_virtual_display_position(const int x, const int y) { constexpr int kLimit = 100000; return std::abs(x) <= kLimit && std::abs(y) <= kLimit; } } // namespace void save_virtual_display_position(const std::string &identity, const int x, const int y) { if (identity.empty() || !valid_virtual_display_position(x, y)) { return; } migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return; } std::lock_guard guard(state_mutex()); const fs::path path(path_str); pt::ptree root; if (load_tree_for_update(path, root) == policy::load_result_e::failed) { return; } auto &root_node = ensure_root(root); std::vector> positions; if (auto positions_node = root_node.get_child_optional("virtual_display_positions")) { for (const auto &item : *positions_node) { const auto saved_identity = item.second.get_optional("identity"); const auto saved_x = item.second.get_optional("x"); const auto saved_y = item.second.get_optional("y"); if (saved_identity && saved_x && saved_y && !saved_identity->empty() && valid_virtual_display_position(*saved_x, *saved_y) && *saved_identity != identity) { positions.emplace_back(*saved_identity, *saved_x, *saved_y); } } } positions.emplace_back(identity, x, y); if (positions.size() > kMaxVirtualDisplayPositions) { positions.erase(positions.begin(), positions.end() - kMaxVirtualDisplayPositions); } pt::ptree positions_node; for (const auto &[saved_identity, saved_x, saved_y] : positions) { pt::ptree item; item.put("identity", saved_identity); item.put("x", saved_x); item.put("y", saved_y); positions_node.push_back({"", item}); } root_node.put_child("virtual_display_positions", positions_node); try { write_tree(path, root); } catch (const std::exception &e) { BOOST_LOG(error) << "statefile: failed to persist virtual display position: " << e.what(); return; } BOOST_LOG(info) << "statefile: remembered virtual display position (" << x << ", " << y << ") for " << identity; } std::optional load_virtual_display_position(const std::string &identity) { if (identity.empty()) { return std::nullopt; } migrate_recent_state_keys(); const auto &path_str = vibeshine_state_path(); if (path_str.empty()) { return std::nullopt; } std::lock_guard guard(state_mutex()); pt::ptree root; if (!load_tree_if_exists(fs::path(path_str), root)) { return std::nullopt; } try { const auto positions_node = root.get_child_optional("root.virtual_display_positions"); if (!positions_node) { return std::nullopt; } for (auto item = positions_node->rbegin(); item != positions_node->rend(); ++item) { const auto saved_identity = item->second.get_optional("identity"); if (!saved_identity || *saved_identity != identity) { continue; } const auto x = item->second.get_optional("x"); const auto y = item->second.get_optional("y"); if (x && y && valid_virtual_display_position(*x, *y)) { return virtual_display_position_t {*x, *y}; } return std::nullopt; } } catch (const std::exception &e) { BOOST_LOG(warning) << "statefile: failed to read virtual display position: " << e.what(); } return std::nullopt; } } // namespace statefile