commit ced6232373240e4e908c1a76a4990428e8826ff9 Author: user Date: Thu Feb 19 11:29:59 2026 +0100 feat: initial IRC bouncer implementation Async Python IRC bouncer with SOCKS5 proxy support, multi-network connections, password auth, and persistent SQLite backlog with replay. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21a1216 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.venv/ +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ +*.db +*.sqlite +*.sqlite3 +.ruff_cache/ +.pytest_cache/ +.mypy_cache/ +*.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b09221 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +APP_NAME := bouncer +VENV := .venv +PIP := $(VENV)/bin/pip +PYTHON := $(VENV)/bin/python +BOUNCER := $(VENV)/bin/bouncer + +.PHONY: help venv install dev lint fmt test run clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " \033[38;5;110m%-12s\033[0m %s\n", $$1, $$2}' + +venv: ## Create virtual environment + python3 -m venv $(VENV) + $(PIP) install --upgrade pip + +install: venv ## Install package (editable) + $(PIP) install -e . + +dev: venv ## Install with dev dependencies + $(PIP) install -e ".[dev]" + +lint: ## Run linter + $(VENV)/bin/ruff check src/ tests/ + +fmt: ## Format code + $(VENV)/bin/black src/ tests/ + $(VENV)/bin/ruff check --fix src/ tests/ + +test: ## Run tests + $(VENV)/bin/pytest -v + +run: ## Run bouncer + $(BOUNCER) --config config/bouncer.toml + +clean: ## Remove build artifacts + rm -rf $(VENV) dist build *.egg-info src/*.egg-info diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..dc88188 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,46 @@ +# Project: bouncer + +## Purpose + +IRC bouncer that maintains persistent connections to IRC servers through a SOCKS5 proxy, allowing IRC clients to connect/disconnect while keeping the session alive and replaying missed messages. + +## Architecture + +``` +IRC Client(s) --> [bouncer:6667] --> Router --> [SOCKS5:1080] --> IRC Server(s) + | + Backlog + (SQLite) +``` + +### Components + +| Module | Responsibility | +|--------|---------------| +| `irc.py` | IRC protocol parser/formatter (RFC 2812 subset) | +| `config.py` | TOML configuration loading and validation | +| `proxy.py` | SOCKS5 async connection wrapper | +| `network.py` | Persistent IRC server connection per network | +| `server.py` | TCP listener accepting IRC client connections | +| `client.py` | Per-client session and IRC handshake | +| `router.py` | Message routing between clients and networks | +| `backlog.py` | SQLite message storage and replay | + +### Key Decisions + +- **asyncio**: Single-threaded async for all I/O +- **python-socks**: Async SOCKS5 proxy support +- **aiosqlite**: Non-blocking SQLite for backlog +- **No IRC library**: Manual protocol handling (IRC is simple line-based) + +## Dependencies + +| Package | Version | Purpose | +|---------|---------|---------| +| python-socks[asyncio] | >=2.4 | SOCKS5 proxy | +| aiosqlite | >=0.19 | Async SQLite | + +## Requirements + +- Python 3.10+ +- SOCKS5 proxy running on 127.0.0.1:1080 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbd8a31 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# bouncer + +IRC bouncer with SOCKS5 proxy support and persistent message backlog. + +## Features + +- Connect to multiple IRC networks simultaneously +- All outbound connections routed through SOCKS5 proxy +- Persistent message backlog (SQLite) with replay on reconnect +- Multiple clients can attach to the same network session +- Password authentication +- TLS support for IRC server connections +- Automatic reconnection with exponential backoff +- Nick collision handling + +## Quick Start + +```bash +# Clone and install +cd ~/git/bouncer +make dev + +# Copy and edit config +cp config/bouncer.example.toml config/bouncer.toml +$EDITOR config/bouncer.toml + +# Run +bouncer -c config/bouncer.toml -v +``` + +## Connect + +From your IRC client, connect to `127.0.0.1:6667` with: + +``` +PASS networkname:yourpassword +``` + +Where `networkname` matches a `[networks.NAME]` section in your config. + +## Configuration + +See [config/bouncer.example.toml](config/bouncer.example.toml) for a full example. + +## Documentation + +| Document | Description | +|----------|-------------| +| [docs/INSTALL.md](docs/INSTALL.md) | Prerequisites and setup | +| [docs/USAGE.md](docs/USAGE.md) | Comprehensive guide | +| [docs/CHEATSHEET.md](docs/CHEATSHEET.md) | Quick reference | +| [docs/DEBUG.md](docs/DEBUG.md) | Troubleshooting | + +## Development + +```bash +make dev # Install with dev deps +make test # Run tests +make lint # Run linter +make fmt # Format code +``` + +## License + +MIT diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a3b25d7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,38 @@ +# Roadmap + +## v0.1.0 (current) + +- [x] IRC protocol parser/formatter +- [x] TOML configuration +- [x] SOCKS5 proxy connector +- [x] Multi-network support +- [x] Client authentication (password) +- [x] Persistent backlog (SQLite) +- [x] Backlog replay on reconnect +- [x] Automatic reconnection with backoff +- [x] Nick collision handling +- [x] TLS support + +## v0.2.0 + +- [ ] Client-side TLS (accept TLS from clients) +- [ ] Per-network password support +- [ ] CTCP version/ping response +- [ ] Channel key support (JOIN #channel key) +- [ ] SASL authentication to IRC servers +- [ ] Configurable backlog format (timestamps) + +## v0.3.0 + +- [ ] Web status page +- [ ] Hot config reload (SIGHUP) +- [ ] Systemd service file +- [ ] Per-client backlog tracking (multi-user) +- [ ] DCC passthrough + +## v1.0.0 + +- [ ] Stable API +- [ ] Comprehensive test coverage +- [ ] Documentation complete +- [ ] Packaged for PyPI diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..02288c9 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,16 @@ +# Tasks + +## Current + +- [x] P0: Core implementation (irc, config, proxy, network, client, server, router, backlog) +- [x] P0: Unit tests (irc, config, backlog) +- [x] P0: CLI and entry point +- [x] P0: Documentation +- [ ] P1: Integration testing with live IRC server +- [ ] P1: Verify SOCKS5 proxy connectivity end-to-end + +## Next + +- [ ] P2: Client-side TLS support +- [ ] P2: SASL authentication +- [ ] P3: Systemd service file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..266fd94 --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ +# TODO + +## Features + +- [ ] Client TLS (accept encrypted client connections) +- [ ] SASL PLAIN/EXTERNAL for IRC server auth +- [ ] Channel key support +- [ ] CTCP VERSION/PING responses +- [ ] Hot config reload on SIGHUP +- [ ] Web status dashboard +- [ ] DCC passthrough + +## Infrastructure + +- [ ] Systemd unit file +- [ ] Containerfile for podman deployment +- [ ] PyPI packaging + +## Testing + +- [ ] Integration tests with mock IRC server +- [ ] SOCKS5 proxy failure tests +- [ ] Backlog replay edge cases +- [ ] Concurrent client attach/detach + +## Documentation + +- [ ] Architecture diagram +- [ ] Sequence diagrams for connection flow diff --git a/config/bouncer.example.toml b/config/bouncer.example.toml new file mode 100644 index 0000000..706b085 --- /dev/null +++ b/config/bouncer.example.toml @@ -0,0 +1,29 @@ +[bouncer] +bind = "127.0.0.1" +port = 6667 +password = "changeme" + +[bouncer.backlog] +max_messages = 10000 +replay_on_connect = true + +[proxy] +host = "127.0.0.1" +port = 1080 + +[networks.libera] +host = "irc.libera.chat" +port = 6697 +tls = true +nick = "mynick" +user = "mynick" +realname = "bouncer user" +channels = ["#test"] +autojoin = true + +# [networks.oftc] +# host = "irc.oftc.net" +# port = 6697 +# tls = true +# nick = "mynick" +# channels = ["#debian"] diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md new file mode 100644 index 0000000..115990c --- /dev/null +++ b/docs/CHEATSHEET.md @@ -0,0 +1,53 @@ +# Cheatsheet + +## Commands + +```bash +bouncer -c config/bouncer.toml # Start with config +bouncer -c config/bouncer.toml -v # Start with debug output +bouncer --version # Show version +bouncer --help # Show help +``` + +## Development + +```bash +make dev # Install with dev deps +make test # Run pytest +make lint # Run ruff +make fmt # Format with black + ruff +make run # Run with default config +make clean # Remove .venv and build artifacts +``` + +## Client Connection + +``` +PASS : # Authenticate + select network +PASS # Authenticate, use first network +``` + +## Config Structure + +```toml +[bouncer] # Listener settings + bind / port / password + [bouncer.backlog] # Backlog settings + max_messages / replay_on_connect + +[proxy] # SOCKS5 proxy + host / port + +[networks.] # IRC server (repeatable) + host / port / tls + nick / user / realname + channels / autojoin / password +``` + +## Files + +| Path | Purpose | +|------|---------| +| `config/bouncer.toml` | Active configuration | +| `config/bouncer.db` | SQLite backlog database | +| `config/bouncer.example.toml` | Example config template | diff --git a/docs/DEBUG.md b/docs/DEBUG.md new file mode 100644 index 0000000..d1a246f --- /dev/null +++ b/docs/DEBUG.md @@ -0,0 +1,75 @@ +# Debugging + +## Verbose Mode + +```bash +bouncer -c config/bouncer.toml -v +``` + +Debug logging shows: +- SOCKS5 proxy connection attempts +- IRC server registration +- Client connect/disconnect events +- Message routing +- Backlog replay counts + +## Common Issues + +### "config not found" + +Ensure the config path is correct: + +```bash +bouncer -c /full/path/to/bouncer.toml +``` + +### Connection refused (SOCKS5 proxy) + +Verify the proxy is running: + +```bash +ss -tlnp | grep 1080 +``` + +### Connection timeout to IRC server + +Check the SOCKS5 proxy can reach the IRC server: + +```bash +curl --socks5 127.0.0.1:1080 -v telnet://irc.libera.chat:6697 +``` + +### Nick already in use + +The bouncer appends `_` to the nick and retries. Check logs for: + +``` +WARNING bouncer.network [libera] nick in use, trying mynick_ +``` + +### TLS certificate errors + +If connecting to a server with a self-signed cert, this is currently not supported. All TLS connections use the system CA store. + +## Inspecting the Backlog Database + +```bash +sqlite3 config/bouncer.db + +-- Recent messages +SELECT * FROM messages ORDER BY id DESC LIMIT 20; + +-- Messages per network +SELECT network, COUNT(*) FROM messages GROUP BY network; + +-- Client state +SELECT * FROM client_state; +``` + +## Log Format + +``` +HH:MM:SS LEVEL module message +``` + +Levels: `DEBUG`, `INFO`, `WARNING`, `ERROR` diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..ef203c4 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,42 @@ +# Installation + +## Prerequisites + +- Python 3.10+ +- SOCKS5 proxy running on `127.0.0.1:1080` + +## Setup + +```bash +cd ~/git/bouncer +make dev +``` + +This creates `.venv/`, installs dependencies, and registers the `bouncer` command. + +## Verify + +```bash +bouncer --version +``` + +## Configuration + +```bash +cp config/bouncer.example.toml config/bouncer.toml +``` + +Edit `config/bouncer.toml` with your network details. At minimum, set: + +- `bouncer.password` -- client authentication password +- `networks..host` -- IRC server hostname +- `networks..nick` -- your IRC nickname +- `networks..channels` -- channels to auto-join + +## Symlink + +The `make dev` editable install registers `bouncer` in `.venv/bin/`. To make it available system-wide: + +```bash +ln -sf ~/git/bouncer/.venv/bin/bouncer ~/.local/bin/bouncer +``` diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..73e53eb --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,77 @@ +# Usage + +## Starting the Bouncer + +```bash +bouncer -c config/bouncer.toml -v +``` + +| Flag | Description | +|------|-------------| +| `-c, --config PATH` | Config file (default: `config/bouncer.toml`) | +| `-v, --verbose` | Debug logging | +| `--version` | Show version | + +## Connecting with an IRC Client + +Configure your IRC client to connect to the bouncer: + +| Setting | Value | +|---------|-------| +| Server | `127.0.0.1` | +| Port | `6667` (or as configured) | +| Password | `networkname:yourpassword` | + +### Password Format + +``` +PASS : +``` + +- `network` -- matches a `[networks.NAME]` section in config +- `password` -- the `bouncer.password` value from config + +If you omit the network prefix (`PASS yourpassword`), the first configured network is used. + +### Client Examples + +**irssi:** +``` +/connect -password libera:mypassword 127.0.0.1 6667 +``` + +**weechat:** +``` +/server add bouncer 127.0.0.1/6667 -password=libera:mypassword +/connect bouncer +``` + +**hexchat:** +Set server password to `libera:mypassword` in the network settings. + +## Multiple Networks + +Define multiple `[networks.*]` sections in the config. Connect with different passwords to access each: + +``` +PASS libera:mypassword # connects to libera +PASS oftc:mypassword # connects to oftc +``` + +Multiple clients can attach to the same network simultaneously. + +## Backlog + +Messages are stored in `bouncer.db` (SQLite) next to the config file. When you reconnect, missed messages are automatically replayed. + +Configure backlog in `bouncer.toml`: + +```toml +[bouncer.backlog] +max_messages = 10000 # per network, 0 = unlimited +replay_on_connect = true # set false to disable replay +``` + +## Stopping + +Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8075c3f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=68.0", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[project] +name = "bouncer" +version = "0.1.0" +description = "IRC bouncer with SOCKS5 proxy support and persistent backlog" +requires-python = ">=3.10" +dependencies = [ + "python-socks[asyncio]>=2.4", + "aiosqlite>=0.19", +] + +[project.optional-dependencies] +dev = [ + "ruff>=0.4", + "black>=24.0", + "pytest>=8.0", + "pytest-asyncio>=0.23", +] + +[project.scripts] +bouncer = "bouncer.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] + +[tool.black] +line-length = 100 +target-version = ["py310"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/src/bouncer/__init__.py b/src/bouncer/__init__.py new file mode 100644 index 0000000..f11d189 --- /dev/null +++ b/src/bouncer/__init__.py @@ -0,0 +1,3 @@ +"""IRC bouncer with SOCKS5 proxy support and persistent backlog.""" + +__version__ = "0.1.0" diff --git a/src/bouncer/__main__.py b/src/bouncer/__main__.py new file mode 100644 index 0000000..487ca85 --- /dev/null +++ b/src/bouncer/__main__.py @@ -0,0 +1,80 @@ +"""Entry point for bouncer.""" + +from __future__ import annotations + +import asyncio +import logging +import signal +import sys +from pathlib import Path + +from bouncer.backlog import Backlog +from bouncer.cli import parse_args +from bouncer.config import load +from bouncer.router import Router +from bouncer.server import start + +log = logging.getLogger("bouncer") + + +def _setup_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + fmt = "\033[2m%(asctime)s\033[0m %(levelname)-5s \033[38;5;110m%(name)s\033[0m %(message)s" + datefmt = "%H:%M:%S" + logging.basicConfig(level=level, format=fmt, datefmt=datefmt) + + +async def _run(config_path: Path, verbose: bool) -> None: + _setup_logging(verbose) + + cfg = load(config_path) + log.info("loaded config: %d network(s)", len(cfg.networks)) + + # Data directory alongside config + data_dir = config_path.parent + db_path = data_dir / "bouncer.db" + + backlog = Backlog(db_path) + await backlog.open() + + router = Router(cfg, backlog) + await router.start_networks() + + server = await start(cfg.bouncer, router) + + # Graceful shutdown on SIGINT/SIGTERM + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + def _signal_handler() -> None: + log.info("shutting down...") + stop_event.set() + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, _signal_handler) + + await stop_event.wait() + + server.close() + await server.wait_closed() + await router.stop_networks() + await backlog.close() + log.info("shutdown complete") + + +def main() -> None: + """CLI entry point.""" + args = parse_args() + + if not args.config.exists(): + print(f"error: config not found: {args.config}", file=sys.stderr) + sys.exit(1) + + try: + asyncio.run(_run(args.config, args.verbose)) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/src/bouncer/backlog.py b/src/bouncer/backlog.py new file mode 100644 index 0000000..560e3fc --- /dev/null +++ b/src/bouncer/backlog.py @@ -0,0 +1,152 @@ +"""SQLite-backed persistent message backlog.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from pathlib import Path + +import aiosqlite + +log = logging.getLogger(__name__) + +SCHEMA = """\ +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + network TEXT NOT NULL, + target TEXT NOT NULL, + sender TEXT NOT NULL, + command TEXT NOT NULL, + content TEXT NOT NULL, + timestamp REAL NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_net_ts ON messages(network, timestamp); + +CREATE TABLE IF NOT EXISTS client_state ( + network TEXT PRIMARY KEY, + last_seen_id INTEGER NOT NULL DEFAULT 0, + last_disconnect REAL +); +""" + + +@dataclass(slots=True) +class BacklogEntry: + """A stored message.""" + + id: int + network: str + target: str + sender: str + command: str + content: str + timestamp: float + + +class Backlog: + """Async SQLite backlog storage.""" + + def __init__(self, db_path: Path) -> None: + self._path = db_path + self._db: aiosqlite.Connection | None = None + + async def open(self) -> None: + """Open database and ensure schema exists.""" + self._db = await aiosqlite.connect(self._path) + await self._db.executescript(SCHEMA) + await self._db.commit() + log.debug("backlog database opened: %s", self._path) + + async def close(self) -> None: + """Close the database connection.""" + if self._db: + await self._db.close() + self._db = None + + async def store( + self, + network: str, + target: str, + sender: str, + command: str, + content: str, + ) -> int: + """Store a message. Returns the message ID.""" + assert self._db is not None + cursor = await self._db.execute( + "INSERT INTO messages (network, target, sender, command, content, timestamp) " + "VALUES (?, ?, ?, ?, ?, ?)", + (network, target, sender, command, content, time.time()), + ) + await self._db.commit() + return cursor.lastrowid # type: ignore[return-value] + + async def replay(self, network: str, since_id: int = 0) -> list[BacklogEntry]: + """Fetch messages for a network since a given message ID.""" + assert self._db is not None + cursor = await self._db.execute( + "SELECT id, network, target, sender, command, content, timestamp " + "FROM messages WHERE network = ? AND id > ? ORDER BY id", + (network, since_id), + ) + rows = await cursor.fetchall() + return [BacklogEntry(*row) for row in rows] + + async def get_last_seen(self, network: str) -> int: + """Get the last seen message ID for a network.""" + assert self._db is not None + cursor = await self._db.execute( + "SELECT last_seen_id FROM client_state WHERE network = ?", + (network,), + ) + row = await cursor.fetchone() + return row[0] if row else 0 + + async def mark_seen(self, network: str, message_id: int) -> None: + """Update the last seen message ID for a network.""" + assert self._db is not None + await self._db.execute( + "INSERT INTO client_state (network, last_seen_id, last_disconnect) " + "VALUES (?, ?, ?) " + "ON CONFLICT(network) DO UPDATE SET last_seen_id = excluded.last_seen_id", + (network, message_id, time.time()), + ) + await self._db.commit() + + async def record_disconnect(self, network: str) -> None: + """Record when the last client disconnected from a network.""" + assert self._db is not None + last_id = await self._max_id(network) + await self._db.execute( + "INSERT INTO client_state (network, last_seen_id, last_disconnect) " + "VALUES (?, ?, ?) " + "ON CONFLICT(network) DO UPDATE SET " + "last_seen_id = excluded.last_seen_id, last_disconnect = excluded.last_disconnect", + (network, last_id, time.time()), + ) + await self._db.commit() + + async def prune(self, network: str, keep: int) -> int: + """Delete old messages, keeping the most recent `keep` entries. Returns count deleted.""" + if keep <= 0: + return 0 + assert self._db is not None + cursor = await self._db.execute( + "DELETE FROM messages WHERE network = ? AND id NOT IN " + "(SELECT id FROM messages WHERE network = ? ORDER BY id DESC LIMIT ?)", + (network, network, keep), + ) + await self._db.commit() + return cursor.rowcount # type: ignore[return-value] + + async def _max_id(self, network: str) -> int: + """Get the maximum message ID for a network.""" + assert self._db is not None + cursor = await self._db.execute( + "SELECT COALESCE(MAX(id), 0) FROM messages WHERE network = ?", + (network,), + ) + row = await cursor.fetchone() + return row[0] if row else 0 diff --git a/src/bouncer/cli.py b/src/bouncer/cli.py new file mode 100644 index 0000000..9f4ef57 --- /dev/null +++ b/src/bouncer/cli.py @@ -0,0 +1,33 @@ +"""Command-line interface.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from bouncer import __version__ + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + prog="bouncer", + description="IRC bouncer with SOCKS5 proxy support", + ) + parser.add_argument( + "-c", "--config", + type=Path, + default=Path("config/bouncer.toml"), + help="path to configuration file (default: config/bouncer.toml)", + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="enable debug logging", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) + return parser.parse_args(argv) diff --git a/src/bouncer/client.py b/src/bouncer/client.py new file mode 100644 index 0000000..ed95548 --- /dev/null +++ b/src/bouncer/client.py @@ -0,0 +1,250 @@ +"""IRC client session handler.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from bouncer.irc import IRCMessage, parse + +if TYPE_CHECKING: + from bouncer.network import Network + from bouncer.router import Router + +log = logging.getLogger(__name__) + +# Numeric replies for synthetic welcome +RPL_WELCOME = "001" +RPL_YOURHOST = "002" +RPL_CREATED = "003" +RPL_MYINFO = "004" +RPL_TOPIC = "332" +RPL_NAMREPLY = "353" +RPL_ENDOFNAMES = "366" + +# Commands to forward from client to network +FORWARD_COMMANDS = { + "PRIVMSG", "NOTICE", "JOIN", "PART", "QUIT", "KICK", "MODE", + "TOPIC", "INVITE", "WHO", "WHOIS", "WHOWAS", "LIST", "NAMES", + "AWAY", "USERHOST", "ISON", "PING", "PONG", +} + + +class Client: + """Handles a single IRC client connection to the bouncer.""" + + def __init__( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + router: Router, + password: str, + ) -> None: + self._reader = reader + self._writer = writer + self._router = router + self._password = password + self._network_name: str | None = None + self._network: Network | None = None + self._nick: str = "*" + self._user: str = "unknown" + self._realname: str = "" + self._authenticated: bool = False + self._registered: bool = False + self._got_pass: bool = False + self._got_nick: bool = False + self._got_user: bool = False + self._pass_raw: str = "" + self._addr = writer.get_extra_info("peername", ("?", 0)) + + async def handle(self) -> None: + """Main client session loop.""" + log.info("client connected from %s", self._addr) + buf = b"" + try: + while True: + data = await self._reader.read(4096) + if not data: + break + + buf += data + while b"\r\n" in buf: + line, buf = buf.split(b"\r\n", 1) + if not line: + continue + try: + msg = parse(line) + await self._handle_message(msg) + except Exception: + log.exception("error handling client message: %r", line) + + except asyncio.CancelledError: + pass + except ConnectionResetError: + pass + except Exception: + log.exception("client error from %s", self._addr) + finally: + await self._cleanup() + + def write(self, data: bytes) -> None: + """Write raw bytes to the client.""" + if not self._writer.is_closing(): + self._writer.write(data) + + async def _handle_message(self, msg: IRCMessage) -> None: + """Process an IRC message from the client.""" + if not self._registered: + await self._handle_registration(msg) + return + + if msg.command == "PING": + self._send_msg(IRCMessage(command="PONG", params=msg.params, prefix="bouncer")) + return + + if msg.command == "QUIT": + return + + if msg.command in FORWARD_COMMANDS and self._network_name: + await self._router.client_to_network(self._network_name, msg) + + async def _handle_registration(self, msg: IRCMessage) -> None: + """Handle PASS/NICK/USER registration sequence.""" + if msg.command == "CAP": + # Minimal CAP handling - just reject capabilities + if msg.params and msg.params[0] == "LS": + self._send_msg(IRCMessage(command="CAP", params=["*", "LS", ""])) + elif msg.params and msg.params[0] == "END": + pass + return + + if msg.command == "PASS" and msg.params: + self._pass_raw = msg.params[0] + self._got_pass = True + + elif msg.command == "NICK" and msg.params: + self._nick = msg.params[0] + self._got_nick = True + + elif msg.command == "USER" and len(msg.params) >= 4: + self._user = msg.params[0] + self._realname = msg.params[3] + self._got_user = True + + # Check if we have all three + if self._got_nick and self._got_user: + await self._complete_registration() + + async def _complete_registration(self) -> None: + """Validate credentials and attach to network.""" + # Parse PASS: "network:password" or just "password" (use first network) + network_name: str | None = None + password: str = "" + + if self._got_pass and ":" in self._pass_raw: + network_name, password = self._pass_raw.split(":", 1) + elif self._got_pass: + password = self._pass_raw + else: + self._send_error("Password required (PASS network:password)") + self._writer.close() + return + + if password != self._password: + self._send_error("Invalid password") + self._writer.close() + return + + # Resolve network + if not network_name: + # Default to first configured network + names = self._router.network_names() + if names: + network_name = names[0] + + if not network_name: + self._send_error("No network specified and none configured") + self._writer.close() + return + + self._authenticated = True + self._registered = True + self._network_name = network_name + + # Attach to network + self._network = await self._router.attach(self, network_name) + if not self._network: + self._send_error(f"Unknown network: {network_name}") + self._writer.close() + return + + log.info( + "client %s authenticated for network %s (nick=%s)", + self._addr, network_name, self._nick, + ) + + # Send synthetic welcome + await self._send_welcome() + + async def _send_welcome(self) -> None: + """Send IRC welcome sequence and channel state to client.""" + assert self._network is not None + server_name = "bouncer" + nick = self._network.nick + + self._send_msg(IRCMessage( + command=RPL_WELCOME, prefix=server_name, + params=[nick, f"Welcome to bouncer ({self._network_name})"], + )) + self._send_msg(IRCMessage( + command=RPL_YOURHOST, prefix=server_name, + params=[nick, f"Your host is {server_name}"], + )) + self._send_msg(IRCMessage( + command=RPL_CREATED, prefix=server_name, + params=[nick, "This server was created by bouncer"], + )) + self._send_msg(IRCMessage( + command=RPL_MYINFO, prefix=server_name, + params=[nick, server_name, "bouncer-0.1", "o", "o"], + )) + + # Send channel state for joined channels + for channel in self._network.channels: + # Topic + topic = self._network.topics.get(channel, "") + if topic: + self._send_msg(IRCMessage( + command=RPL_TOPIC, prefix=server_name, + params=[nick, channel, topic], + )) + + # Names + names = self._network.names.get(channel, set()) + if names: + name_str = " ".join(sorted(names)) + self._send_msg(IRCMessage( + command=RPL_NAMREPLY, prefix=server_name, + params=[nick, "=", channel, name_str], + )) + self._send_msg(IRCMessage( + command=RPL_ENDOFNAMES, prefix=server_name, + params=[nick, channel, "End of /NAMES list"], + )) + + def _send_msg(self, msg: IRCMessage) -> None: + """Send an IRCMessage to this client.""" + self.write(msg.format()) + + def _send_error(self, text: str) -> None: + """Send an ERROR message to the client.""" + self._send_msg(IRCMessage(command="ERROR", params=[text])) + + async def _cleanup(self) -> None: + """Detach from network and close connection.""" + log.info("client disconnected from %s", self._addr) + if self._network_name: + await self._router.detach(self, self._network_name) + if not self._writer.is_closing(): + self._writer.close() diff --git a/src/bouncer/config.py b/src/bouncer/config.py new file mode 100644 index 0000000..3bd80f8 --- /dev/null +++ b/src/bouncer/config.py @@ -0,0 +1,108 @@ +"""Configuration loader and validation.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + + +@dataclass(slots=True) +class BacklogConfig: + """Backlog storage settings.""" + + max_messages: int = 10000 + replay_on_connect: bool = True + + +@dataclass(slots=True) +class ProxyConfig: + """SOCKS5 proxy settings.""" + + host: str = "127.0.0.1" + port: int = 1080 + + +@dataclass(slots=True) +class NetworkConfig: + """IRC network connection settings.""" + + name: str + host: str + port: int = 6667 + tls: bool = False + nick: str = "bouncer" + user: str = "bouncer" + realname: str = "bouncer" + channels: list[str] = field(default_factory=list) + autojoin: bool = True + password: str | None = None + + +@dataclass(slots=True) +class BouncerConfig: + """Main bouncer settings.""" + + bind: str = "127.0.0.1" + port: int = 6667 + password: str = "changeme" + backlog: BacklogConfig = field(default_factory=BacklogConfig) + + +@dataclass(slots=True) +class Config: + """Top-level configuration.""" + + bouncer: BouncerConfig + proxy: ProxyConfig + networks: dict[str, NetworkConfig] + + +def load(path: Path) -> Config: + """Load and validate configuration from a TOML file.""" + with open(path, "rb") as f: + raw = tomllib.load(f) + + bouncer_raw = raw.get("bouncer", {}) + backlog_raw = bouncer_raw.pop("backlog", {}) + + bouncer = BouncerConfig( + bind=bouncer_raw.get("bind", "127.0.0.1"), + port=bouncer_raw.get("port", 6667), + password=bouncer_raw.get("password", "changeme"), + backlog=BacklogConfig(**backlog_raw), + ) + + proxy_raw = raw.get("proxy", {}) + proxy = ProxyConfig( + host=proxy_raw.get("host", "127.0.0.1"), + port=proxy_raw.get("port", 1080), + ) + + networks: dict[str, NetworkConfig] = {} + for name, net_raw in raw.get("networks", {}).items(): + networks[name] = NetworkConfig( + name=name, + host=net_raw["host"], + port=net_raw.get("port", 6697 if net_raw.get("tls", False) else 6667), + tls=net_raw.get("tls", False), + nick=net_raw.get("nick", "bouncer"), + user=net_raw.get("user", net_raw.get("nick", "bouncer")), + realname=net_raw.get("realname", "bouncer"), + channels=net_raw.get("channels", []), + autojoin=net_raw.get("autojoin", True), + password=net_raw.get("password"), + ) + + if not networks: + raise ValueError("at least one network must be configured") + + return Config(bouncer=bouncer, proxy=proxy, networks=networks) diff --git a/src/bouncer/irc.py b/src/bouncer/irc.py new file mode 100644 index 0000000..a0f1c1d --- /dev/null +++ b/src/bouncer/irc.py @@ -0,0 +1,108 @@ +"""IRC message parser and formatter (RFC 2812 subset).""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class IRCMessage: + """Parsed IRC protocol message.""" + + command: str + params: list[str] = field(default_factory=list) + prefix: str | None = None + tags: dict[str, str | None] = field(default_factory=dict) + + @property + def trailing(self) -> str | None: + """Return the trailing parameter (last param), or None.""" + return self.params[-1] if self.params else None + + def format(self) -> bytes: + """Serialize to IRC wire format (with CRLF).""" + parts: list[str] = [] + if self.tags: + tag_str = ";".join( + f"{k}={v}" if v is not None else k for k, v in self.tags.items() + ) + parts.append(f"@{tag_str}") + if self.prefix: + parts.append(f":{self.prefix}") + parts.append(self.command) + if self.params: + for i, param in enumerate(self.params): + if i == len(self.params) - 1 and ( + " " in param or param.startswith(":") or not param + ): + parts.append(f":{param}") + else: + parts.append(param) + return (_encode(" ".join(parts)) + b"\r\n") + + +def parse(data: bytes) -> IRCMessage: + """Parse a single IRC line (without trailing CRLF) into an IRCMessage.""" + line = _decode(data.rstrip(b"\r\n")) + pos = 0 + + # Parse IRCv3 tags (@key=value;key2) + tags: dict[str, str | None] = {} + if line.startswith("@"): + space = line.index(" ", 1) + tag_str = line[1:space] + for tag in tag_str.split(";"): + if "=" in tag: + k, v = tag.split("=", 1) + tags[k] = v + else: + tags[tag] = None + pos = space + 1 + + # Parse prefix (:nick!user@host) + prefix: str | None = None + if pos < len(line) and line[pos] == ":": + space = line.index(" ", pos + 1) + prefix = line[pos + 1 : space] + pos = space + 1 + + # Parse command and params + rest = line[pos:] + if " :" in rest: + head, trailing = rest.split(" :", 1) + parts = head.split() + command = parts[0].upper() + params = parts[1:] + [trailing] + else: + parts = rest.split() + command = parts[0].upper() + params = parts[1:] + + return IRCMessage(command=command, params=params, prefix=prefix, tags=tags) + + +def parse_prefix(prefix: str) -> tuple[str, str | None, str | None]: + """Split prefix into (nick, user, host). User/host may be None.""" + if "!" in prefix: + nick, rest = prefix.split("!", 1) + if "@" in rest: + user, host = rest.split("@", 1) + return nick, user, host + return nick, rest, None + if "@" in prefix: + nick, host = prefix.split("@", 1) + return nick, None, host + return prefix, None, None + + +def _decode(data: bytes) -> str: + """Decode IRC bytes, UTF-8 with latin-1 fallback.""" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("latin-1") + + +def _encode(text: str) -> bytes: + """Encode IRC string to bytes.""" + return text.encode("utf-8") diff --git a/src/bouncer/network.py b/src/bouncer/network.py new file mode 100644 index 0000000..900360e --- /dev/null +++ b/src/bouncer/network.py @@ -0,0 +1,222 @@ +"""Persistent IRC server connection manager.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Callable + +from bouncer.config import NetworkConfig, ProxyConfig +from bouncer.irc import IRCMessage, parse + +log = logging.getLogger(__name__) + +BACKOFF_STEPS = [5, 10, 30, 60, 120, 300] + + +class Network: + """Manages a persistent connection to a single IRC server.""" + + def __init__( + self, + cfg: NetworkConfig, + proxy_cfg: ProxyConfig, + on_message: Callable[[str, IRCMessage], None] | None = None, + ) -> None: + self.cfg = cfg + self.proxy_cfg = proxy_cfg + self.on_message = on_message + self.nick: str = cfg.nick + self.channels: set[str] = set() + self.connected: bool = False + self.registered: bool = False + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self._reconnect_attempt: int = 0 + self._running: bool = False + self._read_task: asyncio.Task[None] | None = None + # Channel state: topic + names per channel + self.topics: dict[str, str] = {} + self.names: dict[str, set[str]] = {} + + async def start(self) -> None: + """Start the network connection loop.""" + self._running = True + await self._connect() + + async def stop(self) -> None: + """Disconnect and stop reconnection.""" + self._running = False + if self._read_task and not self._read_task.done(): + self._read_task.cancel() + await self._disconnect() + + async def send(self, msg: IRCMessage) -> None: + """Send an IRC message to the server.""" + if self._writer and not self._writer.is_closing(): + self._writer.write(msg.format()) + await self._writer.drain() + + async def send_raw(self, command: str, *params: str) -> None: + """Build and send an IRC message.""" + await self.send(IRCMessage(command=command, params=list(params))) + + async def _connect(self) -> None: + """Establish connection via SOCKS5 proxy and register.""" + from bouncer.proxy import connect + + try: + log.info( + "[%s] connecting to %s:%d (tls=%s)", + self.cfg.name, self.cfg.host, self.cfg.port, self.cfg.tls, + ) + self._reader, self._writer = await connect( + self.cfg.host, + self.cfg.port, + self.proxy_cfg, + tls=self.cfg.tls, + ) + self.connected = True + self._reconnect_attempt = 0 + log.info("[%s] connected", self.cfg.name) + + # IRC registration + if self.cfg.password: + await self.send_raw("PASS", self.cfg.password) + await self.send_raw("NICK", self.cfg.nick) + await self.send_raw( + "USER", self.cfg.user, "0", "*", self.cfg.realname, + ) + + # Start reading + self._read_task = asyncio.create_task(self._read_loop()) + + except Exception: + log.exception("[%s] connection failed", self.cfg.name) + self.connected = False + if self._running: + await self._schedule_reconnect() + + async def _disconnect(self) -> None: + """Close the connection.""" + self.connected = False + self.registered = False + if self._writer and not self._writer.is_closing(): + try: + self._writer.close() + await self._writer.wait_closed() + except Exception: + pass + self._reader = None + self._writer = None + + async def _schedule_reconnect(self) -> None: + """Wait with exponential backoff, then reconnect.""" + delay = BACKOFF_STEPS[min(self._reconnect_attempt, len(BACKOFF_STEPS) - 1)] + self._reconnect_attempt += 1 + log.info( + "[%s] reconnecting in %ds (attempt %d)", + self.cfg.name, delay, self._reconnect_attempt, + ) + await asyncio.sleep(delay) + if self._running: + await self._connect() + + async def _read_loop(self) -> None: + """Read and dispatch messages from the IRC server.""" + assert self._reader is not None + buf = b"" + try: + while self._running and self.connected: + data = await self._reader.read(4096) + if not data: + log.warning("[%s] server closed connection", self.cfg.name) + break + + buf += data + while b"\r\n" in buf: + line, buf = buf.split(b"\r\n", 1) + if not line: + continue + try: + msg = parse(line) + await self._handle(msg) + except Exception: + log.exception("[%s] failed to parse: %r", self.cfg.name, line) + + except asyncio.CancelledError: + return + except Exception: + log.exception("[%s] read loop error", self.cfg.name) + finally: + await self._disconnect() + if self._running: + await self._schedule_reconnect() + + async def _handle(self, msg: IRCMessage) -> None: + """Handle an IRC message from the server.""" + if msg.command == "PING": + await self.send_raw("PONG", *msg.params) + return + + if msg.command == "001": + # RPL_WELCOME - registration complete + self.registered = True + self.nick = msg.params[0] if msg.params else self.cfg.nick + log.info("[%s] registered as %s", self.cfg.name, self.nick) + if self.cfg.autojoin and self.cfg.channels: + for ch in self.cfg.channels: + await self.send_raw("JOIN", ch) + + elif msg.command == "JOIN" and msg.prefix: + nick = msg.prefix.split("!")[0] + channel = msg.params[0] if msg.params else "" + if nick == self.nick: + self.channels.add(channel) + self.names.setdefault(channel, set()) + log.info("[%s] joined %s", self.cfg.name, channel) + + elif msg.command == "PART" and msg.prefix: + nick = msg.prefix.split("!")[0] + channel = msg.params[0] if msg.params else "" + if nick == self.nick: + self.channels.discard(channel) + self.names.pop(channel, None) + self.topics.pop(channel, None) + + elif msg.command == "332": + # RPL_TOPIC + if len(msg.params) >= 3: + self.topics[msg.params[1]] = msg.params[2] + + elif msg.command == "353": + # RPL_NAMREPLY + if len(msg.params) >= 4: + channel = msg.params[2] + nicks = msg.params[3].split() + self.names.setdefault(channel, set()).update(nicks) + + elif msg.command == "366": + # RPL_ENDOFNAMES + pass + + elif msg.command == "433": + # ERR_NICKNAMEINUSE - append underscore + self.nick = self.nick + "_" + await self.send_raw("NICK", self.nick) + log.warning("[%s] nick in use, trying %s", self.cfg.name, self.nick) + + elif msg.command == "KICK" and msg.params: + channel = msg.params[0] + kicked = msg.params[1] if len(msg.params) > 1 else "" + if kicked == self.nick: + self.channels.discard(channel) + log.warning("[%s] kicked from %s", self.cfg.name, channel) + # Rejoin after a brief delay + await asyncio.sleep(3) + if channel in {c for c in self.cfg.channels} and self._running: + await self.send_raw("JOIN", channel) + + # Forward to router + if self.on_message: + self.on_message(self.cfg.name, msg) diff --git a/src/bouncer/proxy.py b/src/bouncer/proxy.py new file mode 100644 index 0000000..f21cb52 --- /dev/null +++ b/src/bouncer/proxy.py @@ -0,0 +1,46 @@ +"""SOCKS5 async connection wrapper.""" + +from __future__ import annotations + +import asyncio +import logging +import ssl + +from python_socks.async_.asyncio import Proxy + +from bouncer.config import ProxyConfig + +log = logging.getLogger(__name__) + + +async def connect( + host: str, + port: int, + proxy_cfg: ProxyConfig, + tls: bool = False, +) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Open a TCP connection through the SOCKS5 proxy. + + Returns an (asyncio.StreamReader, asyncio.StreamWriter) pair. + If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake. + """ + proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}") + + log.debug("connecting to %s:%d via socks5://%s:%d", host, port, proxy_cfg.host, proxy_cfg.port) + + sock = await proxy.connect(dest_host=host, dest_port=port) + + ssl_ctx: ssl.SSLContext | None = None + if tls: + ssl_ctx = ssl.create_default_context() + + reader, writer = await asyncio.open_connection( + host=None, + port=None, + sock=sock.socket, + ssl=ssl_ctx, + server_hostname=host if tls else None, + ) + + log.debug("connected to %s:%d (tls=%s)", host, port, tls) + return reader, writer diff --git a/src/bouncer/router.py b/src/bouncer/router.py new file mode 100644 index 0000000..ff99fb2 --- /dev/null +++ b/src/bouncer/router.py @@ -0,0 +1,148 @@ +"""Message router between IRC clients and network connections.""" + +from __future__ import annotations + +import asyncio +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.network import Network + +if TYPE_CHECKING: + from bouncer.client import Client + +log = logging.getLogger(__name__) + +# Commands worth storing in backlog +BACKLOG_COMMANDS = {"PRIVMSG", "NOTICE", "TOPIC", "KICK", "MODE"} + + +class Router: + """Central message hub linking clients to networks.""" + + def __init__(self, config: Config, backlog: Backlog) -> None: + self.config = config + self.backlog = backlog + self.networks: dict[str, Network] = {} + self.clients: dict[str, list[Client]] = {} # network_name -> clients + self._lock = asyncio.Lock() + + async def start_networks(self) -> None: + """Connect to all configured networks.""" + for name, net_cfg in self.config.networks.items(): + network = Network( + cfg=net_cfg, + proxy_cfg=self.config.proxy, + on_message=self._on_network_message, + ) + self.networks[name] = network + self.clients[name] = [] + asyncio.create_task(network.start()) + + async def stop_networks(self) -> None: + """Disconnect all networks.""" + for network in self.networks.values(): + await network.stop() + + async def attach(self, client: Client, network_name: str) -> Network | None: + """Attach a client to a network. Returns the network or None if not found.""" + if network_name not in self.networks: + return None + + async with self._lock: + self.clients[network_name].append(client) + + network = self.networks[network_name] + client_count = len(self.clients[network_name]) + log.info("client attached to %s (%d clients)", network_name, client_count) + + # Replay backlog + if self.config.bouncer.backlog.replay_on_connect: + await self._replay_backlog(client, network_name) + + return network + + async def detach(self, client: Client, network_name: str) -> None: + """Detach a client from a network.""" + async with self._lock: + if network_name in self.clients: + try: + self.clients[network_name].remove(client) + except ValueError: + pass + + remaining = len(self.clients.get(network_name, [])) + log.info("client detached from %s (%d remaining)", network_name, remaining) + + if remaining == 0: + await self.backlog.record_disconnect(network_name) + + async def client_to_network(self, network_name: str, msg: IRCMessage) -> None: + """Forward a client command to the network.""" + network = self.networks.get(network_name) + if network and network.connected: + await network.send(msg) + + def _on_network_message(self, network_name: str, msg: IRCMessage) -> None: + """Handle a message from an IRC server (called synchronously from network).""" + asyncio.create_task(self._dispatch(network_name, msg)) + + async def _dispatch(self, network_name: str, msg: IRCMessage) -> None: + """Dispatch a network message to attached clients and backlog.""" + # Store in backlog for relevant commands + 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) + + # Prune if configured + max_msgs = self.config.bouncer.backlog.max_messages + if max_msgs > 0: + await self.backlog.prune(network_name, keep=max_msgs) + + # Forward to all attached clients + clients = self.clients.get(network_name, []) + data = msg.format() + for client in clients: + try: + client.write(data) + except Exception: + log.exception("failed to write to client") + + async def _replay_backlog(self, client: Client, network_name: str) -> None: + """Replay missed messages to a newly connected client.""" + since_id = await self.backlog.get_last_seen(network_name) + entries = await self.backlog.replay(network_name, since_id=since_id) + + if not entries: + return + + log.info("replaying %d messages for %s", len(entries), network_name) + + for entry in entries: + msg = IRCMessage( + command=entry.command, + params=[entry.target, entry.content], + prefix=entry.sender, + ) + try: + client.write(msg.format()) + except Exception: + log.exception("failed to replay to client") + break + + # Mark the latest as seen + if entries: + await self.backlog.mark_seen(network_name, entries[-1].id) + + def network_names(self) -> list[str]: + """Return available network names.""" + return list(self.networks.keys()) + + def get_network(self, name: str) -> Network | None: + """Get a network by name.""" + return self.networks.get(name) diff --git a/src/bouncer/server.py b/src/bouncer/server.py new file mode 100644 index 0000000..5e49155 --- /dev/null +++ b/src/bouncer/server.py @@ -0,0 +1,34 @@ +"""TCP server accepting IRC client connections.""" + +from __future__ import annotations + +import asyncio +import logging + +from bouncer.client import Client +from bouncer.config import BouncerConfig +from bouncer.router import Router + +log = logging.getLogger(__name__) + + +async def start(config: BouncerConfig, router: Router) -> asyncio.Server: + """Start the client listener and return the server object.""" + + async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + client = Client(reader, writer, router, config.password) + try: + await client.handle() + except Exception: + log.exception("unhandled client error") + + server = await asyncio.start_server( + _handle, + host=config.bind, + port=config.port, + ) + + addrs = ", ".join(str(s.getsockname()) for s in server.sockets) + log.info("listening on %s", addrs) + + return server diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_backlog.py b/tests/test_backlog.py new file mode 100644 index 0000000..b4bf5e0 --- /dev/null +++ b/tests/test_backlog.py @@ -0,0 +1,89 @@ +"""Tests for SQLite backlog storage.""" + +import tempfile +from pathlib import Path + +import pytest + +from bouncer.backlog import Backlog + + +@pytest.fixture +async def backlog(): + db_path = Path(tempfile.mktemp(suffix=".db")) + bl = Backlog(db_path) + await bl.open() + yield bl + await bl.close() + db_path.unlink(missing_ok=True) + + +class TestBacklog: + async def test_store_and_replay(self, backlog: Backlog): + msg_id = await backlog.store("libera", "#test", "nick", "PRIVMSG", "hello") + assert msg_id > 0 + + entries = await backlog.replay("libera") + assert len(entries) == 1 + assert entries[0].content == "hello" + assert entries[0].sender == "nick" + assert entries[0].target == "#test" + + async def test_replay_since_id(self, backlog: Backlog): + id1 = await backlog.store("libera", "#test", "a", "PRIVMSG", "first") + id2 = await backlog.store("libera", "#test", "b", "PRIVMSG", "second") + id3 = await backlog.store("libera", "#test", "c", "PRIVMSG", "third") + + entries = await backlog.replay("libera", since_id=id1) + assert len(entries) == 2 + assert entries[0].id == id2 + assert entries[1].id == id3 + + async def test_replay_empty(self, backlog: Backlog): + entries = await backlog.replay("nonexistent") + assert entries == [] + + async def test_network_isolation(self, backlog: Backlog): + await backlog.store("libera", "#test", "a", "PRIVMSG", "libera msg") + await backlog.store("oftc", "#test", "b", "PRIVMSG", "oftc msg") + + libera = await backlog.replay("libera") + oftc = await backlog.replay("oftc") + assert len(libera) == 1 + assert len(oftc) == 1 + assert libera[0].content == "libera msg" + assert oftc[0].content == "oftc msg" + + async def test_mark_and_get_last_seen(self, backlog: Backlog): + assert await backlog.get_last_seen("libera") == 0 + + await backlog.mark_seen("libera", 42) + assert await backlog.get_last_seen("libera") == 42 + + await backlog.mark_seen("libera", 100) + assert await backlog.get_last_seen("libera") == 100 + + async def test_prune(self, backlog: Backlog): + for i in range(10): + await backlog.store("libera", "#test", "n", "PRIVMSG", f"msg{i}") + + deleted = await backlog.prune("libera", keep=3) + assert deleted == 7 + + entries = await backlog.replay("libera") + assert len(entries) == 3 + assert entries[0].content == "msg7" + + async def test_prune_zero_keeps_all(self, backlog: Backlog): + for i in range(5): + await backlog.store("libera", "#test", "n", "PRIVMSG", f"msg{i}") + + deleted = await backlog.prune("libera", keep=0) + assert deleted == 0 + assert len(await backlog.replay("libera")) == 5 + + async def test_record_disconnect(self, backlog: Backlog): + await backlog.store("libera", "#test", "n", "PRIVMSG", "msg") + await backlog.record_disconnect("libera") + last = await backlog.get_last_seen("libera") + assert last > 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..614e700 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,112 @@ +"""Tests for configuration loading.""" + +import tempfile +from pathlib import Path + +import pytest + +from bouncer.config import load + +MINIMAL_CONFIG = """\ +[bouncer] +password = "secret" + +[proxy] +host = "127.0.0.1" +port = 1080 + +[networks.test] +host = "irc.example.com" +""" + +FULL_CONFIG = """\ +[bouncer] +bind = "0.0.0.0" +port = 6668 +password = "hunter2" + +[bouncer.backlog] +max_messages = 5000 +replay_on_connect = false + +[proxy] +host = "10.0.0.1" +port = 9050 + +[networks.libera] +host = "irc.libera.chat" +port = 6697 +tls = true +nick = "testbot" +user = "testuser" +realname = "Test Bot" +channels = ["#test", "#dev"] +autojoin = true + +[networks.oftc] +host = "irc.oftc.net" +port = 6697 +tls = true +nick = "testbot" +channels = ["#debian"] +""" + + +def _write_config(content: str) -> Path: + f = tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) + f.write(content) + f.close() + return Path(f.name) + + +class TestLoad: + def test_minimal(self): + cfg = load(_write_config(MINIMAL_CONFIG)) + assert cfg.bouncer.password == "secret" + assert cfg.bouncer.bind == "127.0.0.1" + assert cfg.bouncer.port == 6667 + assert cfg.bouncer.backlog.max_messages == 10000 + assert cfg.proxy.host == "127.0.0.1" + assert "test" in cfg.networks + net = cfg.networks["test"] + assert net.host == "irc.example.com" + assert net.port == 6667 + assert net.tls is False + + def test_full(self): + cfg = load(_write_config(FULL_CONFIG)) + assert cfg.bouncer.bind == "0.0.0.0" + assert cfg.bouncer.port == 6668 + assert cfg.bouncer.backlog.max_messages == 5000 + assert cfg.bouncer.backlog.replay_on_connect is False + assert cfg.proxy.port == 9050 + assert len(cfg.networks) == 2 + libera = cfg.networks["libera"] + assert libera.tls is True + assert libera.port == 6697 + assert libera.channels == ["#test", "#dev"] + assert libera.nick == "testbot" + + def test_no_networks_raises(self): + config = """\ +[bouncer] +password = "x" + +[proxy] +""" + with pytest.raises(ValueError, match="at least one network"): + load(_write_config(config)) + + def test_tls_default_port(self): + config = """\ +[bouncer] +password = "x" + +[proxy] + +[networks.test] +host = "irc.example.com" +tls = true +""" + cfg = load(_write_config(config)) + assert cfg.networks["test"].port == 6697 diff --git a/tests/test_irc.py b/tests/test_irc.py new file mode 100644 index 0000000..df3a887 --- /dev/null +++ b/tests/test_irc.py @@ -0,0 +1,129 @@ +"""Tests for IRC message parsing and formatting.""" + +from bouncer.irc import IRCMessage, parse, parse_prefix + + +class TestParse: + def test_simple_command(self): + msg = parse(b"PING") + assert msg.command == "PING" + assert msg.params == [] + assert msg.prefix is None + + def test_command_with_param(self): + msg = parse(b"PING :server.example.com") + assert msg.command == "PING" + assert msg.params == ["server.example.com"] + + def test_privmsg(self): + msg = parse(b":nick!user@host PRIVMSG #channel :Hello world") + assert msg.prefix == "nick!user@host" + assert msg.command == "PRIVMSG" + assert msg.params == ["#channel", "Hello world"] + + def test_numeric_reply(self): + msg = parse(b":server 001 nick :Welcome to the network") + assert msg.prefix == "server" + assert msg.command == "001" + assert msg.params == ["nick", "Welcome to the network"] + + def test_join(self): + msg = parse(b":nick!user@host JOIN #channel") + assert msg.command == "JOIN" + assert msg.params == ["#channel"] + + def test_nick_user_registration(self): + msg = parse(b"NICK mynick") + assert msg.command == "NICK" + assert msg.params == ["mynick"] + + def test_user_command(self): + msg = parse(b"USER myuser 0 * :Real Name") + assert msg.command == "USER" + assert msg.params == ["myuser", "0", "*", "Real Name"] + + def test_pass_with_network(self): + msg = parse(b"PASS libera:secretpass") + assert msg.command == "PASS" + assert msg.params == ["libera:secretpass"] + + def test_quit_with_message(self): + msg = parse(b":nick!user@host QUIT :Gone fishing") + assert msg.command == "QUIT" + assert msg.params == ["Gone fishing"] + + def test_mode(self): + msg = parse(b":nick!user@host MODE #channel +o othernick") + assert msg.command == "MODE" + assert msg.params == ["#channel", "+o", "othernick"] + + def test_crlf_stripped(self): + msg = parse(b"PING :test\r\n") + assert msg.command == "PING" + assert msg.params == ["test"] + + def test_ircv3_tags(self): + msg = parse(b"@time=2024-01-01T00:00:00Z :nick!user@host PRIVMSG #ch :hi") + assert msg.tags == {"time": "2024-01-01T00:00:00Z"} + assert msg.command == "PRIVMSG" + + def test_ircv3_tag_no_value(self): + msg = parse(b"@draft/reply;+example :nick PRIVMSG #ch :test") + assert msg.tags == {"draft/reply": None, "+example": None} + + def test_latin1_fallback(self): + msg = parse(b":nick PRIVMSG #ch :\xe9\xe8\xea") + assert msg.params == ["#ch", "\xe9\xe8\xea"] + + def test_trailing(self): + msg = parse(b":nick PRIVMSG #ch :hello world") + assert msg.trailing == "hello world" + + def test_trailing_empty(self): + msg = parse(b"PING") + assert msg.trailing is None + + +class TestFormat: + def test_simple_command(self): + msg = IRCMessage(command="PING") + assert msg.format() == b"PING\r\n" + + def test_with_params(self): + msg = IRCMessage(command="NICK", params=["mynick"]) + assert msg.format() == b"NICK mynick\r\n" + + def test_with_trailing(self): + msg = IRCMessage(command="PRIVMSG", params=["#channel", "Hello world"]) + assert msg.format() == b"PRIVMSG #channel :Hello world\r\n" + + def test_with_prefix(self): + msg = IRCMessage(command="PRIVMSG", params=["#ch", "hi"], prefix="nick!user@host") + assert msg.format() == b":nick!user@host PRIVMSG #ch hi\r\n" + + def test_with_tags(self): + msg = IRCMessage( + command="PRIVMSG", + params=["#ch", "hi"], + tags={"time": "2024-01-01T00:00:00Z"}, + ) + assert msg.format() == b"@time=2024-01-01T00:00:00Z PRIVMSG #ch hi\r\n" + + def test_roundtrip(self): + original = b":nick!user@host PRIVMSG #channel :Hello world" + msg = parse(original) + assert msg.format() == original + b"\r\n" + + +class TestParsePrefix: + def test_full(self): + assert parse_prefix("nick!user@host") == ("nick", "user", "host") + + def test_nick_only(self): + assert parse_prefix("server.example.com") == ("server.example.com", None, None) + + def test_nick_host(self): + assert parse_prefix("nick@host") == ("nick", None, "host") + + def test_nick_user(self): + assert parse_prefix("nick!user") == ("nick", "user", None)