use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use aa_core::config::RemoteModeConfig;
use axum::{routing::get, Extension, Router};
use axum_server::tls_rustls::RustlsConfig;
use axum_server::Handle;
use super::error::GatewayError;
use super::tls::{self, TlsValidation};
use crate::routes::admin_status::{admin_status, AdminStatusState};
use crate::routes::healthz::{healthz, HealthzState};
use crate::storage::{open_postgres_backend, PostgresConfig, StorageBackend};
pub fn router(storage: Option<Arc<dyn StorageBackend>>, database_url: Option<String>) -> Router {
let storage_label = if storage.is_some() { "postgres" } else { "memory" };
let healthz_state = HealthzState::new("remote", storage_label);
let mut app = Router::new()
.route("/healthz", get(healthz))
.layer(Extension(healthz_state));
if let Some(backend) = storage {
let admin_state = AdminStatusState::new("remote", backend, None, database_url);
app = app
.route("/api/v1/admin/status", get(admin_status))
.layer(Extension(admin_state));
}
app
}
fn log_startup_banner(cfg: &RemoteModeConfig) {
let scheme = if cfg.tls.is_some() { "https" } else { "http" };
tracing::info!(
scheme,
addr = %cfg.listen_addr,
storage = "memory",
version = env!("CARGO_PKG_VERSION"),
"Agent Assembly [remote mode] starting on {scheme}://{}",
cfg.listen_addr
);
}
const GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_secs(30);
pub async fn start_remote(cfg: &RemoteModeConfig) -> Result<(), GatewayError> {
let handle = Handle::new();
let signal_handle = handle.clone();
tokio::spawn(async move {
if let Err(err) = wait_for_shutdown_signal().await {
tracing::error!(error = %err, "shutdown signal listener failed");
signal_handle.shutdown();
return;
}
tracing::info!(
secs = GRACEFUL_SHUTDOWN_BUDGET.as_secs(),
"shutdown signal received — draining in-flight requests"
);
signal_handle.graceful_shutdown(Some(GRACEFUL_SHUTDOWN_BUDGET));
});
start_remote_with_handle(cfg, handle).await
}
async fn wait_for_shutdown_signal() -> Result<(), GatewayError> {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).map_err(GatewayError::Signal)?;
tokio::select! {
res = ctrl_c => res.map_err(GatewayError::Signal),
_ = sigterm.recv() => Ok(()),
}
}
#[cfg(not(unix))]
{
ctrl_c.await.map_err(GatewayError::Signal)
}
}
pub async fn start_remote_with_handle(cfg: &RemoteModeConfig, handle: Handle<SocketAddr>) -> Result<(), GatewayError> {
log_startup_banner(cfg);
let storage: Option<Arc<dyn StorageBackend>> = if let Some(url) = cfg.database_url.as_ref() {
let pg = PostgresConfig {
database_url: Some(url.clone()),
..PostgresConfig::default()
};
Some(open_postgres_backend(&pg).await.map_err(GatewayError::Storage)?)
} else {
None
};
let app = router(storage, cfg.database_url.clone()).into_make_service();
if let Some(tls_cfg) = &cfg.tls {
match tls::validate(tls_cfg)? {
TlsValidation::Ok => {}
TlsValidation::ExpiringSoon { days_until_expiry } => {
tracing::warn!(
days_until_expiry,
"⚠ TLS cert expires within 30 days — rotate before notAfter"
);
}
TlsValidation::Expired { expired_days_ago } => {
tracing::error!(
expired_days_ago,
"TLS cert has already expired — new TLS clients will reject the chain"
);
}
}
let rustls_cfg = RustlsConfig::from_pem_file(&tls_cfg.cert_file, &tls_cfg.key_file)
.await
.map_err(GatewayError::TlsLoad)?;
axum_server::bind_rustls(cfg.listen_addr, rustls_cfg)
.handle(handle)
.serve(app)
.await
.map_err(GatewayError::Serve)
} else {
tracing::warn!("⚠ TLS not configured — running over plain HTTP");
axum_server::bind(cfg.listen_addr)
.handle(handle)
.serve(app)
.await
.map_err(GatewayError::Serve)
}
}