securitygroups: UnrealIRCd-style groups + g: extban + SECURITYGROUPS + WHOIS
This commit is contained in:
parent
97a35338fa
commit
64dcbd8bf4
9 changed files with 249 additions and 6 deletions
|
|
@ -83,3 +83,10 @@ amu_target = both
|
|||
|
||||
# --- connflood: refuse >max connections per <secs> from a single IP ---
|
||||
# connflood = 5 10
|
||||
|
||||
# --- security groups (UnrealIRCd-style): securitygroup = <name> [criteria...]
|
||||
# criteria: public tls insecure account unregistered oper exclude-oper
|
||||
# bot exclude-bot webirc exclude-webirc mask=<glob> exclude=<glob>
|
||||
# scoremin=<n> scoremax=<n> — use as an extban: MODE #c +b g:<name>
|
||||
# securitygroup = trusted account tls public
|
||||
# securitygroup = newbies scoremax=10 public
|
||||
|
|
|
|||
|
|
@ -545,10 +545,8 @@ impl Server {
|
|||
}
|
||||
}
|
||||
// +b — bans block even an invited user, unless a +e exception matches
|
||||
let mask = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||
if ch.bans.iter().any(|b| glob_match(&b.mask, &mask))
|
||||
&& !ch.excepts.iter().any(|e| glob_match(&e.mask, &mask))
|
||||
{
|
||||
// (both honour the g: security-group extban)
|
||||
if self.ban_list_hit(uid, &ch.bans) && !self.ban_list_hit(uid, &ch.excepts) {
|
||||
if !is_oper {
|
||||
self.numeric(
|
||||
uid,
|
||||
|
|
@ -562,7 +560,7 @@ impl Server {
|
|||
// +i — unless invited or matched by a +I invite exception
|
||||
if ch.modes.invite_only
|
||||
&& !ch.invites.contains(&uid)
|
||||
&& !ch.invex.iter().any(|e| glob_match(&e.mask, &mask))
|
||||
&& !self.ban_list_hit(uid, &ch.invex)
|
||||
{
|
||||
if !is_oper {
|
||||
self.numeric(
|
||||
|
|
@ -885,6 +883,23 @@ impl Server {
|
|||
/// True if `uid` is caught by an acting extban of type `kind` (`m`/`c`/`n`) on
|
||||
/// `key` with no matching `kind:` exception in +e. The stored mask is
|
||||
/// `kind:<hostmask>`; we glob the hostmask part against the user's prefix.
|
||||
/// Whether any entry in `list` catches `uid`: a plain `nick!user@host` glob,
|
||||
/// or the `g:<group>` security-group matching extban. Acting extbans (`m:`/`c:`/
|
||||
/// `n:`) never match here — they restrict actions, not join/ban membership.
|
||||
pub fn ban_list_hit(&self, uid: Uid, list: &[Ban]) -> bool {
|
||||
let who = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||
list.iter().any(|b| {
|
||||
if b.mask.as_bytes().get(1) == Some(&b':') {
|
||||
match b.mask.as_bytes().first() {
|
||||
Some(b'g') => crate::modules::securitygroups::in_group(self, uid, &b.mask[2..]),
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
glob_match(&b.mask, &who)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extban_active(&self, uid: Uid, key: &str, kind: char) -> bool {
|
||||
let Some(ch) = self.channels.get(key) else {
|
||||
return false;
|
||||
|
|
@ -1085,6 +1100,10 @@ pub fn normalize_mask(m: &str) -> String {
|
|||
pub fn normalize_ban_mask(m: &str) -> String {
|
||||
let b = m.as_bytes();
|
||||
if b.len() >= 2 && b[1] == b':' && (b[0] as char).is_ascii_alphabetic() {
|
||||
// the g: security-group extban's argument is a group name, not a host mask
|
||||
if b[0] == b'g' {
|
||||
return m.to_string();
|
||||
}
|
||||
return format!("{}:{}", &m[..1], normalize_mask(&m[2..]));
|
||||
}
|
||||
normalize_mask(m)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,32 @@
|
|||
//! oper = god secret
|
||||
//! ```
|
||||
|
||||
/// Tri-state for a security-group criterion: don't-care / must-be / must-not-be.
|
||||
#[derive(Clone, Copy, PartialEq, Default)]
|
||||
pub enum Tri {
|
||||
#[default]
|
||||
Ignore,
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
/// A UnrealIRCd-style security group (InspIRCd `m_securitygroups`). All criteria
|
||||
/// are AND-ed: a user is a member iff every set criterion matches.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SecGroup {
|
||||
pub name: String,
|
||||
pub public: bool, // shown to non-opers
|
||||
pub masks: Vec<String>, // positive: match any one nick!user@host glob
|
||||
pub exclude_masks: Vec<String>, // negative: matching any one vetoes membership
|
||||
pub tls: Tri,
|
||||
pub account: Tri,
|
||||
pub oper: Tri,
|
||||
pub bot: Tri,
|
||||
pub webirc: Tri,
|
||||
pub score_min: Option<u32>, // reputation lower bound
|
||||
pub score_max: Option<u32>, // reputation upper bound
|
||||
}
|
||||
|
||||
/// A server-link block: how to authenticate a peer named `name` (and, if
|
||||
/// `autoconnect`, where to dial it). Passwords are the shared link secret.
|
||||
#[derive(Clone)]
|
||||
|
|
@ -80,6 +106,7 @@ pub struct Config {
|
|||
pub vhosts: Vec<(String, String, String)>, // self-service vhosts: (user, pass, host)
|
||||
pub aliases: Vec<(String, String)>, // command aliases: (name, target-nick)
|
||||
pub connflood: Option<(u32, u64)>, // (max conns, per secs) from one IP before refusing
|
||||
pub sec_groups: Vec<SecGroup>, // UnrealIRCd-style security groups
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
@ -112,6 +139,7 @@ impl Default for Config {
|
|||
vhosts: Vec::new(),
|
||||
aliases: Vec::new(),
|
||||
connflood: None,
|
||||
sec_groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -281,6 +309,45 @@ impl Config {
|
|||
}
|
||||
}
|
||||
}
|
||||
"securitygroup" | "secgroup" => {
|
||||
// securitygroup = <name> [public] [tls|insecure] [account|unregistered]
|
||||
// [oper|exclude-oper] [bot|exclude-bot] [webirc|exclude-webirc]
|
||||
// [mask=<glob>]... [exclude=<glob>]... [scoremin=N] [scoremax=N]
|
||||
let mut it = v.split_whitespace();
|
||||
if let Some(name) = it.next() {
|
||||
let mut g = SecGroup {
|
||||
name: name.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
for tok in it {
|
||||
let (k, val) = match tok.split_once('=') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (tok, None),
|
||||
};
|
||||
match (k, val) {
|
||||
("public", _) => g.public = true,
|
||||
("mask", Some(m)) => g.masks.push(m.to_string()),
|
||||
("exclude", Some(m)) | ("exclude-mask", Some(m)) => {
|
||||
g.exclude_masks.push(m.to_string())
|
||||
}
|
||||
("tls", _) | ("tls-users", _) => g.tls = Tri::Yes,
|
||||
("insecure", _) | ("exclude-tls", _) => g.tls = Tri::No,
|
||||
("account", _) | ("registered", _) => g.account = Tri::Yes,
|
||||
("unregistered", _) | ("exclude-account", _) => g.account = Tri::No,
|
||||
("oper", _) => g.oper = Tri::Yes,
|
||||
("exclude-oper", _) => g.oper = Tri::No,
|
||||
("bot", _) | ("bmode", _) => g.bot = Tri::Yes,
|
||||
("exclude-bot", _) | ("exclude-bmode", _) => g.bot = Tri::No,
|
||||
("webirc", _) => g.webirc = Tri::Yes,
|
||||
("exclude-webirc", _) => g.webirc = Tri::No,
|
||||
("scoremin", Some(n)) => g.score_min = n.parse().ok(),
|
||||
("scoremax", Some(n)) => g.score_max = n.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
c.sec_groups.push(g);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,15 @@ impl Command for Whois {
|
|||
if let Some(line) = &swhois {
|
||||
s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}"));
|
||||
}
|
||||
// security groups (public ones to all; opers/self see private ones too)
|
||||
let groups = crate::modules::securitygroups::user_groups(s, tuid, is_self || asker_oper);
|
||||
if !groups.is_empty() {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSPECIAL,
|
||||
&format!(":is in security groups: {}", groups.join(", ")),
|
||||
);
|
||||
}
|
||||
// opers can see through the cloak to the real host/ip
|
||||
if asker_oper && disp != realhost {
|
||||
s.numeric(
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ impl Command for WebIrc {
|
|||
};
|
||||
let newip = ip.parse::<IpAddr>().ok();
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.flags.via_webirc = true; // securitygroups: webirc criterion
|
||||
u.host = host.clone();
|
||||
if let Some(a) = newip {
|
||||
u.addr = SocketAddr::new(a, u.addr.port());
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod markread;
|
|||
pub mod metadata;
|
||||
pub mod multiline;
|
||||
pub mod reputation;
|
||||
pub mod securitygroups;
|
||||
pub mod snoop;
|
||||
|
||||
use crate::command::Command;
|
||||
|
|
@ -43,5 +44,6 @@ pub fn module_commands() -> Vec<Box<dyn Command>> {
|
|||
.chain(multiline::commands())
|
||||
.chain(chathistory::commands())
|
||||
.chain(reputation::commands())
|
||||
.chain(securitygroups::commands())
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
135
src/modules/securitygroups.rs
Normal file
135
src/modules/securitygroups.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
//! securitygroups — UnrealIRCd-style security groups (InspIRCd `m_securitygroups`).
|
||||
//! A `securitygroup` config line defines a named set of users by AND-ed criteria
|
||||
//! (host masks, TLS, account, oper, bot, webirc, reputation score range). Groups
|
||||
//! drive the `g:` matching extban, the `SECURITYGROUPS` command, and a WHOIS line.
|
||||
//! Self-contained: the group defs live in `Server.sec_groups`; evaluation is here.
|
||||
|
||||
use crate::channels::glob_match;
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::config::{SecGroup, Tri};
|
||||
use crate::numeric::ERR_NOSUCHNICK;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// Does `uid`'s identity match `mask` (glob against nick!user@{display,real,ip})?
|
||||
fn mask_matches(s: &Server, uid: Uid, mask: &str) -> bool {
|
||||
let Some(u) = s.users.get(&uid) else {
|
||||
return false;
|
||||
};
|
||||
let forms = [
|
||||
format!("{}!{}@{}", u.nick, u.ident, u.host_display()),
|
||||
format!("{}!{}@{}", u.nick, u.ident, u.host),
|
||||
format!("{}!{}@{}", u.nick, u.ident, u.addr.ip()),
|
||||
];
|
||||
forms.iter().any(|f| glob_match(mask, f))
|
||||
}
|
||||
|
||||
/// True when a tri-state criterion is satisfied by `fact`.
|
||||
fn tri_ok(want: Tri, fact: bool) -> bool {
|
||||
match want {
|
||||
Tri::Yes => fact,
|
||||
Tri::No => !fact,
|
||||
Tri::Ignore => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `uid` match every criterion of `g`?
|
||||
fn matches(s: &Server, uid: Uid, g: &SecGroup) -> bool {
|
||||
let Some(u) = s.users.get(&uid) else {
|
||||
return false;
|
||||
};
|
||||
// masks: an exclude match vetoes; positive masks (if any) require one to match
|
||||
if g.exclude_masks.iter().any(|m| mask_matches(s, uid, m)) {
|
||||
return false;
|
||||
}
|
||||
if !g.masks.is_empty() && !g.masks.iter().any(|m| mask_matches(s, uid, m)) {
|
||||
return false;
|
||||
}
|
||||
if !tri_ok(g.tls, u.secure)
|
||||
|| !tri_ok(g.account, u.account.is_some())
|
||||
|| !tri_ok(g.oper, u.flags.oper)
|
||||
|| !tri_ok(g.bot, u.flags.bot)
|
||||
|| !tri_ok(g.webirc, u.flags.via_webirc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if g.score_min.is_some() || g.score_max.is_some() {
|
||||
let ip = u.addr.ip();
|
||||
let score = s
|
||||
.ext
|
||||
.get::<crate::modules::reputation::Reputation>()
|
||||
.and_then(|r| r.0.get(&ip))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if g.score_min.is_some_and(|m| score < m) || g.score_max.is_some_and(|m| score > m) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether `uid` is a member of the named security group (case-insensitive).
|
||||
pub fn in_group(s: &Server, uid: Uid, name: &str) -> bool {
|
||||
s.sec_groups
|
||||
.iter()
|
||||
.any(|g| g.name.eq_ignore_ascii_case(name) && matches(s, uid, g))
|
||||
}
|
||||
|
||||
/// The names of the groups `uid` is in (only public ones unless `include_private`).
|
||||
pub fn user_groups(s: &Server, uid: Uid, include_private: bool) -> Vec<String> {
|
||||
s.sec_groups
|
||||
.iter()
|
||||
.filter(|g| (include_private || g.public) && matches(s, uid, g))
|
||||
.map(|g| g.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(SecGroupsCmd)]
|
||||
}
|
||||
|
||||
/// SECURITYGROUPS — `SECURITYGROUPS [nick]`. List the security groups a user is in.
|
||||
/// You always see your own; opers see everyone's; otherwise only public groups show.
|
||||
struct SecGroupsCmd;
|
||||
impl Command for SecGroupsCmd {
|
||||
fn name(&self) -> &'static str {
|
||||
"SECURITYGROUPS"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let tuid = match params.first() {
|
||||
Some(n) => match s.find_nick(n) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
s.numeric(uid, ERR_NOSUCHNICK, &format!("{n} :No such nick/channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
},
|
||||
None => uid,
|
||||
};
|
||||
let include_private = tuid == uid || s.is_oper(uid);
|
||||
let groups = user_groups(s, tuid, include_private);
|
||||
let list = if groups.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
groups.join(", ")
|
||||
};
|
||||
let (tnick, anick) = (
|
||||
s.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default(),
|
||||
s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
s.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {anick} :{tnick} is in security groups: {list}",
|
||||
s.name
|
||||
),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +137,7 @@ pub struct Server {
|
|||
pub aliases: Vec<(String, String)>, // command aliases: (name, target-nick)
|
||||
pub connflood: Option<(u32, u64)>, // (max, secs) connection throttle per IP
|
||||
pub conn_history: HashMap<IpAddr, Vec<u64>>, // recent connection times per IP (connflood)
|
||||
pub sec_groups: Vec<crate::config::SecGroup>, // UnrealIRCd-style security groups
|
||||
// labeled-response: while Some((uid, buf)), that client's own responses are
|
||||
// diverted into `buf` instead of the socket, so `on_line` can wrap them with
|
||||
// the command's `label` (single tag, BATCH, or ACK). RefCell because the
|
||||
|
|
@ -193,6 +194,7 @@ impl Server {
|
|||
aliases: cfg.aliases,
|
||||
connflood: cfg.connflood,
|
||||
conn_history: HashMap::new(),
|
||||
sec_groups: cfg.sec_groups,
|
||||
label_capture: RefCell::new(None),
|
||||
event_tx,
|
||||
conn_counter,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ pub struct UserFlags {
|
|||
pub showwhois: bool, // +W (get a notice when someone WHOISes you)
|
||||
pub deny_uncommon: bool, // +c (only users sharing a channel may PM you)
|
||||
pub nick_locked: bool, // NICKLOCK: services/oper holds this nick (no self-change)
|
||||
pub via_webirc: bool, // connected through a WEBIRC gateway (securitygroups)
|
||||
pub away: Option<String>, // AWAY message, if set
|
||||
}
|
||||
|
||||
|
|
@ -430,7 +431,7 @@ impl Server {
|
|||
uid,
|
||||
RPL_ISUPPORT,
|
||||
&format!(
|
||||
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,cgmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||
self.network
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue