Compare commits

...

11 Commits

Author SHA1 Message Date
user 0e06a18851 feat: per-network proxy override, CERT ADD timing fix
config: add optional proxy_host/proxy_port to NetworkConfig
router: resolve per-network proxy via _proxy_for() helper
commands: trigger REHASH reconnect on proxy config changes
network: send CERT ADD before CAP END to beat K-line race

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 02:25:39 +01:00
user 15f0d374d2 feat: remote DNS fallback, .onion TLS handling, SASL EXTERNAL fallback
proxy.py:
- Refactor connection logic into _connect_once() helper
- Fall back to remote DNS via SOCKS5 when local resolution fails
  (enables .onion and proxy-only hostnames)
- Skip TLS hostname verification for .onion addresses (Tor routing
  provides authentication)

network.py:
- Fall back from SASL EXTERNAL to PLAIN on 904 (same connection)
- Auto-register cert fingerprint with NickServ CERT ADD immediately
  after SASL PLAIN success (903) and after RPL_WELCOME (001)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:39:57 +01:00
user 2f40f5e508 feat: add CertFP authentication with SASL EXTERNAL
Per-network, per-nick client certificates (EC P-256, self-signed,
10-year validity) stored as combined PEM files. Authentication
cascade: SASL EXTERNAL > SASL PLAIN > NickServ IDENTIFY.

New commands: GENCERT, CERTFP, DELCERT. GENCERT auto-registers
the fingerprint with NickServ CERT ADD when the network is connected.

Includes email verification module for NickServ registration and
expanded NickServ interaction (IDENTIFY, REGISTER, VERIFY).
2026-02-21 01:15:25 +01:00
user e6b1ce4c6d fix: block PASS/USER/NICK from clients post-registration
All three registration commands are now explicitly intercepted after
the client has authenticated. NICK gets a notice pointing to the
bouncer command; PASS and USER are silently dropped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:48:03 +01:00
user ee2175f565 fix: block direct NICK from clients, require bouncer command
Clients sending /nick are intercepted with a NOTICE pointing them
to /msg *bouncer NICK <network> <nick> instead. Prevents unmanaged
nick changes that bypass the bouncer's identity tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:45:45 +01:00
user 3d9aa33ec4 feat: add 16 extended bouncer control commands
Network control (CONNECT, DISCONNECT, RECONNECT, NICK, RAW), visibility
(CHANNELS, CLIENTS, BACKLOG, VERSION), config management (REHASH,
ADDNETWORK, DELNETWORK, AUTOJOIN), and NickServ operations (IDENTIFY,
REGISTER, DROPCREDS). Total command count: 22.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:34:23 +01:00
user 6478c514ad feat: add bouncer control commands via /msg *bouncer
Users can now inspect bouncer state and manage it from their IRC client
by sending PRIVMSG to *bouncer (or bouncer). Supported commands:
HELP, STATUS, INFO, UPTIME, NETWORKS, CREDS. Responses arrive as
NOTICE messages. All commands are case-insensitive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:10:39 +01:00
user 532ceb3c3d fix: track reconnect task for clean shutdown
Reconnect backoff sleeps (up to 300s) were not cancellable, causing
SIGKILL on container stop. Now _schedule_reconnect spawns a tracked
task that stop() cancels, enabling graceful shutdown within the
podman timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:16:57 +01:00
user 54218d2677 fix: suppress connection noise, MOTD, CTCP, and DCC from clients
Filter out messages that are useless to bouncer clients:
- Server notices (prefix without !, NOTICE to */AUTH)
- MOTD numerics (375, 372, 376, 422)
- Welcome/stats numerics (001-005, 042, 250-255, 265-266)
- User mode changes (MODE to non-channel targets)
- CTCP queries and DCC requests (PRIVMSG with \x01, except ACTION)
- CTCP replies in NOTICE

Filter applies to both live dispatch and backlog replay. Purged
existing noise from the backlog database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:01:20 +01:00
user 3c6f0bcf19 fix: use client nick for synthetic JOINs and own-nick rewriting
irssi (and other IRC clients) only open a channel window when they see
a JOIN from their own nick. The synthetic JOINs were using the network
nick (e.g. pagumowa) but the client registered as tester -- mismatch.

Three changes:
- Synthetic JOIN prefix is now client_nick!user@bouncer
- 001 welcome uses the client's registered nick
- encode_nick/encode_message accept client_nick param to rewrite own
  nicks from any network to the client's nick, so irssi recognizes
  all self-actions consistently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:42:10 +01:00
user 8cc57a7af4 feat: multi-network namespace multiplexing
Multiplex all networks onto a single client connection using /network
suffixes on channels and nicks. PASS is now just the password (no
network prefix). Channels appear as #channel/network, foreign nicks as
nick/network, own nicks stay bare.

