clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

main
devsite / crates / devsite-server / src / main.rs
main.rs 9.3K
1//! The dev.site control plane.2//!3//! Holds identities, profiles, permissions and daemon presence, and issues short-lived4//! capabilities. It never carries private service traffic.56mod api;7mod auth;8mod db;9mod issuer;10mod oidc;11mod policy;12mod theme;1314use std::path::PathBuf;15use std::sync::{Arc, Mutex};1617use anyhow::{Context, Result};18use axum::http::{header, HeaderValue};19use axum::response::{Html, IntoResponse, Response};20use axum::routing::get;21use tower_http::compression::CompressionLayer;22use tower_http::services::{ServeDir, ServeFile};23use tower_http::set_header::SetResponseHeaderLayer;24use tower_http::trace::TraceLayer;2526use crate::api::{AppState, RateLimits};27use crate::db::Db;28use crate::issuer::Issuer;29use crate::oidc::OidcConfig;3031struct Config {32 bind: String,33 /// The exact origin browsers load, e.g. `https://dev.site`. It forms OAuth callback34 /// URLs and the capability issuer name, so it must match the externally visible site.35 public_origin: String,36 database: String,37 state_dir: PathBuf,38 web_root: PathBuf,39 /// The capability signing key as 64 hex characters, when it is supplied40 /// directly rather than kept in `state_dir`. See `Issuer::from_hex`.41 signing_key: Option<String>,42}4344impl Config {45 fn from_env() -> Result<Self> {46 let public_origin = std::env::var("DEVSITE_PUBLIC_ORIGIN")47 .context("DEVSITE_PUBLIC_ORIGIN must be set, e.g. https://dev.site")?48 .trim_end_matches('/')49 .to_string();5051 Ok(Self {52 bind: std::env::var("DEVSITE_BIND").unwrap_or_else(|_| "127.0.0.1:4000".to_string()),53 public_origin,54 database: std::env::var("DEVSITE_DB").unwrap_or_else(|_| "devsite.db".to_string()),55 state_dir: std::env::var("DEVSITE_STATE_DIR")56 .unwrap_or_else(|_| ".devsite-state".to_string())57 .into(),58 web_root: std::env::var("DEVSITE_WEB_ROOT")59 .unwrap_or_else(|_| "web".to_string())60 .into(),61 signing_key: std::env::var("DEVSITE_SIGNING_KEY")62 .ok()63 .filter(|k| !k.trim().is_empty()),64 })65 }6667 /// The capability issuer this configuration asks for.68 ///69 /// A supplied key wins over the state directory, and a bad one is fatal70 /// rather than something to fall back from — see `Issuer::from_hex`.71 fn issuer(&self) -> Result<Issuer> {72 match &self.signing_key {73 Some(hex) => {74 tracing::info!("capability signing key supplied by DEVSITE_SIGNING_KEY");75 Issuer::from_hex(hex, &self.public_origin)76 }77 None => Issuer::load_or_create(&self.state_dir, &self.public_origin),78 }79 }80}8182#[tokio::main]83async fn main() -> Result<()> {84 tracing_subscriber::fmt()85 .with_env_filter(86 tracing_subscriber::EnvFilter::try_from_default_env()87 .unwrap_or_else(|_| "devsite_server=info,tower_http=warn,warn".into()),88 )89 .init();9091 let config = Config::from_env()?;9293 // Operator command, deliberately not reachable over HTTP: minting a session requires94 // shell access to the machine holding the database. Used to bootstrap an account (and95 // to exercise the stack without a round trip through Google).96 let args: Vec<String> = std::env::args().skip(1).collect();97 if args.first().map(String::as_str) == Some("issue-session") {98 let handle = args99 .get(1)100 .context("usage: devsite-server issue-session <handle>")?;101 return issue_session(&config, handle);102 }103104 let db = Db::open(&config.database)?;105 let issuer = config.issuer()?;106 let relay_config = devsite_iroh::RelayConfig::from_env()?;107 let oidc = OidcConfig::from_env(&config.public_origin)?;108109 tracing::info!(110 origin = %config.public_origin,111 identity_namespace = %oidc.issuer(),112 oidc_client_id = %oidc.client_id(),113 "capability signing key {}",114 issuer.public_key_hex()115 );116117 let (profile_changes, _) = tokio::sync::broadcast::channel(64);118 let state = Arc::new(AppState {119 db: Mutex::new(db),120 rate_limits: Mutex::new(RateLimits::default()),121 issuer,122 identity_namespace: oidc.issuer().to_string(),123 public_origin: config.public_origin.clone(),124 relay_config,125 profile_changes,126 profile_revision: std::sync::atomic::AtomicU64::new(0),127 });128129 let index = config.web_root.join("index.html");130 let app = api::router(Arc::clone(&state))131 .merge(oidc::router(Arc::clone(&state), oidc)?)132 // Nothing from the API is ever cacheable: profiles depend on who is asking, and a133 // cached capability would be a reusable grant sitting in a shared cache.134 .layer(SetResponseHeaderLayer::overriding(135 header::CACHE_CONTROL,136 HeaderValue::from_static("no-store, private"),137 ))138 // `/@handle` is a client-side route; serve the shell and let the page render it.139 .route("/@{handle}", get(serve_index(index.clone())))140 .fallback_service(static_assets(&config.web_root, &index))141 // Fly also compresses at its edge today, but the origin should have the same142 // contract when it is run directly or moved behind a different proxy.143 .layer(CompressionLayer::new().br(true).gzip(true))144 .layer(TraceLayer::new_for_http());145146 let listener = tokio::net::TcpListener::bind(&config.bind)147 .await148 .with_context(|| format!("binding {}", config.bind))?;149 tracing::info!("listening on http://{}", config.bind);150151 axum::serve(listener, app)152 .with_graceful_shutdown(shutdown())153 .await?;154 Ok(())155}156157/// Static assets always revalidate. They are small and deployed with the server binary.158fn static_assets(web_root: &std::path::Path, index: &std::path::Path) -> axum::routing::Router {159 let files = ServeDir::new(web_root).not_found_service(ServeFile::new(index));160161 axum::routing::Router::new()162 .fallback_service(files)163 .layer(axum::middleware::from_fn(164 |req: axum::extract::Request, next: axum::middleware::Next| async move {165 let mut response = next.run(req).await;166 response167 .headers_mut()168 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));169 response170 },171 ))172}173174/// Serve the SPA shell for client-side routes.175///176/// Read per request rather than cached so editing the page during development does not177/// need a server restart.178///179/// The `no-cache` is load-bearing and has to be set here rather than by a layer.180/// `Router::layer` only wraps routes registered before it, so these two sit181/// between the API's `no-store` layer and the static fallback's own headers, and182/// inherit neither. A shell with no directives and no validator is one a browser183/// may cache heuristically. `/` never had the problem; client-side routes silently did.184fn serve_index(path: PathBuf) -> impl Fn() -> std::future::Ready<Response> + Clone {185 move || {186 let body = std::fs::read_to_string(&path)187 .unwrap_or_else(|_| "<!doctype html><p>web/index.html is missing.".to_string());188 let mut response = Html(body).into_response();189 response190 .headers_mut()191 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));192 std::future::ready(response)193 }194}195196/// Mint a session for `handle`, creating the account if it does not exist.197///198/// New operator-created accounts use the `local` identity namespace, so they cannot collide199/// with an external provider that happens to issue the same subject.200fn issue_session(config: &Config, handle: &str) -> Result<()> {201 let db = Db::open(&config.database)?;202 let handle = crate::auth::validate_handle(handle)?;203 let now = api::now_secs();204205 // Attach to the existing account when the handle is taken. Creating a second one would206 // collide on the unique handle, and the useful operator action here is "give me a207 // session for @alice" — including when @alice signed in through an external provider.208 let account = match db.account_by_handle(&handle)? {209 Some(existing) => existing,210 None => {211 let created = db.upsert_account("local", &handle, now)?;212 db.set_handle(created.id, &handle)?;213 created214 }215 };216217 let token = auth::generate_session_token();218 db.create_session(219 &auth::hash_token(&token),220 account.id,221 now + auth::SESSION_LIFETIME_SECS,222 )?;223224 println!("account @{handle} ({})", account.id);225 println!("session token:\n{token}");226 Ok(())227}228229async fn shutdown() {230 let _ = tokio::signal::ctrl_c().await;231 tracing::info!("shutting down");232}233234#[cfg(test)]235mod tests {236 use super::*;237238 #[test]239 fn the_spa_shell_is_never_cached_without_revalidating() {240 let index = std::env::temp_dir().join(format!("devsite-index-{}.html", std::process::id()));241 std::fs::write(&index, "<!doctype html><title>x</title>").unwrap();242243 let response = serve_index(index.clone())().into_inner();244 assert_eq!(245 response.headers().get(header::CACHE_CONTROL).unwrap(),246 "no-cache",247 "a cached shell pins a browser to assets a later deploy may have removed"248 );249250 std::fs::remove_file(&index).ok();251 }252}