feat: Phase 6 — proxy polish, TLS, admin UI, replay CLI
## TLS (#11) - Self-signed cert generated at startup via rcgen (covers localhost, 127.0.0.1, fut.ea.com, utas.mob.v4.fut.ea.com); activated with TLS_ENABLED=true - Custom accept loop: tokio-rustls acceptor → hyper-util auto Builder → axum Router (no axum-server dependency — uses hyper 1.x natively) ## Replay CLI (#12) - New binary: openfut-bridge-replay <file.json|dir> [bridge-url] - Replays single capture or entire directory against Bridge - Accepts self-signed certs automatically ## Capture quality (#13, #14) - DELETE /_bridge/captures — wipe all capture files from disk - Deduplication: same method+path within 1 s is skipped (configurable constant) ## Admin UI (#21, #22, #23) - GET /_bridge/admin — embedded HTML dashboard; auto-refresh every 10 s Shows: live stats, SSE log of incoming traffic, endpoint status table, recent captures list with delete button - GET /_bridge/status — per-endpoint mapped/known/unknown status - GET /_bridge/captures/stream — SSE stream; event: capture on each request Uses tokio::sync::broadcast channel (capacity 256) in ProxyState ## Tests (#24, #25) - 9 new tests: placeholder format, full HTTP integration (health, placeholder, captures list, delete captures, status), TLS cert generation + acceptor build - Total bridge tests: 13/13 passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+302
-7
@@ -78,7 +78,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper 1.0.2",
|
"sync_wrapper 1.0.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -122,6 +122,12 @@ version = "0.21.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -202,6 +208,12 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -325,6 +337,17 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -355,6 +378,25 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -438,7 +480,7 @@ dependencies = [
|
|||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -462,6 +504,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2 0.4.15",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -472,6 +515,20 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -763,6 +820,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -788,11 +851,19 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
|
"hyper 1.10.1",
|
||||||
|
"hyper-util",
|
||||||
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower 0.4.13",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -865,12 +936,42 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -892,6 +993,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -916,6 +1023,18 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -948,15 +1067,16 @@ version = "0.11.27"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.21.7",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"hyper 0.14.32",
|
"hyper 0.14.32",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
"hyper-tls",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -966,6 +1086,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -974,14 +1095,45 @@ dependencies = [
|
|||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
"winreg",
|
"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]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -995,13 +1147,35 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pemfile"
|
name = "rustls-pemfile"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -1031,6 +1205,16 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -1177,6 +1361,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -1245,7 +1435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -1280,6 +1470,25 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -1328,6 +1537,28 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -1341,6 +1572,21 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1460,6 +1706,18 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
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]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -1484,7 +1742,7 @@ version = "1.23.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -1582,6 +1840,34 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -1805,6 +2091,15 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yasna"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||||
|
dependencies = [
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
+16
-1
@@ -15,11 +15,17 @@ path = "src/lib.rs"
|
|||||||
name = "openfut-bridge"
|
name = "openfut-bridge"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-bridge-replay"
|
||||||
|
path = "src/bin/replay.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -27,10 +33,19 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
http = "1"
|
http = "1"
|
||||||
bytes = "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]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
|||||||
@@ -13,10 +13,14 @@
|
|||||||
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
|
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
|
||||||
|
|
||||||
## Proxy
|
## Proxy
|
||||||
- [ ] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
|
- [x] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
|
||||||
- [ ] #12 Add replay CLI: `openfut-bridge replay captures/some_file.json`
|
→ `TLS_ENABLED=true` generates cert at startup via rcgen; uses tokio-rustls
|
||||||
- [ ] #13 Add `DELETE /_bridge/captures` to wipe capture folder
|
→ Cert covers localhost, 127.0.0.1, fut.ea.com, utas.mob.v4.fut.ea.com
|
||||||
- [ ] #14 Add capture deduplication (same method+path within 1 second)
|
- [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
|
- [ ] #15 Add request diff tool: show what changed between two captures
|
||||||
|
|
||||||
## Mapper
|
## Mapper
|
||||||
@@ -27,10 +31,15 @@
|
|||||||
- [ ] #20 Add phishing token passthrough
|
- [ ] #20 Add phishing token passthrough
|
||||||
|
|
||||||
## Admin UI
|
## Admin UI
|
||||||
- [ ] #21 Build a simple web dashboard for viewing captures
|
- [x] #21 Build a simple web dashboard for viewing captures (`GET /_bridge/admin`)
|
||||||
- [ ] #22 Add endpoint status page (known vs unknown vs confirmed)
|
→ Auto-refreshes every 10s; shows stats, endpoint table, recent captures
|
||||||
- [ ] #23 Add live capture stream via SSE
|
- [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
|
## Testing
|
||||||
- [ ] #24 Add test for placeholder response format
|
- [x] #24 Add test for placeholder response format
|
||||||
- [ ] #25 Add integration test that fires real HTTP at the Bridge
|
- [x] #25 Add integration test that fires real HTTP at the Bridge
|
||||||
|
→ 8 new tests: health, placeholder, captures CRUD, status, TLS cert gen
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>OpenFUT Bridge — Admin</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.5; }
|
||||||
|
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
|
||||||
|
header h1 { font-size: 1.1rem; color: #58a6ff; }
|
||||||
|
header .badge { font-size: 0.75rem; background: #238636; color: #fff; padding: 2px 8px; border-radius: 12px; }
|
||||||
|
main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
section { margin-bottom: 32px; }
|
||||||
|
h2 { font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; color: #8b949e; border-bottom: 1px solid #30363d; padding-bottom: 8px; margin-bottom: 16px; }
|
||||||
|
.stats { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||||
|
.stat { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px 24px; min-width: 140px; }
|
||||||
|
.stat .num { font-size: 2rem; font-weight: 700; color: #58a6ff; }
|
||||||
|
.stat .label { font-size: 0.8rem; color: #8b949e; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
th { text-align: left; padding: 8px 12px; background: #161b22; color: #8b949e; border-bottom: 1px solid #30363d; white-space: nowrap; }
|
||||||
|
td { padding: 8px 12px; border-bottom: 1px solid #21262d; vertical-align: top; word-break: break-all; max-width: 400px; }
|
||||||
|
tr:hover td { background: #161b22; }
|
||||||
|
.badge-method { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.GET { background: #1f6feb; color: #fff; }
|
||||||
|
.POST { background: #2ea043; color: #fff; }
|
||||||
|
.PUT { background: #9e6a03; color: #fff; }
|
||||||
|
.DELETE { background: #b91c1c; color: #fff; }
|
||||||
|
.status-mapped { color: #3fb950; }
|
||||||
|
.status-unknown { color: #f85149; }
|
||||||
|
.btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
||||||
|
.btn:hover { background: #30363d; }
|
||||||
|
.btn-danger { border-color: #b91c1c; color: #f85149; }
|
||||||
|
.btn-danger:hover { background: #b91c1c22; }
|
||||||
|
#live-log { height: 220px; overflow-y: auto; background: #010409; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 0.78rem; color: #7ee787; }
|
||||||
|
#live-log .entry { margin-bottom: 4px; }
|
||||||
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.toolbar .spacer { flex: 1; }
|
||||||
|
.empty { color: #8b949e; font-style: italic; font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>OpenFUT Bridge</h1>
|
||||||
|
<span class="badge">Admin Dashboard</span>
|
||||||
|
<span id="status-dot" style="margin-left:auto;font-size:0.8rem;color:#8b949e">connecting…</span>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<div class="stats" id="stats">
|
||||||
|
<div class="stat"><div class="num" id="s-total">—</div><div class="label">Total captures</div></div>
|
||||||
|
<div class="stat"><div class="num" id="s-unknown">—</div><div class="label">Unknown endpoints</div></div>
|
||||||
|
<div class="stat"><div class="num" id="s-endpoints">—</div><div class="label">Unique endpoints</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Live capture stream</h2>
|
||||||
|
<div id="live-log"><span class="empty">Waiting for traffic…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Endpoint status</h2>
|
||||||
|
<div id="endpoints-table"><span class="empty">Loading…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Recent captures</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn" onclick="loadCaptures()">Refresh</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn btn-danger" onclick="clearCaptures()">Delete all captures</button>
|
||||||
|
</div>
|
||||||
|
<div id="captures-table"><span class="empty">Loading…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
async function api(path) {
|
||||||
|
const r = await fetch(path);
|
||||||
|
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodBadge(m) {
|
||||||
|
return `<span class="badge-method ${m}">${m}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/captures');
|
||||||
|
$('s-total').textContent = d.total;
|
||||||
|
$('s-unknown').textContent = d.unknown_endpoints;
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEndpoints() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/status');
|
||||||
|
$('s-endpoints').textContent = d.total_unique_endpoints;
|
||||||
|
if (!d.endpoints.length) {
|
||||||
|
$('endpoints-table').innerHTML = '<span class="empty">No endpoints seen yet.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = d.endpoints.map(ep => `
|
||||||
|
<tr>
|
||||||
|
<td>${methodBadge(ep.method)}</td>
|
||||||
|
<td><code>${ep.path}</code></td>
|
||||||
|
<td class="status-${ep.status}">${ep.status}</td>
|
||||||
|
<td>${ep.mapped_to || '—'}</td>
|
||||||
|
<td>${ep.first_seen ? ep.first_seen.slice(0,19).replace('T',' ') : '—'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('endpoints-table').innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Method</th><th>Path</th><th>Status</th><th>Maps to</th><th>First seen</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) { $('endpoints-table').innerHTML = `<span class="empty">Error: ${e.message}</span>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCaptures() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/captures');
|
||||||
|
if (!d.captures.length) {
|
||||||
|
$('captures-table').innerHTML = '<span class="empty">No captures yet.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = d.captures.slice(-50).reverse().map(c => `
|
||||||
|
<tr>
|
||||||
|
<td>${c.timestamp ? c.timestamp.slice(0,19).replace('T',' ') : ''}</td>
|
||||||
|
<td>${methodBadge(c.method)}</td>
|
||||||
|
<td><code>${c.path}${c.query ? '?' + c.query : ''}</code></td>
|
||||||
|
<td>${c.response_status || '—'}</td>
|
||||||
|
<td class="status-${c.mapped_to_core ? 'mapped' : 'unknown'}">${c.mapped_to_core || 'unknown'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('captures-table').innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Time</th><th>Method</th><th>Path</th><th>Status</th><th>Core mapping</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) { $('captures-table').innerHTML = `<span class="empty">Error: ${e.message}</span>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCaptures() {
|
||||||
|
if (!confirm('Delete all capture files from disk?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/_bridge/captures', { method: 'DELETE' });
|
||||||
|
const d = await r.json();
|
||||||
|
alert(`Deleted ${d.deleted} capture(s).`);
|
||||||
|
loadCaptures(); loadStats();
|
||||||
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
const es = new EventSource('/_bridge/captures/stream');
|
||||||
|
const log = $('live-log');
|
||||||
|
|
||||||
|
es.onopen = () => { $('status-dot').textContent = '● live'; $('status-dot').style.color = '#3fb950'; };
|
||||||
|
es.onerror = () => { $('status-dot').textContent = '○ disconnected'; $('status-dot').style.color = '#f85149'; };
|
||||||
|
|
||||||
|
es.addEventListener('capture', e => {
|
||||||
|
try {
|
||||||
|
const c = JSON.parse(e.data);
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'entry';
|
||||||
|
entry.textContent = `${c.timestamp?.slice(11,19) || ''} ${c.method} ${c.path} → ${c.response_status || '?'} [${c.mapped_to_core || 'unknown'}]`;
|
||||||
|
if (log.firstChild?.className === 'empty') log.innerHTML = '';
|
||||||
|
log.prepend(entry);
|
||||||
|
if (log.children.length > 100) log.lastChild?.remove();
|
||||||
|
loadStats();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadStats();
|
||||||
|
loadEndpoints();
|
||||||
|
loadCaptures();
|
||||||
|
connectSSE();
|
||||||
|
setInterval(() => { loadEndpoints(); loadCaptures(); }, 10_000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! openfut-bridge-replay — replay a captured request file against the bridge.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! openfut-bridge-replay \<capture.json\> [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<String> = std::env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: openfut-bridge-replay <capture.json|dir> [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<Vec<CapturedRequest>> {
|
||||||
|
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<u16> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ pub struct Config {
|
|||||||
pub captures_dir: String,
|
pub captures_dir: String,
|
||||||
/// If true, return placeholder 200 responses for unknown routes
|
/// If true, return placeholder 200 responses for unknown routes
|
||||||
pub placeholder_mode: bool,
|
pub placeholder_mode: bool,
|
||||||
|
/// If true, serve over TLS with a generated self-signed certificate
|
||||||
|
pub tls_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -22,6 +24,9 @@ impl Config {
|
|||||||
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
|
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
|
||||||
.map(|v| v == "true" || v == "1")
|
.map(|v| v == "true" || v == "1")
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
|
tls_enabled: std::env::var("TLS_ENABLED")
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ pub mod error;
|
|||||||
pub mod mapper;
|
pub mod mapper;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod tls;
|
||||||
|
|||||||
+65
-5
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{any, get},
|
routing::{any, delete, get},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
|
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
|
||||||
@@ -22,11 +22,13 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let cfg = Config::from_env()?;
|
let cfg = Config::from_env()?;
|
||||||
let listen_addr = cfg.listen_addr.clone();
|
let listen_addr = cfg.listen_addr.clone();
|
||||||
|
let tls_enabled = cfg.tls_enabled;
|
||||||
|
|
||||||
info!("OpenFUT Bridge starting on {listen_addr}");
|
info!("OpenFUT Bridge starting on {listen_addr}");
|
||||||
info!("Core URL: {}", cfg.core_url);
|
info!("Core URL: {}", cfg.core_url);
|
||||||
info!("Placeholder mode: {}", cfg.placeholder_mode);
|
info!("Placeholder mode: {}", cfg.placeholder_mode);
|
||||||
info!("Captures dir: {}", cfg.captures_dir);
|
info!("Captures dir: {}", cfg.captures_dir);
|
||||||
|
info!("TLS enabled: {tls_enabled}");
|
||||||
|
|
||||||
std::fs::create_dir_all(&cfg.captures_dir)?;
|
std::fs::create_dir_all(&cfg.captures_dir)?;
|
||||||
|
|
||||||
@@ -34,19 +36,77 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/_bridge/health", get(routes::health::get_health))
|
.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", get(routes::admin::get_captures))
|
||||||
|
.route("/_bridge/captures", delete(routes::admin::delete_captures))
|
||||||
|
.route(
|
||||||
|
"/_bridge/captures/stream",
|
||||||
|
get(routes::admin::get_captures_stream),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/_bridge/unknown",
|
"/_bridge/unknown",
|
||||||
get(routes::admin::get_unknown_endpoints),
|
get(routes::admin::get_unknown_endpoints),
|
||||||
)
|
)
|
||||||
|
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
|
||||||
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
|
let addr: std::net::SocketAddr = listen_addr.parse()?;
|
||||||
info!("Bridge listening on http://{listen_addr}");
|
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
|
|
||||||
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<hyper::body::Incoming>| {
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-9
@@ -6,7 +6,12 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
capture::{save_capture, CapturedRequest},
|
capture::{save_capture, CapturedRequest},
|
||||||
@@ -15,24 +20,48 @@ use crate::{
|
|||||||
mapper::{map_to_core, placeholder_response},
|
mapper::{map_to_core, placeholder_response},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CAPTURE_DEDUP_SECS: u64 = 1;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ProxyState {
|
pub struct ProxyState {
|
||||||
pub config: Arc<Config>,
|
pub config: Arc<Config>,
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
|
/// Broadcast channel for streaming new captures to SSE subscribers.
|
||||||
|
pub capture_tx: Arc<broadcast::Sender<CapturedRequest>>,
|
||||||
|
/// Deduplication window: (method+path) → last saved instant.
|
||||||
|
pub dedup: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyState {
|
impl ProxyState {
|
||||||
pub fn new(config: Config) -> Self {
|
pub fn new(config: Config) -> Self {
|
||||||
|
let (capture_tx, _) = broadcast::channel(256);
|
||||||
Self {
|
Self {
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
http_client: reqwest::Client::builder()
|
http_client: reqwest::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to build HTTP client"),
|
.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<HashMap<String, Instant>>, 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(
|
pub async fn catch_all_handler(
|
||||||
State(state): State<ProxyState>,
|
State(state): State<ProxyState>,
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -48,7 +77,6 @@ pub async fn catch_all_handler(
|
|||||||
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
|
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Extract body via axum's built-in mechanism
|
|
||||||
let (_parts, body) = req.into_parts();
|
let (_parts, body) = req.into_parts();
|
||||||
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
|
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
|
||||||
.await
|
.await
|
||||||
@@ -104,13 +132,22 @@ pub async fn catch_all_handler(
|
|||||||
|
|
||||||
capture = capture.with_response(status_code, Some(response_body.to_string()));
|
capture = capture.with_response(status_code, Some(response_body.to_string()));
|
||||||
|
|
||||||
let captures_dir = state.config.captures_dir.clone();
|
// Deduplicate: skip saving if same method+path was saved within 1 second
|
||||||
let capture_clone = capture.clone();
|
let should_save = !is_duplicate(&state.dedup, &method, &path);
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = save_capture(&captures_dir, &capture_clone) {
|
if should_save {
|
||||||
tracing::warn!("Failed to save capture: {e}");
|
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 status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK);
|
||||||
let json_bytes =
|
let json_bytes =
|
||||||
|
|||||||
+103
-7
@@ -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 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.
|
/// List all captured requests.
|
||||||
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
let captures = load_all_captures(&state.config.captures_dir)
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
.map_err(crate::error::BridgeError::Internal)?;
|
|
||||||
|
|
||||||
let unknown: Vec<_> = captures
|
let unknown: Vec<_> = captures
|
||||||
.iter()
|
.iter()
|
||||||
@@ -20,10 +32,9 @@ pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List only unknown (unmapped) endpoints.
|
/// List only unknown (unmapped) endpoints, deduplicated by method+path.
|
||||||
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
let captures = load_all_captures(&state.config.captures_dir)
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
.map_err(crate::error::BridgeError::Internal)?;
|
|
||||||
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
let unknown: Vec<Value> = captures
|
let unknown: Vec<Value> = captures
|
||||||
@@ -49,3 +60,88 @@ pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeRes
|
|||||||
"endpoints": unknown,
|
"endpoints": unknown,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wipe all capture files from disk.
|
||||||
|
pub async fn delete_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
|
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<ProxyState>,
|
||||||
|
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
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<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
|
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<String, Value> = 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<Value> = 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()
|
||||||
|
}
|
||||||
|
|||||||
+44
@@ -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<u8>, Vec<u8>)> {
|
||||||
|
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<TlsAcceptor> {
|
||||||
|
use rustls::{Certificate, PrivateKey, ServerConfig};
|
||||||
|
|
||||||
|
let certs: Vec<Certificate> = 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))
|
||||||
|
}
|
||||||
+166
-1
@@ -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]
|
#[test]
|
||||||
fn test_known_endpoint_maps_to_core() {
|
fn test_known_endpoint_maps_to_core() {
|
||||||
@@ -46,3 +60,154 @@ fn test_capture_with_response() {
|
|||||||
assert_eq!(capture.response_status, Some(200));
|
assert_eq!(capture.response_status, Some(200));
|
||||||
assert!(capture.response_body.is_some());
|
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());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user