use k8s_openapi::api::core::v1::ConfigMap; use kube::api::ObjectMeta; use kube::Resource; use crate::types::ErgoNetwork; pub fn build_configmap(network: &ErgoNetwork, oper_hash: Option<&str>, oauth2_secret: Option<&str>) -> ConfigMap { let name = network.metadata.name.clone().unwrap(); let ns = network.metadata.namespace.clone().unwrap(); let oref = network.controller_owner_ref(&()).unwrap(); let config = generate_ergo_config(network, oper_hash, oauth2_secret); ConfigMap { metadata: ObjectMeta { name: Some(format!("{name}-config")), namespace: Some(ns), owner_references: Some(vec![oref]), ..Default::default() }, data: Some([("ircd.yaml".to_string(), config)].into()), ..Default::default() } } fn sanitize_yaml_value(s: &str) -> String { s.replace('\\', "\\\\") .replace('"', "\\\"") .replace('\n', "") .replace('\r', "") } fn generate_ergo_config(network: &ErgoNetwork, oper_hash: Option<&str>, oauth2_secret: Option<&str>) -> String { let spec = &network.spec; let network_name = spec .network_name .as_deref() .unwrap_or(&spec.hostname); let mut lines = Vec::new(); let hostname = sanitize_yaml_value(&spec.hostname); lines.push(format!("server:\n name: {hostname}")); lines.push(" listeners:".to_string()); for listener in &spec.listeners { if listener.tls { lines.push(format!( " \"{}\":\n tls:\n cert: /config/tls/tls.crt\n key: /config/tls/tls.key", listener.address )); } else { lines.push(format!(" \"{}\":", listener.address)); } } let max_sendq = sanitize_yaml_value(spec.max_sendq.as_deref().unwrap_or("96k")); lines.push(format!(" max-sendq: {max_sendq}")); if let Some(cloaking) = &spec.cloaking { let cidr4 = cloaking.cidr_len_ipv4.unwrap_or(32); let cidr6 = cloaking.cidr_len_ipv6.unwrap_or(64); let bits = cloaking.num_bits.unwrap_or(64); let netname = sanitize_yaml_value(&cloaking.netname); lines.push(format!( " ip-cloaking:\n enabled: true\n enabled-for-always-on: true\n netname: \"{netname}\"\n cidr-len-ipv4: {cidr4}\n cidr-len-ipv6: {cidr6}\n num-bits: {bits}" )); } let network_name = sanitize_yaml_value(network_name); lines.push(format!("\nnetwork:\n name: \"{network_name}\"")); lines.push("\ndatastore:\n path: /ircd/ircd.db\n autoupgrade: true".to_string()); { let l = spec.limits.as_ref(); let nicklen = l.and_then(|l| l.nicklen).unwrap_or(32); let identlen = l.and_then(|l| l.identlen).unwrap_or(20); let realnamelen = l.and_then(|l| l.realnamelen).unwrap_or(150); let channellen = l.and_then(|l| l.channellen).unwrap_or(64); let awaylen = l.and_then(|l| l.awaylen).unwrap_or(390); let kicklen = l.and_then(|l| l.kicklen).unwrap_or(390); let topiclen = l.and_then(|l| l.topiclen).unwrap_or(390); let monitor = l.and_then(|l| l.monitor_entries).unwrap_or(100); let whowas = l.and_then(|l| l.whowas_entries).unwrap_or(100); let chanlist = l.and_then(|l| l.chan_list_modes).unwrap_or(100); let regmsgs = l.and_then(|l| l.registration_messages).unwrap_or(1024); let ml = l.and_then(|l| l.multiline.as_ref()); let ml_bytes = ml.and_then(|m| m.max_bytes).unwrap_or(4096); let ml_lines = ml.and_then(|m| m.max_lines).unwrap_or(100); lines.push(format!( "\nlimits:\n nicklen: {nicklen}\n identlen: {identlen}\n realnamelen: {realnamelen}\n channellen: {channellen}\n awaylen: {awaylen}\n kicklen: {kicklen}\n topiclen: {topiclen}\n monitor-entries: {monitor}\n whowas-entries: {whowas}\n chan-list-modes: {chanlist}\n registration-messages: {regmsgs}\n multiline:\n max-bytes: {ml_bytes}\n max-lines: {ml_lines}" )); } if let Some(hash) = oper_hash { let class_name = spec .oper_credentials .as_ref() .and_then(|o| o.class_name.as_deref()) .unwrap_or("server-admin"); lines.push(concat!( "\noper-classes:", "\n chat-moderator:", "\n title: Chat Moderator", "\n capabilities:", "\n - kill", "\n - ban", "\n - nofakelag", "\n - relaymsg", "\n - vhosts", "\n - sajoin", "\n - samode", "\n - snomasks", "\n server-admin:", "\n title: Server Admin", "\n extends: chat-moderator", "\n capabilities:", "\n - rehash", "\n - accreg", "\n - chanreg", "\n - history", "\n - defcon", "\n - massmessage", "\n - metadata", ).to_string()); let hash = sanitize_yaml_value(hash); let class_name = sanitize_yaml_value(class_name); lines.push(format!( "\nopers:\n admin:\n class: {class_name}\n password: \"{hash}\"" )); } { let v = spec.vhosts.as_ref(); let vhost_enabled = v.and_then(|v| v.enabled).unwrap_or(true); let vhost_maxlen = v.and_then(|v| v.max_length).unwrap_or(64); let vhost_regexp = sanitize_yaml_value(v .and_then(|v| v.valid_regexp.as_deref()) .unwrap_or("^[a-zA-Z0-9.\\-]+$")); lines.push(format!( "\naccounts:\n authentication-enabled: true\n registration:\n enabled: true\n enabled-callbacks:\n - none\n enabled-credential-types:\n - passphrase\n nick-reservation:\n enabled: true\n method: strict\n multiclient:\n enabled: true\n vhosts:\n enabled: {vhost_enabled}\n max-length: {vhost_maxlen}\n valid-regexp: '{vhost_regexp}'" )); } if let Some(oauth2) = &spec.oauth2 { if let Some(secret) = oauth2_secret { let client_id = oauth2.client_id.as_deref().unwrap_or("ergo"); let introspection_url = sanitize_yaml_value(&oauth2.introspection_url); let client_id = sanitize_yaml_value(client_id); let secret = sanitize_yaml_value(secret); lines.push(format!( " oauth2:\n enabled: true\n autocreate: {}\n introspection-url: \"{introspection_url}\"\n introspection-timeout: 10s\n client-id: {client_id}\n client-secret: \"{secret}\"", oauth2.autocreate )); } } lines.push(String::new()); lines.join("\n") } #[cfg(test)] mod tests { use super::*; use crate::testutil::test_network; #[test] fn config_contains_hostname() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("test-network.irc.now")); } #[test] fn config_contains_network_name() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("Test Network")); } #[test] fn config_contains_listeners() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains(":6697")); assert!(config.contains(":6667")); } #[test] fn config_contains_tls_paths() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("/config/tls/tls.crt")); assert!(config.contains("/config/tls/tls.key")); } #[test] fn config_contains_oper_hash() { let hash = "$2a$10$examplehash"; let cm = build_configmap(&test_network(), Some(hash), None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains(hash)); assert!(config.contains("server-admin")); } #[test] fn config_without_oper_omits_opers_section() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(!config.contains("opers")); } #[test] fn network_name_defaults_to_hostname() { let mut network = test_network(); network.spec.network_name = None; let cm = build_configmap(&network, None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("test-network.irc.now")); } #[test] fn configmap_named_correctly() { let cm = build_configmap(&test_network(), None, None); assert_eq!(cm.metadata.name.unwrap(), "test-network-config"); } #[test] fn configmap_has_owner_reference() { let cm = build_configmap(&test_network(), None, None); let orefs = cm.metadata.owner_references.unwrap(); assert_eq!(orefs.len(), 1); assert_eq!(orefs[0].name, "test-network"); } #[test] fn config_contains_oauth2_when_configured() { let cm = build_configmap(&test_network(), None, Some("test-secret")); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("oauth2")); assert!(config.contains("introspection")); assert!(config.contains("autocreate: true")); } #[test] fn config_omits_oauth2_when_not_configured() { let mut network = test_network(); network.spec.oauth2 = None; let cm = build_configmap(&network, None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(!config.contains("oauth2")); } #[test] fn config_omits_oauth2_without_secret() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(!config.contains("oauth2")); } #[test] fn config_contains_cloaking_when_configured() { let cm = build_configmap(&test_network(), None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(config.contains("ip-cloaking")); assert!(config.contains("irc.now")); } #[test] fn config_omits_cloaking_when_not_configured() { let mut network = test_network(); network.spec.cloaking = None; let cm = build_configmap(&network, None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(!config.contains("ip-cloaking")); } #[test] fn config_uses_default_max_sendq() { let cm = build_configmap(&test_network(), None, None); let config = &cm.data.unwrap()["ircd.yaml"]; assert!(config.contains("max-sendq: 96k")); } #[test] fn config_uses_custom_max_sendq() { let mut network = test_network(); network.spec.max_sendq = Some("128k".to_string()); let config = &build_configmap(&network, None, None).data.unwrap()["ircd.yaml"]; assert!(config.contains("max-sendq: 128k")); } #[test] fn config_uses_default_cloaking_params() { let cm = build_configmap(&test_network(), None, None); let config = &cm.data.unwrap()["ircd.yaml"]; assert!(config.contains("cidr-len-ipv4: 32")); assert!(config.contains("cidr-len-ipv6: 64")); assert!(config.contains("num-bits: 64")); } #[test] fn config_uses_custom_cloaking_params() { let mut network = test_network(); let cloaking = network.spec.cloaking.as_mut().unwrap(); cloaking.cidr_len_ipv4 = Some(24); cloaking.cidr_len_ipv6 = Some(48); cloaking.num_bits = Some(80); let config = &build_configmap(&network, None, None).data.unwrap()["ircd.yaml"]; assert!(config.contains("cidr-len-ipv4: 24")); assert!(config.contains("cidr-len-ipv6: 48")); assert!(config.contains("num-bits: 80")); } #[test] fn config_uses_default_limits() { let config = &build_configmap(&test_network(), None, None).data.unwrap()["ircd.yaml"]; assert!(config.contains("nicklen: 32")); assert!(config.contains("identlen: 20")); assert!(config.contains("realnamelen: 150")); assert!(config.contains("channellen: 64")); assert!(config.contains("awaylen: 390")); assert!(config.contains("kicklen: 390")); assert!(config.contains("topiclen: 390")); assert!(config.contains("monitor-entries: 100")); assert!(config.contains("whowas-entries: 100")); assert!(config.contains("chan-list-modes: 100")); assert!(config.contains("registration-messages: 1024")); assert!(config.contains("max-bytes: 4096")); assert!(config.contains("max-lines: 100")); } #[test] fn config_uses_custom_limits() { use crate::types::{LimitsSpec, MultilineSpec}; let mut network = test_network(); network.spec.limits = Some(LimitsSpec { nicklen: Some(16), topiclen: Some(500), multiline: Some(MultilineSpec { max_bytes: Some(8192), max_lines: Some(200), }), ..Default::default() }); let config = &build_configmap(&network, None, None).data.unwrap()["ircd.yaml"]; assert!(config.contains("nicklen: 16")); assert!(config.contains("topiclen: 500")); assert!(config.contains("max-bytes: 8192")); assert!(config.contains("max-lines: 200")); assert!(config.contains("identlen: 20")); } #[test] fn config_uses_custom_oper_class() { let mut network = test_network(); network.spec.oper_credentials = Some(crate::types::OperCredentials { secret_name: "test-oper".to_string(), class_name: Some("network-admin".to_string()), }); let config = &build_configmap(&network, Some("$hash"), None).data.unwrap()["ircd.yaml"]; assert!(config.contains("class: network-admin")); } #[test] fn config_uses_default_oper_class() { let config = &build_configmap(&test_network(), Some("$hash"), None).data.unwrap()["ircd.yaml"]; assert!(config.contains("class: server-admin")); } #[test] fn config_uses_default_vhosts() { let config = &build_configmap(&test_network(), None, None).data.unwrap()["ircd.yaml"]; assert!(config.contains("enabled: true")); assert!(config.contains("max-length: 64")); assert!(config.contains("valid-regexp:")); } #[test] fn config_uses_custom_vhosts() { use crate::types::VhostSpec; let mut network = test_network(); network.spec.vhosts = Some(VhostSpec { enabled: Some(false), max_length: Some(128), valid_regexp: Some("^[a-z.]+$".to_string()), }); let config = &build_configmap(&network, None, None).data.unwrap()["ircd.yaml"]; assert!(config.contains("enabled: false")); assert!(config.contains("max-length: 128")); assert!(config.contains("'^[a-z.]+$'")); } #[test] fn config_uses_custom_oauth2_client_id() { let mut network = test_network(); network.spec.oauth2.as_mut().unwrap().client_id = Some("custom-client".to_string()); let config = &build_configmap(&network, None, Some("secret")).data.unwrap()["ircd.yaml"]; assert!(config.contains("client-id: custom-client")); } #[test] fn config_uses_default_oauth2_client_id() { let config = &build_configmap(&test_network(), None, Some("secret")).data.unwrap()["ircd.yaml"]; assert!(config.contains("client-id: ergo")); } #[test] fn sanitize_yaml_value_escapes_backslash() { assert_eq!(sanitize_yaml_value("a\\b"), "a\\\\b"); } #[test] fn sanitize_yaml_value_escapes_quotes() { assert_eq!(sanitize_yaml_value(r#"a"b"#), r#"a\"b"#); } #[test] fn sanitize_yaml_value_strips_newlines() { assert_eq!(sanitize_yaml_value("a\nb\r"), "ab"); } #[test] fn sanitize_yaml_value_combined() { assert_eq!(sanitize_yaml_value("a\\\"\n"), "a\\\\\\\""); } #[test] fn sanitize_yaml_value_empty() { assert_eq!(sanitize_yaml_value(""), ""); } #[test] fn config_rejects_netname_injection() { let mut network = test_network(); network.spec.cloaking = Some(crate::types::CloakingSpec { netname: "evil\"\nnew-key: injected".to_string(), cidr_len_ipv4: None, cidr_len_ipv6: None, num_bits: None, }); let cm = build_configmap(&network, None, None); let data = cm.data.unwrap(); let config = &data["ircd.yaml"]; assert!(!config.contains("new-key: injected")); } }