Files
foxhunt/crates/web-gateway/src/main.rs
jgrusewski 609f533abc feat: implement all 15 fxt CLI commands with real gRPC calls
- 13 commands with full gRPC implementations: service, train, tune,
  model, trade, broker, agent, data, risk, config, cluster, auth, backtest
- Streaming support: train logs --follow, broker executions --follow
- --json output on every command via OutputFormat/HumanReadable
- Fix web-gateway monitoring URL default (50057 → 50051, API Gateway)
- Rewire 7 remaining build.rs to consolidated proto/ root (web-gateway,
  backtesting_service, training_uploader, 3 test crates, e2e)
- Fix web-gateway ml_training.proto new fields (mode, max_epochs, resume)
- 75 fxt tests + 139 web-gateway tests, 0 clippy warnings, workspace clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:16:35 +01:00

202 lines
7.2 KiB
Rust

#![deny(clippy::unwrap_used, clippy::expect_used)]
use anyhow::{bail, Result};
use axum::extract::Request;
use axum::http::{HeaderValue, Method};
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::extract::DefaultBodyLimit;
use tower_http::cors::{AllowHeaders, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::info;
use web_gateway::config::GatewayConfig;
use web_gateway::grpc::streams::start_grpc_stream_bridges;
use web_gateway::routes::create_router;
use web_gateway::state::AppState;
/// Newtype wrapper for request IDs, stored in request extensions.
#[derive(Clone, Debug)]
pub struct RequestId(pub String);
#[tokio::main]
async fn main() -> Result<()> {
// Initialize observability (JSON logging + OpenTelemetry tracing via OTLP)
let otlp_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4317".to_owned());
if let Err(e) = common::observability::init_observability("web_gateway", Some(&otlp_endpoint)) {
eprintln!("Failed to initialize observability: {}", e);
}
let config = GatewayConfig::from_env();
// Fail fast if JWT secret is not configured or too short
if config.jwt_secret.is_empty() {
bail!("JWT_SECRET environment variable must be set (non-empty) for token validation");
}
if config.jwt_secret.len() < 32 {
bail!("JWT_SECRET must be at least 32 characters for adequate security");
}
let listen_addr = config.listen_addr.clone();
info!("Starting web-gateway on {}", listen_addr);
info!(" Trading service: {}", config.trading_service_url);
info!(" Backtesting service: {}", config.backtesting_service_url);
info!(" ML Training service: {}", config.ml_training_service_url);
info!(" Monitoring (via API Gateway): {}", config.monitoring_service_url);
info!(" CORS origins: {:?}", config.cors_origins);
let state = AppState::new(config.clone()).await?;
// Start gRPC stream bridge tasks (forward gRPC streams to WebSocket broadcast)
start_grpc_stream_bridges(
state.trading_channel.clone(),
state.monitoring_channel.clone(),
state.ws_broadcast.clone(),
config.jwt_secret.clone(),
);
// Build CORS from configured origins with restricted methods/headers
let origins: Vec<HeaderValue> = config
.cors_origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
let cors = CorsLayer::new()
.allow_origin(origins)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
.allow_headers(AllowHeaders::list([
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
]));
let app = create_router(state)
.layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB max request body
.layer(cors)
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(request_id_middleware));
// Initialize Prometheus metrics
web_gateway::metrics::init_metrics();
let service_start = std::time::Instant::now();
// Spawn uptime updater
#[allow(clippy::infinite_loop)]
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
loop {
interval.tick().await;
web_gateway::metrics::update_uptime(service_start);
}
});
// Start Prometheus metrics HTTP endpoint on a separate port
let metrics_port: u16 = std::env::var("METRICS_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(9098);
tokio::spawn(async move {
use axum::{routing::get, Router};
use prometheus::{Encoder, TextEncoder};
async fn metrics_handler() -> String {
let encoder = TextEncoder::new();
let metric_families = prometheus::gather();
let mut buffer = vec![];
drop(encoder.encode(&metric_families, &mut buffer));
String::from_utf8(buffer).unwrap_or_else(|_| String::new())
}
let metrics_app = Router::new().route("/metrics", get(metrics_handler));
let addr = format!("0.0.0.0:{}", metrics_port);
tracing::info!("Prometheus metrics endpoint listening on http://{}", addr);
let metrics_listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
tracing::error!("Failed to bind metrics endpoint {}: {}", addr, e);
return;
}
};
if let Err(e) = axum::serve(metrics_listener, metrics_app).await {
tracing::error!("Metrics server failed: {}", e);
}
});
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
info!("Web gateway listening on {}", listen_addr);
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
/// Middleware that ensures every request has an `X-Request-Id` header.
///
/// If the incoming request already carries an `X-Request-Id` header with a
/// valid UTF-8 value it is preserved; otherwise a new UUID v4 is generated.
/// The ID is:
/// - injected into request extensions as [`RequestId`] for downstream handlers
/// - added to a tracing span so it appears in structured logs
/// - echoed back on the response as an `X-Request-Id` header
async fn request_id_middleware(mut request: Request, next: Next) -> Response {
use tracing::Instrument;
let request_id = request
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(String::from)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// Create span but do NOT call .entered() — Entered is !Send and would
// poison the future across the await point. Use .instrument() instead.
let span = tracing::info_span!("request", request_id = %request_id);
// Store in extensions so handlers can retrieve it via Extension<RequestId>
request
.extensions_mut()
.insert(RequestId(request_id.clone()));
async move {
let mut response = next.run(request).await;
let headers = response.headers_mut();
// Echo the request id back on the response
if let Ok(val) = request_id.parse() {
headers.insert("x-request-id", val);
}
// Security headers (OWASP recommendations)
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
headers.insert("x-xss-protection", HeaderValue::from_static("0"));
headers.insert(
"referrer-policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
headers.insert(
"strict-transport-security",
HeaderValue::from_static("max-age=31536000; includeSubDomains"),
);
response
}
.instrument(span)
.await
}
async fn shutdown_signal() {
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::error!("Failed to install CTRL+C handler: {}", e);
} else {
info!("Shutdown signal received");
}
}