First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
//! Skald Remote Control relay server library (see data/ios-app/relay.md).
//!
//! Exposes the building blocks — [`AppState`], [`router`], [`spawn_gc`] — so the
//! `main` binary stays thin and integration tests can spin up the real server on
//! an ephemeral port.
pub mod auth;
pub mod config;
pub mod limits;
pub mod pipe;
pub mod push;
pub mod routing;
pub mod store;
pub mod types;
pub mod ws;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use axum::Router;
use axum::extract::{ConnectInfo, State, ws::WebSocketUpgrade};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use config::Config;
use limits::{FixedWindow, IP_NEW_CONN_PER_MIN, TRANSPORT_FRAME_CAP, TTL_DAYS};
use pipe::PipeRegistry;
use push::{LogPusher, Pusher};
use routing::Registry;
use store::Store;
/// Shared, cheaply-cloneable application state handed to every connection.
#[derive(Clone)]
pub struct AppState {
pub store: Store,
pub registry: Arc<Registry>,
/// Stateful proxy registry for `/v1/pipe` (docs/relay/pipe.md §2).
pub pipes: Arc<PipeRegistry>,
pub ip_limiter: Arc<FixedWindow<IpAddr>>,
pub pusher: Arc<dyn Pusher>,
conn_seq: Arc<AtomicU64>,
pub cfg: Arc<Config>,
}
impl AppState {
/// Build the full application state: open the store and wire the default
/// (credential-free) push bridge.
pub async fn build(cfg: Config) -> anyhow::Result<AppState> {
// Ensure the DB directory exists (SQLite creates the file, not the dir).
if let Some(parent) = std::path::Path::new(&cfg.db_path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let store = Store::init(&cfg.db_path).await?;
// Push bridge: live APNs when `push-live` is enabled and credentials
// are present; otherwise the credential-free LogPusher (so the relay
// still boots locally — see push.rs).
#[cfg(feature = "push-live")]
let pusher: Arc<dyn Pusher> = match cfg.apns.as_ref() {
Some(apns) => push::build_pusher(apns),
None => {
tracing::warn!(
target: "relay::push",
"push-live feature enabled but no APNs config; falling back to LogPusher"
);
Arc::new(LogPusher)
}
};
#[cfg(not(feature = "push-live"))]
let pusher: Arc<dyn Pusher> = Arc::new(LogPusher);
Ok(AppState {
store,
registry: Arc::new(Registry::new()),
pipes: Arc::new(PipeRegistry::new()),
ip_limiter: Arc::new(FixedWindow::new(
Duration::from_secs(60),
IP_NEW_CONN_PER_MIN,
)),
pusher,
conn_seq: Arc::new(AtomicU64::new(1)),
cfg: Arc::new(cfg),
})
}
/// Monotonic per-process connection id (used for safe self-removal).
pub fn next_conn_id(&self) -> u64 {
self.conn_seq.fetch_add(1, Ordering::Relaxed)
}
}
/// Build the axum router: `GET /healthz`, the control WebSocket `GET /v1/ws`,
/// and the data-plane pipe WebSocket `GET /v1/pipe` (docs/relay/pipe.md §2).
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/v1/ws", get(ws_upgrade))
.route("/v1/pipe", get(pipe_upgrade))
.with_state(state)
}
async fn healthz() -> &'static str {
"ok"
}
/// `GET /v1/pipe` → data-plane WebSocket upgrade. Same per-IP new-connection
/// quota as `/v1/ws`; the per-message cap is the pipe frame size (bulk transfer).
async fn pipe_upgrade(
ws: WebSocketUpgrade,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(state): State<AppState>,
) -> Response {
let ip = addr.ip();
if !state.ip_limiter.allow(&ip) {
tracing::warn!(%ip, "rate_limited: too many new pipe connections");
return (StatusCode::TOO_MANY_REQUESTS, "rate_limited").into_response();
}
let max_frame = state.cfg.pipe.max_frame_bytes;
ws.max_message_size(max_frame)
.on_upgrade(move |socket| pipe::handle_pipe_socket(socket, state, ip))
}
/// `GET /v1/ws` → WebSocket upgrade. Per-IP new-connection quota is enforced
/// here (before upgrade) so unauthenticated floods are cheap to reject.
async fn ws_upgrade(
ws: WebSocketUpgrade,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(state): State<AppState>,
) -> Response {
let ip = addr.ip();
if !state.ip_limiter.allow(&ip) {
tracing::warn!(%ip, "rate_limited: too many new connections");
return (StatusCode::TOO_MANY_REQUESTS, "rate_limited").into_response();
}
ws.max_message_size(TRANSPORT_FRAME_CAP)
.on_upgrade(move |socket| ws::handle_socket(socket, state, ip))
}
/// Periodic garbage collection: drop messages/namespaces past TTL (relay.md §6)
/// and prune the IP rate-limiter map.
pub fn spawn_gc(state: AppState) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(3600));
loop {
tick.tick().await;
match state.store.gc(TTL_DAYS).await {
Ok((m, n)) if m > 0 || n > 0 => {
tracing::info!(messages = m, namespaces = n, "gc removed expired rows");
}
Ok(_) => {}
Err(e) => tracing::error!(error = %e, "gc failed"),
}
state.ip_limiter.prune();
}
});
}
/// Resolves when SIGINT or SIGTERM arrives (graceful shutdown trigger).
pub async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut sig) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
sig.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received; draining");
}