feat: add admin/owner permission system

Hostmask-based admin controls with automatic IRCOP detection via WHO.
Permission enforcement in the central dispatch path denies restricted
commands to non-admins. Includes !whoami and !admins commands, marks
load/reload/unload as admin-only.

Also lands previously-implemented SASL PLAIN auth, token-bucket rate
limiting, and CTCP VERSION/TIME/PING responses that were staged but
uncommitted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 02:24:56 +01:00
parent 36b21e2463
commit f96224afb1
9 changed files with 408 additions and 18 deletions

View File

@@ -3,10 +3,14 @@
from __future__ import annotations
import asyncio
import base64
import fnmatch
import logging
import time
from datetime import datetime, timezone
from pathlib import Path
from derp import __version__
from derp.irc import IRCConnection, Message, format_msg, parse
from derp.plugin import Handler, PluginRegistry
@@ -16,6 +20,30 @@ RECONNECT_DELAY = 30
_AMBIGUOUS = object() # sentinel for ambiguous prefix matches
class _TokenBucket:
"""Token bucket rate limiter for outgoing messages."""
def __init__(self, rate: float, burst: int) -> None:
self._rate = rate
self._burst = burst
self._tokens = float(burst)
self._last = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self) -> None:
"""Wait until a token is available."""
async with self._lock:
now = time.monotonic()
self._tokens = min(self._burst, self._tokens + (now - self._last) * self._rate)
self._last = now
if self._tokens < 1:
wait = (1 - self._tokens) / self._rate
await asyncio.sleep(wait)
self._tokens = 0
else:
self._tokens -= 1
class Bot:
"""IRC bot: ties connection, config, and plugins together."""
@@ -33,6 +61,14 @@ class Bot:
self._running = False
self._started: float = time.monotonic()
self._tasks: set[asyncio.Task] = set()
self._admins: list[str] = config.get("bot", {}).get("admins", [])
self._opers: set[str] = set() # hostmasks of known IRC operators
# Rate limiter: default 2 msg/sec, burst of 5
rate_cfg = config.get("bot", {})
self._bucket = _TokenBucket(
rate=rate_cfg.get("rate_limit", 2.0),
burst=rate_cfg.get("rate_burst", 5),
)
async def start(self) -> None:
"""Connect, register, join channels, and enter the main loop."""
@@ -56,13 +92,71 @@ class Bot:
await self.conn.close()
async def _register(self) -> None:
"""Send NICK/USER registration to the server."""
"""Send NICK/USER registration, with optional SASL PLAIN."""
srv = self.config["server"]
sasl_user = srv.get("sasl_user", "")
sasl_pass = srv.get("sasl_pass", "")
if sasl_user and sasl_pass:
await self._sasl_auth(sasl_user, sasl_pass)
if srv.get("password"):
await self.conn.send(format_msg("PASS", srv["password"]))
await self.conn.send(format_msg("NICK", self.nick))
await self.conn.send(format_msg("USER", srv["user"], "0", "*", srv["realname"]))
async def _sasl_auth(self, user: str, password: str) -> None:
"""Perform SASL PLAIN authentication during registration."""
await self.conn.send("CAP REQ :sasl")
# Wait for CAP ACK or NAK
while True:
line = await self.conn.readline()
if line is None:
log.error("connection closed during SASL negotiation")
return
msg = parse(line)
if msg.command == "CAP" and len(msg.params) >= 3:
sub = msg.params[1].upper()
if sub == "ACK" and "sasl" in msg.params[-1].lower():
break
if sub == "NAK":
log.warning("server rejected SASL capability")
await self.conn.send("CAP END")
return
# Send AUTHENTICATE PLAIN
await self.conn.send("AUTHENTICATE PLAIN")
# Wait for AUTHENTICATE +
while True:
line = await self.conn.readline()
if line is None:
return
msg = parse(line)
if msg.command == "AUTHENTICATE" and msg.params and msg.params[0] == "+":
break
# Send credentials: base64(user\0user\0pass)
payload = f"{user}\x00{user}\x00{password}"
encoded = base64.b64encode(payload.encode("utf-8")).decode("ascii")
await self.conn.send(f"AUTHENTICATE {encoded}")
# Wait for 903 (success) or 904 (failure)
while True:
line = await self.conn.readline()
if line is None:
return
msg = parse(line)
if msg.command == "903":
log.info("SASL authentication successful")
break
if msg.command in ("904", "905", "906"):
log.error("SASL authentication failed: %s", msg.params[-1] if msg.params else "")
break
await self.conn.send("CAP END")
async def _loop(self) -> None:
"""Read and dispatch messages until disconnect."""
while self._running:
@@ -80,11 +174,22 @@ class Bot:
await self.conn.send(format_msg("PONG", msg.params[0] if msg.params else ""))
return
# RPL_WELCOME (001) — join channels
# RPL_WELCOME (001) — join channels and WHO for oper detection
if msg.command == "001":
self.nick = msg.params[0] if msg.params else self.nick
for channel in self.config["bot"]["channels"]:
await self.join(channel)
await self.conn.send(format_msg("WHO", channel))
# RPL_WHOREPLY (352) — detect IRC operators
if msg.command == "352" and len(msg.params) >= 7:
_chan, user, host, _server, nick, flags = msg.params[1:7]
if "*" in flags:
self._opers.add(f"{nick}!{user}@{host}")
# QUIT — remove departed nicks from oper set
if msg.command == "QUIT" and msg.prefix:
self._opers.discard(msg.prefix)
# Nick already in use (433) — append underscore
if msg.command == "433":
@@ -92,6 +197,11 @@ class Bot:
await self.conn.send(format_msg("NICK", self.nick))
return
# CTCP queries (PRIVMSG with \x01...\x01 wrapping)
if msg.command == "PRIVMSG" and msg.text and msg.text.startswith("\x01"):
self._spawn(self._handle_ctcp(msg), name="ctcp")
return
# Dispatch to event handlers (fire-and-forget)
event_type = msg.command
for handler in self.registry.events.get(event_type, []):
@@ -101,6 +211,44 @@ class Bot:
if msg.command == "PRIVMSG" and msg.text:
self._dispatch_command(msg)
async def _handle_ctcp(self, msg: Message) -> None:
"""Respond to CTCP VERSION, TIME, and PING queries."""
text = msg.text.strip("\x01")
parts = text.split(None, 1)
ctcp_cmd = parts[0].upper() if parts else ""
ctcp_arg = parts[1] if len(parts) > 1 else ""
if not msg.nick:
return
if ctcp_cmd == "VERSION":
reply = f"\x01VERSION derp {__version__}\x01"
elif ctcp_cmd == "TIME":
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
reply = f"\x01TIME {now}\x01"
elif ctcp_cmd == "PING":
reply = f"\x01PING {ctcp_arg}\x01"
else:
return
await self.conn.send(format_msg("NOTICE", msg.nick, reply))
log.debug("CTCP %s reply to %s", ctcp_cmd, msg.nick)
def _is_admin(self, msg: Message) -> bool:
"""Check if the message sender is a bot admin.
Returns True if the sender is a known IRC operator or matches
a configured hostmask pattern (fnmatch-style).
"""
if not msg.prefix:
return False
if msg.prefix in self._opers:
return True
for pattern in self._admins:
if fnmatch.fnmatch(msg.prefix, pattern):
return True
return False
def _dispatch_command(self, msg: Message) -> None:
"""Check if a PRIVMSG is a bot command and spawn it."""
text = msg.text
@@ -119,6 +267,11 @@ class Bot:
name=f"cmd:{cmd_name}:ambiguous")
return
if handler.admin and not self._is_admin(msg):
deny = f"Permission denied: {self.prefix}{cmd_name} requires admin"
self._spawn(self.reply(msg, deny), name=f"cmd:{cmd_name}:denied")
return
self._spawn(self._run_command(handler, cmd_name, msg), name=f"cmd:{cmd_name}")
async def _run_command(self, handler: Handler, cmd_name: str, msg: Message) -> None:
@@ -157,8 +310,9 @@ class Bot:
# -- Public API for plugins --
async def send(self, target: str, text: str) -> None:
"""Send a PRIVMSG to a target (channel or nick)."""
"""Send a PRIVMSG to a target (channel or nick), rate-limited."""
for line in text.split("\n"):
await self._bucket.acquire()
await self.conn.send(format_msg("PRIVMSG", target, line))
async def reply(self, msg: Message, text: str) -> None:

