mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-26 09:16:42 +01:00
Keep the game off EA's online services
Skate.exe contacts EA on every launch, offline mode included: error and session reports (collector.errors.ea.com), SDK telemetry (freeform-river.data.ea.com) and remote configuration (gcs.ea.com). - Block the game's lookups of ea.com and tnt-ea.com names inside the process (getaddrinfo and the other Winsock resolvers). Installed at bootstrap, and fails closed like the EA app launch block - Turn EA's error reporting (EadpErrorsData) off in game, next to the existing DingoTelemetry settings; the game ignores them on its command line - Leave EA's image CDN (dingo-dev-assets.akamaized.net) reachable for the fast-travel artwork, which only downloads images - Add a hostname test, contrib/ea-watch.ps1 to check the traffic, and a short note in the README's privacy section Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2cdc5ee8df
commit
e3ec512878
9 files changed
+821
-2
No files matched your search
@@ -172,6 +172,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
|
||||
add_test(NAME multiplayer_settings_lock COMMAND dingosdk_settings_lock_tests)
|
||||
add_executable(dingosdk_held_input_tests Extension/UI/Overlay/Test/held_input_tests.cpp)
|
||||
add_test(NAME overlay_held_input COMMAND dingosdk_held_input_tests)
|
||||
add_executable(dingosdk_ea_service_block_tests Extension/Boot/Test/ea_service_block_tests.cpp)
|
||||
target_include_directories(dingosdk_ea_service_block_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME ea_service_block COMMAND dingosdk_ea_service_block_tests)
|
||||
endif()
|
||||
# The route and logging tests live in the local, untracked tests/ folder.
|
||||
option(DINGOSDK_BUILD_ROUTE_TESTS "Build native route cycle regression tests" OFF)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "Extension/Boot/ea_service_block.h"
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
using namespace dingosdk;
|
||||
void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); }
|
||||
bool blocked(std::string_view host) { return ea_service_host(host); }
|
||||
bool blocked(std::wstring_view host) { return ea_service_host(host); }
|
||||
static_assert(ea_service_host(std::string_view("collector.errors.ea.com")), "The match is usable at compile time");
|
||||
int main() {
|
||||
try {
|
||||
// Every EA service the game contacted in a recorded session, and a sample of the others it names.
|
||||
for (const auto* host : {"collector.errors.ea.com", "a-collector.errors.ea.com", "freeform-river.data.ea.com",
|
||||
"gcs.ea.com", "pin-river-grpc.data.ea.com", "pin-em.data.ea.com", "spring25.client.blazeredirector.ea.com",
|
||||
"spring18.gosredirector.ea.com", "accounts.ea.com", "gateway.grpc.ea.com", "experimentation-grpc.data.ea.com",
|
||||
"adtech-runtime-rest-internal.data.ea.com", "river-grpc-lt.eadpdata.ddns.ea.com", "pn.tnt-ea.com",
|
||||
"rtm.tnt-ea.com", "ea.com", "tnt-ea.com"})
|
||||
check(blocked(host), std::string("EA service must be blocked: ") + host);
|
||||
check(blocked("COLLECTOR.Errors.EA.com"), "Host names compare without case");
|
||||
check(blocked("gcs.ea.com."), "A trailing root dot is the same name");
|
||||
check(blocked(L"freeform-river.data.ea.com"), "Wide lookups are blocked too");
|
||||
check(blocked(L"GCS.EA.COM."), "Wide lookups compare without case and root dot");
|
||||
// What ReSkate and the game still need, and names that only look like EA's.
|
||||
for (const auto* host : {"dingo-dev-assets.akamaized.net", "api.reskate.dev", "api.github.com",
|
||||
"raw.githubusercontent.com", "thunderstore.io", "api.steampowered.com", "idea.com", "notea.com",
|
||||
"xtnt-ea.com", "ea.com.example.net", "ea.co", "eacom", "com", ".", "", "159.153.1.1"})
|
||||
check(!blocked(host), std::string("Host must stay reachable: ") + host);
|
||||
check(!blocked(L"dingo-dev-assets.akamaized.net"), "The fast-travel artwork CDN stays reachable");
|
||||
check(!blocked(L"gcs.ea.cöm"), "Characters outside ASCII never match a domain letter");
|
||||
check(!blocked("gcs.ea.com.."), "Only one root dot is stripped");
|
||||
std::cout << "EA service block checks passed.\n";
|
||||
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <Windows.h>
|
||||
#include "ea_service_block.h"
|
||||
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace {
|
||||
|
||||
using GetAddrInfoAFn = INT (WSAAPI*)(PCSTR, PCSTR, const ADDRINFOA*, PADDRINFOA*);
|
||||
using GetAddrInfoWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, const ADDRINFOW*, PADDRINFOW*);
|
||||
using GetAddrInfoExAFn = INT (WSAAPI*)(PCSTR, PCSTR, DWORD, LPGUID, const ADDRINFOEXA*, PADDRINFOEXA*, timeval*,
|
||||
LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE);
|
||||
using GetAddrInfoExWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, DWORD, LPGUID, const ADDRINFOEXW*, PADDRINFOEXW*, timeval*,
|
||||
LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE);
|
||||
using GetHostByNameFn = hostent* (WSAAPI*)(const char*);
|
||||
|
||||
std::atomic<GetAddrInfoAFn> original_get_addr_info_a{};
|
||||
std::atomic<GetAddrInfoWFn> original_get_addr_info_w{};
|
||||
std::atomic<GetAddrInfoExAFn> original_get_addr_info_ex_a{};
|
||||
std::atomic<GetAddrInfoExWFn> original_get_addr_info_ex_w{};
|
||||
std::atomic<GetHostByNameFn> original_get_host_by_name{};
|
||||
|
||||
// A refused name is logged once at info level; a service that cannot connect keeps retrying, so
|
||||
// repeats are logged at debug level only. Never destroyed, because lookups can still arrive while
|
||||
// the process exits.
|
||||
struct RefusedNames {
|
||||
std::mutex mutex;
|
||||
std::set<std::wstring> names;
|
||||
};
|
||||
RefusedNames& refused_names() {
|
||||
static auto* value = new RefusedNames;
|
||||
return *value;
|
||||
}
|
||||
|
||||
template<class Char>
|
||||
bool refuse(const Char* name) noexcept {
|
||||
if (!name) return false;
|
||||
const std::basic_string_view<Char> host(name);
|
||||
if (!ea_service_host(host)) return false;
|
||||
try {
|
||||
std::wstring wide;
|
||||
wide.reserve(host.size());
|
||||
for (const auto ch : host) wide.push_back(static_cast<wchar_t>(static_cast<std::make_unsigned_t<Char>>(ch)));
|
||||
bool first{};
|
||||
{
|
||||
auto& refused = refused_names();
|
||||
const std::lock_guard lock(refused.mutex);
|
||||
first = refused.names.insert(wide).second;
|
||||
}
|
||||
if (first)
|
||||
logging::write(logging::Level::info, logging::Channel::runtime,
|
||||
L"Blocked the game's lookup of " + wide + L": ReSkate keeps it off EA's online services");
|
||||
else if (logging::enabled(logging::Level::debug))
|
||||
logging::write(logging::Level::debug, logging::Channel::runtime, L"Blocked the game's lookup of " + wide + L" again");
|
||||
} catch (...) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
// What the resolver itself answers for a name that does not exist.
|
||||
template<class Result>
|
||||
INT not_found(Result* result) noexcept {
|
||||
if (result) *result = nullptr;
|
||||
WSASetLastError(WSAHOST_NOT_FOUND);
|
||||
return WSAHOST_NOT_FOUND;
|
||||
}
|
||||
|
||||
INT WSAAPI get_addr_info_a(PCSTR name, PCSTR service, const ADDRINFOA* hints, PADDRINFOA* result) {
|
||||
if (refuse(name)) return not_found(result);
|
||||
return original_get_addr_info_a.load()(name, service, hints, result);
|
||||
}
|
||||
|
||||
INT WSAAPI get_addr_info_w(PCWSTR name, PCWSTR service, const ADDRINFOW* hints, PADDRINFOW* result) {
|
||||
if (refuse(name)) return not_found(result);
|
||||
return original_get_addr_info_w.load()(name, service, hints, result);
|
||||
}
|
||||
|
||||
// Failing before the lookup starts is a valid answer for an overlapped call too: the completion
|
||||
// routine and event are only used once the function has returned WSA_IO_PENDING.
|
||||
INT WSAAPI get_addr_info_ex_a(PCSTR name, PCSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXA* hints,
|
||||
PADDRINFOEXA* result, timeval* timeout, LPOVERLAPPED overlapped,
|
||||
LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) {
|
||||
if (refuse(name)) {
|
||||
if (handle) *handle = nullptr;
|
||||
return not_found(result);
|
||||
}
|
||||
return original_get_addr_info_ex_a.load()(name, service, name_space, provider, hints, result, timeout, overlapped,
|
||||
completion, handle);
|
||||
}
|
||||
|
||||
INT WSAAPI get_addr_info_ex_w(PCWSTR name, PCWSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXW* hints,
|
||||
PADDRINFOEXW* result, timeval* timeout, LPOVERLAPPED overlapped,
|
||||
LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) {
|
||||
if (refuse(name)) {
|
||||
if (handle) *handle = nullptr;
|
||||
return not_found(result);
|
||||
}
|
||||
return original_get_addr_info_ex_w.load()(name, service, name_space, provider, hints, result, timeout, overlapped,
|
||||
completion, handle);
|
||||
}
|
||||
|
||||
hostent* WSAAPI get_host_by_name(const char* name) {
|
||||
if (refuse(name)) {
|
||||
WSASetLastError(WSAHOST_NOT_FOUND);
|
||||
return nullptr;
|
||||
}
|
||||
return original_get_host_by_name.load()(name);
|
||||
}
|
||||
|
||||
template<class Fn>
|
||||
void hook(const char* name, void* replacement, std::atomic<Fn>& original) {
|
||||
const auto module = LoadLibraryExW(L"ws2_32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
|
||||
const auto target = module ? reinterpret_cast<void*>(GetProcAddress(module, name)) : nullptr;
|
||||
if (!target) throw std::runtime_error(std::string("Missing ") + name);
|
||||
void* relay{};
|
||||
const auto create = hook_prepare(target, replacement, &relay);
|
||||
if (create != HookOk) throw std::runtime_error(std::string("Cannot hook ") + name + ": " + hook_status_string(create));
|
||||
original.store(reinterpret_cast<Fn>(relay));
|
||||
const auto enable = hook_enable(target);
|
||||
if (enable != HookOk) throw std::runtime_error(std::string("Cannot enable ") + name + ": " + hook_status_string(enable));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool start_ea_service_block(std::string& error) noexcept {
|
||||
try {
|
||||
// Skate.exe resolves names through getaddrinfo alone; the other entry points close the
|
||||
// same door for anything else loaded into the process.
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable: 4191)
|
||||
hook("getaddrinfo", reinterpret_cast<void*>(&get_addr_info_a), original_get_addr_info_a);
|
||||
hook("GetAddrInfoW", reinterpret_cast<void*>(&get_addr_info_w), original_get_addr_info_w);
|
||||
hook("GetAddrInfoExA", reinterpret_cast<void*>(&get_addr_info_ex_a), original_get_addr_info_ex_a);
|
||||
hook("GetAddrInfoExW", reinterpret_cast<void*>(&get_addr_info_ex_w), original_get_addr_info_ex_w);
|
||||
hook("gethostbyname", reinterpret_cast<void*>(&get_host_by_name), original_get_host_by_name);
|
||||
#pragma warning(pop)
|
||||
error.clear();
|
||||
return true;
|
||||
} catch (const std::exception& exception) {
|
||||
error = exception.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace dingosdk
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
namespace dingosdk {
|
||||
|
||||
// EA's online services, as Skate.exe names them. ea.com holds the Blaze redirectors, accounts and
|
||||
// gateway, telemetry (pin-*, freeform-river), error reports (collector.errors), remote
|
||||
// configuration (gcs), experimentation, leaderboards, social and ad tech; tnt-ea.com holds push
|
||||
// notifications and real-time messaging. Not listed on purpose: dingo-dev-assets.akamaized.net,
|
||||
// the CDN the fast-travel artwork comes from (Engine/Game/World/location_travel.h). It only serves
|
||||
// images, and the travel menu would lose its icons without it.
|
||||
inline constexpr std::array<std::string_view, 2> ea_service_domains{"ea.com", "tnt-ea.com"};
|
||||
|
||||
// Whether a host name is one of ea_service_domains or a name under one, ignoring ASCII case and a
|
||||
// trailing root dot. Address literals never match: Skate.exe reaches EA by name only.
|
||||
template<class Char>
|
||||
constexpr bool ea_service_host(std::basic_string_view<Char> host) noexcept {
|
||||
using Unit = std::make_unsigned_t<Char>;
|
||||
if (!host.empty() && host.back() == Char('.')) host.remove_suffix(1);
|
||||
for (const auto domain : ea_service_domains) {
|
||||
if (host.size() < domain.size()) continue;
|
||||
const auto start = host.size() - domain.size();
|
||||
if (start != 0 && host[start - 1] != Char('.')) continue;
|
||||
bool same = true;
|
||||
for (std::size_t i = 0; same && i < domain.size(); ++i) {
|
||||
auto unit = static_cast<Unit>(host[start + i]);
|
||||
if (unit >= Unit('A') && unit <= Unit('Z')) unit = static_cast<Unit>(unit + Unit('a' - 'A'));
|
||||
same = unit == static_cast<Unit>(domain[i]);
|
||||
}
|
||||
if (same) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Makes every name lookup inside Skate.exe for an EA service fail as if the name did not exist, so
|
||||
// the game cannot reach EA's servers: no error reports, telemetry or remote configuration. Always on.
|
||||
bool start_ea_service_block(std::string& error) noexcept;
|
||||
|
||||
} // namespace dingosdk
|
||||
@@ -238,6 +238,11 @@ login name.
|
||||
To turn it off, untick **Send crash reports** in the launcher's Settings (ADVANCED), or set the
|
||||
environment variable `RESKATE_CRASH_REPORTING=0`.
|
||||
|
||||
ReSkate also keeps the game off EA's online services: the game's own crash reports, telemetry and
|
||||
remote configuration requests are turned off and blocked. Fast-travel artwork still comes from EA's
|
||||
image CDN (`dingo-dev-assets.akamaized.net`), which only downloads images.
|
||||
[contrib/ea-watch.ps1](contrib/ea-watch.ps1) shows what the game connects to, if you want to check.
|
||||
|
||||
## Third-party code
|
||||
|
||||
ReSkate uses Dear ImGui, Microsoft Detours, RapidJSON, spdlog, SQLite, LZ4, Zstandard, miniz, bcdec and
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "Extension/Boot/steam_restart_guard.h"
|
||||
#include "Extension/Boot/user_data_redirect.h"
|
||||
#include "Extension/Boot/ea_app_block.h"
|
||||
#include "Extension/Boot/ea_service_block.h"
|
||||
#include "Extension/Assets/native_patch_support.h"
|
||||
#include "Extension/Assets/mod_layers.h"
|
||||
#include "Extension/Assets/loose_files.h"
|
||||
@@ -61,6 +62,8 @@ bool initialize_bootstrap(std::uintptr_t base) {
|
||||
if (!stage(Channel::runtime, "Separate game user data", ready, error)) return false;
|
||||
error.clear(); ready = start_ea_app_block(error);
|
||||
if (!stage(Channel::runtime, "EA app launch block", ready, error)) return false;
|
||||
error.clear(); ready = start_ea_service_block(error);
|
||||
if (!stage(Channel::runtime, "EA online services block", ready, error)) return false;
|
||||
error.clear(); ready = start_steam_restart_guard(error);
|
||||
return stage(Channel::runtime, launcher::offline_mode() ? "Offline Steam" : "Steam restart guard", ready, error);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ void refresh_interactive_model(std::uintptr_t client, DWORD state, DWORD game_ty
|
||||
// Engine settings that cost simulation time for nothing here, set once through the named
|
||||
// setter (retried until the settings registry answers):
|
||||
// - telemetry and the performance tracker only feed EA's backend, absent offline;
|
||||
// - EA's error reporting (EadpErrorsData) only builds reports for EA's servers, which
|
||||
// ea_service_block keeps unreachable. Set here because the same values given on the game's
|
||||
// command line do not take;
|
||||
// - the world-transform update, which grows with every remote player's entities, is split
|
||||
// over more jobs than Game.cfg's 2.
|
||||
void apply_performance_settings() {
|
||||
@@ -104,11 +107,14 @@ void apply_performance_settings() {
|
||||
next_attempt = now + 500;
|
||||
try {
|
||||
const auto jobs = std::to_string(std::clamp(std::thread::hardware_concurrency() / 2, 2u, 8u));
|
||||
const std::array<std::pair<const char*, std::string>, 7> settings{{
|
||||
const std::array<std::pair<const char*, std::string>, 10> settings{{
|
||||
{"DingoTelemetry.Enable", "0"},
|
||||
{"DingoTelemetry.EnablePlayerTickEvents", "0"},
|
||||
{"DingoTelemetry.EnablePerformanceEvents", "0"},
|
||||
{"DingoTelemetry.EnableCPUBenchmark", "0"},
|
||||
{"EadpErrorsData.DisableAllEventsReporting", "1"},
|
||||
{"EadpErrorsData.EnableReportCrashes", "0"},
|
||||
{"EadpErrorsData.AutoSendEventFromPersistence", "0"},
|
||||
{"PerformanceTracker.Enabled", "0"},
|
||||
{"PerformanceTracker.JuiceLogPerformance", "0"},
|
||||
{"EcsWorldTransform.ParallelWorldTransformUpdateJobCount", jobs},
|
||||
|
||||
+2
-1
@@ -173,7 +173,8 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/Progression/fixed_stop_entitlement_observation.cpp
|
||||
Extension/Progression/neighborhood_unlock_override.cpp
|
||||
Extension/Boot/user_data_redirect.cpp
|
||||
Extension/Boot/ea_app_block.cpp)
|
||||
Extension/Boot/ea_app_block.cpp
|
||||
Extension/Boot/ea_service_block.cpp)
|
||||
|
||||
target_link_libraries(dingosdk_runtime PRIVATE dingosdk_logging dingosdk_profiler dingosdk_supported_build dingosdk_overlay dingosdk_console_core dingosdk_steam_restart_guard
|
||||
dingosdk_startup_interventions dingosdk_fast_travel_unlock dingosdk_local_profile dingosdk_initfs dingosdk_mods dingosdk_startup_window dingosdk_custom_scripts dingosdk_world_layer_scan bcrypt
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Records which programs on this PC look up and talk to EA's servers, so you can check what
|
||||
skate. and ReSkate send to EA.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses two event-tracing providers built into Windows; nothing is installed and the game is not
|
||||
touched:
|
||||
Microsoft-Windows-DNS-Client every DNS lookup, with the program that made it
|
||||
Microsoft-Windows-Kernel-Network every TCP/UDP connect, send and receive, with the program,
|
||||
the remote address and the size in bytes
|
||||
The traffic itself is encrypted; the report shows which EA hosts were looked up, by which
|
||||
program, and how many bytes went to and came from them.
|
||||
|
||||
The game keeps the fast-travel artwork it downloads in its own cache and only fetches it from
|
||||
EA's image CDN when it is missing. To see those downloads, close the game and rename
|
||||
%LOCALAPPDATA%\ReSkate\Game\Skate\data\cache\http before recording; the game makes a new one.
|
||||
The report also warns about hosts-file entries and Windows Firewall rules that would hide or
|
||||
change what the game sends.
|
||||
|
||||
Each recording gets its own folder, %LOCALAPPDATA%\ReSkate\ea-watch\<date>-<time>, holding the
|
||||
trace (ea-watch.etl), the report (ea-watch-report.txt) and CSV files with every lookup and
|
||||
connection. These list your IP addresses and the programs you ran, so read them before sharing.
|
||||
|
||||
Recording needs an administrator PowerShell, because Windows only lets administrators start an
|
||||
event trace. Analysing a recording again (-Analyze) does not.
|
||||
|
||||
Run it with Windows PowerShell (powershell.exe), which every Windows 10 and 11 PC has.
|
||||
-ExecutionPolicy Bypass in the examples applies to that one command only.
|
||||
|
||||
.PARAMETER Analyze
|
||||
Reads a recording again instead of making a new one: the newest one, or the folder in -OutDir.
|
||||
|
||||
.PARAMETER OutDir
|
||||
The folder to record into or analyse. Defaults to a new folder per recording (see above).
|
||||
|
||||
.PARAMETER MaxMinutes
|
||||
Stops the recording by itself after this many minutes (default 30).
|
||||
|
||||
.PARAMETER ReverseDns
|
||||
Names addresses no recorded lookup explains through reverse DNS. Each unanswered query waits
|
||||
for a timeout, so with many such addresses this takes minutes.
|
||||
|
||||
.PARAMETER MaxEvents
|
||||
Reads only this many trace events (0, the default, reads all); for timing a large trace.
|
||||
|
||||
.EXAMPLE
|
||||
# 1. Open Windows PowerShell as administrator (Start, type PowerShell, Run as administrator).
|
||||
# 2. Start recording, from the folder this script is in:
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\ea-watch.ps1
|
||||
# 3. Start ReSkate from its launcher, play as usual, and quit with Quit Game.
|
||||
# 4. Press Enter in the recording window. The report is printed and saved.
|
||||
|
||||
.EXAMPLE
|
||||
# Read the newest recording again (no administrator rights needed):
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\ea-watch.ps1 -Analyze
|
||||
#>
|
||||
#Requires -PSEdition Desktop
|
||||
|
||||
param(
|
||||
[switch]$Analyze,
|
||||
[string]$OutDir,
|
||||
[int]$MaxMinutes = 30,
|
||||
[switch]$ReverseDns,
|
||||
[long]$MaxEvents = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$runsRoot = Join-Path $env:LOCALAPPDATA 'ReSkate\ea-watch'
|
||||
if (-not $OutDir) {
|
||||
if ($Analyze) {
|
||||
$latest = Get-ChildItem -LiteralPath $runsRoot -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'ea-watch.etl') } |
|
||||
Sort-Object Name -Descending | Select-Object -First 1
|
||||
if (-not $latest) { throw "No recording found under $runsRoot. Record one first, or pass -OutDir." }
|
||||
$OutDir = $latest.FullName
|
||||
} else {
|
||||
$OutDir = Join-Path $runsRoot (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||
}
|
||||
}
|
||||
|
||||
$session = 'ReSkateEaWatch'
|
||||
$etlPath = Join-Path $OutDir 'ea-watch.etl'
|
||||
$stopPath = Join-Path $OutDir 'ea-watch.stop'
|
||||
$procPath = Join-Path $OutDir 'ea-watch-processes.csv'
|
||||
$statsPath = Join-Path $OutDir 'ea-watch-session.txt'
|
||||
$envPath = Join-Path $OutDir 'ea-watch-environment.txt'
|
||||
$reportPath = Join-Path $OutDir 'ea-watch-report.txt'
|
||||
$dnsCsvPath = Join-Path $OutDir 'ea-watch-dns.csv'
|
||||
$flowCsvPath = Join-Path $OutDir 'ea-watch-flows.csv'
|
||||
|
||||
# Programs whose whole network activity is listed, EA or not.
|
||||
$focusProcesses = @('Skate', 'ReSkateLauncher')
|
||||
|
||||
# 'service' for EA's online services, 'cdn' for the image CDN the fast-travel artwork comes from
|
||||
# (ReSkate still uses it, for downloads only), nothing for other hosts.
|
||||
function Get-EaKind([string]$Name) {
|
||||
if (-not $Name) { return $null }
|
||||
$Name = $Name.TrimEnd('.').ToLowerInvariant()
|
||||
if ($Name -match '(^|\.)(ea\.com|ea\.net|eadp\.com|origin\.com|tnt-ea\.com|easports\.com|eaplay\.com)$') { return 'service' }
|
||||
if ($Name -match '^dingo-[a-z0-9-]*\.akamaized\.net$') { return 'cdn' }
|
||||
return $null
|
||||
}
|
||||
|
||||
# 159.153.0.0/16 is Electronic Arts' own address block (AS3402). EA services hosted on AWS or a
|
||||
# CDN are only recognisable by the names they were looked up under.
|
||||
function Test-EaAddress([string]$Address) {
|
||||
return $Address -match '^159\.153\.'
|
||||
}
|
||||
|
||||
# Private, link-local, multicast and broadcast addresses: traffic that never leaves the local network.
|
||||
function Test-LocalNetworkAddress([string]$Address) {
|
||||
return $Address -match '^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|22[4-9]\.|23\d\.|255\.255\.255\.255$|fe[89ab][0-9a-f]:|ff[0-9a-f]{2}:|f[cd][0-9a-f]{2}:)'
|
||||
}
|
||||
|
||||
# Reading the trace is done in compiled code: PowerShell takes minutes over ~100k events.
|
||||
Add-Type -ReferencedAssemblies 'System.Core' -TypeDefinition @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
public sealed class TraceFlow
|
||||
{
|
||||
public int ProcessId;
|
||||
public string Process;
|
||||
public string Protocol;
|
||||
public string Address;
|
||||
public int Port;
|
||||
public int Connects;
|
||||
public long SentBytes;
|
||||
public int Sends;
|
||||
public long ReceivedBytes;
|
||||
public int Receives;
|
||||
public DateTime First;
|
||||
public DateTime Last;
|
||||
}
|
||||
|
||||
public sealed class TraceLookup
|
||||
{
|
||||
public DateTime Time;
|
||||
public int Event;
|
||||
public int ProcessId;
|
||||
public string Process;
|
||||
public string Name;
|
||||
public string Type;
|
||||
public string Status;
|
||||
public string Results;
|
||||
}
|
||||
|
||||
public sealed class TraceResult
|
||||
{
|
||||
public readonly List<TraceFlow> Flows = new List<TraceFlow>();
|
||||
public readonly List<TraceLookup> Lookups = new List<TraceLookup>();
|
||||
public readonly Dictionary<string, HashSet<string>> NamesByAddress =
|
||||
new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
public long Events, NetworkEvents, LoopbackEvents, DnsEvents;
|
||||
public DateTime First, Last;
|
||||
public double Seconds;
|
||||
}
|
||||
|
||||
public static class TraceAnalysis
|
||||
{
|
||||
static readonly Guid KernelNetwork = new Guid("7DD42A49-5329-4832-8DFD-43D979153A88");
|
||||
static readonly Guid DnsClient = new Guid("1C95126E-7EEA-49A9-A3FE-A378B03DDB4D");
|
||||
static readonly Regex DataPattern = new Regex("<Data Name=['\"](\\w+)['\"]>([^<]*)</Data>", RegexOptions.Compiled);
|
||||
static readonly Regex AliasPattern = new Regex(@"^type:\s*\d+\s+(\S+)$", RegexOptions.Compiled);
|
||||
|
||||
// Kernel-Network event ids: 10/26 TCP sent, 11/27 TCP received, 12/28 TCP connect attempted,
|
||||
// 42/58 UDP sent, 43/59 UDP received (IPv4/IPv6). Their payload starts with
|
||||
// PID, size, daddr (the remote end), saddr, dport, sport.
|
||||
static int Kind(int id)
|
||||
{
|
||||
switch (id)
|
||||
{
|
||||
case 10: case 26: case 42: case 58: return 1;
|
||||
case 11: case 27: case 43: case 59: return 2;
|
||||
case 12: case 28: return 3;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// IPv4 arrives as the four address bytes read as one little-endian number
|
||||
// (4211081440 = E0 00 00 FB backwards = 224.0.0.251); IPv6 as 16 bytes.
|
||||
static IPAddress Address(object value)
|
||||
{
|
||||
if (value is uint) return new IPAddress((long)(uint)value);
|
||||
if (value is int) return new IPAddress((long)(uint)(int)value);
|
||||
var bytes = value as byte[];
|
||||
if (bytes != null && (bytes.Length == 4 || bytes.Length == 16)) return new IPAddress(bytes);
|
||||
var text = value == null ? "" : value.ToString().Trim();
|
||||
uint number;
|
||||
if (uint.TryParse(text, out number)) return new IPAddress((long)number);
|
||||
if (text.Length == 32)
|
||||
{
|
||||
var parsed = new byte[16];
|
||||
for (int i = 0; i < 16; i++) parsed[i] = Convert.ToByte(text.Substring(i * 2, 2), 16);
|
||||
return new IPAddress(parsed);
|
||||
}
|
||||
IPAddress address;
|
||||
return IPAddress.TryParse(text, out address) ? address : null;
|
||||
}
|
||||
|
||||
// Ports arrive in network byte order: 47873 is 0xBB01, port 0x01BB = 443.
|
||||
static int Port(object value)
|
||||
{
|
||||
int raw = Convert.ToInt32(value);
|
||||
return ((raw & 0xFF) << 8) | ((raw >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
static string Text(Dictionary<string, string> data, string name)
|
||||
{
|
||||
string value;
|
||||
return data.TryGetValue(name, out value) ? value : null;
|
||||
}
|
||||
|
||||
public static TraceResult Run(string path, long maxEvents)
|
||||
{
|
||||
var watch = Stopwatch.StartNew();
|
||||
var result = new TraceResult();
|
||||
var flows = new Dictionary<string, TraceFlow>();
|
||||
using (var reader = new EventLogReader(new EventLogQuery(path, PathType.FilePath)))
|
||||
{
|
||||
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
if (maxEvents > 0 && result.Events >= maxEvents) break;
|
||||
result.Events++;
|
||||
var time = record.TimeCreated ?? DateTime.MinValue;
|
||||
if (result.Events == 1) result.First = time;
|
||||
result.Last = time;
|
||||
var provider = record.ProviderId ?? Guid.Empty;
|
||||
|
||||
if (provider == KernelNetwork)
|
||||
{
|
||||
int kind = Kind(record.Id);
|
||||
if (kind == 0) continue;
|
||||
var properties = record.Properties;
|
||||
if (properties.Count < 6) continue;
|
||||
var address = Address(properties[2].Value);
|
||||
if (address == null) continue;
|
||||
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||
if (IPAddress.IsLoopback(address)) { result.LoopbackEvents++; continue; }
|
||||
result.NetworkEvents++;
|
||||
|
||||
int pid = Convert.ToInt32(properties[0].Value);
|
||||
long size = Convert.ToInt64(properties[1].Value);
|
||||
int port = Port(properties[4].Value);
|
||||
string protocol = record.Id < 40 ? "TCP" : "UDP";
|
||||
string text = address.ToString();
|
||||
string key = pid + "|" + protocol + "|" + text + "|" + port;
|
||||
TraceFlow flow;
|
||||
if (!flows.TryGetValue(key, out flow))
|
||||
{
|
||||
flow = new TraceFlow { ProcessId = pid, Protocol = protocol, Address = text, Port = port, First = time };
|
||||
flows[key] = flow;
|
||||
result.Flows.Add(flow);
|
||||
}
|
||||
flow.Last = time;
|
||||
if (kind == 1) { flow.SentBytes += size; flow.Sends++; }
|
||||
else if (kind == 2) { flow.ReceivedBytes += size; flow.Receives++; }
|
||||
else flow.Connects++;
|
||||
}
|
||||
else if (provider == DnsClient && (record.Id == 3006 || record.Id == 3008))
|
||||
{
|
||||
result.DnsEvents++;
|
||||
var data = new Dictionary<string, string>();
|
||||
foreach (Match m in DataPattern.Matches(record.ToXml())) data[m.Groups[1].Value] = m.Groups[2].Value;
|
||||
var name = Text(data, "QueryName");
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
var results = Text(data, "QueryResults");
|
||||
result.Lookups.Add(new TraceLookup {
|
||||
Time = time, Event = record.Id, ProcessId = record.ProcessId ?? 0, Name = name,
|
||||
Type = Text(data, "QueryType"), Status = Text(data, "QueryStatus"), Results = results });
|
||||
|
||||
// "type: 5 alias.example;::ffff:1.2.3.4;" - CNAME targets and addresses, ';'-separated.
|
||||
if (record.Id != 3008 || string.IsNullOrEmpty(results)) continue;
|
||||
var aliases = new List<string> { name };
|
||||
foreach (var raw in results.Split(';'))
|
||||
{
|
||||
var token = raw.Trim();
|
||||
if (token.Length == 0) continue;
|
||||
var alias = AliasPattern.Match(token);
|
||||
if (alias.Success) { aliases.Add(alias.Groups[1].Value); continue; }
|
||||
IPAddress answer;
|
||||
if (!IPAddress.TryParse(token, out answer)) continue;
|
||||
if (answer.IsIPv4MappedToIPv6) answer = answer.MapToIPv4();
|
||||
HashSet<string> names;
|
||||
if (!result.NamesByAddress.TryGetValue(answer.ToString(), out names))
|
||||
{
|
||||
names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
result.NamesByAddress[answer.ToString()] = names;
|
||||
}
|
||||
foreach (var n in aliases) names.Add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Seconds = watch.Elapsed.TotalSeconds;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
# logman reports its own failures through the exit code; its output is only shown on failure.
|
||||
function Invoke-Logman([string[]]$Arguments) {
|
||||
$previous = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try { $output = & logman @Arguments 2>&1 | Out-String } finally { $ErrorActionPreference = $previous }
|
||||
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output.Trim() }
|
||||
}
|
||||
|
||||
function Start-Recording {
|
||||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Recording needs an administrator PowerShell (Start, type PowerShell, Run as administrator).'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
[void](Invoke-Logman @('stop', $session, '-ets'))
|
||||
Remove-Item -LiteralPath $etlPath, $stopPath -ErrorAction SilentlyContinue
|
||||
|
||||
# What would make the report misleading: a hosts-file entry answers a lookup before DNS sees
|
||||
# it, and a firewall rule stops a connection before it opens (blocked UDP still shows as sent).
|
||||
$environment = New-Object System.Collections.Generic.List[string]
|
||||
$hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts'
|
||||
foreach ($line in Get-Content -LiteralPath $hostsPath -ErrorAction SilentlyContinue) {
|
||||
$entry = ($line -replace '#.*$', '').Trim()
|
||||
if ($entry -and @($entry -split '\s+' | Select-Object -Skip 1 | Where-Object { Get-EaKind $_ }).Count) {
|
||||
$environment.Add("The hosts file has '$entry', so lookups of that name are answered on this PC and its traffic does not show as it would without it.")
|
||||
}
|
||||
}
|
||||
try {
|
||||
foreach ($filter in Get-NetFirewallApplicationFilter -ErrorAction Stop | Where-Object { $_.Program -match '\\(Skate|ReSkateLauncher)\.exe$' }) {
|
||||
foreach ($rule in $filter | Get-NetFirewallRule | Where-Object { "$($_.Enabled)" -eq 'True' -and "$($_.Action)" -eq 'Block' }) {
|
||||
$environment.Add("Windows Firewall rule '$($rule.DisplayName)' blocks $("$($rule.Direction)".ToLowerInvariant()) traffic of $($filter.Program), so connections it stops do not show and what shows as sent may not have left this PC.")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$environment.Add("Windows Firewall rules could not be read ($($_.Exception.Message)), so rules that block the game were not checked.")
|
||||
}
|
||||
$environment | Set-Content -LiteralPath $envPath -Encoding utf8
|
||||
|
||||
# Lookups answered from the cache are still logged, but flushing makes every
|
||||
# name the game needs go through a fresh, visible lookup.
|
||||
Clear-DnsClientCache
|
||||
|
||||
# Flags and levels are quoted: unquoted, PowerShell would turn 0xffffffffffffffff into -1.
|
||||
$start = Invoke-Logman @('start', $session, '-p', 'Microsoft-Windows-Kernel-Network', '0x30', '5',
|
||||
'-o', $etlPath, '-bs', '1024', '-nb', '64', '512', '-ets')
|
||||
if ($start.ExitCode -ne 0) { throw "logman could not start the trace session: $($start.Output)" }
|
||||
$update = Invoke-Logman @('update', 'trace', $session, '-p', 'Microsoft-Windows-DNS-Client', '0xffffffffffffffff', '5', '-ets')
|
||||
if ($update.ExitCode -ne 0) {
|
||||
[void](Invoke-Logman @('stop', $session, '-ets'))
|
||||
throw "logman could not add the DNS client provider: $($update.Output)"
|
||||
}
|
||||
|
||||
$processes = @{}
|
||||
$started = Get-Date
|
||||
$deadline = $started.AddMinutes($MaxMinutes)
|
||||
Write-Host ''
|
||||
Write-Host "Recording since $($started.ToString('HH:mm:ss')) into $OutDir" -ForegroundColor Green
|
||||
Write-Host 'Start ReSkate from its launcher now, play, and quit with Quit Game.' -ForegroundColor Green
|
||||
Write-Host "Then press Enter here. It stops by itself after $MaxMinutes minutes." -ForegroundColor Green
|
||||
|
||||
$keysReadable = $true
|
||||
try {
|
||||
while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $stopPath)) {
|
||||
# Kernel-Network only gives process ids, so names are collected while processes live.
|
||||
foreach ($p in Get-Process) {
|
||||
$existing = $processes[$p.Id]
|
||||
if (-not $existing) { $processes[$p.Id] = $p.ProcessName }
|
||||
elseif (($existing -split '/') -notcontains $p.ProcessName) { $processes[$p.Id] = "$existing/$($p.ProcessName)" }
|
||||
}
|
||||
$enter = $false
|
||||
if ($keysReadable) {
|
||||
try {
|
||||
while ($Host.UI.RawUI.KeyAvailable) {
|
||||
$key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown,IncludeKeyUp')
|
||||
if ($key.KeyDown -and $key.VirtualKeyCode -eq 13) { $enter = $true }
|
||||
}
|
||||
} catch {
|
||||
# Hosts without a console (PowerShell ISE, redirected input) cannot report key presses.
|
||||
$keysReadable = $false
|
||||
Write-Host "This window cannot detect key presses ($($_.Exception.Message))." -ForegroundColor Yellow
|
||||
Write-Host "To stop, create the file $stopPath" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
if ($enter) { break }
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
} finally {
|
||||
(Invoke-Logman @('query', $session, '-ets')).Output | Set-Content -LiteralPath $statsPath -Encoding utf8
|
||||
$stop = Invoke-Logman @('stop', $session, '-ets')
|
||||
if ($stop.ExitCode -ne 0) { Write-Warning "logman could not stop the trace session: $($stop.Output)" }
|
||||
$processes.GetEnumerator() | ForEach-Object { [pscustomobject]@{ Id = $_.Key; Name = $_.Value } } |
|
||||
Export-Csv -LiteralPath $procPath -NoTypeInformation -Encoding utf8
|
||||
Remove-Item -LiteralPath $stopPath -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "Recording stopped at $((Get-Date).ToString('HH:mm:ss')). Analysing..." -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Invoke-Analysis {
|
||||
if (-not (Test-Path -LiteralPath $etlPath)) { throw "No trace at $etlPath." }
|
||||
|
||||
$processes = @{}
|
||||
if (Test-Path -LiteralPath $procPath) {
|
||||
Import-Csv -LiteralPath $procPath | ForEach-Object { $processes[[int]$_.Id] = $_.Name }
|
||||
}
|
||||
function Get-ProcessLabel([int]$Id) {
|
||||
$name = $processes[$Id]
|
||||
if ($name) { return "$name($Id)" }
|
||||
return "pid $Id (name not captured)"
|
||||
}
|
||||
|
||||
$result = [TraceAnalysis]::Run($etlPath, $MaxEvents)
|
||||
foreach ($f in $result.Flows) { $f.Process = Get-ProcessLabel $f.ProcessId }
|
||||
foreach ($l in $result.Lookups) { $l.Process = Get-ProcessLabel $l.ProcessId }
|
||||
|
||||
$namesByAddress = $result.NamesByAddress
|
||||
function Get-Names([string]$Address) {
|
||||
if ($Address -and $namesByAddress.ContainsKey($Address)) { return @($namesByAddress[$Address]) }
|
||||
return @()
|
||||
}
|
||||
# A flow counts as an EA service when any name its address was looked up under is one.
|
||||
function Get-FlowKind($Flow) {
|
||||
if (Test-EaAddress $Flow.Address) { return 'service' }
|
||||
$kinds = @(Get-Names $Flow.Address | ForEach-Object { Get-EaKind $_ })
|
||||
if ($kinds -contains 'service') { return 'service' }
|
||||
if ($kinds -contains 'cdn') { return 'cdn' }
|
||||
return $null
|
||||
}
|
||||
function Format-Flow($Flow) {
|
||||
$names = (Get-Names $Flow.Address) -join ', '
|
||||
if (-not $names) { $names = 'no lookup recorded' }
|
||||
'{0,-28} {1} {2}:{3} [{4}] connects {5}, sent {6:N0} B in {7}, received {8:N0} B in {9}, {10:HH:mm:ss.fff}-{11:HH:mm:ss.fff}' -f
|
||||
$Flow.Process, $Flow.Protocol, $Flow.Address, $Flow.Port, $names, $Flow.Connects,
|
||||
$Flow.SentBytes, $Flow.Sends, $Flow.ReceivedBytes, $Flow.Receives, $Flow.First, $Flow.Last
|
||||
}
|
||||
function Format-Targets($Flows) {
|
||||
($Flows | ForEach-Object {
|
||||
$n = (Get-Names $_.Address) -join '/'
|
||||
if (-not $n) { $n = $_.Address }
|
||||
"$n ($($_.Address):$($_.Port))"
|
||||
} | Select-Object -Unique) -join ', '
|
||||
}
|
||||
|
||||
$allFlows = @($result.Flows | Sort-Object First)
|
||||
# Windows itself (System, pid 4) sends NetBIOS name queries (UDP 137) to addresses this PC has
|
||||
# just connected to; they are not the game's traffic.
|
||||
$isNetBios = { $_.ProcessId -eq 4 -and $_.Protocol -eq 'UDP' -and $_.Port -eq 137 }
|
||||
$eaFlows = @($allFlows | Where-Object { Get-FlowKind $_ })
|
||||
$netBiosQueries = @($eaFlows | Where-Object $isNetBios).Count
|
||||
$eaFlows = @($eaFlows | Where-Object { -not (& $isNetBios) })
|
||||
$serviceFlows = @($eaFlows | Where-Object { (Get-FlowKind $_) -eq 'service' })
|
||||
$cdnFlows = @($eaFlows | Where-Object { (Get-FlowKind $_) -eq 'cdn' })
|
||||
# Tools that read the DNS cache through WMI (Get-DnsClientCache) show up as lookups by WmiPrvSE;
|
||||
# those are cache reads, not a program contacting the host.
|
||||
$isEaLookup = { $_.Event -eq 3006 -and (Get-EaKind $_.Name) }
|
||||
$cacheReaders = @($result.Lookups | Where-Object $isEaLookup | Where-Object { $_.Process -like 'WmiPrvSE(*' })
|
||||
$eaLookups = @($result.Lookups | Where-Object $isEaLookup | Where-Object { $_.Process -notlike 'WmiPrvSE(*' } | Sort-Object Time)
|
||||
$eaAnswers = @($result.Lookups | Where-Object { $_.Event -eq 3008 -and $_.Status -eq '0' -and (Get-EaKind $_.Name) })
|
||||
$focusIds = @($processes.GetEnumerator() | Where-Object { ($_.Value -split '/') | Where-Object { $focusProcesses -contains $_ } } | ForEach-Object { [int]$_.Key })
|
||||
$focusFlows = @($allFlows | Where-Object { $focusIds -contains $_.ProcessId })
|
||||
$focusListed = @($focusFlows | Where-Object { $_.Protocol -eq 'TCP' -or -not (Test-LocalNetworkAddress $_.Address) })
|
||||
$focusLocal = $focusFlows.Count - $focusListed.Count
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add('ReSkate EA traffic watch')
|
||||
$lines.Add(('Recorded {0:yyyy-MM-dd HH:mm:ss} to {1:HH:mm:ss}; {2:N0} events read in {3:N1} s ({4:N0} network, {5:N0} loopback network left out, {6:N0} DNS).' -f
|
||||
$result.First, $result.Last, $result.Events, $result.Seconds, $result.NetworkEvents, $result.LoopbackEvents, $result.DnsEvents))
|
||||
if (Test-Path -LiteralPath $statsPath) {
|
||||
$lost = Get-Content -LiteralPath $statsPath | Where-Object { $_ -match 'Lost' }
|
||||
if ($lost) { $lines.Add('Trace session: ' + (($lost | ForEach-Object { $_.Trim() }) -join '; ')) }
|
||||
}
|
||||
if ($MaxEvents -gt 0) { $lines.Add("NOTE: only the first $MaxEvents events were read (-MaxEvents).") }
|
||||
if ($result.NetworkEvents -eq 0) { $lines.Add('WARNING: no network events were recorded, so this capture cannot show traffic.') }
|
||||
if ($result.DnsEvents -eq 0) { $lines.Add('WARNING: no DNS events were recorded, so this capture cannot show lookups.') }
|
||||
if ($focusIds.Count -eq 0) { $lines.Add('WARNING: Skate.exe and ReSkateLauncher.exe were not running during the recording.') }
|
||||
if (Test-Path -LiteralPath $envPath) {
|
||||
Get-Content -LiteralPath $envPath | Where-Object { $_ } | ForEach-Object { $lines.Add("WARNING: $_") }
|
||||
}
|
||||
if ($netBiosQueries -gt 0) {
|
||||
$lines.Add("NOTE: $netBiosQueries NetBIOS name queries (UDP 137) that Windows itself sent to EA addresses are left out; they are not the game's traffic.")
|
||||
}
|
||||
|
||||
$lines.Add('')
|
||||
$lines.Add('== 1. EA hostnames looked up, and by which program ==')
|
||||
if ($cacheReaders.Count -gt 0) { $lines.Add("($($cacheReaders.Count) DNS cache reads through WMI (WmiPrvSE) left out.)") }
|
||||
if ($eaLookups.Count -eq 0) { $lines.Add('(none)') }
|
||||
foreach ($l in $eaLookups) {
|
||||
$answer = $eaAnswers | Where-Object { $_.Name -eq $l.Name -and $_.ProcessId -eq $l.ProcessId -and $_.Time -ge $l.Time } |
|
||||
Sort-Object Time | Select-Object -First 1
|
||||
$answerText = if ($answer) { $answer.Results } else { 'no answer recorded' }
|
||||
$lines.Add(('{0:HH:mm:ss.fff} {1,-28} {2} -> {3}' -f $l.Time, $l.Process, $l.Name, $answerText))
|
||||
}
|
||||
|
||||
$lines.Add('')
|
||||
$lines.Add('== 2. Traffic to or from EA services ==')
|
||||
if ($serviceFlows.Count -eq 0) { $lines.Add('(none)') }
|
||||
foreach ($f in $serviceFlows) { $lines.Add((Format-Flow $f)) }
|
||||
|
||||
$lines.Add('')
|
||||
$lines.Add("== 3. Traffic to or from EA's image CDN (fast-travel artwork downloads) ==")
|
||||
if ($cdnFlows.Count -eq 0) {
|
||||
$lines.Add('(none) The game only downloads artwork missing from its cache. To record these downloads, close the game')
|
||||
$lines.Add('and rename %LOCALAPPDATA%\ReSkate\Game\Skate\data\cache\http before recording.')
|
||||
}
|
||||
foreach ($f in $cdnFlows) { $lines.Add((Format-Flow $f)) }
|
||||
|
||||
$lines.Add('')
|
||||
$lines.Add('== 4. Verdict ==')
|
||||
$serviceSent = @($serviceFlows | Where-Object { $_.SentBytes -gt 0 })
|
||||
if ($serviceSent.Count -gt 0) {
|
||||
$lines.Add('DATA WAS SENT TO EA SERVICES:')
|
||||
foreach ($group in $serviceSent | Group-Object Process) {
|
||||
$bytes = ($group.Group | Measure-Object SentBytes -Sum).Sum
|
||||
$lines.Add((' {0} sent {1:N0} bytes to {2}' -f $group.Name, $bytes, (Format-Targets $group.Group)))
|
||||
}
|
||||
} elseif (@($eaLookups | Where-Object { (Get-EaKind $_.Name) -eq 'service' }).Count -gt 0) {
|
||||
$lines.Add('EA service hostnames were looked up, but no data was sent to their addresses.')
|
||||
} else {
|
||||
$lines.Add('No EA service was looked up or contacted.')
|
||||
}
|
||||
foreach ($group in $cdnFlows | Group-Object Process) {
|
||||
$sent = ($group.Group | Measure-Object SentBytes -Sum).Sum
|
||||
$received = ($group.Group | Measure-Object ReceivedBytes -Sum).Sum
|
||||
$lines.Add(('CDN downloads: {0} sent {1:N0} bytes of requests and received {2:N0} bytes from {3}' -f
|
||||
$group.Name, $sent, $received, (Format-Targets $group.Group)))
|
||||
}
|
||||
|
||||
$lines.Add('')
|
||||
$lines.Add("== 5. Traffic of $($focusProcesses -join ' / '): all TCP, and UDP beyond the local network ==")
|
||||
if ($focusLocal -gt 0) { $lines.Add("($focusLocal UDP flows to local-network, broadcast or multicast addresses left out.)") }
|
||||
if ($focusListed.Count -eq 0) { $lines.Add('(none)') }
|
||||
$ptrTried = @{}
|
||||
foreach ($f in $focusListed) {
|
||||
if ($ReverseDns -and (Get-Names $f.Address).Count -eq 0 -and -not $ptrTried.ContainsKey($f.Address)) {
|
||||
$ptrTried[$f.Address] = $true
|
||||
$ptr = try { (Resolve-DnsName -Type PTR $f.Address -QuickTimeout -ErrorAction Stop | Select-Object -First 1).NameHost } catch { $null }
|
||||
if ($ptr) {
|
||||
$set = New-Object 'System.Collections.Generic.HashSet[string]'
|
||||
[void]$set.Add("reverse DNS: $ptr")
|
||||
$namesByAddress[$f.Address] = $set
|
||||
}
|
||||
}
|
||||
$lines.Add((Format-Flow $f))
|
||||
}
|
||||
|
||||
$lines | Set-Content -LiteralPath $reportPath -Encoding utf8
|
||||
$result.Lookups | Sort-Object Time | Select-Object Time, Event, Process, ProcessId, Name, Type, Status, Results |
|
||||
Export-Csv -LiteralPath $dnsCsvPath -NoTypeInformation -Encoding utf8
|
||||
$allFlows | Select-Object Process, ProcessId, Protocol, Address, Port,
|
||||
@{ n = 'Names'; e = { (Get-Names $_.Address) -join ', ' } }, @{ n = 'EA'; e = { Get-FlowKind $_ } },
|
||||
Connects, SentBytes, Sends, ReceivedBytes, Receives, First, Last |
|
||||
Export-Csv -LiteralPath $flowCsvPath -NoTypeInformation -Encoding utf8
|
||||
|
||||
$lines | ForEach-Object { Write-Host $_ }
|
||||
Write-Host ''
|
||||
Write-Host "Report: $reportPath" -ForegroundColor Green
|
||||
}
|
||||
|
||||
if (-not $Analyze) { Start-Recording }
|
||||
Invoke-Analysis
|
||||
Reference in new issue
Block a user