Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
177 lines
6.1 KiB
Rust
177 lines
6.1 KiB
Rust
//! 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");
|
|
}
|