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:
+46
-9
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user