- 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>
202 lines
7.2 KiB
Rust
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");
|
|
}
|
|
}
|