OperServ: per-IP session limiting with exceptions

The connection that puts an IP over its allowance is killed on connect.
The ircd's per-user IP (UID param 7) is now plumbed through UserConnect and
tracked per-IP in the live network view (incremented on connect, released
on quit).

- [session] default_limit sets the base allowance (0/absent = off).
- OperServ SESSION LIST <min> / VIEW <ip> inspect live counts.
- OperServ EXCEPTION ADD <ip-mask> <limit> [reason] / DEL / LIST override
  the limit per IP-mask (limit 0 = unlimited); event-sourced so they
  federate and survive restart, matched most-permissive-wins.

All admin-gated.
This commit is contained in:
Jean Chevronnet 2026-07-14 01:46:09 +00:00
parent 78ad909706
commit 76a7162227
No known key found for this signature in database
11 changed files with 410 additions and 116 deletions

View file

@ -27,6 +27,8 @@ mod info;
mod news;
#[path = "oper.rs"]
mod oper;
#[path = "session.rs"]
mod session;
pub struct OperServ {
pub uid: String,
@ -65,6 +67,8 @@ impl Service for OperServ {
Some(cmd) if cmd.eq_ignore_ascii_case("INFO") => info::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("NEWS") => news::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("OPER") => oper::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SESSION") => session::handle_session(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("EXCEPTION") => session::handle_exception(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")),
@ -73,5 +77,5 @@ impl Service for OperServ {
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators).");
ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits).");
}

76
operserv/src/session.rs Normal file
View file

@ -0,0 +1,76 @@
use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store};
// SESSION LIST <min> | SESSION VIEW <ip>: inspect live per-IP session counts.
// EXCEPTION ADD <ip-mask> <limit> [reason] | DEL <ip-mask> | LIST: manage the
// per-IP-mask allowances that override the default limit. All admin-only.
pub fn handle_session(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — SESSION needs the \x02admin\x02 privilege.");
return;
}
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("LIST") => {
let min = args.get(2).and_then(|n| n.parse::<u32>().ok()).unwrap_or(2);
let over = net.sessions_over(min);
if over.is_empty() {
ctx.notice(me, from.uid, format!("No IP has {min} or more sessions."));
return;
}
for (ip, n) in &over {
ctx.notice(me, from.uid, format!(" \x02{n}\x02 sessions from \x02{ip}\x02"));
}
ctx.notice(me, from.uid, format!("End of session list ({} IP(s)).", over.len()));
}
Some("VIEW") => {
let Some(&ip) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: SESSION VIEW <ip>");
return;
};
ctx.notice(me, from.uid, format!("\x02{ip}\x02 has \x02{}\x02 live session(s).", net.session_count(ip)));
}
_ => ctx.notice(me, from.uid, "Syntax: SESSION LIST <min> | SESSION VIEW <ip>"),
}
}
pub fn handle_exception(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — EXCEPTION needs the \x02admin\x02 privilege.");
return;
}
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => {
let (Some(&mask), Some(limit)) = (args.get(2), args.get(3).and_then(|n| n.parse::<u32>().ok())) else {
ctx.notice(me, from.uid, "Syntax: EXCEPTION ADD <ip-mask> <limit> [reason]");
return;
};
let reason = if args.len() > 4 { args[4..].join(" ") } else { "No reason given".to_string() };
db.session_except_add(mask, limit, &reason);
let note = if limit == 0 { " (unlimited)".to_string() } else { format!(" (limit {limit})") };
ctx.notice(me, from.uid, format!("Session exception for \x02{mask}\x02 set{note}."));
}
Some("DEL") | Some("REMOVE") => {
let Some(&mask) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: EXCEPTION DEL <ip-mask>");
return;
};
if db.session_except_del(mask) {
ctx.notice(me, from.uid, format!("Session exception for \x02{mask}\x02 removed."));
} else {
ctx.notice(me, from.uid, format!("No session exception matches \x02{mask}\x02."));
}
}
Some("LIST") => {
let list = db.session_exceptions();
if list.is_empty() {
ctx.notice(me, from.uid, "No session exceptions.");
return;
}
for (mask, limit, reason) in &list {
let lim = if *limit == 0 { "unlimited".to_string() } else { limit.to_string() };
ctx.notice(me, from.uid, format!(" \x02{mask}\x02{lim}{reason}"));
}
ctx.notice(me, from.uid, format!("End of exception list ({} shown).", list.len()));
}
_ => ctx.notice(me, from.uid, "Syntax: EXCEPTION ADD <ip-mask> <limit> [reason] | EXCEPTION DEL <ip-mask> | EXCEPTION LIST"),
}
}