//! Archify Sync server: live, shared Archify models. mod api; mod auth; mod config; mod doc; mod error; mod hub; mod metrics; mod oidc; mod pages; mod state; use std::time::Duration; use anyhow::Result; use archify_sync_store::Store; use axum::Router; use axum::extract::DefaultBodyLimit; use axum::routing::{delete, get, post}; use tower_http::trace::TraceLayer; use tracing_subscriber::EnvFilter; use crate::config::Config; use crate::state::AppState; #[tokio::main] async fn main() -> Result<()> { // The image has no shell or curl; `archify-sync-server healthcheck` is its probe. if std::env::args().nth(1).as_deref() == Some("healthcheck") { return healthcheck().await; } // reqwest (single sign-on) and sqlx share rustls with the ring provider. let _ = rustls::crypto::ring::default_provider().install_default(); let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("info,tower_http=warn,sqlx=warn")); if std::env::var("ARCHIFY_SYNC_LOG_JSON").is_ok_and(|v| v == "true") { tracing_subscriber::fmt() .with_env_filter(filter) .json() .init(); } else { tracing_subscriber::fmt().with_env_filter(filter).init(); } let config = Config::from_env()?; let store = Store::connect(&config.database_url, config.database_max_connections).await?; store.migrate().await?; let listen = config.listen; let state = AppState::new(config, store).await?; let background = tokio::spawn(housekeeping(state.clone())); if let Some(address) = state.config.metrics_listen { let listener = tokio::net::TcpListener::bind(address).await?; tracing::info!(listen = %address, "metrics listening"); let app = Router::new() .route("/metrics", get(metrics::serve)) .with_state(state.clone()); tokio::spawn(async move { axum::serve(listener, app).await }); } let listener = tokio::net::TcpListener::bind(listen).await?; tracing::info!(%listen, version = env!("CARGO_PKG_VERSION"), "Archify Sync listening"); axum::serve(listener, router(state.clone())) .with_graceful_shutdown(shutdown()) .await?; background.abort(); state.hub.snapshot_all().await; tracing::info!("stopped"); Ok(()) } pub fn router(state: AppState) -> Router { let v1 = Router::new() .route("/info", get(api::info)) .route("/setup", post(api::setup)) .route("/auth/token", post(api::token)) .route("/auth/logout", post(api::logout)) .route("/me", get(api::me)) .route("/spaces", get(api::list_spaces).post(api::create_space)) .route("/spaces/{space}", get(api::get_space)) .route("/spaces/{space}/members", get(api::members)) .route( "/spaces/{space}/members/{account}", delete(api::remove_member), ) .route("/spaces/{space}/invites", post(api::create_invite)) .route("/invites/{token}/accept", post(api::accept_invite)) .route( "/spaces/{space}/versions", get(api::versions).post(api::publish), ) .route( "/spaces/{space}/versions/{version}/files", get(api::version_files), ) .route( "/spaces/{space}/versions/{version}/restore", post(api::restore), ) .route("/spaces/{space}/sync", get(api::sync)); Router::new() .nest("/v1", v1) .route("/healthz", get(api::healthz)) .route("/readyz", get(api::readyz)) .route("/setup", get(pages::setup_page).post(pages::setup_submit)) .route( "/authorize", get(pages::authorize_page).post(pages::authorize_submit), ) .route("/join/{token}", get(pages::join_page)) .route("/oidc/start", get(pages::oidc_start)) .route("/oidc/callback", get(pages::oidc_callback)) .layer(DefaultBodyLimit::max(1024 * 1024)) .layer(TraceLayer::new_for_http()) .with_state(state) } /// Idle snapshots every few seconds; expired sessions once an hour. async fn housekeeping(state: AppState) { let mut tick = tokio::time::interval(Duration::from_secs(5)); let mut n: u64 = 0; loop { tick.tick().await; state.hub.snapshot_idle_rooms().await; n += 1; if n.is_multiple_of(720) { match state.store.delete_expired_refresh().await { Ok(removed) if removed > 0 => tracing::info!(removed, "expired sessions removed"), Ok(_) => {} Err(error) => tracing::warn!(%error, "removing expired sessions failed"), } let rooms = state.hub.open_rooms().await; tracing::debug!(rooms, "open rooms"); } } } /// Asks the running server's `/healthz` on this machine; exits non-zero if it does not answer. async fn healthcheck() -> Result<()> { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listen: std::net::SocketAddr = std::env::var("ARCHIFY_SYNC_LISTEN") .unwrap_or_else(|_| "0.0.0.0:8080".into()) .parse()?; let target = std::net::SocketAddr::from(([127, 0, 0, 1], listen.port())); let reply = tokio::time::timeout(Duration::from_secs(3), async { let mut stream = tokio::net::TcpStream::connect(target).await?; stream .write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") .await?; let mut reply = String::new(); stream.read_to_string(&mut reply).await?; anyhow::Ok(reply) }) .await??; anyhow::ensure!(reply.starts_with("HTTP/1.1 200"), "unhealthy"); Ok(()) } async fn shutdown() { let ctrl_c = async { let _ = tokio::signal::ctrl_c().await; }; #[cfg(unix)] let term = async { if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { s.recv().await; } }; #[cfg(not(unix))] let term = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {}, _ = term => {} } tracing::info!("shutting down; storing snapshots"); }