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
+46 -9
View File
@@ -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<Config>,
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 {
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<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(
State(state): State<ProxyState>,
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()))
.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 =