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
+184
View File
@@ -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>
+113
View File
@@ -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())
}
+5
View File
@@ -10,6 +10,8 @@ pub struct Config {
pub captures_dir: String,
/// If true, return placeholder 200 responses for unknown routes
pub placeholder_mode: bool,
/// If true, serve over TLS with a generated self-signed certificate
pub tls_enabled: bool,
}
impl Config {
@@ -22,6 +24,9 @@ impl Config {
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
.map(|v| v == "true" || v == "1")
.unwrap_or(true),
tls_enabled: std::env::var("TLS_ENABLED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false),
})
}
}
+1
View File
@@ -4,3 +4,4 @@ pub mod error;
pub mod mapper;
pub mod proxy;
pub mod routes;
pub mod tls;
+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}");
}
});
}
}
+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 =
+103 -7
View File
@@ -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 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.
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
let captures = load_all_captures(&state.config.captures_dir)
.map_err(crate::error::BridgeError::Internal)?;
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
let unknown: Vec<_> = captures
.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>> {
let captures = load_all_captures(&state.config.captures_dir)
.map_err(crate::error::BridgeError::Internal)?;
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
@@ -49,3 +60,88 @@ pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeRes
"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
View File
@@ -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))
}