forked from boos/BoosAPI
feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "boosapi-gateway"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
boosapi-core = { path = "../core" }
|
||||
boosapi-providers = { path = "../providers" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
uuid = { workspace = true }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tracing = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
futures = "0.3"
|
||||
async-trait = "0.1"
|
||||
tokio-stream = "0.1"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
@@ -0,0 +1,70 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde_json::json;
|
||||
|
||||
use boosapi_core::types::GatewayKey;
|
||||
|
||||
/// Authenticated key injected into request extensions after auth middleware.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedKey(pub GatewayKey);
|
||||
|
||||
/// Auth middleware: extracts Bearer token, verifies against gateway_keys, updates last_used_at.
|
||||
pub async fn auth_middleware(
|
||||
State(pool): State<DatabaseConnection>,
|
||||
mut request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
let token = match auth_header {
|
||||
Some(h) if h.starts_with("Bearer ") => &h[7..],
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": "Missing or invalid Authorization header. Expected: Bearer <api-key>",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match boosapi_core::repo::gateway::verify_key(&pool, token).await {
|
||||
Ok(key) => {
|
||||
// Update last_used_at in background (non-blocking)
|
||||
let pool_bg = pool.clone();
|
||||
let key_id = key.id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = boosapi_core::repo::gateway::update_last_used(&pool_bg, &key_id).await;
|
||||
});
|
||||
|
||||
request.extensions_mut().insert(AuthenticatedKey(key));
|
||||
next.run(request).await
|
||||
}
|
||||
Err(_) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": "Invalid or disabled API key",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod handlers;
|
||||
pub mod middleware;
|
||||
pub mod native;
|
||||
pub mod realtime;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Gateway middleware — auth middleware is in auth.rs
|
||||
// This module can be extended with rate limiting, logging, etc.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
Query, State, WebSocketUpgrade,
|
||||
},
|
||||
response::Response,
|
||||
};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::server::GatewayAppState;
|
||||
|
||||
// --- Client → Server messages ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum RealtimeClientMessage {
|
||||
#[serde(rename = "session.create")]
|
||||
SessionCreate { model: String },
|
||||
#[serde(rename = "input_audio_buffer.append")]
|
||||
AudioAppend { audio: String },
|
||||
#[serde(rename = "input_audio_buffer.commit")]
|
||||
AudioCommit,
|
||||
#[serde(rename = "session.close")]
|
||||
SessionClose,
|
||||
}
|
||||
|
||||
// --- Server → Client messages ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[allow(dead_code)]
|
||||
enum RealtimeServerMessage {
|
||||
#[serde(rename = "session.created")]
|
||||
SessionCreated { session_id: String },
|
||||
#[serde(rename = "response.audio.delta")]
|
||||
AudioDelta { delta: String },
|
||||
#[serde(rename = "response.text.delta")]
|
||||
TextDelta { delta: String },
|
||||
#[serde(rename = "response.done")]
|
||||
ResponseDone,
|
||||
#[serde(rename = "error")]
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RealtimeQuery {
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /v1/realtime — WebSocket upgrade with query-param auth
|
||||
pub async fn realtime_handler(
|
||||
State(state): State<GatewayAppState>,
|
||||
Query(params): Query<RealtimeQuery>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
let api_key = match params.api_key {
|
||||
Some(k) if !k.is_empty() => k,
|
||||
_ => {
|
||||
return Response::builder()
|
||||
.status(401)
|
||||
.body(axum::body::Body::from(
|
||||
r#"{"error":{"message":"Missing api_key query parameter","type":"invalid_request_error","code":"invalid_api_key"}}"#,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify key before upgrading
|
||||
match boosapi_core::repo::gateway::verify_key(&state.db, &api_key).await {
|
||||
Ok(key) => {
|
||||
// Update last_used_at in background
|
||||
let pool_bg = state.db.clone();
|
||||
let key_id = key.id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = boosapi_core::repo::gateway::update_last_used(&pool_bg, &key_id).await;
|
||||
});
|
||||
|
||||
ws.on_upgrade(move |socket| handle_realtime_session(socket, state.db))
|
||||
}
|
||||
Err(_) => Response::builder()
|
||||
.status(401)
|
||||
.body(axum::body::Body::from(
|
||||
r#"{"error":{"message":"Invalid or disabled API key","type":"invalid_request_error","code":"invalid_api_key"}}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_realtime_session(mut socket: WebSocket, _db: DatabaseConnection) {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let mut audio_buffer: Vec<String> = Vec::new();
|
||||
let mut _model: Option<String> = None;
|
||||
let mut session_created = false;
|
||||
|
||||
while let Some(msg_result) = socket.recv().await {
|
||||
let msg = match msg_result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::debug!("WebSocket recv error: {}", e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Close(_) => break,
|
||||
Message::Ping(data) => {
|
||||
if socket.send(Message::Pong(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let client_msg: RealtimeClientMessage = match serde_json::from_str(&text) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
let _ = send_msg(
|
||||
&mut socket,
|
||||
&RealtimeServerMessage::Error {
|
||||
message: format!("Invalid message: {}", e),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match client_msg {
|
||||
RealtimeClientMessage::SessionCreate { model } => {
|
||||
_model = Some(model);
|
||||
session_created = true;
|
||||
if send_msg(
|
||||
&mut socket,
|
||||
&RealtimeServerMessage::SessionCreated {
|
||||
session_id: session_id.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RealtimeClientMessage::AudioAppend { audio } => {
|
||||
if !session_created {
|
||||
let _ = send_msg(
|
||||
&mut socket,
|
||||
&RealtimeServerMessage::Error {
|
||||
message: "Session not created. Send session.create first.".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
audio_buffer.push(audio);
|
||||
}
|
||||
|
||||
RealtimeClientMessage::AudioCommit => {
|
||||
if !session_created {
|
||||
let _ = send_msg(
|
||||
&mut socket,
|
||||
&RealtimeServerMessage::Error {
|
||||
message: "Session not created. Send session.create first.".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stub: echo back a text response instead of forwarding to a provider
|
||||
audio_buffer.clear();
|
||||
|
||||
let send_ok = send_msg(
|
||||
&mut socket,
|
||||
&RealtimeServerMessage::TextDelta {
|
||||
delta: "Realtime voice is not yet connected to a provider".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
&& send_msg(&mut socket, &RealtimeServerMessage::ResponseDone)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
if !send_ok {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RealtimeClientMessage::SessionClose => {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("Realtime session {} closed", session_id);
|
||||
}
|
||||
|
||||
async fn send_msg(socket: &mut WebSocket, msg: &RealtimeServerMessage) -> Result<(), axum::Error> {
|
||||
let json = serde_json::to_string(msg).unwrap();
|
||||
socket.send(Message::Text(json.into())).await
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use axum::{
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use crate::auth::auth_middleware;
|
||||
use crate::handlers::{chat_completions, health_check, list_models};
|
||||
use crate::native::{
|
||||
anthropic_count_tokens, anthropic_messages, gemini_list_models, gemini_model_operation,
|
||||
openai_responses,
|
||||
};
|
||||
use crate::realtime::realtime_handler;
|
||||
use crate::server::GatewayAppState;
|
||||
|
||||
pub fn create_router(state: GatewayAppState) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
// Protected routes (require auth)
|
||||
let protected = Router::new()
|
||||
.route("/v1/chat/completions", post(chat_completions))
|
||||
.route("/v1/responses", post(openai_responses))
|
||||
.route("/v1/messages", post(anthropic_messages))
|
||||
.route("/v1/messages/count_tokens", post(anthropic_count_tokens))
|
||||
.route("/v1/models", get(list_models))
|
||||
.route("/v1beta/models", get(gemini_list_models))
|
||||
.route(
|
||||
"/v1beta/models/{model_action}",
|
||||
post(gemini_model_operation),
|
||||
)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.db.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
// Public routes (auth handled internally for realtime)
|
||||
let public = Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.route("/v1/realtime", get(realtime_handler));
|
||||
|
||||
Router::new()
|
||||
.merge(protected)
|
||||
.merge(public)
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use boosapi_core::db::create_test_pool;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn test_state(db: sea_orm::DatabaseConnection) -> GatewayAppState {
|
||||
GatewayAppState {
|
||||
db,
|
||||
master_key: [7u8; 32],
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_protected_route_exists(method: Method, uri: &str) {
|
||||
let handle = create_test_pool().await.unwrap();
|
||||
let app = create_router(test_state(handle.conn));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"expected protected route {} {} to reject missing auth instead of 404/405",
|
||||
uri,
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_protocol_routes_require_auth() {
|
||||
assert_protected_route_exists(Method::POST, "/v1/responses").await;
|
||||
assert_protected_route_exists(Method::POST, "/v1/messages").await;
|
||||
assert_protected_route_exists(Method::POST, "/v1/messages/count_tokens").await;
|
||||
assert_protected_route_exists(Method::GET, "/v1beta/models").await;
|
||||
assert_protected_route_exists(
|
||||
Method::POST,
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
)
|
||||
.await;
|
||||
assert_protected_route_exists(
|
||||
Method::POST,
|
||||
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
|
||||
)
|
||||
.await;
|
||||
assert_protected_route_exists(Method::POST, "/v1beta/models/gemini-2.5-pro:countTokens")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State as AxumState,
|
||||
http::{header, Request, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Router,
|
||||
};
|
||||
use axum_server::{tls_rustls::RustlsConfig, Handle};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
|
||||
/// Shared state for Axum handlers (separate from Tauri AppState).
|
||||
#[derive(Clone)]
|
||||
pub struct GatewayAppState {
|
||||
pub db: DatabaseConnection,
|
||||
pub master_key: [u8; 32],
|
||||
}
|
||||
|
||||
/// TLS certificate material.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GatewayTlsConfig {
|
||||
pub cert_path: String,
|
||||
pub key_path: String,
|
||||
}
|
||||
|
||||
/// SSL listener configuration: port number plus TLS certificate material.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GatewaySslConfig {
|
||||
pub ssl_port: u16,
|
||||
pub tls: GatewayTlsConfig,
|
||||
}
|
||||
|
||||
/// Full configuration passed to [`GatewayServer::start`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GatewayStartConfig {
|
||||
pub listen_address: String,
|
||||
pub http_port: u16,
|
||||
/// `None` means HTTP-only mode.
|
||||
pub ssl: Option<GatewaySslConfig>,
|
||||
/// When `true` and `ssl` is `Some`, the HTTP listener returns 302 redirects
|
||||
/// to the HTTPS URL instead of serving the gateway directly.
|
||||
pub force_ssl: bool,
|
||||
}
|
||||
|
||||
// ─── SSL redirect handler ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RedirectState {
|
||||
https_port: u16,
|
||||
}
|
||||
|
||||
async fn ssl_redirect_handler(
|
||||
AxumState(state): AxumState<RedirectState>,
|
||||
req: Request<Body>,
|
||||
) -> Response {
|
||||
let host_header = req
|
||||
.headers()
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("localhost");
|
||||
|
||||
// Strip any existing port from the Host header, handling bracketed IPv6.
|
||||
let bare_host = if host_header.starts_with('[') {
|
||||
// Bracketed IPv6: "[::1]:port" → "[::1]", or "[::1]" → "[::1]".
|
||||
match host_header.find("]:") {
|
||||
Some(pos) => &host_header[..pos + 1],
|
||||
None => host_header,
|
||||
}
|
||||
} else {
|
||||
match host_header.rfind(':') {
|
||||
Some(pos) => &host_header[..pos],
|
||||
None => host_header,
|
||||
}
|
||||
};
|
||||
|
||||
let path_and_query = req
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or("/");
|
||||
|
||||
let location = format!(
|
||||
"https://{}:{}{}",
|
||||
bare_host, state.https_port, path_and_query
|
||||
);
|
||||
// 307 preserves the request method (POST stays POST), unlike 302.
|
||||
(
|
||||
StatusCode::TEMPORARY_REDIRECT,
|
||||
[(header::LOCATION, location)],
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn create_redirect_router(https_port: u16) -> Router {
|
||||
Router::new()
|
||||
.fallback(ssl_redirect_handler)
|
||||
.with_state(RedirectState { https_port })
|
||||
}
|
||||
|
||||
// ─── GatewayServer ────────────────────────────────────────────────────────
|
||||
|
||||
pub struct GatewayServer {
|
||||
http_handle: Handle,
|
||||
http_task: Option<JoinHandle<()>>,
|
||||
http_addr: SocketAddr,
|
||||
https_handle: Option<Handle>,
|
||||
https_task: Option<JoinHandle<()>>,
|
||||
https_addr: Option<SocketAddr>,
|
||||
force_ssl: bool,
|
||||
running: Arc<AtomicBool>,
|
||||
started_at: i64,
|
||||
}
|
||||
|
||||
impl GatewayServer {
|
||||
pub async fn start(
|
||||
pool: DatabaseConnection,
|
||||
master_key: [u8; 32],
|
||||
config: GatewayStartConfig,
|
||||
) -> Result<Self> {
|
||||
let app_state = GatewayAppState {
|
||||
db: pool,
|
||||
master_key,
|
||||
};
|
||||
|
||||
// ── Bind HTTP listener ──────────────────────────────────────────
|
||||
let http_bind: SocketAddr = format!("{}:{}", config.listen_address, config.http_port)
|
||||
.parse()
|
||||
.map_err(|e| BoosApiError::Gateway(format!("Invalid HTTP bind address: {}", e)))?;
|
||||
let http_listener = std::net::TcpListener::bind(http_bind)
|
||||
.map_err(|e| BoosApiError::Gateway(format!("Failed to bind HTTP port: {}", e)))?;
|
||||
http_listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| BoosApiError::Gateway(format!("Failed to set nonblocking: {}", e)))?;
|
||||
let http_actual_addr = http_listener
|
||||
.local_addr()
|
||||
.map_err(|e| BoosApiError::Gateway(format!("Failed to get HTTP local addr: {}", e)))?;
|
||||
|
||||
// ── Optionally bind HTTPS listener and load TLS config ──────────
|
||||
struct HttpsBinding {
|
||||
listener: std::net::TcpListener,
|
||||
rustls: RustlsConfig,
|
||||
addr: SocketAddr,
|
||||
}
|
||||
let https_binding: Option<HttpsBinding> = match &config.ssl {
|
||||
Some(ssl_cfg) => {
|
||||
let https_bind: SocketAddr =
|
||||
format!("{}:{}", config.listen_address, ssl_cfg.ssl_port)
|
||||
.parse()
|
||||
.map_err(|e| {
|
||||
BoosApiError::Gateway(format!("Invalid HTTPS bind address: {}", e))
|
||||
})?;
|
||||
let listener = std::net::TcpListener::bind(https_bind).map_err(|e| {
|
||||
BoosApiError::Gateway(format!("Failed to bind HTTPS port: {}", e))
|
||||
})?;
|
||||
listener.set_nonblocking(true).map_err(|e| {
|
||||
BoosApiError::Gateway(format!("Failed to set HTTPS nonblocking: {}", e))
|
||||
})?;
|
||||
let addr = listener.local_addr().map_err(|e| {
|
||||
BoosApiError::Gateway(format!("Failed to get HTTPS local addr: {}", e))
|
||||
})?;
|
||||
let rustls =
|
||||
RustlsConfig::from_pem_file(&ssl_cfg.tls.cert_path, &ssl_cfg.tls.key_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
BoosApiError::Gateway(format!("Failed to load TLS certificate: {}", e))
|
||||
})?;
|
||||
Some(HttpsBinding {
|
||||
listener,
|
||||
rustls,
|
||||
addr,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let https_actual_addr = https_binding.as_ref().map(|b| b.addr);
|
||||
|
||||
// ── Build router(s) ─────────────────────────────────────────────
|
||||
// HTTP router: redirect (force_ssl) or full gateway.
|
||||
// HTTPS router: always the full gateway when SSL is configured.
|
||||
let http_router: Router = if config.force_ssl && https_actual_addr.is_some() {
|
||||
create_redirect_router(https_actual_addr.unwrap().port())
|
||||
} else {
|
||||
crate::routes::create_router(app_state.clone())
|
||||
};
|
||||
let https_router: Option<Router> = if https_binding.is_some() {
|
||||
Some(crate::routes::create_router(app_state))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ── Spawn HTTP task ─────────────────────────────────────────────
|
||||
// Pre-create the HTTPS Handle (when HTTPS will be active) before
|
||||
// spawning the HTTP task so that each task holds a clone of its
|
||||
// sibling's handle for mutual-shutdown: if one listener exits
|
||||
// unexpectedly, it triggers a graceful shutdown of the other so
|
||||
// the gateway never ends up half-dead.
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let http_handle = Handle::new();
|
||||
let https_handle: Option<Handle> = if https_binding.is_some() && https_router.is_some() {
|
||||
Some(Handle::new())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let http_task = {
|
||||
let server_handle = http_handle.clone();
|
||||
let running_flag = running.clone();
|
||||
let router = http_router;
|
||||
let addr = http_actual_addr;
|
||||
let peer_handle = https_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("Gateway HTTP listener on http://{}", addr);
|
||||
let result = axum_server::from_tcp(http_listener)
|
||||
.handle(server_handle)
|
||||
.serve(router.into_make_service())
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Gateway HTTP server error: {}", e);
|
||||
}
|
||||
// Shut down the sibling HTTPS listener if still running.
|
||||
if let Some(h) = peer_handle {
|
||||
h.graceful_shutdown(Some(Duration::from_secs(5)));
|
||||
}
|
||||
running_flag.store(false, Ordering::SeqCst);
|
||||
tracing::info!("Gateway HTTP server stopped");
|
||||
})
|
||||
};
|
||||
|
||||
// ── Spawn HTTPS task (when SSL is configured) ───────────────────
|
||||
let https_task = match (https_binding, https_router) {
|
||||
(Some(binding), Some(router)) => {
|
||||
// Reuse the pre-created handle so the HTTP task already
|
||||
// holds a clone for mutual-shutdown.
|
||||
let server_handle = https_handle
|
||||
.as_ref()
|
||||
.expect("handle pre-created above")
|
||||
.clone();
|
||||
let addr = binding.addr;
|
||||
let running_flag = running.clone();
|
||||
let peer_handle = http_handle.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
tracing::info!("Gateway HTTPS listener on https://{}", addr);
|
||||
let result = axum_server::from_tcp_rustls(binding.listener, binding.rustls)
|
||||
.handle(server_handle)
|
||||
.serve(router.into_make_service())
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Gateway HTTPS server error: {}", e);
|
||||
}
|
||||
// Shut down the sibling HTTP listener if still running.
|
||||
peer_handle.graceful_shutdown(Some(Duration::from_secs(5)));
|
||||
running_flag.store(false, Ordering::SeqCst);
|
||||
tracing::info!("Gateway HTTPS server stopped");
|
||||
});
|
||||
Some(task)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
http_handle,
|
||||
http_task: Some(http_task),
|
||||
http_addr: http_actual_addr,
|
||||
https_handle,
|
||||
https_task,
|
||||
https_addr: https_actual_addr,
|
||||
force_ssl: config.force_ssl,
|
||||
running,
|
||||
started_at: boosapi_core::utils::now_ts(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop(&mut self) -> Result<()> {
|
||||
// Signal graceful shutdown on both listeners.
|
||||
self.http_handle
|
||||
.graceful_shutdown(Some(Duration::from_secs(5)));
|
||||
if let Some(ref h) = self.https_handle {
|
||||
h.graceful_shutdown(Some(Duration::from_secs(5)));
|
||||
}
|
||||
// Await both tasks.
|
||||
if let Some(task) = self.http_task.take() {
|
||||
let _ = task.await;
|
||||
}
|
||||
if let Some(task) = self.https_task.take() {
|
||||
let _ = task.await;
|
||||
}
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Bound address of the HTTP listener.
|
||||
pub fn http_addr(&self) -> SocketAddr {
|
||||
self.http_addr
|
||||
}
|
||||
|
||||
/// Bound address of the HTTPS listener, or `None` if SSL is not active.
|
||||
pub fn https_addr(&self) -> Option<SocketAddr> {
|
||||
self.https_addr
|
||||
}
|
||||
|
||||
pub fn force_ssl(&self) -> bool {
|
||||
self.force_ssl
|
||||
}
|
||||
|
||||
pub fn started_at(&self) -> i64 {
|
||||
self.started_at
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user