use k8s_openapi::api::core::v1::Secret; use kube::Api; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; const ERGO_HOST: &str = "irc-now-net"; const ERGO_PORT: u16 = 6667; const OPER_SECRET_NAME: &str = "irc-now-net-oper"; const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); async fn read_oper_password(kube: &kube::Client, namespace: &str) -> Result { let api: Api = Api::namespaced(kube.clone(), namespace); let secret = api .get(OPER_SECRET_NAME) .await .map_err(|e| format!("failed to get oper secret: {e}"))?; secret .data .as_ref() .and_then(|d| d.get("password")) .map(|b| String::from_utf8_lossy(&b.0).trim().to_string()) .ok_or_else(|| "missing 'password' key in oper secret".to_string()) } fn validate_irc_param(s: &str) -> Result<(), String> { let re = regex::Regex::new(r"^[a-zA-Z0-9_\-]{1,50}$").unwrap(); if re.is_match(s) { Ok(()) } else { Err(format!("invalid IRC parameter: {s}")) } } pub async fn ensure_ergo_account( kube: &kube::Client, namespace: &str, username: &str, password: &str, ) -> Result<(), String> { ensure_ergo_account_with_vhost(kube, namespace, username, password, None).await } pub async fn ensure_ergo_account_with_vhost( kube: &kube::Client, namespace: &str, username: &str, password: &str, vhost: Option<&str>, ) -> Result<(), String> { validate_irc_param(username)?; let oper_password = read_oper_password(kube, namespace).await?; tokio::time::timeout(TIMEOUT, async { let stream = TcpStream::connect((ERGO_HOST, ERGO_PORT)) .await .map_err(|e| format!("connect to ergo failed: {e}"))?; let (reader, mut writer) = stream.into_split(); let mut lines = BufReader::new(reader).lines(); writer .write_all(b"NICK ergo-admin\r\nUSER ergo-admin 0 * :admin\r\n") .await .map_err(|e| format!("write failed: {e}"))?; wait_for(&mut lines, &mut writer, " 001 ").await?; writer .write_all(format!("OPER admin {oper_password}\r\n").as_bytes()) .await .map_err(|e| format!("write failed: {e}"))?; loop { let line = next_line(&mut lines, &mut writer).await?; if line.contains(" 381 ") { break; } if line.contains(" 491 ") || line.contains("ERR_NOOPERHOST") { return Err("OPER authentication failed".to_string()); } } writer .write_all( format!("NS SAREGISTER {username} {password}\r\n").as_bytes(), ) .await .map_err(|e| format!("write failed: {e}"))?; loop { let line = next_line(&mut lines, &mut writer).await?; if line.contains("NickServ") || line.contains("nickserv") { let lower = line.to_lowercase(); if lower.contains("successfully registered") || lower.contains("registered account") { tracing::info!("registered ergo account '{username}'"); set_vhost_and_quit(&mut lines, &mut writer, username, vhost).await?; return Ok(()); } if lower.contains("account already exists") { tracing::info!("ergo account '{username}' exists, resetting password"); writer .write_all( format!("NS SAPASSWD {username} {password}\r\n").as_bytes(), ) .await .map_err(|e| format!("write failed: {e}"))?; loop { let line2 = next_line(&mut lines, &mut writer).await?; if line2.contains("NickServ") || line2.contains("nickserv") { let lower2 = line2.to_lowercase(); if lower2.contains("error") || lower2.contains("fail") || lower2.contains("unknown command") { let _ = writer.write_all(b"QUIT\r\n").await; return Err(format!("SAPASSWD failed: {line2}")); } tracing::info!("reset ergo password for '{username}'"); set_vhost_and_quit(&mut lines, &mut writer, username, vhost).await?; return Ok(()); } } } if lower.contains("error") || lower.contains("fail") { let _ = writer.write_all(b"QUIT\r\n").await; return Err(format!("SAREGISTER failed: {line}")); } tracing::info!("SAREGISTER response: {line}"); set_vhost_and_quit(&mut lines, &mut writer, username, vhost).await?; return Ok(()); } } }) .await .map_err(|_| "ergo admin connection timed out".to_string())? } type Lines = tokio::io::Lines>; type Writer = tokio::net::tcp::OwnedWriteHalf; async fn next_line(lines: &mut Lines, writer: &mut Writer) -> Result { loop { let line = lines .next_line() .await .map_err(|e| format!("read failed: {e}"))? .ok_or("connection closed unexpectedly")?; if line.starts_with("PING") { let token = line.splitn(2, ' ').nth(1).unwrap_or(""); writer .write_all(format!("PONG {token}\r\n").as_bytes()) .await .map_err(|e| format!("write failed: {e}"))?; continue; } return Ok(line); } } async fn wait_for(lines: &mut Lines, writer: &mut Writer, pattern: &str) -> Result<(), String> { loop { let line = next_line(lines, writer).await?; if line.contains(pattern) { return Ok(()); } } } async fn set_vhost_and_quit( lines: &mut Lines, writer: &mut Writer, username: &str, vhost: Option<&str>, ) -> Result<(), String> { if let Some(vhost) = vhost { writer .write_all(format!("HS SET {username} {vhost}\r\n").as_bytes()) .await .map_err(|e| format!("write failed: {e}"))?; loop { let line = next_line(lines, writer).await?; if line.contains("HostServ") || line.contains("hostserv") { let lower = line.to_lowercase(); if lower.contains("successfully set") { tracing::info!("set vhost '{vhost}' for '{username}'"); } else { tracing::warn!("vhost set response for '{username}': {line}"); } break; } } } let _ = writer.write_all(b"QUIT\r\n").await; Ok(()) } pub fn generate_password() -> String { use rand::Rng; const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let mut rng = rand::thread_rng(); (0..24) .map(|_| { let idx = rng.gen_range(0..CHARSET.len()); CHARSET[idx] as char }) .collect() }