Compare commits
11 Commits
ab7603f638
...
0e06a18851
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e06a18851 | |||
| 15f0d374d2 | |||
| 2f40f5e508 | |||
| e6b1ce4c6d | |||
| ee2175f565 | |||
| 3d9aa33ec4 | |||
| 6478c514ad | |||
| 532ceb3c3d | |||
| 54218d2677 | |||
| 3c6f0bcf19 | |||
| 8cc57a7af4 |
@@ -18,3 +18,6 @@ build/
|
||||
|
||||
# Personal config (keep example only)
|
||||
config/bouncer.toml
|
||||
|
||||
# Client certificates (generated per-network)
|
||||
certs/
|
||||
|
||||
+4
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+26
-3
@@ -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,7 +82,19 @@ def main() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
asyncio.run(_run(args.config, args.verbose))
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+84
-65
@@ -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,28 +208,50 @@ class Client:
|
||||
params=[nick, server_name, "bouncer-0.1", "o", "o"],
|
||||
))
|
||||
|
||||
# Send channel state for joined channels
|
||||
for channel in self._network.channels:
|
||||
# Topic
|
||||
topic = self._network.topics.get(channel, "")
|
||||
if topic:
|
||||
# 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=RPL_TOPIC, prefix=server_name,
|
||||
params=[nick, channel, topic],
|
||||
command="JOIN",
|
||||
params=[ns_channel],
|
||||
prefix=self_prefix,
|
||||
))
|
||||
|
||||
# Names
|
||||
names = self._network.names.get(channel, set())
|
||||
if names:
|
||||
name_str = " ".join(sorted(names))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_NAMREPLY, prefix=server_name,
|
||||
params=[nick, "=", channel, name_str],
|
||||
))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_ENDOFNAMES, prefix=server_name,
|
||||
params=[nick, channel, "End of /NAMES list"],
|
||||
))
|
||||
# Topic
|
||||
topic = network.topics.get(channel, "")
|
||||
if topic:
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_TOPIC, prefix=server_name,
|
||||
params=[nick, ns_channel, topic],
|
||||
))
|
||||
|
||||
# Names
|
||||
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, "=", ns_channel, name_str],
|
||||
))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_ENDOFNAMES, prefix=server_name,
|
||||
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."""
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
+555
-50
@@ -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,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
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._nick_confirmed.wait(), timeout=10)
|
||||
await asyncio.wait_for(self._nickserv_done.wait(), timeout=15)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning("[%s] nick change not confirmed in 10s, joining anyway", self.cfg.name)
|
||||
log.warning("[%s] NickServ did not respond in 15s", self.cfg.name)
|
||||
self._nickserv_pending = ""
|
||||
await self._nickserv_complete()
|
||||
|
||||
# Join configured channels
|
||||
if self.cfg.autojoin and self.cfg.channels:
|
||||
for ch in self.cfg.channels:
|
||||
await self.send_raw("JOIN", ch)
|
||||
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 to %s not confirmed", self.cfg.name, stored_nick)
|
||||
|
||||
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:
|
||||
|
||||
+76
-31
@@ -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()
|
||||
infos = await loop.getaddrinfo(
|
||||
host, port, family=socket.AF_INET, type=socket.SOCK_STREAM,
|
||||
)
|
||||
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_once(
|
||||
proxy_cfg: ProxyConfig,
|
||||
dest_host: str,
|
||||
dest_port: int,
|
||||
tls: bool,
|
||||
server_hostname: str,
|
||||
client_cert: Path | None,
|
||||
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||
"""Connect through SOCKS5 to a single destination."""
|
||||
proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}")
|
||||
log.debug(
|
||||
"trying %s:%d via socks5://%s:%d",
|
||||
dest_host, dest_port, proxy_cfg.host, proxy_cfg.port,
|
||||
)
|
||||
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=server_hostname if tls else None,
|
||||
)
|
||||
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 and tries all addresses, since many
|
||||
SOCKS5 proxies cannot do remote DNS resolution reliably.
|
||||
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
|
||||
|
||||
for dest_ip in addrs:
|
||||
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,
|
||||
)
|
||||
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:
|
||||
sock = await proxy.connect(dest_host=dest_ip, dest_port=port)
|
||||
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 %s: %s", dest_ip, e)
|
||||
log.debug("failed to connect via remote DNS for %s: %s", host, e)
|
||||
last_err = e
|
||||
continue
|
||||
|
||||
ssl_ctx: ssl.SSLContext | None = None
|
||||
if tls:
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
|
||||
reader, writer = await asyncio.open_connection(
|
||||
host=None,
|
||||
port=None,
|
||||
sock=sock,
|
||||
ssl=ssl_ctx,
|
||||
server_hostname=host if tls else None,
|
||||
)
|
||||
|
||||
log.debug("connected to %s (%s):%d (tls=%s)", host, dest_ip, port, tls)
|
||||
return reader, writer
|
||||
|
||||
raise OSError(f"all addresses for {host} failed via SOCKS5") from last_err
|
||||
|
||||
+220
-52
@@ -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)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
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())
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user