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 <noreply@anthropic.com>
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -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
|
||||
36
Makefile
Normal file
36
Makefile
Normal file
@@ -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
|
||||
46
PROJECT.md
Normal file
46
PROJECT.md
Normal file
@@ -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
|
||||
65
README.md
Normal file
65
README.md
Normal file
@@ -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
|
||||
38
ROADMAP.md
Normal file
38
ROADMAP.md
Normal file
@@ -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
|
||||
16
TASKS.md
Normal file
16
TASKS.md
Normal file
@@ -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
|
||||
29
TODO.md
Normal file
29
TODO.md
Normal file
@@ -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
|
||||
29
config/bouncer.example.toml
Normal file
29
config/bouncer.example.toml
Normal file
@@ -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"]
|
||||
53
docs/CHEATSHEET.md
Normal file
53
docs/CHEATSHEET.md
Normal file
@@ -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 <network>:<password> # Authenticate + select network
|
||||
PASS <password> # 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.<name>] # 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 |
|
||||
75
docs/DEBUG.md
Normal file
75
docs/DEBUG.md
Normal file
@@ -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`
|
||||
42
docs/INSTALL.md
Normal file
42
docs/INSTALL.md
Normal file
@@ -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.<name>.host` -- IRC server hostname
|
||||
- `networks.<name>.nick` -- your IRC nickname
|
||||
- `networks.<name>.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
|
||||
```
|
||||
77
docs/USAGE.md
Normal file
77
docs/USAGE.md
Normal file
@@ -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>:<password>
|
||||
```
|
||||
|
||||
- `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.
|
||||
42
pyproject.toml
Normal file
42
pyproject.toml
Normal file
@@ -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"
|
||||
3
src/bouncer/__init__.py
Normal file
3
src/bouncer/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""IRC bouncer with SOCKS5 proxy support and persistent backlog."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
80
src/bouncer/__main__.py
Normal file
80
src/bouncer/__main__.py
Normal file
@@ -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()
|
||||
152
src/bouncer/backlog.py
Normal file
152
src/bouncer/backlog.py
Normal file
@@ -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
|
||||
33
src/bouncer/cli.py
Normal file
33
src/bouncer/cli.py
Normal file
@@ -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)
|
||||
250
src/bouncer/client.py
Normal file
250
src/bouncer/client.py
Normal file
@@ -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()
|
||||
108
src/bouncer/config.py
Normal file
108
src/bouncer/config.py
Normal file
@@ -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)
|
||||
108
src/bouncer/irc.py
Normal file
108
src/bouncer/irc.py
Normal file
@@ -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")
|
||||
222
src/bouncer/network.py
Normal file
222
src/bouncer/network.py
Normal file
@@ -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)
|
||||
46
src/bouncer/proxy.py
Normal file
46
src/bouncer/proxy.py
Normal file
@@ -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
|
||||
148
src/bouncer/router.py
Normal file
148
src/bouncer/router.py
Normal file
@@ -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)
|
||||
34
src/bouncer/server.py
Normal file
34
src/bouncer/server.py
Normal file
@@ -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
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
89
tests/test_backlog.py
Normal file
89
tests/test_backlog.py
Normal file
@@ -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
|
||||
112
tests/test_config.py
Normal file
112
tests/test_config.py
Normal file
@@ -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
|
||||
129
tests/test_irc.py
Normal file
129
tests/test_irc.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user