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:
funman300
2026-06-25 16:15:48 -07:00
parent a826e5f7d3
commit 3b7a4928c9
12 changed files with 1063 additions and 39 deletions
+65 -5
View File
@@ -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<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}");
}
});
}
}