feat: add 16 extended bouncer control commands

Network control (CONNECT, DISCONNECT, RECONNECT, NICK, RAW), visibility
(CHANNELS, CLIENTS, BACKLOG, VERSION), config management (REHASH,
ADDNETWORK, DELNETWORK, AUTOJOIN), and NickServ operations (IDENTIFY,
REGISTER, DROPCREDS). Total command count: 22.

Adds stats()/db_size() to Backlog, add_network()/remove_network() to
Router, and _connected_at timestamp to Client. 74 command tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 00:34:23 +01:00
parent 6478c514ad
commit 3d9aa33ec4
9 changed files with 1203 additions and 39 deletions

View File

@@ -14,6 +14,7 @@
- [x] P1: Multi-network namespace multiplexing (`/network` suffixes)
- [x] P1: Bouncer control commands (`/msg *bouncer STATUS/INFO/UPTIME/NETWORKS/CREDS/HELP`)
- [x] P1: Extended control commands (CONNECT/DISCONNECT/RECONNECT/NICK/RAW/CHANNELS/CLIENTS/BACKLOG/VERSION/REHASH/ADDNETWORK/DELNETWORK/AUTOJOIN/IDENTIFY/REGISTER/DROPCREDS)
## Next

View File

@@ -43,14 +43,48 @@ PASS <password> # authenticate (all networks)
## Bouncer Commands
### Inspection
```
/msg *bouncer HELP # list commands
/msg *bouncer STATUS # all network states
/msg *bouncer INFO libera # detailed network info
/msg *bouncer UPTIME # process uptime
/msg *bouncer NETWORKS # list networks
/msg *bouncer CREDS # all NickServ creds
/msg *bouncer CREDS libera # creds for one network
/msg *bouncer CREDS [network] # NickServ creds
/msg *bouncer CHANNELS [network] # joined channels + topics
/msg *bouncer CLIENTS # connected clients
/msg *bouncer BACKLOG [network] # message counts + DB size
/msg *bouncer VERSION # bouncer + Python version
```
### Network Control
```
/msg *bouncer CONNECT libera # start disconnected network
/msg *bouncer DISCONNECT libera # stop network
/msg *bouncer RECONNECT libera # restart with fresh identity
/msg *bouncer NICK libera newnick # change nick
/msg *bouncer RAW libera WHOIS user # send raw IRC command
```
### Config Management
```
/msg *bouncer REHASH # reload config file
/msg *bouncer ADDNETWORK name host=h port=N tls=yes nick=n channels=#a,#b
/msg *bouncer DELNETWORK name # remove network
/msg *bouncer AUTOJOIN net +#chan # add to autojoin
/msg *bouncer AUTOJOIN net -#chan # remove from autojoin
```
### NickServ
```
/msg *bouncer IDENTIFY libera # force IDENTIFY
/msg *bouncer REGISTER libera # trigger registration
/msg *bouncer DROPCREDS libera # delete all creds
/msg *bouncer DROPCREDS libera nick # delete one nick's creds
```
## Namespacing
@@ -136,7 +170,7 @@ src/bouncer/
proxy.py # SOCKS5 connector (local DNS, multi-IP)
network.py # server connection + state machine
client.py # client session handler
commands.py # bouncer control commands (/msg *bouncer)
commands.py # 22 bouncer control commands (/msg *bouncer)
router.py # message routing + backlog trigger
server.py # TCP listener
backlog.py # SQLite store/replay/prune

View File

