use askama::Template; use askama_web::WebTemplate; use axum::extract::{Path, State}; use axum::http::HeaderMap; use axum::response::{AppendHeaders, IntoResponse, Redirect}; use axum::Form; use k8s_openapi::api::core::v1::Secret; use kube::api::{DeleteParams, ListParams, PostParams}; use kube::Api; use serde::Deserialize; use crate::auth_guard::AuthUser; use crate::ergo_admin; use crate::flash; use crate::k8s::{SojuBouncer, SojuBouncerSpec, sanitize_bouncer_name}; use crate::page::PageContext; use crate::state::AppState; #[derive(Template, WebTemplate)] #[template(path = "bouncers.html")] pub struct BouncersTemplate { pub page: PageContext, pub bouncers: Vec, } pub struct BouncerInfo { pub name: String, pub hostname: String, pub ready: bool, } pub async fn list( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> impl IntoResponse { let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let lp = ListParams::default().labels(&format!("irc.now/owner={}", user.sub)); let bouncers = api .list(&lp) .await .map(|list| { list.items .into_iter() .map(|b| { let ready = b.status.as_ref() .and_then(|s| s.conditions.first()) .map(|c| c.status == "True") .unwrap_or(false); BouncerInfo { name: b.metadata.name.unwrap_or_default(), hostname: b.spec.hostname, ready, } }) .collect() }) .unwrap_or_default(); let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = BouncersTemplate { page, bouncers }; if clear { (AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } #[derive(Deserialize)] pub struct CreateForm { pub name: String, } pub async fn create( State(state): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result<(AppendHeaders<[(axum::http::HeaderName, axum::http::HeaderValue); 1]>, Redirect), (AppendHeaders<[(axum::http::HeaderName, axum::http::HeaderValue); 1]>, Redirect)> { let name = sanitize_bouncer_name(&form.name); if name.is_empty() { return Err((AppendHeaders([flash::set_flash("bouncer_create_failed")]), Redirect::to("/bouncers"))); } let bouncer = SojuBouncer::new(&name, SojuBouncerSpec { hostname: format!("{name}.irc.now"), owner: Some(user.sub.clone()), hostname_cloak: Some(format!("{name}.irc.now")), }); let mut bouncer = bouncer; bouncer .metadata .labels .get_or_insert_with(Default::default) .insert( "irc.now/owner".to_string(), user.sub, ); let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); api.create(&PostParams::default(), &bouncer) .await .map_err(|e| { tracing::error!("failed to create bouncer: {e}"); (AppendHeaders([flash::set_flash("bouncer_create_failed")]), Redirect::to("/bouncers")) })?; if let Err(e) = add_default_upstream(&state, &name).await { tracing::warn!("failed to add default upstream: {e}"); } Ok((AppendHeaders([flash::set_flash("bouncer_created")]), Redirect::to("/bouncers"))) } pub struct NetworkInfo { pub name: String, pub addr: String, pub nick: String, pub enabled: bool, } #[derive(Template, WebTemplate)] #[template(path = "bouncer_detail.html")] pub struct BouncerDetailTemplate { pub page: PageContext, pub name: String, pub hostname: String, pub ready: bool, pub status_message: String, pub username: String, pub networks: Vec, } pub async fn detail( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(name): Path, ) -> Result { let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let bouncer = api.get(&name).await.map_err(|e| { tracing::error!("failed to get bouncer {name}: {e}"); (AppendHeaders([flash::set_flash("bouncer_not_found")]), Redirect::to("/bouncers")) })?; let owner = bouncer.metadata.labels.as_ref() .and_then(|l| l.get("irc.now/owner")) .map(|s| s.as_str()); if owner != Some(&user.sub) { return Err((AppendHeaders([flash::set_flash("bouncer_not_found")]), Redirect::to("/bouncers"))); } let (ready, status_message) = bouncer.status.as_ref() .and_then(|s| s.conditions.first()) .map(|c| ( c.status == "True", c.message.clone().unwrap_or_default(), )) .unwrap_or((false, "pending".to_string())); let (username, networks) = match get_bouncer_details(&state, &name).await { Ok((u, n)) => (u, n), Err(e) => { tracing::warn!("failed to read soju db for {name}: {e}"); (String::new(), vec![]) } }; let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = BouncerDetailTemplate { page, name, hostname: bouncer.spec.hostname, ready, status_message, username, networks, }; if clear { Ok((AppendHeaders([flash::clear_flash()]), template).into_response()) } else { Ok(template.into_response()) } } async fn get_bouncer_details( state: &AppState, bouncer_name: &str, ) -> Result<(String, Vec), Box> { let secret_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let secret = secret_api.get(&format!("{bouncer_name}-db")).await?; let uri = secret.data.as_ref() .and_then(|d| d.get("uri")) .map(|b| String::from_utf8_lossy(&b.0).to_string()) .ok_or("missing uri in db secret")?; let (client, connection) = tokio_postgres::connect(&uri, tokio_postgres::NoTls).await?; tokio::spawn(async move { if let Err(e) = connection.await { tracing::warn!("soju db connection error: {e}"); } }); let username: String = client .query_one(r#"SELECT username FROM "User" LIMIT 1"#, &[]) .await? .get(0); let rows = client .query( r#"SELECT COALESCE(name, '') as name, addr, COALESCE(nick, '') as nick, enabled FROM "Network" ORDER BY id"#, &[], ) .await?; let networks = rows.iter().map(|r| NetworkInfo { name: r.get(0), addr: r.get(1), nick: r.get(2), enabled: r.get(3), }).collect(); Ok((username, networks)) } pub async fn delete( State(state): State, AuthUser(user): AuthUser, Path(name): Path, ) -> (AppendHeaders<[(axum::http::HeaderName, axum::http::HeaderValue); 1]>, Redirect) { let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); match api.get(&name).await { Ok(bouncer) => { let owner = bouncer.metadata.labels.as_ref() .and_then(|l| l.get("irc.now/owner")) .map(|s| s.as_str()); if owner != Some(&user.sub) { return (AppendHeaders([flash::set_flash("bouncer_not_found")]), Redirect::to("/bouncers")); } } Err(_) => { return (AppendHeaders([flash::set_flash("bouncer_not_found")]), Redirect::to("/bouncers")); } } match api.delete(&name, &DeleteParams::default()).await { Ok(_) => (AppendHeaders([flash::set_flash("bouncer_deleted")]), Redirect::to("/bouncers")), Err(e) => { tracing::error!("failed to delete bouncer {name}: {e}"); (AppendHeaders([flash::set_flash("bouncer_delete_failed")]), Redirect::to(&format!("/bouncers/{name}"))) } } } async fn add_default_upstream( state: &AppState, bouncer_name: &str, ) -> Result<(), Box> { let secret_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let secret = secret_api.get(&format!("{bouncer_name}-db")).await?; let uri = secret .data .as_ref() .and_then(|d| d.get("uri")) .map(|b| String::from_utf8_lossy(&b.0).to_string()) .ok_or("missing uri in db secret")?; let (client, connection) = tokio_postgres::connect(&uri, tokio_postgres::NoTls).await?; tokio::spawn(async move { if let Err(e) = connection.await { tracing::warn!("soju db connection error: {e}"); } }); let username: String = client .query_one(r#"SELECT username FROM "User" LIMIT 1"#, &[]) .await? .get(0); let ergo_password = ergo_admin::generate_password(); if let Err(e) = ergo_admin::register_ergo_account(&state.kube, &state.namespace, &username, &ergo_password) .await { tracing::warn!("ergo account registration failed for {username}: {e}"); } client .execute( r#"INSERT INTO "Network" ("user", name, addr, nick, enabled, connect_commands, sasl_mechanism, sasl_plain_username, sasl_plain_password) SELECT id, 'irc.now', 'irc+insecure://irc-now-net:6667', username, true, '', 'PLAIN', username, $1 FROM "User" LIMIT 1 ON CONFLICT DO NOTHING"#, &[&ergo_password], ) .await?; Ok(()) }