diff --git a/echoircd.conf.example b/echoircd.conf.example index f74aa89..c0a5ca2 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -456,6 +456,11 @@ logging { # log_json "/var/log/echoircd/events.jsonl"; # --- snoop_stderr: also echo the server-notice stream to stderr --- # snoop_stderr yes; + # --- snoop_sensitive_opertype: which oper type(s) may see the sensitive fields + # of the "Client connecting" notice (the raw IP and the geo/ASN). Other opers + # get a "🔒 restricted" redaction; the server log always keeps the full line. + # Repeatable; default netadmin. Use "*" to let every +c oper see them. --- + # snoop_sensitive_opertype netadmin; # --- metrics: OpenMetrics/Prometheus scrape endpoint (plaintext HTTP GET); # bind privately or behind a proxy. --- # metrics_bind "127.0.0.1:9109"; diff --git a/src/modules/opertypes.rs b/src/modules/opertypes.rs index ca2b4c2..52297f5 100644 --- a/src/modules/opertypes.rs +++ b/src/modules/opertypes.rs @@ -22,6 +22,7 @@ use crate::Uid; /// Per-user resolved grant, stored on `User.ext` at oper-up. Present ⇒ a typed /// oper; absent ⇒ a legacy oper with full access. Read by WHOIS for the title. pub struct OperType { + pub type_id: String, // the resolved type id (e.g. "netadmin"), lower-cased pub title: String, pub color: Option, // mIRC colour for the WHOIS title line (None = plain) pub all_commands: bool, @@ -94,6 +95,16 @@ pub fn whois_line(s: &Server, uid: Uid) -> Option { }) } +/// Whether `u` may see operator-only sensitive fields, given the allowed type ids +/// (case-insensitive). Untyped opers — legacy `oper` blocks with no `type=`, which +/// carry unrestricted access — are always allowed. Used by the connect-notice redaction. +pub fn user_type_allowed(u: &crate::users::User, allowed: &[String]) -> bool { + match u.ext.get::() { + None => true, + Some(t) => allowed.iter().any(|a| a.eq_ignore_ascii_case(&t.type_id)), + } +} + /// Apply the oper's type at oper-up: auto usermodes / snomasks / vhost / level, then /// store the grant + title. A missing type (or an unknown id) leaves the oper with /// full access, so `oper` blocks without `type=` keep working. @@ -122,6 +133,7 @@ pub fn apply(s: &mut Server, uid: Uid, type_id: Option<&str>) { } if let Some(u) = s.users.get_mut(&uid) { u.ext.set(OperType { + type_id: id.clone(), title: r.title.clone(), color: r.color, all_commands: r.all_commands, diff --git a/src/modules/snoop.rs b/src/modules/snoop.rs index 19d2948..173a241 100644 --- a/src/modules/snoop.rs +++ b/src/modules/snoop.rs @@ -47,13 +47,12 @@ impl Module for Snoop { if srv.conf_bool("snoop_stderr", false) { eprintln!("[snoop] connect {nick} ({ident}@{shown_host})"); } - // port is always shown; sni/account only when present. Prose + field labels come - // from the locale catalog so a translated build reads naturally. - let mut msg = srv.trf( - "Client connecting: {0} ({1}@{2})", - &[nick.as_str(), ident.as_str(), shown_host.as_str()], - ); - // the connecting address, tagged by family (an ipv4-mapped v6 shows its ipv4 form) + // The notice is rendered per-viewer. The sensitive fields — the raw IP and the + // geo/ASN — are shown only to opers whose type may see them (default netadmin, + // config `snoop_sensitive_opertype`; `*` = everyone); lower opers get a redaction. + // The rest (cloak hostmask, port, transport, security, sni, account) is identical + // for all, and the server log always keeps the full detail. Prose + field labels + // come from the locale catalog so a translated build reads naturally. let (fam, ip_s) = match ip { std::net::IpAddr::V4(v4) => ("ipv4", v4.to_string()), std::net::IpAddr::V6(v6) => match v6.to_ipv4_mapped() { @@ -61,31 +60,58 @@ impl Module for Snoop { None => ("ipv6", v6.to_string()), }, }; - msg.push_str(&srv.trf(", {0}:{1}", &[fam, ip_s.as_str()])); - let port_s = port.to_string(); - msg.push_str(&srv.trf(", port: {0}", &[port_s.as_str()])); - // transport + security of this connection (WebSocket clients arrive on the wss - // listener via nginx/Orbit; TLS clients get the negotiated version/cipher). + let redacted_val = srv.trf("🔒 restricted", &[]); + let head = srv.trf( + "Client connecting: {0} ({1}@{2})", + &[nick.as_str(), ident.as_str(), shown_host.as_str()], + ); + // the connecting address, tagged by family (an ipv4-mapped v6 shows its ipv4 form) + let ip_full = srv.trf(", {0}:{1}", &[fam, ip_s.as_str()]); + let ip_red = srv.trf(", {0}:{1}", &[fam, redacted_val.as_str()]); + let port_seg = srv.trf(", port: {0}", &[port.to_string().as_str()]); + // transport + security (WebSocket clients arrive on the wss listener via + // nginx/Orbit; TLS clients get the negotiated version/cipher). + let mut trans = String::new(); if websocket { - msg.push_str(&srv.trf(", websocket", &[])); + trans.push_str(&srv.trf(", websocket", &[])); } if secure { match &tls_info { - Some(info) => msg.push_str(&srv.trf(", tls: {0}", &[info.as_str()])), - None => msg.push_str(&srv.trf(", secure", &[])), + Some(info) => trans.push_str(&srv.trf(", tls: {0}", &[info.as_str()])), + None => trans.push_str(&srv.trf(", secure", &[])), } } - // where the client is connecting from: GeoIP country/city (+ ASN if that db is loaded) - if let Some(geo) = crate::modules::geoip::describe(srv, ip) { - msg.push_str(&srv.trf(", geo: {0}", &[geo.as_str()])); - } + // geo: GeoIP country/city (+ ASN when that db is loaded) — sensitive like the IP + let (geo_full, geo_red) = match crate::modules::geoip::describe(srv, ip) { + Some(g) => ( + srv.trf(", geo: {0}", &[g.as_str()]), + srv.trf(", geo: {0}", &[redacted_val.as_str()]), + ), + None => (String::new(), String::new()), + }; + let mut tail = String::new(); if let Some(sni) = &sni { - msg.push_str(&srv.trf(", sni: {0}", &[sni.as_str()])); + tail.push_str(&srv.trf(", sni: {0}", &[sni.as_str()])); } if let Some(acct) = &account { - msg.push_str(&srv.trf(", account: {0}", &[acct.as_str()])); + tail.push_str(&srv.trf(", account: {0}", &[acct.as_str()])); + } + let full = format!("{head}{ip_full}{port_seg}{trans}{geo_full}{tail}"); + let redacted = format!("{head}{ip_red}{port_seg}{trans}{geo_red}{tail}"); + // who may see the sensitive fields: the configured oper types, default netadmin. + let configured = srv.conf_all("snoop_sensitive_opertype"); + let allow: Vec = if configured.is_empty() { + vec!["netadmin".to_string()] + } else { + configured.to_vec() + }; + if allow.iter().any(|a| a == "*") { + srv.snotice_c('c', &full); // redaction disabled — every +c oper sees the full line + } else { + srv.snotice_c_gated('c', &full, &redacted, |u| { + crate::modules::opertypes::user_type_allowed(u, &allow) + }); } - srv.snotice_c('c', &msg); } fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) { if srv.conf_bool("snoop_stderr", false) { diff --git a/src/server.rs b/src/server.rs index 80f2486..7ecb3ac 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1130,6 +1130,38 @@ impl Server { crate::modules::log_json::tee(self, msg); } + /// Like [`Self::snotice_c`], but redacted per viewer: each `+c` oper for whom + /// `allow(&user)` is false receives `redacted` instead of `full`. The server log and + /// the chan/syslog/json tees always record the full line, so nothing is lost for audit. + pub fn snotice_c_gated( + &self, + cat: char, + full: &str, + redacted: &str, + allow: impl Fn(&crate::users::User) -> bool, + ) { + self.log_push(full); + let recips: Vec<(Uid, bool)> = self + .users + .iter() + .filter(|(_, u)| u.flags.oper && u.flags.snomask_cats.contains(cat)) + .map(|(&uid, u)| (uid, allow(u))) + .collect(); + let uids: Vec = recips.iter().map(|(u, _)| *u).collect(); + let jfull = self.json_log_value(full, &uids); + let jred = self.json_log_value(redacted, &uids); + for (o, revealed) in recips { + if revealed { + self.deliver_server_notice(o, full, &jfull); + } else { + self.deliver_server_notice(o, redacted, &jred); + } + } + crate::modules::chanlog::tee(self, cat, full); + crate::modules::syslog::tee(self, full); + crate::modules::log_json::tee(self, full); + } + /// Broadcast a `*** msg` server NOTICE to *every* registered local user — for /// server-wide announcements everyone should see (e.g. a config reload). Goes /// through the same tagged path as `snotice`, so cap-holders get the server-time