New namespace.py module with pure encode/decode functions. Router
tracks clients globally (not per-network), namespaces messages before
delivery. Client attaches to all networks on connect, sends synthetic
JOIN/TOPIC/NAMES for every channel across all networks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:03:58 +01:00
25 changed files with 4524 additions and 236 deletions
+3
View File
@@ -18,3 +18,6 @@ build/
# Personal config (keep example only)
config/bouncer.toml
# Client certificates (generated per-network)
certs/
+4 -1
View File
@@ -4,7 +4,10 @@ WORKDIR /app
RUN pip install --no-cache-dir \
"python-socks[asyncio]>=2.4" \
"aiosqlite>=0.19"
"aiosqlite>=0.19" \
"aiohttp>=3.9" \
"aiohttp-socks>=0.8" \
"cryptography>=41.0"
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
+3 -2
View File
@@ -35,11 +35,12 @@ DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY
|--------|---------------|
| `irc.py` | IRC protocol parser/formatter (RFC 2812 subset) |
| `config.py` | TOML configuration loading and validation |
| `namespace.py` | `/network` suffix encode/decode for multi-network multiplexing |
| `proxy.py` | SOCKS5 async connector with local DNS + multi-IP failover |
| `network.py` | Server connection state machine, stealth registration |
| `server.py` | TCP listener accepting IRC client connections |
| `client.py` | Per-client session, PASS/NICK/USER handshake |
| `router.py` | Message routing between clients and networks |
| `client.py` | Per-client session, PASS/NICK/USER handshake, multi-network attach |
| `router.py` | Namespaced message routing between clients and networks |
| `backlog.py` | SQLite message storage and replay |
### Key Decisions
+12 -4
View File
@@ -4,12 +4,12 @@ IRC bouncer with SOCKS5 proxy support and persistent message backlog.
## Features
- Connect to multiple IRC networks simultaneously
- **Multi-network multiplexing**: single client connection sees all networks via `/network` suffixes
- All outbound connections routed through SOCKS5 proxy
- Stealth connect: registers with a random pronounceable nick and generic identity
- Probation window: waits 15s after registration to detect K-lines before revealing real nick
- Persistent message backlog (SQLite) with replay on reconnect
- Multiple clients can attach to the same network session
- Multiple clients can attach simultaneously
- Password authentication
- TLS support for IRC server connections
- Automatic reconnection with exponential backoff
@@ -32,10 +32,18 @@ bouncer -c config/bouncer.toml -v
From your IRC client, connect to `127.0.0.1:6667` with:
```
PASS networkname:yourpassword
PASS yourpassword
```
Where `networkname` matches a `[networks.NAME]` section in your config.
A single connection gives you all configured networks. Channels and nicks
appear with a `/network` suffix:
```
Client sees: Server sends/receives:
#libera/libera <-> #libera (on libera network)
#debian/oftc <-> #debian (on oftc network)
user123/libera <-> user123 (on libera network)
```
## How It Works
+1
View File
@@ -15,6 +15,7 @@
- [x] Stealth connect (random markov-generated identity)
- [x] Probation window (K-line detection before revealing nick)
- [x] Verified end-to-end on Libera.Chat via SOCKS5
- [x] Multi-network namespace multiplexing (`/network` suffixes)
## v0.2.0
+4
View File
@@ -11,6 +11,10 @@
- [x] P1: Integration testing with live IRC server (Libera.Chat)
- [x] P1: Verified SOCKS5 proxy connectivity end-to-end
- [x] P1: Documentation update
- [x] P1: Multi-network namespace multiplexing (`/network` suffixes)
- [x] P1: Bouncer control commands (`/msg *bouncer STATUS/INFO/UPTIME/NETWORKS/CREDS/HELP`)
- [x] P1: Extended control commands (CONNECT/DISCONNECT/RECONNECT/NICK/RAW/CHANNELS/CLIENTS/BACKLOG/VERSION/REHASH/ADDNETWORK/DELNETWORK/AUTOJOIN/IDENTIFY/REGISTER/DROPCREDS)
## Next
+5
View File
@@ -11,11 +11,16 @@ replay_on_connect = true
host = "127.0.0.1"
port = 1080
# Client PASS is just the password (no network prefix).
# A single client connection sees all networks via /network suffixes:
# #libera/libera, #debian/oftc, user123/libera
#
# Registration uses a random nick and generic ident/realname.
# After surviving the probation window (no k-line), the bouncer
# derives a stable nick from the exit endpoint hostname. The same
# exit IP always produces the same nick across reconnects.
# Set nick to override (optional, used as fallback only).
# Network names must not contain '/' (reserved for namespace separator).
[networks.libera]
host = "irc.libera.chat"
+84 -4
View File
@@ -38,8 +38,78 @@ make clean # rm .venv, build artifacts
## Client Auth
```
PASS <network>:<password> # select network + authenticate
PASS <password> # use first network
PASS <password> # authenticate (all networks)
```
## Bouncer Commands
### Inspection
```
/msg *bouncer HELP # list commands
/msg *bouncer STATUS # all network states
/msg *bouncer INFO libera # detailed network info
/msg *bouncer UPTIME # process uptime
/msg *bouncer NETWORKS # list networks
/msg *bouncer CREDS [network] # NickServ creds
/msg *bouncer CHANNELS [network] # joined channels + topics
/msg *bouncer CLIENTS # connected clients
/msg *bouncer BACKLOG [network] # message counts + DB size
/msg *bouncer VERSION # bouncer + Python version
```
### Network Control
```
/msg *bouncer CONNECT libera # start disconnected network
/msg *bouncer DISCONNECT libera # stop network
/msg *bouncer RECONNECT libera # restart with fresh identity
/msg *bouncer NICK libera newnick # change nick
/msg *bouncer RAW libera WHOIS user # send raw IRC command
```
### Config Management
```
/msg *bouncer REHASH # reload config file
/msg *bouncer ADDNETWORK name host=h port=N tls=yes nick=n channels=#a,#b
/msg *bouncer DELNETWORK name # remove network
/msg *bouncer AUTOJOIN net +#chan # add to autojoin
/msg *bouncer AUTOJOIN net -#chan # remove from autojoin
```
### NickServ
```
/msg *bouncer IDENTIFY libera # force IDENTIFY
/msg *bouncer REGISTER libera # trigger registration
/msg *bouncer DROPCREDS libera # delete all creds
/msg *bouncer DROPCREDS libera nick # delete one nick's creds
```
### CertFP
```
/msg *bouncer GENCERT libera # generate cert (current nick)
/msg *bouncer GENCERT libera nick # generate cert (specific nick)
/msg *bouncer CERTFP # list all cert fingerprints
/msg *bouncer CERTFP libera # list certs for one network
/msg *bouncer DELCERT libera # delete cert (current nick)
/msg *bouncer DELCERT libera nick # delete cert (specific nick)
```
## Namespacing
```
#channel/network # channel on a specific network
nick/network # foreign nick on a specific network
own-nick # own nicks shown without suffix
```
```
/msg #libera/libera hello # send to #libera on libera network
/join #test/oftc # join #test on oftc
/join #a/libera,#b/oftc # comma-separated, different networks
```
## Connection States
@@ -55,6 +125,12 @@ DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY
| PROBATION | 15s wait, watching for K-line |
| READY | Switch to configured nick, join channels |
## Auth Cascade
```
SASL EXTERNAL (cert + creds) > SASL PLAIN (creds) > NickServ IDENTIFY
```
## Reconnect Backoff
```
@@ -85,6 +161,7 @@ password # optional, IRC server PASS
| `config/bouncer.toml` | Active config (gitignored) |
| `config/bouncer.example.toml` | Example template |
| `config/bouncer.db` | SQLite backlog (auto-created) |
| `{data_dir}/certs/{net}/{nick}.pem` | Client certificates (auto-created) |
## Backlog Queries
@@ -107,9 +184,12 @@ src/bouncer/
cli.py # argparse
config.py # TOML loader
irc.py # IRC message parse/format
proxy.py # SOCKS5 connector (local DNS, multi-IP)
network.py # server connection + state machine
namespace.py # /network encode/decode for multiplexing
proxy.py # SOCKS5 connector (local DNS, multi-IP, CertFP)
network.py # server connection + state machine + SASL
client.py # client session handler
cert.py # client certificate generation + management
commands.py # 25 bouncer control commands (/msg *bouncer)
router.py # message routing + backlog trigger
server.py # TCP listener
backlog.py # SQLite store/replay/prune
+206 -23
View File
@@ -82,59 +82,78 @@ Configure your IRC client to connect to the bouncer:
|---------|-------|
| Server | `127.0.0.1` |
| Port | `6667` (or as configured) |
| Password | `networkname:yourpassword` |
| Password | `yourpassword` |
### Password Format
```
PASS <network>:<password>
PASS <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.
The password is the `bouncer.password` value from config. A single connection
automatically attaches to **all** configured networks.
### Client Examples
**irssi:**
```
/connect -password libera:mypassword 127.0.0.1 6667
/connect -password mypassword 127.0.0.1 6667
```
**weechat:**
```
/server add bouncer 127.0.0.1/6667 -password=libera:mypassword
/server add bouncer 127.0.0.1/6667 -password=mypassword
/connect bouncer
```
**hexchat:**
Set server password to `libera:mypassword` in the network settings.
Set server password to `mypassword` in the network settings.
## Multiple Networks
## Multi-Network Namespacing
Define multiple `[networks.*]` sections in the config. Each gets its own
persistent server connection through the SOCKS5 proxy.
Connect your client with the appropriate network prefix:
All configured networks are multiplexed onto a single client connection. Channels
and nicks carry a `/network` suffix so you can tell which network they belong to:
```
PASS libera:mypassword # connects to [networks.libera]
PASS oftc:mypassword # connects to [networks.oftc]
Client sees: Server wire:
#libera/libera <-> #libera (on libera network)
#debian/oftc <-> #debian (on oftc network)
user123/libera <-> user123 (on libera network)
```
Multiple clients can attach to the same network simultaneously. All receive
the same messages in real time.
### Rules
- **Channels**: `#channel/network` in client, `#channel` on wire
- **Foreign nicks**: `nick/network` in client, `nick` on wire
- **Own nicks**: shown without suffix (prevents client confusion)
- **Sending messages**: include the `/network` suffix in the target
```
/msg #libera/libera hello -> sends "hello" to #libera on libera network
/join #test/oftc -> joins #test on oftc network
/msg user123/libera hi -> private message to user123 on libera
```
### Comma-Separated JOIN/PART
Targets can span networks:
```
/join #a/libera,#b/oftc -> joins #a on libera AND #b on oftc
```
Multiple clients can attach simultaneously. All receive the same namespaced
messages in real time.
## What Clients Receive on Connect
When a client authenticates and attaches to a network:
When a client authenticates:
1. **Backlog replay** -- missed messages since last disconnect
2. **Synthetic welcome** -- 001-004 numeric replies from the bouncer
3. **Channel state** -- TOPIC and NAMES for each joined channel
1. **Backlog replay** -- missed messages (namespaced) from all networks
2. **Synthetic welcome** -- 001-004 numeric replies listing all networks
3. **Channel state** -- synthetic JOIN, TOPIC, and NAMES for every joined
channel across all networks (all namespaced with `/network` suffix)
## Backlog
@@ -177,6 +196,170 @@ autojoin = true # auto-join channels on ready (default: true)
password = "" # IRC server password (optional, for PASS command)
```
## CertFP Authentication
The bouncer supports client certificate fingerprint (CertFP) authentication
via SASL EXTERNAL. Each certificate is unique per (network, nick) pair and
stored as a combined PEM file at `{data_dir}/certs/{network}/{nick}.pem`.
### Authentication Cascade
When connecting, the bouncer selects the strongest available method:
| Priority | Method | Condition |
|----------|--------|-----------|
| 1 | SASL EXTERNAL | Stored creds + cert file exists |
| 2 | SASL PLAIN | Stored creds, no cert |
| 3 | NickServ IDENTIFY | Fallback after SASL failure |
### Setup
1. Generate a certificate:
```
/msg *bouncer GENCERT libera
```
This creates an EC P-256 self-signed cert (10-year validity) and
auto-sends `NickServ CERT ADD <fingerprint>` if the network is connected.
2. Reconnect to use CertFP:
```
/msg *bouncer RECONNECT libera
```
The bouncer will now present the client certificate during TLS and
authenticate via SASL EXTERNAL.
3. Verify the fingerprint is registered:
```
/msg *bouncer CERTFP libera
```
### Certificate Storage
Certificates are stored alongside the config file:
```
{data_dir}/certs/
libera/
fabesune.pem # cert + private key (chmod 600)
oftc/
mynick.pem
```
## Bouncer Commands
Send a PRIVMSG to `*bouncer` (or `bouncer`) from your IRC client to inspect
and control the bouncer. All commands are case-insensitive.
Responses arrive as NOTICE messages from `*bouncer`.
### Inspection
| Command | Description |
|---------|-------------|
| `HELP` | List available commands |
| `STATUS` | Overview: state, nick, host per network |
| `INFO <network>` | Detailed info for one network (state, server, channels, creds) |
| `UPTIME` | Bouncer uptime since process start |
| `NETWORKS` | List all configured networks with state |
| `CREDS [network]` | NickServ credential status (all or per-network) |
| `CHANNELS [network]` | List joined channels with topics (all or per-network) |
| `CLIENTS` | List connected bouncer clients |
| `BACKLOG [network]` | Message counts per network and database size |
| `VERSION` | Bouncer and Python version |
### Network Control
| Command | Description |
|---------|-------------|
| `CONNECT <network>` | Start a disconnected network |
| `DISCONNECT <network>` | Stop a network |
| `RECONNECT <network>` | Stop and restart with a fresh identity |
| `NICK <network> <nick>` | Change nick on a network |
| `RAW <network> <command>` | Send a raw IRC command to a network |
### Config Management
| Command | Description |
|---------|-------------|
| `REHASH` | Reload config file, add/remove/reconnect networks |
| `ADDNETWORK <name> key=val ...` | Create a network at runtime |
| `DELNETWORK <name>` | Stop and remove a network |
| `AUTOJOIN <network> +/-#channel` | Add or remove channel from autojoin list |
**ADDNETWORK keys:** `host` (required), `port`, `tls` (yes/no), `nick`,
`channels` (comma-separated), `password`.
### NickServ
| Command | Description |
|---------|-------------|
| `IDENTIFY <network>` | Force NickServ IDENTIFY with stored credentials |
| `REGISTER <network>` | Trigger NickServ registration attempt |
| `DROPCREDS <network> [nick]` | Delete stored NickServ credentials |
### CertFP
| Command | Description |
|---------|-------------|
| `GENCERT <network> [nick]` | Generate client cert, auto-register with NickServ |
| `CERTFP [network]` | Show certificate fingerprints (all or per-network) |
| `DELCERT <network> [nick]` | Delete a client certificate |
### Examples
```
/msg *bouncer HELP
/msg *bouncer STATUS
/msg *bouncer INFO libera
/msg *bouncer CHANNELS
/msg *bouncer CLIENTS
/msg *bouncer BACKLOG
/msg *bouncer VERSION
/msg *bouncer CONNECT libera
/msg *bouncer DISCONNECT libera
/msg *bouncer RECONNECT libera
/msg *bouncer NICK libera newnick
/msg *bouncer RAW libera WHOIS someuser
/msg *bouncer REHASH
/msg *bouncer ADDNETWORK oftc host=irc.oftc.net port=6697 tls=yes channels=#test
/msg *bouncer DELNETWORK oftc
/msg *bouncer AUTOJOIN libera +#newchannel
/msg *bouncer AUTOJOIN libera -#oldchannel
/msg *bouncer IDENTIFY libera
/msg *bouncer REGISTER libera
/msg *bouncer DROPCREDS libera
/msg *bouncer DROPCREDS libera oldnick
/msg *bouncer GENCERT libera
/msg *bouncer GENCERT libera fabesune
/msg *bouncer CERTFP
/msg *bouncer CERTFP libera
/msg *bouncer DELCERT libera
/msg *bouncer DELCERT libera fabesune
```
### Example Output
```
[STATUS]
libera ready fabesune user/fabesune
oftc ready ceraty cloaked.user
hackint connecting (attempt 3)
quakenet ready spetyo --
[CHANNELS]
libera #test Welcome to the test channel
libera #dev
oftc #debian Debian support
[CLIENTS]
myuser 127.0.0.1:54321 connected 2h 15m 3s
[BACKLOG]
libera 1,500 messages
oftc 842 messages
DB size: 2.1 MB
```
## Stopping
Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing
+6
View File
@@ -10,6 +10,9 @@ requires-python = ">=3.10"
dependencies = [
"python-socks[asyncio]>=2.4",
"aiosqlite>=0.19",
"aiohttp>=3.9",
"aiohttp-socks>=0.8",
"cryptography>=41.0",
]
[project.optional-dependencies]
@@ -19,6 +22,9 @@ dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
]
browser = [
"playwright>=1.40",
]
[project.scripts]
bouncer = "bouncer.__main__:main"
+25 -2
View File
@@ -6,8 +6,10 @@ import asyncio
import logging
import signal
import sys
import time
from pathlib import Path
from bouncer import commands
from bouncer.backlog import Backlog
from bouncer.cli import parse_args
from bouncer.config import load
@@ -21,7 +23,12 @@ 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)
logging.basicConfig(level=level, format=fmt, datefmt=datefmt, force=True)
# Also log to file for container environments
fh = logging.FileHandler("/data/bouncer.log")
fh.setLevel(level)
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s %(name)s %(message)s", datefmt))
logging.getLogger().addHandler(fh)
async def _run(config_path: Path, verbose: bool) -> None:
@@ -37,7 +44,11 @@ async def _run(config_path: Path, verbose: bool) -> None:
backlog = Backlog(db_path)
await backlog.open()
router = Router(cfg, backlog)
commands.STARTUP_TIME = time.time()
commands.CONFIG_PATH = config_path
commands.DATA_DIR = data_dir
router = Router(cfg, backlog, data_dir=data_dir)
await router.start_networks()
server = await start(cfg.bouncer, router)
@@ -71,6 +82,18 @@ def main() -> None:
sys.exit(1)
try:
if args.cprofile:
import cProfile
prof = cProfile.Profile()
prof.enable()
try:
asyncio.run(_run(args.config, args.verbose))
finally:
prof.disable()
prof.dump_stats(str(args.cprofile))
print(f"cProfile stats written to {args.cprofile}", file=sys.stderr)
else:
asyncio.run(_run(args.config, args.verbose))
except KeyboardInterrupt:
pass
+171
View File
@@ -29,8 +29,24 @@ CREATE TABLE IF NOT EXISTS client_state (
last_seen_id INTEGER NOT NULL DEFAULT 0,
last_disconnect REAL
);
CREATE TABLE IF NOT EXISTS nickserv_creds (
network TEXT NOT NULL,
nick TEXT NOT NULL,
password TEXT NOT NULL,
email TEXT NOT NULL,
registered_at REAL NOT NULL,
host TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'verified',
PRIMARY KEY (network, nick)
);
"""
# Migration: add status column if missing (existing DBs)
_MIGRATIONS = [
"ALTER TABLE nickserv_creds ADD COLUMN status TEXT NOT NULL DEFAULT 'verified'",
]
@dataclass(slots=True)
class BacklogEntry:
@@ -57,8 +73,19 @@ class Backlog:
self._db = await aiosqlite.connect(self._path)
await self._db.executescript(SCHEMA)
await self._db.commit()
await self._run_migrations()
log.debug("backlog database opened: %s", self._path)
async def _run_migrations(self) -> None:
"""Apply schema migrations, skipping any that have already been applied."""
assert self._db is not None
for sql in _MIGRATIONS:
try:
await self._db.execute(sql)
await self._db.commit()
except Exception:
pass # column/index already exists
async def close(self) -> None:
"""Close the database connection."""
if self._db:
@@ -141,6 +168,150 @@ class Backlog:
await self._db.commit()
return cursor.rowcount # type: ignore[return-value]
async def save_nickserv_creds(
self,
network: str,
nick: str,
password: str,
email: str,
host: str,
status: str = "verified",
) -> None:
"""Save NickServ credentials.
Status is 'pending' during registration (email not yet verified)
or 'verified' after successful verification / IDENTIFY.
"""
assert self._db is not None
await self._db.execute(
"INSERT INTO nickserv_creds (network, nick, password, email, registered_at, host, status) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(network, nick) DO UPDATE SET "
"password = excluded.password, email = excluded.email, "
"registered_at = excluded.registered_at, host = excluded.host, "
"status = excluded.status",
(network, nick, password, email, time.time(), host, status),
)
await self._db.commit()
log.info("saved NickServ creds: %s/%s (host=%s, status=%s)", network, nick, host, status)
async def get_nickserv_creds(
self, network: str, nick: str
) -> tuple[str, str] | None:
"""Get stored NickServ password and email for a nick. Returns (password, email) or None."""
assert self._db is not None
cursor = await self._db.execute(
"SELECT password, email FROM nickserv_creds WHERE network = ? AND nick = ?",
(network, nick),
)
row = await cursor.fetchone()
return (row[0], row[1]) if row else None
async def get_nickserv_creds_by_network(
self, network: str,
) -> tuple[str, str] | None:
"""Get most recent verified NickServ nick and password for a network.
Only returns verified credentials (safe for SASL).
Returns (nick, password) or None.
"""
assert self._db is not None
cursor = await self._db.execute(
"SELECT nick, password FROM nickserv_creds "
"WHERE network = ? AND status = 'verified' "
"ORDER BY registered_at DESC LIMIT 1",
(network,),
)
row = await cursor.fetchone()
return (row[0], row[1]) if row else None
async def get_nickserv_creds_by_host(
self, network: str, host: str
) -> tuple[str, str] | None:
"""Get stored verified NickServ nick and password by host. Returns (nick, password) or None."""
assert self._db is not None
cursor = await self._db.execute(
"SELECT nick, password FROM nickserv_creds "
"WHERE network = ? AND host = ? AND status = 'verified' "
"ORDER BY registered_at DESC LIMIT 1",
(network, host),
)
row = await cursor.fetchone()
return (row[0], row[1]) if row else None
async def get_pending_registration(
self, network: str,
) -> tuple[str, str, str, str] | None:
"""Get a pending (unverified) registration for a network.
Returns (nick, password, email, host) or None.
"""
assert self._db is not None
cursor = await self._db.execute(
"SELECT nick, password, email, host FROM nickserv_creds "
"WHERE network = ? AND status = 'pending' "
"ORDER BY registered_at DESC LIMIT 1",
(network,),
)
row = await cursor.fetchone()
return (row[0], row[1], row[2], row[3]) if row else None
async def mark_nickserv_verified(self, network: str, nick: str) -> None:
"""Promote a pending registration to verified."""
assert self._db is not None
await self._db.execute(
"UPDATE nickserv_creds SET status = 'verified' WHERE network = ? AND nick = ?",
(network, nick),
)
await self._db.commit()
log.info("marked verified: %s/%s", network, nick)
async def list_nickserv_creds(
self, network: str | None = None,
) -> list[tuple[str, str, str, str, float, str]]:
"""List NickServ credentials, optionally filtered by network.
Returns list of (network, nick, email, host, registered_at, status).
"""
assert self._db is not None
if network:
cursor = await self._db.execute(
"SELECT network, nick, email, host, registered_at, status "
"FROM nickserv_creds WHERE network = ? ORDER BY registered_at DESC",
(network,),
)
else:
cursor = await self._db.execute(
"SELECT network, nick, email, host, registered_at, status "
"FROM nickserv_creds ORDER BY network, registered_at DESC",
)
return await cursor.fetchall()
async def delete_nickserv_creds(self, network: str, nick: str) -> None:
"""Remove stored credentials for a nick."""
assert self._db is not None
await self._db.execute(
"DELETE FROM nickserv_creds WHERE network = ? AND nick = ?",
(network, nick),
)
await self._db.commit()
async def stats(self, network: str | None = None) -> list[tuple[str, int]]:
"""Message counts per network. Returns [(network, count), ...]."""
assert self._db is not None
if network:
sql = "SELECT network, COUNT(*) FROM messages WHERE network = ? GROUP BY network"
params = (network,)
else:
sql = "SELECT network, COUNT(*) FROM messages GROUP BY network ORDER BY network"
params = ()
cursor = await self._db.execute(sql, params)
return await cursor.fetchall()
async def db_size(self) -> int:
"""Return database file size in bytes."""
return self._path.stat().st_size
async def _max_id(self, network: str) -> int:
"""Get the maximum message ID for a network."""
assert self._db is not None
+126
View File
@@ -0,0 +1,126 @@
"""Client certificate management for CertFP authentication."""
from __future__ import annotations
import datetime
import logging
import os
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
log = logging.getLogger(__name__)
_VALIDITY_DAYS = 3650 # ~10 years
def cert_path(data_dir: Path, network: str, nick: str) -> Path:
"""Return the PEM file path for a (network, nick) pair."""
return data_dir / "certs" / network / f"{nick}.pem"
def generate_cert(data_dir: Path, network: str, nick: str) -> Path:
"""Generate a self-signed EC P-256 client certificate.
Creates a combined PEM file (cert + key) at the standard path.
Returns the path to the generated file.
"""
pem = cert_path(data_dir, network, nick)
pem.parent.mkdir(parents=True, exist_ok=True)
key = ec.generate_private_key(ec.SECP256R1())
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, f"{nick}@{network}"),
])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=_VALIDITY_DAYS))
.sign(key, hashes.SHA256())
)
key_bytes = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
cert_bytes = cert.public_bytes(serialization.Encoding.PEM)
pem.write_bytes(cert_bytes + key_bytes)
os.chmod(pem, 0o600)
log.info("generated cert %s (CN=%s@%s)", pem, nick, network)
return pem
def fingerprint(pem_path: Path) -> str:
"""Return the SHA-256 fingerprint in colon-separated uppercase hex.
This is the format NickServ expects for CERT ADD.
"""
cert_data = pem_path.read_bytes()
cert = x509.load_pem_x509_certificate(cert_data)
digest = cert.fingerprint(hashes.SHA256())
return ":".join(f"{b:02X}" for b in digest)
def has_cert(data_dir: Path, network: str, nick: str) -> bool:
"""Check whether a certificate exists for (network, nick)."""
return cert_path(data_dir, network, nick).is_file()
def delete_cert(data_dir: Path, network: str, nick: str) -> bool:
"""Delete the certificate for (network, nick). Returns True if removed."""
pem = cert_path(data_dir, network, nick)
if pem.is_file():
pem.unlink()
log.info("deleted cert %s", pem)
# Clean up empty directories
try:
pem.parent.rmdir()
except OSError:
pass
return True
return False
def list_certs(
data_dir: Path, network: str | None = None,
) -> list[tuple[str, str, str]]:
"""List certificates as (network, nick, fingerprint) tuples.
If network is given, only lists certs for that network.
"""
certs_dir = data_dir / "certs"
if not certs_dir.is_dir():
return []
results: list[tuple[str, str, str]] = []
if network:
net_dir = certs_dir / network
if net_dir.is_dir():
for pem_file in sorted(net_dir.glob("*.pem")):
nick = pem_file.stem
fp = fingerprint(pem_file)
results.append((network, nick, fp))
else:
for net_dir in sorted(certs_dir.iterdir()):
if not net_dir.is_dir():
continue
for pem_file in sorted(net_dir.glob("*.pem")):
nick = pem_file.stem
fp = fingerprint(pem_file)
results.append((net_dir.name, nick, fp))
return results
+72 -53
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from bouncer import commands
from bouncer.irc import IRCMessage, parse
from bouncer.namespace import encode_channel
if TYPE_CHECKING:
from bouncer.network import Network
from bouncer.router import Router
log = logging.getLogger(__name__)
@@ -45,8 +47,6 @@ class Client:
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 = ""
@@ -56,8 +56,14 @@ class Client:
self._got_nick: bool = False
self._got_user: bool = False
self._pass_raw: str = ""
self._connected_at: float = time.time()
self._addr = writer.get_extra_info("peername", ("?", 0))
@property
def nick(self) -> str:
"""The nick the client registered with."""
return self._nick
async def handle(self) -> None:
"""Main client session loop."""
log.info("client connected from %s", self._addr)
@@ -106,8 +112,26 @@ class Client:
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)
# Block registration commands -- never forward to networks
if msg.command in ("NICK", "PASS", "USER"):
if msg.command == "NICK":
self._send_msg(IRCMessage(
command="NOTICE",
params=[self._nick, "Use /msg *bouncer NICK <network> <nick>"],
prefix="*bouncer!bouncer@bouncer",
))
return
# Intercept bouncer control commands
if msg.command == "PRIVMSG" and len(msg.params) >= 2:
target = msg.params[0].lower()
log.debug("PRIVMSG target=%r params=%r", msg.params[0], msg.params)
if target in ("*bouncer", "bouncer"):
await self._handle_bouncer_command(msg.params[1])
return
if msg.command in FORWARD_COMMANDS:
await self._router.route_client_message(msg)
async def _handle_registration(self, msg: IRCMessage) -> None:
"""Handle PASS/NICK/USER registration sequence."""
@@ -137,17 +161,11 @@ class Client:
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 = ""
"""Validate credentials and attach to all networks."""
password = self._pass_raw if self._got_pass else ""
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)")
if not password:
self._send_error("Password required (PASS <password>)")
self._writer.close()
return
@@ -156,46 +174,26 @@ class Client:
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
# Attach to all networks at once
await self._router.attach_all(self)
log.info(
"client %s authenticated for network %s (nick=%s)",
self._addr, network_name, self._nick,
)
log.info("client %s authenticated (nick=%s)", self._addr, 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
"""Send IRC welcome sequence and channel state from all networks."""
server_name = "bouncer"
nick = self._network.nick
nick = self._nick
self_prefix = f"{nick}!{self._user}@bouncer"
networks = ", ".join(self._router.network_names())
self._send_msg(IRCMessage(
command=RPL_WELCOME, prefix=server_name,
params=[nick, f"Welcome to bouncer ({self._network_name})"],
params=[nick, f"Welcome to bouncer (networks: {networks})"],
))
self._send_msg(IRCMessage(
command=RPL_YOURHOST, prefix=server_name,
@@ -210,29 +208,51 @@ class Client:
params=[nick, server_name, "bouncer-0.1", "o", "o"],
))
# Send channel state for joined channels
for channel in self._network.channels:
# Send namespaced channel state from every network
for net_name, network in sorted(self._router.networks.items()):
for channel in sorted(network.channels):
ns_channel = encode_channel(channel, net_name)
# Synthetic JOIN from client's own nick so irssi opens the window
self._send_msg(IRCMessage(
command="JOIN",
params=[ns_channel],
prefix=self_prefix,
))
# Topic
topic = self._network.topics.get(channel, "")
topic = network.topics.get(channel, "")
if topic:
self._send_msg(IRCMessage(
command=RPL_TOPIC, prefix=server_name,
params=[nick, channel, topic],
params=[nick, ns_channel, topic],
))
# Names
names = self._network.names.get(channel, set())
names = 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],
params=[nick, "=", ns_channel, name_str],
))
self._send_msg(IRCMessage(
command=RPL_ENDOFNAMES, prefix=server_name,
params=[nick, channel, "End of /NAMES list"],
params=[nick, ns_channel, "End of /NAMES list"],
))
async def _handle_bouncer_command(self, text: str) -> None:
"""Dispatch a bouncer control command and send responses."""
lines = await commands.dispatch(text, self._router, self)
for line in lines:
msg = IRCMessage(
command="NOTICE",
params=[self._nick, line],
prefix="*bouncer!bouncer@bouncer",
)
log.debug("bouncer reply: %r", msg.format())
self._send_msg(msg)
def _send_msg(self, msg: IRCMessage) -> None:
"""Send an IRCMessage to this client."""
self.write(msg.format())
@@ -242,9 +262,8 @@ class Client:
self._send_msg(IRCMessage(command="ERROR", params=[text]))
async def _cleanup(self) -> None:
"""Detach from network and close connection."""
"""Detach from all networks and close connection."""
log.info("client disconnected from %s", self._addr)
if self._network_name:
await self._router.detach(self, self._network_name)
await self._router.detach_all(self)
if not self._writer.is_closing():
self._writer.close()
+777
View File
@@ -0,0 +1,777 @@
"""Bouncer control commands via /msg *bouncer."""
from __future__ import annotations
import asyncio
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING
from bouncer.network import State
if TYPE_CHECKING:
from bouncer.client import Client
from bouncer.router import Router
# Set by __main__.py before entering the event loop
STARTUP_TIME: float = 0.0
CONFIG_PATH: Path | None = None
DATA_DIR: Path | None = None
_COMMANDS: dict[str, str] = {
"HELP": "List available commands",
"STATUS": "Overview of all networks",
"INFO": "Detailed info for a network (INFO <network>)",
"UPTIME": "Bouncer uptime since start",
"NETWORKS": "List configured networks",
"CREDS": "NickServ credential status (CREDS [network])",
"CONNECT": "Start a disconnected network (CONNECT <network>)",
"DISCONNECT": "Stop a network (DISCONNECT <network>)",
"RECONNECT": "Restart a network (RECONNECT <network>)",
"NICK": "Change nick on a network (NICK <network> <nick>)",
"RAW": "Send raw IRC command (RAW <network> <command>)",
"CHANNELS": "List joined channels (CHANNELS [network])",
"CLIENTS": "List connected bouncer clients",
"BACKLOG": "Message counts and DB size (BACKLOG [network])",
"VERSION": "Bouncer and Python version",
"REHASH": "Reload config, add/remove networks",
"ADDNETWORK": "Create network at runtime (ADDNETWORK <name> key=val ...)",
"DELNETWORK": "Remove a network (DELNETWORK <name>)",
"AUTOJOIN": "Modify autojoin list (AUTOJOIN <network> +/-channel)",
"IDENTIFY": "Force NickServ IDENTIFY (IDENTIFY <network>)",
"REGISTER": "Trigger NickServ registration (REGISTER <network>)",
"DROPCREDS": "Delete stored NickServ creds (DROPCREDS <network> [nick])",
"GENCERT": "Generate client cert (GENCERT <network> [nick])",
"CERTFP": "Show cert fingerprints (CERTFP [network])",
"DELCERT": "Delete client cert (DELCERT <network> [nick])",
}
async def dispatch(text: str, router: Router, client: Client) -> list[str]:
"""Parse and execute a bouncer command. Returns response lines."""
parts = text.strip().split(None, 1)
if not parts:
return _cmd_help()
cmd = parts[0].upper()
arg = parts[1].strip() if len(parts) > 1 else ""
if cmd == "HELP":
return _cmd_help()
if cmd == "STATUS":
return _cmd_status(router)
if cmd == "INFO":
return await _cmd_info(router, arg)
if cmd == "UPTIME":
return _cmd_uptime()
if cmd == "NETWORKS":
return _cmd_networks(router)
if cmd == "CREDS":
return await _cmd_creds(router, arg or None)
if cmd == "CONNECT":
return await _cmd_connect(router, arg)
if cmd == "DISCONNECT":
return await _cmd_disconnect(router, arg)
if cmd == "RECONNECT":
return await _cmd_reconnect(router, arg)
if cmd == "NICK":
return await _cmd_nick(router, arg)
if cmd == "RAW":
return await _cmd_raw(router, arg)
if cmd == "CHANNELS":
return _cmd_channels(router, arg or None)
if cmd == "CLIENTS":
return _cmd_clients(router)
if cmd == "BACKLOG":
return await _cmd_backlog(router, arg or None)
if cmd == "VERSION":
return _cmd_version()
if cmd == "REHASH":
return await _cmd_rehash(router)
if cmd == "ADDNETWORK":
return await _cmd_addnetwork(router, arg)
if cmd == "DELNETWORK":
return await _cmd_delnetwork(router, arg)
if cmd == "AUTOJOIN":
return _cmd_autojoin(router, arg)
if cmd == "IDENTIFY":
return await _cmd_identify(router, arg)
if cmd == "REGISTER":
return await _cmd_register(router, arg)
if cmd == "DROPCREDS":
return await _cmd_dropcreds(router, arg)
if cmd == "GENCERT":
return await _cmd_gencert(router, arg)
if cmd == "CERTFP":
return _cmd_certfp(router, arg or None)
if cmd == "DELCERT":
return _cmd_delcert(router, arg)
return [f"Unknown command: {cmd}", "Use HELP for available commands."]
def _cmd_help() -> list[str]:
"""List available commands."""
lines = ["[HELP]"]
# Align command names
width = max(len(c) for c in _COMMANDS)
for cmd, desc in _COMMANDS.items():
lines.append(f" {cmd:<{width}} {desc}")
return lines
def _state_label(state: State) -> str:
"""Human-readable state label."""
return {
State.DISCONNECTED: "disconnected",
State.CONNECTING: "connecting",
State.REGISTERING: "registering",
State.PROBATION: "probation",
State.READY: "ready",
}.get(state, str(state))
def _cmd_status(router: Router) -> list[str]:
"""Overview: state, nick, host per network."""
lines = ["[STATUS]"]
if not router.networks:
lines.append(" (no networks configured)")
return lines
# Calculate column widths
name_w = max(len(n) for n in router.networks)
state_w = max(len(_state_label(net.state)) for net in router.networks.values())
for name in sorted(router.networks):
net = router.networks[name]
label = _state_label(net.state)
if net.state == State.READY:
host = net.visible_host or "--"
lines.append(f" {name:<{name_w}} {label:<{state_w}} {net.nick:<14} {host}")
elif net.state == State.CONNECTING:
attempt = net._reconnect_attempt
lines.append(f" {name:<{name_w}} {label} (attempt {attempt})")
else:
lines.append(f" {name:<{name_w}} {label}")
return lines
async def _cmd_info(router: Router, network_name: str) -> list[str]:
"""Detailed info for a single network."""
if not network_name:
return ["Usage: INFO <network>"]
net = router.get_network(network_name.lower())
if not net:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
lines = [f"[INFO] {net.cfg.name}"]
lines.append(f" State {_state_label(net.state)}")
tls_label = "yes" if net.cfg.tls else "no"
lines.append(f" Server {net.cfg.host}:{net.cfg.port} (tls={tls_label})")
lines.append(f" Nick {net.nick}")
lines.append(f" Host {net.visible_host or '--'}")
if net.channels:
lines.append(f" Channels {', '.join(sorted(net.channels))}")
else:
lines.append(" Channels (none)")
if router.backlog:
rows = await router.backlog.list_nickserv_creds(net.cfg.name)
if rows:
_net, nick, email, _host, _ts, status = rows[0]
lines.append(f" NickServ {nick} ({status})")
else:
lines.append(" NickServ (no credentials)")
return lines
def _cmd_uptime() -> list[str]:
"""Bouncer uptime since process start."""
if not STARTUP_TIME:
return ["[UPTIME] unknown"]
elapsed = time.time() - STARTUP_TIME
days, rem = divmod(int(elapsed), 86400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
parts = []
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
parts.append(f"{secs}s")
return [f"[UPTIME] {' '.join(parts)}"]
def _cmd_networks(router: Router) -> list[str]:
"""List all configured networks."""
lines = ["[NETWORKS]"]
if not router.networks:
lines.append(" (none)")
return lines
for name in sorted(router.networks):
net = router.networks[name]
label = _state_label(net.state)
lines.append(f" {name} {label}")
return lines
async def _cmd_creds(router: Router, network_name: str | None) -> list[str]:
"""Show stored NickServ credentials and their status."""
if not router.backlog:
return ["[CREDS] backlog not available"]
net_filter = network_name.lower() if network_name else None
if net_filter and net_filter not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
rows = await router.backlog.list_nickserv_creds(net_filter)
if not rows:
scope = net_filter or "any network"
return [f"[CREDS] no stored credentials for {scope}"]
lines = ["[CREDS]"]
for net, nick, email, host, registered_at, status in rows:
indicator = "+" if status == "verified" else "~"
email_display = email if email else "--"
lines.append(f" {indicator} {net} {nick} {status} {email_display}")
lines.append("")
lines.append(" + verified ~ pending")
return lines
# --- Network Control ---
def _resolve_network(router: Router, name: str) -> tuple[object | None, list[str] | None]:
"""Look up a network by name. Returns (network, None) or (None, error_lines)."""
if not name:
return None, ["Usage: provide a network name"]
net = router.get_network(name.lower())
if not net:
names = ", ".join(sorted(router.networks))
return None, [f"Unknown network: {name}", f"Available: {names}"]
return net, None
async def _cmd_connect(router: Router, arg: str) -> list[str]:
"""Start a disconnected network."""
net, err = _resolve_network(router, arg)
if err:
return err
if net.state != State.DISCONNECTED:
return [f"{net.cfg.name} is already {_state_label(net.state)}"]
asyncio.create_task(net.start())
return [f"[CONNECT] {net.cfg.name} starting"]
async def _cmd_disconnect(router: Router, arg: str) -> list[str]:
"""Stop a network."""
net, err = _resolve_network(router, arg)
if err:
return err
if net.state == State.DISCONNECTED:
return [f"{net.cfg.name} is already disconnected"]
await net.stop()
return [f"[DISCONNECT] {net.cfg.name} stopped"]
async def _cmd_reconnect(router: Router, arg: str) -> list[str]:
"""Stop and restart a network with a fresh identity."""
net, err = _resolve_network(router, arg)
if err:
return err
await net.stop()
asyncio.create_task(net.start())
return [f"[RECONNECT] {net.cfg.name} restarting"]
async def _cmd_nick(router: Router, arg: str) -> list[str]:
"""Change nick on a network."""
parts = arg.split(None, 1)
if len(parts) < 2:
return ["Usage: NICK <network> <nick>"]
net, err = _resolve_network(router, parts[0])
if err:
return err
new_nick = parts[1]
if not net.connected:
return [f"{net.cfg.name} is not connected"]
await net.send_raw("NICK", new_nick)
return [f"[NICK] {net.cfg.name} changing nick to {new_nick}"]
async def _cmd_raw(router: Router, arg: str) -> list[str]:
"""Send a raw IRC command to a network."""
parts = arg.split(None, 1)
if len(parts) < 2:
return ["Usage: RAW <network> <irc command>"]
net, err = _resolve_network(router, parts[0])
if err:
return err
if not net.connected:
return [f"{net.cfg.name} is not connected"]
from bouncer.irc import parse
try:
msg = parse(parts[1].encode())
except Exception as exc:
return [f"Parse error: {exc}"]
await net.send(msg)
return [f"[RAW] {net.cfg.name} sent: {parts[1]}"]
# --- Visibility ---
def _cmd_channels(router: Router, network_name: str | None) -> list[str]:
"""List joined channels, optionally filtered by network."""
lines = ["[CHANNELS]"]
targets: dict[str, object] = {}
if network_name:
net = router.get_network(network_name.lower())
if not net:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
targets[net.cfg.name] = net
else:
targets = dict(sorted(router.networks.items()))
for name, net in targets.items():
if not net.channels:
lines.append(f" {name}: (none)")
else:
for ch in sorted(net.channels):
topic = net.topics.get(ch, "")
suffix = f" {topic}" if topic else ""
lines.append(f" {name} {ch}{suffix}")
if len(lines) == 1:
lines.append(" (no channels)")
return lines
def _cmd_clients(router: Router) -> list[str]:
"""List connected bouncer clients."""
lines = ["[CLIENTS]"]
if not router.clients:
lines.append(" (none)")
return lines
for client in router.clients:
addr = f"{client._addr[0]}:{client._addr[1]}"
elapsed = int(time.time() - client._connected_at)
dur = _format_duration(elapsed)
lines.append(f" {client.nick} {addr} connected {dur}")
return lines
def _format_duration(seconds: int) -> str:
"""Format seconds into a compact duration string."""
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
parts = []
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
parts.append(f"{secs}s")
return " ".join(parts)
async def _cmd_backlog(router: Router, network_name: str | None) -> list[str]:
"""Show message counts per network and database size."""
if not router.backlog:
return ["[BACKLOG] backlog not available"]
net_filter = network_name.lower() if network_name else None
if net_filter and net_filter not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
rows = await router.backlog.stats(net_filter)
db_bytes = await router.backlog.db_size()
lines = ["[BACKLOG]"]
if rows:
name_w = max(len(r[0]) for r in rows)
for net, count in rows:
lines.append(f" {net:<{name_w}} {count:,} messages")
else:
lines.append(" (no messages)")
if db_bytes >= 1_048_576:
size_str = f"{db_bytes / 1_048_576:.1f} MB"
elif db_bytes >= 1024:
size_str = f"{db_bytes / 1024:.1f} KB"
else:
size_str = f"{db_bytes} B"
lines.append(f" DB size: {size_str}")
return lines
def _cmd_version() -> list[str]:
"""Show bouncer and Python version."""
from bouncer import __version__
return [f"[VERSION] bouncer {__version__} / Python {sys.version.split()[0]}"]
# --- Config Management ---
async def _cmd_rehash(router: Router) -> list[str]:
"""Reload config, add/remove networks (proxy/bind unchanged)."""
if not CONFIG_PATH:
return ["[REHASH] config path not set"]
from bouncer.config import load
try:
new_cfg = load(CONFIG_PATH)
except Exception as exc:
return [f"[REHASH] config error: {exc}"]
old_names = set(router.networks.keys())
new_names = set(new_cfg.networks.keys())
added = new_names - old_names
removed = old_names - new_names
kept = old_names & new_names
lines = ["[REHASH]"]
# Remove networks no longer in config
for name in sorted(removed):
await router.remove_network(name)
lines.append(f" removed: {name}")
# Add new networks
for name in sorted(added):
await router.add_network(new_cfg.networks[name])
lines.append(f" added: {name}")
# Check for changed networks (host/port/tls differ)
for name in sorted(kept):
old_net = router.networks[name]
new_net_cfg = new_cfg.networks[name]
if (old_net.cfg.host != new_net_cfg.host
or old_net.cfg.port != new_net_cfg.port
or old_net.cfg.tls != new_net_cfg.tls
or old_net.cfg.proxy_host != new_net_cfg.proxy_host
or old_net.cfg.proxy_port != new_net_cfg.proxy_port):
await router.remove_network(name)
await router.add_network(new_net_cfg)
lines.append(f" reconnected: {name}")
else:
# Update mutable config fields
old_net.cfg.channels = new_net_cfg.channels
old_net.cfg.nick = new_net_cfg.nick
old_net.cfg.password = new_net_cfg.password
lines.append(f" unchanged: {name}")
router.config = new_cfg
lines.append(f" {len(new_cfg.networks)} network(s) loaded")
return lines
async def _cmd_addnetwork(router: Router, arg: str) -> list[str]:
"""Create a network at runtime from key=value pairs."""
from bouncer.config import NetworkConfig
parts = arg.split()
if not parts:
return ["Usage: ADDNETWORK <name> host=<host> [port=N] [tls=yes|no]",
" [nick=N] [channels=#a,#b] [password=P]"]
name = parts[0].lower()
if "/" in name:
return ["Network name must not contain '/'"]
if name in router.networks:
return [f"Network {name} already exists"]
kvs: dict[str, str] = {}
for part in parts[1:]:
if "=" not in part:
return [f"Invalid key=value: {part}"]
k, v = part.split("=", 1)
kvs[k.lower()] = v
if "host" not in kvs:
return ["Required: host=<hostname>"]
tls = kvs.get("tls", "no").lower() in ("yes", "true", "1")
default_port = 6697 if tls else 6667
port = int(kvs.get("port", str(default_port)))
channels = kvs.get("channels", "").split(",") if kvs.get("channels") else []
cfg = NetworkConfig(
name=name,
host=kvs["host"],
port=port,
tls=tls,
nick=kvs.get("nick", ""),
channels=channels,
password=kvs.get("password"),
)
await router.add_network(cfg)
return [f"[ADDNETWORK] {name} created ({cfg.host}:{cfg.port}, tls={'yes' if tls else 'no'})"]
async def _cmd_delnetwork(router: Router, arg: str) -> list[str]:
"""Stop and remove a network."""
if not arg:
return ["Usage: DELNETWORK <name>"]
name = arg.strip().lower()
if name not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {name}", f"Available: {names}"]
await router.remove_network(name)
return [f"[DELNETWORK] {name} removed"]
def _cmd_autojoin(router: Router, arg: str) -> list[str]:
"""Add or remove a channel from a network's autojoin list."""
parts = arg.split(None, 1)
if len(parts) < 2:
return ["Usage: AUTOJOIN <network> +#channel | -#channel"]
net, err = _resolve_network(router, parts[0])
if err:
return err
spec = parts[1].strip()
if not spec or spec[0] not in ("+", "-"):
return ["Channel must start with + (add) or - (remove)"]
action = spec[0]
channel = spec[1:]
if not channel:
return ["Channel name required after +/-"]
lines = [f"[AUTOJOIN] {net.cfg.name}"]
if action == "+":
if channel not in net.cfg.channels:
net.cfg.channels.append(channel)
lines.append(f" added: {channel}")
# Join immediately if network is ready
if net.ready:
asyncio.create_task(net.send_raw("JOIN", channel))
lines.append(f" joining {channel}")
else:
try:
net.cfg.channels.remove(channel)
lines.append(f" removed: {channel}")
except ValueError:
lines.append(f" {channel} not in autojoin list")
lines.append(f" autojoin: {', '.join(net.cfg.channels) or '(empty)'}")
return lines
# --- NickServ ---
async def _cmd_identify(router: Router, arg: str) -> list[str]:
"""Force NickServ IDENTIFY with stored credentials."""
net, err = _resolve_network(router, arg)
if err:
return err
if not net.connected:
return [f"{net.cfg.name} is not connected"]
if not router.backlog:
return ["Backlog not available"]
creds = await router.backlog.get_nickserv_creds_by_network(net.cfg.name)
if not creds:
return [f"No stored credentials for {net.cfg.name}"]
stored_nick, stored_pass = creds
lines = [f"[IDENTIFY] {net.cfg.name}"]
# Switch nick if needed
if net.nick != stored_nick:
await net.send_raw("NICK", stored_nick)
lines.append(f" switching nick to {stored_nick}")
await net.send_raw("PRIVMSG", "NickServ", f"IDENTIFY {stored_pass}")
lines.append(f" sent IDENTIFY as {stored_nick}")
return lines
async def _cmd_register(router: Router, arg: str) -> list[str]:
"""Trigger NickServ registration on a network."""
net, err = _resolve_network(router, arg)
if err:
return err
if not net.ready:
return [f"{net.cfg.name} is not ready (state: {_state_label(net.state)})"]
asyncio.create_task(net._nickserv_register())
return [f"[REGISTER] {net.cfg.name} registration started"]
async def _cmd_dropcreds(router: Router, arg: str) -> list[str]:
"""Delete stored NickServ credentials."""
if not router.backlog:
return ["Backlog not available"]
parts = arg.split(None, 1)
if not parts:
return ["Usage: DROPCREDS <network> [nick]"]
net_name = parts[0].lower()
if net_name not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {net_name}", f"Available: {names}"]
lines = [f"[DROPCREDS] {net_name}"]
if len(parts) >= 2:
# Delete specific nick
nick = parts[1]
await router.backlog.delete_nickserv_creds(net_name, nick)
lines.append(f" deleted: {nick}")
else:
# Delete all creds for this network
rows = await router.backlog.list_nickserv_creds(net_name)
if not rows:
lines.append(" no credentials found")
else:
for _net, nick, *_ in rows:
await router.backlog.delete_nickserv_creds(net_name, nick)
lines.append(f" deleted: {nick}")
return lines
# --- CertFP ---
async def _cmd_gencert(router: Router, arg: str) -> list[str]:
"""Generate a client certificate for CertFP authentication."""
from bouncer.cert import fingerprint, generate_cert
if not DATA_DIR:
return ["[GENCERT] data directory not available"]
parts = arg.split(None, 1)
if not parts:
return ["Usage: GENCERT <network> [nick]"]
net, err = _resolve_network(router, parts[0])
if err:
return err
# Determine nick: explicit arg > current network nick > stored creds
if len(parts) >= 2:
nick = parts[1]
elif net.nick and net.nick != "*":
nick = net.nick
elif router.backlog:
creds = await router.backlog.get_nickserv_creds_by_network(net.cfg.name)
if creds:
nick = creds[0]
else:
return [f"No nick available for {net.cfg.name}",
"Usage: GENCERT <network> <nick>"]
else:
return [f"No nick available for {net.cfg.name}",
"Usage: GENCERT <network> <nick>"]
pem = generate_cert(DATA_DIR, net.cfg.name, nick)
fp = fingerprint(pem)
lines = [f"[GENCERT] {net.cfg.name}/{nick}"]
lines.append(f" fingerprint: {fp}")
lines.append(f" path: {pem}")
# Auto-register with NickServ if connected
if net.ready:
await net.send_raw("PRIVMSG", "NickServ", f"CERT ADD {fp}")
lines.append(" sent: NickServ CERT ADD")
else:
lines.append(" (network not ready, register fingerprint manually)")
lines.append(f" /msg NickServ CERT ADD {fp}")
return lines
def _cmd_certfp(router: Router, network_name: str | None) -> list[str]:
"""List client certificate fingerprints."""
from bouncer.cert import list_certs
if not DATA_DIR:
return ["[CERTFP] data directory not available"]
net_filter = network_name.lower() if network_name else None
if net_filter and net_filter not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
certs = list_certs(DATA_DIR, network=net_filter)
if not certs:
scope = net_filter or "any network"
return [f"[CERTFP] no certificates for {scope}"]
lines = ["[CERTFP]"]
for net, nick, fp in certs:
lines.append(f" {net} {nick} {fp}")
return lines
def _cmd_delcert(router: Router, arg: str) -> list[str]:
"""Delete a client certificate."""
from bouncer.cert import delete_cert
if not DATA_DIR:
return ["[DELCERT] data directory not available"]
parts = arg.split(None, 1)
if not parts:
return ["Usage: DELCERT <network> [nick]"]
net_name = parts[0].lower()
if net_name not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {net_name}", f"Available: {names}"]
net = router.networks[net_name]
if len(parts) >= 2:
nick = parts[1]
elif net.nick and net.nick != "*":
nick = net.nick
else:
return [f"No nick specified for {net_name}",
"Usage: DELCERT <network> <nick>"]
if delete_cert(DATA_DIR, net_name, nick):
return [f"[DELCERT] deleted cert for {net_name}/{nick}"]
else:
return [f"[DELCERT] no cert found for {net_name}/{nick}"]
+12 -1
View File
@@ -43,8 +43,10 @@ class NetworkConfig:
user: str = ""
realname: str = ""
channels: list[str] = field(default_factory=list)
autojoin: bool = True
autojoin: bool = False
password: str | None = None
proxy_host: str | None = None
proxy_port: int | None = None
@dataclass(slots=True)
@@ -100,9 +102,18 @@ def load(path: Path) -> Config:
channels=net_raw.get("channels", []),
autojoin=net_raw.get("autojoin", True),
password=net_raw.get("password"),
proxy_host=net_raw.get("proxy_host"),
proxy_port=net_raw.get("proxy_port"),
)
if not networks:
raise ValueError("at least one network must be configured")
for name in networks:
if "/" in name:
raise ValueError(
f"network name {name!r} must not contain '/' "
"(reserved for namespace separator)"
)
return Config(bouncer=bouncer, proxy=proxy, networks=networks)
+736
View File
@@ -0,0 +1,736 @@
"""Temp email client for NickServ verification.
Supports multiple providers:
- Guerrilla Mail (API) -- sharklasers.com, guerrillamail.com, grr.la, etc.
- Mail.tm / Mail.gw (API) -- domains fetched dynamically
- YOPmail (Playwright) -- yopmail.com, yopmail.fr, yopmail.net
- TrashMailr (Playwright) -- discard.email, discardmail.com, tempr.email
- Temp-Mail.org (Playwright) -- domains fetched dynamically
All HTTP requests go through the SOCKS5 proxy. Proxy circuits may drop at
any time, so every individual request is retried with exponential backoff
and a fresh connector on each attempt.
Playwright providers use the proxy for all browser traffic and retry
individual poll attempts on failure.
"""
from __future__ import annotations
import asyncio
import html
import json
import logging
import re
import aiohttp
from aiohttp_socks import ProxyConnector
log = logging.getLogger(__name__)
POLL_INTERVAL = 15 # seconds between inbox checks
MAX_POLLS = 30 # ~7.5 minutes total
REQUEST_TIMEOUT = 20 # per-request timeout
REQUEST_RETRIES = 4 # retries per API call
RETRY_BACKOFF = [2, 5, 10, 20] # seconds between retries
PW_PAGE_TIMEOUT = 20000 # playwright page load timeout (ms)
# ---------------------------------------------------------------------------
# Domain registry
# ---------------------------------------------------------------------------
# Guerrilla Mail API domains
GUERRILLA_DOMAINS = {
"sharklasers.com", "guerrillamail.com", "grr.la", "guerrillamail.de",
"guerrillamail.net", "guerrillamail.org", "guerrillamailblock.com",
"pokemail.net", "spam4.me",
}
# YOPmail domains (Playwright)
YOPMAIL_DOMAINS = {"yopmail.com", "yopmail.fr", "yopmail.net"}
# TrashMailr / discard.email domains (Playwright)
TRASHMAILR_DOMAINS = {"discard.email", "discardmail.com", "tempr.email"}
# Mail.tm / Mail.gw REST API
MAILTM_APIS = ["https://api.mail.tm", "https://api.mail.gw"]
_mailtm_domains: set[str] = set()
_mailtm_domain_api: dict[str, str] = {} # domain -> api_base
# Temp-Mail.org (Playwright) -- domains fetched at runtime
_tempmail_domains: set[str] = set()
GUERRILLA_API = "https://api.guerrillamail.com/ajax.php"
def get_all_domains() -> list[str]:
"""Return all currently known email domains (static + dynamically fetched)."""
return sorted(
GUERRILLA_DOMAINS | YOPMAIL_DOMAINS | TRASHMAILR_DOMAINS
| _mailtm_domains | _tempmail_domains
)
async def fetch_extra_domains(proxy_host: str, proxy_port: int) -> set[str]:
"""Fetch additional domains from mail.tm/gw APIs.
Returns newly discovered domains and updates the module-level cache.
"""
discovered: set[str] = set()
for api_base in MAILTM_APIS:
data = await _proxy_json(
proxy_host, proxy_port, f"{api_base}/domains", method="GET",
)
if not data:
continue
members = data.get("hydra:member", []) if isinstance(data, dict) else []
for entry in members:
domain = entry.get("domain", "")
if domain and entry.get("isActive", True):
discovered.add(domain)
_mailtm_domain_api[domain] = api_base
if discovered:
_mailtm_domains.update(discovered)
log.info("mail.tm/gw domains: %s", ", ".join(sorted(discovered)))
return discovered
async def verify_email(
email_addr: str,
proxy_host: str = "127.0.0.1",
proxy_port: int = 1080,
) -> VerifyResult | None:
"""Poll temp email provider for a NickServ verification code.
Routes to the correct provider based on email domain.
"""
if "@" not in email_addr:
return None
_, domain = email_addr.rsplit("@", 1)
if domain in GUERRILLA_DOMAINS:
return await _guerrilla_verify(email_addr, proxy_host, proxy_port)
elif domain in YOPMAIL_DOMAINS:
return await _yopmail_verify(email_addr, proxy_host, proxy_port)
elif domain in TRASHMAILR_DOMAINS:
return await _trashmailr_verify(email_addr, proxy_host, proxy_port)
elif domain in _mailtm_domains:
api_base = _mailtm_domain_api.get(domain, MAILTM_APIS[0])
return await _mailtm_verify(email_addr, api_base, proxy_host, proxy_port)
elif domain in _tempmail_domains:
return await _tempmail_verify(email_addr, proxy_host, proxy_port)
else:
log.warning("unsupported email domain: %s", domain)
return None
# ---------------------------------------------------------------------------
# Resilient HTTP helper
# ---------------------------------------------------------------------------
async def _proxy_json(
proxy_host: str, proxy_port: int, url: str, *,
method: str = "GET", params: dict | None = None,
json_body: dict | None = None, headers: dict | None = None,
bearer: str | None = None,
) -> dict | list | None:
"""HTTP request through SOCKS5 proxy with retries and fresh connector.
Returns parsed JSON on success, None on total failure.
"""
for attempt in range(REQUEST_RETRIES):
connector = None
try:
proxy_url = f"socks5://{proxy_host}:{proxy_port}"
connector = ProxyConnector.from_url(proxy_url)
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
hdrs = dict(headers or {})
if bearer:
hdrs["Authorization"] = f"Bearer {bearer}"
async with aiohttp.ClientSession(
connector=connector, timeout=timeout,
) as session:
req_kwargs: dict = {"headers": hdrs}
if params:
req_kwargs["params"] = params
if json_body is not None:
req_kwargs["json"] = json_body
async with session.request(method, url, **req_kwargs) as resp:
raw = await resp.read()
if not raw:
raise aiohttp.ClientError("empty response")
text = raw.decode("utf-8", errors="replace")
if text.startswith("Could not load"):
raise aiohttp.ClientError(f"bad response: {text[:80]}")
return json.loads(raw)
except asyncio.CancelledError:
raise
except Exception:
delay = RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)]
log.debug("request %s failed (attempt %d/%d), retrying in %ds",
url, attempt + 1, REQUEST_RETRIES, delay, exc_info=True)
await asyncio.sleep(delay)
finally:
if connector:
await connector.close()
log.warning("request failed after %d attempts: %s", REQUEST_RETRIES, url)
return None
def _pw_proxy(proxy_host: str, proxy_port: int) -> dict:
"""Build Playwright proxy config."""
return {"server": f"socks5://{proxy_host}:{proxy_port}"}
async def _pw_launch(pw, proxy_host: str, proxy_port: int):
"""Launch a headless Chromium browser with SOCKS5 proxy."""
browser = await pw.chromium.launch(headless=True)
context = await browser.new_context(proxy=_pw_proxy(proxy_host, proxy_port))
page = await context.new_page()
return browser, page
# ---------------------------------------------------------------------------
# Guerrilla Mail (JSON API)
# ---------------------------------------------------------------------------
async def _guerrilla_verify(
email_addr: str, proxy_host: str, proxy_port: int,
) -> VerifyResult | None:
"""Poll guerrillamail API for verification code."""
local, domain = email_addr.rsplit("@", 1)
sid = await _gm_init(proxy_host, proxy_port, local, domain)
if not sid:
log.warning("failed to init guerrillamail session for %s", email_addr)
return None
log.info("polling guerrillamail for %s (sid=%s...)", email_addr, sid[:8])
for attempt in range(MAX_POLLS):
await asyncio.sleep(POLL_INTERVAL)
try:
result = await _gm_check(proxy_host, proxy_port, sid)
if result:
log.info("found verification code for %s: %s", email_addr, result)
return result
log.debug("poll %d/%d: no verification email yet", attempt + 1, MAX_POLLS)
except asyncio.CancelledError:
raise
except Exception:
log.debug("poll %d/%d failed, will retry next cycle",
attempt + 1, MAX_POLLS, exc_info=True)
log.warning("gave up polling guerrillamail for %s", email_addr)
return None
async def _gm_init(
proxy_host: str, proxy_port: int, local: str, domain: str,
) -> str | None:
"""Initialize guerrillamail session and claim address."""
data = await _proxy_json(proxy_host, proxy_port, GUERRILLA_API, params={
"f": "get_email_address", "lang": "en", "site": domain,
})
if not data or not isinstance(data, dict):
return None
sid = data.get("sid_token", "")
if not sid:
return None
data = await _proxy_json(proxy_host, proxy_port, GUERRILLA_API, params={
"f": "set_email_user",
"email_user": local,
"site": domain,
"lang": "en",
"sid_token": sid,
})
if data and isinstance(data, dict):
sid = data.get("sid_token", sid)
claimed = data.get("email_addr", "")
log.debug("claimed email: %s (sid=%s...)", claimed, sid[:8])
return sid
async def _gm_check(
proxy_host: str, proxy_port: int, sid: str,
) -> VerifyResult | None:
"""Check guerrillamail inbox for verification code."""
data = await _proxy_json(proxy_host, proxy_port, GUERRILLA_API, params={
"f": "check_email", "seq": "0", "sid_token": sid,
})
if not data or not isinstance(data, dict):
return None
emails = data.get("list", [])
if not emails:
return None
sid_new = data.get("sid_token", sid)
for email in emails:
subject = email.get("mail_subject", "").lower()
mail_from = email.get("mail_from", "").lower()
if not _is_nickserv_email(subject, mail_from):
continue
mail_id = email.get("mail_id")
if not mail_id:
continue
mail_data = await _proxy_json(proxy_host, proxy_port, GUERRILLA_API, params={
"f": "fetch_email", "email_id": mail_id, "sid_token": sid_new,
})
if not mail_data or not isinstance(mail_data, dict):
continue
body = mail_data.get("mail_body", "")
result = extract_code(body)
if result:
return result
return None
# ---------------------------------------------------------------------------
# Mail.tm / Mail.gw (REST API)
# ---------------------------------------------------------------------------
async def _mailtm_verify(
email_addr: str, api_base: str, proxy_host: str, proxy_port: int,
) -> VerifyResult | None:
"""Poll mail.tm/gw API for verification code."""
local, domain = email_addr.rsplit("@", 1)
password = f"{local}Pass1!" # mail.tm requires 6+ chars
# Create account
data = await _proxy_json(
proxy_host, proxy_port, f"{api_base}/accounts",
method="POST",
json_body={"address": email_addr, "password": password},
)
if not data or not isinstance(data, dict) or not data.get("id"):
log.warning("failed to create mail.tm account for %s", email_addr)
return None
# Get auth token
token_data = await _proxy_json(
proxy_host, proxy_port, f"{api_base}/token",
method="POST",
json_body={"address": email_addr, "password": password},
)
if not token_data or not isinstance(token_data, dict):
log.warning("failed to get mail.tm token for %s", email_addr)
return None
token = token_data.get("token", "")
if not token:
return None
log.info("polling mail.tm for %s (api=%s)", email_addr, api_base)
for attempt in range(MAX_POLLS):
await asyncio.sleep(POLL_INTERVAL)
try:
msgs = await _proxy_json(
proxy_host, proxy_port, f"{api_base}/messages",
bearer=token,
)
if not msgs or not isinstance(msgs, dict):
continue
for msg in msgs.get("hydra:member", []):
subject = msg.get("subject", "").lower()
from_addr = ""
if msg.get("from"):
from_addr = msg["from"].get("address", "").lower()
if not _is_nickserv_email(subject, from_addr):
continue
msg_id = msg.get("id")
if not msg_id:
continue
detail = await _proxy_json(
proxy_host, proxy_port, f"{api_base}/messages/{msg_id}",
bearer=token,
)
if not detail or not isinstance(detail, dict):
continue
body = detail.get("html", "") or detail.get("text", "") or ""
result = extract_code(body)
if result:
log.info("found verification code for %s: %s", email_addr, result)
return result
log.debug("poll %d/%d: no verification email yet", attempt + 1, MAX_POLLS)
except asyncio.CancelledError:
raise
except Exception:
log.debug("mail.tm poll %d/%d failed", attempt + 1, MAX_POLLS, exc_info=True)
log.warning("gave up polling mail.tm for %s", email_addr)
return None
# ---------------------------------------------------------------------------
# YOPmail (Playwright)
# ---------------------------------------------------------------------------
async def _yopmail_verify(
email_addr: str, proxy_host: str, proxy_port: int,
) -> VerifyResult | None:
"""Poll yopmail via Playwright for verification code."""
try:
from playwright.async_api import async_playwright
except ImportError:
log.error("playwright not installed, cannot check yopmail")
return None
local = email_addr.rsplit("@", 1)[0]
try:
async with async_playwright() as p:
browser, page = await _pw_launch(p, proxy_host, proxy_port)
log.info("polling yopmail for %s", email_addr)
for attempt in range(MAX_POLLS):
await asyncio.sleep(POLL_INTERVAL)
try:
await page.goto(
f"https://yopmail.com/en/?login={local}",
wait_until="networkidle",
timeout=PW_PAGE_TIMEOUT,
)
inbox_frame = page.frame("ifinbox")
if not inbox_frame:
continue
first_mail = inbox_frame.locator(".m")
if await first_mail.count() == 0:
log.debug("poll %d/%d: yopmail inbox empty", attempt + 1, MAX_POLLS)
continue
await first_mail.first.click()
await asyncio.sleep(2)
mail_frame = page.frame("ifmail")
if not mail_frame:
continue
body = await mail_frame.locator("body").inner_html()
result = extract_code(body)
if result:
log.info("found verification code for %s: %s", email_addr, result)
await browser.close()
return result
except Exception:
log.debug("yopmail poll %d failed", attempt + 1, exc_info=True)
log.warning("gave up polling yopmail for %s", email_addr)
await browser.close()
return None
except asyncio.CancelledError:
raise
except Exception:
log.exception("yopmail verification failed for %s", email_addr)
return None
# ---------------------------------------------------------------------------
# TrashMailr / discard.email (Playwright)
# ---------------------------------------------------------------------------
async def _trashmailr_verify(
email_addr: str, proxy_host: str, proxy_port: int,
) -> VerifyResult | None:
"""Poll trashmailr.com via Playwright for verification code.
Works for discard.email, discardmail.com, tempr.email domains.
"""
try:
from playwright.async_api import async_playwright
except ImportError:
log.error("playwright not installed, cannot check trashmailr")
return None
local, domain = email_addr.rsplit("@", 1)
try:
async with async_playwright() as p:
browser, page = await _pw_launch(p, proxy_host, proxy_port)
log.info("polling trashmailr for %s", email_addr)
for attempt in range(MAX_POLLS):
await asyncio.sleep(POLL_INTERVAL)
try:
# Navigate to inbox list for this address
url = f"https://trashmailr.com/inbox/list.htm?mailAddress={local}@{domain}"
await page.goto(url, wait_until="networkidle", timeout=PW_PAGE_TIMEOUT)
# Wait for mail list to render
try:
await page.wait_for_selector(
".mailList, .mail-list, .noMails, .no-mails",
timeout=5000,
)
except Exception:
pass
# Check for email rows -- trashmailr uses table rows or divs
rows = page.locator(".mailList tr, .mail-item, [data-mail-id]")
count = await rows.count()
if count == 0:
log.debug("poll %d/%d: trashmailr inbox empty", attempt + 1, MAX_POLLS)
continue
# Check each email
for i in range(count):
row = rows.nth(i)
row_text = (await row.inner_text()).lower()
if not _is_nickserv_email(row_text, ""):
continue
# Click to open the email
await row.click()
await asyncio.sleep(2)
# Read the email body
body_el = page.locator(".mailBody, .mail-body, .mailContent, .mail-content")
if await body_el.count() > 0:
body = await body_el.first.inner_html()
result = extract_code(body)
if result:
log.info("found verification code for %s: %s", email_addr, result)
await browser.close()
return result
# Also try the full page body as fallback
body = await page.locator("body").inner_html()
result = extract_code(body)
if result:
log.info("found verification code for %s: %s", email_addr, result)
await browser.close()
return result
except Exception:
log.debug("trashmailr poll %d failed", attempt + 1, exc_info=True)
log.warning("gave up polling trashmailr for %s", email_addr)
await browser.close()
return None
except asyncio.CancelledError:
raise
except Exception:
log.exception("trashmailr verification failed for %s", email_addr)
return None
# ---------------------------------------------------------------------------
# Temp-Mail.org (Playwright)
# ---------------------------------------------------------------------------
async def _tempmail_verify(
email_addr: str, proxy_host: str, proxy_port: int,
) -> VerifyResult | None:
"""Poll temp-mail.org via Playwright for verification code."""
try:
from playwright.async_api import async_playwright
except ImportError:
log.error("playwright not installed, cannot check temp-mail.org")
return None
local, domain = email_addr.rsplit("@", 1)
try:
async with async_playwright() as p:
browser, page = await _pw_launch(p, proxy_host, proxy_port)
log.info("polling temp-mail.org for %s", email_addr)
# Set the email address
await page.goto("https://temp-mail.org/en/",
wait_until="networkidle", timeout=PW_PAGE_TIMEOUT)
# Try to set custom address via the change button
change_btn = page.locator("#click-to-edit, .click-to-edit, [data-clipboard-action]")
if await change_btn.count() > 0:
await change_btn.first.click()
await asyncio.sleep(1)
input_field = page.locator("#mail-input, input[name='mail']")
if await input_field.count() > 0:
await input_field.first.fill(local)
# Select domain from dropdown if available
domain_select = page.locator("select, .domain-select")
if await domain_select.count() > 0:
await domain_select.first.select_option(label=domain)
save_btn = page.locator("#save, .save-btn, button[type='submit']")
if await save_btn.count() > 0:
await save_btn.first.click()
await asyncio.sleep(2)
for attempt in range(MAX_POLLS):
await asyncio.sleep(POLL_INTERVAL)
try:
# Refresh the inbox
refresh_btn = page.locator("#refresh, .refresh, [data-type='refresh']")
if await refresh_btn.count() > 0:
await refresh_btn.first.click()
await asyncio.sleep(3)
else:
await page.reload(wait_until="networkidle", timeout=PW_PAGE_TIMEOUT)
# Check for emails in the inbox
mail_items = page.locator(".inbox-dataList li, .mail-item, .message-list-item")
count = await mail_items.count()
if count == 0:
log.debug("poll %d/%d: temp-mail inbox empty", attempt + 1, MAX_POLLS)
continue
for i in range(count):
item = mail_items.nth(i)
item_text = (await item.inner_text()).lower()
if not _is_nickserv_email(item_text, ""):
continue
await item.click()
await asyncio.sleep(2)
# Read email body
body_el = page.locator(".inbox-data-content, .mail-text, .message-body")
if await body_el.count() > 0:
body = await body_el.first.inner_html()
result = extract_code(body)
if result:
log.info("found verification code for %s: %s", email_addr, result)
await browser.close()
return result
except Exception:
log.debug("temp-mail poll %d failed", attempt + 1, exc_info=True)
log.warning("gave up polling temp-mail.org for %s", email_addr)
await browser.close()
return None
except asyncio.CancelledError:
raise
except Exception:
log.exception("temp-mail.org verification failed for %s", email_addr)
return None
async def fetch_tempmail_domains(proxy_host: str, proxy_port: int) -> set[str]:
"""Fetch available domains from temp-mail.org via Playwright.
Updates the module-level _tempmail_domains cache.
"""
try:
from playwright.async_api import async_playwright
except ImportError:
return set()
discovered: set[str] = set()
try:
async with async_playwright() as p:
browser, page = await _pw_launch(p, proxy_host, proxy_port)
await page.goto("https://temp-mail.org/en/",
wait_until="networkidle", timeout=PW_PAGE_TIMEOUT)
# The domain is shown in the email display or a dropdown
# Try to find domain options
options = page.locator("select option, .domain-item")
count = await options.count()
for i in range(count):
text = (await options.nth(i).inner_text()).strip()
if "." in text and "@" not in text:
# Clean up domain (remove leading @)
domain = text.lstrip("@").strip()
if domain:
discovered.add(domain)
# Also try to read the current email address for its domain
addr_el = page.locator("#mail-address, .mail-address, #click-to-copy")
if await addr_el.count() > 0:
addr_text = (await addr_el.first.inner_text()).strip()
if "@" in addr_text:
domain = addr_text.rsplit("@", 1)[1]
discovered.add(domain)
await browser.close()
except Exception:
log.debug("failed to fetch temp-mail.org domains", exc_info=True)
if discovered:
_tempmail_domains.update(discovered)
log.info("temp-mail.org domains: %s", ", ".join(sorted(discovered)))
return discovered
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _is_nickserv_email(subject: str, mail_from: str) -> bool:
"""Check if an email looks like a NickServ verification."""
combined = subject + " " + mail_from
return any(kw in combined for kw in (
"nickserv", "verify", "registration", "confirm", "activate",
))
class VerifyResult:
"""Parsed verification code with the command format to use."""
__slots__ = ("code", "style")
def __init__(self, code: str, style: str) -> None:
self.code = code
self.style = style # "atheme" or "anope"
def __repr__(self) -> str:
return f"VerifyResult({self.code!r}, {self.style!r})"
def extract_code(body: str) -> VerifyResult | None:
"""Extract NickServ verification code from email body.
Returns a VerifyResult with the code and which style to use:
- atheme: /msg NickServ VERIFY REGISTER nick code
- anope: /msg NickServ CONFIRM code
"""
text = html.unescape(body)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text)
# Atheme-style: VERIFY REGISTER <nick> <code>
m = re.search(r"VERIFY\s+REGISTER\s+\S+\s+(\S+)", text, re.IGNORECASE)
if m:
return VerifyResult(m.group(1), "atheme")
# Anope-style: CONFIRM <code>
m = re.search(r"CONFIRM\s+(\S+)", text, re.IGNORECASE)
if m:
return VerifyResult(m.group(1), "anope")
# Generic: verification code is/: <code>
m = re.search(
r"(?:verification|confirm|activation)\s+(?:code|token)[:\s]+(\S+)",
text, re.IGNORECASE,
)
if m:
return VerifyResult(m.group(1), "anope")
return None
+134
View File
@@ -0,0 +1,134 @@
"""Network namespace encoding/decoding for multi-network multiplexing.
Channels and nicks are suffixed with /network so a single client connection
can see traffic from all networks at once.
Client sees: Wire:
#libera/libera <-> #libera (on libera network)
user123/libera <-> user123 (on libera network)
"""
from __future__ import annotations
from bouncer.irc import IRCMessage, parse_prefix
SEPARATOR = "/"
def encode_channel(channel: str, network: str) -> str:
"""Suffix a channel with its network name. ``#ch`` -> ``#ch/net``."""
return f"{channel}{SEPARATOR}{network}"
def decode_channel(namespaced: str) -> tuple[str, str | None]:
"""Split ``#ch/net`` -> ``('#ch', 'net')``. No separator returns ``(channel, None)``."""
idx = namespaced.rfind(SEPARATOR)
if idx < 0:
return namespaced, None
return namespaced[:idx], namespaced[idx + 1:]
def encode_nick(
nick: str,
network: str,
own_nicks: dict[str, str],
client_nick: str | None = None,
) -> str:
"""Suffix a nick unless it belongs to us on *any* network.
If *client_nick* is set, own nicks are rewritten to match the client's
registered nick so IRC clients recognise them as "self".
"""
if nick in own_nicks.values():
return client_nick if client_nick else nick
return f"{nick}{SEPARATOR}{network}"
def encode_prefix(
prefix: str,
network: str,
own_nicks: dict[str, str],
client_nick: str | None = None,
) -> str:
"""Namespace the nick portion of ``nick!user@host``."""
nick, user, host = parse_prefix(prefix)
enc = encode_nick(nick, network, own_nicks, client_nick=client_nick)
if user and host:
return f"{enc}!{user}@{host}"
if user:
return f"{enc}!{user}"
if host:
return f"{enc}@{host}"
return enc
def decode_target(target: str) -> tuple[str, str | None]:
"""Decode a namespaced target (channel or nick).
``#ch/net`` -> ``('#ch', 'net')``
``nick/net`` -> ``('nick', 'net')``
``#ch`` -> ``('#ch', None)``
"""
idx = target.rfind(SEPARATOR)
if idx < 0:
return target, None
return target[:idx], target[idx + 1:]
def _is_channel(target: str) -> bool:
"""Check if a target looks like a channel name."""
return target.startswith(("#", "&", "+", "!"))
def encode_message(
msg: IRCMessage,
network: str,
own_nicks: dict[str, str],
client_nick: str | None = None,
) -> IRCMessage:
"""Namespace an entire IRC message for delivery to a client.
- Channels in params get ``/network`` suffix
- Prefix nick gets ``/network`` suffix (unless it's our own)
- Own nicks rewritten to *client_nick* when set
- Special handling for 353 (NAMREPLY nick list) and NICK (new nick in trailing)
"""
prefix = msg.prefix
if prefix:
prefix = encode_prefix(prefix, network, own_nicks, client_nick=client_nick)
params = list(msg.params)
if msg.command == "353" and len(params) >= 4:
# RPL_NAMREPLY: params = [nick, "=", #channel, "nick1 @nick2 +nick3"]
params[2] = encode_channel(params[2], network)
nicks = params[3].split()
encoded = []
for n in nicks:
# Strip mode prefixes (@, +, %, ~, &)
mode_prefix = ""
bare = n
while bare and bare[0] in "@+%~&":
mode_prefix += bare[0]
bare = bare[1:]
encoded.append(mode_prefix + encode_nick(
bare, network, own_nicks, client_nick=client_nick,
))
params[3] = " ".join(encoded)
elif msg.command == "NICK" and params:
# NICK: params[0] is the new nick
new_nick = params[0]
params[0] = encode_nick(new_nick, network, own_nicks, client_nick=client_nick)
else:
# Generic: namespace channel-like params[0]
if params and _is_channel(params[0]):
params[0] = encode_channel(params[0], network)
return IRCMessage(
command=msg.command,
params=params,
prefix=prefix,
tags=msg.tags,
)
+553 -48
View File
@@ -3,19 +3,23 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import random
from enum import Enum, auto
from pathlib import Path
from typing import Callable
from bouncer.backlog import Backlog
from bouncer.config import NetworkConfig, ProxyConfig
from bouncer.email import fetch_extra_domains, get_all_domains, verify_email
from bouncer.irc import IRCMessage, parse
log = logging.getLogger(__name__)
BACKOFF_STEPS = [5, 10, 30, 60, 120, 300]
PROBATION_SECONDS = 15
PROBATION_SECONDS = 45
class State(Enum):
@@ -99,20 +103,24 @@ def _random_nick() -> str:
return base
_GENERIC_IDENTS = ["user", "ident"]
_GENERIC_REALNAMES = ["realname", "unknown"]
_GENERIC_IDENTS = [
"user", "me", "usr", "x", "i", "u", "id", "anon",
"client", "irc", "chat", "net", "null", "void",
]
_GENERIC_REALNAMES = [
"...", "-", ".", "New User", "realname", "me",
"IRC User", "Unknown", "Anonymous", "user",
]
def _email_domains() -> list[str]:
"""Return all currently available email domains."""
return get_all_domains()
def _nick_for_host(host: str) -> str:
"""Generate a deterministic pronounceable nick from a hostname.
The same hostname always produces the same nick. Uses the host string
as a seed for the markov generator so nicks are stable across reconnects
to known endpoints.
"""
seed = int(hashlib.sha256(host.encode()).hexdigest(), 16)
rng = random.Random(seed)
length = rng.randint(5, 8)
def _seeded_markov(rng: random.Random, min_len: int, max_len: int) -> str:
"""Generate a pronounceable word using a seeded RNG."""
length = rng.randint(min_len, max_len)
ch = rng.choice(_STARTERS)
word = [ch]
consonant_run = 0 if ch in _VOWELS else 1
@@ -129,13 +137,55 @@ def _nick_for_host(host: str) -> str:
else:
consonant_run += 1
word.append(ch)
return "".join(word)
base = "".join(word)
def _rng_for_key(key: str) -> random.Random:
"""Create a seeded RNG from an arbitrary string key."""
seed = int(hashlib.sha256(key.encode()).hexdigest(), 16)
return random.Random(seed)
def _nick_for_host(host: str) -> str:
"""Generate a deterministic pronounceable nick from a hostname.
The same hostname always produces the same nick, stable across reconnects.
"""
rng = _rng_for_key(host)
base = _seeded_markov(rng, 5, 8)
if rng.random() < 0.3:
base += str(rng.randint(0, 99))
return base
def _password_for_host(host: str) -> str:
"""Derive a deterministic NickServ password from a hostname.
Uses a different hash domain than the nick so they're independent.
"""
raw = hashlib.sha256(f"nickserv:{host}".encode()).hexdigest()
return raw[:16]
def _email_for_host(
host: str, excluded: set[str] | None = None,
) -> str | None:
"""Generate a random-looking email from a hostname.
Deterministic local part, random domain from available (non-excluded) list.
Returns None if all domains are excluded.
"""
available = [d for d in _email_domains() if d not in (excluded or set())]
if not available:
return None
rng = _rng_for_key(f"email:{host}")
local = _seeded_markov(rng, 5, 9)
if rng.random() < 0.5:
local += str(rng.randint(10, 99))
domain = random.choice(available)
return f"{local}{random.randint(1, 999)}@{domain}"
class Network:
"""Manages a persistent connection to a single IRC server."""
@@ -143,11 +193,17 @@ class Network:
self,
cfg: NetworkConfig,
proxy_cfg: ProxyConfig,
backlog: Backlog | None = None,
on_message: Callable[[str, IRCMessage], None] | None = None,
on_status: Callable[[str, str], None] | None = None,
data_dir: Path | None = None,
) -> None:
self.cfg = cfg
self.proxy_cfg = proxy_cfg
self.backlog = backlog
self.on_message = on_message
self.on_status = on_status # (network_name, status_text)
self.data_dir = data_dir
self.nick: str = cfg.nick or "*"
self.channels: set[str] = set()
self.state: State = State.DISCONNECTED
@@ -156,6 +212,7 @@ class Network:
self._reconnect_attempt: int = 0
self._running: bool = False
self._read_task: asyncio.Task[None] | None = None
self._reconnect_task: asyncio.Task[None] | None = None
self._probation_task: asyncio.Task[None] | None = None
# Transient nick used during registration/probation
self._connect_nick: str = ""
@@ -166,6 +223,23 @@ class Network:
# Channel state: topic + names per channel
self.topics: dict[str, str] = {}
self.names: dict[str, set[str]] = {}
# NickServ registration state
self._nickserv_pending: str = "" # "identify" or "register"
self._nickserv_password: str = ""
self._nickserv_email: str = ""
self._nickserv_done: asyncio.Event = asyncio.Event()
self._verify_task: asyncio.Task[None] | None = None
self._rejected_email_domains: set[str] = set()
# SASL authentication state
self._sasl_nick: str = ""
self._sasl_pass: str = ""
self._sasl_mechanism: str = "" # "EXTERNAL" or "PLAIN"
self._sasl_complete: asyncio.Event = asyncio.Event()
def _status(self, text: str) -> None:
"""Emit a status message to attached clients."""
if self.on_status:
self.on_status(self.cfg.name, text)
@property
def connected(self) -> bool:
@@ -187,10 +261,9 @@ class Network:
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()
if self._probation_task and not self._probation_task.done():
self._probation_task.cancel()
for task in (self._read_task, self._reconnect_task, self._probation_task, self._verify_task):
if task and not task.done():
task.cancel()
await self._disconnect()
async def send(self, msg: IRCMessage) -> None:
@@ -204,29 +277,72 @@ class Network:
await self.send(IRCMessage(command=command, params=list(params)))
async def _connect(self) -> None:
"""Establish connection via SOCKS5 proxy and register with random nick."""
"""Establish connection via SOCKS5 proxy and register.
Authentication cascade:
1. Stored creds + client cert -> SASL EXTERNAL (CertFP)
2. Stored creds, no cert -> SASL PLAIN
3. No creds -> random nick, no SASL
"""
from bouncer.cert import cert_path, has_cert
from bouncer.proxy import connect
self.state = State.CONNECTING
self._connect_nick = _random_nick()
self.visible_host = None
self._sasl_nick = ""
self._sasl_pass = ""
self._sasl_mechanism = ""
self._sasl_complete = asyncio.Event()
# Check for stored creds to decide SASL strategy
use_sasl = False
client_cert = None
if self.backlog:
creds = await self.backlog.get_nickserv_creds_by_network(self.cfg.name)
if creds:
self._sasl_nick, self._sasl_pass = creds
self._connect_nick = self._sasl_nick
use_sasl = True
# Prefer EXTERNAL if a cert exists for this nick
if self.data_dir and has_cert(self.data_dir, self.cfg.name, self._sasl_nick):
self._sasl_mechanism = "EXTERNAL"
client_cert = cert_path(self.data_dir, self.cfg.name, self._sasl_nick)
log.info("[%s] stored creds + cert for %s, will use SASL EXTERNAL",
self.cfg.name, self._sasl_nick)
else:
self._sasl_mechanism = "PLAIN"
log.info("[%s] stored creds for %s, will use SASL PLAIN",
self.cfg.name, self._sasl_nick)
if not use_sasl:
self._connect_nick = _random_nick()
try:
log.info(
"[%s] connecting to %s:%d (tls=%s)",
"[%s] connecting to %s:%d (tls=%s, sasl=%s)",
self.cfg.name, self.cfg.host, self.cfg.port, self.cfg.tls,
self._sasl_mechanism or "none",
)
self._reader, self._writer = await connect(
self.cfg.host,
self.cfg.port,
self.proxy_cfg,
tls=self.cfg.tls,
client_cert=client_cert,
)
self.state = State.REGISTERING
self._reconnect_attempt = 0
log.info("[%s] connected, registering as %s", self.cfg.name, self._connect_nick)
# IRC registration with generic identity
if use_sasl:
self._status(
f"connected, authenticating as {self._connect_nick}"
f" (SASL {self._sasl_mechanism})"
)
await self.send_raw("CAP", "REQ", "sasl")
else:
self._status(f"connected, registering as {self._connect_nick}")
# IRC registration
if self.cfg.password:
await self.send_raw("PASS", self.cfg.password)
await self.send_raw("NICK", self._connect_nick)
@@ -242,7 +358,7 @@ class Network:
log.exception("[%s] connection failed", self.cfg.name)
self.state = State.DISCONNECTED
if self._running:
await self._schedule_reconnect()
self._schedule_reconnect()
async def _disconnect(self) -> None:
"""Close the connection."""
@@ -259,7 +375,11 @@ class Network:
self._reader = None
self._writer = None
async def _schedule_reconnect(self) -> None:
def _schedule_reconnect(self) -> None:
"""Schedule a reconnect after exponential backoff."""
self._reconnect_task = asyncio.create_task(self._reconnect_wait())
async def _reconnect_wait(self) -> None:
"""Wait with exponential backoff, then reconnect."""
delay = BACKOFF_STEPS[min(self._reconnect_attempt, len(BACKOFF_STEPS) - 1)]
self._reconnect_attempt += 1
@@ -267,7 +387,10 @@ class Network:
"[%s] reconnecting in %ds (attempt %d)",
self.cfg.name, delay, self._reconnect_attempt,
)
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if self._running:
await self._connect()
@@ -300,7 +423,7 @@ class Network:
finally:
await self._disconnect()
if self._running:
await self._schedule_reconnect()
self._schedule_reconnect()
async def _enter_probation(self) -> None:
"""Start probation period after registration. Survive = ready."""
@@ -321,35 +444,338 @@ class Network:
if self.state != State.PROBATION:
return
self._status("probation passed, connection stable")
log.info("[%s] probation passed, connection stable", self.cfg.name)
self._reconnect_attempt = 0
await self._go_ready()
async def _register_cert_fingerprint(self) -> None:
"""Register cert fingerprint with NickServ if a cert exists.
Called immediately after SASL PLAIN success so the fingerprint is
registered before a potential K-line disconnects us.
"""
from bouncer.cert import fingerprint, has_cert, cert_path
nick = self._sasl_nick or self.nick
if not has_cert(self.data_dir, self.cfg.name, nick):
return
pem = cert_path(self.data_dir, self.cfg.name, nick)
fp = fingerprint(pem)
log.info("[%s] registering cert fingerprint with NickServ: %s",
self.cfg.name, fp)
self._status(f"registering cert fingerprint for {nick}")
await self.send_raw("PRIVMSG", "NickServ", f"CERT ADD {fp}")
async def _go_ready(self) -> None:
"""Transition to ready: switch to host-derived nick, then join channels."""
"""Transition to ready: skip NickServ if SASL succeeded, otherwise register.
Also checks for pending (unverified) registrations from a previous
session and resumes email verification if found.
"""
self.state = State.READY
# Derive a stable nick from the exit endpoint
if self.visible_host:
desired = _nick_for_host(self.visible_host)
elif self.cfg.nick:
desired = self.cfg.nick
else:
desired = _random_nick()
log.info("[%s] switching nick: %s -> %s (host=%s)", self.cfg.name, self.nick, desired,
log.info("[%s] ready as %s (host=%s)", self.cfg.name, self.nick,
self.visible_host or "unknown")
self._nick_confirmed.clear()
await self.send_raw("NICK", desired)
# Wait for server to confirm the nick change before joining
# SASL already authenticated -- skip NickServ entirely
if self._sasl_complete.is_set():
self._status(f"ready as {self.nick} (SASL)")
# Still check for pending registrations to resume verification
await self._resume_pending_verification()
await self._nickserv_complete()
return
# Check for a pending registration from a previous session
if await self._resume_pending_verification():
# Pending verification resumed -- skip normal NickServ flow
await self._nickserv_complete()
return
# Try NickServ: IDENTIFY first (previous session), else REGISTER
self._nickserv_done = asyncio.Event()
await self._nickserv_identify()
# If NickServ doesn't respond within 15s, move on
try:
await asyncio.wait_for(self._nickserv_done.wait(), timeout=15)
except asyncio.TimeoutError:
log.warning("[%s] NickServ did not respond in 15s", self.cfg.name)
self._nickserv_pending = ""
await self._nickserv_complete()
async def _nickserv_identify(self) -> None:
"""Attempt to IDENTIFY with NickServ using stored credentials.
If no stored creds exist for this network, skip straight to REGISTER.
"""
host = self.visible_host or ""
# Look up stored credentials by network + host
if self.backlog and host:
creds = await self.backlog.get_nickserv_creds_by_host(
self.cfg.name, host,
)
if creds:
stored_nick, stored_pass = creds
log.info("[%s] found stored creds for nick %s, switching", self.cfg.name, stored_nick)
# Switch to the registered nick first
self._nick_confirmed.clear()
await self.send_raw("NICK", stored_nick)
try:
await asyncio.wait_for(self._nick_confirmed.wait(), timeout=10)
except asyncio.TimeoutError:
log.warning("[%s] nick change not confirmed in 10s, joining anyway", self.cfg.name)
log.warning("[%s] nick change to %s not confirmed", self.cfg.name, stored_nick)
# Join configured channels
if self.cfg.autojoin and self.cfg.channels:
for ch in self.cfg.channels:
await self.send_raw("JOIN", ch)
self._nickserv_password = stored_pass
self._nickserv_pending = "identify"
log.info("[%s] attempting NickServ IDENTIFY as %s", self.cfg.name, self.nick)
await self.send_raw("PRIVMSG", "NickServ", f"IDENTIFY {stored_pass}")
return
# No stored creds — register the current random nick
await self._nickserv_register()
async def _nickserv_register(self) -> None:
"""Attempt to REGISTER with NickServ using a generated email.
Picks a domain not yet rejected by this server. If all static domains
are exhausted, fetches additional domains from mail.tm/gw before giving up.
"""
host = self.visible_host or self.nick
password = _password_for_host(host)
email = _email_for_host(host, excluded=self._rejected_email_domains)
if not email:
# All known domains rejected -- try fetching more from mail.tm/gw
self._status("all email domains rejected, fetching more...")
log.info("[%s] all domains rejected, fetching mail.tm/gw domains", self.cfg.name)
new_domains = await fetch_extra_domains(
self.proxy_cfg.host, self.proxy_cfg.port,
)
if new_domains:
email = _email_for_host(host, excluded=self._rejected_email_domains)
if not email:
self._status("all email domains exhausted")
log.warning("[%s] no email domains left to try", self.cfg.name)
self._nickserv_pending = ""
await self._nickserv_complete()
return
self._nickserv_password = password
self._nickserv_email = email
self._nickserv_pending = "register"
log.info("[%s] attempting NickServ REGISTER (email=%s)", self.cfg.name, email)
await self.send_raw("PRIVMSG", "NickServ", f"REGISTER {password} {email}")
async def _nickserv_complete(self) -> None:
"""Signal that NickServ interaction is finished."""
self._nickserv_done.set()
async def _verify_email_code(self) -> None:
"""Poll temp email for NickServ verification code and confirm."""
if not self._nickserv_email:
return
self._status(f"checking email {self._nickserv_email} for verification code...")
nick = self.nick
result = await verify_email(
self._nickserv_email,
proxy_host=self.proxy_cfg.host,
proxy_port=self.proxy_cfg.port,
)
if not result:
self._status("no verification code found in email")
return
if self.state != State.READY or not self._running:
return
self._status(f"verifying {nick} with code {result.code}")
if result.style == "atheme":
cmd = f"VERIFY REGISTER {nick} {result.code}"
else:
cmd = f"CONFIRM {result.code}"
log.info("[%s] sending NickServ %s", self.cfg.name, cmd)
await self.send_raw("PRIVMSG", "NickServ", cmd)
async def _handle_nickserv(self, text: str) -> None:
"""Process NickServ NOTICE responses.
Handles both immediate responses (while _nickserv_pending is set) and
late-arriving responses (after the 15s timeout cleared pending state).
"""
lower = text.lower()
log.info("[%s] NickServ: %s", self.cfg.name, text)
if self._nickserv_pending == "identify":
if "you are now identified" in lower:
self._status(f"identified as {self.nick}")
log.info("[%s] NickServ IDENTIFY succeeded", self.cfg.name)
if self.backlog and self._nickserv_password:
await self.backlog.save_nickserv_creds(
self.cfg.name, self.nick,
self._nickserv_password, "",
self.visible_host or "",
)
self._nickserv_pending = ""
await self._nickserv_complete()
elif "is not a registered nickname" in lower or "not registered" in lower:
self._status(f"{self.nick} not registered, attempting REGISTER")
log.info("[%s] nick not registered, attempting REGISTER", self.cfg.name)
self._nickserv_pending = ""
await self._nickserv_register()
elif "invalid password" in lower or "password incorrect" in lower:
self._status(f"IDENTIFY failed for {self.nick} (wrong password)")
log.warning("[%s] NickServ IDENTIFY failed (wrong password)", self.cfg.name)
self._nickserv_pending = ""
await self._nickserv_complete()
elif self._nickserv_pending == "register":
if self._registration_immediate(lower):
# Some servers register immediately without email
await self._on_verify_success()
elif self._registration_confirmed(lower):
await self._on_register_success()
elif "is already registered" in lower:
self._status(f"{self.nick} already registered by someone else")
log.warning("[%s] nick already registered by someone else", self.cfg.name)
self._nickserv_pending = ""
await self._nickserv_complete()
elif "do not accept" in lower or "not allowed" in lower:
# Blacklist this domain and try the next one
rejected = ""
if self._nickserv_email and "@" in self._nickserv_email:
rejected = self._nickserv_email.rsplit("@", 1)[1]
self._rejected_email_domains.add(rejected)
remaining = len(_email_domains()) - len(self._rejected_email_domains)
self._status(f"email domain {rejected} rejected, {remaining} left")
log.warning("[%s] NickServ rejected %s, trying next domain (%d left)",
self.cfg.name, rejected, remaining)
self._nickserv_pending = ""
await self._nickserv_register()
elif "too soon" in lower or "wait" in lower or "too many" in lower:
self._status(f"REGISTER rejected (too soon/rate limited)")
log.warning("[%s] NickServ rate limited: %s", self.cfg.name, text)
self._nickserv_pending = ""
await self._nickserv_complete()
elif self._nickserv_pending == "verify":
# Waiting for VERIFY/CONFIRM response
if self._verification_succeeded(lower):
await self._on_verify_success()
elif "invalid" in lower or "unknown" in lower:
self._status(f"verification failed: {text}")
log.warning("[%s] verification failed: %s", self.cfg.name, text)
self._nickserv_pending = ""
else:
# Late-arriving messages (after 15s timeout cleared pending state)
if self._registration_confirmed(lower) and self._nickserv_password:
log.info("[%s] late NickServ registration confirmation", self.cfg.name)
await self._on_register_success()
elif self._verification_succeeded(lower) and self._nickserv_password:
log.info("[%s] late NickServ verification confirmation", self.cfg.name)
await self._on_verify_success()
def _registration_confirmed(self, lower: str) -> bool:
"""Check if a NickServ message indicates registration accepted."""
return any(kw in lower for kw in (
"has been sent to", "passcode has been sent",
"activation instructions", "already been requested",
))
def _registration_immediate(self, lower: str) -> bool:
"""Check if registration completed without email verification."""
return "nickname registered" in lower and "email" not in lower
def _verification_succeeded(self, lower: str) -> bool:
"""Check if email verification / nick activation succeeded."""
return any(kw in lower for kw in (
"has been verified", "has now been verified",
"has been activated", "has now been activated",
"now identified", "registration complete",
"nickname confirmed", "account confirmed",
"you are now identified",
))
async def _on_register_success(self) -> None:
"""Handle NickServ REGISTER accepted (email verification pending).
Saves credentials as 'pending' so verification can resume across
reconnects. SASL only uses 'verified' creds, so this is safe.
"""
self._status(f"registered {self.nick} (verification email sent)")
log.info("[%s] NickServ REGISTER accepted, awaiting verification", self.cfg.name)
# Persist pending state for cross-session resume
if self.backlog and self._nickserv_password and self._nickserv_email:
await self.backlog.save_nickserv_creds(
self.cfg.name, self.nick,
self._nickserv_password, self._nickserv_email,
self.visible_host or "",
status="pending",
)
self._nickserv_pending = "verify"
# Start email verification in the background (if not already running)
if not self._verify_task or self._verify_task.done():
self._verify_task = asyncio.create_task(
self._verify_email_code()
)
await self._nickserv_complete()
async def _on_verify_success(self) -> None:
"""Handle verified registration -- promote to verified for SASL."""
self._status(f"verified {self.nick} -- SASL ready")
log.info("[%s] nick %s fully verified, saving credentials", self.cfg.name, self.nick)
if self.backlog and self._nickserv_password:
await self.backlog.mark_nickserv_verified(self.cfg.name, self.nick)
self._nickserv_pending = ""
async def _resume_pending_verification(self) -> bool:
"""Check for a pending registration from a previous session and resume.
If the pending nick matches the current nick (or we can switch to it),
resumes email verification in the background.
Returns True if a pending verification was resumed.
"""
if not self.backlog:
return False
pending = await self.backlog.get_pending_registration(self.cfg.name)
if not pending:
return False
p_nick, p_pass, p_email, p_host = pending
log.info("[%s] found pending registration: nick=%s email=%s",
self.cfg.name, p_nick, p_email)
# If we're already SASL'd as a different nick, we can't verify
# for the pending nick on this connection -- just resume email check
# The verification code doesn't require being connected as that nick
self._nickserv_password = p_pass
self._nickserv_email = p_email
self._nickserv_pending = "verify"
self._status(f"resuming verification for {p_nick} ({p_email})")
# Switch to the pending nick if possible (needed for VERIFY command)
if self.nick != p_nick and not self._sasl_complete.is_set():
self._nick_confirmed.clear()
await self.send_raw("NICK", p_nick)
try:
await asyncio.wait_for(self._nick_confirmed.wait(), timeout=10)
except asyncio.TimeoutError:
log.warning("[%s] could not switch to pending nick %s",
self.cfg.name, p_nick)
# Resume email verification in the background
if not self._verify_task or self._verify_task.done():
self._verify_task = asyncio.create_task(
self._verify_email_code()
)
return True
async def _handle(self, msg: IRCMessage) -> None:
"""Handle an IRC message from the server."""
@@ -359,8 +785,77 @@ class Network:
if msg.command == "ERROR":
reason = msg.params[0] if msg.params else "unknown"
self._status(f"ERROR: {reason}")
log.warning("[%s] server ERROR: %s", self.cfg.name, reason)
# Connection will be closed by server; read_loop handles reconnect
return
# --- SASL capability negotiation ---
if msg.command == "CAP" and len(msg.params) >= 3:
subcommand = msg.params[1].upper()
caps = msg.params[2].strip().lower()
if subcommand == "ACK" and "sasl" in caps:
log.info("[%s] SASL capability acknowledged, using %s",
self.cfg.name, self._sasl_mechanism)
await self.send_raw("AUTHENTICATE", self._sasl_mechanism or "PLAIN")
elif subcommand == "NAK" and "sasl" in caps:
log.warning("[%s] SASL not supported by server", self.cfg.name)
self._status("SASL not supported, falling back")
self._sasl_nick = ""
self._sasl_pass = ""
self._sasl_mechanism = ""
await self.send_raw("CAP", "END")
return
if msg.command == "AUTHENTICATE" and msg.params and msg.params[0] == "+":
if self._sasl_mechanism == "EXTERNAL":
# EXTERNAL: send account name (nick) base64-encoded
encoded = base64.b64encode(self._sasl_nick.encode()).decode()
await self.send_raw("AUTHENTICATE", encoded)
log.debug("[%s] sent SASL EXTERNAL identity", self.cfg.name)
elif self._sasl_nick and self._sasl_pass:
# PLAIN: nick\0nick\0pass
cred = f"{self._sasl_nick}\0{self._sasl_nick}\0{self._sasl_pass}"
encoded = base64.b64encode(cred.encode()).decode()
await self.send_raw("AUTHENTICATE", encoded)
log.debug("[%s] sent SASL PLAIN credentials", self.cfg.name)
else:
await self.send_raw("AUTHENTICATE", "*") # abort
return
if msg.command == "903":
# RPL_SASLSUCCESS
log.info("[%s] SASL %s authentication successful", self.cfg.name, self._sasl_mechanism)
self._status(f"SASL {self._sasl_mechanism} authenticated as {self._sasl_nick}")
self._sasl_complete.set()
# Register cert fingerprint BEFORE CAP END so NickServ processes
# it while we're still in capability negotiation (before K-line)
if self._sasl_mechanism == "PLAIN" and self.data_dir:
await self._register_cert_fingerprint()
await self.send_raw("CAP", "END")
return
if msg.command in ("902", "904", "905"):
# ERR_NICKLOCKED / ERR_SASLFAIL / ERR_SASLTOOLONG
reason = msg.params[-1] if msg.params else msg.command
log.warning("[%s] SASL %s failed (%s): %s",
self.cfg.name, self._sasl_mechanism, msg.command, reason)
# EXTERNAL failed but we have PLAIN creds -- retry on same connection
if self._sasl_mechanism == "EXTERNAL" and self._sasl_pass:
self._sasl_mechanism = "PLAIN"
self._status("SASL EXTERNAL failed, trying PLAIN")
log.info("[%s] falling back to SASL PLAIN", self.cfg.name)
await self.send_raw("AUTHENTICATE", "PLAIN")
return
self._status(f"SASL {self._sasl_mechanism} failed, falling back")
self._sasl_nick = ""
self._sasl_pass = ""
self._sasl_mechanism = ""
await self.send_raw("CAP", "END")
return
if msg.command in ("906", "908"):
# ERR_SASLABORTED / RPL_SASLMECHS
await self.send_raw("CAP", "END")
return
if msg.command == "001":
@@ -375,6 +870,10 @@ class Network:
if "@" in hostmask:
self.visible_host = hostmask.split("@", 1)[1]
log.info("[%s] visible host: %s", self.cfg.name, self.visible_host)
# Register cert fingerprint immediately after 001 (before K-line)
if self._sasl_complete.is_set() and self._sasl_mechanism == "PLAIN":
if self.data_dir:
await self._register_cert_fingerprint()
await self._enter_probation()
elif msg.command == "396":
@@ -384,9 +883,15 @@ class Network:
log.info("[%s] visible host (396): %s", self.cfg.name, self.visible_host)
elif msg.command == "NOTICE" and msg.params:
# Extract hostname from server notices during connect
text = msg.params[-1] if msg.params else ""
if "Found your hostname" in text:
sender = msg.prefix.split("!")[0].lower() if msg.prefix else ""
# NickServ response handling (always route, even after timeout)
if sender == "nickserv":
await self._handle_nickserv(text)
# Extract hostname from server notices during connect
elif "Found your hostname" in text:
# "*** Found your hostname: some.host.example.com"
parts = text.rsplit(": ", 1)
if len(parts) == 2:
+74 -29
View File
@@ -6,6 +6,7 @@ import asyncio
import logging
import socket
import ssl
from pathlib import Path
from python_socks.async_.asyncio import Proxy
@@ -15,13 +16,21 @@ log = logging.getLogger(__name__)
async def _resolve_all(host: str, port: int) -> list[str]:
"""Resolve hostname to all IPv4 addresses locally."""
"""Resolve hostname to all IPv4 addresses locally.
Returns an empty list if local resolution fails (the caller should
fall back to remote resolution via the proxy).
"""
loop = asyncio.get_running_loop()
try:
infos = await loop.getaddrinfo(
host, port, family=socket.AF_INET, type=socket.SOCK_STREAM,
)
except OSError:
log.debug("local DNS failed for %s, will use remote resolution", host)
return []
if not infos:
raise OSError(f"could not resolve {host}")
return []
# Deduplicate while preserving order
seen: set[str] = set()
addrs: list[str] = []
@@ -34,49 +43,85 @@ async def _resolve_all(host: str, port: int) -> list[str]:
return addrs
async def connect(
host: str,
port: int,
async def _connect_once(
proxy_cfg: ProxyConfig,
tls: bool = False,
dest_host: str,
dest_port: int,
tls: bool,
server_hostname: str,
client_cert: Path | None,
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
"""Open a TCP connection through the SOCKS5 proxy.
Resolves hostnames locally and tries all addresses, since many
SOCKS5 proxies cannot do remote DNS resolution reliably.
Returns an (asyncio.StreamReader, asyncio.StreamWriter) pair.
If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake.
"""
addrs = await _resolve_all(host, port)
last_err: Exception | None = None
for dest_ip in addrs:
"""Connect through SOCKS5 to a single destination."""
proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}")
log.debug(
"trying %s (%s):%d via socks5://%s:%d",
host, dest_ip, port, proxy_cfg.host, proxy_cfg.port,
"trying %s:%d via socks5://%s:%d",
dest_host, dest_port, proxy_cfg.host, proxy_cfg.port,
)
try:
sock = await proxy.connect(dest_host=dest_ip, dest_port=port)
except Exception as e:
log.debug("failed to connect via %s: %s", dest_ip, e)
last_err = e
continue
sock = await proxy.connect(dest_host=dest_host, dest_port=dest_port)
ssl_ctx: ssl.SSLContext | None = None
if tls:
ssl_ctx = ssl.create_default_context()
# Onion addresses are authenticated by Tor routing; skip hostname check
if server_hostname.endswith(".onion"):
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
if client_cert:
ssl_ctx.load_cert_chain(certfile=str(client_cert))
log.debug("loaded client cert %s", client_cert)
reader, writer = await asyncio.open_connection(
host=None,
port=None,
sock=sock,
ssl=ssl_ctx,
server_hostname=host if tls else None,
server_hostname=server_hostname if tls else None,
)
log.debug("connected to %s (%s):%d (tls=%s)", host, dest_ip, port, tls)
return reader, writer
async def connect(
host: str,
port: int,
proxy_cfg: ProxyConfig,
tls: bool = False,
client_cert: Path | None = None,
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
"""Open a TCP connection through the SOCKS5 proxy.
Resolves hostnames locally first and tries all addresses. If local DNS
fails (e.g. onion addresses, proxy-only hostnames), falls back to remote
resolution by passing the hostname directly to the SOCKS5 proxy.
Returns an (asyncio.StreamReader, asyncio.StreamWriter) pair.
If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake.
If client_cert is given (Path to a combined PEM), it is loaded for CertFP.
"""
addrs = await _resolve_all(host, port)
last_err: Exception | None = None
if addrs:
# Local resolution succeeded -- try each IP
for dest_ip in addrs:
try:
reader, writer = await _connect_once(
proxy_cfg, dest_ip, port, tls, host, client_cert,
)
log.debug("connected to %s (%s):%d (tls=%s)", host, dest_ip, port, tls)
return reader, writer
except Exception as e:
log.debug("failed to connect via %s: %s", dest_ip, e)
last_err = e
else:
# Local resolution failed -- let the proxy resolve the hostname
try:
reader, writer = await _connect_once(
proxy_cfg, host, port, tls, host, client_cert,
)
log.debug("connected to %s:%d via remote DNS (tls=%s)", host, port, tls)
return reader, writer
except Exception as e:
log.debug("failed to connect via remote DNS for %s: %s", host, e)
last_err = e
raise OSError(f"all addresses for {host} failed via SOCKS5") from last_err
+217 -49
View File
@@ -4,11 +4,13 @@ from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from bouncer.backlog import Backlog
from bouncer.config import Config
from bouncer.irc import IRCMessage, parse_prefix
from bouncer.config import Config, NetworkConfig, ProxyConfig
from bouncer.irc import IRCMessage
from bouncer.namespace import decode_target, encode_message
from bouncer.network import Network
if TYPE_CHECKING:
@@ -19,27 +21,97 @@ log = logging.getLogger(__name__)
# Commands worth storing in backlog
BACKLOG_COMMANDS = {"PRIVMSG", "NOTICE", "TOPIC", "KICK", "MODE"}
# Commands where params[0] is a target to decode
_TARGET0_COMMANDS = {
"PRIVMSG", "NOTICE", "JOIN", "PART", "MODE", "TOPIC", "NAMES", "WHO", "WHOIS",
}
# Numerics suppressed from client delivery (connection noise, MOTD, server stats)
_SUPPRESS_NUMERICS = {
# Welcome block (we synthesize our own)
"001", "002", "003", "004", "005",
# Unique ID
"042",
# LUSERS / server stats
"250", "251", "252", "253", "254", "255",
# Local/global user counts
"265", "266",
# MOTD
"375", "372", "376", "422",
# Visible host (handled internally by network)
"396",
# Nick in use (handled internally by network)
"433",
}
_CTCP_MARKER = "\x01"
def _suppress(msg: IRCMessage) -> bool:
"""Return True if this message should not reach the client."""
# Noisy numerics
if msg.command in _SUPPRESS_NUMERICS:
return True
# Server notices: prefix without '!' is a server, not a user
if msg.command == "NOTICE" and msg.prefix and "!" not in msg.prefix:
return True
# Connection notices (NOTICE to * or AUTH, regardless of prefix)
if msg.command == "NOTICE" and msg.params and msg.params[0] in ("*", "AUTH"):
return True
# CTCP replies in NOTICE
if msg.command == "NOTICE" and len(msg.params) >= 2:
if msg.params[1].startswith(_CTCP_MARKER):
return True
# CTCP/DCC inside PRIVMSG (keep ACTION)
if msg.command == "PRIVMSG" and len(msg.params) >= 2:
text = msg.params[1]
if text.startswith(_CTCP_MARKER) and not text.startswith("\x01ACTION"):
return True
# User mode changes (MODE for non-channel targets)
if msg.command == "MODE" and msg.params:
if not msg.params[0].startswith(("#", "&", "+", "!")):
return True
return False
class Router:
"""Central message hub linking clients to networks."""
def __init__(self, config: Config, backlog: Backlog) -> None:
def __init__(self, config: Config, backlog: Backlog, data_dir: Path | None = None) -> None:
self.config = config
self.backlog = backlog
self.data_dir = data_dir
self.networks: dict[str, Network] = {}
self.clients: dict[str, list[Client]] = {} # network_name -> clients
self.clients: list[Client] = []
self._lock = asyncio.Lock()
def _proxy_for(self, net_cfg: NetworkConfig) -> ProxyConfig:
"""Return the effective proxy config for a network."""
if net_cfg.proxy_host is not None:
return ProxyConfig(
host=net_cfg.proxy_host,
port=net_cfg.proxy_port or self.config.proxy.port,
)
return self.config.proxy
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,
proxy_cfg=self._proxy_for(net_cfg),
backlog=self.backlog,
on_message=self._on_network_message,
on_status=self._on_network_status,
data_dir=self.data_dir,
)
self.networks[name] = network
self.clients[name] = []
asyncio.create_task(network.start())
async def stop_networks(self) -> None:
@@ -47,69 +119,138 @@ class Router:
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 def attach_all(self, client: Client) -> None:
"""Attach a client to all networks."""
async with self._lock:
self.clients[network_name].append(client)
self.clients.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)
log.info("client attached to all networks (%d clients)", len(self.clients))
# 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 def detach_all(self, client: Client) -> None:
"""Detach a client from all networks."""
async with self._lock:
if network_name in self.clients:
try:
self.clients[network_name].remove(client)
self.clients.remove(client)
except ValueError:
pass
remaining = len(self.clients.get(network_name, []))
log.info("client detached from %s (%d remaining)", network_name, remaining)
remaining = len(self.clients)
log.info("client detached (%d remaining)", remaining)
if remaining == 0:
await self.backlog.record_disconnect(network_name)
# Future: record disconnect for backlog replay
async def client_to_network(self, network_name: str, msg: IRCMessage) -> None:
"""Forward a client command to the network."""
async def route_client_message(self, msg: IRCMessage) -> None:
"""Decode namespace from a client message and forward to the right network.
The target in params[0] (or params[1] for some commands) carries a
``/network`` suffix that tells us where to route.
"""
if not msg.params:
return
if msg.command == "KICK" and len(msg.params) >= 2:
# KICK #channel/net nick/net :reason
raw_chan, net = decode_target(msg.params[0])
raw_nick, _ = decode_target(msg.params[1])
if not net:
return
fwd = IRCMessage(
command=msg.command,
params=[raw_chan, raw_nick] + msg.params[2:],
prefix=msg.prefix,
tags=msg.tags,
)
await self._send_to_network(net, fwd)
return
if msg.command == "INVITE" and len(msg.params) >= 2:
# INVITE nick/net #channel/net
raw_nick, net1 = decode_target(msg.params[0])
raw_chan, net2 = decode_target(msg.params[1])
net = net1 or net2
if not net:
return
fwd = IRCMessage(
command=msg.command,
params=[raw_nick, raw_chan] + msg.params[2:],
prefix=msg.prefix,
tags=msg.tags,
)
await self._send_to_network(net, fwd)
return
if msg.command in ("JOIN", "PART") and msg.params:
# May be comma-separated: JOIN #a/net1,#b/net2
targets = msg.params[0].split(",")
by_network: dict[str, list[str]] = {}
for t in targets:
raw, net = decode_target(t)
if net:
by_network.setdefault(net, []).append(raw)
for net, chans in by_network.items():
fwd = IRCMessage(
command=msg.command,
params=[",".join(chans)] + msg.params[1:],
prefix=msg.prefix,
tags=msg.tags,
)
await self._send_to_network(net, fwd)
return
if msg.command in _TARGET0_COMMANDS and msg.params:
raw_target, net = decode_target(msg.params[0])
if not net:
return
fwd = IRCMessage(
command=msg.command,
params=[raw_target] + msg.params[1:],
prefix=msg.prefix,
tags=msg.tags,
)
await self._send_to_network(net, fwd)
return
async def _send_to_network(self, network_name: str, msg: IRCMessage) -> None:
"""Forward a message to a specific network."""
network = self.networks.get(network_name)
if network and network.connected:
await network.send(msg)
def get_own_nicks(self) -> dict[str, str]:
"""Return ``{network_name: current_nick}`` for all networks."""
return {name: net.nick for name, net in self.networks.items()}
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))
def _on_network_status(self, network_name: str, text: str) -> None:
"""Forward network status to attached clients as a NOTICE."""
notice = IRCMessage(
command="NOTICE",
params=["*", f"[{network_name}] {text}"],
prefix="bouncer",
)
raw = notice.format()
for client in self.clients:
try:
client.write(raw)
except Exception:
pass
async def _dispatch(self, network_name: str, msg: IRCMessage) -> None:
"""Dispatch a network message to attached clients and backlog."""
# 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)
if _suppress(msg):
return
# 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 (prefer raw bytes from server)
clients = self.clients.get(network_name, [])
data = msg.raw if msg.raw else msg.format()
for client in clients:
# Namespace and forward to all clients (per-client: own nicks -> client nick)
own_nicks = self.get_own_nicks()
for client in self.clients:
try:
client.write(data)
namespaced = encode_message(
msg, network_name, own_nicks, client_nick=client.nick,
)
client.write(namespaced.format())
except Exception:
log.exception("failed to write to client")
@@ -123,22 +264,49 @@ class Router:
log.info("replaying %d messages for %s", len(entries), network_name)
own_nicks = self.get_own_nicks()
for entry in entries:
msg = IRCMessage(
command=entry.command,
params=[entry.target, entry.content],
prefix=entry.sender,
)
if _suppress(msg):
continue
namespaced = encode_message(
msg, network_name, own_nicks, client_nick=client.nick,
)
try:
client.write(msg.format())
client.write(namespaced.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)
async def add_network(self, cfg: NetworkConfig) -> Network:
"""Create and start a new network at runtime."""
network = Network(
cfg=cfg,
proxy_cfg=self._proxy_for(cfg),
backlog=self.backlog,
on_message=self._on_network_message,
on_status=self._on_network_status,
data_dir=self.data_dir,
)
self.networks[cfg.name] = network
asyncio.create_task(network.start())
return network
async def remove_network(self, name: str) -> bool:
"""Stop and remove a network. Returns True if found."""
network = self.networks.pop(name, None)
if not network:
return False
await network.stop()
return True
def network_names(self) -> list[str]:
"""Return available network names."""
return list(self.networks.keys())
+135
View File
@@ -0,0 +1,135 @@
"""Tests for client certificate management."""
from __future__ import annotations
from pathlib import Path
import pytest
from bouncer.cert import (
cert_path,
delete_cert,
fingerprint,
generate_cert,
has_cert,
list_certs,
)
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
"""Provide a temporary data directory."""
return tmp_path
class TestCertPath:
def test_standard_path(self, data_dir: Path) -> None:
p = cert_path(data_dir, "libera", "fabesune")
assert p == data_dir / "certs" / "libera" / "fabesune.pem"
class TestGenerateCert:
def test_creates_pem_file(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
assert pem.is_file()
assert pem == cert_path(data_dir, "libera", "testnick")
def test_pem_contains_cert_and_key(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
content = pem.read_text()
assert "BEGIN CERTIFICATE" in content
assert "BEGIN PRIVATE KEY" in content
def test_file_permissions(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
mode = pem.stat().st_mode & 0o777
assert mode == 0o600
def test_overwrites_existing(self, data_dir: Path) -> None:
pem1 = generate_cert(data_dir, "libera", "testnick")
fp1 = fingerprint(pem1)
pem2 = generate_cert(data_dir, "libera", "testnick")
fp2 = fingerprint(pem2)
assert pem1 == pem2
assert fp1 != fp2 # New cert = new fingerprint
class TestFingerprint:
def test_format(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
fp = fingerprint(pem)
parts = fp.split(":")
assert len(parts) == 32 # SHA-256 = 32 bytes
for part in parts:
assert len(part) == 2
int(part, 16) # Must be valid hex
def test_uppercase_hex(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
fp = fingerprint(pem)
assert fp == fp.upper()
def test_deterministic_for_same_cert(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
assert fingerprint(pem) == fingerprint(pem)
class TestHasCert:
def test_exists(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "testnick")
assert has_cert(data_dir, "libera", "testnick") is True
def test_not_exists(self, data_dir: Path) -> None:
assert has_cert(data_dir, "libera", "testnick") is False
class TestDeleteCert:
def test_delete_existing(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "testnick")
assert delete_cert(data_dir, "libera", "testnick") is True
assert has_cert(data_dir, "libera", "testnick") is False
def test_delete_nonexistent(self, data_dir: Path) -> None:
assert delete_cert(data_dir, "libera", "testnick") is False
def test_cleans_empty_dir(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "testnick")
delete_cert(data_dir, "libera", "testnick")
assert not (data_dir / "certs" / "libera").exists()
class TestListCerts:
def test_empty(self, data_dir: Path) -> None:
assert list_certs(data_dir) == []
def test_list_all(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "nick1")
generate_cert(data_dir, "oftc", "nick2")
certs = list_certs(data_dir)
assert len(certs) == 2
networks = {c[0] for c in certs}
assert networks == {"libera", "oftc"}
def test_list_by_network(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "nick1")
generate_cert(data_dir, "oftc", "nick2")
certs = list_certs(data_dir, network="libera")
assert len(certs) == 1
assert certs[0][0] == "libera"
assert certs[0][1] == "nick1"
def test_list_multiple_per_network(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "nick1")
generate_cert(data_dir, "libera", "nick2")
certs = list_certs(data_dir, network="libera")
assert len(certs) == 2
nicks = {c[1] for c in certs}
assert nicks == {"nick1", "nick2"}
def test_fingerprints_present(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "testnick")
certs = list_certs(data_dir)
assert len(certs) == 1
_, _, fp = certs[0]
assert ":" in fp
assert len(fp.split(":")) == 32
+963
View File
@@ -0,0 +1,963 @@
"""Tests for bouncer control commands."""
from __future__ import annotations
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bouncer import commands
from bouncer.network import State
def _make_network(name: str, state: State, nick: str = "testnick",
host: str | None = None, channels: set[str] | None = None,
topics: dict[str, str] | None = None) -> MagicMock:
"""Create a mock Network."""
net = MagicMock()
net.cfg.name = name
net.cfg.host = f"irc.{name}.chat"
net.cfg.port = 6697
net.cfg.tls = True
net.cfg.channels = list(channels) if channels else []
net.cfg.nick = nick
net.cfg.password = None
net.state = state
net.nick = nick
net.visible_host = host
net.channels = channels or set()
net.topics = topics or {}
net.names = {}
net._reconnect_attempt = 0
net.connected = state not in (State.DISCONNECTED, State.CONNECTING)
net.ready = state == State.READY
net.start = AsyncMock()
net.stop = AsyncMock()
net.send = AsyncMock()
net.send_raw = AsyncMock()
net._nickserv_register = AsyncMock()
return net
def _make_router(*networks: MagicMock) -> MagicMock:
"""Create a mock Router with the given networks."""
router = MagicMock()
router.networks = {n.cfg.name: n for n in networks}
router.network_names.return_value = [n.cfg.name for n in networks]
router.get_network = lambda name: router.networks.get(name)
router.backlog = AsyncMock()
router.add_network = AsyncMock()
router.remove_network = AsyncMock(return_value=True)
router.config = MagicMock()
return router
def _make_client(nick: str = "testuser") -> MagicMock:
"""Create a mock Client."""
client = MagicMock()
client.nick = nick
client._connected_at = time.time() - 120
client._addr = ("127.0.0.1", 54321)
return client
class TestHelp:
@pytest.mark.asyncio
async def test_help_lists_commands(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("HELP", router, client)
assert lines[0] == "[HELP]"
assert any("STATUS" in line for line in lines)
assert any("UPTIME" in line for line in lines)
@pytest.mark.asyncio
async def test_empty_input_shows_help(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("", router, client)
assert lines[0] == "[HELP]"
class TestStatus:
@pytest.mark.asyncio
async def test_status_no_networks(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("STATUS", router, client)
assert lines[0] == "[STATUS]"
assert "(no networks configured)" in lines[1]
@pytest.mark.asyncio
async def test_status_ready_network(self) -> None:
net = _make_network("libera", State.READY, nick="fabesune", host="user/fabesune")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("STATUS", router, client)
assert lines[0] == "[STATUS]"
assert "ready" in lines[1]
assert "fabesune" in lines[1]
assert "user/fabesune" in lines[1]
@pytest.mark.asyncio
async def test_status_connecting_shows_attempt(self) -> None:
net = _make_network("hackint", State.CONNECTING)
net._reconnect_attempt = 3
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("STATUS", router, client)
assert "connecting" in lines[1]
assert "attempt 3" in lines[1]
@pytest.mark.asyncio
async def test_status_case_insensitive(self) -> None:
net = _make_network("libera", State.READY, nick="testnick")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("status", router, client)
assert lines[0] == "[STATUS]"
class TestInfo:
@pytest.mark.asyncio
async def test_info_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("INFO", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_info_unknown_network(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("INFO fakenet", router, client)
assert "Unknown network" in lines[0]
assert "libera" in lines[1]
@pytest.mark.asyncio
async def test_info_valid_network(self) -> None:
net = _make_network("libera", State.READY, nick="fabesune",
host="user/fabesune", channels={"#test", "#dev"})
router = _make_router(net)
router.backlog.list_nickserv_creds.return_value = [
("libera", "fabesune", "test@mail.tm", "user/fabesune", 1700000000.0, "verified"),
]
client = _make_client()
lines = await commands.dispatch("INFO libera", router, client)
assert lines[0] == "[INFO] libera"
assert any("ready" in line for line in lines)
assert any("fabesune" in line for line in lines)
assert any("#dev" in line or "#test" in line for line in lines)
assert any("verified" in line for line in lines)
class TestUptime:
@pytest.mark.asyncio
async def test_uptime(self) -> None:
commands.STARTUP_TIME = time.time() - 3661 # 1h 1m 1s
router = _make_router()
client = _make_client()
lines = await commands.dispatch("UPTIME", router, client)
assert lines[0].startswith("[UPTIME]")
assert "1h" in lines[0]
assert "1m" in lines[0]
assert "1s" in lines[0]
@pytest.mark.asyncio
async def test_uptime_unknown(self) -> None:
commands.STARTUP_TIME = 0.0
router = _make_router()
client = _make_client()
lines = await commands.dispatch("UPTIME", router, client)
assert "unknown" in lines[0]
class TestNetworks:
@pytest.mark.asyncio
async def test_networks_empty(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("NETWORKS", router, client)
assert lines[0] == "[NETWORKS]"
assert "(none)" in lines[1]
@pytest.mark.asyncio
async def test_networks_lists_all(self) -> None:
libera = _make_network("libera", State.READY)
oftc = _make_network("oftc", State.CONNECTING)
router = _make_router(libera, oftc)
client = _make_client()
lines = await commands.dispatch("NETWORKS", router, client)
assert lines[0] == "[NETWORKS]"
assert any("libera" in line and "ready" in line for line in lines[1:])
assert any("oftc" in line and "connecting" in line for line in lines[1:])
class TestCreds:
@pytest.mark.asyncio
async def test_creds_no_backlog(self) -> None:
router = _make_router()
router.backlog = None
client = _make_client()
lines = await commands.dispatch("CREDS", router, client)
assert "not available" in lines[0]
@pytest.mark.asyncio
async def test_creds_empty(self) -> None:
router = _make_router()
router.backlog.list_nickserv_creds.return_value = []
client = _make_client()
lines = await commands.dispatch("CREDS", router, client)
assert "no stored credentials" in lines[0]
@pytest.mark.asyncio
async def test_creds_lists_entries(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.list_nickserv_creds.return_value = [
("libera", "fabesune", "test@mail.tm", "user/fabesune", 1700000000.0, "verified"),
("libera", "oldnick", "old@mail.tm", "old/host", 1699000000.0, "pending"),
]
client = _make_client()
lines = await commands.dispatch("CREDS libera", router, client)
assert lines[0] == "[CREDS]"
assert any("+" in line and "fabesune" in line and "verified" in line for line in lines)
assert any("~" in line and "oldnick" in line and "pending" in line for line in lines)
@pytest.mark.asyncio
async def test_creds_unknown_network(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CREDS fakenet", router, client)
assert "Unknown network" in lines[0]
class TestConnect:
@pytest.mark.asyncio
async def test_connect_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("CONNECT", router, client)
assert "Usage" in lines[0] or "provide" in lines[0]
@pytest.mark.asyncio
async def test_connect_unknown_network(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("CONNECT fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_connect_already_connected(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CONNECT libera", router, client)
assert "already" in lines[0]
@pytest.mark.asyncio
async def test_connect_disconnected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CONNECT libera", router, client)
assert "[CONNECT]" in lines[0]
assert "starting" in lines[0]
class TestDisconnect:
@pytest.mark.asyncio
async def test_disconnect_ready(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("DISCONNECT libera", router, client)
assert "[DISCONNECT]" in lines[0]
net.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_disconnect_already_disconnected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("DISCONNECT libera", router, client)
assert "already disconnected" in lines[0]
class TestReconnect:
@pytest.mark.asyncio
async def test_reconnect(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("RECONNECT libera", router, client)
assert "[RECONNECT]" in lines[0]
net.stop.assert_awaited_once()
class TestNick:
@pytest.mark.asyncio
async def test_nick_missing_args(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("NICK", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_nick_missing_nick(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("NICK libera", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_nick_change(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("NICK libera newnick", router, client)
assert "[NICK]" in lines[0]
net.send_raw.assert_awaited_once_with("NICK", "newnick")
@pytest.mark.asyncio
async def test_nick_not_connected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("NICK libera newnick", router, client)
assert "not connected" in lines[0]
class TestRaw:
@pytest.mark.asyncio
async def test_raw_missing_args(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("RAW", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_raw_send(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("RAW libera WHOIS testuser", router, client)
assert "[RAW]" in lines[0]
net.send.assert_awaited_once()
@pytest.mark.asyncio
async def test_raw_not_connected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("RAW libera WHOIS testuser", router, client)
assert "not connected" in lines[0]
class TestChannels:
@pytest.mark.asyncio
async def test_channels_all(self) -> None:
net = _make_network("libera", State.READY, channels={"#test", "#dev"})
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CHANNELS", router, client)
assert lines[0] == "[CHANNELS]"
assert any("#test" in line for line in lines)
assert any("#dev" in line for line in lines)
@pytest.mark.asyncio
async def test_channels_specific_network(self) -> None:
net = _make_network("libera", State.READY, channels={"#test"})
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CHANNELS libera", router, client)
assert lines[0] == "[CHANNELS]"
assert any("#test" in line for line in lines)
@pytest.mark.asyncio
async def test_channels_unknown_network(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("CHANNELS fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_channels_with_topics(self) -> None:
net = _make_network("libera", State.READY, channels={"#test"},
topics={"#test": "Welcome to test"})
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CHANNELS libera", router, client)
assert any("Welcome to test" in line for line in lines)
@pytest.mark.asyncio
async def test_channels_empty(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CHANNELS libera", router, client)
assert any("(none)" in line for line in lines)
class TestClients:
@pytest.mark.asyncio
async def test_clients_empty(self) -> None:
router = _make_router()
router.clients = []
client = _make_client()
lines = await commands.dispatch("CLIENTS", router, client)
assert lines[0] == "[CLIENTS]"
assert "(none)" in lines[1]
@pytest.mark.asyncio
async def test_clients_lists_connected(self) -> None:
router = _make_router()
c1 = _make_client("user1")
c2 = _make_client("user2")
router.clients = [c1, c2]
client = _make_client()
lines = await commands.dispatch("CLIENTS", router, client)
assert lines[0] == "[CLIENTS]"
assert any("user1" in line for line in lines)
assert any("user2" in line for line in lines)
assert any("connected" in line for line in lines)
class TestBacklog:
@pytest.mark.asyncio
async def test_backlog_no_backlog(self) -> None:
router = _make_router()
router.backlog = None
client = _make_client()
lines = await commands.dispatch("BACKLOG", router, client)
assert "not available" in lines[0]
@pytest.mark.asyncio
async def test_backlog_stats(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.stats.return_value = [("libera", 1500)]
router.backlog.db_size.return_value = 2_097_152 # 2 MB
client = _make_client()
lines = await commands.dispatch("BACKLOG", router, client)
assert lines[0] == "[BACKLOG]"
assert any("1,500" in line for line in lines)
assert any("2.0 MB" in line for line in lines)
@pytest.mark.asyncio
async def test_backlog_specific_network(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.stats.return_value = [("libera", 42)]
router.backlog.db_size.return_value = 4096
client = _make_client()
lines = await commands.dispatch("BACKLOG libera", router, client)
assert lines[0] == "[BACKLOG]"
assert any("42" in line for line in lines)
@pytest.mark.asyncio
async def test_backlog_unknown_network(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("BACKLOG fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_backlog_empty(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.stats.return_value = []
router.backlog.db_size.return_value = 1024
client = _make_client()
lines = await commands.dispatch("BACKLOG", router, client)
assert any("no messages" in line for line in lines)
class TestVersion:
@pytest.mark.asyncio
async def test_version(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("VERSION", router, client)
assert "[VERSION]" in lines[0]
assert "bouncer" in lines[0]
assert "Python" in lines[0]
class TestRehash:
@pytest.mark.asyncio
async def test_rehash_no_config_path(self) -> None:
commands.CONFIG_PATH = None
router = _make_router()
client = _make_client()
lines = await commands.dispatch("REHASH", router, client)
assert "config path not set" in lines[0]
@pytest.mark.asyncio
async def test_rehash_config_error(self) -> None:
commands.CONFIG_PATH = Path("/nonexistent/config.toml")
router = _make_router()
client = _make_client()
lines = await commands.dispatch("REHASH", router, client)
assert "config error" in lines[0]
@pytest.mark.asyncio
async def test_rehash_adds_and_removes(self) -> None:
from bouncer.config import BouncerConfig, Config, NetworkConfig, ProxyConfig
old_net = _make_network("libera", State.READY)
router = _make_router(old_net)
new_cfg = Config(
bouncer=BouncerConfig(),
proxy=ProxyConfig(),
networks={
"oftc": NetworkConfig(name="oftc", host="irc.oftc.net", port=6697, tls=True),
},
)
commands.CONFIG_PATH = Path("/tmp/test.toml")
with patch("bouncer.config.load", return_value=new_cfg):
client = _make_client()
lines = await commands.dispatch("REHASH", router, client)
assert lines[0] == "[REHASH]"
assert any("removed: libera" in line for line in lines)
assert any("added: oftc" in line for line in lines)
router.remove_network.assert_awaited()
router.add_network.assert_awaited()
class TestAddNetwork:
@pytest.mark.asyncio
async def test_addnetwork_missing_args(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("ADDNETWORK", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_addnetwork_missing_host(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("ADDNETWORK testnet port=6667", router, client)
assert "Required" in lines[0]
@pytest.mark.asyncio
async def test_addnetwork_already_exists(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("ADDNETWORK libera host=irc.libera.chat", router, client)
assert "already exists" in lines[0]
@pytest.mark.asyncio
async def test_addnetwork_success(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch(
"ADDNETWORK testnet host=irc.test.com port=6697 tls=yes nick=mynick channels=#a,#b",
router, client,
)
assert "[ADDNETWORK]" in lines[0]
assert "testnet" in lines[0]
router.add_network.assert_awaited_once()
cfg = router.add_network.call_args[0][0]
assert cfg.name == "testnet"
assert cfg.host == "irc.test.com"
assert cfg.port == 6697
assert cfg.tls is True
assert cfg.nick == "mynick"
assert cfg.channels == ["#a", "#b"]
@pytest.mark.asyncio
async def test_addnetwork_slash_in_name(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("ADDNETWORK test/net host=h", router, client)
assert "must not contain" in lines[0]
class TestDelNetwork:
@pytest.mark.asyncio
async def test_delnetwork_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("DELNETWORK", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_delnetwork_unknown(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("DELNETWORK fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_delnetwork_success(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("DELNETWORK libera", router, client)
assert "[DELNETWORK]" in lines[0]
router.remove_network.assert_awaited_once_with("libera")
class TestAutojoin:
@pytest.mark.asyncio
async def test_autojoin_missing_args(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("AUTOJOIN", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_autojoin_add(self) -> None:
net = _make_network("libera", State.READY)
net.cfg.channels = ["#test"]
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("AUTOJOIN libera +#dev", router, client)
assert "[AUTOJOIN]" in lines[0]
assert any("added: #dev" in line for line in lines)
assert "#dev" in net.cfg.channels
@pytest.mark.asyncio
async def test_autojoin_remove(self) -> None:
net = _make_network("libera", State.READY, channels={"#test"})
net.cfg.channels = ["#test"]
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("AUTOJOIN libera -#test", router, client)
assert any("removed: #test" in line for line in lines)
assert "#test" not in net.cfg.channels
@pytest.mark.asyncio
async def test_autojoin_remove_missing(self) -> None:
net = _make_network("libera", State.READY)
net.cfg.channels = []
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("AUTOJOIN libera -#missing", router, client)
assert any("not in autojoin" in line for line in lines)
@pytest.mark.asyncio
async def test_autojoin_invalid_spec(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("AUTOJOIN libera #test", router, client)
assert "must start with" in lines[0]
class TestIdentify:
@pytest.mark.asyncio
async def test_identify_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("IDENTIFY", router, client)
assert "Usage" in lines[0] or "provide" in lines[0]
@pytest.mark.asyncio
async def test_identify_not_connected(self) -> None:
net = _make_network("libera", State.DISCONNECTED)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("IDENTIFY libera", router, client)
assert "not connected" in lines[0]
@pytest.mark.asyncio
async def test_identify_no_creds(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.get_nickserv_creds_by_network.return_value = None
client = _make_client()
lines = await commands.dispatch("IDENTIFY libera", router, client)
assert "No stored credentials" in lines[0]
@pytest.mark.asyncio
async def test_identify_success(self) -> None:
net = _make_network("libera", State.READY, nick="fabesune")
router = _make_router(net)
router.backlog.get_nickserv_creds_by_network.return_value = ("fabesune", "secret123")
client = _make_client()
lines = await commands.dispatch("IDENTIFY libera", router, client)
assert "[IDENTIFY]" in lines[0]
net.send_raw.assert_awaited_with("PRIVMSG", "NickServ", "IDENTIFY secret123")
@pytest.mark.asyncio
async def test_identify_nick_switch(self) -> None:
net = _make_network("libera", State.READY, nick="randomnick")
router = _make_router(net)
router.backlog.get_nickserv_creds_by_network.return_value = ("fabesune", "secret123")
client = _make_client()
lines = await commands.dispatch("IDENTIFY libera", router, client)
assert any("switching nick" in line for line in lines)
calls = net.send_raw.await_args_list
assert any(c.args == ("NICK", "fabesune") for c in calls)
class TestRegister:
@pytest.mark.asyncio
async def test_register_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("REGISTER", router, client)
assert "Usage" in lines[0] or "provide" in lines[0]
@pytest.mark.asyncio
async def test_register_not_ready(self) -> None:
net = _make_network("libera", State.CONNECTING)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("REGISTER libera", router, client)
assert "not ready" in lines[0]
@pytest.mark.asyncio
async def test_register_success(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("REGISTER libera", router, client)
assert "[REGISTER]" in lines[0]
class TestDropCreds:
@pytest.mark.asyncio
async def test_dropcreds_no_backlog(self) -> None:
router = _make_router()
router.backlog = None
client = _make_client()
lines = await commands.dispatch("DROPCREDS libera", router, client)
assert "not available" in lines[0]
@pytest.mark.asyncio
async def test_dropcreds_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("DROPCREDS", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_dropcreds_specific_nick(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("DROPCREDS libera fabesune", router, client)
assert "[DROPCREDS]" in lines[0]
assert any("deleted: fabesune" in line for line in lines)
router.backlog.delete_nickserv_creds.assert_awaited_once_with("libera", "fabesune")
@pytest.mark.asyncio
async def test_dropcreds_all(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.list_nickserv_creds.return_value = [
("libera", "nick1", "a@b.c", "", 0.0, "verified"),
("libera", "nick2", "d@e.f", "", 0.0, "pending"),
]
client = _make_client()
lines = await commands.dispatch("DROPCREDS libera", router, client)
assert any("deleted: nick1" in line for line in lines)
assert any("deleted: nick2" in line for line in lines)
@pytest.mark.asyncio
async def test_dropcreds_unknown_network(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("DROPCREDS fakenet", router, client)
assert "Unknown network" in lines[0]
class TestGencert:
@pytest.mark.asyncio
async def test_gencert_no_data_dir(self) -> None:
commands.DATA_DIR = None
router = _make_router()
client = _make_client()
lines = await commands.dispatch("GENCERT libera", router, client)
assert "not available" in lines[0]
@pytest.mark.asyncio
async def test_gencert_missing_arg(self) -> None:
commands.DATA_DIR = Path("/tmp")
router = _make_router()
client = _make_client()
lines = await commands.dispatch("GENCERT", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_gencert_unknown_network(self) -> None:
commands.DATA_DIR = Path("/tmp")
router = _make_router()
client = _make_client()
lines = await commands.dispatch("GENCERT fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_gencert_with_nick(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.READY, nick="fabesune")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("GENCERT libera testnick", router, client)
assert "[GENCERT]" in lines[0]
assert "testnick" in lines[0]
assert any("fingerprint" in line for line in lines)
# Should auto-send CERT ADD since network is ready
net.send_raw.assert_awaited()
calls = net.send_raw.await_args_list
assert any(
c.args[0] == "PRIVMSG" and c.args[1] == "NickServ"
and "CERT ADD" in c.args[2]
for c in calls
)
@pytest.mark.asyncio
async def test_gencert_uses_current_nick(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.READY, nick="fabesune")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("GENCERT libera", router, client)
assert "[GENCERT]" in lines[0]
assert "fabesune" in lines[0]
@pytest.mark.asyncio
async def test_gencert_not_ready(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.CONNECTING, nick="fabesune")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("GENCERT libera", router, client)
assert "[GENCERT]" in lines[0]
assert any("not ready" in line or "manually" in line for line in lines)
@pytest.mark.asyncio
async def test_gencert_no_nick(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.READY, nick="*")
router = _make_router(net)
router.backlog.get_nickserv_creds_by_network.return_value = None
client = _make_client()
lines = await commands.dispatch("GENCERT libera", router, client)
assert "No nick available" in lines[0]
class TestCertfp:
def test_certfp_no_data_dir(self) -> None:
commands.DATA_DIR = None
router = _make_router()
lines = _cmd_certfp_sync(router, None)
assert "not available" in lines[0]
def test_certfp_empty(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
router = _make_router()
lines = _cmd_certfp_sync(router, None)
assert "no certificates" in lines[0]
def test_certfp_lists_certs(self, tmp_path: Path) -> None:
from bouncer.cert import generate_cert
commands.DATA_DIR = tmp_path
generate_cert(tmp_path, "libera", "fabesune")
net = _make_network("libera", State.READY)
router = _make_router(net)
lines = _cmd_certfp_sync(router, None)
assert lines[0] == "[CERTFP]"
assert any("libera" in line and "fabesune" in line for line in lines)
def test_certfp_filter_network(self, tmp_path: Path) -> None:
from bouncer.cert import generate_cert
commands.DATA_DIR = tmp_path
generate_cert(tmp_path, "libera", "nick1")
generate_cert(tmp_path, "oftc", "nick2")
libera = _make_network("libera", State.READY)
oftc = _make_network("oftc", State.READY)
router = _make_router(libera, oftc)
lines = _cmd_certfp_sync(router, "libera")
assert lines[0] == "[CERTFP]"
assert any("nick1" in line for line in lines)
assert not any("nick2" in line for line in lines)
def test_certfp_unknown_network(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
router = _make_router()
lines = _cmd_certfp_sync(router, "fakenet")
assert "Unknown network" in lines[0]
class TestDelcert:
def test_delcert_no_data_dir(self) -> None:
commands.DATA_DIR = None
router = _make_router()
lines = _cmd_delcert_sync(router, "libera")
assert "not available" in lines[0]
def test_delcert_missing_arg(self) -> None:
commands.DATA_DIR = Path("/tmp")
router = _make_router()
lines = _cmd_delcert_sync(router, "")
assert "Usage" in lines[0]
def test_delcert_unknown_network(self) -> None:
commands.DATA_DIR = Path("/tmp")
router = _make_router()
lines = _cmd_delcert_sync(router, "fakenet")
assert "Unknown network" in lines[0]
def test_delcert_removes_cert(self, tmp_path: Path) -> None:
from bouncer.cert import generate_cert, has_cert
commands.DATA_DIR = tmp_path
generate_cert(tmp_path, "libera", "testnick")
assert has_cert(tmp_path, "libera", "testnick")
net = _make_network("libera", State.READY, nick="testnick")
router = _make_router(net)
lines = _cmd_delcert_sync(router, "libera testnick")
assert "[DELCERT]" in lines[0]
assert "deleted" in lines[0]
assert not has_cert(tmp_path, "libera", "testnick")
def test_delcert_nonexistent(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.READY, nick="testnick")
router = _make_router(net)
lines = _cmd_delcert_sync(router, "libera testnick")
assert "no cert found" in lines[0]
def test_delcert_uses_current_nick(self, tmp_path: Path) -> None:
from bouncer.cert import generate_cert, has_cert
commands.DATA_DIR = tmp_path
generate_cert(tmp_path, "libera", "fabesune")
net = _make_network("libera", State.READY, nick="fabesune")
router = _make_router(net)
lines = _cmd_delcert_sync(router, "libera")
assert "deleted" in lines[0]
assert not has_cert(tmp_path, "libera", "fabesune")
def _cmd_certfp_sync(router: MagicMock, network_name: str | None) -> list[str]:
"""Call _cmd_certfp synchronously (it's not async)."""
from bouncer.commands import _cmd_certfp
return _cmd_certfp(router, network_name)
def _cmd_delcert_sync(router: MagicMock, arg: str) -> list[str]:
"""Call _cmd_delcert synchronously (it's not async)."""
from bouncer.commands import _cmd_delcert
return _cmd_delcert(router, arg)
class TestUnknownCommand:
@pytest.mark.asyncio
async def test_unknown_command(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("FOOBAR", router, client)
assert "Unknown command" in lines[0]
assert "HELP" in lines[1]
+13
View File
@@ -97,6 +97,19 @@ password = "x"
with pytest.raises(ValueError, match="at least one network"):
load(_write_config(config))
def test_slash_in_network_name_raises(self):
config = """\
[bouncer]
password = "x"
[proxy]
[networks."lib/era"]
host = "irc.example.com"
"""
with pytest.raises(ValueError, match="must not contain '/'"):
load(_write_config(config))
def test_tls_default_port(self):
config = """\
[bouncer]
+168
View File
@@ -0,0 +1,168 @@
"""Tests for network namespace encoding/decoding."""
from bouncer.irc import IRCMessage
from bouncer.namespace import (
decode_channel,
decode_target,
encode_channel,
encode_message,
encode_nick,
encode_prefix,
)
OWN = {"libera": "mybot", "oftc": "mybot2"}
class TestEncodeChannel:
def test_basic(self):
assert encode_channel("#libera", "libera") == "#libera/libera"
def test_preserves_prefix(self):
assert encode_channel("&local", "net") == "&local/net"
class TestDecodeChannel:
def test_basic(self):
assert decode_channel("#libera/libera") == ("#libera", "libera")
def test_no_separator(self):
assert decode_channel("#libera") == ("#libera", None)
def test_channel_with_slash_in_name(self):
# Edge: channel name itself contains slash -- rfind picks the last one
assert decode_channel("#a/b/net") == ("#a/b", "net")
class TestEncodeNick:
def test_foreign_nick(self):
assert encode_nick("user123", "libera", OWN) == "user123/libera"
def test_own_nick_bare(self):
assert encode_nick("mybot", "libera", OWN) == "mybot"
def test_own_nick_other_network(self):
# Own nick on another network still shown bare
assert encode_nick("mybot2", "libera", OWN) == "mybot2"
def test_own_nick_rewritten_to_client_nick(self):
assert encode_nick("mybot", "libera", OWN, client_nick="tester") == "tester"
def test_foreign_nick_unaffected_by_client_nick(self):
assert encode_nick("user123", "libera", OWN, client_nick="tester") == "user123/libera"
class TestEncodePrefix:
def test_full_prefix(self):
result = encode_prefix("user!ident@host", "libera", OWN)
assert result == "user/libera!ident@host"
def test_own_prefix(self):
result = encode_prefix("mybot!ident@host", "libera", OWN)
assert result == "mybot!ident@host"
def test_nick_only(self):
result = encode_prefix("server.example.com", "libera", OWN)
assert result == "server.example.com/libera"
class TestDecodeTarget:
def test_channel(self):
assert decode_target("#libera/libera") == ("#libera", "libera")
def test_nick(self):
assert decode_target("user123/libera") == ("user123", "libera")
def test_bare(self):
assert decode_target("#libera") == ("#libera", None)
class TestEncodeMessage:
def test_privmsg_channel(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="user!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "#test/libera"
assert out.prefix == "user/libera!ident@host"
def test_privmsg_own_prefix(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="mybot!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.prefix == "mybot!ident@host"
def test_namreply(self):
msg = IRCMessage(
command="353",
params=["mybot", "=", "#test", "@op +voice regular mybot"],
)
out = encode_message(msg, "libera", OWN)
assert out.params[2] == "#test/libera"
names = out.params[3].split()
assert names[0] == "@op/libera"
assert names[1] == "+voice/libera"
assert names[2] == "regular/libera"
assert names[3] == "mybot" # own nick stays bare
def test_nick_change(self):
msg = IRCMessage(
command="NICK",
params=["newnick"],
prefix="oldnick!user@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "newnick/libera"
assert out.prefix == "oldnick/libera!user@host"
def test_join(self):
msg = IRCMessage(
command="JOIN",
params=["#test"],
prefix="user!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "#test/libera"
def test_non_channel_target_untouched(self):
msg = IRCMessage(
command="PRIVMSG",
params=["someuser", "hi"],
prefix="other!ident@host",
)
out = encode_message(msg, "libera", OWN)
# Private message to a nick -- params[0] is not a channel, left as-is
assert out.params[0] == "someuser"
def test_no_raw_preserved(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="user!ident@host",
raw=b":user!ident@host PRIVMSG #test :hello\r\n",
)
out = encode_message(msg, "libera", OWN)
assert out.raw is None # raw must not carry over
def test_own_prefix_rewritten_to_client_nick(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="mybot!ident@host",
)
out = encode_message(msg, "libera", OWN, client_nick="tester")
assert out.prefix == "tester!ident@host"
def test_namreply_own_nick_rewritten(self):
msg = IRCMessage(
command="353",
params=["mybot", "=", "#test", "@op mybot"],
)
out = encode_message(msg, "libera", OWN, client_nick="tester")
names = out.params[3].split()
assert names[0] == "@op/libera"
assert names[1] == "tester"