All checks were successful
build / build-test (push) Successful in 1m46s
923 lines
29 KiB
Rust
923 lines
29 KiB
Rust
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 http::request::Request;
|
|
use http_body_util::BodyExt;
|
|
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 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,
|
|
shutdown_bluestacks: bool,
|
|
}
|
|
|
|
#[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, shutdown_bluestacks: true }
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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 remove_adb_reverse(config: &CaptureConfig) {
|
|
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
|
|
let _ = adb(config, &["reverse", remove_socket.as_str()]);
|
|
}
|
|
|
|
fn clear_stale_proxy(config: &CaptureConfig) {
|
|
if let Ok(value) = adb_shell(config, "settings get global http_proxy") {
|
|
let value = value.trim();
|
|
if !value.is_empty() && value != "null" {
|
|
clear_device_proxy(config);
|
|
println!("cleared stale proxy setting left by a previous run: {value}");
|
|
}
|
|
}
|
|
remove_adb_reverse(config);
|
|
}
|
|
|
|
fn revert_device_state(config: &CaptureConfig) {
|
|
clear_device_proxy(config);
|
|
remove_adb_reverse(config);
|
|
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");
|
|
let _ = adb_shell_root(config, "rm -rf /data/local/tmp/cacerts-backup");
|
|
let _ = adb_shell(config, "rm -f /data/local/tmp/topdrives-ca.pem");
|
|
println!(
|
|
"device reverted: proxy cleared, CA removed from the trust store, staging files deleted"
|
|
);
|
|
}
|
|
|
|
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)?;
|
|
clear_stale_proxy(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()),
|
|
};
|
|
|
|
revert_device_state(config);
|
|
|
|
println!("stopping {GAME_PACKAGE}");
|
|
let _ = adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"));
|
|
let _ = run_adb(config, &["kill-server"]);
|
|
println!("adb stopped");
|
|
if config.device.shutdown_bluestacks {
|
|
close_bluestacks();
|
|
}
|
|
|
|
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)?;
|
|
revert_device_state(config);
|
|
Ok(())
|
|
}
|
|
|
|
fn close_bluestacks() {
|
|
let silent = |args: &[&str]| {
|
|
let _ = Command::new("taskkill")
|
|
.args(args)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
};
|
|
silent(&["/IM", "HD-Player.exe"]);
|
|
std::thread::sleep(Duration::from_secs(5));
|
|
silent(&["/F", "/IM", "HD-Player.exe"]);
|
|
println!("BlueStacks closed");
|
|
}
|
|
|
|
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 revert the device: clear the proxy, remove the injected CA and staging files
|
|
|
|
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);
|
|
}
|
|
}
|