snoop: redact connect-notice IP + geo/ASN to non-netadmin opers
Per-viewer rendering via new snotice_c_gated: only opers whose type is in
snoop_sensitive_opertype (default netadmin) see the raw IP and geo/ASN;
lower opers see a 🔒 restricted redaction. Logs keep the full line.
This commit is contained in:
parent
0af2ab7d21
commit
d73f68f278
4 changed files with 97 additions and 22 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<u8>, // 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<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 {
|
||||
match u.ext.get::<OperType>() {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<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('c', &msg);
|
||||
}
|
||||
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {
|
||||
if srv.conf_bool("snoop_stderr", false) {
|
||||
|
|
|
|||
|
|
@ -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<Uid> = 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue