From ced6232373240e4e908c1a76a4990428e8826ff9 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 19 Feb 2026 11:29:59 +0100 Subject: [PATCH] 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 --- .gitignore | 17 +++ Makefile | 36 ++++++ PROJECT.md | 46 +++++++ README.md | 65 ++++++++++ ROADMAP.md | 38 ++++++ TASKS.md | 16 +++ TODO.md | 29 +++++ config/bouncer.example.toml | 29 +++++ docs/CHEATSHEET.md | 53 ++++++++ docs/DEBUG.md | 75 +++++++++++ docs/INSTALL.md | 42 ++++++ docs/USAGE.md | 77 +++++++++++ pyproject.toml | 42 ++++++ src/bouncer/__init__.py | 3 + src/bouncer/__main__.py | 80 ++++++++++++ src/bouncer/backlog.py | 152 ++++++++++++++++++++++ src/bouncer/cli.py | 33 +++++ src/bouncer/client.py | 250 ++++++++++++++++++++++++++++++++++++ src/bouncer/config.py | 108 ++++++++++++++++ src/bouncer/irc.py | 108 ++++++++++++++++ src/bouncer/network.py | 222 ++++++++++++++++++++++++++++++++ src/bouncer/proxy.py | 46 +++++++ src/bouncer/router.py | 148 +++++++++++++++++++++ src/bouncer/server.py | 34 +++++ tests/__init__.py | 0 tests/test_backlog.py | 89 +++++++++++++ tests/test_config.py | 112 ++++++++++++++++ tests/test_irc.py | 129 +++++++++++++++++++ 28 files changed, 2079 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 PROJECT.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 TASKS.md create mode 100644 TODO.md create mode 100644 config/bouncer.example.toml create mode 100644 docs/CHEATSHEET.md create mode 100644 docs/DEBUG.md create mode 100644 docs/INSTALL.md create mode 100644 docs/USAGE.md create mode 100644 pyproject.toml create mode 100644 src/bouncer/__init__.py create mode 100644 src/bouncer/__main__.py create mode 100644 src/bouncer/backlog.py create mode 100644 src/bouncer/cli.py create mode 100644 src/bouncer/client.py create mode 100644 src/bouncer/config.py create mode 100644 src/bouncer/irc.py create mode 100644 src/bouncer/network.py create mode 100644 src/bouncer/proxy.py create mode 100644 src/bouncer/router.py create mode 100644 src/bouncer/server.py create mode 100644 tests/__init__.py create mode 100644 tests/test_backlog.py create mode 100644 tests/test_config.py create mode 100644 tests/test_irc.py 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)