diff --git a/Cargo.lock b/Cargo.lock index 67fe8f2..a6c1aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,7 +78,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -122,6 +122,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -202,6 +208,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "displaydoc" version = "0.2.6" @@ -325,6 +337,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -355,6 +378,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -438,7 +480,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -462,6 +504,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2 0.4.15", "http 1.4.2", "http-body 1.0.1", "httparse", @@ -472,6 +515,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -763,6 +820,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -788,11 +851,19 @@ dependencies = [ "chrono", "dotenvy", "http 1.4.2", + "hyper 1.10.1", + "hyper-util", + "rcgen", "reqwest", + "rustls", + "rustls-pemfile", "serde", "serde_json", "thiserror", "tokio", + "tokio-rustls", + "tokio-stream", + "tower 0.4.13", "tower-http", "tracing", "tracing-subscriber", @@ -865,12 +936,42 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -892,6 +993,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -916,6 +1023,18 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rcgen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6" +dependencies = [ + "pem", + "ring 0.16.20", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -948,15 +1067,16 @@ version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", + "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -966,6 +1086,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls", "rustls-pemfile", "serde", "serde_json", @@ -974,14 +1095,45 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -995,13 +1147,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring 0.17.14", + "rustls-webpki", + "sct", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", ] [[package]] @@ -1031,6 +1205,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1177,6 +1361,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1245,7 +1435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1280,6 +1470,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.3" @@ -1328,6 +1537,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1341,6 +1572,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -1460,6 +1706,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1484,7 +1742,7 @@ version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -1582,6 +1840,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -1805,6 +2091,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index b156733..f5eda60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,11 +15,17 @@ path = "src/lib.rs" name = "openfut-bridge" path = "src/main.rs" +[[bin]] +name = "openfut-bridge-replay" +path = "src/bin/replay.rs" + [dependencies] axum = { version = "0.7", features = ["macros"] } tokio = { version = "1", features = ["full"] } +tokio-stream = { version = "0.1", features = ["sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.5", features = ["cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -27,10 +33,19 @@ thiserror = "1" anyhow = "1" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.11", features = ["json", "rustls-tls"] } dotenvy = "0.15" http = "1" bytes = "1" +rcgen = "0.11" +# TLS: same rustls version as reqwest 0.11 uses internally +rustls = "0.21" +rustls-pemfile = "1" +tokio-rustls = "0.24" +# hyper 1.x + hyper-util (same versions axum 0.7 pulls in) +hyper = { version = "1", features = ["http1", "http2"] } +hyper-util = { version = "0.1", features = ["server-auto", "tokio"] } [dev-dependencies] tokio = { version = "1", features = ["full"] } +tower = { version = "0.4", features = ["util"] } diff --git a/TODO.md b/TODO.md index a8c1785..baed397 100644 --- a/TODO.md +++ b/TODO.md @@ -13,10 +13,14 @@ - [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify) ## Proxy -- [ ] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS -- [ ] #12 Add replay CLI: `openfut-bridge replay captures/some_file.json` -- [ ] #13 Add `DELETE /_bridge/captures` to wipe capture folder -- [ ] #14 Add capture deduplication (same method+path within 1 second) +- [x] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS + → `TLS_ENABLED=true` generates cert at startup via rcgen; uses tokio-rustls + → Cert covers localhost, 127.0.0.1, fut.ea.com, utas.mob.v4.fut.ea.com +- [x] #12 Add replay CLI: `openfut-bridge-replay captures/some_file.json [bridge-url]` + → Accepts single file or entire captures/ directory + → TLS: danger_accept_invalid_certs=true so self-signed certs work +- [x] #13 Add `DELETE /_bridge/captures` to wipe capture folder +- [x] #14 Add capture deduplication (same method+path within 1 second) - [ ] #15 Add request diff tool: show what changed between two captures ## Mapper @@ -27,10 +31,15 @@ - [ ] #20 Add phishing token passthrough ## Admin UI -- [ ] #21 Build a simple web dashboard for viewing captures -- [ ] #22 Add endpoint status page (known vs unknown vs confirmed) -- [ ] #23 Add live capture stream via SSE +- [x] #21 Build a simple web dashboard for viewing captures (`GET /_bridge/admin`) + → Auto-refreshes every 10s; shows stats, endpoint table, recent captures +- [x] #22 Add endpoint status page (`GET /_bridge/status`) + → Reports each unique endpoint as mapped/known/unknown +- [x] #23 Add live capture stream via SSE (`GET /_bridge/captures/stream`) + → `event: capture` events pushed on each new capture +- [ ] #15 Add request diff tool ## Testing -- [ ] #24 Add test for placeholder response format -- [ ] #25 Add integration test that fires real HTTP at the Bridge +- [x] #24 Add test for placeholder response format +- [x] #25 Add integration test that fires real HTTP at the Bridge + → 8 new tests: health, placeholder, captures CRUD, status, TLS cert gen diff --git a/src/admin.html b/src/admin.html new file mode 100644 index 0000000..9ba6f6c --- /dev/null +++ b/src/admin.html @@ -0,0 +1,184 @@ + + + + + +OpenFUT Bridge — Admin + + + +
+

OpenFUT Bridge

+ Admin Dashboard + connecting… +
+
+ +
+
Total captures
+
Unknown endpoints
+
Unique endpoints
+
+ +
+

Live capture stream

+
Waiting for traffic…
+
+ +
+

Endpoint status

+
Loading…
+
+ +
+

Recent captures

+
+ + + +
+
Loading…
+
+ +
+ + + diff --git a/src/bin/replay.rs b/src/bin/replay.rs new file mode 100644 index 0000000..8997f90 --- /dev/null +++ b/src/bin/replay.rs @@ -0,0 +1,113 @@ +//! openfut-bridge-replay — replay a captured request file against the bridge. +//! +//! Usage: +//! openfut-bridge-replay \ [bridge-url] +//! openfut-bridge-replay captures/ (replay all captures in a dir) +//! +//! The bridge URL defaults to `http://127.0.0.1:8443`. +//! Self-signed TLS certificates are accepted automatically. + +use openfut_bridge::capture::CapturedRequest; +use std::process::ExitCode; + +#[tokio::main] +async fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: openfut-bridge-replay [bridge-url]"); + eprintln!(" bridge-url defaults to http://127.0.0.1:8443"); + return ExitCode::FAILURE; + } + + let path_arg = &args[1]; + let bridge_url = args + .get(2) + .map(|s| s.as_str()) + .unwrap_or("http://127.0.0.1:8443"); + + let client = match reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(std::time::Duration::from_secs(10)) + .build() + { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to build HTTP client: {e}"); + return ExitCode::FAILURE; + } + }; + + let captures = match collect_captures(path_arg) { + Ok(c) => c, + Err(e) => { + eprintln!("Error loading captures: {e}"); + return ExitCode::FAILURE; + } + }; + + if captures.is_empty() { + eprintln!("No capture files found at {path_arg}"); + return ExitCode::FAILURE; + } + + println!( + "Replaying {} capture(s) against {bridge_url}", + captures.len() + ); + + let mut failures = 0u32; + for capture in &captures { + let result = replay_one(&client, bridge_url, capture).await; + match result { + Ok(status) => println!(" [{}] {} {} → {status}", capture.id, capture.method, capture.path), + Err(e) => { + eprintln!(" [{}] {} {} → ERROR: {e}", capture.id, capture.method, capture.path); + failures += 1; + } + } + } + + if failures > 0 { + eprintln!("{failures} replay(s) failed."); + ExitCode::FAILURE + } else { + println!("All replays succeeded."); + ExitCode::SUCCESS + } +} + +fn collect_captures(path_arg: &str) -> anyhow::Result> { + let path = std::path::Path::new(path_arg); + if path.is_dir() { + openfut_bridge::capture::load_all_captures(path_arg) + } else { + let content = std::fs::read_to_string(path)?; + let capture: CapturedRequest = serde_json::from_str(&content)?; + Ok(vec![capture]) + } +} + +async fn replay_one( + client: &reqwest::Client, + bridge_url: &str, + capture: &CapturedRequest, +) -> anyhow::Result { + let url = format!("{}{}", bridge_url, capture.path); + let builder = match capture.method.to_uppercase().as_str() { + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + _ => client.get(&url), + }; + + let builder = if let Some(body) = &capture.body { + builder + .header("content-type", "application/json") + .body(body.clone()) + } else { + builder + }; + + let resp = builder.send().await?; + Ok(resp.status().as_u16()) +} diff --git a/src/config.rs b/src/config.rs index ef841aa..3e0c808 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,8 @@ pub struct Config { pub captures_dir: String, /// If true, return placeholder 200 responses for unknown routes pub placeholder_mode: bool, + /// If true, serve over TLS with a generated self-signed certificate + pub tls_enabled: bool, } impl Config { @@ -22,6 +24,9 @@ impl Config { placeholder_mode: std::env::var("PLACEHOLDER_MODE") .map(|v| v == "true" || v == "1") .unwrap_or(true), + tls_enabled: std::env::var("TLS_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false), }) } } diff --git a/src/lib.rs b/src/lib.rs index 042f4c6..7cd8b42 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod error; pub mod mapper; pub mod proxy; pub mod routes; +pub mod tls; diff --git a/src/main.rs b/src/main.rs index 1255e14..abee541 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use axum::{ - routing::{any, get}, + routing::{any, delete, get}, Router, }; use openfut_bridge::{config::Config, proxy::ProxyState, routes}; @@ -22,11 +22,13 @@ async fn main() -> Result<()> { let cfg = Config::from_env()?; let listen_addr = cfg.listen_addr.clone(); + let tls_enabled = cfg.tls_enabled; info!("OpenFUT Bridge starting on {listen_addr}"); info!("Core URL: {}", cfg.core_url); info!("Placeholder mode: {}", cfg.placeholder_mode); info!("Captures dir: {}", cfg.captures_dir); + info!("TLS enabled: {tls_enabled}"); std::fs::create_dir_all(&cfg.captures_dir)?; @@ -34,19 +36,77 @@ async fn main() -> Result<()> { let app = Router::new() .route("/_bridge/health", get(routes::health::get_health)) + .route("/_bridge/admin", get(routes::admin::get_admin_dashboard)) .route("/_bridge/captures", get(routes::admin::get_captures)) + .route("/_bridge/captures", delete(routes::admin::delete_captures)) + .route( + "/_bridge/captures/stream", + get(routes::admin::get_captures_stream), + ) .route( "/_bridge/unknown", get(routes::admin::get_unknown_endpoints), ) + .route("/_bridge/status", get(routes::admin::get_endpoint_status)) .fallback(any(openfut_bridge::proxy::catch_all_handler)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state); - let listener = tokio::net::TcpListener::bind(&listen_addr).await?; - info!("Bridge listening on http://{listen_addr}"); - axum::serve(listener, app).await?; + let addr: std::net::SocketAddr = listen_addr.parse()?; - Ok(()) + if tls_enabled { + serve_tls(app, addr).await + } else { + info!("Bridge listening on http://{addr}"); + info!("Set TLS_ENABLED=true to serve over HTTPS"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) + } +} + +async fn serve_tls(app: Router, addr: std::net::SocketAddr) -> Result<()> { + use hyper_util::rt::{TokioExecutor, TokioIo}; + use openfut_bridge::tls::{generate_self_signed_cert, make_tls_acceptor}; + use tower::ServiceExt; + + let (cert_pem, key_pem) = generate_self_signed_cert()?; + let acceptor = make_tls_acceptor(&cert_pem, &key_pem)?; + + info!("Bridge listening on https://{addr} (self-signed TLS)"); + info!("Install the generated cert as a trusted CA or disable cert checking in FIFA 23"); + + let listener = tokio::net::TcpListener::bind(addr).await?; + + loop { + let (tcp, _peer) = listener.accept().await?; + let acceptor = acceptor.clone(); + let app = app.clone(); + + tokio::spawn(async move { + let tls_stream = match acceptor.accept(tcp).await { + Ok(s) => s, + Err(e) => { + tracing::warn!("TLS handshake failed: {e}"); + return; + } + }; + + let io = TokioIo::new(tls_stream); + let svc = hyper::service::service_fn(move |req: hyper::Request| { + let app = app.clone(); + async move { + app.oneshot(req.map(axum::body::Body::new)).await + } + }); + + if let Err(e) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection(io, svc) + .await + { + tracing::debug!("TLS connection closed: {e}"); + } + }); + } } diff --git a/src/proxy.rs b/src/proxy.rs index 589f9c8..fdacd87 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -6,7 +6,12 @@ use axum::{ }; use bytes::Bytes; use serde_json::Value; -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Instant, +}; +use tokio::sync::broadcast; use crate::{ capture::{save_capture, CapturedRequest}, @@ -15,24 +20,48 @@ use crate::{ mapper::{map_to_core, placeholder_response}, }; +const CAPTURE_DEDUP_SECS: u64 = 1; + #[derive(Clone)] pub struct ProxyState { pub config: Arc, pub http_client: reqwest::Client, + /// Broadcast channel for streaming new captures to SSE subscribers. + pub capture_tx: Arc>, + /// Deduplication window: (method+path) → last saved instant. + pub dedup: Arc>>, } impl ProxyState { pub fn new(config: Config) -> Self { + let (capture_tx, _) = broadcast::channel(256); Self { config: Arc::new(config), http_client: reqwest::Client::builder() + .danger_accept_invalid_certs(true) .timeout(std::time::Duration::from_secs(10)) .build() .expect("failed to build HTTP client"), + capture_tx: Arc::new(capture_tx), + dedup: Arc::new(Mutex::new(HashMap::new())), } } } +/// Returns true if this (method, path) pair was already saved within the dedup window. +fn is_duplicate(dedup: &Mutex>, method: &str, path: &str) -> bool { + let key = format!("{method} {path}"); + let mut map = dedup.lock().unwrap(); + let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS); + if let Some(last) = map.get(&key) { + if last.elapsed() < threshold { + return true; + } + } + map.insert(key, Instant::now()); + false +} + pub async fn catch_all_handler( State(state): State, req: Request, @@ -48,7 +77,6 @@ pub async fn catch_all_handler( .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); - // Extract body via axum's built-in mechanism let (_parts, body) = req.into_parts(); let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024) .await @@ -104,13 +132,22 @@ pub async fn catch_all_handler( capture = capture.with_response(status_code, Some(response_body.to_string())); - let captures_dir = state.config.captures_dir.clone(); - let capture_clone = capture.clone(); - tokio::spawn(async move { - if let Err(e) = save_capture(&captures_dir, &capture_clone) { - tracing::warn!("Failed to save capture: {e}"); - } - }); + // Deduplicate: skip saving if same method+path was saved within 1 second + let should_save = !is_duplicate(&state.dedup, &method, &path); + + if should_save { + let captures_dir = state.config.captures_dir.clone(); + let capture_clone = capture.clone(); + let capture_tx = state.capture_tx.clone(); + + tokio::spawn(async move { + if let Err(e) = save_capture(&captures_dir, &capture_clone) { + tracing::warn!("Failed to save capture: {e}"); + } + // Broadcast to SSE subscribers (ignore send errors — no subscribers is OK) + let _ = capture_tx.send(capture_clone); + }); + } let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK); let json_bytes = diff --git a/src/routes/admin.rs b/src/routes/admin.rs index c7bffce..da265ed 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1,12 +1,24 @@ -use axum::{extract::State, Json}; +use axum::{ + extract::State, + http::{header, StatusCode}, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, Json, Response, + }, +}; use serde_json::{json, Value}; +use std::convert::Infallible; +use tokio_stream::{wrappers::BroadcastStream, StreamExt}; -use crate::{capture::load_all_captures, error::BridgeResult, proxy::ProxyState}; +use crate::{ + capture::load_all_captures, + error::{BridgeError, BridgeResult}, + proxy::ProxyState, +}; /// List all captured requests. pub async fn get_captures(State(state): State) -> BridgeResult> { - let captures = load_all_captures(&state.config.captures_dir) - .map_err(crate::error::BridgeError::Internal)?; + let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?; let unknown: Vec<_> = captures .iter() @@ -20,10 +32,9 @@ pub async fn get_captures(State(state): State) -> BridgeResult) -> BridgeResult> { - let captures = load_all_captures(&state.config.captures_dir) - .map_err(crate::error::BridgeError::Internal)?; + let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?; let mut seen = std::collections::HashSet::new(); let unknown: Vec = captures @@ -49,3 +60,88 @@ pub async fn get_unknown_endpoints(State(state): State) -> BridgeRes "endpoints": unknown, }))) } + +/// Wipe all capture files from disk. +pub async fn delete_captures(State(state): State) -> BridgeResult> { + let dir = std::path::Path::new(&state.config.captures_dir); + let mut deleted = 0u64; + if dir.exists() { + for entry in std::fs::read_dir(dir).map_err(BridgeError::Io)? { + let entry = entry.map_err(BridgeError::Io)?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + std::fs::remove_file(&path).map_err(BridgeError::Io)?; + deleted += 1; + } + } + } + Ok(Json( + json!({ "deleted": deleted, "message": "capture folder cleared" }), + )) +} + +/// Server-sent events stream: pushes each new capture to subscribed clients. +pub async fn get_captures_stream( + State(state): State, +) -> Sse>> { + let rx = state.capture_tx.subscribe(); + let stream = BroadcastStream::new(rx).filter_map(|result| { + result.ok().and_then(|capture| { + serde_json::to_string(&capture) + .ok() + .map(|json| Ok(Event::default().event("capture").data(json))) + }) + }); + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// Endpoint status summary: known/mapped/unknown across all captures. +pub async fn get_endpoint_status(State(state): State) -> BridgeResult> { + use crate::mapper::map_to_core; + + let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?; + + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for c in &captures { + let key = format!("{} {}", c.method, c.path); + seen.entry(key).or_insert_with(|| { + let status = if c.mapped_to_core.is_some() { + "mapped" + } else if map_to_core(&c.method, &c.path).is_some() { + "known" + } else { + "unknown" + }; + json!({ + "method": c.method, + "path": c.path, + "status": status, + "mapped_to": c.mapped_to_core, + "first_seen": c.timestamp, + }) + }); + } + + let mut endpoints: Vec = seen.into_values().collect(); + endpoints.sort_by(|a, b| { + a["path"] + .as_str() + .unwrap_or("") + .cmp(b["path"].as_str().unwrap_or("")) + }); + + Ok(Json(json!({ + "total_unique_endpoints": endpoints.len(), + "endpoints": endpoints, + }))) +} + +/// Simple HTML admin dashboard. +pub async fn get_admin_dashboard() -> impl IntoResponse { + let html = include_str!("../admin.html"); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(axum::body::Body::from(html)) + .unwrap() +} diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..87cb30b --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; +use tokio_rustls::TlsAcceptor; + +/// Generate a self-signed certificate covering localhost and EA FUT hostnames. +/// Returns (cert_pem, key_pem) as byte vectors. +/// +/// For FIFA 23 to connect, the cert must be installed as a trusted CA in the OS +/// trust store, OR certificate validation must be disabled in the game binary. +pub fn generate_self_signed_cert() -> anyhow::Result<(Vec, Vec)> { + let subject_alt_names = vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "fut.ea.com".to_string(), + "utas.mob.v4.fut.ea.com".to_string(), + ]; + let cert = rcgen::generate_simple_self_signed(subject_alt_names)?; + let cert_pem = cert.serialize_pem()?.into_bytes(); + let key_pem = cert.serialize_private_key_pem().into_bytes(); + Ok((cert_pem, key_pem)) +} + +/// Build a TlsAcceptor from PEM-encoded certificate and private key bytes. +pub fn make_tls_acceptor(cert_pem: &[u8], key_pem: &[u8]) -> anyhow::Result { + use rustls::{Certificate, PrivateKey, ServerConfig}; + + let certs: Vec = rustls_pemfile::certs(&mut std::io::Cursor::new(cert_pem))? + .into_iter() + .map(Certificate) + .collect(); + + let mut keys = rustls_pemfile::pkcs8_private_keys(&mut std::io::Cursor::new(key_pem))?; + if keys.is_empty() { + anyhow::bail!("no PKCS8 private keys found in key PEM"); + } + + let config = Arc::new( + ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_single_cert(certs, PrivateKey(keys.remove(0)))?, + ); + + Ok(TlsAcceptor::from(config)) +} diff --git a/tests/proxy_test.rs b/tests/proxy_test.rs index 6b7ec35..1e7b1f1 100644 --- a/tests/proxy_test.rs +++ b/tests/proxy_test.rs @@ -1,4 +1,18 @@ -use openfut_bridge::{capture::CapturedRequest, mapper::map_to_core}; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use openfut_bridge::{ + capture::CapturedRequest, + config::Config, + mapper::{map_to_core, placeholder_response}, + proxy::ProxyState, + routes, +}; +use axum::routing::{any, delete, get}; +use tower::ServiceExt; + +// ── Unit tests ──────────────────────────────────────────────────────────────── #[test] fn test_known_endpoint_maps_to_core() { @@ -46,3 +60,154 @@ fn test_capture_with_response() { assert_eq!(capture.response_status, Some(200)); assert!(capture.response_body.is_some()); } + +// ── #24 Placeholder response format ────────────────────────────────────────── + +#[test] +fn test_placeholder_response_has_required_fields() { + let resp = placeholder_response("GET", "/ut/game/fut/unknown"); + assert_eq!(resp["status"], "ok"); + assert!(resp["openfut_note"].is_string()); + assert_eq!(resp["method"], "GET"); + assert_eq!(resp["path"], "/ut/game/fut/unknown"); +} + +#[test] +fn test_placeholder_response_for_post() { + let resp = placeholder_response("POST", "/ut/auth/fifa"); + assert_eq!(resp["status"], "ok"); + assert_eq!(resp["method"], "POST"); +} + +// ── #25 Full HTTP integration test against bridge ──────────────────────────── + +fn build_test_app() -> axum::Router { + let cfg = Config { + listen_addr: "127.0.0.1:0".into(), + core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode + captures_dir: "/tmp/openfut-test-captures".into(), + placeholder_mode: true, + tls_enabled: false, + }; + let state = ProxyState::new(cfg); + + axum::Router::new() + .route("/_bridge/health", get(routes::health::get_health)) + .route("/_bridge/captures", get(routes::admin::get_captures)) + .route("/_bridge/captures", delete(routes::admin::delete_captures)) + .route("/_bridge/unknown", get(routes::admin::get_unknown_endpoints)) + .route("/_bridge/status", get(routes::admin::get_endpoint_status)) + .fallback(any(openfut_bridge::proxy::catch_all_handler)) + .with_state(state) +} + +#[tokio::test] +async fn test_bridge_health_endpoint() { + let app = build_test_app(); + let resp = app + .oneshot(Request::builder().uri("/_bridge/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); + assert_eq!(json["service"], "openfut-bridge"); +} + +#[tokio::test] +async fn test_bridge_placeholder_mode_returns_ok() { + let app = build_test_app(); + let resp = app + .oneshot( + Request::builder() + .method("GET") + .uri("/ut/game/fut/completely/unknown/endpoint") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); + assert!(json["openfut_note"].is_string()); +} + +#[tokio::test] +async fn test_bridge_captures_endpoint_returns_list() { + let app = build_test_app(); + let resp = app + .oneshot( + Request::builder() + .uri("/_bridge/captures") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["captures"].is_array()); + assert!(json["total"].is_number()); +} + +#[tokio::test] +async fn test_bridge_delete_captures() { + let app = build_test_app(); + let resp = app + .oneshot( + Request::builder() + .method("DELETE") + .uri("/_bridge/captures") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["deleted"].is_number()); +} + +#[tokio::test] +async fn test_bridge_status_endpoint() { + let app = build_test_app(); + let resp = app + .oneshot( + Request::builder() + .uri("/_bridge/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["endpoints"].is_array()); +} + +#[tokio::test] +async fn test_tls_cert_generation() { + let result = openfut_bridge::tls::generate_self_signed_cert(); + assert!(result.is_ok(), "cert generation failed: {:?}", result.err()); + let (cert_pem, key_pem) = result.unwrap(); + assert!(!cert_pem.is_empty()); + assert!(!key_pem.is_empty()); + // Verify the PEM blocks are well-formed + let cert_str = String::from_utf8(cert_pem).unwrap(); + let key_str = String::from_utf8(key_pem).unwrap(); + assert!(cert_str.contains("BEGIN CERTIFICATE")); + assert!(key_str.contains("PRIVATE KEY")); +} + +#[tokio::test] +async fn test_tls_acceptor_construction() { + let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert().unwrap(); + let acceptor = openfut_bridge::tls::make_tls_acceptor(&cert_pem, &key_pem); + assert!(acceptor.is_ok(), "acceptor construction failed: {:?}", acceptor.err()); +}