mod auth_guard; mod bouncer_watcher; mod business_metrics; mod ergo_admin; mod events; mod flash; mod htmx; mod irccloud; mod keycloak; mod k8s; mod page; mod routes; mod soju_migrate; mod state; mod stripe_util; use std::sync::{Arc, OnceLock}; use axum::{Router, routing::{get, post}}; static PROM_HANDLE: OnceLock = OnceLock::new(); async fn metrics_handler() -> String { PROM_HANDLE.get().map(|h| h.render()).unwrap_or_default() } use tower_http::services::ServeDir; use openid::DiscoveredClient; use state::AppState; use tower_sessions::{ExpiredDeletion, SessionManagerLayer, Expiry, cookie::SameSite}; use tower_sessions_sqlx_store::PostgresStore; use irc_now_common::auth::OidcConfig; use irc_now_common::db::DbConfig; async fn health() -> &'static str { "ok" } async fn sync_shared_bouncer_limits(kube: kube::Client, namespace: String) { let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); loop { interval.tick().await; match soju_migrate::connect_soju_db(&kube, &namespace, "soju-shared-db").await { Ok(client) => { match client .execute( r#"UPDATE "User" SET max_networks = 1 WHERE max_networks != 1 OR max_networks IS NULL"#, &[], ) .await { Ok(n) => { if n > 0 { tracing::info!("synced max_networks=1 for {n} shared bouncer users"); } } Err(e) => tracing::warn!("shared bouncer max_networks sync failed: {e}"), } } Err(e) => tracing::debug!("shared bouncer db not available: {e}"), } } } fn irc_nick_from_email(email: &str) -> Option { let local = email.split('@').next().unwrap_or(email); let sanitized: String = local .chars() .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') .take(20) .collect(); if sanitized.is_empty() { None } else { Some(sanitized) } } async fn sync_shared_bouncer_networks(kube: kube::Client, namespace: String) { let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); loop { interval.tick().await; if let Err(e) = do_sync_shared_networks(&kube, &namespace).await { tracing::debug!("shared bouncer network sync: {e}"); } } } async fn do_sync_shared_networks(kube: &kube::Client, namespace: &str) -> Result<(), String> { let client = soju_migrate::connect_soju_db(kube, namespace, "soju-shared-db").await?; let rows = client .query( r#"SELECT u.id, u.username FROM "User" u LEFT JOIN "Network" n ON n."user" = u.id AND n.name = 'irc.now' WHERE n.id IS NULL"#, &[], ) .await .map_err(|e| format!("query failed: {e}"))?; for row in &rows { let user_id: i32 = row.get("id"); let username: String = row.get("username"); let nick = match irc_nick_from_email(&username) { Some(n) => n, None => { tracing::warn!("cannot derive IRC nick from '{username}', skipping"); continue; } }; let password = ergo_admin::generate_password(); if let Err(e) = ergo_admin::ensure_ergo_account(kube, namespace, &nick, &password).await { tracing::warn!("ergo account setup for '{nick}' failed: {e}"); continue; } match client .execute( r#"INSERT INTO "Network" ("user", name, addr, nick, enabled, sasl_mechanism, sasl_plain_username, sasl_plain_password) VALUES ($1, 'irc.now', 'irc+insecure://irc-now-net:6667', $2, true, 'PLAIN', $2, $3) ON CONFLICT (name, "user") DO NOTHING"#, &[&user_id, &nick, &password], ) .await { Ok(_) => tracing::info!("created default irc.now network for shared bouncer user '{username}' (nick: {nick})"), Err(e) => tracing::warn!("network insert for '{username}' failed: {e}"), } } Ok(()) } #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let db = DbConfig::from_env("ACCOUNTS").connect().await.unwrap(); let oidc = OidcConfig::from_env(); let issuer_url = url::Url::parse(&oidc.issuer_url).expect("invalid OIDC issuer URL"); tracing::info!("discovering OIDC provider at {issuer_url}"); let oidc_client = DiscoveredClient::discover( oidc.client_id.clone(), oidc.client_secret.clone(), Some(oidc.redirect_url.clone()), issuer_url, ) .await .expect("OIDC discovery failed"); tracing::info!("OIDC provider discovered"); let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); let stripe_secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(); let stripe_price_id = std::env::var("STRIPE_PRICE_ID").unwrap_or_default(); let stripe_client = stripe::Client::new(&stripe_secret_key); let kube_client = 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 http_client = reqwest::Client::new(); let announcement = std::env::var("ANNOUNCEMENT").ok().filter(|s| !s.is_empty()); let txt_url = std::env::var("TXT_INTERNAL_URL").unwrap_or_else(|_| "http://txt:8080".to_string()); let pics_url = std::env::var("PICS_INTERNAL_URL").unwrap_or_else(|_| "http://pics:8080".to_string()); let bot_url = std::env::var("BOT_INTERNAL_URL").unwrap_or_else(|_| "http://bot:8080".to_string()); let state = AppState { db, oidc, oidc_client: Arc::new(oidc_client), stripe_webhook_secret, stripe_client, stripe_price_id, kube: kube_client, namespace, http_client, announcement, txt_url, pics_url, bot_url, }; let handle = metrics_exporter_prometheus::PrometheusBuilder::new() .install_recorder() .expect("prometheus recorder install failed"); PROM_HANDLE.set(handle).expect("prometheus handle already set"); let session_store = PostgresStore::new(state.db.clone()); session_store.migrate().await.expect("session store migration failed"); let _deletion_task = tokio::task::spawn( session_store .clone() .continuously_delete_expired(tokio::time::Duration::from_secs(3600)), ); let session_layer = SessionManagerLayer::new(session_store) .with_same_site(SameSite::Lax) .with_expiry(Expiry::OnInactivity(time::Duration::days(7))); let internal_app = Router::new() .route("/metrics", get(metrics_handler)) .route("/health", get(health)); let app = Router::new() .route("/", get(|| async { axum::response::Redirect::to("/dashboard") })) .route("/health", get(health)) .route("/auth/login", get(routes::auth::login)) .route("/auth/callback", get(routes::auth::callback)) .route("/auth/logout", get(routes::auth::logout)) .route("/auth/error", get(routes::auth::error_page)) .route("/dashboard", get(routes::dashboard::index)) .route("/bouncers", get(routes::bouncer::list)) .route("/bouncers/create", post(routes::bouncer::create)) .route("/bouncers/{name}", get(routes::bouncer::detail)) .route("/bouncers/{name}/delete", post(routes::bouncer::delete)) .route("/bouncers/{name}/downgrade", get(routes::bouncer::downgrade_form).post(routes::bouncer::downgrade)) .route("/bouncers/{name}/status", get(routes::bouncer::status)) .route("/bots", get(routes::bot::list)) .route("/bots/create", get(routes::bot::create_form).post(routes::bot::create)) .route("/bots/{id}", get(routes::bot::detail)) .route("/bots/{id}/update", post(routes::bot::update)) .route("/bots/{id}/delete", post(routes::bot::delete)) .route("/bots/{id}/start", post(routes::bot::start)) .route("/bots/{id}/stop", post(routes::bot::stop)) .route("/bots/{id}/scripts/create", get(routes::bot::script_create_form).post(routes::bot::script_create)) .route("/bots/{id}/scripts/{sid}", get(routes::bot::script_edit)) .route("/bots/{id}/scripts/{sid}/update", post(routes::bot::script_update)) .route("/bots/{id}/scripts/{sid}/delete", post(routes::bot::script_delete)) .route("/bots/{id}/logs", get(routes::bot::logs)) .route("/bots/{id}/kv", get(routes::bot::kv_list)) .route("/bots/{id}/kv/set", post(routes::bot::kv_set)) .route("/bots/{id}/kv/{key}/delete", post(routes::bot::kv_delete)) .route("/networks", get(routes::network::list)) .route("/networks/create", post(routes::network::create)) .route("/networks/{name}", get(routes::network::detail)) .route("/networks/{name}/delete", post(routes::network::delete)) .route("/networks/{name}/status", get(routes::network::status)) .route("/billing", get(routes::billing::index)) .route("/billing/checkout", post(routes::billing::checkout)) .route("/billing/portal", get(routes::billing::portal)) .route("/billing/webhook", post(routes::billing::webhook)) .route("/migrate", get(routes::migrate::form)) .route("/migrate", post(routes::migrate::run_migration)) .route("/profile", get(routes::profile::index)) .route("/profile/update", post(routes::profile::update)) .route("/report", get(routes::report::form).post(routes::report::submit)) .route("/admin/reports", get(routes::admin::reports)) .route("/admin/reports/{id}", get(routes::admin::report_detail)) .route("/admin/reports/{id}/action", post(routes::admin::report_action)) .route("/admin/users", get(routes::admin::users)) .route("/admin/users/{sub}", get(routes::admin::user_detail)) .route("/admin/users/{sub}/suspend", post(routes::admin::suspend_user)) .route("/admin/users/{sub}/unsuspend", post(routes::admin::unsuspend_user)) .route("/admin/audit", get(routes::admin::audit_log)) .route("/admin/stats", get(routes::admin::stats)) .nest_service("/static", ServeDir::new("static")) .layer(session_layer) .with_state(state.clone()); tokio::spawn(bouncer_watcher::watch_bouncers(state.clone())); tokio::spawn(sync_shared_bouncer_limits(state.kube.clone(), state.namespace.clone())); tokio::spawn(business_metrics::record_event_metrics(state.db.clone())); tokio::spawn(business_metrics::record_soju_metrics(state.kube.clone(), state.namespace.clone())); tokio::spawn(business_metrics::record_stripe_metrics(state.stripe_client.clone())); let internal_listener = tokio::net::TcpListener::bind("0.0.0.0:9090").await.unwrap(); tracing::info!("metrics listening on {}", internal_listener.local_addr().unwrap()); tokio::spawn(async move { axum::serve(internal_listener, internal_app).await.unwrap(); }); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); tracing::info!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }