Compare commits
39 Commits
ab7603f638
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b893969d2 | |||
| f9f38adadc | |||
| f3eae9291b | |||
| 5eb64d034e | |||
| 18992c63e1 | |||
| ed513251db | |||
| f14d067779 | |||
| aae9b0f771 | |||
| e9c8290f9c | |||
| 875997aa45 | |||
| 900813fc20 | |||
| 28f78567df | |||
| 2f7b82047d | |||
| 1ea72011b7 | |||
| 0064e52fee | |||
| f4f3132b6b | |||
| 638f12dbb3 | |||
| 2ab5f95476 | |||
| c11bd5555a | |||
| bf4a589fc5 | |||
| bfcebad6dd | |||
| ae8de25b27 | |||
| 0d762ced49 | |||
| 4dd817ea75 | |||
| b8d8c22dc8 | |||
| d13d090e8e | |||
| ed576b002d | |||
| 246b77e90a | |||
| 0e06a18851 | |||
| 15f0d374d2 | |||
| 2f40f5e508 | |||
| e6b1ce4c6d | |||
| ee2175f565 | |||
| 3d9aa33ec4 | |||
| 6478c514ad | |||
| 532ceb3c3d | |||
| 54218d2677 | |||
| 3c6f0bcf19 | |||
| 8cc57a7af4 |
@@ -0,0 +1,83 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: python:3.12-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache -q git
|
||||
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
|
||||
"https://oauth2:${{ github.token }}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" .
|
||||
- name: Install ruff
|
||||
run: pip install --no-cache-dir -q ruff
|
||||
- name: Lint
|
||||
run: ruff check src/ tests/
|
||||
|
||||
test:
|
||||
runs-on: linux
|
||||
needs: [lint]
|
||||
container:
|
||||
image: python:3.12-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache -q git
|
||||
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
|
||||
"https://oauth2:${{ github.token }}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" .
|
||||
- name: Install deps
|
||||
run: |
|
||||
pip install --no-cache-dir -q -r requirements.txt
|
||||
pip install --no-cache-dir -q pytest pytest-asyncio
|
||||
- name: Test
|
||||
run: PYTHONPATH=src pytest tests/ -v
|
||||
|
||||
secrets:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache -q git curl
|
||||
git clone --branch "${GITHUB_REF_NAME}" \
|
||||
"https://oauth2:${{ github.token }}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" .
|
||||
- name: Install gitleaks
|
||||
run: |
|
||||
ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')
|
||||
VER=$(curl -sI https://github.com/gitleaks/gitleaks/releases/latest | grep -i location | grep -oE 'v[0-9.]+' | tr -d v)
|
||||
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VER}/gitleaks_${VER}_linux_${ARCH}.tar.gz" \
|
||||
| tar xz -C /usr/local/bin/ gitleaks
|
||||
- name: Scan for secrets
|
||||
run: gitleaks detect --source . -v
|
||||
|
||||
build:
|
||||
runs-on: linux
|
||||
needs: [test, secrets]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
container:
|
||||
image: docker:latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache -q git
|
||||
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
|
||||
"https://oauth2:${{ github.token }}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" .
|
||||
- name: Login to Harbor
|
||||
run: echo "$HARBOR_PASS" | docker login -u "$HARBOR_USER" --password-stdin harbor.mymx.me
|
||||
env:
|
||||
HARBOR_USER: ${{ secrets.HARBOR_USER }}
|
||||
HARBOR_PASS: ${{ secrets.HARBOR_PASS }}
|
||||
- name: Build and push
|
||||
run: |
|
||||
TAG="harbor.mymx.me/library/bouncer:${GITHUB_SHA::8}"
|
||||
LATEST="harbor.mymx.me/library/bouncer:latest"
|
||||
docker build --push -t "$TAG" -t "$LATEST" -f Containerfile .
|
||||
@@ -15,6 +15,10 @@ build/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.log
|
||||
*.prof
|
||||
|
||||
# Personal config (keep example only)
|
||||
config/bouncer.toml
|
||||
|
||||
# Client certificates (generated per-network)
|
||||
certs/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[allowlist]
|
||||
description = "Test fixture false positives"
|
||||
paths = ["tests/test_captcha\\.py"]
|
||||
+8
-5
@@ -1,16 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
"python-socks[asyncio]>=2.4" \
|
||||
"aiosqlite>=0.19"
|
||||
COPY requirements.txt .
|
||||
RUN apk add --no-cache --virtual .build gcc musl-dev libffi-dev openssl-dev && \
|
||||
pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt && \
|
||||
apk del .build
|
||||
|
||||
COPY src/ /app/src/
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
VOLUME /app/src
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["python", "-m", "bouncer"]
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+23
-13
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
## v0.1.0 (current)
|
||||
## v0.1.0 (done)
|
||||
|
||||
- [x] IRC protocol parser/formatter
|
||||
- [x] TOML configuration
|
||||
@@ -11,27 +11,37 @@
|
||||
- [x] Backlog replay on reconnect
|
||||
- [x] Automatic reconnection with exponential backoff
|
||||
- [x] Nick collision handling
|
||||
- [x] TLS support
|
||||
- [x] TLS support (server-side)
|
||||
- [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
|
||||
## v0.2.0 (done)
|
||||
|
||||
- [ ] Client-side TLS (accept TLS from clients)
|
||||
- [ ] SASL authentication to IRC servers
|
||||
- [ ] CTCP VERSION/PING response
|
||||
- [ ] Channel key support (JOIN #channel key)
|
||||
- [ ] Configurable probation duration
|
||||
- [ ] Configurable backlog timestamp format
|
||||
- [x] NickServ auto-registration + email verification
|
||||
- [x] SASL PLAIN authentication
|
||||
- [x] SASL EXTERNAL (CertFP) authentication
|
||||
- [x] Client certificate generation + management
|
||||
- [x] hCaptcha auto-solving (NoCaptchaAI)
|
||||
- [x] Configurable operational constants (probation, backoff, etc.)
|
||||
- [x] PING watchdog (stale connection detection)
|
||||
- [x] IRCv3 server-time capability
|
||||
- [x] Push notifications (ntfy/webhook)
|
||||
- [x] Background account farming (ephemeral connections)
|
||||
- [x] 25+ bouncer control commands
|
||||
|
||||
## v0.3.0
|
||||
|
||||
- [ ] Hot config reload (SIGHUP)
|
||||
- [ ] Systemd service file
|
||||
- [x] Client-side TLS (accept TLS from clients)
|
||||
- [x] Channel key support (JOIN #channel key)
|
||||
- [x] Hot config reload (SIGHUP)
|
||||
- [x] Systemd service file
|
||||
|
||||
## v0.4.0
|
||||
|
||||
- [ ] Per-client backlog tracking (multi-user)
|
||||
- [ ] Web status page
|
||||
- [ ] DCC passthrough
|
||||
- [ ] Containerfile for podman deployment
|
||||
|
||||
## v1.0.0
|
||||
|
||||
|
||||
@@ -11,9 +11,22 @@
|
||||
- [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 (25+ commands via `/msg *bouncer`)
|
||||
- [x] P1: NickServ auto-registration + email verification
|
||||
- [x] P1: SASL PLAIN + EXTERNAL (CertFP) authentication
|
||||
- [x] P1: Client certificate generation + fingerprint management
|
||||
- [x] P1: PING watchdog (stale connection detection)
|
||||
- [x] P1: IRCv3 server-time capability
|
||||
- [x] P1: Push notifications (ntfy/webhook)
|
||||
- [x] P1: hCaptcha auto-solving (NoCaptchaAI)
|
||||
- [x] P1: Background account farming (ephemeral connections)
|
||||
- [x] P1: Configurable operational constants
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] P2: Client-side TLS support
|
||||
- [ ] P2: SASL authentication
|
||||
- [ ] P3: Systemd service file
|
||||
- [x] P2: Client-side TLS support
|
||||
- [x] P2: Channel key support
|
||||
- [x] P2: Hot config reload (SIGHUP + REHASH refactor)
|
||||
- [x] P3: Systemd service file
|
||||
- [ ] P3: Containerfile for podman deployment
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
## Features
|
||||
|
||||
- [ ] Client TLS (accept encrypted client connections)
|
||||
- [ ] SASL PLAIN/EXTERNAL for IRC server auth
|
||||
- [ ] Channel key support
|
||||
- [ ] CTCP VERSION/PING responses
|
||||
- [ ] Hot config reload on SIGHUP
|
||||
- [ ] Configurable probation duration
|
||||
- [ ] Web status dashboard
|
||||
- [ ] DCC passthrough
|
||||
- [ ] Per-client backlog tracking (multi-user)
|
||||
- [ ] Farm: configurable ephemeral deadline
|
||||
- [ ] Farm: per-network enable/disable override
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- [ ] Systemd unit file
|
||||
- [ ] Containerfile for podman deployment
|
||||
- [ ] PyPI packaging
|
||||
|
||||
@@ -23,4 +18,4 @@
|
||||
- [ ] SOCKS5 proxy failure tests
|
||||
- [ ] Backlog replay edge cases
|
||||
- [ ] Concurrent client attach/detach
|
||||
- [ ] Probation timeout / K-line detection tests
|
||||
- [ ] Farm ephemeral lifecycle integration tests
|
||||
|
||||
+1
-1
@@ -15,4 +15,4 @@ services:
|
||||
volumes:
|
||||
- ./src:/app/src:Z,ro
|
||||
- ./config:/data:Z
|
||||
command: ["-c", "/data/bouncer.toml", "-v"]
|
||||
command: ["-c", "/data/bouncer.toml", "-v", "--cprofile", "/data/bouncer.prof"]
|
||||
|
||||
@@ -3,6 +3,27 @@ bind = "127.0.0.1"
|
||||
port = 6667
|
||||
password = "changeme"
|
||||
|
||||
# Client TLS -- encrypt client-to-bouncer connections
|
||||
# client_tls = false # enable TLS for client listener
|
||||
# client_tls_cert = "" # path to PEM cert (auto-generated if empty)
|
||||
# client_tls_key = "" # path to PEM key (or same file as cert)
|
||||
|
||||
# PING watchdog -- detect stale server connections
|
||||
# ping_interval = 120 # seconds of silence before sending PING
|
||||
# ping_timeout = 30 # seconds to wait for PONG after PING
|
||||
|
||||
# Push notifications -- alerts when no clients are attached
|
||||
# notify_url = "" # ntfy or webhook URL (empty = disabled)
|
||||
# notify_on_highlight = true
|
||||
# notify_on_privmsg = true
|
||||
# notify_cooldown = 60 # min seconds between notifications
|
||||
# notify_proxy = false # route notifications through SOCKS5
|
||||
|
||||
# Background account farming -- grow a pool of verified accounts
|
||||
# farm_enabled = false # enable background registration
|
||||
# farm_interval = 3600 # seconds between attempts per network
|
||||
# farm_max_accounts = 10 # max verified accounts per network
|
||||
|
||||
[bouncer.backlog]
|
||||
max_messages = 10000
|
||||
replay_on_connect = true
|
||||
@@ -11,11 +32,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"
|
||||
@@ -23,6 +49,7 @@ port = 6697
|
||||
tls = true
|
||||
# nick = "mynick" # optional: override host-derived nick
|
||||
channels = ["#test"]
|
||||
# channel_keys = { "#secret" = "hunter2" } # keys for +k channels
|
||||
autojoin = true
|
||||
|
||||
# [networks.oftc]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=IRC bouncer with stealth connect and multi-network multiplexing
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=user
|
||||
Group=user
|
||||
|
||||
ExecStart=%h/git/bouncer/.venv/bin/bouncer -c %h/git/bouncer/config/bouncer.toml
|
||||
ExecReload=kill -HUP $MAINPID
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
# Logging (stdout/stderr -> journal)
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=bouncer
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=tmpfs
|
||||
BindPaths=%h/git/bouncer
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
+170
-8
@@ -9,6 +9,18 @@ bouncer --version # version
|
||||
bouncer --help # help
|
||||
```
|
||||
|
||||
## Systemd
|
||||
|
||||
```bash
|
||||
systemctl --user enable bouncer # enable at boot
|
||||
systemctl --user start bouncer # start
|
||||
systemctl --user stop bouncer # stop
|
||||
systemctl --user restart bouncer # restart
|
||||
systemctl --user reload bouncer # hot reload (SIGHUP)
|
||||
systemctl --user status bouncer # status
|
||||
journalctl --user -u bouncer -f # follow logs
|
||||
```
|
||||
|
||||
## Podman
|
||||
|
||||
```bash
|
||||
@@ -38,27 +50,154 @@ 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 key # add with channel key
|
||||
/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)
|
||||
```
|
||||
|
||||
### Account Farming
|
||||
|
||||
```
|
||||
/msg *bouncer FARM # global farming status
|
||||
/msg *bouncer FARM libera # network stats + trigger attempt
|
||||
/msg *bouncer ACCOUNTS # list all stored accounts
|
||||
/msg *bouncer ACCOUNTS libera # accounts for one network
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY
|
||||
DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (45s) -> READY
|
||||
```
|
||||
|
||||
| State | What happens |
|
||||
|-------|-------------|
|
||||
| CONNECTING | TCP + SOCKS5 + TLS handshake |
|
||||
| REGISTERING | Random nick/user/realname sent to server |
|
||||
| PROBATION | 15s wait, watching for K-line |
|
||||
| PROBATION | 45s wait (configurable), watching for K-line |
|
||||
| READY | Switch to configured nick, join channels |
|
||||
|
||||
## Auth Cascade
|
||||
|
||||
```
|
||||
SASL EXTERNAL (cert + creds) > SASL PLAIN (creds) > NickServ IDENTIFY
|
||||
```
|
||||
|
||||
## Reconnect Backoff
|
||||
|
||||
```
|
||||
5s -> 10s -> 30s -> 60s -> 120s -> 300s (cap)
|
||||
1s (flat, no escalation)
|
||||
```
|
||||
|
||||
## PING Watchdog
|
||||
|
||||
Detects stale connections where TCP stays open but server stops responding.
|
||||
|
||||
```toml
|
||||
ping_interval = 120 # silence before PING (seconds)
|
||||
ping_timeout = 30 # wait for PONG (seconds)
|
||||
```
|
||||
|
||||
Total detection time: `ping_interval + ping_timeout` (default 150s).
|
||||
|
||||
## server-time (IRCv3)
|
||||
|
||||
Automatic -- no config needed. Timestamps injected on all messages.
|
||||
Backlog replay includes original timestamps.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
```toml
|
||||
notify_url = "https://ntfy.sh/my-topic" # ntfy or generic webhook
|
||||
notify_on_highlight = true # channel mentions
|
||||
notify_on_privmsg = true # private messages
|
||||
notify_cooldown = 60 # rate limit (seconds)
|
||||
notify_proxy = false # use SOCKS5 for notifications
|
||||
```
|
||||
|
||||
Only fires when no clients are attached.
|
||||
|
||||
## Security
|
||||
|
||||
- DCC/CTCP stripped both directions (prevents IP leaks). ACTION preserved.
|
||||
- All server connections routed through SOCKS5 proxy.
|
||||
- Stealth connect: random nick/user/realname on every connection.
|
||||
|
||||
## Hot Reload
|
||||
|
||||
```bash
|
||||
kill -HUP $(pidof bouncer) # reload config via signal
|
||||
/msg *bouncer REHASH # reload config via command
|
||||
```
|
||||
|
||||
## Config Skeleton
|
||||
@@ -66,6 +205,19 @@ DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY
|
||||
```toml
|
||||
[bouncer]
|
||||
bind / port / password
|
||||
client_tls / client_tls_cert # client-side TLS
|
||||
client_tls_key # separate key file (optional)
|
||||
captcha_api_key # NoCaptchaAI key (optional)
|
||||
captcha_poll_interval / captcha_poll_timeout
|
||||
probation_seconds / nick_timeout / rejoin_delay
|
||||
backoff_steps / http_timeout
|
||||
email_poll_interval / email_max_polls / email_request_timeout
|
||||
cert_validity_days
|
||||
ping_interval / ping_timeout # PING watchdog
|
||||
notify_url / notify_on_highlight / notify_on_privmsg
|
||||
notify_cooldown / notify_proxy # push notifications
|
||||
farm_enabled / farm_interval # background account farming
|
||||
farm_max_accounts
|
||||
[bouncer.backlog]
|
||||
max_messages / replay_on_connect
|
||||
|
||||
@@ -75,6 +227,7 @@ host / port
|
||||
[networks.<name>] # repeatable
|
||||
host / port / tls
|
||||
nick / channels / autojoin
|
||||
channel_keys # keys for +k channels
|
||||
password # optional, IRC server PASS
|
||||
```
|
||||
|
||||
@@ -84,7 +237,10 @@ password # optional, IRC server PASS
|
||||
|------|---------|
|
||||
| `config/bouncer.toml` | Active config (gitignored) |
|
||||
| `config/bouncer.example.toml` | Example template |
|
||||
| `config/bouncer.service` | Systemd user service unit |
|
||||
| `config/bouncer.db` | SQLite backlog (auto-created) |
|
||||
| `{data_dir}/bouncer.pem` | Listener TLS cert (auto-created) |
|
||||
| `{data_dir}/certs/{net}/{nick}.pem` | Client certificates (auto-created) |
|
||||
|
||||
## Backlog Queries
|
||||
|
||||
@@ -107,10 +263,16 @@ 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
|
||||
router.py # message routing + backlog trigger
|
||||
cert.py # client certificate generation + management
|
||||
captcha.py # hCaptcha solver via NoCaptchaAI
|
||||
farm.py # background account farming
|
||||
commands.py # bouncer control commands (/msg *bouncer)
|
||||
notify.py # push notifications (ntfy/webhook)
|
||||
router.py # message routing + backlog trigger + server-time
|
||||
server.py # TCP listener
|
||||
backlog.py # SQLite store/replay/prune
|
||||
```
|
||||
|
||||
@@ -68,6 +68,45 @@ Verify:
|
||||
which bouncer
|
||||
```
|
||||
|
||||
## Systemd (User Service)
|
||||
|
||||
Install and enable the bouncer as a user service (no root required):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp config/bouncer.service ~/.config/systemd/user/bouncer.service
|
||||
```
|
||||
|
||||
Edit `ExecStart=` paths if your install differs from the defaults:
|
||||
|
||||
```bash
|
||||
$EDITOR ~/.config/systemd/user/bouncer.service
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable bouncer
|
||||
systemctl --user start bouncer
|
||||
```
|
||||
|
||||
Enable lingering so the service runs without an active login session:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
### Management
|
||||
|
||||
```bash
|
||||
systemctl --user status bouncer # check status
|
||||
systemctl --user restart bouncer # restart
|
||||
systemctl --user stop bouncer # stop
|
||||
journalctl --user -u bouncer -f # follow logs
|
||||
systemctl --user reload bouncer # hot reload config (SIGHUP)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Installed automatically by `make dev`:
|
||||
|
||||
+561
-34
@@ -35,8 +35,8 @@ No fixed prefix or pattern -- each attempt looks like a different person.
|
||||
|
||||
### 2. Probation (15 seconds)
|
||||
|
||||
After registration succeeds (001 RPL_WELCOME), the bouncer enters a 15-second
|
||||
probation window. During this time it watches for:
|
||||
After registration succeeds (001 RPL_WELCOME), the bouncer enters a probation
|
||||
window (default 45s, configurable via `probation_seconds`). During this time it watches for:
|
||||
|
||||
- `ERROR` messages (K-line, ban)
|
||||
- Server closing the connection
|
||||
@@ -54,16 +54,11 @@ Once probation passes without incident:
|
||||
|
||||
### 4. Reconnection
|
||||
|
||||
On any disconnection, the bouncer reconnects with exponential backoff:
|
||||
On any disconnection, the bouncer reconnects with exponential backoff
|
||||
(configurable via `backoff_steps`):
|
||||
|
||||
| Attempt | Delay |
|
||||
|---------|-------|
|
||||
| 1 | 5s |
|
||||
| 2 | 10s |
|
||||
| 3 | 30s |
|
||||
| 4 | 60s |
|
||||
| 5 | 120s |
|
||||
| 6+ | 300s |
|
||||
Reconnection delay is **1 second** (flat, no escalation). Each attempt gets a
|
||||
fresh random identity and potentially a different exit IP.
|
||||
|
||||
Each reconnection uses a fresh random identity.
|
||||
|
||||
@@ -82,59 +77,131 @@ 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
|
||||
## Client TLS
|
||||
|
||||
Define multiple `[networks.*]` sections in the config. Each gets its own
|
||||
persistent server connection through the SOCKS5 proxy.
|
||||
The bouncer can accept TLS-encrypted connections from IRC clients. This
|
||||
encrypts the password and all traffic between your client and the bouncer.
|
||||
|
||||
Connect your client with the appropriate network prefix:
|
||||
### Setup
|
||||
|
||||
```
|
||||
PASS libera:mypassword # connects to [networks.libera]
|
||||
PASS oftc:mypassword # connects to [networks.oftc]
|
||||
```toml
|
||||
[bouncer]
|
||||
client_tls = true
|
||||
```
|
||||
|
||||
Multiple clients can attach to the same network simultaneously. All receive
|
||||
the same messages in real time.
|
||||
On first start with `client_tls = true`, the bouncer auto-generates a
|
||||
self-signed EC P-256 certificate at `{data_dir}/bouncer.pem` (10-year validity).
|
||||
The certificate fingerprint is logged at startup.
|
||||
|
||||
### Custom Certificate
|
||||
|
||||
To use your own certificate (e.g. from Let's Encrypt):
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
client_tls = true
|
||||
client_tls_cert = "/path/to/fullchain.pem"
|
||||
client_tls_key = "/path/to/privkey.pem"
|
||||
```
|
||||
|
||||
If the cert and key are in the same PEM file, set only `client_tls_cert`.
|
||||
|
||||
### Client Examples
|
||||
|
||||
**irssi:**
|
||||
```
|
||||
/connect -tls -tls_verify no -password mypassword 127.0.0.1 6667
|
||||
```
|
||||
|
||||
**weechat:**
|
||||
```
|
||||
/server add bouncer 127.0.0.1/6667 -password=mypassword -ssl -ssl_verify=0
|
||||
/connect bouncer
|
||||
```
|
||||
|
||||
**hexchat:**
|
||||
|
||||
Enable "Use SSL for all the servers on this network" and accept the
|
||||
self-signed certificate.
|
||||
|
||||
### Verify with openssl
|
||||
|
||||
```bash
|
||||
openssl s_client -connect 127.0.0.1:6667
|
||||
```
|
||||
|
||||
## Multi-Network Namespacing
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
Client sees: Server wire:
|
||||
#libera/libera <-> #libera (on libera network)
|
||||
#debian/oftc <-> #debian (on oftc network)
|
||||
user123/libera <-> user123 (on libera network)
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -151,6 +218,83 @@ replay_on_connect = true # set false to disable replay
|
||||
|
||||
Stored commands: `PRIVMSG`, `NOTICE`, `TOPIC`, `KICK`, `MODE`.
|
||||
|
||||
## PING Watchdog
|
||||
|
||||
The bouncer sends periodic PING messages to detect stale server connections
|
||||
(socket open but no data flowing). If no data is received within the configured
|
||||
interval, a PING is sent. If the server doesn't respond within the timeout,
|
||||
the connection is dropped and a reconnect is scheduled.
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
ping_interval = 120 # seconds of silence before sending PING
|
||||
ping_timeout = 30 # seconds to wait for PONG after PING
|
||||
```
|
||||
|
||||
The watchdog starts automatically when a network enters the READY state.
|
||||
Any received data (not just PONG) resets the timer.
|
||||
|
||||
## IRCv3 server-time
|
||||
|
||||
The bouncer requests the `server-time` IRCv3 capability on every connection.
|
||||
When enabled by the server, timestamps on incoming messages are preserved and
|
||||
forwarded to clients. When the server does not provide a timestamp, the bouncer
|
||||
injects one using the current UTC time.
|
||||
|
||||
Backlog replay also includes timestamps from when messages were originally
|
||||
stored, so clients that support `server-time` see accurate times on replayed
|
||||
messages.
|
||||
|
||||
No client configuration is needed -- timestamps appear automatically if the
|
||||
client supports IRCv3 message tags.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
When no IRC clients are connected to the bouncer, highlights and private
|
||||
messages can trigger push notifications via [ntfy](https://ntfy.sh) or a
|
||||
generic webhook.
|
||||
|
||||
### Setup
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
notify_url = "https://ntfy.sh/my-bouncer-topic"
|
||||
notify_on_highlight = true # mentions of your nick in channels
|
||||
notify_on_privmsg = true # private messages
|
||||
notify_cooldown = 60 # min seconds between notifications
|
||||
notify_proxy = false # route notifications through SOCKS5
|
||||
```
|
||||
|
||||
### ntfy Example
|
||||
|
||||
```toml
|
||||
notify_url = "https://ntfy.sh/my-secret-topic"
|
||||
```
|
||||
|
||||
Install the ntfy app on your phone and subscribe to the topic. Notifications
|
||||
include the sender, target, and message text.
|
||||
|
||||
### Generic Webhook
|
||||
|
||||
Any URL that does not contain `ntfy` in the hostname is treated as a generic
|
||||
webhook. The bouncer POSTs JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"network": "libera",
|
||||
"sender": "user",
|
||||
"target": "#channel",
|
||||
"text": "hey mynick, check this out"
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- Notifications only fire when **no clients** are attached
|
||||
- The cooldown prevents notification floods (one per `notify_cooldown` seconds)
|
||||
- When `notify_proxy = true`, notification requests are routed through the
|
||||
configured SOCKS5 proxy
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
```toml
|
||||
@@ -159,6 +303,47 @@ bind = "127.0.0.1" # listen address
|
||||
port = 6667 # listen port
|
||||
password = "changeme" # client authentication password
|
||||
|
||||
# Client TLS
|
||||
client_tls = false # enable TLS for client listener
|
||||
client_tls_cert = "" # path to PEM cert (auto-generated if empty)
|
||||
client_tls_key = "" # path to PEM key (or same file as cert)
|
||||
|
||||
# Captcha solving (NoCaptchaAI)
|
||||
captcha_api_key = "" # API key (optional, for auto-verification)
|
||||
captcha_poll_interval = 3 # seconds between solve polls
|
||||
captcha_poll_timeout = 120 # max seconds to wait for solve
|
||||
|
||||
# Connection tuning
|
||||
probation_seconds = 45 # post-connect watch period for k-lines
|
||||
backoff_steps = [1] # reconnect delay (seconds)
|
||||
nick_timeout = 10 # seconds to wait for nick change
|
||||
rejoin_delay = 3 # seconds before rejoin after kick
|
||||
http_timeout = 15 # per-request HTTP timeout
|
||||
|
||||
# Email verification
|
||||
email_poll_interval = 15 # seconds between inbox checks
|
||||
email_max_polls = 30 # max inbox checks (~7.5 min)
|
||||
email_request_timeout = 20 # per-request timeout for email APIs
|
||||
|
||||
# Certificate generation
|
||||
cert_validity_days = 3650 # client cert validity (~10 years)
|
||||
|
||||
# PING watchdog
|
||||
ping_interval = 120 # seconds of silence before sending PING
|
||||
ping_timeout = 30 # seconds to wait for PONG after PING
|
||||
|
||||
# Push notifications
|
||||
notify_url = "" # ntfy or webhook URL (empty = disabled)
|
||||
notify_on_highlight = true # notify on nick mentions
|
||||
notify_on_privmsg = true # notify on private messages
|
||||
notify_cooldown = 60 # min seconds between notifications
|
||||
notify_proxy = false # route notifications through SOCKS5
|
||||
|
||||
# Background account farming
|
||||
farm_enabled = false # enable background registration
|
||||
farm_interval = 3600 # seconds between attempts per network
|
||||
farm_max_accounts = 10 # max verified accounts per network
|
||||
|
||||
[bouncer.backlog]
|
||||
max_messages = 10000 # per network, 0 = unlimited
|
||||
replay_on_connect = true # replay missed messages on client connect
|
||||
@@ -173,10 +358,352 @@ port = 6697 # server port (default: 6697 if tls, 6667 otherwise)
|
||||
tls = true # use TLS for server connection
|
||||
nick = "mynick" # desired IRC nick (set after probation)
|
||||
channels = ["#test"] # channels to join (after probation)
|
||||
channel_keys = { "#secret" = "hunter2" } # keys for +k channels (optional)
|
||||
autojoin = true # auto-join channels on ready (default: true)
|
||||
password = "" # IRC server password (optional, for PASS command)
|
||||
```
|
||||
|
||||
## Automatic Captcha Solving
|
||||
|
||||
Some IRC networks (e.g. OFTC) require visiting a URL with hCaptcha to verify
|
||||
nick registration. The bouncer can solve these automatically using NoCaptchaAI.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Sign up at [dash.nocaptchaai.com](https://dash.nocaptchaai.com) (free tier: 6000 solves/month)
|
||||
2. Copy your API key from the dashboard
|
||||
3. Add to config:
|
||||
```toml
|
||||
[bouncer]
|
||||
captcha_api_key = "your-api-key-here"
|
||||
```
|
||||
4. Reload config:
|
||||
```
|
||||
/msg *bouncer REHASH
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
When NickServ sends a verification URL containing `/verify/`:
|
||||
|
||||
1. The bouncer fetches the page via the SOCKS proxy
|
||||
2. If hCaptcha is detected and an API key is configured, it submits the
|
||||
challenge to NoCaptchaAI for solving (all traffic routed through the proxy)
|
||||
3. The solved token is submitted with the verification form
|
||||
4. On success, the nick is promoted from `pending` to `verified` status
|
||||
|
||||
If no API key is set, or solving fails, the URL is stored as `pending` and
|
||||
shown via the `CREDS` command for manual verification.
|
||||
|
||||
## 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 [key]` | Add channel (with optional key for +k channels) |
|
||||
| `AUTOJOIN <network> -#channel` | Remove channel from autojoin list |
|
||||
|
||||
**ADDNETWORK keys:** `host` (required), `port`, `tls` (yes/no), `nick`,
|
||||
`channels` (comma-separated), `channel_keys` (`#chan=key,...`), `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 |
|
||||
|
||||
### Account Farming
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `FARM` | Global farming status (enabled/disabled, per-network stats) |
|
||||
| `FARM <network>` | Network stats + trigger an immediate registration attempt |
|
||||
| `ACCOUNTS [network]` | List all stored accounts with verified/pending counts |
|
||||
|
||||
### 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 +#secret hunter2
|
||||
/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
|
||||
/msg *bouncer FARM
|
||||
/msg *bouncer FARM libera
|
||||
/msg *bouncer ACCOUNTS
|
||||
/msg *bouncer ACCOUNTS libera
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## Background Account Farming
|
||||
|
||||
The bouncer can automatically grow a pool of verified NickServ accounts across
|
||||
all configured networks. Primary connections stay active with SASL-authenticated
|
||||
identities while ephemeral connections register new nicks in the background.
|
||||
|
||||
### Setup
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
farm_enabled = true
|
||||
farm_interval = 3600 # seconds between attempts per network
|
||||
farm_max_accounts = 10 # max verified accounts per network
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. A sweep loop runs every 60 seconds (after an initial 60s stabilization delay)
|
||||
2. For each NickServ-enabled network, it checks:
|
||||
- Is there already an active farming attempt? (skip)
|
||||
- Has the cooldown (`farm_interval`) elapsed since the last attempt? (skip)
|
||||
- Are there already `farm_max_accounts` verified accounts? (skip)
|
||||
3. If eligible, an ephemeral connection is spawned with a random nick
|
||||
4. The ephemeral goes through the full registration lifecycle: REGISTER, email
|
||||
verification (or captcha), and credential storage
|
||||
5. Credentials are saved under the real network name, not the ephemeral's
|
||||
internal `_farm_` prefix
|
||||
6. Each ephemeral has a 15-minute deadline before being terminated
|
||||
7. Ephemeral connections are invisible to IRC clients (no status broadcasts,
|
||||
no channel joins)
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| `FARM` | Global overview: enabled/disabled, interval, per-network stats |
|
||||
| `FARM <network>` | Network stats + triggers an immediate registration attempt |
|
||||
| `ACCOUNTS` | List all stored accounts with verified/pending counts |
|
||||
| `ACCOUNTS <network>` | Accounts for a specific network |
|
||||
|
||||
### Configuration Reference
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
farm_enabled = false # enable background registration (default: off)
|
||||
farm_interval = 3600 # seconds between attempts per network
|
||||
farm_max_accounts = 10 # stop farming when this many verified accounts exist
|
||||
```
|
||||
|
||||
## Channel Keys
|
||||
|
||||
Channels with mode `+k` require a key to join. Configure keys in TOML:
|
||||
|
||||
```toml
|
||||
[networks.libera]
|
||||
channels = ["#secret", "#public"]
|
||||
channel_keys = { "#secret" = "hunter2" }
|
||||
```
|
||||
|
||||
Keys are used automatically during autojoin and KICK rejoin. To add a keyed
|
||||
channel at runtime:
|
||||
|
||||
```
|
||||
/msg *bouncer AUTOJOIN libera +#secret hunter2
|
||||
```
|
||||
|
||||
Removing a channel also clears its key:
|
||||
|
||||
```
|
||||
/msg *bouncer AUTOJOIN libera -#secret
|
||||
```
|
||||
|
||||
## DCC Stripping
|
||||
|
||||
DCC requests (`DCC SEND`, `DCC CHAT`) embed the sender's real IP address in the
|
||||
protocol payload. The bouncer strips all DCC and non-ACTION CTCP messages in
|
||||
both directions:
|
||||
|
||||
- **Inbound** (server to client): silently dropped, logged as warning
|
||||
- **Outbound** (client to server): blocked before reaching the network
|
||||
|
||||
ACTION (`/me`) is preserved. This is a hard security boundary -- there is no
|
||||
config toggle to disable it.
|
||||
|
||||
## Hot Reload
|
||||
|
||||
The bouncer reloads its config file on `SIGHUP` or via the `REHASH` command.
|
||||
Both use the same logic: re-read TOML, diff networks (add/remove/reconnect),
|
||||
and update mutable fields (channels, channel_keys, nick, password).
|
||||
|
||||
### SIGHUP
|
||||
|
||||
```bash
|
||||
kill -HUP $(pidof bouncer)
|
||||
```
|
||||
|
||||
Results are logged (no client connection needed). Useful for headless
|
||||
operation (systemd, containers).
|
||||
|
||||
### REHASH command
|
||||
|
||||
```
|
||||
/msg *bouncer REHASH
|
||||
```
|
||||
|
||||
Results are sent back as NOTICE messages.
|
||||
|
||||
### What changes on reload
|
||||
|
||||
| Field | Effect |
|
||||
|-------|--------|
|
||||
| Network host/port/tls/proxy | Network reconnected |
|
||||
| channels, channel_keys, nick, password | Updated in-place |
|
||||
| notify_url, notify_cooldown, etc. | Notifier recreated |
|
||||
| farm_enabled, farm_interval, etc. | Farm started/stopped |
|
||||
| bind, port, password, client_tls | Warning logged (restart required) |
|
||||
|
||||
## Systemd
|
||||
|
||||
The bouncer ships with a systemd user service file. See [INSTALL.md](INSTALL.md)
|
||||
for setup. Key operations:
|
||||
|
||||
```bash
|
||||
systemctl --user start bouncer # start
|
||||
systemctl --user stop bouncer # stop
|
||||
systemctl --user reload bouncer # hot reload (SIGHUP)
|
||||
journalctl --user -u bouncer -f # follow logs
|
||||
```
|
||||
|
||||
The service restarts automatically on failure (`RestartSec=10`).
|
||||
|
||||
## 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"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
python-socks[asyncio]>=2.4
|
||||
aiosqlite>=0.19
|
||||
aiohttp>=3.9
|
||||
aiohttp-socks>=0.8
|
||||
cryptography>=41.0
|
||||
+68
-4
@@ -5,12 +5,16 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from bouncer import commands
|
||||
from bouncer.backlog import Backlog
|
||||
from bouncer.cert import fingerprint, generate_listener_cert
|
||||
from bouncer.cli import parse_args
|
||||
from bouncer.config import load
|
||||
from bouncer.config import BouncerConfig, load
|
||||
from bouncer.router import Router
|
||||
from bouncer.server import start
|
||||
|
||||
@@ -21,7 +25,32 @@ 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)
|
||||
|
||||
|
||||
def _build_client_ssl_ctx(bouncer_cfg: BouncerConfig, data_dir: Path) -> ssl.SSLContext:
|
||||
"""Build an SSL context for the client listener."""
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
|
||||
if bouncer_cfg.client_tls_cert:
|
||||
cert_file = bouncer_cfg.client_tls_cert
|
||||
key_file = bouncer_cfg.client_tls_key or None
|
||||
else:
|
||||
cert_file = str(generate_listener_cert(
|
||||
data_dir, bouncer_cfg.cert_validity_days,
|
||||
))
|
||||
key_file = None # combined PEM
|
||||
|
||||
ctx.load_cert_chain(certfile=cert_file, keyfile=key_file)
|
||||
fp = fingerprint(Path(cert_file))
|
||||
log.info("client TLS cert: %s (SHA256:%s)", cert_file, fp)
|
||||
return ctx
|
||||
|
||||
|
||||
async def _run(config_path: Path, verbose: bool) -> None:
|
||||
@@ -37,10 +66,18 @@ 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)
|
||||
ssl_ctx = None
|
||||
if cfg.bouncer.client_tls:
|
||||
ssl_ctx = _build_client_ssl_ctx(cfg.bouncer, data_dir)
|
||||
|
||||
server = await start(cfg.bouncer, router, ssl_ctx=ssl_ctx)
|
||||
|
||||
# Graceful shutdown on SIGINT/SIGTERM
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -53,6 +90,21 @@ async def _run(config_path: Path, verbose: bool) -> None:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
|
||||
# Hot reload on SIGHUP
|
||||
async def _sighup_rehash() -> None:
|
||||
try:
|
||||
lines = await commands.rehash(router, config_path)
|
||||
for line in lines:
|
||||
log.info("REHASH: %s", line)
|
||||
except Exception:
|
||||
log.exception("SIGHUP rehash failed")
|
||||
|
||||
def _sighup_handler() -> None:
|
||||
log.info("SIGHUP received, reloading config...")
|
||||
asyncio.create_task(_sighup_rehash())
|
||||
|
||||
loop.add_signal_handler(signal.SIGHUP, _sighup_handler)
|
||||
|
||||
await stop_event.wait()
|
||||
|
||||
server.close()
|
||||
@@ -71,6 +123,18 @@ def main() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
if args.cprofile:
|
||||
import cProfile
|
||||
|
||||
prof = cProfile.Profile()
|
||||
prof.enable()
|
||||
try:
|
||||
asyncio.run(_run(args.config, args.verbose))
|
||||
finally:
|
||||
prof.disable()
|
||||
prof.dump_stats(str(args.cprofile))
|
||||
print(f"cProfile stats written to {args.cprofile}", file=sys.stderr)
|
||||
else:
|
||||
asyncio.run(_run(args.config, args.verbose))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
@@ -29,8 +29,26 @@ 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',
|
||||
verify_url TEXT NOT NULL DEFAULT '',
|
||||
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'",
|
||||
"ALTER TABLE nickserv_creds ADD COLUMN verify_url TEXT NOT NULL DEFAULT ''",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BacklogEntry:
|
||||
@@ -57,8 +75,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 +170,167 @@ 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",
|
||||
verify_url: str = "",
|
||||
) -> 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, verify_url) "
|
||||
"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, verify_url = excluded.verify_url",
|
||||
(network, nick, password, email, time.time(), host, status, verify_url),
|
||||
)
|
||||
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, str] | None:
|
||||
"""Get a pending (unverified) registration for a network.
|
||||
|
||||
Returns (nick, password, email, host, verify_url) or None.
|
||||
"""
|
||||
assert self._db is not None
|
||||
cursor = await self._db.execute(
|
||||
"SELECT nick, password, email, host, verify_url "
|
||||
"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], row[4]) 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 count_verified_creds(self, network: str) -> int:
|
||||
"""Count verified NickServ credentials for a network."""
|
||||
assert self._db is not None
|
||||
cursor = await self._db.execute(
|
||||
"SELECT COUNT(*) FROM nickserv_creds "
|
||||
"WHERE network = ? AND status = 'verified'",
|
||||
(network,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
async def list_nickserv_creds(
|
||||
self, network: str | None = None,
|
||||
) -> list[tuple[str, str, str, str, float, str, str]]:
|
||||
"""List NickServ credentials, optionally filtered by network.
|
||||
|
||||
Returns list of (network, nick, email, host, registered_at, status, verify_url).
|
||||
"""
|
||||
assert self._db is not None
|
||||
if network:
|
||||
cursor = await self._db.execute(
|
||||
"SELECT network, nick, email, host, registered_at, status, verify_url "
|
||||
"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, verify_url "
|
||||
"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,124 @@
|
||||
"""hCaptcha solver via NoCaptchaAI token service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN_URL = "https://token.nocaptchaai.com/token"
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
def _extract_sitekey(html: str) -> str | None:
|
||||
"""Extract hCaptcha sitekey from page HTML."""
|
||||
match = re.search(r'data-sitekey=["\']([a-f0-9-]+)["\']', html)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
async def solve_hcaptcha(
|
||||
page_url: str,
|
||||
page_html: str,
|
||||
api_key: str,
|
||||
proxy_host: str = "127.0.0.1",
|
||||
proxy_port: int = 1080,
|
||||
poll_interval: int = 3,
|
||||
poll_timeout: int = 120,
|
||||
) -> str | None:
|
||||
"""Solve an hCaptcha challenge via NoCaptchaAI.
|
||||
|
||||
Extracts the sitekey from the page HTML, submits to the token service,
|
||||
polls for the result, and returns the hCaptcha response token.
|
||||
|
||||
All HTTP traffic goes through the SOCKS5 proxy.
|
||||
|
||||
Returns the solved token string, or None on failure.
|
||||
"""
|
||||
sitekey = _extract_sitekey(page_html)
|
||||
if not sitekey:
|
||||
log.warning("captcha: no hCaptcha sitekey found in page")
|
||||
return None
|
||||
|
||||
log.info("captcha: solving hCaptcha (sitekey=%s, url=%s)", sitekey, page_url)
|
||||
|
||||
connector = ProxyConnector.from_url(
|
||||
f"socks5://{proxy_host}:{proxy_port}",
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"apikey": api_key,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
# Step 1: create task
|
||||
payload = {
|
||||
"type": "hcaptcha",
|
||||
"url": page_url,
|
||||
"sitekey": sitekey,
|
||||
"useragent": _USER_AGENT,
|
||||
}
|
||||
try:
|
||||
resp = await session.post(
|
||||
_TOKEN_URL, json=payload, headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
data = await resp.json()
|
||||
except Exception as e:
|
||||
log.warning("captcha: failed to create task: %s", e)
|
||||
return None
|
||||
|
||||
status = data.get("status", "")
|
||||
if status == "processed":
|
||||
token = data.get("token", "")
|
||||
log.info("captcha: solved immediately")
|
||||
return token or None
|
||||
|
||||
poll_url = data.get("url")
|
||||
task_id = data.get("id", "")
|
||||
if not poll_url:
|
||||
log.warning("captcha: no poll URL in response: %s", data)
|
||||
return None
|
||||
|
||||
log.info("captcha: task created (id=%s), polling...", task_id)
|
||||
|
||||
# Step 2: poll for result
|
||||
elapsed = 0
|
||||
await asyncio.sleep(7) # initial wait per API docs
|
||||
elapsed += 7
|
||||
|
||||
while elapsed < poll_timeout:
|
||||
try:
|
||||
resp = await session.get(
|
||||
poll_url, headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
)
|
||||
data = await resp.json()
|
||||
except Exception as e:
|
||||
log.warning("captcha: poll error: %s", e)
|
||||
await asyncio.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
continue
|
||||
|
||||
status = data.get("status", "")
|
||||
if status == "processed":
|
||||
token = data.get("token", "")
|
||||
log.info("captcha: solved (elapsed=%ds)", elapsed)
|
||||
return token or None
|
||||
if status == "failed":
|
||||
log.warning("captcha: solve failed: %s", data.get("message", ""))
|
||||
return None
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
|
||||
log.warning("captcha: timed out after %ds", poll_timeout)
|
||||
return None
|
||||
@@ -0,0 +1,181 @@
|
||||
"""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__)
|
||||
|
||||
DEFAULT_VALIDITY_DAYS = 3650 # ~10 years
|
||||
|
||||
|
||||
def listener_cert_path(data_dir: Path) -> Path:
|
||||
"""Return the PEM file path for the bouncer listener certificate."""
|
||||
return data_dir / "bouncer.pem"
|
||||
|
||||
|
||||
def generate_listener_cert(
|
||||
data_dir: Path,
|
||||
validity_days: int = DEFAULT_VALIDITY_DAYS,
|
||||
) -> Path:
|
||||
"""Generate a self-signed EC P-256 certificate for the client listener.
|
||||
|
||||
Creates a combined PEM file (cert + key) at ``{data_dir}/bouncer.pem``.
|
||||
Idempotent: skips generation if the file already exists.
|
||||
Returns the path to the PEM file.
|
||||
"""
|
||||
pem = listener_cert_path(data_dir)
|
||||
if pem.is_file():
|
||||
log.info("listener cert already exists: %s", pem)
|
||||
return pem
|
||||
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "bouncer"),
|
||||
])
|
||||
|
||||
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 listener cert %s (CN=bouncer)", pem)
|
||||
return pem
|
||||
|
||||
|
||||
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,
|
||||
validity_days: int = DEFAULT_VALIDITY_DAYS,
|
||||
) -> 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
|
||||
@@ -25,6 +25,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
action="store_true",
|
||||
help="enable debug logging",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cprofile",
|
||||
type=Path,
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="enable cProfile, write stats to PATH on shutdown",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
|
||||
+72
-53
@@ -4,12 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bouncer import commands
|
||||
from bouncer.irc import IRCMessage, parse
|
||||
from bouncer.namespace import encode_channel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bouncer.network import Network
|
||||
from bouncer.router import Router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -45,8 +47,6 @@ class Client:
|
||||
self._writer = writer
|
||||
self._router = router
|
||||
self._password = password
|
||||
self._network_name: str | None = None
|
||||
self._network: Network | None = None
|
||||
self._nick: str = "*"
|
||||
self._user: str = "unknown"
|
||||
self._realname: str = ""
|
||||
@@ -56,8 +56,14 @@ class Client:
|
||||
self._got_nick: bool = False
|
||||
self._got_user: bool = False
|
||||
self._pass_raw: str = ""
|
||||
self._connected_at: float = time.time()
|
||||
self._addr = writer.get_extra_info("peername", ("?", 0))
|
||||
|
||||
@property
|
||||
def nick(self) -> str:
|
||||
"""The nick the client registered with."""
|
||||
return self._nick
|
||||
|
||||
async def handle(self) -> None:
|
||||
"""Main client session loop."""
|
||||
log.info("client connected from %s", self._addr)
|
||||
@@ -106,8 +112,26 @@ class Client:
|
||||
if msg.command == "QUIT":
|
||||
return
|
||||
|
||||
if msg.command in FORWARD_COMMANDS and self._network_name:
|
||||
await self._router.client_to_network(self._network_name, msg)
|
||||
# Block registration commands -- never forward to networks
|
||||
if msg.command in ("NICK", "PASS", "USER"):
|
||||
if msg.command == "NICK":
|
||||
self._send_msg(IRCMessage(
|
||||
command="NOTICE",
|
||||
params=[self._nick, "Use /msg *bouncer NICK <network> <nick>"],
|
||||
prefix="*bouncer!bouncer@bouncer",
|
||||
))
|
||||
return
|
||||
|
||||
# Intercept bouncer control commands
|
||||
if msg.command == "PRIVMSG" and len(msg.params) >= 2:
|
||||
target = msg.params[0].lower()
|
||||
log.debug("PRIVMSG target=%r params=%r", msg.params[0], msg.params)
|
||||
if target in ("*bouncer", "bouncer"):
|
||||
await self._handle_bouncer_command(msg.params[1])
|
||||
return
|
||||
|
||||
if msg.command in FORWARD_COMMANDS:
|
||||
await self._router.route_client_message(msg)
|
||||
|
||||
async def _handle_registration(self, msg: IRCMessage) -> None:
|
||||
"""Handle PASS/NICK/USER registration sequence."""
|
||||
@@ -137,17 +161,11 @@ class Client:
|
||||
await self._complete_registration()
|
||||
|
||||
async def _complete_registration(self) -> None:
|
||||
"""Validate credentials and attach to network."""
|
||||
# Parse PASS: "network:password" or just "password" (use first network)
|
||||
network_name: str | None = None
|
||||
password: str = ""
|
||||
"""Validate credentials and attach to all networks."""
|
||||
password = self._pass_raw if self._got_pass else ""
|
||||
|
||||
if self._got_pass and ":" in self._pass_raw:
|
||||
network_name, password = self._pass_raw.split(":", 1)
|
||||
elif self._got_pass:
|
||||
password = self._pass_raw
|
||||
else:
|
||||
self._send_error("Password required (PASS network:password)")
|
||||
if not password:
|
||||
self._send_error("Password required (PASS <password>)")
|
||||
self._writer.close()
|
||||
return
|
||||
|
||||
@@ -156,46 +174,26 @@ class Client:
|
||||
self._writer.close()
|
||||
return
|
||||
|
||||
# Resolve network
|
||||
if not network_name:
|
||||
# Default to first configured network
|
||||
names = self._router.network_names()
|
||||
if names:
|
||||
network_name = names[0]
|
||||
|
||||
if not network_name:
|
||||
self._send_error("No network specified and none configured")
|
||||
self._writer.close()
|
||||
return
|
||||
|
||||
self._authenticated = True
|
||||
self._registered = True
|
||||
self._network_name = network_name
|
||||
|
||||
# Attach to network
|
||||
self._network = await self._router.attach(self, network_name)
|
||||
if not self._network:
|
||||
self._send_error(f"Unknown network: {network_name}")
|
||||
self._writer.close()
|
||||
return
|
||||
# Attach to all networks at once
|
||||
await self._router.attach_all(self)
|
||||
|
||||
log.info(
|
||||
"client %s authenticated for network %s (nick=%s)",
|
||||
self._addr, network_name, self._nick,
|
||||
)
|
||||
log.info("client %s authenticated (nick=%s)", self._addr, self._nick)
|
||||
|
||||
# Send synthetic welcome
|
||||
await self._send_welcome()
|
||||
|
||||
async def _send_welcome(self) -> None:
|
||||
"""Send IRC welcome sequence and channel state to client."""
|
||||
assert self._network is not None
|
||||
"""Send IRC welcome sequence and channel state from all networks."""
|
||||
server_name = "bouncer"
|
||||
nick = self._network.nick
|
||||
nick = self._nick
|
||||
self_prefix = f"{nick}!{self._user}@bouncer"
|
||||
|
||||
networks = ", ".join(self._router.network_names())
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_WELCOME, prefix=server_name,
|
||||
params=[nick, f"Welcome to bouncer ({self._network_name})"],
|
||||
params=[nick, f"Welcome to bouncer (networks: {networks})"],
|
||||
))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_YOURHOST, prefix=server_name,
|
||||
@@ -210,29 +208,51 @@ class Client:
|
||||
params=[nick, server_name, "bouncer-0.1", "o", "o"],
|
||||
))
|
||||
|
||||
# Send channel state for joined channels
|
||||
for channel in self._network.channels:
|
||||
# Send namespaced channel state from every network
|
||||
for net_name, network in sorted(self._router.networks.items()):
|
||||
for channel in sorted(network.channels):
|
||||
ns_channel = encode_channel(channel, net_name)
|
||||
|
||||
# Synthetic JOIN from client's own nick so irssi opens the window
|
||||
self._send_msg(IRCMessage(
|
||||
command="JOIN",
|
||||
params=[ns_channel],
|
||||
prefix=self_prefix,
|
||||
))
|
||||
|
||||
# Topic
|
||||
topic = self._network.topics.get(channel, "")
|
||||
topic = network.topics.get(channel, "")
|
||||
if topic:
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_TOPIC, prefix=server_name,
|
||||
params=[nick, channel, topic],
|
||||
params=[nick, ns_channel, topic],
|
||||
))
|
||||
|
||||
# Names
|
||||
names = self._network.names.get(channel, set())
|
||||
names = network.names.get(channel, set())
|
||||
if names:
|
||||
name_str = " ".join(sorted(names))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_NAMREPLY, prefix=server_name,
|
||||
params=[nick, "=", channel, name_str],
|
||||
params=[nick, "=", ns_channel, name_str],
|
||||
))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_ENDOFNAMES, prefix=server_name,
|
||||
params=[nick, channel, "End of /NAMES list"],
|
||||
params=[nick, ns_channel, "End of /NAMES list"],
|
||||
))
|
||||
|
||||
async def _handle_bouncer_command(self, text: str) -> None:
|
||||
"""Dispatch a bouncer control command and send responses."""
|
||||
lines = await commands.dispatch(text, self._router, self)
|
||||
for line in lines:
|
||||
msg = IRCMessage(
|
||||
command="NOTICE",
|
||||
params=[self._nick, line],
|
||||
prefix="*bouncer!bouncer@bouncer",
|
||||
)
|
||||
log.debug("bouncer reply: %r", msg.format())
|
||||
self._send_msg(msg)
|
||||
|
||||
def _send_msg(self, msg: IRCMessage) -> None:
|
||||
"""Send an IRCMessage to this client."""
|
||||
self.write(msg.format())
|
||||
@@ -242,9 +262,8 @@ class Client:
|
||||
self._send_msg(IRCMessage(command="ERROR", params=[text]))
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
"""Detach from network and close connection."""
|
||||
"""Detach from all networks and close connection."""
|
||||
log.info("client disconnected from %s", self._addr)
|
||||
if self._network_name:
|
||||
await self._router.detach(self, self._network_name)
|
||||
await self._router.detach_all(self)
|
||||
if not self._writer.is_closing():
|
||||
self._writer.close()
|
||||
|
||||
@@ -0,0 +1,948 @@
|
||||
"""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
|
||||
from bouncer.notify import Notifier
|
||||
|
||||
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])",
|
||||
"FARM": "Account farming status/trigger (FARM [network])",
|
||||
"ACCOUNTS": "List stored accounts (ACCOUNTS [network])",
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
if cmd == "FARM":
|
||||
return await _cmd_farm(router, arg or None)
|
||||
if cmd == "ACCOUNTS":
|
||||
return await _cmd_accounts(router, arg or None)
|
||||
|
||||
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, _url = 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, verify_url in rows:
|
||||
indicator = "+" if status == "verified" else "~"
|
||||
email_display = email if email else "--"
|
||||
lines.append(f" {indicator} {net} {nick} {status} {email_display}")
|
||||
if verify_url and status == "pending":
|
||||
lines.append(f" verify: {verify_url}")
|
||||
|
||||
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 rehash(router: Router, config_path: Path) -> list[str]:
|
||||
"""Reload config and apply changes. Returns status lines.
|
||||
|
||||
Reusable core -- called by both the REHASH command and SIGHUP handler.
|
||||
"""
|
||||
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.channel_keys = new_net_cfg.channel_keys
|
||||
old_net.cfg.nick = new_net_cfg.nick
|
||||
old_net.cfg.password = new_net_cfg.password
|
||||
lines.append(f" unchanged: {name}")
|
||||
|
||||
# Propagate bouncer-level settings to live objects
|
||||
old_b = router.config.bouncer
|
||||
new_b = new_cfg.bouncer
|
||||
|
||||
# Warn about immutable fields
|
||||
for field_name in ("bind", "port", "password", "client_tls"):
|
||||
old_val = getattr(old_b, field_name)
|
||||
new_val = getattr(new_b, field_name)
|
||||
if old_val != new_val:
|
||||
lines.append(f" warning: {field_name} changed (restart required)")
|
||||
|
||||
# Update notifier settings
|
||||
router._notifier = Notifier(new_b, new_cfg.proxy)
|
||||
|
||||
# Update farm settings
|
||||
farm_was_enabled = old_b.farm_enabled
|
||||
router._farm._cfg = new_b
|
||||
if new_b.farm_enabled and not farm_was_enabled:
|
||||
await router._farm.start()
|
||||
lines.append(" farm: started")
|
||||
elif not new_b.farm_enabled and farm_was_enabled:
|
||||
await router._farm.stop()
|
||||
lines.append(" farm: stopped")
|
||||
|
||||
router.config = new_cfg
|
||||
lines.append(f" {len(new_cfg.networks)} network(s) loaded")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
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"]
|
||||
return await rehash(router, CONFIG_PATH)
|
||||
|
||||
|
||||
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] [channel_keys=#c=key,...] [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 []
|
||||
|
||||
# Parse channel_keys: #secret=hunter2,#vip=pass
|
||||
channel_keys: dict[str, str] = {}
|
||||
if kvs.get("channel_keys"):
|
||||
for pair in kvs["channel_keys"].split(","):
|
||||
if "=" in pair:
|
||||
ch, k = pair.split("=", 1)
|
||||
channel_keys[ch] = k
|
||||
|
||||
cfg = NetworkConfig(
|
||||
name=name,
|
||||
host=kvs["host"],
|
||||
port=port,
|
||||
tls=tls,
|
||||
nick=kvs.get("nick", ""),
|
||||
channels=channels,
|
||||
channel_keys=channel_keys,
|
||||
password=kvs.get("password"),
|
||||
auth_service=kvs.get("auth_service", "nickserv"),
|
||||
)
|
||||
|
||||
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()
|
||||
if len(parts) < 2:
|
||||
return ["Usage: AUTOJOIN <network> +#channel [key] | -#channel"]
|
||||
|
||||
net, err = _resolve_network(router, parts[0])
|
||||
if err:
|
||||
return err
|
||||
|
||||
spec = parts[1]
|
||||
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 +/-"]
|
||||
|
||||
key = parts[2] if len(parts) >= 3 and action == "+" else ""
|
||||
|
||||
lines = [f"[AUTOJOIN] {net.cfg.name}"]
|
||||
|
||||
if action == "+":
|
||||
if channel not in net.cfg.channels:
|
||||
net.cfg.channels.append(channel)
|
||||
if key:
|
||||
net.cfg.channel_keys[channel] = key
|
||||
lines.append(f" added: {channel}")
|
||||
# Join immediately if network is ready
|
||||
if net.ready:
|
||||
if key:
|
||||
asyncio.create_task(net.send_raw("JOIN", channel, key))
|
||||
else:
|
||||
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")
|
||||
net.cfg.channel_keys.pop(channel, None)
|
||||
|
||||
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>"]
|
||||
|
||||
validity_days = router.config.bouncer.cert_validity_days
|
||||
pem = generate_cert(DATA_DIR, net.cfg.name, nick, validity_days=validity_days)
|
||||
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}"]
|
||||
|
||||
|
||||
# --- Account Farming ---
|
||||
|
||||
|
||||
async def _cmd_farm(router: Router, network_name: str | None) -> list[str]:
|
||||
"""Show farming status or trigger an immediate attempt."""
|
||||
farm = router.farm
|
||||
lines = ["[FARM]"]
|
||||
|
||||
if not farm.enabled:
|
||||
lines.append(" status: disabled")
|
||||
lines.append(" enable with farm_enabled = true in [bouncer]")
|
||||
return lines
|
||||
|
||||
lines.append(" status: enabled")
|
||||
lines.append(f" interval: {farm.interval}s")
|
||||
lines.append(f" max accounts: {farm.max_accounts}")
|
||||
|
||||
if network_name:
|
||||
name = network_name.lower()
|
||||
if name not in router.networks:
|
||||
names = ", ".join(sorted(router.networks))
|
||||
return [f"Unknown network: {network_name}", f"Available: {names}"]
|
||||
|
||||
# Trigger immediate attempt
|
||||
triggered = farm.trigger(name)
|
||||
stats_map = farm.status(name)
|
||||
stats = stats_map.get(name)
|
||||
|
||||
if router.backlog:
|
||||
verified = await router.backlog.count_verified_creds(name)
|
||||
else:
|
||||
verified = 0
|
||||
|
||||
lines.append(f" --- {name} ---")
|
||||
lines.append(f" verified: {verified}/{farm.max_accounts}")
|
||||
if stats:
|
||||
lines.append(f" attempts: {stats.attempts}")
|
||||
lines.append(f" successes: {stats.successes}")
|
||||
lines.append(f" failures: {stats.failures}")
|
||||
if stats.last_error:
|
||||
lines.append(f" last error: {stats.last_error}")
|
||||
if triggered:
|
||||
lines.append(" triggered registration attempt")
|
||||
else:
|
||||
lines.append(" already active or unknown")
|
||||
else:
|
||||
# Global overview
|
||||
all_stats = farm.status()
|
||||
if not all_stats:
|
||||
lines.append(" (no farming activity yet)")
|
||||
else:
|
||||
for name in sorted(all_stats):
|
||||
s = all_stats[name]
|
||||
if router.backlog:
|
||||
verified = await router.backlog.count_verified_creds(name)
|
||||
else:
|
||||
verified = 0
|
||||
lines.append(
|
||||
f" {name} {verified}/{farm.max_accounts} verified"
|
||||
f" {s.attempts}a/{s.successes}s/{s.failures}f"
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
async def _cmd_accounts(router: Router, network_name: str | None) -> list[str]:
|
||||
"""List all stored NickServ accounts with counts."""
|
||||
if not router.backlog:
|
||||
return ["[ACCOUNTS] 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"[ACCOUNTS] no stored accounts for {scope}"]
|
||||
|
||||
lines = ["[ACCOUNTS]"]
|
||||
|
||||
# Tally per-network
|
||||
counts: dict[str, dict[str, int]] = {}
|
||||
for net, nick, email, host, registered_at, status, verify_url in rows:
|
||||
c = counts.setdefault(net, {"verified": 0, "pending": 0})
|
||||
if status == "verified":
|
||||
c["verified"] += 1
|
||||
else:
|
||||
c["pending"] += 1
|
||||
|
||||
# Summary line per network
|
||||
for net in sorted(counts):
|
||||
c = counts[net]
|
||||
lines.append(f" {net} {c['verified']} verified {c['pending']} pending")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Detail per account
|
||||
for net, nick, email, host, registered_at, status, verify_url 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
|
||||
+82
-1
@@ -43,8 +43,12 @@ class NetworkConfig:
|
||||
user: str = ""
|
||||
realname: str = ""
|
||||
channels: list[str] = field(default_factory=list)
|
||||
autojoin: bool = True
|
||||
channel_keys: dict[str, str] = field(default_factory=dict)
|
||||
autojoin: bool = False
|
||||
password: str | None = None
|
||||
proxy_host: str | None = None
|
||||
proxy_port: int | None = None
|
||||
auth_service: str = "nickserv" # "nickserv", "qbot", or "none"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -56,6 +60,47 @@ class BouncerConfig:
|
||||
password: str = "changeme"
|
||||
backlog: BacklogConfig = field(default_factory=BacklogConfig)
|
||||
|
||||
# Captcha solving (NoCaptchaAI)
|
||||
captcha_api_key: str = ""
|
||||
captcha_poll_interval: int = 3
|
||||
captcha_poll_timeout: int = 120
|
||||
|
||||
# Connection tuning
|
||||
probation_seconds: int = 45
|
||||
backoff_steps: list[int] = field(default_factory=lambda: [1])
|
||||
nick_timeout: int = 10
|
||||
rejoin_delay: int = 3
|
||||
http_timeout: int = 15
|
||||
|
||||
# Email verification
|
||||
email_poll_interval: int = 15
|
||||
email_max_polls: int = 30
|
||||
email_request_timeout: int = 20
|
||||
|
||||
# Certificate generation
|
||||
cert_validity_days: int = 3650
|
||||
|
||||
# PING watchdog
|
||||
ping_interval: int = 120 # seconds of silence before sending PING
|
||||
ping_timeout: int = 30 # seconds to wait for PONG after PING
|
||||
|
||||
# Push notifications
|
||||
notify_url: str = "" # ntfy/webhook URL (empty = disabled)
|
||||
notify_on_highlight: bool = True
|
||||
notify_on_privmsg: bool = True
|
||||
notify_cooldown: int = 60 # min seconds between notifications
|
||||
notify_proxy: bool = False # route notifications through SOCKS5
|
||||
|
||||
# Client TLS
|
||||
client_tls: bool = False # enable TLS for client listener
|
||||
client_tls_cert: str = "" # path to PEM cert (auto-generated if empty)
|
||||
client_tls_key: str = "" # path to PEM key (or same file as cert)
|
||||
|
||||
# Background account farming
|
||||
farm_enabled: bool = False
|
||||
farm_interval: int = 3600 # seconds between attempts per network
|
||||
farm_max_accounts: int = 10 # max verified accounts per network
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Config:
|
||||
@@ -79,6 +124,31 @@ def load(path: Path) -> Config:
|
||||
port=bouncer_raw.get("port", 6667),
|
||||
password=bouncer_raw.get("password", "changeme"),
|
||||
backlog=BacklogConfig(**backlog_raw),
|
||||
captcha_api_key=bouncer_raw.get("captcha_api_key", ""),
|
||||
captcha_poll_interval=bouncer_raw.get("captcha_poll_interval", 3),
|
||||
captcha_poll_timeout=bouncer_raw.get("captcha_poll_timeout", 120),
|
||||
probation_seconds=bouncer_raw.get("probation_seconds", 45),
|
||||
backoff_steps=bouncer_raw.get("backoff_steps", [1]),
|
||||
nick_timeout=bouncer_raw.get("nick_timeout", 10),
|
||||
rejoin_delay=bouncer_raw.get("rejoin_delay", 3),
|
||||
http_timeout=bouncer_raw.get("http_timeout", 15),
|
||||
email_poll_interval=bouncer_raw.get("email_poll_interval", 15),
|
||||
email_max_polls=bouncer_raw.get("email_max_polls", 30),
|
||||
email_request_timeout=bouncer_raw.get("email_request_timeout", 20),
|
||||
cert_validity_days=bouncer_raw.get("cert_validity_days", 3650),
|
||||
ping_interval=bouncer_raw.get("ping_interval", 120),
|
||||
ping_timeout=bouncer_raw.get("ping_timeout", 30),
|
||||
notify_url=bouncer_raw.get("notify_url", ""),
|
||||
notify_on_highlight=bouncer_raw.get("notify_on_highlight", True),
|
||||
notify_on_privmsg=bouncer_raw.get("notify_on_privmsg", True),
|
||||
notify_cooldown=bouncer_raw.get("notify_cooldown", 60),
|
||||
notify_proxy=bouncer_raw.get("notify_proxy", False),
|
||||
client_tls=bouncer_raw.get("client_tls", False),
|
||||
client_tls_cert=bouncer_raw.get("client_tls_cert", ""),
|
||||
client_tls_key=bouncer_raw.get("client_tls_key", ""),
|
||||
farm_enabled=bouncer_raw.get("farm_enabled", False),
|
||||
farm_interval=bouncer_raw.get("farm_interval", 3600),
|
||||
farm_max_accounts=bouncer_raw.get("farm_max_accounts", 10),
|
||||
)
|
||||
|
||||
proxy_raw = raw.get("proxy", {})
|
||||
@@ -98,11 +168,22 @@ def load(path: Path) -> Config:
|
||||
user=net_raw.get("user", ""),
|
||||
realname=net_raw.get("realname", ""),
|
||||
channels=net_raw.get("channels", []),
|
||||
channel_keys=dict(net_raw.get("channel_keys", {})),
|
||||
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"),
|
||||
auth_service=net_raw.get("auth_service", "nickserv"),
|
||||
)
|
||||
|
||||
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,767 @@
|
||||
"""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__)
|
||||
|
||||
DEFAULT_POLL_INTERVAL = 15 # seconds between inbox checks
|
||||
DEFAULT_MAX_POLLS = 30 # ~7.5 minutes total
|
||||
DEFAULT_REQUEST_TIMEOUT = 20 # per-request timeout
|
||||
REQUEST_RETRIES = 4 # retries per API call
|
||||
RETRY_BACKOFF = [2, 5, 10, 20] # seconds between retries
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,
|
||||
poll_interval: int = DEFAULT_POLL_INTERVAL,
|
||||
max_polls: int = DEFAULT_MAX_POLLS,
|
||||
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
|
||||
) -> 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)
|
||||
|
||||
kw = dict(poll_interval=poll_interval, max_polls=max_polls,
|
||||
request_timeout=request_timeout)
|
||||
if domain in GUERRILLA_DOMAINS:
|
||||
return await _guerrilla_verify(email_addr, proxy_host, proxy_port, **kw)
|
||||
elif domain in YOPMAIL_DOMAINS:
|
||||
return await _yopmail_verify(email_addr, proxy_host, proxy_port, **kw)
|
||||
elif domain in TRASHMAILR_DOMAINS:
|
||||
return await _trashmailr_verify(email_addr, proxy_host, proxy_port, **kw)
|
||||
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, **kw)
|
||||
elif domain in _tempmail_domains:
|
||||
return await _tempmail_verify(email_addr, proxy_host, proxy_port, **kw)
|
||||
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,
|
||||
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
|
||||
) -> 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,
|
||||
poll_interval: int = DEFAULT_POLL_INTERVAL,
|
||||
max_polls: int = DEFAULT_MAX_POLLS,
|
||||
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
|
||||
) -> 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,
|
||||
poll_interval: int = DEFAULT_POLL_INTERVAL,
|
||||
max_polls: int = DEFAULT_MAX_POLLS,
|
||||
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
|
||||
) -> 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},
|
||||
request_timeout=request_timeout,
|
||||
)
|
||||
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},
|
||||
request_timeout=request_timeout,
|
||||
)
|
||||
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,
|
||||
request_timeout=request_timeout,
|
||||
)
|
||||
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,
|
||||
request_timeout=request_timeout,
|
||||
)
|
||||
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,
|
||||
poll_interval: int = DEFAULT_POLL_INTERVAL,
|
||||
max_polls: int = DEFAULT_MAX_POLLS,
|
||||
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
|
||||
) -> 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]
|
||||
pw_timeout = request_timeout * 1000 # Playwright uses milliseconds
|
||||
|
||||
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_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,
|
||||
poll_interval: int = DEFAULT_POLL_INTERVAL,
|
||||
max_polls: int = DEFAULT_MAX_POLLS,
|
||||
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
|
||||
) -> 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)
|
||||
pw_timeout = request_timeout * 1000 # Playwright uses milliseconds
|
||||
|
||||
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_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,
|
||||
poll_interval: int = DEFAULT_POLL_INTERVAL,
|
||||
max_polls: int = DEFAULT_MAX_POLLS,
|
||||
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
|
||||
) -> 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)
|
||||
pw_timeout = request_timeout * 1000 # Playwright uses milliseconds
|
||||
|
||||
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_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_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,
|
||||
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
|
||||
) -> 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()
|
||||
|
||||
pw_timeout = request_timeout * 1000 # Playwright uses milliseconds
|
||||
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_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,241 @@
|
||||
"""Background account farming -- register ephemeral nicks across networks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from bouncer.backlog import Backlog
|
||||
from bouncer.config import BouncerConfig, NetworkConfig, ProxyConfig
|
||||
from bouncer.network import Network
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How often the sweep loop checks for eligible networks.
|
||||
_SWEEP_INTERVAL = 60
|
||||
|
||||
# Hard deadline for a single ephemeral registration attempt.
|
||||
_EPHEMERAL_DEADLINE = 900 # 15 minutes
|
||||
|
||||
# Poll interval while waiting for ephemeral completion.
|
||||
_POLL_INTERVAL = 5
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FarmStats:
|
||||
"""Per-network farming statistics."""
|
||||
|
||||
attempts: int = 0
|
||||
successes: int = 0
|
||||
failures: int = 0
|
||||
last_attempt: float = 0.0
|
||||
last_success: float = 0.0
|
||||
last_error: str = ""
|
||||
|
||||
|
||||
class RegistrationManager:
|
||||
"""Periodically spawns ephemeral connections to farm NickServ accounts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bouncer_cfg: BouncerConfig,
|
||||
networks: dict[str, NetworkConfig],
|
||||
proxy_resolver: Callable[[NetworkConfig], ProxyConfig],
|
||||
backlog: Backlog,
|
||||
data_dir: Path | None = None,
|
||||
) -> None:
|
||||
self._cfg = bouncer_cfg
|
||||
self._networks = networks
|
||||
self._proxy_resolver = proxy_resolver
|
||||
self._backlog = backlog
|
||||
self._data_dir = data_dir
|
||||
self._stats: dict[str, FarmStats] = {}
|
||||
self._active: dict[str, asyncio.Task[None]] = {}
|
||||
self._loop_task: asyncio.Task[None] | None = None
|
||||
|
||||
# -- lifecycle -------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the farming loop. No-op if farming is disabled."""
|
||||
if not self._cfg.farm_enabled:
|
||||
log.debug("farm disabled, skipping start")
|
||||
return
|
||||
log.info("farm starting (interval=%ds, max=%d)",
|
||||
self._cfg.farm_interval, self._cfg.farm_max_accounts)
|
||||
self._loop_task = asyncio.create_task(self._loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel all active ephemerals and the sweep loop."""
|
||||
if self._loop_task and not self._loop_task.done():
|
||||
self._loop_task.cancel()
|
||||
try:
|
||||
await self._loop_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._loop_task = None
|
||||
|
||||
# Stop active ephemerals
|
||||
for name, task in list(self._active.items()):
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._active.clear()
|
||||
log.info("farm stopped")
|
||||
|
||||
# -- main loop -------------------------------------------------------------
|
||||
|
||||
async def _loop(self) -> None:
|
||||
"""Sweep loop: check all networks periodically."""
|
||||
try:
|
||||
# Initial delay -- let primary connections stabilize
|
||||
await asyncio.sleep(_SWEEP_INTERVAL)
|
||||
|
||||
while True:
|
||||
for name, net_cfg in self._networks.items():
|
||||
await self._maybe_spawn(name, net_cfg)
|
||||
await asyncio.sleep(_SWEEP_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def _maybe_spawn(self, name: str, net_cfg: NetworkConfig) -> None:
|
||||
"""Decide whether to spawn an ephemeral for this network."""
|
||||
# Only farm NickServ-enabled networks
|
||||
if net_cfg.auth_service not in ("nickserv",):
|
||||
return
|
||||
|
||||
# One at a time per network
|
||||
if name in self._active and not self._active[name].done():
|
||||
return
|
||||
|
||||
# Respect cooldown
|
||||
stats = self._stats.get(name)
|
||||
if stats and (time.time() - stats.last_attempt) < self._cfg.farm_interval:
|
||||
return
|
||||
|
||||
# Check account cap
|
||||
count = await self._backlog.count_verified_creds(name)
|
||||
if count >= self._cfg.farm_max_accounts:
|
||||
return
|
||||
|
||||
self._spawn_ephemeral(name, net_cfg)
|
||||
|
||||
# -- ephemeral management --------------------------------------------------
|
||||
|
||||
def _spawn_ephemeral(self, name: str, net_cfg: NetworkConfig) -> None:
|
||||
"""Create and start an ephemeral Network for registration."""
|
||||
farm_name = f"_farm_{name}"
|
||||
eph_cfg = NetworkConfig(
|
||||
name=farm_name,
|
||||
host=net_cfg.host,
|
||||
port=net_cfg.port,
|
||||
tls=net_cfg.tls,
|
||||
nick="",
|
||||
channels=[],
|
||||
autojoin=False,
|
||||
password=net_cfg.password,
|
||||
proxy_host=net_cfg.proxy_host,
|
||||
proxy_port=net_cfg.proxy_port,
|
||||
auth_service="nickserv",
|
||||
)
|
||||
proxy_cfg = self._proxy_resolver(net_cfg)
|
||||
eph = Network(
|
||||
cfg=eph_cfg,
|
||||
proxy_cfg=proxy_cfg,
|
||||
backlog=self._backlog,
|
||||
on_message=None,
|
||||
on_status=None,
|
||||
data_dir=self._data_dir,
|
||||
bouncer_cfg=self._cfg,
|
||||
cred_network=name,
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
stats = self._stats.setdefault(name, FarmStats())
|
||||
stats.attempts += 1
|
||||
stats.last_attempt = time.time()
|
||||
|
||||
task = asyncio.create_task(self._run_ephemeral(name, eph))
|
||||
self._active[name] = task
|
||||
log.info("[farm] spawned ephemeral for %s (%s:%d)",
|
||||
name, net_cfg.host, net_cfg.port)
|
||||
|
||||
async def _run_ephemeral(self, name: str, eph: Network) -> None:
|
||||
"""Run one ephemeral registration attempt with a hard deadline."""
|
||||
stats = self._stats.setdefault(name, FarmStats())
|
||||
before = await self._backlog.count_verified_creds(name)
|
||||
|
||||
try:
|
||||
await eph.start()
|
||||
|
||||
deadline = time.monotonic() + _EPHEMERAL_DEADLINE
|
||||
while time.monotonic() < deadline:
|
||||
if eph._nickserv_done.is_set():
|
||||
break
|
||||
await asyncio.sleep(_POLL_INTERVAL)
|
||||
|
||||
# Check if a new verified cred appeared
|
||||
after = await self._backlog.count_verified_creds(name)
|
||||
if after > before:
|
||||
stats.successes += 1
|
||||
stats.last_success = time.time()
|
||||
log.info("[farm] %s: new verified account (%d total)", name, after)
|
||||
else:
|
||||
stats.failures += 1
|
||||
stats.last_error = "no new verified account"
|
||||
log.info("[farm] %s: attempt finished, no new account", name)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
log.debug("[farm] %s: ephemeral cancelled", name)
|
||||
raise
|
||||
except Exception as exc:
|
||||
stats.failures += 1
|
||||
stats.last_error = str(exc)
|
||||
log.warning("[farm] %s: ephemeral error: %s", name, exc)
|
||||
finally:
|
||||
await eph.stop()
|
||||
self._active.pop(name, None)
|
||||
|
||||
# -- public API ------------------------------------------------------------
|
||||
|
||||
def trigger(self, network: str) -> bool:
|
||||
"""Manually trigger an immediate registration attempt.
|
||||
|
||||
Bypasses cooldown. Returns False if the network is unknown or
|
||||
already has an active ephemeral.
|
||||
"""
|
||||
net_cfg = self._networks.get(network)
|
||||
if not net_cfg:
|
||||
return False
|
||||
if network in self._active and not self._active[network].done():
|
||||
return False
|
||||
|
||||
# Reset cooldown so _maybe_spawn won't skip
|
||||
stats = self._stats.setdefault(network, FarmStats())
|
||||
stats.last_attempt = 0.0
|
||||
self._spawn_ephemeral(network, net_cfg)
|
||||
return True
|
||||
|
||||
def status(self, network: str | None = None) -> dict[str, FarmStats]:
|
||||
"""Return farming stats, optionally filtered by network."""
|
||||
if network:
|
||||
s = self._stats.get(network)
|
||||
return {network: s} if s else {}
|
||||
return dict(self._stats)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._cfg.farm_enabled
|
||||
|
||||
@property
|
||||
def interval(self) -> int:
|
||||
return self._cfg.farm_interval
|
||||
|
||||
@property
|
||||
def max_accounts(self) -> int:
|
||||
return self._cfg.farm_max_accounts
|
||||
@@ -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,
|
||||
)
|
||||
+848
-58
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
"""Push notifications for highlights and private messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
from bouncer.config import BouncerConfig, ProxyConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Notifier:
|
||||
"""Sends push notifications when no clients are attached."""
|
||||
|
||||
def __init__(self, cfg: BouncerConfig, proxy_cfg: ProxyConfig) -> None:
|
||||
self._url = cfg.notify_url
|
||||
self._on_highlight = cfg.notify_on_highlight
|
||||
self._on_privmsg = cfg.notify_on_privmsg
|
||||
self._cooldown = cfg.notify_cooldown
|
||||
self._use_proxy = cfg.notify_proxy
|
||||
self._proxy_cfg = proxy_cfg
|
||||
self._last_sent: float = 0.0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._url)
|
||||
|
||||
def should_notify(
|
||||
self,
|
||||
nick: str,
|
||||
target: str,
|
||||
text: str,
|
||||
own_nick: str,
|
||||
) -> bool:
|
||||
"""Check if this message warrants a notification."""
|
||||
if not self.enabled:
|
||||
return False
|
||||
if time.monotonic() - self._last_sent < self._cooldown:
|
||||
return False
|
||||
is_pm = not target.startswith(("#", "&", "+", "!"))
|
||||
is_highlight = own_nick.lower() in text.lower()
|
||||
if is_pm and self._on_privmsg:
|
||||
return True
|
||||
if is_highlight and self._on_highlight:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def send(
|
||||
self,
|
||||
network: str,
|
||||
sender: str,
|
||||
target: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
"""Fire notification. Auto-detects ntfy vs generic webhook."""
|
||||
try:
|
||||
connector = None
|
||||
if self._use_proxy:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
connector = ProxyConnector.from_url(
|
||||
f"socks5://{self._proxy_cfg.host}:{self._proxy_cfg.port}",
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
if self._is_ntfy():
|
||||
await self._send_ntfy(session, network, sender, target, text)
|
||||
else:
|
||||
await self._send_webhook(session, network, sender, target, text)
|
||||
|
||||
self._last_sent = time.monotonic()
|
||||
except Exception:
|
||||
log.exception("notification send failed")
|
||||
|
||||
def _is_ntfy(self) -> bool:
|
||||
"""Check if the URL looks like an ntfy endpoint."""
|
||||
hostname = urlparse(self._url).hostname or ""
|
||||
return "ntfy" in hostname
|
||||
|
||||
async def _send_ntfy(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
network: str,
|
||||
sender: str,
|
||||
target: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
"""Send notification via ntfy (POST plain text with headers)."""
|
||||
title = f"{sender} on {target}/{network}"
|
||||
headers = {
|
||||
"Title": title,
|
||||
"Tags": "speech_balloon",
|
||||
}
|
||||
async with session.post(
|
||||
self._url,
|
||||
data=text.encode(),
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
log.warning("ntfy returned %d: %s", resp.status, body[:200])
|
||||
else:
|
||||
log.info("ntfy notification sent: %s -> %s", sender, target)
|
||||
|
||||
async def _send_webhook(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
network: str,
|
||||
sender: str,
|
||||
target: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
"""Send notification via generic webhook (POST JSON)."""
|
||||
payload = {
|
||||
"network": network,
|
||||
"sender": sender,
|
||||
"target": target,
|
||||
"text": text,
|
||||
}
|
||||
async with session.post(
|
||||
self._url,
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
log.warning("webhook returned %d: %s", resp.status, body[:200])
|
||||
else:
|
||||
log.info("webhook notification sent: %s -> %s", sender, target)
|
||||
+74
-29
@@ -6,6 +6,7 @@ import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
from python_socks.async_.asyncio import Proxy
|
||||
|
||||
@@ -15,13 +16,21 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _resolve_all(host: str, port: int) -> list[str]:
|
||||
"""Resolve hostname to all IPv4 addresses locally."""
|
||||
"""Resolve hostname to all IPv4 addresses locally.
|
||||
|
||||
Returns an empty list if local resolution fails (the caller should
|
||||
fall back to remote resolution via the proxy).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
infos = await loop.getaddrinfo(
|
||||
host, port, family=socket.AF_INET, type=socket.SOCK_STREAM,
|
||||
)
|
||||
except OSError:
|
||||
log.debug("local DNS failed for %s, will use remote resolution", host)
|
||||
return []
|
||||
if not infos:
|
||||
raise OSError(f"could not resolve {host}")
|
||||
return []
|
||||
# Deduplicate while preserving order
|
||||
seen: set[str] = set()
|
||||
addrs: list[str] = []
|
||||
@@ -34,49 +43,85 @@ async def _resolve_all(host: str, port: int) -> list[str]:
|
||||
return addrs
|
||||
|
||||
|
||||
async def connect(
|
||||
host: str,
|
||||
port: int,
|
||||
async def _connect_once(
|
||||
proxy_cfg: ProxyConfig,
|
||||
tls: bool = False,
|
||||
dest_host: str,
|
||||
dest_port: int,
|
||||
tls: bool,
|
||||
server_hostname: str,
|
||||
client_cert: Path | None,
|
||||
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||
"""Open a TCP connection through the SOCKS5 proxy.
|
||||
|
||||
Resolves hostnames locally and tries all addresses, since many
|
||||
SOCKS5 proxies cannot do remote DNS resolution reliably.
|
||||
|
||||
Returns an (asyncio.StreamReader, asyncio.StreamWriter) pair.
|
||||
If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake.
|
||||
"""
|
||||
addrs = await _resolve_all(host, port)
|
||||
last_err: Exception | None = None
|
||||
|
||||
for dest_ip in addrs:
|
||||
"""Connect through SOCKS5 to a single destination."""
|
||||
proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}")
|
||||
log.debug(
|
||||
"trying %s (%s):%d via socks5://%s:%d",
|
||||
host, dest_ip, port, proxy_cfg.host, proxy_cfg.port,
|
||||
"trying %s:%d via socks5://%s:%d",
|
||||
dest_host, dest_port, proxy_cfg.host, proxy_cfg.port,
|
||||
)
|
||||
try:
|
||||
sock = await proxy.connect(dest_host=dest_ip, dest_port=port)
|
||||
except Exception as e:
|
||||
log.debug("failed to connect via %s: %s", dest_ip, e)
|
||||
last_err = e
|
||||
continue
|
||||
sock = await proxy.connect(dest_host=dest_host, dest_port=dest_port)
|
||||
|
||||
ssl_ctx: ssl.SSLContext | None = None
|
||||
if tls:
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
# Onion addresses are authenticated by Tor routing; skip hostname check
|
||||
if server_hostname.endswith(".onion"):
|
||||
ssl_ctx.check_hostname = False
|
||||
ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||
if client_cert:
|
||||
ssl_ctx.load_cert_chain(certfile=str(client_cert))
|
||||
log.debug("loaded client cert %s", client_cert)
|
||||
|
||||
reader, writer = await asyncio.open_connection(
|
||||
host=None,
|
||||
port=None,
|
||||
sock=sock,
|
||||
ssl=ssl_ctx,
|
||||
server_hostname=host if tls else None,
|
||||
server_hostname=server_hostname if tls else None,
|
||||
)
|
||||
|
||||
log.debug("connected to %s (%s):%d (tls=%s)", host, dest_ip, port, tls)
|
||||
return reader, writer
|
||||
|
||||
|
||||
async def connect(
|
||||
host: str,
|
||||
port: int,
|
||||
proxy_cfg: ProxyConfig,
|
||||
tls: bool = False,
|
||||
client_cert: Path | None = None,
|
||||
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||
"""Open a TCP connection through the SOCKS5 proxy.
|
||||
|
||||
Resolves hostnames locally first and tries all addresses. If local DNS
|
||||
fails (e.g. onion addresses, proxy-only hostnames), falls back to remote
|
||||
resolution by passing the hostname directly to the SOCKS5 proxy.
|
||||
|
||||
Returns an (asyncio.StreamReader, asyncio.StreamWriter) pair.
|
||||
If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake.
|
||||
If client_cert is given (Path to a combined PEM), it is loaded for CertFP.
|
||||
"""
|
||||
addrs = await _resolve_all(host, port)
|
||||
last_err: Exception | None = None
|
||||
|
||||
if addrs:
|
||||
# Local resolution succeeded -- try each IP
|
||||
for dest_ip in addrs:
|
||||
try:
|
||||
reader, writer = await _connect_once(
|
||||
proxy_cfg, dest_ip, port, tls, host, client_cert,
|
||||
)
|
||||
log.debug("connected to %s (%s):%d (tls=%s)", host, dest_ip, port, tls)
|
||||
return reader, writer
|
||||
except Exception as e:
|
||||
log.debug("failed to connect via %s: %s", dest_ip, e)
|
||||
last_err = e
|
||||
else:
|
||||
# Local resolution failed -- let the proxy resolve the hostname
|
||||
try:
|
||||
reader, writer = await _connect_once(
|
||||
proxy_cfg, host, port, tls, host, client_cert,
|
||||
)
|
||||
log.debug("connected to %s:%d via remote DNS (tls=%s)", host, port, tls)
|
||||
return reader, writer
|
||||
except Exception as e:
|
||||
log.debug("failed to connect via remote DNS for %s: %s", host, e)
|
||||
last_err = e
|
||||
|
||||
raise OSError(f"all addresses for {host} failed via SOCKS5") from last_err
|
||||
|
||||
+268
-48
@@ -4,12 +4,17 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
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.farm import RegistrationManager
|
||||
from bouncer.irc import IRCMessage
|
||||
from bouncer.namespace import decode_target, encode_message
|
||||
from bouncer.network import Network
|
||||
from bouncer.notify import Notifier
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bouncer.client import Client
|
||||
@@ -19,97 +24,275 @@ 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):
|
||||
log.warning("stripped inbound CTCP reply: %s %.80s", msg.prefix, msg.params[1])
|
||||
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"):
|
||||
log.warning("stripped inbound CTCP/DCC: %s %.80s", msg.prefix, text)
|
||||
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()
|
||||
self._notifier = Notifier(config.bouncer, config.proxy)
|
||||
self._farm = RegistrationManager(
|
||||
bouncer_cfg=config.bouncer,
|
||||
networks=config.networks,
|
||||
proxy_resolver=self._proxy_for,
|
||||
backlog=backlog,
|
||||
data_dir=data_dir,
|
||||
)
|
||||
|
||||
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,
|
||||
bouncer_cfg=self.config.bouncer,
|
||||
)
|
||||
self.networks[name] = network
|
||||
self.clients[name] = []
|
||||
asyncio.create_task(network.start())
|
||||
await self._farm.start()
|
||||
|
||||
async def stop_networks(self) -> None:
|
||||
"""Disconnect all networks."""
|
||||
await self._farm.stop()
|
||||
for network in self.networks.values():
|
||||
await network.stop()
|
||||
|
||||
async def attach(self, client: Client, network_name: str) -> Network | None:
|
||||
"""Attach a client to a network. Returns the network or None if not found."""
|
||||
if network_name not in self.networks:
|
||||
return None
|
||||
|
||||
async def attach_all(self, client: Client) -> None:
|
||||
"""Attach a client to all networks."""
|
||||
async with self._lock:
|
||||
self.clients[network_name].append(client)
|
||||
self.clients.append(client)
|
||||
|
||||
network = self.networks[network_name]
|
||||
client_count = len(self.clients[network_name])
|
||||
log.info("client attached to %s (%d clients)", network_name, client_count)
|
||||
log.info("client attached to all networks (%d clients)", len(self.clients))
|
||||
|
||||
# Replay backlog
|
||||
if self.config.bouncer.backlog.replay_on_connect:
|
||||
await self._replay_backlog(client, network_name)
|
||||
|
||||
return network
|
||||
|
||||
async def detach(self, client: Client, network_name: str) -> None:
|
||||
"""Detach a client from a network."""
|
||||
async def detach_all(self, client: Client) -> None:
|
||||
"""Detach a client from all networks."""
|
||||
async with self._lock:
|
||||
if network_name in self.clients:
|
||||
try:
|
||||
self.clients[network_name].remove(client)
|
||||
self.clients.remove(client)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
remaining = len(self.clients.get(network_name, []))
|
||||
log.info("client detached from %s (%d remaining)", network_name, remaining)
|
||||
remaining = len(self.clients)
|
||||
log.info("client detached (%d remaining)", remaining)
|
||||
|
||||
if remaining == 0:
|
||||
await self.backlog.record_disconnect(network_name)
|
||||
# Future: record disconnect for backlog replay
|
||||
|
||||
async def client_to_network(self, network_name: str, msg: IRCMessage) -> None:
|
||||
"""Forward a client command to the network."""
|
||||
async def route_client_message(self, msg: IRCMessage) -> None:
|
||||
"""Decode namespace from a client message and forward to the right network.
|
||||
|
||||
The target in params[0] (or params[1] for some commands) carries a
|
||||
``/network`` suffix that tells us where to route.
|
||||
"""
|
||||
if not msg.params:
|
||||
return
|
||||
|
||||
# Block outbound CTCP/DCC (except ACTION) -- prevents IP leaks
|
||||
if msg.command in ("PRIVMSG", "NOTICE") and len(msg.params) >= 2:
|
||||
text = msg.params[1]
|
||||
if text.startswith(_CTCP_MARKER) and not text.startswith("\x01ACTION"):
|
||||
log.warning("blocked outbound CTCP/DCC: %.80s", text)
|
||||
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:
|
||||
if _suppress(msg):
|
||||
return
|
||||
|
||||
# Inject server-time tag if not present
|
||||
if "time" not in msg.tags:
|
||||
msg.tags["time"] = datetime.now(timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
)
|
||||
|
||||
# Push notification when no clients are attached
|
||||
if not self.clients and self._notifier.enabled:
|
||||
if msg.command == "PRIVMSG" and msg.prefix and len(msg.params) >= 2:
|
||||
sender_nick = msg.prefix.split("!")[0]
|
||||
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)
|
||||
text = msg.params[1]
|
||||
network = self.networks.get(network_name)
|
||||
own_nick = network.nick if network else ""
|
||||
if self._notifier.should_notify(sender_nick, target, text, own_nick):
|
||||
asyncio.create_task(
|
||||
self._notifier.send(network_name, sender_nick, target, text)
|
||||
)
|
||||
|
||||
# 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 +306,54 @@ class Router:
|
||||
|
||||
log.info("replaying %d messages for %s", len(entries), network_name)
|
||||
|
||||
own_nicks = self.get_own_nicks()
|
||||
for entry in entries:
|
||||
ts = datetime.fromtimestamp(entry.timestamp, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
)
|
||||
msg = IRCMessage(
|
||||
command=entry.command,
|
||||
params=[entry.target, entry.content],
|
||||
prefix=entry.sender,
|
||||
tags={"time": ts},
|
||||
)
|
||||
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,
|
||||
bouncer_cfg=self.config.bouncer,
|
||||
)
|
||||
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())
|
||||
@@ -146,3 +361,8 @@ class Router:
|
||||
def get_network(self, name: str) -> Network | None:
|
||||
"""Get a network by name."""
|
||||
return self.networks.get(name)
|
||||
|
||||
@property
|
||||
def farm(self) -> RegistrationManager:
|
||||
"""Access the background account farming manager."""
|
||||
return self._farm
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
|
||||
from bouncer.client import Client
|
||||
from bouncer.config import BouncerConfig
|
||||
@@ -12,7 +13,11 @@ from bouncer.router import Router
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start(config: BouncerConfig, router: Router) -> asyncio.Server:
|
||||
async def start(
|
||||
config: BouncerConfig,
|
||||
router: Router,
|
||||
ssl_ctx: ssl.SSLContext | None = None,
|
||||
) -> asyncio.Server:
|
||||
"""Start the client listener and return the server object."""
|
||||
|
||||
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
@@ -26,9 +31,11 @@ async def start(config: BouncerConfig, router: Router) -> asyncio.Server:
|
||||
_handle,
|
||||
host=config.bind,
|
||||
port=config.port,
|
||||
ssl=ssl_ctx,
|
||||
)
|
||||
|
||||
proto = "tls" if ssl_ctx else "plaintext"
|
||||
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
||||
log.info("listening on %s", addrs)
|
||||
log.info("listening on %s (%s)", addrs, proto)
|
||||
|
||||
return server
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for bouncer.captcha module."""
|
||||
|
||||
from bouncer.captcha import _extract_sitekey
|
||||
|
||||
|
||||
class TestExtractSitekey:
|
||||
"""Test hCaptcha sitekey extraction from HTML."""
|
||||
|
||||
def test_extracts_sitekey_from_div(self) -> None:
|
||||
html = '<div class="h-captcha" data-sitekey="a1b2c3d4-e5f6-7890-abcd-ef1234567890"></div>'
|
||||
assert _extract_sitekey(html) == "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
|
||||
def test_extracts_sitekey_single_quotes(self) -> None:
|
||||
html = "<div class='h-captcha' data-sitekey='10000000-ffff-ffff-ffff-000000000001'></div>"
|
||||
assert _extract_sitekey(html) == "10000000-ffff-ffff-ffff-000000000001"
|
||||
|
||||
def test_returns_none_no_sitekey(self) -> None:
|
||||
html = "<div>No captcha here</div>"
|
||||
assert _extract_sitekey(html) is None
|
||||
|
||||
def test_returns_none_empty_html(self) -> None:
|
||||
assert _extract_sitekey("") is None
|
||||
|
||||
def test_extracts_from_full_page(self) -> None:
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Verify</title></head>
|
||||
<body>
|
||||
<form action="" method="POST">
|
||||
<input type="hidden" name="token" value="abc123">
|
||||
<div class="h-captcha" data-sitekey="abcdef01-2345-6789-abcd-ef0123456789"
|
||||
data-callback="on_success"></div>
|
||||
<input type="submit" value="Verify">
|
||||
</form>
|
||||
</body>
|
||||
</html>"""
|
||||
assert _extract_sitekey(html) == "abcdef01-2345-6789-abcd-ef0123456789"
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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,
|
||||
generate_listener_cert,
|
||||
has_cert,
|
||||
list_certs,
|
||||
listener_cert_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir(tmp_path: Path) -> Path:
|
||||
"""Provide a temporary data directory."""
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestGenerateListenerCert:
|
||||
def test_creates_pem_with_cn_bouncer(self, data_dir: Path) -> None:
|
||||
from cryptography import x509 as x509_mod
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
pem = generate_listener_cert(data_dir)
|
||||
assert pem.is_file()
|
||||
assert pem == listener_cert_path(data_dir)
|
||||
|
||||
cert_data = pem.read_bytes()
|
||||
cert_obj = x509_mod.load_pem_x509_certificate(cert_data)
|
||||
cn = cert_obj.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
||||
assert cn == "bouncer"
|
||||
|
||||
content = pem.read_text()
|
||||
assert "BEGIN CERTIFICATE" in content
|
||||
assert "BEGIN PRIVATE KEY" in content
|
||||
|
||||
mode = pem.stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
def test_idempotent(self, data_dir: Path) -> None:
|
||||
pem1 = generate_listener_cert(data_dir)
|
||||
fp1 = fingerprint(pem1)
|
||||
mtime1 = pem1.stat().st_mtime
|
||||
|
||||
pem2 = generate_listener_cert(data_dir)
|
||||
fp2 = fingerprint(pem2)
|
||||
mtime2 = pem2.stat().st_mtime
|
||||
|
||||
assert pem1 == pem2
|
||||
assert fp1 == fp2
|
||||
assert mtime1 == mtime2 # file not regenerated
|
||||
|
||||
|
||||
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
|
||||
|
||||
def test_custom_validity_days(self, data_dir: Path) -> None:
|
||||
from cryptography import x509 as x509_mod
|
||||
pem = generate_cert(data_dir, "libera", "testnick", validity_days=365)
|
||||
cert_data = pem.read_bytes()
|
||||
cert_obj = x509_mod.load_pem_x509_certificate(cert_data)
|
||||
delta = cert_obj.not_valid_after_utc - cert_obj.not_valid_before_utc
|
||||
assert 364 <= delta.days <= 366
|
||||
|
||||
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
@@ -110,3 +123,80 @@ tls = true
|
||||
"""
|
||||
cfg = load(_write_config(config))
|
||||
assert cfg.networks["test"].port == 6697
|
||||
|
||||
def test_channel_keys_parsed(self):
|
||||
config = """\
|
||||
[bouncer]
|
||||
password = "x"
|
||||
|
||||
[proxy]
|
||||
|
||||
[networks.test]
|
||||
host = "irc.example.com"
|
||||
channels = ["#secret", "#public"]
|
||||
channel_keys = { "#secret" = "hunter2" }
|
||||
"""
|
||||
cfg = load(_write_config(config))
|
||||
net = cfg.networks["test"]
|
||||
assert net.channel_keys == {"#secret": "hunter2"}
|
||||
assert "#secret" in net.channels
|
||||
|
||||
def test_channel_keys_default_empty(self):
|
||||
cfg = load(_write_config(MINIMAL_CONFIG))
|
||||
net = cfg.networks["test"]
|
||||
assert net.channel_keys == {}
|
||||
|
||||
def test_operational_defaults(self):
|
||||
"""Ensure all operational values have sane defaults."""
|
||||
cfg = load(_write_config(MINIMAL_CONFIG))
|
||||
b = cfg.bouncer
|
||||
assert b.probation_seconds == 45
|
||||
assert b.backoff_steps == [1]
|
||||
assert b.nick_timeout == 10
|
||||
assert b.rejoin_delay == 3
|
||||
assert b.http_timeout == 15
|
||||
assert b.captcha_api_key == ""
|
||||
assert b.captcha_poll_interval == 3
|
||||
assert b.captcha_poll_timeout == 120
|
||||
assert b.email_poll_interval == 15
|
||||
assert b.email_max_polls == 30
|
||||
assert b.email_request_timeout == 20
|
||||
assert b.cert_validity_days == 3650
|
||||
|
||||
def test_operational_overrides(self):
|
||||
"""Configurable operational values are parsed from TOML."""
|
||||
config = """\
|
||||
[bouncer]
|
||||
password = "x"
|
||||
probation_seconds = 60
|
||||
backoff_steps = [10, 30, 90]
|
||||
nick_timeout = 20
|
||||
rejoin_delay = 5
|
||||
http_timeout = 30
|
||||
captcha_api_key = "test-key"
|
||||
captcha_poll_interval = 5
|
||||
captcha_poll_timeout = 60
|
||||
email_poll_interval = 10
|
||||
email_max_polls = 20
|
||||
email_request_timeout = 25
|
||||
cert_validity_days = 365
|
||||
|
||||
[proxy]
|
||||
|
||||
[networks.test]
|
||||
host = "irc.example.com"
|
||||
"""
|
||||
cfg = load(_write_config(config))
|
||||
b = cfg.bouncer
|
||||
assert b.probation_seconds == 60
|
||||
assert b.backoff_steps == [10, 30, 90]
|
||||
assert b.nick_timeout == 20
|
||||
assert b.rejoin_delay == 5
|
||||
assert b.http_timeout == 30
|
||||
assert b.captcha_api_key == "test-key"
|
||||
assert b.captcha_poll_interval == 5
|
||||
assert b.captcha_poll_timeout == 60
|
||||
assert b.email_poll_interval == 10
|
||||
assert b.email_max_polls == 20
|
||||
assert b.email_request_timeout == 25
|
||||
assert b.cert_validity_days == 365
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Tests for background account farming."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bouncer.config import BouncerConfig, NetworkConfig, ProxyConfig
|
||||
from bouncer.farm import FarmStats, RegistrationManager
|
||||
|
||||
# -- helpers -----------------------------------------------------------------
|
||||
|
||||
def _bouncer(**overrides: object) -> BouncerConfig:
|
||||
defaults: dict[str, object] = {
|
||||
"farm_enabled": True,
|
||||
"farm_interval": 3600,
|
||||
"farm_max_accounts": 10,
|
||||
"probation_seconds": 1,
|
||||
"backoff_steps": [0],
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return BouncerConfig(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _net_cfg(name: str = "testnet", auth_service: str = "nickserv") -> NetworkConfig:
|
||||
return NetworkConfig(
|
||||
name=name,
|
||||
host="irc.test.net",
|
||||
port=6697,
|
||||
tls=True,
|
||||
auth_service=auth_service,
|
||||
)
|
||||
|
||||
|
||||
def _proxy_resolver(net_cfg: NetworkConfig) -> ProxyConfig:
|
||||
return ProxyConfig(host="127.0.0.1", port=1080)
|
||||
|
||||
|
||||
def _mock_backlog(verified_count: int = 0) -> AsyncMock:
|
||||
bl = AsyncMock()
|
||||
bl.count_verified_creds = AsyncMock(return_value=verified_count)
|
||||
return bl
|
||||
|
||||
|
||||
def _manager(
|
||||
networks: dict[str, NetworkConfig] | None = None,
|
||||
backlog: AsyncMock | None = None,
|
||||
**bouncer_kw: object,
|
||||
) -> RegistrationManager:
|
||||
nets = networks or {"testnet": _net_cfg()}
|
||||
return RegistrationManager(
|
||||
bouncer_cfg=_bouncer(**bouncer_kw),
|
||||
networks=nets,
|
||||
proxy_resolver=_proxy_resolver,
|
||||
backlog=backlog or _mock_backlog(),
|
||||
)
|
||||
|
||||
|
||||
# -- tests -------------------------------------------------------------------
|
||||
|
||||
class TestFarmDisabled:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_noop_when_disabled(self) -> None:
|
||||
"""start() is a no-op when farm_enabled=False."""
|
||||
mgr = _manager(farm_enabled=False)
|
||||
await mgr.start()
|
||||
assert mgr._loop_task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_safe_when_not_started(self) -> None:
|
||||
mgr = _manager(farm_enabled=False)
|
||||
await mgr.stop() # should not raise
|
||||
|
||||
|
||||
class TestFarmSkipsNonNickserv:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_qbot(self) -> None:
|
||||
"""Networks with auth_service='qbot' are skipped."""
|
||||
nets = {"quake": _net_cfg("quake", auth_service="qbot")}
|
||||
mgr = _manager(networks=nets)
|
||||
await mgr._maybe_spawn("quake", nets["quake"])
|
||||
assert "quake" not in mgr._active
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_none(self) -> None:
|
||||
"""Networks with auth_service='none' are skipped."""
|
||||
nets = {"anon": _net_cfg("anon", auth_service="none")}
|
||||
mgr = _manager(networks=nets)
|
||||
await mgr._maybe_spawn("anon", nets["anon"])
|
||||
assert "anon" not in mgr._active
|
||||
|
||||
|
||||
class TestFarmMaxAccounts:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_at_max(self) -> None:
|
||||
"""No spawn when verified count >= farm_max_accounts."""
|
||||
bl = _mock_backlog(verified_count=10)
|
||||
mgr = _manager(backlog=bl, farm_max_accounts=10)
|
||||
net_cfg = _net_cfg()
|
||||
await mgr._maybe_spawn("testnet", net_cfg)
|
||||
assert "testnet" not in mgr._active
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawns_below_max(self) -> None:
|
||||
"""Spawn when verified count < farm_max_accounts."""
|
||||
bl = _mock_backlog(verified_count=5)
|
||||
mgr = _manager(backlog=bl, farm_max_accounts=10)
|
||||
net_cfg = _net_cfg()
|
||||
with patch.object(mgr, "_spawn_ephemeral") as mock_spawn:
|
||||
await mgr._maybe_spawn("testnet", net_cfg)
|
||||
mock_spawn.assert_called_once_with("testnet", net_cfg)
|
||||
|
||||
|
||||
class TestFarmInterval:
|
||||
@pytest.mark.asyncio
|
||||
async def test_respects_cooldown(self) -> None:
|
||||
"""Cooldown enforced between attempts."""
|
||||
import time
|
||||
bl = _mock_backlog(verified_count=0)
|
||||
mgr = _manager(backlog=bl, farm_interval=3600)
|
||||
# Simulate recent attempt
|
||||
mgr._stats["testnet"] = FarmStats(
|
||||
attempts=1, last_attempt=time.time(),
|
||||
)
|
||||
net_cfg = _net_cfg()
|
||||
with patch.object(mgr, "_spawn_ephemeral") as mock_spawn:
|
||||
await mgr._maybe_spawn("testnet", net_cfg)
|
||||
mock_spawn.assert_not_called()
|
||||
|
||||
|
||||
class TestFarmSpawnEphemeral:
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_correct_config(self) -> None:
|
||||
"""Ephemeral Network gets correct config (cred_network, ephemeral, no channels)."""
|
||||
bl = _mock_backlog()
|
||||
mgr = _manager(backlog=bl)
|
||||
net_cfg = _net_cfg()
|
||||
|
||||
with patch("bouncer.farm.Network") as MockNetwork:
|
||||
mock_eph = MagicMock()
|
||||
mock_eph._nickserv_done = asyncio.Event()
|
||||
mock_eph.start = AsyncMock()
|
||||
mock_eph.stop = AsyncMock()
|
||||
MockNetwork.return_value = mock_eph
|
||||
|
||||
mgr._spawn_ephemeral("testnet", net_cfg)
|
||||
|
||||
# Verify Network was constructed with correct params
|
||||
call_kwargs = MockNetwork.call_args[1]
|
||||
assert call_kwargs["cred_network"] == "testnet"
|
||||
assert call_kwargs["ephemeral"] is True
|
||||
assert call_kwargs["on_message"] is None
|
||||
assert call_kwargs["on_status"] is None
|
||||
|
||||
eph_cfg = MockNetwork.call_args[1]["cfg"]
|
||||
assert eph_cfg.name == "_farm_testnet"
|
||||
assert eph_cfg.channels == []
|
||||
assert eph_cfg.host == "irc.test.net"
|
||||
|
||||
assert "testnet" in mgr._active
|
||||
# Cleanup spawned task
|
||||
mgr._active["testnet"].cancel()
|
||||
try:
|
||||
await mgr._active["testnet"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
class TestFarmOneAtATime:
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocks_second_spawn(self) -> None:
|
||||
"""Second spawn blocked while first is active."""
|
||||
bl = _mock_backlog(verified_count=0)
|
||||
mgr = _manager(backlog=bl)
|
||||
net_cfg = _net_cfg()
|
||||
|
||||
# Simulate an active task
|
||||
mgr._active["testnet"] = asyncio.create_task(asyncio.sleep(999))
|
||||
try:
|
||||
with patch.object(mgr, "_spawn_ephemeral") as mock_spawn:
|
||||
await mgr._maybe_spawn("testnet", net_cfg)
|
||||
mock_spawn.assert_not_called()
|
||||
finally:
|
||||
mgr._active["testnet"].cancel()
|
||||
try:
|
||||
await mgr._active["testnet"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
class TestFarmCleanup:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_cancels_active(self) -> None:
|
||||
"""All active ephemerals stopped on stop()."""
|
||||
mgr = _manager()
|
||||
task = asyncio.create_task(asyncio.sleep(999))
|
||||
mgr._active["testnet"] = task
|
||||
mgr._loop_task = asyncio.create_task(asyncio.sleep(999))
|
||||
|
||||
await mgr.stop()
|
||||
assert task.cancelled()
|
||||
assert not mgr._active
|
||||
assert mgr._loop_task is None
|
||||
|
||||
|
||||
class TestFarmStatsTracking:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_updated_on_success(self) -> None:
|
||||
"""FarmStats updated on success."""
|
||||
bl = AsyncMock()
|
||||
# Before: 0 verified, after: 1 verified
|
||||
bl.count_verified_creds = AsyncMock(side_effect=[0, 1])
|
||||
mgr = _manager(backlog=bl)
|
||||
|
||||
mock_eph = MagicMock()
|
||||
done_event = asyncio.Event()
|
||||
done_event.set()
|
||||
mock_eph._nickserv_done = done_event
|
||||
mock_eph.start = AsyncMock()
|
||||
mock_eph.stop = AsyncMock()
|
||||
|
||||
await mgr._run_ephemeral("testnet", mock_eph)
|
||||
stats = mgr._stats["testnet"]
|
||||
assert stats.successes == 1
|
||||
assert stats.last_success > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_updated_on_failure(self) -> None:
|
||||
"""FarmStats updated on failure."""
|
||||
bl = AsyncMock()
|
||||
# Before: 0, after: still 0
|
||||
bl.count_verified_creds = AsyncMock(side_effect=[0, 0])
|
||||
mgr = _manager(backlog=bl)
|
||||
|
||||
mock_eph = MagicMock()
|
||||
done_event = asyncio.Event()
|
||||
done_event.set()
|
||||
mock_eph._nickserv_done = done_event
|
||||
mock_eph.start = AsyncMock()
|
||||
mock_eph.stop = AsyncMock()
|
||||
|
||||
await mgr._run_ephemeral("testnet", mock_eph)
|
||||
stats = mgr._stats["testnet"]
|
||||
assert stats.failures == 1
|
||||
assert stats.last_error == "no new verified account"
|
||||
|
||||
|
||||
class TestFarmManualTrigger:
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_bypasses_cooldown(self) -> None:
|
||||
"""trigger() bypasses cooldown for a specific network."""
|
||||
bl = _mock_backlog()
|
||||
mgr = _manager(backlog=bl)
|
||||
import time
|
||||
mgr._stats["testnet"] = FarmStats(
|
||||
attempts=1, last_attempt=time.time(),
|
||||
)
|
||||
|
||||
with patch("bouncer.farm.Network") as MockNetwork:
|
||||
mock_eph = MagicMock()
|
||||
mock_eph._nickserv_done = asyncio.Event()
|
||||
mock_eph.start = AsyncMock()
|
||||
mock_eph.stop = AsyncMock()
|
||||
MockNetwork.return_value = mock_eph
|
||||
|
||||
result = mgr.trigger("testnet")
|
||||
assert result is True
|
||||
assert "testnet" in mgr._active
|
||||
# Cleanup
|
||||
mgr._active["testnet"].cancel()
|
||||
try:
|
||||
await mgr._active["testnet"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def test_trigger_unknown_network(self) -> None:
|
||||
"""trigger() returns False for unknown network."""
|
||||
mgr = _manager()
|
||||
assert mgr.trigger("nonexistent") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_already_active(self) -> None:
|
||||
"""trigger() returns False when ephemeral already active."""
|
||||
mgr = _manager()
|
||||
mgr._active["testnet"] = asyncio.create_task(asyncio.sleep(999))
|
||||
try:
|
||||
assert mgr.trigger("testnet") is False
|
||||
finally:
|
||||
mgr._active["testnet"].cancel()
|
||||
try:
|
||||
await mgr._active["testnet"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
"""Tests for push notification module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bouncer.config import BouncerConfig, ProxyConfig
|
||||
from bouncer.notify import Notifier
|
||||
|
||||
# -- helpers -----------------------------------------------------------------
|
||||
|
||||
def _cfg(**overrides: object) -> BouncerConfig:
|
||||
defaults: dict[str, object] = {
|
||||
"notify_url": "https://ntfy.sh/bouncer",
|
||||
"notify_on_highlight": True,
|
||||
"notify_on_privmsg": True,
|
||||
"notify_cooldown": 60,
|
||||
"notify_proxy": False,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return BouncerConfig(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _proxy() -> ProxyConfig:
|
||||
return ProxyConfig(host="127.0.0.1", port=1080)
|
||||
|
||||
|
||||
def _notifier(**overrides: object) -> Notifier:
|
||||
return Notifier(_cfg(**overrides), _proxy())
|
||||
|
||||
|
||||
# -- enabled -----------------------------------------------------------------
|
||||
|
||||
class TestEnabled:
|
||||
def test_enabled_with_url(self) -> None:
|
||||
n = _notifier(notify_url="https://ntfy.sh/test")
|
||||
assert n.enabled is True
|
||||
|
||||
def test_disabled_without_url(self) -> None:
|
||||
n = _notifier(notify_url="")
|
||||
assert n.enabled is False
|
||||
|
||||
|
||||
# -- should_notify -----------------------------------------------------------
|
||||
|
||||
class TestShouldNotify:
|
||||
def test_pm_triggers(self) -> None:
|
||||
n = _notifier()
|
||||
assert n.should_notify("sender", "mynick", "hello", "mynick") is True
|
||||
|
||||
def test_highlight_triggers(self) -> None:
|
||||
n = _notifier()
|
||||
assert n.should_notify("sender", "#channel", "hey mynick!", "mynick") is True
|
||||
|
||||
def test_highlight_case_insensitive(self) -> None:
|
||||
n = _notifier()
|
||||
assert n.should_notify("sender", "#channel", "hey MYNICK!", "mynick") is True
|
||||
|
||||
def test_normal_channel_msg_does_not_trigger(self) -> None:
|
||||
n = _notifier()
|
||||
assert n.should_notify("sender", "#channel", "hello world", "mynick") is False
|
||||
|
||||
def test_disabled_does_not_trigger(self) -> None:
|
||||
n = _notifier(notify_url="")
|
||||
assert n.should_notify("sender", "mynick", "hello", "mynick") is False
|
||||
|
||||
def test_pm_disabled(self) -> None:
|
||||
n = _notifier(notify_on_privmsg=False)
|
||||
assert n.should_notify("sender", "mynick", "hello", "mynick") is False
|
||||
|
||||
def test_highlight_disabled(self) -> None:
|
||||
n = _notifier(notify_on_highlight=False)
|
||||
assert n.should_notify("sender", "#channel", "hey mynick!", "mynick") is False
|
||||
|
||||
def test_cooldown_respected(self) -> None:
|
||||
n = _notifier(notify_cooldown=60)
|
||||
n._last_sent = time.monotonic() # just sent
|
||||
assert n.should_notify("sender", "mynick", "hello", "mynick") is False
|
||||
|
||||
def test_cooldown_expired(self) -> None:
|
||||
n = _notifier(notify_cooldown=60)
|
||||
n._last_sent = time.monotonic() - 120 # expired
|
||||
assert n.should_notify("sender", "mynick", "hello", "mynick") is True
|
||||
|
||||
def test_channel_prefixes(self) -> None:
|
||||
"""Targets starting with #, &, +, ! are channels, not PMs."""
|
||||
n = _notifier()
|
||||
for prefix in ("#", "&", "+", "!"):
|
||||
target = f"{prefix}channel"
|
||||
assert n.should_notify("sender", target, "hello", "mynick") is False
|
||||
|
||||
|
||||
# -- _is_ntfy ---------------------------------------------------------------
|
||||
|
||||
class TestIsNtfy:
|
||||
def test_ntfy_sh(self) -> None:
|
||||
n = _notifier(notify_url="https://ntfy.sh/mytopic")
|
||||
assert n._is_ntfy() is True
|
||||
|
||||
def test_self_hosted_ntfy(self) -> None:
|
||||
n = _notifier(notify_url="https://ntfy.example.com/mytopic")
|
||||
assert n._is_ntfy() is True
|
||||
|
||||
def test_generic_webhook(self) -> None:
|
||||
n = _notifier(notify_url="https://hooks.example.com/webhook")
|
||||
assert n._is_ntfy() is False
|
||||
|
||||
|
||||
# -- send --------------------------------------------------------------------
|
||||
|
||||
class TestSend:
|
||||
@pytest.mark.asyncio
|
||||
async def test_ntfy_sends_post(self) -> None:
|
||||
n = _notifier(notify_url="https://ntfy.sh/bouncer")
|
||||
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(return_value=mock_resp)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("bouncer.notify.aiohttp.ClientSession", return_value=mock_session):
|
||||
await n.send("libera", "user", "#test", "hello world")
|
||||
|
||||
mock_session.post.assert_called_once()
|
||||
call_kwargs = mock_session.post.call_args
|
||||
assert call_kwargs[1]["data"] == b"hello world"
|
||||
assert "Title" in call_kwargs[1]["headers"]
|
||||
assert n._last_sent > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_sends_json(self) -> None:
|
||||
n = _notifier(notify_url="https://hooks.example.com/webhook")
|
||||
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(return_value=mock_resp)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("bouncer.notify.aiohttp.ClientSession", return_value=mock_session):
|
||||
await n.send("libera", "user", "#test", "hello world")
|
||||
|
||||
call_kwargs = mock_session.post.call_args
|
||||
assert call_kwargs[1]["json"]["network"] == "libera"
|
||||
assert call_kwargs[1]["json"]["sender"] == "user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_connector_used(self) -> None:
|
||||
n = _notifier(notify_url="https://ntfy.sh/test", notify_proxy=True)
|
||||
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(return_value=mock_resp)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("bouncer.notify.aiohttp.ClientSession", return_value=mock_session):
|
||||
with patch("bouncer.notify.Notifier._send_ntfy", new_callable=AsyncMock):
|
||||
with patch("aiohttp_socks.ProxyConnector.from_url") as mock_proxy:
|
||||
mock_proxy.return_value = MagicMock()
|
||||
await n.send("libera", "user", "#test", "hello")
|
||||
mock_proxy.assert_called_once_with("socks5://127.0.0.1:1080")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_error_does_not_raise(self) -> None:
|
||||
"""Send errors are logged, not propagated."""
|
||||
n = _notifier(notify_url="https://ntfy.sh/test")
|
||||
with patch("bouncer.notify.aiohttp.ClientSession", side_effect=Exception("boom")):
|
||||
await n.send("libera", "user", "#test", "hello") # should not raise
|
||||
@@ -0,0 +1,982 @@
|
||||
"""Tests for message router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bouncer.config import BouncerConfig, Config, NetworkConfig, ProxyConfig
|
||||
from bouncer.irc import IRCMessage
|
||||
from bouncer.router import BACKLOG_COMMANDS, Router, _suppress
|
||||
|
||||
# -- helpers -----------------------------------------------------------------
|
||||
|
||||
def _net_cfg(name: str = "libera", host: str = "irc.libera.chat",
|
||||
port: int = 6697, tls: bool = True,
|
||||
proxy_host: str | None = None,
|
||||
proxy_port: int | None = None) -> NetworkConfig:
|
||||
return NetworkConfig(
|
||||
name=name, host=host, port=port, tls=tls,
|
||||
proxy_host=proxy_host, proxy_port=proxy_port,
|
||||
)
|
||||
|
||||
|
||||
def _config(*net_cfgs: NetworkConfig) -> Config:
|
||||
nets = {n.name: n for n in net_cfgs} if net_cfgs else {
|
||||
"libera": _net_cfg("libera"),
|
||||
}
|
||||
return Config(
|
||||
bouncer=BouncerConfig(),
|
||||
proxy=ProxyConfig(host="127.0.0.1", port=1080),
|
||||
networks=nets,
|
||||
)
|
||||
|
||||
|
||||
def _backlog() -> AsyncMock:
|
||||
bl = AsyncMock()
|
||||
bl.get_last_seen = AsyncMock(return_value=0)
|
||||
bl.replay = AsyncMock(return_value=[])
|
||||
bl.mark_seen = AsyncMock()
|
||||
return bl
|
||||
|
||||
|
||||
def _mock_network(name: str = "libera", nick: str = "botnick",
|
||||
connected: bool = True, channels: set[str] | None = None,
|
||||
topics: dict[str, str] | None = None,
|
||||
names: dict[str, set[str]] | None = None) -> MagicMock:
|
||||
net = MagicMock()
|
||||
net.cfg.name = name
|
||||
net.nick = nick
|
||||
net.connected = connected
|
||||
net.channels = channels or set()
|
||||
net.topics = topics or {}
|
||||
net.names = names or {}
|
||||
net.send = AsyncMock()
|
||||
net.stop = AsyncMock()
|
||||
net.start = AsyncMock()
|
||||
return net
|
||||
|
||||
|
||||
def _mock_client(nick: str = "testuser") -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.nick = nick
|
||||
client.write = MagicMock()
|
||||
return client
|
||||
|
||||
|
||||
def _msg(command: str, params: list[str] | None = None,
|
||||
prefix: str | None = None, tags: dict | None = None) -> IRCMessage:
|
||||
return IRCMessage(
|
||||
command=command,
|
||||
params=params or [],
|
||||
prefix=prefix,
|
||||
tags=tags or {},
|
||||
)
|
||||
|
||||
|
||||
# -- _suppress ---------------------------------------------------------------
|
||||
|
||||
class TestSuppress:
|
||||
def test_suppresses_welcome_numerics(self) -> None:
|
||||
for num in ("001", "002", "003", "004", "005"):
|
||||
assert _suppress(_msg(num, ["nick", "text"])) is True
|
||||
|
||||
def test_suppresses_motd(self) -> None:
|
||||
for num in ("375", "372", "376", "422"):
|
||||
assert _suppress(_msg(num, ["nick", "text"])) is True
|
||||
|
||||
def test_suppresses_lusers(self) -> None:
|
||||
for num in ("250", "251", "252", "253", "254", "255", "265", "266"):
|
||||
assert _suppress(_msg(num, ["nick", "text"])) is True
|
||||
|
||||
def test_suppresses_uid_and_visiblehost(self) -> None:
|
||||
assert _suppress(_msg("042", ["nick", "UID"])) is True
|
||||
assert _suppress(_msg("396", ["nick", "host"])) is True
|
||||
|
||||
def test_suppresses_nick_in_use(self) -> None:
|
||||
assert _suppress(_msg("433", ["*", "nick", "in use"])) is True
|
||||
|
||||
def test_suppresses_server_notice(self) -> None:
|
||||
msg = _msg("NOTICE", ["nick", "server message"], prefix="server.example.com")
|
||||
assert _suppress(msg) is True
|
||||
|
||||
def test_passes_user_notice(self) -> None:
|
||||
msg = _msg("NOTICE", ["nick", "hello"], prefix="user!ident@host")
|
||||
assert _suppress(msg) is False
|
||||
|
||||
def test_suppresses_connection_notice_star(self) -> None:
|
||||
msg = _msg("NOTICE", ["*", "Looking up your hostname..."])
|
||||
assert _suppress(msg) is True
|
||||
|
||||
def test_suppresses_connection_notice_auth(self) -> None:
|
||||
msg = _msg("NOTICE", ["AUTH", "*** Checking Ident"])
|
||||
assert _suppress(msg) is True
|
||||
|
||||
def test_suppresses_ctcp_reply_in_notice(self) -> None:
|
||||
msg = _msg("NOTICE", ["nick", "\x01VERSION mIRC\x01"], prefix="user!i@h")
|
||||
assert _suppress(msg) is True
|
||||
|
||||
def test_suppresses_ctcp_in_privmsg(self) -> None:
|
||||
msg = _msg("PRIVMSG", ["nick", "\x01VERSION\x01"], prefix="user!i@h")
|
||||
assert _suppress(msg) is True
|
||||
|
||||
def test_passes_action_in_privmsg(self) -> None:
|
||||
msg = _msg("PRIVMSG", ["#ch", "\x01ACTION waves\x01"], prefix="user!i@h")
|
||||
assert _suppress(msg) is False
|
||||
|
||||
def test_suppresses_user_mode(self) -> None:
|
||||
msg = _msg("MODE", ["nick", "+i"])
|
||||
assert _suppress(msg) is True
|
||||
|
||||
def test_passes_channel_mode(self) -> None:
|
||||
msg = _msg("MODE", ["#channel", "+o", "nick"])
|
||||
assert _suppress(msg) is False
|
||||
|
||||
def test_passes_normal_privmsg(self) -> None:
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h")
|
||||
assert _suppress(msg) is False
|
||||
|
||||
def test_passes_join(self) -> None:
|
||||
msg = _msg("JOIN", ["#test"], prefix="user!i@h")
|
||||
assert _suppress(msg) is False
|
||||
|
||||
def test_passes_part(self) -> None:
|
||||
msg = _msg("PART", ["#test"], prefix="user!i@h")
|
||||
assert _suppress(msg) is False
|
||||
|
||||
def test_passes_kick(self) -> None:
|
||||
msg = _msg("KICK", ["#test", "nick", "reason"], prefix="op!i@h")
|
||||
assert _suppress(msg) is False
|
||||
|
||||
def test_passes_topic(self) -> None:
|
||||
msg = _msg("TOPIC", ["#test", "new topic"], prefix="user!i@h")
|
||||
assert _suppress(msg) is False
|
||||
|
||||
def test_suppresses_dcc_send(self) -> None:
|
||||
msg = _msg("PRIVMSG", ["nick", "\x01DCC SEND file 3232235777 5000 1024\x01"],
|
||||
prefix="user!i@h")
|
||||
assert _suppress(msg) is True
|
||||
|
||||
def test_suppresses_dcc_chat(self) -> None:
|
||||
msg = _msg("PRIVMSG", ["nick", "\x01DCC CHAT chat 3232235777 5000\x01"],
|
||||
prefix="user!i@h")
|
||||
assert _suppress(msg) is True
|
||||
|
||||
|
||||
# -- Router._proxy_for ------------------------------------------------------
|
||||
|
||||
class TestProxyFor:
|
||||
def test_default_proxy(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
proxy = router._proxy_for(_net_cfg())
|
||||
assert proxy.host == "127.0.0.1"
|
||||
assert proxy.port == 1080
|
||||
|
||||
def test_per_network_proxy_override(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
net = _net_cfg(proxy_host="10.0.0.1", proxy_port=9050)
|
||||
proxy = router._proxy_for(net)
|
||||
assert proxy.host == "10.0.0.1"
|
||||
assert proxy.port == 9050
|
||||
|
||||
def test_per_network_proxy_inherits_port(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
net = _net_cfg(proxy_host="10.0.0.1")
|
||||
proxy = router._proxy_for(net)
|
||||
assert proxy.host == "10.0.0.1"
|
||||
assert proxy.port == 1080 # from global config
|
||||
|
||||
|
||||
# -- Router init and network management --------------------------------------
|
||||
|
||||
class TestNetworkManagement:
|
||||
def test_network_names_empty(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
assert router.network_names() == []
|
||||
|
||||
def test_get_network_none(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
assert router.get_network("nonexistent") is None
|
||||
|
||||
def test_get_network_found(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
assert router.get_network("libera") is net
|
||||
|
||||
def test_network_names(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
router.networks["libera"] = _mock_network("libera")
|
||||
router.networks["oftc"] = _mock_network("oftc")
|
||||
assert sorted(router.network_names()) == ["libera", "oftc"]
|
||||
|
||||
def test_get_own_nicks(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
router.networks["libera"] = _mock_network("libera", nick="lnick")
|
||||
router.networks["oftc"] = _mock_network("oftc", nick="onick")
|
||||
nicks = router.get_own_nicks()
|
||||
assert nicks == {"libera": "lnick", "oftc": "onick"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_network(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
net_cfg = _net_cfg("hackint", host="irc.hackint.org")
|
||||
|
||||
with patch("bouncer.router.Network") as MockNet:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.start = AsyncMock()
|
||||
MockNet.return_value = mock_instance
|
||||
result = await router.add_network(net_cfg)
|
||||
|
||||
assert "hackint" in router.networks
|
||||
assert result is mock_instance
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_network(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
result = await router.remove_network("libera")
|
||||
assert result is True
|
||||
assert "libera" not in router.networks
|
||||
net.stop.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_network_not_found(self) -> None:
|
||||
cfg = _config()
|
||||
router = Router(cfg, _backlog())
|
||||
result = await router.remove_network("nonexistent")
|
||||
assert result is False
|
||||
|
||||
|
||||
# -- Client attach/detach ---------------------------------------------------
|
||||
|
||||
class TestClientAttachDetach:
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_adds_client(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
client = _mock_client()
|
||||
await router.attach_all(client)
|
||||
assert client in router.clients
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detach_removes_client(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
client = _mock_client()
|
||||
await router.attach_all(client)
|
||||
await router.detach_all(client)
|
||||
assert client not in router.clients
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detach_missing_client_no_error(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
client = _mock_client()
|
||||
await router.detach_all(client) # should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_clients(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
c1 = _mock_client("user1")
|
||||
c2 = _mock_client("user2")
|
||||
await router.attach_all(c1)
|
||||
await router.attach_all(c2)
|
||||
assert len(router.clients) == 2
|
||||
|
||||
await router.detach_all(c1)
|
||||
assert len(router.clients) == 1
|
||||
assert c2 in router.clients
|
||||
|
||||
|
||||
# -- stop_networks -----------------------------------------------------------
|
||||
|
||||
class TestStopNetworks:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stops_all(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
n1 = _mock_network("libera")
|
||||
n2 = _mock_network("oftc")
|
||||
router.networks = {"libera": n1, "oftc": n2}
|
||||
|
||||
await router.stop_networks()
|
||||
n1.stop.assert_awaited_once()
|
||||
n2.stop.assert_awaited_once()
|
||||
|
||||
|
||||
# -- route_client_message (outbound: client -> network) ----------------------
|
||||
|
||||
class TestRouteClientMessage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_privmsg_to_channel(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test/libera", "hello"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
net.send.assert_awaited_once()
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.command == "PRIVMSG"
|
||||
assert sent.params == ["#test", "hello"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_privmsg_to_nick(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["user123/libera", "hi there"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.params == ["user123", "hi there"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_namespace_dropped(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_network_dropped(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test/fakenet", "hello"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnected_network_dropped(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera", connected=False)
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test/libera", "hello"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_params_ignored(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
msg = _msg("PRIVMSG")
|
||||
await router.route_client_message(msg) # should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_single_channel(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("JOIN", ["#dev/libera"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.command == "JOIN"
|
||||
assert sent.params == ["#dev"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_comma_separated_same_network(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("JOIN", ["#a/libera,#b/libera"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.params[0] == "#a,#b"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_comma_separated_multi_network(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
n1 = _mock_network("libera")
|
||||
n2 = _mock_network("oftc")
|
||||
router.networks = {"libera": n1, "oftc": n2}
|
||||
|
||||
msg = _msg("JOIN", ["#a/libera,#b/oftc"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
assert n1.send.await_count == 1
|
||||
assert n2.send.await_count == 1
|
||||
s1 = n1.send.call_args[0][0]
|
||||
s2 = n2.send.call_args[0][0]
|
||||
assert s1.params[0] == "#a"
|
||||
assert s2.params[0] == "#b"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_part(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PART", ["#dev/libera", "leaving"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.command == "PART"
|
||||
assert sent.params == ["#dev", "leaving"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kick(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("KICK", ["#test/libera", "baduser/libera", "bye"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.command == "KICK"
|
||||
assert sent.params == ["#test", "baduser", "bye"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kick_no_namespace_dropped(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("KICK", ["#test", "baduser"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("INVITE", ["user/libera", "#test/libera"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.command == "INVITE"
|
||||
assert sent.params == ["user", "#test"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_network_from_either_param(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
# Network suffix only on the nick, not the channel
|
||||
msg = _msg("INVITE", ["user/libera", "#test"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mode_channel(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("MODE", ["#test/libera", "+o", "nick"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.params == ["#test", "+o", "nick"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_who(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("WHO", ["#test/libera"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.params == ["#test"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notice(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("NOTICE", ["user/libera", "you've been warned"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.params == ["user", "you've been warned"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topic(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("TOPIC", ["#test/libera", "new topic"])
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.params == ["#test", "new topic"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_tags(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test/libera", "hi"],
|
||||
tags={"label": "abc"})
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.tags == {"label": "abc"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_prefix(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test/libera", "hi"], prefix="me!u@h")
|
||||
await router.route_client_message(msg)
|
||||
|
||||
sent = net.send.call_args[0][0]
|
||||
assert sent.prefix == "me!u@h"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocks_outbound_dcc_send(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["user/libera", "\x01DCC SEND file 3232235777 5000 1024\x01"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocks_outbound_dcc_chat(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["user/libera", "\x01DCC CHAT chat 3232235777 5000\x01"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_outbound_action(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test/libera", "\x01ACTION waves\x01"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_outbound_normal_privmsg(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera")
|
||||
router.networks["libera"] = net
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test/libera", "just a normal message"])
|
||||
await router.route_client_message(msg)
|
||||
net.send.assert_awaited_once()
|
||||
|
||||
|
||||
# -- _dispatch (inbound: network -> clients) ---------------------------------
|
||||
|
||||
class TestDispatch:
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivers_to_all_clients(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
net = _mock_network("libera", nick="bot")
|
||||
router.networks["libera"] = net
|
||||
c1 = _mock_client("user1")
|
||||
c2 = _mock_client("user2")
|
||||
router.clients = [c1, c2]
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="sender!u@h")
|
||||
await router._dispatch("libera", msg)
|
||||
|
||||
assert c1.write.call_count == 1
|
||||
assert c2.write.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppressed_not_delivered(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera")
|
||||
client = _mock_client()
|
||||
router.clients = [client]
|
||||
|
||||
msg = _msg("001", ["nick", "Welcome"])
|
||||
await router._dispatch("libera", msg)
|
||||
client.write.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_namespaces_channel(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client("me")
|
||||
router.clients = [client]
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
|
||||
written = client.write.call_args[0][0]
|
||||
assert b"#test/libera" in written
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_namespaces_prefix(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client("me")
|
||||
router.clients = [client]
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="sender!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
|
||||
written = client.write.call_args[0][0]
|
||||
assert b"sender/libera" in written
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_own_nick_rewritten_to_client_nick(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client("clientnick")
|
||||
router.clients = [client]
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="bot!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
|
||||
written = client.write.call_args[0][0]
|
||||
assert b"clientnick" in written
|
||||
assert b"bot/libera" not in written
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_write_error_handled(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
bad_client = _mock_client()
|
||||
bad_client.write.side_effect = ConnectionResetError
|
||||
good_client = _mock_client()
|
||||
router.clients = [bad_client, good_client]
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
|
||||
# Bad client raised, but good client still received
|
||||
good_client.write.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_clients_no_error(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera")
|
||||
router.clients = []
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h")
|
||||
await router._dispatch("libera", msg) # should not raise
|
||||
|
||||
|
||||
# -- _on_network_status ------------------------------------------------------
|
||||
|
||||
class TestOnNetworkStatus:
|
||||
def test_broadcasts_to_all_clients(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
c1 = _mock_client()
|
||||
c2 = _mock_client()
|
||||
router.clients = [c1, c2]
|
||||
|
||||
router._on_network_status("libera", "connection stable")
|
||||
|
||||
assert c1.write.call_count == 1
|
||||
assert c2.write.call_count == 1
|
||||
written = c1.write.call_args[0][0]
|
||||
assert b"[libera] connection stable" in written
|
||||
assert b"bouncer" in written # prefix
|
||||
|
||||
def test_client_error_does_not_propagate(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
bad = _mock_client()
|
||||
bad.write.side_effect = ConnectionResetError
|
||||
good = _mock_client()
|
||||
router.clients = [bad, good]
|
||||
|
||||
router._on_network_status("libera", "test")
|
||||
good.write.assert_called_once()
|
||||
|
||||
|
||||
# -- _on_network_message (sync -> async bridge) -----------------------------
|
||||
|
||||
class TestOnNetworkMessage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_dispatch_task(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera")
|
||||
|
||||
with patch.object(router, "_dispatch", new_callable=AsyncMock) as mock_disp:
|
||||
msg = _msg("PRIVMSG", ["#test", "hi"], prefix="u!i@h")
|
||||
router._on_network_message("libera", msg)
|
||||
# Let the task run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_disp.assert_awaited_once_with("libera", msg)
|
||||
|
||||
|
||||
# -- _replay_backlog ---------------------------------------------------------
|
||||
|
||||
class TestReplayBacklog:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_backlog(self) -> None:
|
||||
bl = _backlog()
|
||||
router = Router(_config(), bl)
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client()
|
||||
|
||||
await router._replay_backlog(client, "libera")
|
||||
client.write.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replays_messages(self) -> None:
|
||||
bl = _backlog()
|
||||
entry1 = MagicMock()
|
||||
entry1.id = 1
|
||||
entry1.command = "PRIVMSG"
|
||||
entry1.target = "#test"
|
||||
entry1.content = "hello"
|
||||
entry1.sender = "user!i@h"
|
||||
entry2 = MagicMock()
|
||||
entry2.id = 2
|
||||
entry2.command = "PRIVMSG"
|
||||
entry2.target = "#test"
|
||||
entry2.content = "world"
|
||||
entry2.sender = "other!i@h"
|
||||
bl.replay.return_value = [entry1, entry2]
|
||||
|
||||
router = Router(_config(), bl)
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client("me")
|
||||
|
||||
await router._replay_backlog(client, "libera")
|
||||
|
||||
assert client.write.call_count == 2
|
||||
# Verify namespaced
|
||||
first = client.write.call_args_list[0][0][0]
|
||||
assert b"#test/libera" in first
|
||||
# Should mark last seen
|
||||
bl.mark_seen.assert_awaited_once_with("libera", 2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replays_since_last_seen(self) -> None:
|
||||
bl = _backlog()
|
||||
bl.get_last_seen.return_value = 42
|
||||
|
||||
router = Router(_config(), bl)
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client()
|
||||
|
||||
await router._replay_backlog(client, "libera")
|
||||
bl.replay.assert_awaited_once_with("libera", since_id=42)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppressed_skipped_during_replay(self) -> None:
|
||||
bl = _backlog()
|
||||
entry = MagicMock()
|
||||
entry.id = 1
|
||||
entry.command = "NOTICE"
|
||||
entry.target = "nick"
|
||||
entry.content = "\x01VERSION mIRC\x01"
|
||||
entry.sender = "server.example.com" # no '!' -> server notice
|
||||
bl.replay.return_value = [entry]
|
||||
|
||||
router = Router(_config(), bl)
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client()
|
||||
|
||||
await router._replay_backlog(client, "libera")
|
||||
client.write.assert_not_called()
|
||||
# Still marks seen even if all suppressed
|
||||
bl.mark_seen.assert_awaited_once_with("libera", 1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_error_stops_replay(self) -> None:
|
||||
bl = _backlog()
|
||||
entry1 = MagicMock()
|
||||
entry1.id = 1
|
||||
entry1.command = "PRIVMSG"
|
||||
entry1.target = "#test"
|
||||
entry1.content = "msg1"
|
||||
entry1.sender = "user!i@h"
|
||||
entry2 = MagicMock()
|
||||
entry2.id = 2
|
||||
entry2.command = "PRIVMSG"
|
||||
entry2.target = "#test"
|
||||
entry2.content = "msg2"
|
||||
entry2.sender = "user!i@h"
|
||||
bl.replay.return_value = [entry1, entry2]
|
||||
|
||||
router = Router(_config(), bl)
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client()
|
||||
client.write.side_effect = [ConnectionResetError, None]
|
||||
|
||||
await router._replay_backlog(client, "libera")
|
||||
# Should have stopped after first error
|
||||
assert client.write.call_count == 1
|
||||
|
||||
|
||||
# -- BACKLOG_COMMANDS constant -----------------------------------------------
|
||||
|
||||
class TestBacklogCommands:
|
||||
def test_expected_commands(self) -> None:
|
||||
assert "PRIVMSG" in BACKLOG_COMMANDS
|
||||
assert "NOTICE" in BACKLOG_COMMANDS
|
||||
assert "TOPIC" in BACKLOG_COMMANDS
|
||||
assert "KICK" in BACKLOG_COMMANDS
|
||||
assert "MODE" in BACKLOG_COMMANDS
|
||||
|
||||
def test_join_not_in_backlog(self) -> None:
|
||||
assert "JOIN" not in BACKLOG_COMMANDS
|
||||
assert "PART" not in BACKLOG_COMMANDS
|
||||
assert "QUIT" not in BACKLOG_COMMANDS
|
||||
|
||||
|
||||
# -- server-time tag injection -----------------------------------------------
|
||||
|
||||
class TestServerTimeDispatch:
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_time_tag_when_missing(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client("me")
|
||||
router.clients = [client]
|
||||
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
|
||||
assert "time" in msg.tags
|
||||
# Verify ISO8601 format
|
||||
assert msg.tags["time"].endswith("Z")
|
||||
assert "T" in msg.tags["time"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_existing_time_tag(self) -> None:
|
||||
router = Router(_config(), _backlog())
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client("me")
|
||||
router.clients = [client]
|
||||
|
||||
original_time = "2025-01-15T12:00:00.000000Z"
|
||||
msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h",
|
||||
tags={"time": original_time})
|
||||
await router._dispatch("libera", msg)
|
||||
|
||||
assert msg.tags["time"] == original_time
|
||||
|
||||
|
||||
class TestServerTimeReplay:
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_injects_timestamp(self) -> None:
|
||||
bl = _backlog()
|
||||
entry = MagicMock()
|
||||
entry.id = 1
|
||||
entry.command = "PRIVMSG"
|
||||
entry.target = "#test"
|
||||
entry.content = "hello"
|
||||
entry.sender = "user!i@h"
|
||||
entry.timestamp = 1705320000.0 # 2024-01-15T12:00:00Z
|
||||
bl.replay.return_value = [entry]
|
||||
|
||||
router = Router(_config(), bl)
|
||||
router.networks["libera"] = _mock_network("libera", nick="bot")
|
||||
client = _mock_client("me")
|
||||
|
||||
await router._replay_backlog(client, "libera")
|
||||
|
||||
written = client.write.call_args[0][0]
|
||||
# The time tag should be in the wire format
|
||||
assert b"time=" in written
|
||||
|
||||
|
||||
# -- push notifications ------------------------------------------------------
|
||||
|
||||
class TestNotifications:
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_triggered_on_pm_no_clients(self) -> None:
|
||||
cfg = _config()
|
||||
cfg.bouncer.notify_url = "https://ntfy.sh/test"
|
||||
router = Router(cfg, _backlog())
|
||||
net = _mock_network("libera", nick="bot")
|
||||
router.networks["libera"] = net
|
||||
router.clients = [] # no clients
|
||||
|
||||
with patch.object(router._notifier, "should_notify", return_value=True):
|
||||
with patch.object(router._notifier, "send", new_callable=AsyncMock) as mock_send:
|
||||
msg = _msg("PRIVMSG", ["bot", "hello bot"], prefix="user!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
# Let fire-and-forget task run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_send.assert_awaited_once_with("libera", "user", "bot", "hello bot")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_not_triggered_with_clients(self) -> None:
|
||||
cfg = _config()
|
||||
cfg.bouncer.notify_url = "https://ntfy.sh/test"
|
||||
router = Router(cfg, _backlog())
|
||||
net = _mock_network("libera", nick="bot")
|
||||
router.networks["libera"] = net
|
||||
client = _mock_client()
|
||||
router.clients = [client]
|
||||
|
||||
with patch.object(router._notifier, "send", new_callable=AsyncMock) as mock_send:
|
||||
msg = _msg("PRIVMSG", ["bot", "hello bot"], prefix="user!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_respects_should_notify(self) -> None:
|
||||
cfg = _config()
|
||||
cfg.bouncer.notify_url = "https://ntfy.sh/test"
|
||||
router = Router(cfg, _backlog())
|
||||
net = _mock_network("libera", nick="bot")
|
||||
router.networks["libera"] = net
|
||||
router.clients = []
|
||||
|
||||
with patch.object(router._notifier, "should_notify", return_value=False):
|
||||
with patch.object(router._notifier, "send", new_callable=AsyncMock) as mock_send:
|
||||
msg = _msg("PRIVMSG", ["#channel", "random msg"], prefix="user!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_disabled_skips(self) -> None:
|
||||
cfg = _config()
|
||||
cfg.bouncer.notify_url = "" # disabled
|
||||
router = Router(cfg, _backlog())
|
||||
net = _mock_network("libera", nick="bot")
|
||||
router.networks["libera"] = net
|
||||
router.clients = []
|
||||
|
||||
with patch.object(router._notifier, "send", new_callable=AsyncMock) as mock_send:
|
||||
msg = _msg("PRIVMSG", ["bot", "hello"], prefix="user!i@h")
|
||||
await router._dispatch("libera", msg)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_send.assert_not_awaited()
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for TCP server with optional TLS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bouncer.cert import generate_listener_cert
|
||||
from bouncer.config import BouncerConfig
|
||||
from bouncer.server import start
|
||||
|
||||
|
||||
def _bouncer_cfg(**overrides) -> BouncerConfig:
|
||||
defaults = {"bind": "127.0.0.1", "port": 0} # port 0 = OS-assigned
|
||||
defaults.update(overrides)
|
||||
return BouncerConfig(**defaults)
|
||||
|
||||
|
||||
def _mock_router() -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def _make_ssl_ctx(data_dir: Path) -> ssl.SSLContext:
|
||||
"""Build a server SSL context from an auto-generated listener cert."""
|
||||
pem = generate_listener_cert(data_dir)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.load_cert_chain(certfile=str(pem))
|
||||
return ctx
|
||||
|
||||
|
||||
def _make_client_ssl_ctx() -> ssl.SSLContext:
|
||||
"""Build a client SSL context that trusts any self-signed cert."""
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestStartPlaintext:
|
||||
async def test_accepts_connection(self) -> None:
|
||||
"""Plaintext listener starts and accepts a TCP connection."""
|
||||
cfg = _bouncer_cfg()
|
||||
router = _mock_router()
|
||||
|
||||
with patch("bouncer.server.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.handle = AsyncMock()
|
||||
server = await start(cfg, router)
|
||||
|
||||
addr = server.sockets[0].getsockname()
|
||||
reader, writer = await asyncio.open_connection(addr[0], addr[1])
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
assert mock_client_cls.called
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
server.close()
|
||||
|
||||
|
||||
class TestStartWithTLS:
|
||||
async def test_accepts_tls_connection(self, data_dir: Path) -> None:
|
||||
"""TLS listener starts and accepts a TLS connection."""
|
||||
cfg = _bouncer_cfg()
|
||||
router = _mock_router()
|
||||
ssl_ctx = _make_ssl_ctx(data_dir)
|
||||
|
||||
with patch("bouncer.server.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.handle = AsyncMock()
|
||||
server = await start(cfg, router, ssl_ctx=ssl_ctx)
|
||||
|
||||
addr = server.sockets[0].getsockname()
|
||||
client_ctx = _make_client_ssl_ctx()
|
||||
reader, writer = await asyncio.open_connection(
|
||||
addr[0], addr[1], ssl=client_ctx,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
assert mock_client_cls.called
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
server.close()
|
||||
|
||||
async def test_tls_handshake_and_auth(self, data_dir: Path) -> None:
|
||||
"""TLS handshake succeeds and IRC data flows encrypted."""
|
||||
cfg = _bouncer_cfg()
|
||||
router = _mock_router()
|
||||
ssl_ctx = _make_ssl_ctx(data_dir)
|
||||
|
||||
received_lines: list[bytes] = []
|
||||
|
||||
async def _fake_handle(obj: MagicMock) -> None:
|
||||
"""Minimal handler: read one line, echo a 001."""
|
||||
data = await obj._reader.readline()
|
||||
received_lines.append(data)
|
||||
obj._writer.write(b":bouncer 001 test :Welcome\r\n")
|
||||
await obj._writer.drain()
|
||||
|
||||
def _make_client(reader, writer, router_, password_):
|
||||
obj = MagicMock()
|
||||
obj._reader = reader
|
||||
obj._writer = writer
|
||||
obj.handle = lambda: _fake_handle(obj)
|
||||
return obj
|
||||
|
||||
with patch("bouncer.server.Client", side_effect=_make_client):
|
||||
server = await start(cfg, router, ssl_ctx=ssl_ctx)
|
||||
|
||||
addr = server.sockets[0].getsockname()
|
||||
client_ctx = _make_client_ssl_ctx()
|
||||
reader, writer = await asyncio.open_connection(
|
||||
addr[0], addr[1], ssl=client_ctx,
|
||||
)
|
||||
|
||||
writer.write(b"PASS testpass\r\n")
|
||||
await writer.drain()
|
||||
|
||||
response = await asyncio.wait_for(reader.readline(), timeout=2.0)
|
||||
assert b"001" in response
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
server.close()
|
||||
|
||||
assert len(received_lines) == 1
|
||||
assert b"PASS testpass" in received_lines[0]
|
||||
|
||||
async def test_plaintext_rejected_on_tls(self, data_dir: Path) -> None:
|
||||
"""Non-TLS bytes on a TLS listener get dropped."""
|
||||
cfg = _bouncer_cfg()
|
||||
router = _mock_router()
|
||||
ssl_ctx = _make_ssl_ctx(data_dir)
|
||||
|
||||
with patch("bouncer.server.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.handle = AsyncMock()
|
||||
server = await start(cfg, router, ssl_ctx=ssl_ctx)
|
||||
|
||||
addr = server.sockets[0].getsockname()
|
||||
|
||||
# Connect without TLS to a TLS listener
|
||||
reader, writer = await asyncio.open_connection(addr[0], addr[1])
|
||||
|
||||
writer.write(b"PASS hello\r\n")
|
||||
await writer.drain()
|
||||
|
||||
# Server should close the connection (EOF)
|
||||
data = await asyncio.wait_for(reader.read(1024), timeout=2.0)
|
||||
assert data == b""
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
server.close()
|
||||
Reference in New Issue
Block a user