View File

@@ -14,11 +14,16 @@ DEFAULTS: dict = {
"user": "derp",
"realname": "derp IRC bot",
"password": "",
"sasl_user": "",
"sasl_pass": "",
},
"bot": {
"prefix": "!",
"channels": ["#test"],
"plugins_dir": "plugins",
"rate_limit": 2.0,
"rate_burst": 5,
"admins": [],
},
"logging": {
"level": "info",

View File

@@ -21,9 +21,10 @@ class Handler:
callback: Callable
help: str = ""
plugin: str = ""
admin: bool = False
def command(name: str, help: str = "") -> Callable:
def command(name: str, help: str = "", admin: bool = False) -> Callable:
"""Decorator to register an async function as a bot command.
Usage::
@@ -31,11 +32,16 @@ def command(name: str, help: str = "") -> Callable:
@command("ping", help="Check if the bot is alive")
async def cmd_ping(bot, message):
await bot.reply(message, "pong")
@command("reload", help="Reload a plugin", admin=True)
async def cmd_reload(bot, message):
...
"""
def decorator(func: Callable) -> Callable:
func._derp_command = name # type: ignore[attr-defined]
func._derp_help = help # type: ignore[attr-defined]
func._derp_admin = admin # type: ignore[attr-defined]
return func
return decorator
@@ -68,11 +74,13 @@ class PluginRegistry:
self._paths: dict[str, Path] = {}
def register_command(self, name: str, callback: Callable, help: str = "",
plugin: str = "") -> None:
plugin: str = "", admin: bool = False) -> None:
"""Register a command handler."""
if name in self.commands:
log.warning("command '%s' already registered, overwriting", name)
self.commands[name] = Handler(name=name, callback=callback, help=help, plugin=plugin)
self.commands[name] = Handler(
name=name, callback=callback, help=help, plugin=plugin, admin=admin,
)
log.debug("registered command: %s (%s)", name, plugin)
def register_event(self, event_type: str, callback: Callable, plugin: str = "") -> None:
@@ -93,6 +101,7 @@ class PluginRegistry:
obj._derp_command, obj,
help=getattr(obj, "_derp_help", ""),
plugin=plugin_name,
admin=getattr(obj, "_derp_admin", False),
)
count += 1
if hasattr(obj, "_derp_event"):