3b7a4928c9
## 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>
148 lines
4.7 KiB
Rust
148 lines
4.7 KiB
Rust
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::{BridgeError, BridgeResult},
|
|
proxy::ProxyState,
|
|
};
|
|
|
|
/// List all captured requests.
|
|
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
|
|
|
let unknown: Vec<_> = captures
|
|
.iter()
|
|
.filter(|c| c.mapped_to_core.is_none())
|
|
.collect();
|
|
|
|
Ok(Json(json!({
|
|
"total": captures.len(),
|
|
"unknown_endpoints": unknown.len(),
|
|
"captures": captures,
|
|
})))
|
|
}
|
|
|
|
/// List only unknown (unmapped) endpoints, deduplicated by method+path.
|
|
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
let unknown: Vec<Value> = captures
|
|
.iter()
|
|
.filter(|c| c.mapped_to_core.is_none())
|
|
.filter_map(|c| {
|
|
let key = format!("{} {}", c.method, c.path);
|
|
if seen.insert(key) {
|
|
Some(json!({
|
|
"method": c.method,
|
|
"path": c.path,
|
|
"first_seen": c.timestamp,
|
|
"capture_id": c.id,
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(json!({
|
|
"unknown_endpoint_count": unknown.len(),
|
|
"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()
|
|
}
|