Add Windows-only garage capture via MITM proxy and ADB automation
This commit is contained in:
parent
7ac889cbff
commit
b85672559b
6 changed files with 1962 additions and 7 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,5 +5,8 @@ cars_repo.json
|
||||||
tracks_repo.json
|
tracks_repo.json
|
||||||
times/
|
times/
|
||||||
*.csv
|
*.csv
|
||||||
|
ca/
|
||||||
|
captures/
|
||||||
|
capture.toml
|
||||||
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
|
||||||
1009
Cargo.lock
generated
1009
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
13
Cargo.toml
13
Cargo.toml
|
|
@ -14,6 +14,10 @@ name = "records"
|
||||||
path = "src/records.rs"
|
path = "src/records.rs"
|
||||||
required-features = ["bin-logging"]
|
required-features = ["bin-logging"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "capture"
|
||||||
|
path = "src/capture/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = "0.12.15"
|
reqwest = "0.12.15"
|
||||||
tokio = { version = "1.44.1", features = ["rt-multi-thread", "macros", "fs", "sync", "io-util"] }
|
tokio = { version = "1.44.1", features = ["rt-multi-thread", "macros", "fs", "sync", "io-util"] }
|
||||||
|
|
@ -26,6 +30,15 @@ dashmap = "6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = { version = "0.11", optional = true }
|
env_logger = { version = "0.11", optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
hudsucker = "0.25"
|
||||||
|
http = "1"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
md-5 = "0.10"
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["bin-logging", "test-utils"]
|
default = ["bin-logging", "test-utils"]
|
||||||
bin-logging = ["dep:env_logger"]
|
bin-logging = ["dep:env_logger"]
|
||||||
|
|
|
||||||
29
capture.toml.example
Normal file
29
capture.toml.example
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Example configuration for `cargo run --bin capture`.
|
||||||
|
# Copy to capture.toml and adjust to your machine; capture.toml is gitignored.
|
||||||
|
#
|
||||||
|
# The capture binary is Windows-only (BlueStacks + adb interception); on Unix
|
||||||
|
# hosts it builds as a stub that exits with an error message.
|
||||||
|
|
||||||
|
[device]
|
||||||
|
# BlueStacks ADB endpoint (BlueStacks Settings -> Advanced -> Android Debug Bridge)
|
||||||
|
serial = "127.0.0.1:5555"
|
||||||
|
# How long to wait for the emulator to come online before giving up
|
||||||
|
wait_secs = 180
|
||||||
|
|
||||||
|
[paths]
|
||||||
|
# Path to adb.exe (empty = auto-detect from LOCALAPPDATA, then PATH)
|
||||||
|
adb = 'C:\Users\<you>\AppData\Local\Android\Sdk\platform-tools\adb.exe'
|
||||||
|
# Path to the BlueStacks player, started automatically if the device is offline
|
||||||
|
bluestacks = 'C:\Program Files\BlueStacks_nxt\HD-Player.exe'
|
||||||
|
# Where the captured garage document is written
|
||||||
|
output = 'my_cars.json'
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
# Local port for the MITM proxy (must be free; close HTTP Toolkit first)
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
[capture]
|
||||||
|
# How long to wait for the game to boot and issue the sync request
|
||||||
|
timeout_secs = 300
|
||||||
|
# Per-request timeout when re-issuing the stored sync request (`capture replay`)
|
||||||
|
replay_timeout_secs = 30
|
||||||
13
src/capture/main.rs
Normal file
13
src/capture/main.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn main() {
|
||||||
|
windows::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn main() {
|
||||||
|
eprintln!("error: the capture binary is Windows-only (BlueStacks + ADB MITM interception)");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
902
src/capture/windows.rs
Normal file
902
src/capture/windows.rs
Normal file
|
|
@ -0,0 +1,902 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use hudsucker::certificate_authority::RcgenAuthority;
|
||||||
|
use hudsucker::rcgen::{
|
||||||
|
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose,
|
||||||
|
PKCS_ECDSA_P256_SHA256,
|
||||||
|
};
|
||||||
|
use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse};
|
||||||
|
use http::request::Request;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const GAME_PACKAGE: &str = "com.hutchgames.cccg";
|
||||||
|
const CA_COMMON_NAME: &str = "Top Drives Capture CA";
|
||||||
|
const GARAGE_MARKER: &[u8] = b"playerDeck";
|
||||||
|
const CONFIG_FILE: &str = "capture.toml";
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct CaptureConfig {
|
||||||
|
device: DeviceConfig,
|
||||||
|
paths: PathsConfig,
|
||||||
|
proxy: ProxyConfig,
|
||||||
|
capture: CaptureSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct DeviceConfig {
|
||||||
|
serial: String,
|
||||||
|
wait_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct PathsConfig {
|
||||||
|
adb: String,
|
||||||
|
bluestacks: String,
|
||||||
|
output: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct ProxyConfig {
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct CaptureSettings {
|
||||||
|
timeout_secs: u64,
|
||||||
|
replay_timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CaptureConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
device: DeviceConfig::default(),
|
||||||
|
paths: PathsConfig::default(),
|
||||||
|
proxy: ProxyConfig::default(),
|
||||||
|
capture: CaptureSettings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DeviceConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
serial: "127.0.0.1:5555".to_string(),
|
||||||
|
wait_secs: 180,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PathsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
adb: detect_adb(),
|
||||||
|
bluestacks: detect_bluestacks(),
|
||||||
|
output: "my_cars.json".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { port: 8080 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CaptureSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout_secs: 300,
|
||||||
|
replay_timeout_secs: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_adb() -> String {
|
||||||
|
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
|
||||||
|
let candidate =
|
||||||
|
format!("{local_app_data}\\Android\\Sdk\\platform-tools\\adb.exe");
|
||||||
|
if Path::new(&candidate).exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"adb".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_bluestacks() -> String {
|
||||||
|
const CANDIDATES: [&str; 3] = [
|
||||||
|
r"C:\Program Files\BlueStacks_nxt\HD-Player.exe",
|
||||||
|
r"C:\Program Files\BlueStacks\HD-Player.exe",
|
||||||
|
r"C:\Program Files (x86)\BlueStacks\HD-Player.exe",
|
||||||
|
];
|
||||||
|
CANDIDATES
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| Path::new(candidate).exists())
|
||||||
|
.map(|candidate| candidate.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config(project_root: &Path) -> Result<CaptureConfig> {
|
||||||
|
let config_path = project_root.join(CONFIG_FILE);
|
||||||
|
if config_path.exists() {
|
||||||
|
let text = std::fs::read_to_string(&config_path)?;
|
||||||
|
let config: CaptureConfig = toml::from_str(&text)?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = CaptureConfig::default();
|
||||||
|
let pretty = toml::to_string_pretty(&config)?.replace("\\\\", "/");
|
||||||
|
std::fs::write(&config_path, &pretty)?;
|
||||||
|
println!("created default configuration at {}", config_path.display());
|
||||||
|
println!("review it and adjust paths if needed, then run this command again");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_path(config: &CaptureConfig, project_root: &Path) -> PathBuf {
|
||||||
|
let raw = Path::new(&config.paths.output);
|
||||||
|
if raw.is_absolute() {
|
||||||
|
raw.to_path_buf()
|
||||||
|
} else {
|
||||||
|
project_root.join(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct CapturedRequest {
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CaptureHandler {
|
||||||
|
state: Arc<CaptureState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CaptureState {
|
||||||
|
found: AtomicBool,
|
||||||
|
done: tokio::sync::Notify,
|
||||||
|
project_root: PathBuf,
|
||||||
|
output: PathBuf,
|
||||||
|
pending: std::sync::Mutex<Option<CapturedRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpHandler for CaptureHandler {
|
||||||
|
async fn handle_request(
|
||||||
|
&mut self,
|
||||||
|
_ctx: &HttpContext,
|
||||||
|
mut request: Request<Body>,
|
||||||
|
) -> RequestOrResponse {
|
||||||
|
let host = request_host(&request).to_lowercase();
|
||||||
|
|
||||||
|
if request.method() == http::Method::CONNECT {
|
||||||
|
if !is_noise_host(&host) {
|
||||||
|
println!(" CONNECT {host}");
|
||||||
|
}
|
||||||
|
return request.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_and_query = request
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map(|value| value.as_str().to_string())
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
if !is_noise_host(&host) {
|
||||||
|
println!(" {} {}{}", request.method(), host, path_and_query);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.headers_mut().remove(http::header::ACCEPT_ENCODING);
|
||||||
|
|
||||||
|
if !self.state.found.load(Ordering::SeqCst)
|
||||||
|
&& host.contains("topdrives")
|
||||||
|
&& path_and_query.contains("/sync")
|
||||||
|
{
|
||||||
|
let body_bytes = BodyExt::collect(std::mem::replace(
|
||||||
|
request.body_mut(),
|
||||||
|
Body::from(Vec::new()),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map(|collected| collected.to_bytes().to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let template = CapturedRequest {
|
||||||
|
method: request.method().to_string(),
|
||||||
|
url: format!("https://{host}{path_and_query}"),
|
||||||
|
headers: request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| {
|
||||||
|
(
|
||||||
|
name.as_str().to_string(),
|
||||||
|
String::from_utf8_lossy(value.as_bytes()).to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
body: if body_bytes.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
*self.state.pending.lock().unwrap() = Some(template);
|
||||||
|
*request.body_mut() = Body::from(body_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_response(
|
||||||
|
&mut self,
|
||||||
|
_ctx: &HttpContext,
|
||||||
|
mut response: http::Response<Body>,
|
||||||
|
) -> http::Response<Body> {
|
||||||
|
if self.state.found.load(Ordering::SeqCst) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let looks_textual = response
|
||||||
|
.headers()
|
||||||
|
.get(http::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(|value| value.contains("json") || value.contains("text"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !looks_textual {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = match BodyExt::collect(std::mem::replace(
|
||||||
|
response.body_mut(),
|
||||||
|
Body::from(Vec::new()),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(collected) => collected.to_bytes().to_vec(),
|
||||||
|
Err(_) => return response,
|
||||||
|
};
|
||||||
|
|
||||||
|
if contains_marker(&bytes, GARAGE_MARKER) {
|
||||||
|
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||||
|
if json.get("user").is_some() {
|
||||||
|
let template = self.state.pending.lock().unwrap().take();
|
||||||
|
if let Err(error) = persist_capture(
|
||||||
|
&self.state.project_root,
|
||||||
|
&self.state.output,
|
||||||
|
&json,
|
||||||
|
template.as_ref(),
|
||||||
|
) {
|
||||||
|
eprintln!(" failed to save capture: {error}");
|
||||||
|
}
|
||||||
|
self.state.found.store(true, Ordering::SeqCst);
|
||||||
|
self.state.done.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*response.body_mut() = Body::from(bytes);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_host(request: &Request<Body>) -> String {
|
||||||
|
if let Some(host) = request.uri().host() {
|
||||||
|
return host.to_string();
|
||||||
|
}
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.get(http::header::HOST)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.split(':').next())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_response(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
method: &http::Method,
|
||||||
|
url: &str,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
body: &[u8],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let mut builder = client
|
||||||
|
.request(reqwest::Method::from_bytes(method.as_str().as_bytes())?, url)
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
for (name, value) in headers.iter() {
|
||||||
|
let lower = name.as_str().to_lowercase();
|
||||||
|
if matches!(
|
||||||
|
lower.as_str(),
|
||||||
|
"host" | "content-length" | "accept-encoding" | "connection" | "proxy-connection" | "keep-alive"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
builder = builder.header(name.as_str(), value.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !body.is_empty() {
|
||||||
|
builder = builder.body(body.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = builder.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("HTTP {status}").into());
|
||||||
|
}
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_capture(
|
||||||
|
project_root: &Path,
|
||||||
|
output: &Path,
|
||||||
|
json: &serde_json::Value,
|
||||||
|
template: Option<&CapturedRequest>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let timestamp = time::OffsetDateTime::now_utc().format(
|
||||||
|
&time::format_description::parse_borrowed::<2>(
|
||||||
|
"[year]-[month]-[day]T[hour]-[minute]-[second]",
|
||||||
|
)?,
|
||||||
|
)?;
|
||||||
|
let run_dir = project_root.join("captures").join(×tamp);
|
||||||
|
std::fs::create_dir_all(&run_dir)?;
|
||||||
|
|
||||||
|
let pretty = serde_json::to_string_pretty(json)?;
|
||||||
|
std::fs::write(run_dir.join("my_cars.json"), &pretty)?;
|
||||||
|
|
||||||
|
if output.exists() {
|
||||||
|
let backup = run_dir.join("my_cars.previous.json");
|
||||||
|
std::fs::copy(output, backup)?;
|
||||||
|
}
|
||||||
|
std::fs::write(output, &pretty)?;
|
||||||
|
|
||||||
|
if let Some(template) = template {
|
||||||
|
std::fs::write(
|
||||||
|
project_root.join("captures").join("request.json"),
|
||||||
|
serde_json::to_string_pretty(template)?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" garage data saved to {}", output.display());
|
||||||
|
print_garage_summary(json);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_garage_summary(json: &serde_json::Value) {
|
||||||
|
let social_name = json
|
||||||
|
.pointer("/user/socialName")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("unknown player");
|
||||||
|
let deck = json
|
||||||
|
.get("playerDeck")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|cards| cards.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let locked = json
|
||||||
|
.get("playerDeck")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|cards| {
|
||||||
|
cards
|
||||||
|
.iter()
|
||||||
|
.filter(|card| card.get("locked").and_then(|l| l.as_bool()).unwrap_or(false))
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
println!(" player: {social_name}, garage: {deck} cars ({locked} locked)");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_noise_host(host: &str) -> bool {
|
||||||
|
const NOISE: [&str; 15] = [
|
||||||
|
"google",
|
||||||
|
"gstatic",
|
||||||
|
"crashlytics",
|
||||||
|
"firebase",
|
||||||
|
"facebook",
|
||||||
|
"fbcdn",
|
||||||
|
"unity",
|
||||||
|
"applovin",
|
||||||
|
"admob",
|
||||||
|
"doubleclick",
|
||||||
|
"vungle",
|
||||||
|
"ironsource",
|
||||||
|
"chartboost",
|
||||||
|
"sentry",
|
||||||
|
"bugsnag",
|
||||||
|
];
|
||||||
|
NOISE.iter().any(|fragment| host.contains(fragment))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_marker(haystack: &[u8], marker: &[u8]) -> bool {
|
||||||
|
haystack.windows(marker.len()).any(|window| window == marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_adb(config: &CaptureConfig, args: &[&str]) -> Result<String> {
|
||||||
|
let output = Command::new(&config.paths.adb)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("failed to run adb ({}): {error}", config.paths.adb))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"adb {} failed: {}",
|
||||||
|
args.join(" "),
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adb(config: &CaptureConfig, args: &[&str]) -> Result<String> {
|
||||||
|
let serial = config.device.serial.as_str();
|
||||||
|
run_adb(config, &[&["-s", serial][..], args].concat())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adb_shell(config: &CaptureConfig, command: &str) -> Result<String> {
|
||||||
|
adb(config, &["shell", command])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adb_shell_root(config: &CaptureConfig, command: &str) -> Result<String> {
|
||||||
|
let quoted = format!("su -c '{command}'");
|
||||||
|
adb(config, &["shell", quoted.as_str()])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_listed(config: &CaptureConfig) -> bool {
|
||||||
|
match run_adb(config, &["devices"]) {
|
||||||
|
Ok(devices) => devices
|
||||||
|
.lines()
|
||||||
|
.any(|line| line.starts_with(&config.device.serial) && line.contains("device")),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_device_connected(config: &CaptureConfig) -> Result<()> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(config.device.wait_secs);
|
||||||
|
let mut bluestacks_started = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let _ = run_adb(config, &["connect", &config.device.serial]);
|
||||||
|
if device_listed(config) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bluestacks_started {
|
||||||
|
if config.paths.bluestacks.is_empty() || !Path::new(&config.paths.bluestacks).exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"device {} is offline and no usable BlueStacks path is configured ({} in {CONFIG_FILE})",
|
||||||
|
config.device.serial, config.paths.bluestacks
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"device {} offline, starting BlueStacks ({}) ...",
|
||||||
|
config.device.serial, config.paths.bluestacks
|
||||||
|
);
|
||||||
|
Command::new(&config.paths.bluestacks).spawn()?;
|
||||||
|
bluestacks_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"device {} did not come online within {}s",
|
||||||
|
config.device.serial, config.device.wait_secs
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn android_sdk_level(config: &CaptureConfig) -> Result<u8> {
|
||||||
|
adb_shell(config, "getprop ro.build.version.sdk")?
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "could not read Android SDK level".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_or_create_ca(project_root: &Path) -> Result<(String, String)> {
|
||||||
|
let ca_dir = project_root.join("ca");
|
||||||
|
let cert_path = ca_dir.join("topdrives-ca.pem");
|
||||||
|
let key_path = ca_dir.join("topdrives-ca.key.pem");
|
||||||
|
|
||||||
|
if cert_path.exists() && key_path.exists() {
|
||||||
|
let cert = std::fs::read_to_string(&cert_path)?;
|
||||||
|
let key = std::fs::read_to_string(&key_path)?;
|
||||||
|
return Ok((cert, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&ca_dir)?;
|
||||||
|
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?;
|
||||||
|
let mut params = CertificateParams::new(Vec::new())?;
|
||||||
|
params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME);
|
||||||
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||||
|
params.key_usages = vec![
|
||||||
|
KeyUsagePurpose::KeyCertSign,
|
||||||
|
KeyUsagePurpose::CrlSign,
|
||||||
|
KeyUsagePurpose::DigitalSignature,
|
||||||
|
];
|
||||||
|
let now = time::OffsetDateTime::now_utc();
|
||||||
|
params.not_before = now - time::Duration::days(1);
|
||||||
|
params.not_after = now + time::Duration::days(3650);
|
||||||
|
let certificate = params.self_signed(&key_pair)?;
|
||||||
|
|
||||||
|
let cert_pem = certificate.pem();
|
||||||
|
let key_pem = key_pair.serialize_pem();
|
||||||
|
std::fs::write(&cert_path, &cert_pem)?;
|
||||||
|
std::fs::write(&key_path, &key_pem)?;
|
||||||
|
println!("created new CA certificate at {}", cert_path.display());
|
||||||
|
Ok((cert_pem, key_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn android_ca_filename(common_name: &str) -> String {
|
||||||
|
let mut canonical = Vec::new();
|
||||||
|
canonical.extend_from_slice(&[0x06, 0x03, 0x55, 0x04, 0x03]);
|
||||||
|
canonical.push(0x0C);
|
||||||
|
canonical.push(common_name.len() as u8);
|
||||||
|
canonical.extend_from_slice(common_name.as_bytes());
|
||||||
|
|
||||||
|
let digest = Md5::digest(&canonical);
|
||||||
|
let hash = u32::from_le_bytes([digest[0], digest[1], digest[2], digest[3]]);
|
||||||
|
format!("{hash:08x}.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_ca(config: &CaptureConfig, cert_pem: &str, project_root: &Path) -> Result<()> {
|
||||||
|
let filename = android_ca_filename(CA_COMMON_NAME);
|
||||||
|
let store_path = format!("/system/etc/security/cacerts/{filename}");
|
||||||
|
let staging_path = "/data/local/tmp/topdrives-ca.pem";
|
||||||
|
let local_path = project_root.join("ca").join("topdrives-ca.pem");
|
||||||
|
std::fs::write(&local_path, cert_pem)?;
|
||||||
|
|
||||||
|
let presence = adb_shell(
|
||||||
|
config,
|
||||||
|
&format!("test -f {store_path} && echo present || echo absent"),
|
||||||
|
)?;
|
||||||
|
if presence.trim() == "present" {
|
||||||
|
println!("CA already present in the system trust store");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
adb(
|
||||||
|
config,
|
||||||
|
&["push", local_path.to_string_lossy().as_ref(), staging_path],
|
||||||
|
)?;
|
||||||
|
adb_shell_root(
|
||||||
|
config,
|
||||||
|
"mkdir -p /data/local/tmp/cacerts-backup && cp /system/etc/security/cacerts/* /data/local/tmp/cacerts-backup/",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mounts = adb_shell(config, "cat /proc/mounts")?;
|
||||||
|
if !mounts.contains("/system/etc/security/cacerts") {
|
||||||
|
adb_shell_root(config, "mount -t tmpfs tmpfs /system/etc/security/cacerts")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
adb_shell_root(
|
||||||
|
config,
|
||||||
|
&format!(
|
||||||
|
"cp /data/local/tmp/cacerts-backup/* /system/etc/security/cacerts/ && cp {staging_path} {store_path} && chmod 644 {store_path}"
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let count_text = adb_shell(config, "ls /system/etc/security/cacerts | wc -l")?;
|
||||||
|
let count: u32 = count_text
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "could not count certificates in the store".to_string())?;
|
||||||
|
if count < 100 {
|
||||||
|
return Err(format!("system store looks emptied ({count} certificates), aborting").into());
|
||||||
|
}
|
||||||
|
println!("injected CA into the system trust store as {filename} ({count} certificates total)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_device_proxy(config: &CaptureConfig, port: u16) -> Result<()> {
|
||||||
|
adb_shell(config, &format!("settings put global http_proxy 127.0.0.1:{port}")).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_device_proxy(config: &CaptureConfig) {
|
||||||
|
let _ = adb_shell(config, "settings put global http_proxy :0");
|
||||||
|
let _ = adb_shell(config, "settings delete global http_proxy");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_adb_reverse(config: &CaptureConfig, port: u16) -> Result<()> {
|
||||||
|
let socket = format!("tcp:{port}");
|
||||||
|
match adb(config, &["reverse", socket.as_str(), socket.as_str()]) {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(_) => {
|
||||||
|
let route = adb_shell(config, "ip route")?;
|
||||||
|
let gateway = route.lines().find_map(|line| {
|
||||||
|
line.strip_prefix("default via ")
|
||||||
|
.and_then(|rest| rest.split(' ').next())
|
||||||
|
});
|
||||||
|
match gateway {
|
||||||
|
Some(gateway) => {
|
||||||
|
println!("adb reverse failed, falling back to gateway address {gateway}:{port}");
|
||||||
|
adb_shell(config, &format!("settings put global http_proxy {gateway}:{port}")).map(|_| ())
|
||||||
|
}
|
||||||
|
None => Err("could not set up adb reverse and no default gateway found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_game(config: &CaptureConfig) -> Result<()> {
|
||||||
|
adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"))?;
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
adb(
|
||||||
|
config,
|
||||||
|
&[
|
||||||
|
"shell",
|
||||||
|
"monkey",
|
||||||
|
"-p",
|
||||||
|
GAME_PACKAGE,
|
||||||
|
"-c",
|
||||||
|
"android.intent.category.LAUNCHER",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_proxy(
|
||||||
|
listener: tokio::net::TcpListener,
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
handler: CaptureHandler,
|
||||||
|
) -> Result<ProxyFuture> {
|
||||||
|
let key_pair = KeyPair::from_pem(key_pem)?;
|
||||||
|
let issuer = Issuer::from_ca_cert_pem(cert_pem, key_pair)?;
|
||||||
|
let provider = hudsucker::rustls::crypto::aws_lc_rs::default_provider();
|
||||||
|
let authority = RcgenAuthority::new(issuer, 1_000, provider);
|
||||||
|
|
||||||
|
let proxy = Proxy::builder()
|
||||||
|
.with_listener(listener)
|
||||||
|
.with_ca(authority)
|
||||||
|
.with_rustls_connector(hudsucker::rustls::crypto::aws_lc_rs::default_provider())
|
||||||
|
.with_http_handler(handler)
|
||||||
|
.build()?;
|
||||||
|
Ok(Box::pin(proxy.start()))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyFuture =
|
||||||
|
std::pin::Pin<Box<dyn std::future::Future<Output = std::result::Result<(), hudsucker::Error>> + Send>>;
|
||||||
|
|
||||||
|
async fn command_run(config: &CaptureConfig) -> Result<()> {
|
||||||
|
let project_root = std::env::current_dir()?;
|
||||||
|
let (cert_pem, key_pem) = load_or_create_ca(&project_root)?;
|
||||||
|
|
||||||
|
ensure_device_connected(config)?;
|
||||||
|
let sdk_level = android_sdk_level(config)?;
|
||||||
|
if sdk_level > 28 {
|
||||||
|
println!("warning: Android {sdk_level} detected, the tmpfs CA injection is only tested up to Android 9");
|
||||||
|
}
|
||||||
|
inject_ca(config, &cert_pem, &project_root)?;
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::from((
|
||||||
|
[127, 0, 0, 1],
|
||||||
|
config.proxy.port,
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"cannot listen on 127.0.0.1:{} (HTTP Toolkit still running?): {error}",
|
||||||
|
config.proxy.port
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let state = Arc::new(CaptureState {
|
||||||
|
found: AtomicBool::new(false),
|
||||||
|
done: tokio::sync::Notify::new(),
|
||||||
|
project_root: project_root.clone(),
|
||||||
|
output: output_path(config, &project_root),
|
||||||
|
pending: std::sync::Mutex::new(None),
|
||||||
|
});
|
||||||
|
let proxy = build_proxy(listener, &cert_pem, &key_pem, CaptureHandler { state: state.clone() })?;
|
||||||
|
tokio::spawn(proxy);
|
||||||
|
|
||||||
|
set_device_proxy(config, config.proxy.port)?;
|
||||||
|
setup_adb_reverse(config, config.proxy.port)?;
|
||||||
|
println!(
|
||||||
|
"proxy active on 127.0.0.1:{}, launching {GAME_PACKAGE} ...",
|
||||||
|
config.proxy.port
|
||||||
|
);
|
||||||
|
launch_game(config)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"waiting for the garage request (timeout {}s, Ctrl+C to abort)",
|
||||||
|
config.capture.timeout_secs
|
||||||
|
);
|
||||||
|
let outcome: Result<()> = tokio::select! {
|
||||||
|
_ = state.done.notified() => Ok(()),
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(config.capture.timeout_secs)) => Err("timed out waiting for the garage request".into()),
|
||||||
|
_ = tokio::signal::ctrl_c() => Err("aborted".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
clear_device_proxy(config);
|
||||||
|
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
|
||||||
|
let _ = adb(config, &["reverse", remove_socket.as_str()]);
|
||||||
|
println!("device proxy settings restored");
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("capture finished, run `cargo run --bin records -- best` to analyze");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(message) => Err(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_setup(config: &CaptureConfig) -> Result<()> {
|
||||||
|
let project_root = std::env::current_dir()?;
|
||||||
|
let (cert_pem, _) = load_or_create_ca(&project_root)?;
|
||||||
|
ensure_device_connected(config)?;
|
||||||
|
let sdk_level = android_sdk_level(config)?;
|
||||||
|
println!("device {} connected, Android SDK {}", config.device.serial, sdk_level);
|
||||||
|
inject_ca(config, &cert_pem, &project_root)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_replay(config: &CaptureConfig) -> Result<()> {
|
||||||
|
let project_root = std::env::current_dir()?;
|
||||||
|
let request_path = project_root.join("captures").join("request.json");
|
||||||
|
if !request_path.exists() {
|
||||||
|
return Err("no captured request found, run `capture run` first".into());
|
||||||
|
}
|
||||||
|
let template: CapturedRequest = serde_json::from_str(&std::fs::read_to_string(&request_path)?)?;
|
||||||
|
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
for (name, value) in &template.headers {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
if matches!(
|
||||||
|
lower.as_str(),
|
||||||
|
"host" | "content-length" | "accept-encoding" | "connection" | "proxy-connection" | "keep-alive"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
http::HeaderName::from_bytes(name.as_bytes())?,
|
||||||
|
http::HeaderValue::from_bytes(value.as_bytes())?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = template
|
||||||
|
.body
|
||||||
|
.as_deref()
|
||||||
|
.map(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded))
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let method: http::Method = template.method.parse()?;
|
||||||
|
let timeout = Duration::from_secs(config.capture.replay_timeout_secs);
|
||||||
|
let http = reqwest::Client::builder().timeout(timeout).build()?;
|
||||||
|
|
||||||
|
println!("replaying {} {}", method, template.url);
|
||||||
|
let bytes = fetch_response(&http, &method, &template.url, &headers, &body, timeout).await?;
|
||||||
|
if !contains_marker(&bytes, GARAGE_MARKER) {
|
||||||
|
return Err(
|
||||||
|
"response no longer contains the garage document, the session token probably expired; run `capture run` again".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes)?;
|
||||||
|
persist_capture(
|
||||||
|
&project_root,
|
||||||
|
&output_path(config, &project_root),
|
||||||
|
&json,
|
||||||
|
Some(&template),
|
||||||
|
)?;
|
||||||
|
println!("replay finished successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_clean(config: &CaptureConfig) -> Result<()> {
|
||||||
|
ensure_device_connected(config)?;
|
||||||
|
clear_device_proxy(config);
|
||||||
|
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
|
||||||
|
let _ = adb(config, &["reverse", remove_socket.as_str()]);
|
||||||
|
let filename = android_ca_filename(CA_COMMON_NAME);
|
||||||
|
let _ = adb_shell_root(config, &format!("rm -f /system/etc/security/cacerts/{filename}"));
|
||||||
|
let _ = adb_shell_root(config, "umount /system/etc/security/cacerts");
|
||||||
|
println!("proxy settings cleared and CA removed from the device");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_usage() {
|
||||||
|
println!(
|
||||||
|
"usage: capture <command>
|
||||||
|
|
||||||
|
commands:
|
||||||
|
setup generate the CA and inject it into BlueStacks
|
||||||
|
run full flow: start BlueStacks if needed, proxy, game launch, garage capture
|
||||||
|
replay re-issue the stored garage request without the emulator
|
||||||
|
clean remove proxy settings and the injected CA
|
||||||
|
|
||||||
|
configuration is read from {CONFIG_FILE} in the working directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub(crate) async fn run() {
|
||||||
|
let command = std::env::args().nth(1).unwrap_or_default();
|
||||||
|
if command.is_empty() {
|
||||||
|
print_usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_root = std::env::current_dir().expect("cannot determine working directory");
|
||||||
|
let config = match load_config(&project_root) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("error reading {CONFIG_FILE}: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match command.as_str() {
|
||||||
|
"setup" => command_setup(&config).await,
|
||||||
|
"run" => command_run(&config).await,
|
||||||
|
"replay" => command_replay(&config).await,
|
||||||
|
"clean" => command_clean(&config).await,
|
||||||
|
_ => {
|
||||||
|
print_usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("error: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marker_detection_finds_nested_json_key() {
|
||||||
|
let body = br#"{"serverId":"x","playerDeck":[{"cardId":"a"}]}"#;
|
||||||
|
assert!(contains_marker(body, GARAGE_MARKER));
|
||||||
|
assert!(!contains_marker(b"{}", GARAGE_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_ca_filename_is_openssl_style_hash() {
|
||||||
|
let filename = android_ca_filename(CA_COMMON_NAME);
|
||||||
|
assert_eq!(filename.len(), 10);
|
||||||
|
assert!(filename.ends_with(".0"));
|
||||||
|
assert!(filename[..8]
|
||||||
|
.chars()
|
||||||
|
.all(|character| character.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noise_hosts_are_filtered() {
|
||||||
|
assert!(is_noise_host("ads.google.com"));
|
||||||
|
assert!(is_noise_host("config.firebase.io"));
|
||||||
|
assert!(!is_noise_host("api.hutchgames.com"));
|
||||||
|
assert!(!is_noise_host("topdrives-api.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_round_trips_through_toml() {
|
||||||
|
let config = CaptureConfig::default();
|
||||||
|
let text = toml::to_string_pretty(&config).unwrap();
|
||||||
|
let parsed: CaptureConfig = toml::from_str(&text).unwrap();
|
||||||
|
assert_eq!(parsed.device.serial, config.device.serial);
|
||||||
|
assert_eq!(parsed.paths.output, config.paths.output);
|
||||||
|
assert_eq!(parsed.proxy.port, config.proxy.port);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue