Exempt service bots from ChannelOp secureops and bound the last-seen map
This commit is contained in:
parent
7528b78bf7
commit
d0c7cb6905
2 changed files with 35 additions and 13 deletions
|
|
@ -1434,7 +1434,10 @@ impl Engine {
|
||||||
NetEvent::ChannelOp { channel, uid, op } => {
|
NetEvent::ChannelOp { channel, uid, op } => {
|
||||||
self.network.set_op(&channel, &uid, op);
|
self.network.set_op(&channel, &uid, op);
|
||||||
// SECUREOPS: a user who gains +o without op-level access loses it.
|
// SECUREOPS: a user who gains +o without op-level access loses it.
|
||||||
if op {
|
// A services bot assigned here is staff, not a member — never subject
|
||||||
|
// to secureops (the Join path exempts it the same way).
|
||||||
|
let is_bot = self.bot_uids.values().any(|b| b == &uid);
|
||||||
|
if op && !is_bot {
|
||||||
if let Some(c) = self.db.channel(&channel) {
|
if let Some(c) = self.db.channel(&channel) {
|
||||||
if c.settings.secureops && !self.network.account_of(&uid).is_some_and(|a| c.is_op(a)) {
|
if c.settings.secureops && !self.network.account_of(&uid).is_some_and(|a| c.is_op(a)) {
|
||||||
let from = self.chan_service.clone().unwrap_or_default();
|
let from = self.chan_service.clone().unwrap_or_default();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ pub use echo_api::{ChanSeenView, HelpEntry, IncidentView, NetView, SeenView};
|
||||||
// The most recent moderation/action incidents kept for LOGSEARCH.
|
// The most recent moderation/action incidents kept for LOGSEARCH.
|
||||||
const INCIDENT_CAP: usize = 10_000;
|
const INCIDENT_CAP: usize = 10_000;
|
||||||
|
|
||||||
|
// Bound on the global last-seen map, so nick churn can't grow it without limit.
|
||||||
|
// Best-effort data: when it overflows, the stalest quarter is pruned.
|
||||||
|
const SEEN_CAP: usize = 20_000;
|
||||||
|
|
||||||
// Live network view, rebuilt from the uplink's burst each connect (ephemeral —
|
// Live network view, rebuilt from the uplink's burst each connect (ephemeral —
|
||||||
// unlike the account store, which persists).
|
// unlike the account store, which persists).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -318,20 +322,20 @@ impl Network {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_nick_change(&mut self, uid: &str, nick: String) {
|
pub fn user_nick_change(&mut self, uid: &str, nick: String) {
|
||||||
if let Some(user) = self.users.get_mut(uid) {
|
let Some(old) = self.users.get_mut(uid).map(|user| std::mem::replace(&mut user.nick, nick.clone())) else {
|
||||||
self.seen.insert(lc(&nick), Seen { nick: nick.clone(), ts: now(), what: format!("changing nick from {}", user.nick) });
|
return;
|
||||||
user.nick = nick;
|
};
|
||||||
}
|
self.record_seen(lc(&nick), Seen { nick, ts: now(), what: format!("changing nick from {old}") });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_quit(&mut self, uid: &str) {
|
pub fn user_quit(&mut self, uid: &str) {
|
||||||
if let Some(u) = self.users.get(uid) {
|
if let Some((nick, ip)) = self.users.get(uid).map(|u| (u.nick.clone(), u.ip.clone())) {
|
||||||
self.seen.insert(lc(&u.nick), Seen { nick: u.nick.clone(), ts: now(), what: "quitting".to_string() });
|
self.record_seen(lc(&nick), Seen { nick, ts: now(), what: "quitting".to_string() });
|
||||||
// Release the user's session slot.
|
// Release the user's session slot.
|
||||||
if let Some(n) = self.sessions.get_mut(&u.ip) {
|
if let Some(n) = self.sessions.get_mut(&ip) {
|
||||||
*n -= 1;
|
*n -= 1;
|
||||||
if *n == 0 {
|
if *n == 0 {
|
||||||
self.sessions.remove(&u.ip);
|
self.sessions.remove(&ip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -378,8 +382,8 @@ impl Network {
|
||||||
if op {
|
if op {
|
||||||
c.ops.insert(uid.to_string());
|
c.ops.insert(uid.to_string());
|
||||||
}
|
}
|
||||||
if let Some(nick) = self.nick_of(uid) {
|
if let Some(nick) = self.nick_of(uid).map(str::to_string) {
|
||||||
self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("joining {channel}") });
|
self.record_seen(lc(&nick), Seen { nick, ts: now(), what: format!("joining {channel}") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,8 +394,8 @@ impl Network {
|
||||||
c.ops.remove(uid);
|
c.ops.remove(uid);
|
||||||
c.voices.remove(uid);
|
c.voices.remove(uid);
|
||||||
}
|
}
|
||||||
if let Some(nick) = self.nick_of(uid) {
|
if let Some(nick) = self.nick_of(uid).map(str::to_string) {
|
||||||
self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("leaving {channel}") });
|
self.record_seen(lc(&nick), Seen { nick, ts: now(), what: format!("leaving {channel}") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -561,6 +565,21 @@ impl Network {
|
||||||
pub fn last_seen(&self, nick: &str) -> Option<&Seen> {
|
pub fn last_seen(&self, nick: &str) -> Option<&Seen> {
|
||||||
self.seen.get(&lc(nick))
|
self.seen.get(&lc(nick))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record a last-seen entry, bounding the map so distinct-nick churn (a client
|
||||||
|
// cycling nicks, a botnet) can't grow it without limit. When it overflows, the
|
||||||
|
// stalest quarter is pruned in one pass — amortising the O(n) sort so the common
|
||||||
|
// insert stays O(1). SEEN is best-effort, so forgetting the oldest is safe.
|
||||||
|
fn record_seen(&mut self, key: String, entry: Seen) {
|
||||||
|
self.seen.insert(key, entry);
|
||||||
|
if self.seen.len() > SEEN_CAP {
|
||||||
|
let mut by_ts: Vec<(String, u64)> = self.seen.iter().map(|(k, s)| (k.clone(), s.ts)).collect();
|
||||||
|
by_ts.sort_unstable_by_key(|(_, ts)| *ts);
|
||||||
|
for (k, _) in by_ts.into_iter().take(SEEN_CAP / 4) {
|
||||||
|
self.seen.remove(&k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The module-facing network view. Reads forward to Network's own accessors,
|
// The module-facing network view. Reads forward to Network's own accessors,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue