use std::sync::Arc; use axum::{Json, Router, extract::State, routing::post}; use kube::api::{Api, ListParams}; use kube::core::DynamicObject; use kube::core::admission::{AdmissionRequest, AdmissionResponse, AdmissionReview}; use sqlx::PgPool; struct AppState { db: PgPool, kube: kube::Client, namespace: String, } async fn validate( State(state): State>, Json(review): Json>, ) -> Json> { let req: AdmissionRequest = match review.try_into() { Ok(r) => r, Err(e) => { let resp = AdmissionResponse::invalid(format!("invalid admission request: {e}")); return Json(resp.into_review()); } }; let resp = match validate_bouncer(&state, &req).await { Ok(resp) => resp, Err(e) => { tracing::error!("validation failed: {e}"); AdmissionResponse::invalid(e.to_string()).into_review() } }; Json(resp) } async fn validate_bouncer( state: &AppState, req: &AdmissionRequest, ) -> anyhow::Result> { let obj = req.object.as_ref().ok_or_else(|| anyhow::anyhow!("missing object"))?; let labels = obj.metadata.labels.clone().unwrap_or_default(); let owner = match labels.get("irc.now/owner") { Some(o) => o, None => return Ok(AdmissionResponse::from(req).into_review()), }; let plan: Option<(String,)> = sqlx::query_as("SELECT plan FROM users WHERE keycloak_sub = $1") .bind(owner) .fetch_optional(&state.db) .await?; let plan = match plan { Some((p,)) => p, None => return Ok(AdmissionResponse::from(req).deny("user not found").into_review()), }; if plan != "pro" { return Ok(AdmissionResponse::from(req) .deny("upgrade to pro to create a dedicated bouncer") .into_review()); } let ar = kube::core::ApiResource { group: "irc.now".into(), version: "v1alpha1".into(), api_version: "irc.now/v1alpha1".into(), kind: "SojuBouncer".into(), plural: "sojubouncers".into(), }; let bouncers: Api = Api::namespaced_with(state.kube.clone(), &state.namespace, &ar); let lp = ListParams::default().labels(&format!("irc.now/owner={owner}")); let count = bouncers .list(&lp) .await .map(|l| l.items.len()) .unwrap_or(0); if count >= 1 { return Ok(AdmissionResponse::from(req) .deny("max 1 dedicated bouncer per pro account") .into_review()); } Ok(AdmissionResponse::from(req).into_review()) } #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let db_config = irc_now_common::db::DbConfig::from_env("ACCOUNTS"); let db = db_config.connect().await.expect("database connection failed"); let kube = kube::Client::try_default() .await .expect("kube client init failed"); let namespace = std::env::var("NAMESPACE").unwrap_or_else(|_| "irc-josie-cloud".to_string()); let state = Arc::new(AppState { db, kube, namespace, }); let app = Router::new() .route("/validate", post(validate)) .with_state(state); let cert = std::fs::read("/etc/webhook/tls/tls.crt").expect("reading TLS cert"); let key = std::fs::read("/etc/webhook/tls/tls.key").expect("reading TLS key"); let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem(cert, key) .await .expect("TLS config failed"); let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8443)); tracing::info!("listening on {addr}"); axum_server::bind_rustls(addr, tls_config) .serve(app.into_make_service()) .await .expect("server failed"); }