| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | | pub mod api; |
| 9 | | pub mod apt; |
| 10 | | pub mod assistants; |
| 11 | | pub mod auth; |
| 12 | | pub mod autoland; |
| 13 | | pub mod backups; |
| 14 | | pub mod bus; |
| 15 | | pub mod config; |
| 16 | | pub mod coverage; |
| 17 | | pub mod db; |
| 18 | | pub mod diffusion; |
| 19 | | pub mod drops; |
| 20 | | pub mod events; |
| 21 | | pub mod flow; |
| 22 | | pub mod headers; |
| 23 | | pub mod history_scan; |
| 24 | | pub mod hooks; |
| 25 | | pub mod images; |
| 26 | | pub mod insights; |
| 27 | | pub mod junit; |
| 28 | | pub mod keyless; |
| 29 | | pub mod keys; |
| 30 | | pub mod leader; |
| 31 | | pub mod levels; |
| 32 | | pub mod live; |
| 33 | | pub mod mail; |
| 34 | | pub mod maintenance; |
| 35 | | pub mod metrics; |
| 36 | | pub mod mirror; |
| 37 | | pub mod net; |
| 38 | | pub mod notify; |
| 39 | | pub mod oidc; |
| 40 | | pub mod protocol; |
| 41 | | pub mod provenance; |
| 42 | | pub mod push; |
| 43 | | pub mod push_protection; |
| 44 | | pub mod queue; |
| 45 | | pub mod render; |
| 46 | | pub mod reports; |
| 47 | | pub mod repos; |
| 48 | | pub mod runner_updates; |
| 49 | | pub mod secrets; |
| 50 | | pub mod setup; |
| 51 | | pub mod shutdown; |
| 52 | | pub mod signin; |
| 53 | | pub mod social; |
| 54 | | pub mod starter; |
| 55 | | pub mod storage; |
| 56 | | pub mod swarm; |
| 57 | | pub mod tags; |
| 58 | | pub mod terraform; |
| 59 | | pub mod throttle; |
| 60 | | pub mod tlog; |
| 61 | | pub mod variables; |
| 62 | | pub mod web; |
| 63 | | pub mod wiki; |
| 64 | |
|
| 65 | | use crate::config::Config; |
| 66 | | use crate::repos::Repos; |
| 67 | | use std::sync::Arc; |
| 68 | |
|
| 69 | |
|
| 70 | | pub struct App { |
| 71 | | pub config: Config, |
| 72 | | pub db: db::Pool, |
| 73 | | pub repos: Arc<Repos>, |
| 74 | | |
| 75 | | pub wikis: Arc<Repos>, |
| 76 | | pub server: Arc<oxy_server::Server>, |
| 77 | | pub hub_key: keys::HubKey, |
| 78 | | pub cookie_key: auth::CookieKey, |
| 79 | | |
| 80 | | pub assistant_key: assistants::SecretKey, |
| 81 | | |
| 82 | | pub secret_key: assistants::SecretKey, |
| 83 | | |
| 84 | | pub oidc: tokio::sync::OnceCell<oidc::Issuer>, |
| 85 | | |
| 86 | | |
| 87 | | pub assistant_gate: std::sync::Mutex<assistants::Gate>, |
| 88 | | |
| 89 | | pub throttle: std::sync::Mutex<throttle::Throttle>, |
| 90 | | |
| 91 | | |
| 92 | | pub bus: bus::Bus, |
| 93 | | |
| 94 | | pub rooms: Arc<live::Rooms>, |
| 95 | | |
| 96 | | |
| 97 | | pub mail: std::sync::RwLock<config::Email>, |
| 98 | | |
| 99 | | pub swarm: swarm::Tracker, |
| 100 | | pub started: std::time::Instant, |
| 101 | | |
| 102 | | pub tlog: Arc<tlog::Log>, |
| 103 | | |
| 104 | | pub roles: config::Roles, |
| 105 | | |
| 106 | | pub leader: leader::Leader, |
| 107 | | } |
| 108 | |
|
| 109 | | impl App { |
| 110 | | |
| 111 | 41× | pub fn images(&self) -> images::Store { |
| 112 | 41× | images::Store::new(&self.config.data) |
| 113 | 41× | } |
| 114 | |
|
| 115 | | |
| 116 | 26× | pub async fn oidc(&self) -> anyhow::Result<&oidc::Issuer> { |
| 117 | 26× | let keys = self.config.keys_dir(); |
| 118 | 26× | self.oidc.get_or_try_init(|| async move { tokio::task::spawn_blocking(move || oidc::Issuer::load_or_create(&keys)).await? }).await |
| 119 | 26× | } |
| 120 | | } |
| 121 | |
|
| 122 | | pub type Shared = Arc<App>; |
| 123 | |
|
| 124 | |
|
| 125 | |
|
| 126 | 203× | pub async fn build(config: Config) -> anyhow::Result<Shared> { |
| 127 | 203× | let roles = config.roles()?; |
| 128 | | |
| 129 | 202× | render::markdown::set_origin(config.base_url()); |
| 130 | 202× | std::fs::create_dir_all(config.repos_dir())?; |
| 131 | 202× | let db = db::open(&config).await?; |
| 132 | 202× | let repos = Arc::new(Repos::new(config.repos_dir())); |
| 133 | 202× | let wikis = Arc::new(Repos::new(config.data.join("wikis"))); |
| 134 | 202× | let hub_key = keys::ensure_identity(&config.keys_dir(), &config.site.name, config.base_url())?; |
| 135 | 202× | let cookie_key = auth::CookieKey::load_or_create(&config.keys_dir())?; |
| 136 | 202× | let assistant_key = assistants::SecretKey::load_or_create(&config.keys_dir())?; |
| 137 | 202× | let secret_key = assistants::SecretKey::load_or_create_named(&config.keys_dir(), "secrets.key")?; |
| 138 | 202× | let backend = protocol::HubBackend::new( |
| 139 | 202× | repos.clone(), |
| 140 | 202× | wikis.clone(), |
| 141 | 202× | db.clone(), |
| 142 | 202× | config.auth.push_tokens.clone(), |
| 143 | 202× | tokio::runtime::Handle::current(), |
| 144 | | ) |
| 145 | 202× | .with_acceptor(hub_key.identity.clone(), keys::load_secret(&config.keys_dir())?.1) |
| 146 | 202× | .with_scan_budget(push_protection::Budget { |
| 147 | 202× | bytes: config.limits.scan_limit.0, |
| 148 | 202× | file: config.limits.scan_file_limit.0, |
| 149 | 202× | time: std::time::Duration::from_millis(config.limits.scan_time_ms), |
| 150 | 202× | }); |
| 151 | 202× | let slot = backend.app_slot(); |
| 152 | 202× | let server = oxy_server::Server::new(backend); |
| 153 | 202× | let mail = mail::load(&db, &config.email).await; |
| 154 | | |
| 155 | 202× | let (tlog_dir, origin) = (config.data.join("tlog"), tlog::origin_of(config.base_url())); |
| 156 | 202× | let tlog = Arc::new(if roles.web { tlog::Log::open(&tlog_dir, &origin)? } else { tlog::Log::open_reader(&tlog_dir, &origin)? }); |
| 157 | 202× | tlog.configure(&config.tlog)?; |
| 158 | 202× | let app = Arc::new(App { |
| 159 | 202× | mail: std::sync::RwLock::new(mail), |
| 160 | 202× | config, |
| 161 | 202× | db, |
| 162 | 202× | repos, |
| 163 | 202× | wikis, |
| 164 | 202× | server, |
| 165 | 202× | hub_key, |
| 166 | 202× | cookie_key, |
| 167 | 202× | assistant_key, |
| 168 | 202× | secret_key, |
| 169 | 202× | oidc: tokio::sync::OnceCell::new(), |
| 170 | 202× | assistant_gate: std::sync::Mutex::new(assistants::Gate::default()), |
| 171 | 202× | throttle: std::sync::Mutex::new(throttle::Throttle::default()), |
| 172 | 202× | bus: bus::Bus::default(), |
| 173 | 202× | rooms: Arc::new(live::Rooms::default()), |
| 174 | 202× | swarm: Default::default(), |
| 175 | 202× | started: std::time::Instant::now(), |
| 176 | 202× | tlog, |
| 177 | 202× | roles, |
| 178 | 202× | leader: leader::Leader::default(), |
| 179 | 202× | }); |
| 180 | 202× | *slot.lock().unwrap() = Some(Arc::downgrade(&app)); |
| 181 | 202× | bus::start(&app).await?; |
| 182 | 202× | storage::load(&app).await?; |
| 183 | 202× | coverage::load(&app).await?; |
| 184 | 202× | junit::load(&app).await?; |
| 185 | 202× | reports::load(&app).await?; |
| 186 | 202× | start_background(&app); |
| 187 | 202× | setup::ensure(&app).await?; |
| 188 | 202× | Ok(app) |
| 189 | 203× | } |
| 190 | |
|
| 191 | |
|
| 192 | |
|
| 193 | |
|
| 194 | |
|
| 195 | 202× | fn start_background(app: &Shared) { |
| 196 | 202× | if app.roles.web { |
| 197 | 201× | |
| 198 | 201× | leader::run(app, "tlog.checkpointer", tlog::checkpointer); |
| 199 | 201× | } |
| 200 | 202× | if !app.roles.worker { |
| 201 | 2× | return; |
| 202 | 200× | } |
| 203 | 200× | leader::run(app, "hooks.worker", hooks::worker); |
| 204 | 200× | leader::run(app, "flow.reaper", flow::reaper); |
| 205 | 200× | leader::run(app, "flow.scheduler", flow::scheduler); |
| 206 | 200× | leader::run(app, "flow.placement", flow::placement); |
| 207 | 200× | leader::run(app, "maintenance.nightly", maintenance::nightly); |
| 208 | 200× | leader::run(app, "projects.archiver", maintenance::archiver); |
| 209 | 200× | leader::run(app, "backups.scheduler", backups::scheduler); |
| 210 | | |
| 211 | | |
| 212 | 400× | leader::run(app, "diffusion.backfill", |app| async move { diffusion::backfill(&app).await }); |
| 213 | | |
| 214 | 200× | leader::run(app, "diffusion.reconciler", diffusion::reconciler); |
| 215 | | |
| 216 | | |
| 217 | 200× | leader::run(app, "secrets.history_scan", history_scan::worker); |
| 218 | | |
| 219 | 200× | app.leader.note("queue.worker"); |
| 220 | 200× | tokio::spawn(queue::worker(app.clone())); |
| 221 | | |
| 222 | | |
| 223 | 200× | app.leader.note("images.learn_archs"); |
| 224 | 200× | tokio::spawn({ |
| 225 | 200× | let app = app.clone(); |
| 226 | 200× | async move { |
| 227 | 200× | match images::learn_archs(&app).await { |
| 228 | 199× | Ok(n) if n > 0 => tracing::info!(images = n, "read the architecture of kept images"), |
| 229 | 199× | Ok(_) => {} |
| 230 | 0× | Err(e) => tracing::warn!(error = %e, "learning image architectures"), |
| 231 | | } |
| 232 | 199× | } |
| 233 | | }); |
| 234 | 202× | } |
| 235 | |
|
| 236 | |
|
| 237 | |
|
| 238 | 2839× | pub fn router(app: Shared) -> axum::Router { |
| 239 | 2839× | if !app.roles.web { |
| 240 | 8× | return worker_router(app); |
| 241 | 2831× | } |
| 242 | 2831× | full_router(app) |
| 243 | 2839× | } |
| 244 | |
|
| 245 | |
|
| 246 | 8× | fn worker_router(app: Shared) -> axum::Router { |
| 247 | | use axum::routing::get; |
| 248 | 8× | let hsts = headers::hsts(app.config.base_url(), app.config.server.hsts_max_age); |
| 249 | 8× | axum::Router::new() |
| 250 | 8× | .route("/health", get(web::home::health)) |
| 251 | 8× | .route("/metrics", get(metrics::metrics)) |
| 252 | 8× | .with_state(app) |
| 253 | 8× | .layer(axum::middleware::from_fn_with_state(hsts, headers::security_headers)) |
| 254 | 8× | } |
| 255 | |
|
| 256 | |
|
| 257 | 2831× | fn full_router(app: Shared) -> axum::Router { |
| 258 | 2831× | let https = app_https(&app); |
| 259 | 2831× | let hsts = headers::hsts(app.config.base_url(), app.config.server.hsts_max_age); |
| 260 | 2831× | web::router(app.clone()) |
| 261 | 2831× | .layer(axum::middleware::from_fn_with_state(app, agent_requests)) |
| 262 | 2831× | .layer(axum::middleware::from_fn(private_pages)) |
| 263 | 2831× | .layer(axum::middleware::from_fn(slow_requests)) |
| 264 | 2831× | .layer(axum::middleware::from_fn_with_state(https, https_only)) |
| 265 | 2831× | .layer(axum::middleware::from_fn_with_state(hsts, headers::security_headers)) |
| 266 | 2831× | } |
| 267 | |
|
| 268 | |
|
| 269 | 2831× | fn app_https(app: &Shared) -> Option<String> { |
| 270 | 2831× | let base = app.config.base_url(); |
| 271 | 2831× | base.starts_with("https://").then(|| base.trim_end_matches('/').to_string()) |
| 272 | 2831× | } |
| 273 | |
|
| 274 | |
|
| 275 | |
|
| 276 | |
|
| 277 | 6× | fn forwarded_scheme(headers: &axum::http::HeaderMap) -> Option<String> { |
| 278 | 6× | if let Some(p) = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()) { |
| 279 | 4× | return p.split(',').next().map(|s| s.trim().to_ascii_lowercase()); |
| 280 | 2× | } |
| 281 | 2× | let visitor = headers.get("cf-visitor").and_then(|v| v.to_str().ok())?; |
| 282 | 1× | serde_json::from_str::<serde_json::Value>(visitor).ok()?["scheme"].as_str().map(str::to_ascii_lowercase) |
| 283 | 6× | } |
| 284 | |
|
| 285 | |
|
| 286 | |
|
| 287 | |
|
| 288 | |
|
| 289 | |
|
| 290 | 3838× | async fn https_only( |
| 291 | 3838× | axum::extract::State(https): axum::extract::State<Option<String>>, |
| 292 | 3838× | req: axum::extract::Request, |
| 293 | 3838× | next: axum::middleware::Next, |
| 294 | 3838× | ) -> axum::response::Response { |
| 295 | | use axum::response::IntoResponse; |
| 296 | 3838× | let Some(base) = https else { return next.run(req).await }; |
| 297 | 6× | if forwarded_scheme(req.headers()).as_deref() != Some("http") { |
| 298 | 4× | return next.run(req).await; |
| 299 | 2× | } |
| 300 | 2× | let to = format!("{base}{}", req.uri().path_and_query().map(|p| p.as_str()).unwrap_or("/")); |
| 301 | 2× | if matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) { |
| 302 | 1× | return axum::response::Redirect::permanent(&to).into_response(); |
| 303 | 1× | } |
| 304 | 1× | (axum::http::StatusCode::BAD_REQUEST, format!("This site takes requests over HTTPS only: send it to {to}\n")).into_response() |
| 305 | 3838× | } |
| 306 | |
|
| 307 | |
|
| 308 | |
|
| 309 | |
|
| 310 | 3836× | async fn agent_requests( |
| 311 | 3836× | axum::extract::State(app): axum::extract::State<Shared>, |
| 312 | 3836× | req: axum::extract::Request, |
| 313 | 3836× | next: axum::middleware::Next, |
| 314 | 3836× | ) -> axum::response::Response { |
| 315 | 3836× | let token = req |
| 316 | 3836× | .headers() |
| 317 | 3836× | .get(axum::http::header::AUTHORIZATION) |
| 318 | 3836× | .and_then(|v| v.to_str().ok()) |
| 319 | 3836× | .and_then(|v| v.strip_prefix("Bearer ")) |
| 320 | 3836× | .map(|t| t.trim().to_string()); |
| 321 | 3836× | let agent = match token { |
| 322 | | |
| 323 | 1825× | Some(t) if t.starts_with(keyless::GRANT_TOKEN_PREFIX) => db::accounts::agent_grant_by_token(&app.db, &auth::token_hash(&t)) |
| 324 | 20× | .await |
| 325 | 20× | .ok() |
| 326 | 20× | .flatten() |
| 327 | 20× | .filter(|g| g.live(db::now_ms())) |
| 328 | 20× | .map(|g| g.agent), |
| 329 | 1805× | Some(t) => db::accounts::token_by_hash(&app.db, &auth::token_hash(&t)).await.ok().flatten().filter(|t| t.agent).map(|t| t.name), |
| 330 | 2011× | None => None, |
| 331 | | }; |
| 332 | 3836× | notify::AGENT.scope(agent, next.run(req)).await |
| 333 | 3836× | } |
| 334 | |
|
| 335 | |
|
| 336 | | const SLOW: std::time::Duration = std::time::Duration::from_secs(2); |
| 337 | |
|
| 338 | |
|
| 339 | |
|
| 340 | 3836× | async fn slow_requests(req: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response { |
| 341 | 3836× | let (method, path) = (req.method().clone(), req.uri().path().to_string()); |
| 342 | 3836× | let start = std::time::Instant::now(); |
| 343 | 3836× | let res = next.run(req).await; |
| 344 | 3836× | let took = start.elapsed(); |
| 345 | 3836× | metrics::observe(method.as_str(), &path, res.status().as_u16(), took.as_secs_f64()); |
| 346 | 3836× | if took >= SLOW { |
| 347 | 2× | tracing::warn!("slow request: {method} {path} took {:.1} s ({})", took.as_secs_f64(), res.status().as_u16()); |
| 348 | 3834× | } |
| 349 | 3836× | res |
| 350 | 3836× | } |
| 351 | |
|
| 352 | |
|
| 353 | |
|
| 354 | |
|
| 355 | |
|
| 356 | |
|
| 357 | 3836× | async fn private_pages(req: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response { |
| 358 | | use axum::http::header; |
| 359 | 3836× | let signed_in = req |
| 360 | 3836× | .headers() |
| 361 | 3836× | .get_all(header::COOKIE) |
| 362 | 3836× | .iter() |
| 363 | 3836× | .filter_map(|v| v.to_str().ok()) |
| 364 | 3836× | .any(|c| c.split(';').any(|kv| kv.trim_start().starts_with("oxyhub="))); |
| 365 | 3836× | let mut res = next.run(req).await; |
| 366 | 3836× | let html = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|t| t.starts_with("text/html")); |
| 367 | 3836× | if signed_in && html && !res.headers().contains_key(header::CACHE_CONTROL) { |
| 368 | 857× | res.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private, no-store")); |
| 369 | 2979× | } |
| 370 | 3836× | res |
| 371 | 3836× | } |
| 372 | |
|
| 373 | |
|
| 374 | 1× | pub async fn serve(app: Shared) -> anyhow::Result<()> { |
| 375 | 1× | let router = router(app.clone()); |
| 376 | 1× | let http = app.config.bind_addr()?; |
| 377 | 1× | let https = app.config.tls_bind_addr()?; |
| 378 | 1× | if http.is_none() && https.is_none() && app.roles.web { |
| 379 | 0× | anyhow::bail!("nothing to bind: set server.bind or server.tls_bind"); |
| 380 | 1× | } |
| 381 | 1× | tracing::info!("roles: {}", app.roles.names().join(", ")); |
| 382 | | |
| 383 | 1× | let signal = shutdown::on_signal()?; |
| 384 | 1× | let mut tasks = tokio::task::JoinSet::new(); |
| 385 | 1× | if let Some(addr) = http { |
| 386 | 1× | let listener = match shutdown::systemd_listener() { |
| 387 | 0× | Some(l) => { |
| 388 | 0× | tracing::info!("http on the socket systemd holds ({})", l.local_addr().map(|a| a.to_string()).unwrap_or_default()); |
| 389 | 0× | tokio::net::TcpListener::from_std(l)? |
| 390 | | } |
| 391 | | None => { |
| 392 | 1× | tracing::info!("http on {addr}"); |
| 393 | 1× | tokio::net::TcpListener::bind(addr).await? |
| 394 | | } |
| 395 | | }; |
| 396 | 1× | let r = router.clone(); |
| 397 | 1× | tasks.spawn(async move { |
| 398 | 1× | axum::serve(listener, r.into_make_service_with_connect_info::<std::net::SocketAddr>()) |
| 399 | 1× | .with_graceful_shutdown(shutdown::stopping()) |
| 400 | 1× | .await |
| 401 | 1× | .map_err(anyhow::Error::from) |
| 402 | 1× | }); |
| 403 | 0× | } |
| 404 | 1× | if let Some(addr) = https { |
| 405 | 0× | let _ = rustls::crypto::ring::default_provider().install_default(); |
| 406 | 0× | let tls = axum_server::tls_rustls::RustlsConfig::from_pem_file(app.config.tls_cert(), app.config.tls_key()) |
| 407 | 0× | .await |
| 408 | 0× | .map_err(|e| anyhow::anyhow!("loading TLS files: {e}"))?; |
| 409 | 0× | tracing::info!("https on {addr}"); |
| 410 | 0× | let r = router.clone(); |
| 411 | 0× | let handle = axum_server::Handle::new(); |
| 412 | 0× | let draining = handle.clone(); |
| 413 | 0× | tokio::spawn(async move { |
| 414 | 0× | shutdown::stopping().await; |
| 415 | 0× | draining.graceful_shutdown(Some(shutdown::DRAIN)); |
| 416 | 0× | }); |
| 417 | 0× | tasks.spawn(async move { |
| 418 | 0× | axum_server::bind_rustls(addr, tls) |
| 419 | 0× | .handle(handle) |
| 420 | 0× | .serve(r.into_make_service_with_connect_info::<std::net::SocketAddr>()) |
| 421 | 0× | .await |
| 422 | 0× | .map_err(anyhow::Error::from) |
| 423 | 0× | }); |
| 424 | 1× | } |
| 425 | 1× | tokio::select! { |
| 426 | | |
| 427 | 1× | r = tasks.join_next(), if !tasks.is_empty() => { r.transpose()?.transpose()?; Ok(()) } |
| 428 | 1× | _ = signal => { |
| 429 | | |
| 430 | | |
| 431 | 0× | let drained = tokio::time::timeout(shutdown::DRAIN, async { while tasks.join_next().await.is_some() {} }).await; |
| 432 | 0× | if drained.is_err() { |
| 433 | 0× | tracing::warn!("requests still running after {} s: stopping anyway", shutdown::DRAIN.as_secs()); |
| 434 | 0× | } |
| 435 | 0× | tracing::info!("stopped"); |
| 436 | 0× | Ok(()) |
| 437 | | } |
| 438 | | } |
| 439 | 1× | } |