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
|
||||
times/
|
||||
*.csv
|
||||
ca/
|
||||
captures/
|
||||
capture.toml
|
||||
|
||||
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"
|
||||
required-features = ["bin-logging"]
|
||||
|
||||
[[bin]]
|
||||
name = "capture"
|
||||
path = "src/capture/main.rs"
|
||||
|
||||
[dependencies]
|
||||
reqwest = "0.12.15"
|
||||
tokio = { version = "1.44.1", features = ["rt-multi-thread", "macros", "fs", "sync", "io-util"] }
|
||||
|
|
@ -26,6 +30,15 @@ dashmap = "6"
|
|||
log = "0.4"
|
||||
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]
|
||||
default = ["bin-logging", "test-utils"]
|
||||
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