opertypes: enforced oper privilege system (users/auspex, channels/auspex)
Add has_priv/user_has_priv and wire the auspex privileges into their gates: WHOIS real host+IP + geo, WHO secret-channel members + hidden +i users, LIST secret/private channels, and the connect-notice IP/geo redaction. Only netadmin holds every privilege by default; the reusable auspex class grants the pair to any other oper type. Replaces the ad-hoc snoop type gate.
This commit is contained in:
parent
d73f68f278
commit
ff380d601d
5 changed files with 79 additions and 43 deletions
|
|
@ -89,8 +89,16 @@ oper {
|
||||||
# "is a <title>") built from reusable capability classes; five ship built in:
|
# "is a <title>") built from reusable capability classes; five ship built in:
|
||||||
# helpop, globop, admin, servadmin, netadmin. Running a command your type doesn't
|
# helpop, globop, admin, servadmin, netadmin. Running a command your type doesn't
|
||||||
# grant is refused; its modes/snomasks/vhost are applied on oper-up.
|
# 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 ════════════════════════════════════════════════
|
# ═══ server-to-server linking ════════════════════════════════════════════════
|
||||||
|
|
@ -456,11 +464,6 @@ logging {
|
||||||
# log_json "/var/log/echoircd/events.jsonl";
|
# log_json "/var/log/echoircd/events.jsonl";
|
||||||
# --- snoop_stderr: also echo the server-notice stream to stderr ---
|
# --- snoop_stderr: also echo the server-notice stream to stderr ---
|
||||||
# snoop_stderr yes;
|
# 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);
|
# --- metrics: OpenMetrics/Prometheus scrape endpoint (plaintext HTTP GET);
|
||||||
# bind privately or behind a proxy. ---
|
# bind privately or behind a proxy. ---
|
||||||
# metrics_bind "127.0.0.1:9109";
|
# metrics_bind "127.0.0.1:9109";
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,12 @@ impl Command for List {
|
||||||
}
|
}
|
||||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
s.numeric(uid, RPL_LISTSTART, "Channel :Users Name");
|
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();
|
let keys: Vec<String> = s.channels.keys().cloned().collect();
|
||||||
for key in keys {
|
for key in keys {
|
||||||
let ch = &s.channels[&key];
|
let ch = &s.channels[&key];
|
||||||
// hide secret / private channels from non-members
|
// hide secret / private channels from non-members (channels/auspex sees them)
|
||||||
if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) {
|
if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) && !auspex {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let count = ch.members.len() + ch.rmembers.len();
|
let count = ch.members.len() + ch.rmembers.len();
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,9 @@ impl Command for Whois {
|
||||||
return CmdResult::Fail;
|
return CmdResult::Fail;
|
||||||
};
|
};
|
||||||
let asker_oper = s.is_oper(uid);
|
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;
|
let is_self = tuid == uid;
|
||||||
// hidewhois: hide sensitive lines from ordinary users (opers/self exempt per config)
|
// hidewhois: hide sensitive lines from ordinary users (opers/self exempt per config)
|
||||||
let hide = crate::modules::hidewhois::hide(s, uid, tuid, asker_oper);
|
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
|
// a +s/+p channel is shown only to the target itself, an oper, or a
|
||||||
// fellow member — never leaked to an outside asker.
|
// fellow member — never leaked to an outside asker.
|
||||||
is_self
|
is_self
|
||||||
|| asker_oper
|
|| asker_auspex_c
|
||||||
|| (!c.modes.secret && !c.modes.private)
|
|| (!c.modes.secret && !c.modes.private)
|
||||||
|| c.members.contains_key(&uid)
|
|| c.members.contains_key(&uid)
|
||||||
})
|
})
|
||||||
|
|
@ -356,13 +359,15 @@ impl Command for Whois {
|
||||||
if let Some(line) = crate::modules::whoisport::line(s, tuid) {
|
if let Some(line) = crate::modules::whoisport::line(s, tuid) {
|
||||||
s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}"));
|
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) {
|
if let Some(line) = crate::modules::geoip::whois_line(s, tuid) {
|
||||||
s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}"));
|
s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// opers can see through the cloak to the real host/ip
|
// users/auspex: see through the cloak to the real host/ip
|
||||||
if asker_oper && disp != realhost {
|
if asker_auspex_u && disp != realhost {
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
RPL_WHOISHOST,
|
RPL_WHOISHOST,
|
||||||
|
|
@ -466,6 +471,10 @@ impl Command for Who {
|
||||||
(fields.to_string(), qtype.to_string())
|
(fields.to_string(), qtype.to_string())
|
||||||
});
|
});
|
||||||
let asker_oper = s.is_oper(uid);
|
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
|
let multi = s
|
||||||
.users
|
.users
|
||||||
.get(&uid)
|
.get(&uid)
|
||||||
|
|
@ -480,7 +489,7 @@ impl Command for Who {
|
||||||
Some(ch)
|
Some(ch)
|
||||||
if (ch.modes.secret || ch.modes.private)
|
if (ch.modes.secret || ch.modes.private)
|
||||||
&& !ch.members.contains_key(&uid)
|
&& !ch.members.contains_key(&uid)
|
||||||
&& !asker_oper =>
|
&& !asker_auspex_c =>
|
||||||
{
|
{
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
@ -517,7 +526,7 @@ impl Command for Who {
|
||||||
// with them (self and opers always see them).
|
// with them (self and opers always see them).
|
||||||
let hidden = s.users.get(&tuid).map(|u| u.flags.invisible).unwrap_or(false)
|
let hidden = s.users.get(&tuid).map(|u| u.flags.invisible).unwrap_or(false)
|
||||||
&& tuid != uid
|
&& tuid != uid
|
||||||
&& !asker_oper
|
&& !asker_auspex_u
|
||||||
&& !s
|
&& !s
|
||||||
.channels
|
.channels
|
||||||
.values()
|
.values()
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ use crate::Uid;
|
||||||
/// Per-user resolved grant, stored on `User.ext` at oper-up. Present ⇒ a typed
|
/// 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.
|
/// oper; absent ⇒ a legacy oper with full access. Read by WHOIS for the title.
|
||||||
pub struct OperType {
|
pub struct OperType {
|
||||||
pub type_id: String, // the resolved type id (e.g. "netadmin"), lower-cased
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub color: Option<u8>, // mIRC colour for the WHOIS title line (None = plain)
|
pub color: Option<u8>, // mIRC colour for the WHOIS title line (None = plain)
|
||||||
pub all_commands: bool,
|
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
|
/// Whether operator `uid` holds privilege `name` (e.g. `users/auspex`).
|
||||||
/// (case-insensitive). Untyped opers — legacy `oper` blocks with no `type=`, which
|
/// A typed oper holds it if its type has `privs=*` or lists the privilege; an untyped
|
||||||
/// carry unrestricted access — are always allowed. Used by the connect-notice redaction.
|
/// legacy oper (an `oper` block with no `type=`) holds every privilege; a non-oper holds
|
||||||
pub fn user_type_allowed(u: &crate::users::User, allowed: &[String]) -> bool {
|
/// 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>() {
|
match u.ext.get::<OperType>() {
|
||||||
None => true,
|
None => true, // legacy oper (no type) — unrestricted
|
||||||
Some(t) => allowed.iter().any(|a| a.eq_ignore_ascii_case(&t.type_id)),
|
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) {
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
u.ext.set(OperType {
|
u.ext.set(OperType {
|
||||||
type_id: id.clone(),
|
|
||||||
title: r.title.clone(),
|
title: r.title.clone(),
|
||||||
color: r.color,
|
color: r.color,
|
||||||
all_commands: r.all_commands,
|
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("host".into(), cdef(&["CHGHOST", "CHGIDENT", "CHGNAME", "SETHOST", "SETIDENT", "SETIDLE", "SWHOIS"], &[], ""));
|
||||||
classes.insert("services".into(), cdef(&["SVSNICK", "SVSJOIN", "SVSPART", "SVSMODE", "SVSLOGIN", "SVSLOGOUT"], &[], ""));
|
classes.insert("services".into(), cdef(&["SVSNICK", "SVSJOIN", "SVSPART", "SVSMODE", "SVSLOGIN", "SVSLOGOUT"], &[], ""));
|
||||||
classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &[], "lr"));
|
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();
|
let mut types: HashMap<String, TypeDef> = HashMap::default();
|
||||||
// The WHOIS title line is bold + colour 4 (red) by default; override per type
|
// 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");
|
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]
|
#[test]
|
||||||
fn gated_covers_the_dangerous_commands_only() {
|
fn gated_covers_the_dangerous_commands_only() {
|
||||||
assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT"));
|
assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT"));
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,11 @@ impl Module for Snoop {
|
||||||
if srv.conf_bool("snoop_stderr", false) {
|
if srv.conf_bool("snoop_stderr", false) {
|
||||||
eprintln!("[snoop] connect {nick} ({ident}@{shown_host})");
|
eprintln!("[snoop] connect {nick} ({ident}@{shown_host})");
|
||||||
}
|
}
|
||||||
// The notice is rendered per-viewer. The sensitive fields — the raw IP and the
|
// 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,
|
// geo/ASN — go only to opers holding the users/auspex privilege; lower opers get a
|
||||||
// config `snoop_sensitive_opertype`; `*` = everyone); lower opers get a redaction.
|
// redaction. The rest (cloak hostmask, port, transport, security, sni, account) is
|
||||||
// The rest (cloak hostmask, port, transport, security, sni, account) is identical
|
// identical for all, and the server log always keeps the full detail. Prose + field
|
||||||
// for all, and the server log always keeps the full detail. Prose + field labels
|
// labels come from the locale catalog so a translated build reads naturally.
|
||||||
// come from the locale catalog so a translated build reads naturally.
|
|
||||||
let (fam, ip_s) = match ip {
|
let (fam, ip_s) = match ip {
|
||||||
std::net::IpAddr::V4(v4) => ("ipv4", v4.to_string()),
|
std::net::IpAddr::V4(v4) => ("ipv4", v4.to_string()),
|
||||||
std::net::IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
|
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 full = format!("{head}{ip_full}{port_seg}{trans}{geo_full}{tail}");
|
||||||
let redacted = format!("{head}{ip_red}{port_seg}{trans}{geo_red}{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.
|
srv.snotice_c_gated('c', &full, &redacted, |u| {
|
||||||
let configured = srv.conf_all("snoop_sensitive_opertype");
|
crate::modules::opertypes::user_has_priv(u, "users/auspex")
|
||||||
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)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {
|
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {
|
||||||
if srv.conf_bool("snoop_stderr", false) {
|
if srv.conf_bool("snoop_stderr", false) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue