diff --git a/echoircd.conf.example b/echoircd.conf.example index c0a5ca2..73a5a98 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -89,8 +89,16 @@ oper { # "is a ") built from reusable capability classes; five ship built in: # helpop, globop, admin, servadmin, netadmin. Running a command your type doesn't # grant is refused; its modes/snomasks/vhost are applied on oper-up. -# class { name "helpdesk"; commands "CHECK"; snomasks "c"; } # privs "..." -# opertype { name "helpdesk"; classes "helpdesk"; modes "+ih"; title "Help_Desk"; level 15; } +# +# A class also grants privileges — named permissions checked at sensitive points. +# "privs=*" grants all. Standard privileges: +# users/auspex see a user's real host+IP and geo (WHOIS/WHO + the connect +# notice), and see +i users who share no channel with you +# channels/auspex see secret/private (+s/+p) channels in LIST/WHO/WHOIS +# Only netadmin holds all privileges by default; grant the built-in "auspex" class to +# any other type that should see through privacy. +# class { name "helpdesk"; commands "CHECK"; snomasks "c"; privs "users/auspex"; } +# opertype { name "helpdesk"; classes "helpdesk auspex"; modes "+ih"; title "Help_Desk"; level 15; } # ═══ server-to-server linking ════════════════════════════════════════════════ @@ -456,11 +464,6 @@ 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/coremods/core_extra.rs b/src/coremods/core_extra.rs index 00472e1..d07b39e 100644 --- a/src/coremods/core_extra.rs +++ b/src/coremods/core_extra.rs @@ -99,11 +99,12 @@ impl Command for List { } fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult { s.numeric(uid, RPL_LISTSTART, "Channel :Users Name"); + let auspex = crate::modules::opertypes::has_priv(s, uid, "channels/auspex"); let keys: Vec<String> = s.channels.keys().cloned().collect(); for key in keys { let ch = &s.channels[&key]; - // hide secret / private channels from non-members - if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) { + // hide secret / private channels from non-members (channels/auspex sees them) + if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) && !auspex { continue; } let count = ch.members.len() + ch.rmembers.len(); diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 91d347e..baaeaff 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -176,6 +176,9 @@ impl Command for Whois { return CmdResult::Fail; }; let asker_oper = s.is_oper(uid); + // auspex: see through user privacy (real host+IP, geo) / channel privacy (secret chans) + let asker_auspex_u = crate::modules::opertypes::has_priv(s, uid, "users/auspex"); + let asker_auspex_c = crate::modules::opertypes::has_priv(s, uid, "channels/auspex"); let is_self = tuid == uid; // hidewhois: hide sensitive lines from ordinary users (opers/self exempt per config) let hide = crate::modules::hidewhois::hide(s, uid, tuid, asker_oper); @@ -254,7 +257,7 @@ impl Command for Whois { // a +s/+p channel is shown only to the target itself, an oper, or a // fellow member — never leaked to an outside asker. is_self - || asker_oper + || asker_auspex_c || (!c.modes.secret && !c.modes.private) || c.members.contains_key(&uid) }) @@ -356,13 +359,15 @@ impl Command for Whois { if let Some(line) = crate::modules::whoisport::line(s, tuid) { s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } - // geoip: the country the user connects from — opers only + } + // geoip: where the user connects from — needs users/auspex (like the real host/ip) + if asker_auspex_u { if let Some(line) = crate::modules::geoip::whois_line(s, tuid) { s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } } - // opers can see through the cloak to the real host/ip - if asker_oper && disp != realhost { + // users/auspex: see through the cloak to the real host/ip + if asker_auspex_u && disp != realhost { s.numeric( uid, RPL_WHOISHOST, @@ -466,6 +471,10 @@ impl Command for Who { (fields.to_string(), qtype.to_string()) }); let asker_oper = s.is_oper(uid); + // auspex: reveal secret/private channel members (channels/auspex) and +i users + // who share no channel with the asker (users/auspex) + let asker_auspex_u = crate::modules::opertypes::has_priv(s, uid, "users/auspex"); + let asker_auspex_c = crate::modules::opertypes::has_priv(s, uid, "channels/auspex"); let multi = s .users .get(&uid) @@ -480,7 +489,7 @@ impl Command for Who { Some(ch) if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) - && !asker_oper => + && !asker_auspex_c => { Vec::new() } @@ -517,7 +526,7 @@ impl Command for Who { // with them (self and opers always see them). let hidden = s.users.get(&tuid).map(|u| u.flags.invisible).unwrap_or(false) && tuid != uid - && !asker_oper + && !asker_auspex_u && !s .channels .values() diff --git a/src/modules/opertypes.rs b/src/modules/opertypes.rs index 52297f5..b691f58 100644 --- a/src/modules/opertypes.rs +++ b/src/modules/opertypes.rs @@ -22,7 +22,6 @@ 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<u8>, // mIRC colour for the WHOIS title line (None = plain) pub all_commands: bool, @@ -95,13 +94,22 @@ pub fn whois_line(s: &Server, uid: Uid) -> Option<String> { }) } -/// 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 { +/// Whether operator `uid` holds privilege `name` (e.g. `users/auspex`). +/// A typed oper holds it if its type has `privs=*` or lists the privilege; an untyped +/// legacy oper (an `oper` block with no `type=`) holds every privilege; a non-oper holds +/// none. This is the check every privilege gate calls. +pub fn has_priv(s: &Server, uid: Uid, name: &str) -> bool { + s.users.get(&uid).is_some_and(|u| user_has_priv(u, name)) +} + +/// [`has_priv`] against an already-borrowed `&User`, for use inside a user iteration. +pub fn user_has_priv(u: &crate::users::User, name: &str) -> bool { + if !u.flags.oper { + return false; + } match u.ext.get::<OperType>() { - None => true, - Some(t) => allowed.iter().any(|a| a.eq_ignore_ascii_case(&t.type_id)), + None => true, // legacy oper (no type) — unrestricted + Some(t) => t.all_privs || t.privs.contains(name), } } @@ -133,7 +141,6 @@ 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, @@ -244,6 +251,8 @@ fn builtin() -> (HashMap<String, ClassDef>, HashMap<String, TypeDef>) { classes.insert("host".into(), cdef(&["CHGHOST", "CHGIDENT", "CHGNAME", "SETHOST", "SETIDENT", "SETIDLE", "SWHOIS"], &[], "")); classes.insert("services".into(), cdef(&["SVSNICK", "SVSJOIN", "SVSPART", "SVSMODE", "SVSLOGIN", "SVSLOGOUT"], &[], "")); classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &[], "lr")); + // auspex: see through user/channel privacy (real host+IP, geo, secret channels) + classes.insert("auspex".into(), cdef(&[], &["users/auspex", "channels/auspex"], "")); let mut types: HashMap<String, TypeDef> = HashMap::default(); // The WHOIS title line is bold + colour 4 (red) by default; override per type @@ -463,6 +472,32 @@ mod tests { assert!(netadmin.all_commands, "netadmin gets everything"); } + #[test] + fn only_all_privs_types_hold_auspex_by_default() { + // the resolved priv set is what user_has_priv checks: all_privs || privs.contains. + let has = |r: &Resolved, p: &str| r.all_privs || r.privs.contains(p); + + // netadmin holds every class ⇒ every privilege, incl. the auspex pair + let netadmin = resolved("netadmin"); + assert!(netadmin.all_privs, "netadmin holds every privilege"); + assert!(has(&netadmin, "users/auspex") && has(&netadmin, "channels/auspex")); + + // no lower built-in type sees through privacy until granted the auspex class + for id in ["helpop", "globop", "admin", "servadmin"] { + let r = resolved(id); + assert!(!has(&r, "users/auspex"), "{id} must not hold users/auspex by default"); + assert!(!has(&r, "channels/auspex"), "{id} must not hold channels/auspex by default"); + } + + // the auspex class exists so an admin can opt a type in + let (classes, _) = builtin(); + let aux = classes.get("auspex").expect("auspex class"); + assert!( + aux.privs.contains(&"users/auspex".to_string()) + && aux.privs.contains(&"channels/auspex".to_string()) + ); + } + #[test] fn gated_covers_the_dangerous_commands_only() { assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT")); diff --git a/src/modules/snoop.rs b/src/modules/snoop.rs index 173a241..ec32938 100644 --- a/src/modules/snoop.rs +++ b/src/modules/snoop.rs @@ -47,12 +47,11 @@ impl Module for Snoop { if srv.conf_bool("snoop_stderr", false) { eprintln!("[snoop] connect {nick} ({ident}@{shown_host})"); } - // 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. + // The notice is rendered per-viewer: the sensitive fields — the raw IP and the + // geo/ASN — go only to opers holding the users/auspex privilege; 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() { @@ -98,20 +97,9 @@ impl Module for Snoop { } 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<String> = 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_gated('c', &full, &redacted, |u| { + crate::modules::opertypes::user_has_priv(u, "users/auspex") + }); } fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) { if srv.conf_bool("snoop_stderr", false) {