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…
+
+
+
+
+
+
+ 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());
+}