use anyhow::Result;
use async_trait::async_trait;
use axum::{Router, body::Body, response::Response, routing::get};
use std::sync::Arc;
use oxios_gateway::surface::{Surface, SurfaceContext, SurfaceHandle};
use crate::api::api_docs;
use crate::api::bridge::{WebBridge, WebBridgeHandle};
use crate::api::middleware::RateLimiter;
use crate::api::routes;
use crate::api::server::AppState;
use oxios_gateway::ReliabilityLayer;
fn fs_read(dist: &std::path::Path, path: &str) -> Option<Vec<u8>> {
let clean = path.trim_start_matches('/');
let p = std::path::Path::new(clean);
if p.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
}) {
return None;
}
let file_path = dist.join(clean);
let canon_file = file_path.canonicalize().ok()?;
let canon_dist = dist.canonicalize().ok()?;
if !canon_file.starts_with(&canon_dist) {
return None;
}
std::fs::read(&canon_file).ok()
}
fn mime_type(path: &str) -> axum::http::HeaderValue {
let clean = path.trim_start_matches('/');
mime_guess::from_path(clean)
.first_or_octet_stream()
.to_string()
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("application/octet-stream"))
}
fn is_immutable_asset(path: &str) -> bool {
let clean = path.trim_start_matches('/');
clean.starts_with("assets/")
}
fn read_active_version(dist: &std::path::Path) -> String {
#[derive(serde::Deserialize)]
struct VersionFile {
version: Option<String>,
}
std::fs::read(dist.join("version.json"))
.ok()
.and_then(|b| serde_json::from_slice::<VersionFile>(&b).ok())
.and_then(|v| v.version)
.unwrap_or_else(|| "dev".to_string())
}
fn is_loopback_host(host: &str) -> bool {
let h = host.trim().to_ascii_lowercase();
if h.is_empty() || h == "localhost" {
return true;
}
let h = h.trim_start_matches('[').trim_end_matches(']');
if h == "::1" {
return true;
}
if let Some(rest) = h.strip_prefix("127.")
&& let Some(first) = rest.split('.').next()
&& first.bytes().all(|b| b.is_ascii_digit())
{
return true;
}
false
}
fn serve_file(dist: Option<&std::path::Path>, path: &str) -> Response {
let clean = path.trim_start_matches('/');
if let Some(d) = dist {
let data = fs_read(d, clean).or_else(|| fs_read(d, &format!("assets/{clean}")));
let Some(data) = data else {
return Response::builder().status(404).body(Body::empty()).unwrap();
};
let lookup = if clean.starts_with("assets/") {
clean.to_string()
} else {
format!("assets/{clean}")
};
let cache = if is_immutable_asset(&lookup) {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
return Response::builder()
.status(200)
.header("Content-Type", mime_type(&lookup))
.header("Cache-Control", cache)
.body(Body::from(data))
.unwrap();
}
Response::builder()
.status(503)
.header("Retry-After", "5")
.body(Body::from("Web UI dist not available yet — retry shortly"))
.unwrap()
}
async fn static_handler(
path: axum::extract::Path<String>,
state: axum::extract::State<Arc<AppState>>,
) -> Response {
let dist = state.web_dist.path();
serve_file(dist.as_deref(), &path)
}
async fn spa_handler(axum::extract::State(state): axum::extract::State<Arc<AppState>>) -> Response {
let dist = state.web_dist.path();
if let Some(ref d) = dist
&& let Some(data) = fs_read(d, "index.html")
{
let version = read_active_version(d);
return Response::builder()
.status(200)
.header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache")
.header("X-Web-Version", version)
.body(Body::from(data))
.unwrap();
}
Response::builder()
.status(503)
.header("Retry-After", "5")
.body(Body::from("Web UI dist not available yet — retry shortly"))
.unwrap()
}
pub struct WebSurface;
impl WebSurface {
pub fn new() -> Self {
Self
}
}
impl Default for WebSurface {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Surface for WebSurface {
fn name(&self) -> &str {
"web"
}
async fn start(&self, ctx: SurfaceContext) -> Result<SurfaceHandle> {
let config = ctx.config.read().clone();
let host = config.gateway.host.clone();
let port = config.gateway.port;
let auth_enabled = config.security.auth_enabled;
let is_loopback = is_loopback_host(&host);
if !auth_enabled && !is_loopback {
tracing::error!(
host = %host,
port,
"SECURITY: HTTP API is binding to a non-loopback interface \
({host}) with auth_enabled=false. Destructive endpoints \
(MCP server spawn, update/run, config write, backup) will \
be reachable WITHOUT authentication. Set \
[security].auth_enabled=true, or bind to 127.0.0.1/::1/localhost."
);
eprintln!(
"⚠️ Oxios: HTTP API binding to {host}:{port} with auth_enabled=false.\n\
⚠️ Destructive endpoints are UNAUTHENTICATED. Set auth_enabled=true \
or bind to a loopback address."
);
}
let rate_limit = config.security.rate_limit_per_minute;
let web_dist = ctx.web_dist;
let web_channel = WebBridge::new(256, Arc::new(ReliabilityLayer::new(Default::default())));
let response_timeout = std::time::Duration::from_secs(config.gateway.response_timeout_secs);
let bridge_handle =
WebBridgeHandle::from_bridge(&web_channel).with_response_timeout(response_timeout);
let state = Arc::new(AppState {
base_url: format!("http://{host}:{port}"),
kernel: ctx.kernel.clone(),
bridge: bridge_handle,
config: ctx.config.clone(),
config_path: ctx.config_path.clone(),
start_time: ctx.kernel.start_time(),
rate_limiter: RateLimiter::new(rate_limit),
memory_map_cache: routes::MemoryMapCache::default(),
web_dist,
readiness: ctx.kernel.readiness.clone(),
});
let api_routes = routes::build_routes(state.clone());
let cors_origins: Vec<_> = config
.security
.cors_origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect();
let cors = tower_http::cors::CorsLayer::new()
.allow_origin(cors_origins)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any);
let should_expose_docs = state.config.read().gateway.should_expose_api_docs();
let spa_routes: Router<Arc<AppState>> = Router::new()
.route("/assets/{*path}", get(static_handler))
.route("/favicon.svg", get(static_handler))
.route("/icons.svg", get(static_handler))
.route("/{*path}", get(spa_handler))
.route("/", get(spa_handler));
let mut app = Router::new()
.merge(api_routes)
.merge(spa_routes)
.layer(cors);
if should_expose_docs {
let openapi = api_docs::build_openapi();
let swagger: Router<()> = utoipa_swagger_ui::SwaggerUi::new("/api-docs")
.url("/openapi.json", openapi)
.into();
app = app.nest_service("/api-docs", swagger);
tracing::info!("API docs exposed at /api-docs and /openapi.json");
} else {
tracing::info!(
"API docs disabled (set gateway.expose_api_docs=true on a loopback bind to enable)"
);
}
let app = app.with_state(state);
let addr = format!("{host}:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(addr = %addr, "Web server listening");
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.ok();
tracing::info!("Web server shutting down");
})
.await
{
tracing::error!(error = %e, "Web server error");
}
});
Ok(SurfaceHandle {
channel: Some(Box::new(web_channel)),
tasks: vec![handle],
})
}
}