@@ -201,15 +201,9 @@ password = "" # IRC server password (optional, for PASS command)
Send a PRIVMSG to `*bouncer` (or `bouncer`) from your IRC client to inspect
and control the bouncer. All commands are case-insensitive.
```
/msg *bouncer HELP
/msg *bouncer STATUS
/msg *bouncer INFO libera
/msg *bouncer UPTIME
/msg *bouncer NETWORKS
/msg *bouncer CREDS
/msg *bouncer CREDS libera
```
Responses arrive as NOTICE messages from `*bouncer`.
### Inspection
| Command | Description |
|---------|-------------|
@@ -219,8 +213,66 @@ and control the bouncer. All commands are case-insensitive.
| `UPTIME` | Bouncer uptime since process start |
| `NETWORKS` | List all configured networks with state |
| `CREDS [network]` | NickServ credential status (all or per-network) |
| `CHANNELS [network]` | List joined channels with topics (all or per-network) |
| `CLIENTS` | List connected bouncer clients |
| `BACKLOG [network]` | Message counts per network and database size |
| `VERSION` | Bouncer and Python version |
Responses arrive as NOTICE messages from `*bouncer`.
### Network Control
| Command | Description |
|---------|-------------|
| `CONNECT <network>` | Start a disconnected network |
| `DISCONNECT <network>` | Stop a network |
| `RECONNECT <network>` | Stop and restart with a fresh identity |
| `NICK <network> <nick>` | Change nick on a network |
| `RAW <network> <command>` | Send a raw IRC command to a network |
### Config Management
| Command | Description |
|---------|-------------|
| `REHASH` | Reload config file, add/remove/reconnect networks |
| `ADDNETWORK <name> key=val ...` | Create a network at runtime |
| `DELNETWORK <name>` | Stop and remove a network |
| `AUTOJOIN <network> +/-#channel` | Add or remove channel from autojoin list |
**ADDNETWORK keys:** `host` (required), `port`, `tls` (yes/no), `nick`,
`channels` (comma-separated), `password`.
### NickServ
| Command | Description |
|---------|-------------|
| `IDENTIFY <network>` | Force NickServ IDENTIFY with stored credentials |
| `REGISTER <network>` | Trigger NickServ registration attempt |
| `DROPCREDS <network> [nick]` | Delete stored NickServ credentials |
### Examples
```
/msg *bouncer HELP
/msg *bouncer STATUS
/msg *bouncer INFO libera
/msg *bouncer CHANNELS
/msg *bouncer CLIENTS
/msg *bouncer BACKLOG
/msg *bouncer VERSION
/msg *bouncer CONNECT libera
/msg *bouncer DISCONNECT libera
/msg *bouncer RECONNECT libera
/msg *bouncer NICK libera newnick
/msg *bouncer RAW libera WHOIS someuser
/msg *bouncer REHASH
/msg *bouncer ADDNETWORK oftc host=irc.oftc.net port=6697 tls=yes channels=#test
/msg *bouncer DELNETWORK oftc
/msg *bouncer AUTOJOIN libera +#newchannel
/msg *bouncer AUTOJOIN libera -#oldchannel
/msg *bouncer IDENTIFY libera
/msg *bouncer REGISTER libera
/msg *bouncer DROPCREDS libera
/msg *bouncer DROPCREDS libera oldnick
```
### Example Output
@@ -230,6 +282,19 @@ Responses arrive as NOTICE messages from `*bouncer`.
oftc ready ceraty cloaked.user
hackint connecting (attempt 3)
quakenet ready spetyo --
[CHANNELS]
libera #test Welcome to the test channel
libera #dev
oftc #debian Debian support
[CLIENTS]
myuser 127.0.0.1:54321 connected 2h 15m 3s
[BACKLOG]
libera 1,500 messages
oftc 842 messages
DB size: 2.1 MB
```
## Stopping

View File

@@ -45,6 +45,7 @@ async def _run(config_path: Path, verbose: bool) -> None:
await backlog.open()
commands.STARTUP_TIME = time.time()
commands.CONFIG_PATH = config_path
router = Router(cfg, backlog)
await router.start_networks()

View File

@@ -296,6 +296,22 @@ class Backlog:
)
await self._db.commit()
async def stats(self, network: str | None = None) -> list[tuple[str, int]]:
"""Message counts per network. Returns [(network, count), ...]."""
assert self._db is not None
if network:
sql = "SELECT network, COUNT(*) FROM messages WHERE network = ? GROUP BY network"
params = (network,)
else:
sql = "SELECT network, COUNT(*) FROM messages GROUP BY network ORDER BY network"
params = ()
cursor = await self._db.execute(sql, params)
return await cursor.fetchall()
async def db_size(self) -> int:
"""Return database file size in bytes."""
return self._path.stat().st_size
async def _max_id(self, network: str) -> int:
"""Get the maximum message ID for a network."""
assert self._db is not None

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from bouncer import commands
@@ -55,6 +56,7 @@ class Client:
self._got_nick: bool = False
self._got_user: bool = False
self._pass_raw: str = ""
self._connected_at: float = time.time()
self._addr = writer.get_extra_info("peername", ("?", 0))
@property
@@ -113,6 +115,7 @@ class Client:
# Intercept bouncer control commands
if msg.command == "PRIVMSG" and len(msg.params) >= 2:
target = msg.params[0].lower()
log.debug("PRIVMSG target=%r params=%r", msg.params[0], msg.params)
if target in ("*bouncer", "bouncer"):
await self._handle_bouncer_command(msg.params[1])
return
@@ -232,11 +235,13 @@ class Client:
"""Dispatch a bouncer control command and send responses."""
lines = await commands.dispatch(text, self._router, self)
for line in lines:
self._send_msg(IRCMessage(
msg = IRCMessage(
command="NOTICE",
params=[self._nick, line],
prefix="*bouncer!bouncer@bouncer",
))
)
log.debug("bouncer reply: %r", msg.format())
self._send_msg(msg)
def _send_msg(self, msg: IRCMessage) -> None:
"""Send an IRCMessage to this client."""

View File

@@ -2,7 +2,10 @@
from __future__ import annotations
import asyncio
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING
from bouncer.network import State
@@ -13,6 +16,7 @@ if TYPE_CHECKING:
# Set by __main__.py before entering the event loop
STARTUP_TIME: float = 0.0
CONFIG_PATH: Path | None = None
_COMMANDS: dict[str, str] = {
"HELP": "List available commands",
@@ -21,6 +25,22 @@ _COMMANDS: dict[str, str] = {
"UPTIME": "Bouncer uptime since start",
"NETWORKS": "List configured networks",
"CREDS": "NickServ credential status (CREDS [network])",
"CONNECT": "Start a disconnected network (CONNECT <network>)",
"DISCONNECT": "Stop a network (DISCONNECT <network>)",
"RECONNECT": "Restart a network (RECONNECT <network>)",
"NICK": "Change nick on a network (NICK <network> <nick>)",
"RAW": "Send raw IRC command (RAW <network> <command>)",
"CHANNELS": "List joined channels (CHANNELS [network])",
"CLIENTS": "List connected bouncer clients",
"BACKLOG": "Message counts and DB size (BACKLOG [network])",
"VERSION": "Bouncer and Python version",
"REHASH": "Reload config, add/remove networks",
"ADDNETWORK": "Create network at runtime (ADDNETWORK <name> key=val ...)",
"DELNETWORK": "Remove a network (DELNETWORK <name>)",
"AUTOJOIN": "Modify autojoin list (AUTOJOIN <network> +/-channel)",
"IDENTIFY": "Force NickServ IDENTIFY (IDENTIFY <network>)",
"REGISTER": "Trigger NickServ registration (REGISTER <network>)",
"DROPCREDS": "Delete stored NickServ creds (DROPCREDS <network> [nick])",
}
@@ -45,6 +65,38 @@ async def dispatch(text: str, router: Router, client: Client) -> list[str]:
return _cmd_networks(router)
if cmd == "CREDS":
return await _cmd_creds(router, arg or None)
if cmd == "CONNECT":
return await _cmd_connect(router, arg)
if cmd == "DISCONNECT":
return await _cmd_disconnect(router, arg)
if cmd == "RECONNECT":
return await _cmd_reconnect(router, arg)
if cmd == "NICK":
return await _cmd_nick(router, arg)
if cmd == "RAW":
return await _cmd_raw(router, arg)
if cmd == "CHANNELS":
return _cmd_channels(router, arg or None)
if cmd == "CLIENTS":
return _cmd_clients(router)
if cmd == "BACKLOG":
return await _cmd_backlog(router, arg or None)
if cmd == "VERSION":
return _cmd_version()
if cmd == "REHASH":
return await _cmd_rehash(router)
if cmd == "ADDNETWORK":
return await _cmd_addnetwork(router, arg)
if cmd == "DELNETWORK":
return await _cmd_delnetwork(router, arg)
if cmd == "AUTOJOIN":
return _cmd_autojoin(router, arg)
if cmd == "IDENTIFY":
return await _cmd_identify(router, arg)
if cmd == "REGISTER":
return await _cmd_register(router, arg)
if cmd == "DROPCREDS":
return await _cmd_dropcreds(router, arg)
return [f"Unknown command: {cmd}", "Use HELP for available commands."]
@@ -108,7 +160,8 @@ async def _cmd_info(router: Router, network_name: str) -> list[str]:
lines = [f"[INFO] {net.cfg.name}"]
lines.append(f" State {_state_label(net.state)}")
lines.append(f" Server {net.cfg.host}:{net.cfg.port} (tls={'yes' if net.cfg.tls else 'no'})")
tls_label = "yes" if net.cfg.tls else "no"
lines.append(f" Server {net.cfg.host}:{net.cfg.port} (tls={tls_label})")
lines.append(f" Nick {net.nick}")
lines.append(f" Host {net.visible_host or '--'}")
@@ -190,3 +243,415 @@ async def _cmd_creds(router: Router, network_name: str | None) -> list[str]:
lines.append(" + verified ~ pending")
return lines
# --- Network Control ---
def _resolve_network(router: Router, name: str) -> tuple[object | None, list[str] | None]:
"""Look up a network by name. Returns (network, None) or (None, error_lines)."""
if not name:
return None, ["Usage: provide a network name"]
net = router.get_network(name.lower())
if not net:
names = ", ".join(sorted(router.networks))
return None, [f"Unknown network: {name}", f"Available: {names}"]
return net, None
async def _cmd_connect(router: Router, arg: str) -> list[str]:
"""Start a disconnected network."""
net, err = _resolve_network(router, arg)
if err:
return err
if net.state != State.DISCONNECTED:
return [f"{net.cfg.name} is already {_state_label(net.state)}"]
asyncio.create_task(net.start())
return [f"[CONNECT] {net.cfg.name} starting"]
async def _cmd_disconnect(router: Router, arg: str) -> list[str]:
"""Stop a network."""
net, err = _resolve_network(router, arg)
if err:
return err
if net.state == State.DISCONNECTED:
return [f"{net.cfg.name} is already disconnected"]
await net.stop()
return [f"[DISCONNECT] {net.cfg.name} stopped"]
async def _cmd_reconnect(router: Router, arg: str) -> list[str]:
"""Stop and restart a network with a fresh identity."""
net, err = _resolve_network(router, arg)
if err:
return err
await net.stop()
asyncio.create_task(net.start())
return [f"[RECONNECT] {net.cfg.name} restarting"]
async def _cmd_nick(router: Router, arg: str) -> list[str]:
"""Change nick on a network."""
parts = arg.split(None, 1)
if len(parts) < 2:
return ["Usage: NICK <network> <nick>"]
net, err = _resolve_network(router, parts[0])
if err:
return err
new_nick = parts[1]
if not net.connected:
return [f"{net.cfg.name} is not connected"]
await net.send_raw("NICK", new_nick)
return [f"[NICK] {net.cfg.name} changing nick to {new_nick}"]
async def _cmd_raw(router: Router, arg: str) -> list[str]:
"""Send a raw IRC command to a network."""
parts = arg.split(None, 1)
if len(parts) < 2:
return ["Usage: RAW <network> <irc command>"]
net, err = _resolve_network(router, parts[0])
if err:
return err
if not net.connected:
return [f"{net.cfg.name} is not connected"]
from bouncer.irc import parse
try:
msg = parse(parts[1].encode())
except Exception as exc:
return [f"Parse error: {exc}"]
await net.send(msg)
return [f"[RAW] {net.cfg.name} sent: {parts[1]}"]
# --- Visibility ---
def _cmd_channels(router: Router, network_name: str | None) -> list[str]:
"""List joined channels, optionally filtered by network."""
lines = ["[CHANNELS]"]
targets: dict[str, object] = {}
if network_name:
net = router.get_network(network_name.lower())
if not net:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
targets[net.cfg.name] = net
else:
targets = dict(sorted(router.networks.items()))
for name, net in targets.items():
if not net.channels:
lines.append(f" {name}: (none)")
else:
for ch in sorted(net.channels):
topic = net.topics.get(ch, "")
suffix = f" {topic}" if topic else ""
lines.append(f" {name} {ch}{suffix}")
if len(lines) == 1:
lines.append(" (no channels)")
return lines
def _cmd_clients(router: Router) -> list[str]:
"""List connected bouncer clients."""
lines = ["[CLIENTS]"]
if not router.clients:
lines.append(" (none)")
return lines
for client in router.clients:
addr = f"{client._addr[0]}:{client._addr[1]}"
elapsed = int(time.time() - client._connected_at)
dur = _format_duration(elapsed)
lines.append(f" {client.nick} {addr} connected {dur}")
return lines
def _format_duration(seconds: int) -> str:
"""Format seconds into a compact duration string."""
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
parts = []
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
parts.append(f"{secs}s")
return " ".join(parts)
async def _cmd_backlog(router: Router, network_name: str | None) -> list[str]:
"""Show message counts per network and database size."""
if not router.backlog:
return ["[BACKLOG] backlog not available"]
net_filter = network_name.lower() if network_name else None
if net_filter and net_filter not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
rows = await router.backlog.stats(net_filter)
db_bytes = await router.backlog.db_size()
lines = ["[BACKLOG]"]
if rows:
name_w = max(len(r[0]) for r in rows)
for net, count in rows:
lines.append(f" {net:<{name_w}} {count:,} messages")
else:
lines.append(" (no messages)")
if db_bytes >= 1_048_576:
size_str = f"{db_bytes / 1_048_576:.1f} MB"
elif db_bytes >= 1024:
size_str = f"{db_bytes / 1024:.1f} KB"
else:
size_str = f"{db_bytes} B"
lines.append(f" DB size: {size_str}")
return lines
def _cmd_version() -> list[str]:
"""Show bouncer and Python version."""
from bouncer import __version__
return [f"[VERSION] bouncer {__version__} / Python {sys.version.split()[0]}"]
# --- Config Management ---
async def _cmd_rehash(router: Router) -> list[str]:
"""Reload config, add/remove networks (proxy/bind unchanged)."""
if not CONFIG_PATH:
return ["[REHASH] config path not set"]
from bouncer.config import load
try:
new_cfg = load(CONFIG_PATH)
except Exception as exc:
return [f"[REHASH] config error: {exc}"]
old_names = set(router.networks.keys())
new_names = set(new_cfg.networks.keys())
added = new_names - old_names
removed = old_names - new_names
kept = old_names & new_names
lines = ["[REHASH]"]
# Remove networks no longer in config
for name in sorted(removed):
await router.remove_network(name)
lines.append(f" removed: {name}")
# Add new networks
for name in sorted(added):
await router.add_network(new_cfg.networks[name])
lines.append(f" added: {name}")
# Check for changed networks (host/port/tls differ)
for name in sorted(kept):
old_net = router.networks[name]
new_net_cfg = new_cfg.networks[name]
if (old_net.cfg.host != new_net_cfg.host
or old_net.cfg.port != new_net_cfg.port
or old_net.cfg.tls != new_net_cfg.tls):
await router.remove_network(name)
await router.add_network(new_net_cfg)
lines.append(f" reconnected: {name}")
else:
# Update mutable config fields
old_net.cfg.channels = new_net_cfg.channels
old_net.cfg.nick = new_net_cfg.nick
old_net.cfg.password = new_net_cfg.password
lines.append(f" unchanged: {name}")
router.config = new_cfg
lines.append(f" {len(new_cfg.networks)} network(s) loaded")
return lines
async def _cmd_addnetwork(router: Router, arg: str) -> list[str]:
"""Create a network at runtime from key=value pairs."""
from bouncer.config import NetworkConfig
parts = arg.split()
if not parts:
return ["Usage: ADDNETWORK <name> host=<host> [port=N] [tls=yes|no]",
" [nick=N] [channels=#a,#b] [password=P]"]
name = parts[0].lower()
if "/" in name:
return ["Network name must not contain '/'"]
if name in router.networks:
return [f"Network {name} already exists"]
kvs: dict[str, str] = {}
for part in parts[1:]:
if "=" not in part:
return [f"Invalid key=value: {part}"]
k, v = part.split("=", 1)
kvs[k.lower()] = v
if "host" not in kvs:
return ["Required: host=<hostname>"]
tls = kvs.get("tls", "no").lower() in ("yes", "true", "1")
default_port = 6697 if tls else 6667
port = int(kvs.get("port", str(default_port)))
channels = kvs.get("channels", "").split(",") if kvs.get("channels") else []
cfg = NetworkConfig(
name=name,
host=kvs["host"],
port=port,
tls=tls,
nick=kvs.get("nick", ""),
channels=channels,
password=kvs.get("password"),
)
await router.add_network(cfg)
return [f"[ADDNETWORK] {name} created ({cfg.host}:{cfg.port}, tls={'yes' if tls else 'no'})"]
async def _cmd_delnetwork(router: Router, arg: str) -> list[str]:
"""Stop and remove a network."""
if not arg:
return ["Usage: DELNETWORK <name>"]
name = arg.strip().lower()
if name not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {name}", f"Available: {names}"]
await router.remove_network(name)
return [f"[DELNETWORK] {name} removed"]
def _cmd_autojoin(router: Router, arg: str) -> list[str]:
"""Add or remove a channel from a network's autojoin list."""
parts = arg.split(None, 1)
if len(parts) < 2:
return ["Usage: AUTOJOIN <network> +#channel | -#channel"]
net, err = _resolve_network(router, parts[0])
if err:
return err
spec = parts[1].strip()
if not spec or spec[0] not in ("+", "-"):
return ["Channel must start with + (add) or - (remove)"]
action = spec[0]
channel = spec[1:]
if not channel:
return ["Channel name required after +/-"]
lines = [f"[AUTOJOIN] {net.cfg.name}"]
if action == "+":
if channel not in net.cfg.channels:
net.cfg.channels.append(channel)
lines.append(f" added: {channel}")
# Join immediately if network is ready
if net.ready:
asyncio.create_task(net.send_raw("JOIN", channel))
lines.append(f" joining {channel}")
else:
try:
net.cfg.channels.remove(channel)
lines.append(f" removed: {channel}")
except ValueError:
lines.append(f" {channel} not in autojoin list")
lines.append(f" autojoin: {', '.join(net.cfg.channels) or '(empty)'}")
return lines
# --- NickServ ---
async def _cmd_identify(router: Router, arg: str) -> list[str]:
"""Force NickServ IDENTIFY with stored credentials."""
net, err = _resolve_network(router, arg)
if err:
return err
if not net.connected:
return [f"{net.cfg.name} is not connected"]
if not router.backlog:
return ["Backlog not available"]
creds = await router.backlog.get_nickserv_creds_by_network(net.cfg.name)
if not creds:
return [f"No stored credentials for {net.cfg.name}"]
stored_nick, stored_pass = creds
lines = [f"[IDENTIFY] {net.cfg.name}"]
# Switch nick if needed
if net.nick != stored_nick:
await net.send_raw("NICK", stored_nick)
lines.append(f" switching nick to {stored_nick}")
await net.send_raw("PRIVMSG", "NickServ", f"IDENTIFY {stored_pass}")
lines.append(f" sent IDENTIFY as {stored_nick}")
return lines
async def _cmd_register(router: Router, arg: str) -> list[str]:
"""Trigger NickServ registration on a network."""
net, err = _resolve_network(router, arg)
if err:
return err
if not net.ready:
return [f"{net.cfg.name} is not ready (state: {_state_label(net.state)})"]
asyncio.create_task(net._nickserv_register())
return [f"[REGISTER] {net.cfg.name} registration started"]
async def _cmd_dropcreds(router: Router, arg: str) -> list[str]:
"""Delete stored NickServ credentials."""
if not router.backlog:
return ["Backlog not available"]
parts = arg.split(None, 1)
if not parts:
return ["Usage: DROPCREDS <network> [nick]"]
net_name = parts[0].lower()
if net_name not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {net_name}", f"Available: {names}"]
lines = [f"[DROPCREDS] {net_name}"]
if len(parts) >= 2:
# Delete specific nick
nick = parts[1]
await router.backlog.delete_nickserv_creds(net_name, nick)
lines.append(f" deleted: {nick}")
else:
# Delete all creds for this network
rows = await router.backlog.list_nickserv_creds(net_name)
if not rows:
lines.append(" no credentials found")
else:
for _net, nick, *_ in rows:
await router.backlog.delete_nickserv_creds(net_name, nick)
lines.append(f" deleted: {nick}")
return lines

View File

@@ -7,8 +7,8 @@ import logging
from typing import TYPE_CHECKING
from bouncer.backlog import Backlog
from bouncer.config import Config
from bouncer.irc import IRCMessage, parse_prefix
from bouncer.config import Config, NetworkConfig
from bouncer.irc import IRCMessage
from bouncer.namespace import decode_target, encode_message
from bouncer.network import Network
@@ -95,7 +95,9 @@ class Router:
network = Network(
cfg=net_cfg,
proxy_cfg=self.config.proxy,
backlog=self.backlog,
on_message=self._on_network_message,
on_status=self._on_network_status,
)
self.networks[name] = network
asyncio.create_task(network.start())
@@ -106,16 +108,12 @@ class Router:
await network.stop()
async def attach_all(self, client: Client) -> None:
"""Attach a client to all networks and replay backlogs."""
"""Attach a client to all networks."""
async with self._lock:
self.clients.append(client)
log.info("client attached to all networks (%d clients)", len(self.clients))
if self.config.bouncer.backlog.replay_on_connect:
for name in self.networks:
await self._replay_backlog(client, name)
async def detach_all(self, client: Client) -> None:
"""Detach a client from all networks."""
async with self._lock:
@@ -127,9 +125,7 @@ class Router:
remaining = len(self.clients)
log.info("client detached (%d remaining)", remaining)
if remaining == 0:
for name in self.networks:
await self.backlog.record_disconnect(name)
# Future: record disconnect for backlog replay
async def route_client_message(self, msg: IRCMessage) -> None:
"""Decode namespace from a client message and forward to the right network.
@@ -216,22 +212,25 @@ class Router:
"""Handle a message from an IRC server (called synchronously from network)."""
asyncio.create_task(self._dispatch(network_name, msg))
def _on_network_status(self, network_name: str, text: str) -> None:
"""Forward network status to attached clients as a NOTICE."""
notice = IRCMessage(
command="NOTICE",
params=["*", f"[{network_name}] {text}"],
prefix="bouncer",
)
raw = notice.format()
for client in self.clients:
try:
client.write(raw)
except Exception:
pass
async def _dispatch(self, network_name: str, msg: IRCMessage) -> None:
"""Dispatch a network message to attached clients and backlog."""
if _suppress(msg):
return
# Store in backlog (raw, un-namespaced)
if msg.command in BACKLOG_COMMANDS and msg.params:
target = msg.params[0]
sender = parse_prefix(msg.prefix)[0] if msg.prefix else ""
content = msg.params[1] if len(msg.params) > 1 else ""
await self.backlog.store(network_name, target, sender, msg.command, content)
max_msgs = self.config.bouncer.backlog.max_messages
if max_msgs > 0:
await self.backlog.prune(network_name, keep=max_msgs)
# Namespace and forward to all clients (per-client: own nicks -> client nick)
own_nicks = self.get_own_nicks()
for client in self.clients:
@@ -274,6 +273,27 @@ class Router:
if entries:
await self.backlog.mark_seen(network_name, entries[-1].id)
async def add_network(self, cfg: NetworkConfig) -> Network:
"""Create and start a new network at runtime."""
network = Network(
cfg=cfg,
proxy_cfg=self.config.proxy,
backlog=self.backlog,
on_message=self._on_network_message,
on_status=self._on_network_status,
)
self.networks[cfg.name] = network
asyncio.create_task(network.start())
return network
async def remove_network(self, name: str) -> bool:
"""Stop and remove a network. Returns True if found."""
network = self.networks.pop(name, None)
if not network:
return False
await network.stop()
return True
def network_names(self) -> list[str]:
"""Return available network names."""
return list(self.networks.keys())

View File

@@ -3,7 +3,8 @@
from __future__ import annotations
import time
from unittest.mock import AsyncMock, MagicMock
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -12,18 +13,31 @@ from bouncer.network import State
def _make_network(name: str, state: State, nick: str = "testnick",
host: str | None = None, channels: set[str] | None = None) -> MagicMock:
host: str | None = None, channels: set[str] | None = None,
topics: dict[str, str] | None = None) -> MagicMock:
"""Create a mock Network."""
net = MagicMock()
net.cfg.name = name
net.cfg.host = f"irc.{name}.chat"
net.cfg.port = 6697
net.cfg.tls = True
net.cfg.channels = list(channels) if channels else []
net.cfg.nick = nick
net.cfg.password = None
net.state = state
net.nick = nick
net.visible_host = host
net.channels = channels or set()
net.topics = topics or {}
net.names = {}
net._reconnect_attempt = 0
net.connected = state not in (State.DISCONNECTED, State.CONNECTING)
net.ready = state == State.READY
net.start = AsyncMock()
net.stop = AsyncMock()
net.send = AsyncMock()
net.send_raw = AsyncMock()
net._nickserv_register = AsyncMock()
return net
@@ -34,6 +48,9 @@ def _make_router(*networks: MagicMock) -> MagicMock:
router.network_names.return_value = [n.cfg.name for n in networks]
router.get_network = lambda name: router.networks.get(name)
router.backlog = AsyncMock()
router.add_network = AsyncMock()
router.remove_network = AsyncMock(return_value=True)
router.config = MagicMock()
return router
@@ -41,6 +58,8 @@ def _make_client(nick: str = "testuser") -> MagicMock:
"""Create a mock Client."""
client = MagicMock()
client.nick = nick
client._connected_at = time.time() - 120
client._addr = ("127.0.0.1", 54321)
return client
@@ -217,6 +236,544 @@ class TestCreds:
assert "Unknown network" in lines[0]
class TestConnect:
@pytest.mark.asyncio
async def test_connect_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("CONNECT", router, client)
assert "Usage" in lines[0] or "provide" in lines[0]
@pytest.mark.asyncio
async def test_connect_unknown_network(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("CONNECT fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_connect_already_connected(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CONNECT libera", router, client)
assert "already" in lines[0]
@pytest.mark.asyncio
async def test_connect_disconnected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CONNECT libera", router, client)
assert "[CONNECT]" in lines[0]
assert "starting" in lines[0]
class TestDisconnect:
@pytest.mark.asyncio
async def test_disconnect_ready(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("DISCONNECT libera", router, client)
assert "[DISCONNECT]" in lines[0]
net.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_disconnect_already_disconnected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("DISCONNECT libera", router, client)
assert "already disconnected" in lines[0]
class TestReconnect:
@pytest.mark.asyncio
async def test_reconnect(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("RECONNECT libera", router, client)
assert "[RECONNECT]" in lines[0]
net.stop.assert_awaited_once()
class TestNick:
@pytest.mark.asyncio
async def test_nick_missing_args(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("NICK", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_nick_missing_nick(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("NICK libera", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_nick_change(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("NICK libera newnick", router, client)
assert "[NICK]" in lines[0]
net.send_raw.assert_awaited_once_with("NICK", "newnick")
@pytest.mark.asyncio
async def test_nick_not_connected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("NICK libera newnick", router, client)
assert "not connected" in lines[0]
class TestRaw:
@pytest.mark.asyncio
async def test_raw_missing_args(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("RAW", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_raw_send(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("RAW libera WHOIS testuser", router, client)
assert "[RAW]" in lines[0]
net.send.assert_awaited_once()
@pytest.mark.asyncio
async def test_raw_not_connected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("RAW libera WHOIS testuser", router, client)
assert "not connected" in lines[0]
class TestChannels:
@pytest.mark.asyncio
async def test_channels_all(self) -> None:
net = _make_network("libera", State.READY, channels={"#test", "#dev"})
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CHANNELS", router, client)
assert lines[0] == "[CHANNELS]"
assert any("#test" in line for line in lines)
assert any("#dev" in line for line in lines)
@pytest.mark.asyncio
async def test_channels_specific_network(self) -> None:
net = _make_network("libera", State.READY, channels={"#test"})
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CHANNELS libera", router, client)
assert lines[0] == "[CHANNELS]"
assert any("#test" in line for line in lines)
@pytest.mark.asyncio
async def test_channels_unknown_network(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("CHANNELS fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_channels_with_topics(self) -> None:
net = _make_network("libera", State.READY, channels={"#test"},
topics={"#test": "Welcome to test"})
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CHANNELS libera", router, client)
assert any("Welcome to test" in line for line in lines)
@pytest.mark.asyncio
async def test_channels_empty(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CHANNELS libera", router, client)
assert any("(none)" in line for line in lines)
class TestClients:
@pytest.mark.asyncio
async def test_clients_empty(self) -> None:
router = _make_router()
router.clients = []
client = _make_client()
lines = await commands.dispatch("CLIENTS", router, client)
assert lines[0] == "[CLIENTS]"
assert "(none)" in lines[1]
@pytest.mark.asyncio
async def test_clients_lists_connected(self) -> None:
router = _make_router()
c1 = _make_client("user1")
c2 = _make_client("user2")
router.clients = [c1, c2]
client = _make_client()
lines = await commands.dispatch("CLIENTS", router, client)
assert lines[0] == "[CLIENTS]"
assert any("user1" in line for line in lines)
assert any("user2" in line for line in lines)
assert any("connected" in line for line in lines)
class TestBacklog:
@pytest.mark.asyncio
async def test_backlog_no_backlog(self) -> None:
router = _make_router()
router.backlog = None
client = _make_client()
lines = await commands.dispatch("BACKLOG", router, client)
assert "not available" in lines[0]
@pytest.mark.asyncio
async def test_backlog_stats(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.stats.return_value = [("libera", 1500)]
router.backlog.db_size.return_value = 2_097_152 # 2 MB
client = _make_client()
lines = await commands.dispatch("BACKLOG", router, client)
assert lines[0] == "[BACKLOG]"
assert any("1,500" in line for line in lines)
assert any("2.0 MB" in line for line in lines)
@pytest.mark.asyncio
async def test_backlog_specific_network(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.stats.return_value = [("libera", 42)]
router.backlog.db_size.return_value = 4096
client = _make_client()
lines = await commands.dispatch("BACKLOG libera", router, client)
assert lines[0] == "[BACKLOG]"
assert any("42" in line for line in lines)
@pytest.mark.asyncio
async def test_backlog_unknown_network(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("BACKLOG fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_backlog_empty(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.stats.return_value = []
router.backlog.db_size.return_value = 1024
client = _make_client()
lines = await commands.dispatch("BACKLOG", router, client)
assert any("no messages" in line for line in lines)
class TestVersion:
@pytest.mark.asyncio
async def test_version(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("VERSION", router, client)
assert "[VERSION]" in lines[0]
assert "bouncer" in lines[0]
assert "Python" in lines[0]
class TestRehash:
@pytest.mark.asyncio
async def test_rehash_no_config_path(self) -> None:
commands.CONFIG_PATH = None
router = _make_router()
client = _make_client()
lines = await commands.dispatch("REHASH", router, client)
assert "config path not set" in lines[0]
@pytest.mark.asyncio
async def test_rehash_config_error(self) -> None:
commands.CONFIG_PATH = Path("/nonexistent/config.toml")
router = _make_router()
client = _make_client()
lines = await commands.dispatch("REHASH", router, client)
assert "config error" in lines[0]
@pytest.mark.asyncio
async def test_rehash_adds_and_removes(self) -> None:
from bouncer.config import BouncerConfig, Config, NetworkConfig, ProxyConfig
old_net = _make_network("libera", State.READY)
router = _make_router(old_net)
new_cfg = Config(
bouncer=BouncerConfig(),
proxy=ProxyConfig(),
networks={
"oftc": NetworkConfig(name="oftc", host="irc.oftc.net", port=6697, tls=True),
},
)
commands.CONFIG_PATH = Path("/tmp/test.toml")
with patch("bouncer.config.load", return_value=new_cfg):
client = _make_client()
lines = await commands.dispatch("REHASH", router, client)
assert lines[0] == "[REHASH]"
assert any("removed: libera" in line for line in lines)
assert any("added: oftc" in line for line in lines)
router.remove_network.assert_awaited()
router.add_network.assert_awaited()
class TestAddNetwork:
@pytest.mark.asyncio
async def test_addnetwork_missing_args(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("ADDNETWORK", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_addnetwork_missing_host(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("ADDNETWORK testnet port=6667", router, client)
assert "Required" in lines[0]
@pytest.mark.asyncio
async def test_addnetwork_already_exists(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("ADDNETWORK libera host=irc.libera.chat", router, client)
assert "already exists" in lines[0]
@pytest.mark.asyncio
async def test_addnetwork_success(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch(
"ADDNETWORK testnet host=irc.test.com port=6697 tls=yes nick=mynick channels=#a,#b",
router, client,
)
assert "[ADDNETWORK]" in lines[0]
assert "testnet" in lines[0]
router.add_network.assert_awaited_once()
cfg = router.add_network.call_args[0][0]
assert cfg.name == "testnet"
assert cfg.host == "irc.test.com"
assert cfg.port == 6697
assert cfg.tls is True
assert cfg.nick == "mynick"
assert cfg.channels == ["#a", "#b"]
@pytest.mark.asyncio
async def test_addnetwork_slash_in_name(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("ADDNETWORK test/net host=h", router, client)
assert "must not contain" in lines[0]
class TestDelNetwork:
@pytest.mark.asyncio
async def test_delnetwork_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("DELNETWORK", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_delnetwork_unknown(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("DELNETWORK fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_delnetwork_success(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("DELNETWORK libera", router, client)
assert "[DELNETWORK]" in lines[0]
router.remove_network.assert_awaited_once_with("libera")
class TestAutojoin:
@pytest.mark.asyncio
async def test_autojoin_missing_args(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("AUTOJOIN", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_autojoin_add(self) -> None:
net = _make_network("libera", State.READY)
net.cfg.channels = ["#test"]
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("AUTOJOIN libera +#dev", router, client)
assert "[AUTOJOIN]" in lines[0]
assert any("added: #dev" in line for line in lines)
assert "#dev" in net.cfg.channels
@pytest.mark.asyncio
async def test_autojoin_remove(self) -> None:
net = _make_network("libera", State.READY, channels={"#test"})
net.cfg.channels = ["#test"]
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("AUTOJOIN libera -#test", router, client)
assert any("removed: #test" in line for line in lines)
assert "#test" not in net.cfg.channels
@pytest.mark.asyncio
async def test_autojoin_remove_missing(self) -> None:
net = _make_network("libera", State.READY)
net.cfg.channels = []
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("AUTOJOIN libera -#missing", router, client)
assert any("not in autojoin" in line for line in lines)
@pytest.mark.asyncio
async def test_autojoin_invalid_spec(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("AUTOJOIN libera #test", router, client)
assert "must start with" in lines[0]
class TestIdentify:
@pytest.mark.asyncio
async def test_identify_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("IDENTIFY", router, client)
assert "Usage" in lines[0] or "provide" in lines[0]
@pytest.mark.asyncio
async def test_identify_not_connected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("IDENTIFY libera", router, client)
assert "not connected" in lines[0]
@pytest.mark.asyncio
async def test_identify_no_creds(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.get_nickserv_creds_by_network.return_value = None
client = _make_client()
lines = await commands.dispatch("IDENTIFY libera", router, client)
assert "No stored credentials" in lines[0]
@pytest.mark.asyncio
async def test_identify_success(self) -> None:
net = _make_network("libera", State.READY, nick="fabesune")
router = _make_router(net)
router.backlog.get_nickserv_creds_by_network.return_value = ("fabesune", "secret123")
client = _make_client()
lines = await commands.dispatch("IDENTIFY libera", router, client)
assert "[IDENTIFY]" in lines[0]
net.send_raw.assert_awaited_with("PRIVMSG", "NickServ", "IDENTIFY secret123")
@pytest.mark.asyncio
async def test_identify_nick_switch(self) -> None:
net = _make_network("libera", State.READY, nick="randomnick")
router = _make_router(net)
router.backlog.get_nickserv_creds_by_network.return_value = ("fabesune", "secret123")
client = _make_client()
lines = await commands.dispatch("IDENTIFY libera", router, client)
assert any("switching nick" in line for line in lines)
calls = net.send_raw.await_args_list
assert any(c.args == ("NICK", "fabesune") for c in calls)
class TestRegister:
@pytest.mark.asyncio
async def test_register_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("REGISTER", router, client)
assert "Usage" in lines[0] or "provide" in lines[0]
@pytest.mark.asyncio
async def test_register_not_ready(self) -> None:
net = _make_network("libera", State.CONNECTING)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("REGISTER libera", router, client)
assert "not ready" in lines[0]
@pytest.mark.asyncio
async def test_register_success(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("REGISTER libera", router, client)
assert "[REGISTER]" in lines[0]
class TestDropCreds:
@pytest.mark.asyncio
async def test_dropcreds_no_backlog(self) -> None:
router = _make_router()
router.backlog = None
client = _make_client()
lines = await commands.dispatch("DROPCREDS libera", router, client)
assert "not available" in lines[0]
@pytest.mark.asyncio
async def test_dropcreds_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("DROPCREDS", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_dropcreds_specific_nick(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("DROPCREDS libera fabesune", router, client)
assert "[DROPCREDS]" in lines[0]
assert any("deleted: fabesune" in line for line in lines)
router.backlog.delete_nickserv_creds.assert_awaited_once_with("libera", "fabesune")
@pytest.mark.asyncio
async def test_dropcreds_all(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.list_nickserv_creds.return_value = [
("libera", "nick1", "a@b.c", "", 0.0, "verified"),
("libera", "nick2", "d@e.f", "", 0.0, "pending"),
]
client = _make_client()
lines = await commands.dispatch("DROPCREDS libera", router, client)
assert any("deleted: nick1" in line for line in lines)
assert any("deleted: nick2" in line for line in lines)
@pytest.mark.asyncio
async def test_dropcreds_unknown_network(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("DROPCREDS fakenet", router, client)
assert "Unknown network" in lines[0]
class TestUnknownCommand:
@pytest.mark.asyncio
async def test_unknown_command(self) -> None: