Compare commits

..

39 Commits

Author SHA1 Message Date
user 2b893969d2 fix: switch to alpine base image and upgrade pip
CI / secrets (push) Successful in 8s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 26s
CI / build (push) Successful in 31s
Replace python:3.12-slim (Debian) with python:3.12-alpine to reduce
image size and eliminate 68 Debian-inherited CVEs. Upgrade pip to
resolve CVE-2025-8869. Build deps installed temporarily for native
extensions (cryptography) and removed after pip install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:54:16 +01:00
user f9f38adadc fix: bake source into container image for production builds
CI / secrets (push) Successful in 9s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 24s
CI / build (push) Successful in 21s
Install deps from requirements.txt for better layer caching and COPY
src/ into the image so pushed artifacts are self-contained. Remove
VOLUME /app/src -- runtime config mount (/data) is sufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:54:42 +01:00
user f3eae9291b fix: use docker build --push for buildx container driver
CI / secrets (push) Successful in 9s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 26s
CI / build (push) Successful in 17s
Docker buildx with docker-container driver doesn't load images
into the local daemon. Use --push to push directly during build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:45:52 +01:00
user 5eb64d034e fix: gitleaks version resolution and allowlist test fixtures
CI / secrets (push) Successful in 9s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 26s
CI / build (push) Failing after 28s
Resolve gitleaks version dynamically via GitHub redirect.
Add .gitleaks.toml to allowlist dummy hCaptcha sitekeys in
test_captcha.py (false positives on test UUIDs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:26:30 +01:00
user 18992c63e1 fix: resolve gitleaks version dynamically from GitHub API
CI / secrets (push) Failing after 9s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 26s
CI / build (push) Has been skipped
The /releases/latest/download/ URL doesn't expand version in
the filename. Query the redirect to resolve actual version first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:24:47 +01:00
user ed513251db fix: run all CI jobs in containers via container directive
CI / secrets (push) Failing after 9s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 25s
CI / build (push) Has been skipped
The linux runner has no git/node on host and rootless podman
lacks namespace privileges. Use container: directive for every
job: alpine for secrets (gitleaks binary), docker:latest for
build (docker socket mounted by runner).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:20:20 +01:00
user f14d067779 fix: use alpine/git container for checkout on host jobs
CI / secrets (push) Failing after 0s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 25s
CI / build (push) Has been skipped
The linux runner has podman but no git. Clone repos via
alpine/git container for secrets and build host jobs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:15:42 +01:00
user aae9b0f771 fix: replace all actions/checkout with git clone
CI / secrets (push) Failing after 0s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 26s
CI / build (push) Has been skipped
The linux runner (anvil) has no Node.js, so actions/checkout@v4
fails on every job. Use manual git clone consistently across
all jobs — container and host alike.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:08:22 +01:00
user e9c8290f9c fix: run gitleaks via podman on host instead of container directive
CI / secrets (push) Failing after 1s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 26s
CI / build (push) Has been skipped
The gitleaks image lacks a shell compatible with the runner's
script injection. Use podman run with volume mount on the host
instead, matching the proven s5p pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:43:36 +01:00
user 875997aa45 fix: single-line git clone for gitleaks container
CI / secrets (push) Failing after 4s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 30s
CI / build (push) Has been skipped
Multiline run with backslash continuation gets corrupted inside
the gitleaks container. Collapse to single line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:12:11 +01:00
user 900813fc20 fix: replace actions/checkout with git clone in container jobs
CI / secrets (push) Failing after 3s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 26s
CI / build (push) Has been skipped
The alpine and gitleaks container images lack Node.js, which
actions/checkout@v4 requires. Use manual git clone instead.
Build job stays on host where actions/checkout works natively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:07:12 +01:00
user 28f78567df refactor: use native container directive for CI jobs
CI / secrets (push) Failing after 4s
CI / lint (push) Failing after 6s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
Replace manual podman run invocations with the runner's container:
directive for lint, test, and secrets jobs. Cleaner step definitions,
no volume mounts needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:51:50 +01:00
user 2f7b82047d feat: add Gitea CI pipeline with podman containers
CI / lint (push) Failing after 2s
CI / secrets (push) Failing after 2s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
Lint, test, secrets scan, and Harbor build/push jobs running
on linux runner using ephemeral podman containers. Adds
requirements.txt for container-based pip installs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:33:36 +01:00
user 1ea72011b7 fix: reduce reconnect backoff to 1s flat
Exponential backoff up to 300s made no sense with rotating Tor exits
where each reconnect gets a fresh IP. Single 1s delay is sufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:39:30 +01:00
user 0064e52fee feat: DCC stripping in both directions to prevent IP leaks
Block all non-ACTION CTCP/DCC from client-to-server (outbound) and add
security logging when inbound CTCP/DCC is stripped. Hard boundary with
no config toggle -- DCC exposes the client's real IP which defeats the
stealth proxy architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:30:44 +01:00
user f4f3132b6b feat: systemd user service file for headless deployment
Hardened unit with ProtectSystem/ProtectHome, auto-restart on failure,
and ExecReload for SIGHUP hot config reload. Docs updated with setup,
management, and enable-linger instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:25:36 +01:00
user 638f12dbb3 fix: resolve all pre-existing ruff lint errors
Fix E501 line-too-long in backlog.py, network.py, test_network.py.
Fix F541 f-string-without-placeholders in network.py.
Fix I001 unsorted imports in network.py.
Remove unused datetime import in test_cert.py (F401).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:13:34 +01:00
user 2ab5f95476 feat: SIGHUP hot reload for headless config updates
Add signal handler that calls rehash() on SIGHUP, logging results
instead of sending to a client. Useful for systemd and container
environments where no IRC client is attached. Update docs with
channel key config, hot reload section, and roadmap checkoffs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:03:35 +01:00
user c11bd5555a feat: channel key support for +k channels
Add channel_keys dict to NetworkConfig for storing per-channel keys.
Keys are used in KICK rejoin, passed via AUTOJOIN +#channel key syntax,
supported in ADDNETWORK channel_keys= parameter, and propagated through
REHASH. Extract rehash() as reusable async function for SIGHUP reuse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:03:23 +01:00
user bf4a589fc5 feat: client-side TLS for encrypted client connections
Accept TLS-encrypted connections from IRC clients. Auto-generates a
self-signed EC P-256 listener certificate (bouncer.pem) when no custom
cert is provided. Remove CTCP response items from roadmap (stealth by
design -- router already suppresses all CTCP except ACTION).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 18:47:20 +01:00
user bfcebad6dd feat: background account farming with ephemeral connections
Add RegistrationManager that periodically spawns ephemeral Network
connections to register new NickServ accounts across all configured
networks. Ephemeral connections reuse the existing registration
lifecycle (random nick, email verification, captcha solving) with
two new Network parameters: cred_network (redirect credential storage
to the real network name) and ephemeral (suppress status broadcasts,
skip SASL/IDENTIFY, go straight to REGISTER).

- backlog: add count_verified_creds() query
- config: farm_enabled, farm_interval, farm_max_accounts
- network: cred_network/ephemeral params, credential ref redirection
- farm: new module with sweep loop, per-network cooldown, stats
- router: farm lifecycle integration, farm property
- commands: FARM (status/trigger) and ACCOUNTS (list stored creds)
- tests: 14 farm tests + 5 ephemeral/cred_network network tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 18:17:22 +01:00
user ae8de25b27 fix: verify success completion signal + cross-session verify_url restore
_on_verify_success() was missing _nickserv_complete() call, causing
_go_ready() to hang at _nickserv_done.wait() when registration
completed immediately (no email verification needed).

get_pending_registration() was not returning verify_url from the DB,
so _resume_pending_verification() never restored self._verify_url --
breaking cross-session captcha resume for OFTC-style networks.

Four regression tests added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:55:33 +01:00
user 0d762ced49 feat: PING watchdog, IRCv3 server-time, push notifications
PING watchdog sends PING after configurable silence interval and
disconnects on timeout, detecting stale connections that TCP alone
misses. IRCv3 server-time capability is requested on every connection;
timestamps are injected on dispatch and backlog replay for clients
that support message tags. Push notifications via ntfy or generic
webhook fire on highlights and PMs when no clients are attached,
with configurable cooldown and optional SOCKS5 routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:41:38 +01:00
user 4dd817ea75 test: add 94 tests for network connection manager
Cover state machine, markov nick generation, SASL negotiation,
NickServ/Q-bot auth flows, probation timer, reconnection backoff,
read loop buffering, and IRC message handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:11:58 +01:00
user b8d8c22dc8 feat: add --cprofile flag for runtime profiling
Writes cProfile stats to a file on shutdown for performance analysis.
Enable in compose.yaml for container profiling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:54:29 +01:00
user d13d090e8e feat: make all operational constants configurable via bouncer.toml
Replace hardcoded values across network, captcha, email, and cert
modules with BouncerConfig fields. All values have safe defaults
and are overridable in the [bouncer] section of the config file.

Configurable: probation_seconds, backoff_steps, nick_timeout,
rejoin_delay, http_timeout, captcha_poll_interval/timeout,
email_poll_interval/max_polls/request_timeout, cert_validity_days.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:33:08 +01:00
user ed576b002d feat: auto-visit OFTC verification URLs, captcha fallback
Detect /verify/ URLs in NickServ notices and attempt automated
verification via SOCKS proxy (POST with token). If the page requires
a captcha, save creds as pending and log the URL for manual visit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:49:28 +01:00
user 246b77e90a feat: Q bot auth for QuakeNet, configurable auth_service
Add auth_service config field ("nickserv", "qbot", "none") to support
networks with non-standard auth systems. QuakeNet uses Q bot AUTH
instead of NickServ. Also bumps NickServ timeout from 15s to 30s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:42:46 +01:00
user 0e06a18851 feat: per-network proxy override, CERT ADD timing fix
config: add optional proxy_host/proxy_port to NetworkConfig
router: resolve per-network proxy via _proxy_for() helper
commands: trigger REHASH reconnect on proxy config changes
network: send CERT ADD before CAP END to beat K-line race

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:03:58 +01:00
43 changed files with 9945 additions and 294 deletions
+83
View File
@@ -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 .
+4
View File
@@ -15,6 +15,10 @@ build/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
*.log *.log
*.prof
# Personal config (keep example only) # Personal config (keep example only)
config/bouncer.toml config/bouncer.toml
# Client certificates (generated per-network)
certs/
+3
View File
@@ -0,0 +1,3 @@
[allowlist]
description = "Test fixture false positives"
paths = ["tests/test_captcha\\.py"]
+8 -5
View File
@@ -1,16 +1,19 @@
FROM python:3.12-slim FROM python:3.12-alpine
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir \ COPY requirements.txt .
"python-socks[asyncio]>=2.4" \ RUN apk add --no-cache --virtual .build gcc musl-dev libffi-dev openssl-dev && \
"aiosqlite>=0.19" 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 PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONPATH=/app/src ENV PYTHONPATH=/app/src
VOLUME /app/src
VOLUME /data VOLUME /data
ENTRYPOINT ["python", "-m", "bouncer"] ENTRYPOINT ["python", "-m", "bouncer"]
+3 -2
View File
@@ -35,11 +35,12 @@ DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY
|--------|---------------| |--------|---------------|
| `irc.py` | IRC protocol parser/formatter (RFC 2812 subset) | | `irc.py` | IRC protocol parser/formatter (RFC 2812 subset) |
| `config.py` | TOML configuration loading and validation | | `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 | | `proxy.py` | SOCKS5 async connector with local DNS + multi-IP failover |
| `network.py` | Server connection state machine, stealth registration | | `network.py` | Server connection state machine, stealth registration |
| `server.py` | TCP listener accepting IRC client connections | | `server.py` | TCP listener accepting IRC client connections |
| `client.py` | Per-client session, PASS/NICK/USER handshake | | `client.py` | Per-client session, PASS/NICK/USER handshake, multi-network attach |
| `router.py` | Message routing between clients and networks | | `router.py` | Namespaced message routing between clients and networks |
| `backlog.py` | SQLite message storage and replay | | `backlog.py` | SQLite message storage and replay |
### Key Decisions ### Key Decisions
+12 -4
View File
@@ -4,12 +4,12 @@ IRC bouncer with SOCKS5 proxy support and persistent message backlog.
## Features ## 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 - All outbound connections routed through SOCKS5 proxy
- Stealth connect: registers with a random pronounceable nick and generic identity - 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 - Probation window: waits 15s after registration to detect K-lines before revealing real nick
- Persistent message backlog (SQLite) with replay on reconnect - Persistent message backlog (SQLite) with replay on reconnect
- Multiple clients can attach to the same network session - Multiple clients can attach simultaneously
- Password authentication - Password authentication
- TLS support for IRC server connections - TLS support for IRC server connections
- Automatic reconnection with exponential backoff - 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: 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 ## How It Works
+23 -13
View File
@@ -1,6 +1,6 @@
# Roadmap # Roadmap
## v0.1.0 (current) ## v0.1.0 (done)
- [x] IRC protocol parser/formatter - [x] IRC protocol parser/formatter
- [x] TOML configuration - [x] TOML configuration
@@ -11,27 +11,37 @@
- [x] Backlog replay on reconnect - [x] Backlog replay on reconnect
- [x] Automatic reconnection with exponential backoff - [x] Automatic reconnection with exponential backoff
- [x] Nick collision handling - [x] Nick collision handling
- [x] TLS support - [x] TLS support (server-side)
- [x] Stealth connect (random markov-generated identity) - [x] Stealth connect (random markov-generated identity)
- [x] Probation window (K-line detection before revealing nick) - [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) - [x] NickServ auto-registration + email verification
- [ ] SASL authentication to IRC servers - [x] SASL PLAIN authentication
- [ ] CTCP VERSION/PING response - [x] SASL EXTERNAL (CertFP) authentication
- [ ] Channel key support (JOIN #channel key) - [x] Client certificate generation + management
- [ ] Configurable probation duration - [x] hCaptcha auto-solving (NoCaptchaAI)
- [ ] Configurable backlog timestamp format - [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 ## v0.3.0
- [ ] Hot config reload (SIGHUP) - [x] Client-side TLS (accept TLS from clients)
- [ ] Systemd service file - [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) - [ ] Per-client backlog tracking (multi-user)
- [ ] Web status page - [ ] Web status page
- [ ] DCC passthrough - [ ] Containerfile for podman deployment
## v1.0.0 ## v1.0.0
+16 -3
View File
@@ -11,9 +11,22 @@
- [x] P1: Integration testing with live IRC server (Libera.Chat) - [x] P1: Integration testing with live IRC server (Libera.Chat)
- [x] P1: Verified SOCKS5 proxy connectivity end-to-end - [x] P1: Verified SOCKS5 proxy connectivity end-to-end
- [x] P1: Documentation update - [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 ## Next
- [ ] P2: Client-side TLS support - [x] P2: Client-side TLS support
- [ ] P2: SASL authentication - [x] P2: Channel key support
- [ ] P3: Systemd service file - [x] P2: Hot config reload (SIGHUP + REHASH refactor)
- [x] P3: Systemd service file
- [ ] P3: Containerfile for podman deployment
+4 -9
View File
@@ -2,18 +2,13 @@
## Features ## 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 - [ ] Web status dashboard
- [ ] DCC passthrough - [ ] Per-client backlog tracking (multi-user)
- [ ] Farm: configurable ephemeral deadline
- [ ] Farm: per-network enable/disable override
## Infrastructure ## Infrastructure
- [ ] Systemd unit file
- [ ] Containerfile for podman deployment - [ ] Containerfile for podman deployment
- [ ] PyPI packaging - [ ] PyPI packaging
@@ -23,4 +18,4 @@
- [ ] SOCKS5 proxy failure tests - [ ] SOCKS5 proxy failure tests
- [ ] Backlog replay edge cases - [ ] Backlog replay edge cases
- [ ] Concurrent client attach/detach - [ ] Concurrent client attach/detach
- [ ] Probation timeout / K-line detection tests - [ ] Farm ephemeral lifecycle integration tests
+1 -1
View File
@@ -15,4 +15,4 @@ services:
volumes: volumes:
- ./src:/app/src:Z,ro - ./src:/app/src:Z,ro
- ./config:/data:Z - ./config:/data:Z
command: ["-c", "/data/bouncer.toml", "-v"] command: ["-c", "/data/bouncer.toml", "-v", "--cprofile", "/data/bouncer.prof"]
+27
View File
@@ -3,6 +3,27 @@ bind = "127.0.0.1"
port = 6667 port = 6667
password = "changeme" 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] [bouncer.backlog]
max_messages = 10000 max_messages = 10000
replay_on_connect = true replay_on_connect = true
@@ -11,11 +32,16 @@ replay_on_connect = true
host = "127.0.0.1" host = "127.0.0.1"
port = 1080 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. # Registration uses a random nick and generic ident/realname.
# After surviving the probation window (no k-line), the bouncer # After surviving the probation window (no k-line), the bouncer
# derives a stable nick from the exit endpoint hostname. The same # derives a stable nick from the exit endpoint hostname. The same
# exit IP always produces the same nick across reconnects. # exit IP always produces the same nick across reconnects.
# Set nick to override (optional, used as fallback only). # Set nick to override (optional, used as fallback only).
# Network names must not contain '/' (reserved for namespace separator).
[networks.libera] [networks.libera]
host = "irc.libera.chat" host = "irc.libera.chat"
@@ -23,6 +49,7 @@ port = 6697
tls = true tls = true
# nick = "mynick" # optional: override host-derived nick # nick = "mynick" # optional: override host-derived nick
channels = ["#test"] channels = ["#test"]
# channel_keys = { "#secret" = "hunter2" } # keys for +k channels
autojoin = true autojoin = true
# [networks.oftc] # [networks.oftc]
+36
View File
@@ -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
View File
@@ -9,6 +9,18 @@ bouncer --version # version
bouncer --help # help 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 ## Podman
```bash ```bash
@@ -38,27 +50,154 @@ make clean # rm .venv, build artifacts
## Client Auth ## Client Auth
``` ```
PASS <network>:<password> # select network + authenticate PASS <password> # authenticate (all networks)
PASS <password> # use first network ```
## 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 ## Connection States
``` ```
DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (45s) -> READY
``` ```
| State | What happens | | State | What happens |
|-------|-------------| |-------|-------------|
| CONNECTING | TCP + SOCKS5 + TLS handshake | | CONNECTING | TCP + SOCKS5 + TLS handshake |
| REGISTERING | Random nick/user/realname sent to server | | 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 | | READY | Switch to configured nick, join channels |
## Auth Cascade
```
SASL EXTERNAL (cert + creds) > SASL PLAIN (creds) > NickServ IDENTIFY
```
## Reconnect Backoff ## 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 ## Config Skeleton
@@ -66,6 +205,19 @@ DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY
```toml ```toml
[bouncer] [bouncer]
bind / port / password 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] [bouncer.backlog]
max_messages / replay_on_connect max_messages / replay_on_connect
@@ -75,6 +227,7 @@ host / port
[networks.<name>] # repeatable [networks.<name>] # repeatable
host / port / tls host / port / tls
nick / channels / autojoin nick / channels / autojoin
channel_keys # keys for +k channels
password # optional, IRC server PASS password # optional, IRC server PASS
``` ```
@@ -84,7 +237,10 @@ password # optional, IRC server PASS
|------|---------| |------|---------|
| `config/bouncer.toml` | Active config (gitignored) | | `config/bouncer.toml` | Active config (gitignored) |
| `config/bouncer.example.toml` | Example template | | `config/bouncer.example.toml` | Example template |
| `config/bouncer.service` | Systemd user service unit |
| `config/bouncer.db` | SQLite backlog (auto-created) | | `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 ## Backlog Queries
@@ -107,10 +263,16 @@ src/bouncer/
cli.py # argparse cli.py # argparse
config.py # TOML loader config.py # TOML loader
irc.py # IRC message parse/format irc.py # IRC message parse/format
proxy.py # SOCKS5 connector (local DNS, multi-IP) namespace.py # /network encode/decode for multiplexing
network.py # server connection + state machine proxy.py # SOCKS5 connector (local DNS, multi-IP, CertFP)
network.py # server connection + state machine + SASL
client.py # client session handler 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 server.py # TCP listener
backlog.py # SQLite store/replay/prune backlog.py # SQLite store/replay/prune
``` ```
+39
View File
@@ -68,6 +68,45 @@ Verify:
which bouncer 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 ## Dependencies
Installed automatically by `make dev`: Installed automatically by `make dev`:
+561 -34
View File
@@ -35,8 +35,8 @@ No fixed prefix or pattern -- each attempt looks like a different person.
### 2. Probation (15 seconds) ### 2. Probation (15 seconds)
After registration succeeds (001 RPL_WELCOME), the bouncer enters a 15-second After registration succeeds (001 RPL_WELCOME), the bouncer enters a probation
probation window. During this time it watches for: window (default 45s, configurable via `probation_seconds`). During this time it watches for:
- `ERROR` messages (K-line, ban) - `ERROR` messages (K-line, ban)
- Server closing the connection - Server closing the connection
@@ -54,16 +54,11 @@ Once probation passes without incident:
### 4. Reconnection ### 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 | Reconnection delay is **1 second** (flat, no escalation). Each attempt gets a
|---------|-------| fresh random identity and potentially a different exit IP.
| 1 | 5s |
| 2 | 10s |
| 3 | 30s |
| 4 | 60s |
| 5 | 120s |
| 6+ | 300s |
Each reconnection uses a fresh random identity. 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` | | Server | `127.0.0.1` |
| Port | `6667` (or as configured) | | Port | `6667` (or as configured) |
| Password | `networkname:yourpassword` | | Password | `yourpassword` |
### Password Format ### Password Format
``` ```
PASS <network>:<password> PASS <password>
``` ```
- `network` -- matches a `[networks.NAME]` section in config The password is the `bouncer.password` value from config. A single connection
- `password` -- the `bouncer.password` value from config automatically attaches to **all** configured networks.
If you omit the network prefix (`PASS yourpassword`), the first configured
network is used.
### Client Examples ### Client Examples
**irssi:** **irssi:**
``` ```
/connect -password libera:mypassword 127.0.0.1 6667 /connect -password mypassword 127.0.0.1 6667
``` ```
**weechat:** **weechat:**
``` ```
/server add bouncer 127.0.0.1/6667 -password=libera:mypassword /server add bouncer 127.0.0.1/6667 -password=mypassword
/connect bouncer /connect bouncer
``` ```
**hexchat:** **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 The bouncer can accept TLS-encrypted connections from IRC clients. This
persistent server connection through the SOCKS5 proxy. encrypts the password and all traffic between your client and the bouncer.
Connect your client with the appropriate network prefix: ### Setup
``` ```toml
PASS libera:mypassword # connects to [networks.libera] [bouncer]
PASS oftc:mypassword # connects to [networks.oftc] client_tls = true
``` ```
Multiple clients can attach to the same network simultaneously. All receive On first start with `client_tls = true`, the bouncer auto-generates a
the same messages in real time. 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 ## 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 1. **Backlog replay** -- missed messages (namespaced) from all networks
2. **Synthetic welcome** -- 001-004 numeric replies from the bouncer 2. **Synthetic welcome** -- 001-004 numeric replies listing all networks
3. **Channel state** -- TOPIC and NAMES for each joined channel 3. **Channel state** -- synthetic JOIN, TOPIC, and NAMES for every joined
channel across all networks (all namespaced with `/network` suffix)
## Backlog ## Backlog
@@ -151,6 +218,83 @@ replay_on_connect = true # set false to disable replay
Stored commands: `PRIVMSG`, `NOTICE`, `TOPIC`, `KICK`, `MODE`. 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 ## Configuration Reference
```toml ```toml
@@ -159,6 +303,47 @@ bind = "127.0.0.1" # listen address
port = 6667 # listen port port = 6667 # listen port
password = "changeme" # client authentication password 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] [bouncer.backlog]
max_messages = 10000 # per network, 0 = unlimited max_messages = 10000 # per network, 0 = unlimited
replay_on_connect = true # replay missed messages on client connect 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 tls = true # use TLS for server connection
nick = "mynick" # desired IRC nick (set after probation) nick = "mynick" # desired IRC nick (set after probation)
channels = ["#test"] # channels to join (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) autojoin = true # auto-join channels on ready (default: true)
password = "" # IRC server password (optional, for PASS command) 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 ## Stopping
Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing
+6
View File
@@ -10,6 +10,9 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
"python-socks[asyncio]>=2.4", "python-socks[asyncio]>=2.4",
"aiosqlite>=0.19", "aiosqlite>=0.19",
"aiohttp>=3.9",
"aiohttp-socks>=0.8",
"cryptography>=41.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -19,6 +22,9 @@ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=0.23", "pytest-asyncio>=0.23",
] ]
browser = [
"playwright>=1.40",
]
[project.scripts] [project.scripts]
bouncer = "bouncer.__main__:main" bouncer = "bouncer.__main__:main"
+5
View File
@@ -0,0 +1,5 @@
python-socks[asyncio]>=2.4
aiosqlite>=0.19
aiohttp>=3.9
aiohttp-socks>=0.8
cryptography>=41.0
+68 -4
View File
@@ -5,12 +5,16 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import signal import signal
import ssl
import sys import sys
import time
from pathlib import Path from pathlib import Path
from bouncer import commands
from bouncer.backlog import Backlog from bouncer.backlog import Backlog
from bouncer.cert import fingerprint, generate_listener_cert
from bouncer.cli import parse_args from bouncer.cli import parse_args
from bouncer.config import load from bouncer.config import BouncerConfig, load
from bouncer.router import Router from bouncer.router import Router
from bouncer.server import start from bouncer.server import start
@@ -21,7 +25,32 @@ def _setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO 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" fmt = "\033[2m%(asctime)s\033[0m %(levelname)-5s \033[38;5;110m%(name)s\033[0m %(message)s"
datefmt = "%H:%M:%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: 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) backlog = Backlog(db_path)
await backlog.open() 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() 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 # Graceful shutdown on SIGINT/SIGTERM
loop = asyncio.get_running_loop() 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): for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _signal_handler) 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() await stop_event.wait()
server.close() server.close()
@@ -71,6 +123,18 @@ def main() -> None:
sys.exit(1) sys.exit(1)
try: 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)) asyncio.run(_run(args.config, args.verbose))
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
+190
View File
@@ -29,8 +29,26 @@ CREATE TABLE IF NOT EXISTS client_state (
last_seen_id INTEGER NOT NULL DEFAULT 0, last_seen_id INTEGER NOT NULL DEFAULT 0,
last_disconnect REAL 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) @dataclass(slots=True)
class BacklogEntry: class BacklogEntry:
@@ -57,8 +75,19 @@ class Backlog:
self._db = await aiosqlite.connect(self._path) self._db = await aiosqlite.connect(self._path)
await self._db.executescript(SCHEMA) await self._db.executescript(SCHEMA)
await self._db.commit() await self._db.commit()
await self._run_migrations()
log.debug("backlog database opened: %s", self._path) 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: async def close(self) -> None:
"""Close the database connection.""" """Close the database connection."""
if self._db: if self._db:
@@ -141,6 +170,167 @@ class Backlog:
await self._db.commit() await self._db.commit()
return cursor.rowcount # type: ignore[return-value] 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: async def _max_id(self, network: str) -> int:
"""Get the maximum message ID for a network.""" """Get the maximum message ID for a network."""
assert self._db is not None assert self._db is not None
+124
View File
@@ -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
+181
View File
@@ -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
+7
View File
@@ -25,6 +25,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
action="store_true", action="store_true",
help="enable debug logging", 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( parser.add_argument(
"--version", "--version",
action="version", action="version",
+72 -53
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bouncer import commands
from bouncer.irc import IRCMessage, parse from bouncer.irc import IRCMessage, parse
from bouncer.namespace import encode_channel
if TYPE_CHECKING: if TYPE_CHECKING:
from bouncer.network import Network
from bouncer.router import Router from bouncer.router import Router
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -45,8 +47,6 @@ class Client:
self._writer = writer self._writer = writer
self._router = router self._router = router
self._password = password self._password = password
self._network_name: str | None = None
self._network: Network | None = None
self._nick: str = "*" self._nick: str = "*"
self._user: str = "unknown" self._user: str = "unknown"
self._realname: str = "" self._realname: str = ""
@@ -56,8 +56,14 @@ class Client:
self._got_nick: bool = False self._got_nick: bool = False
self._got_user: bool = False self._got_user: bool = False
self._pass_raw: str = "" self._pass_raw: str = ""
self._connected_at: float = time.time()
self._addr = writer.get_extra_info("peername", ("?", 0)) 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: async def handle(self) -> None:
"""Main client session loop.""" """Main client session loop."""
log.info("client connected from %s", self._addr) log.info("client connected from %s", self._addr)
@@ -106,8 +112,26 @@ class Client:
if msg.command == "QUIT": if msg.command == "QUIT":
return return
if msg.command in FORWARD_COMMANDS and self._network_name: # Block registration commands -- never forward to networks
await self._router.client_to_network(self._network_name, msg) 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: async def _handle_registration(self, msg: IRCMessage) -> None:
"""Handle PASS/NICK/USER registration sequence.""" """Handle PASS/NICK/USER registration sequence."""
@@ -137,17 +161,11 @@ class Client:
await self._complete_registration() await self._complete_registration()
async def _complete_registration(self) -> None: async def _complete_registration(self) -> None:
"""Validate credentials and attach to network.""" """Validate credentials and attach to all networks."""
# Parse PASS: "network:password" or just "password" (use first network) password = self._pass_raw if self._got_pass else ""
network_name: str | None = None
password: str = ""
if self._got_pass and ":" in self._pass_raw: if not password:
network_name, password = self._pass_raw.split(":", 1) self._send_error("Password required (PASS <password>)")
elif self._got_pass:
password = self._pass_raw
else:
self._send_error("Password required (PASS network:password)")
self._writer.close() self._writer.close()
return return
@@ -156,46 +174,26 @@ class Client:
self._writer.close() self._writer.close()
return 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._authenticated = True
self._registered = True self._registered = True
self._network_name = network_name
# Attach to network # Attach to all networks at once
self._network = await self._router.attach(self, network_name) await self._router.attach_all(self)
if not self._network:
self._send_error(f"Unknown network: {network_name}")
self._writer.close()
return
log.info( log.info("client %s authenticated (nick=%s)", self._addr, self._nick)
"client %s authenticated for network %s (nick=%s)",
self._addr, network_name, self._nick,
)
# Send synthetic welcome
await self._send_welcome() await self._send_welcome()
async def _send_welcome(self) -> None: async def _send_welcome(self) -> None:
"""Send IRC welcome sequence and channel state to client.""" """Send IRC welcome sequence and channel state from all networks."""
assert self._network is not None
server_name = "bouncer" 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( self._send_msg(IRCMessage(
command=RPL_WELCOME, prefix=server_name, 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( self._send_msg(IRCMessage(
command=RPL_YOURHOST, prefix=server_name, command=RPL_YOURHOST, prefix=server_name,
@@ -210,29 +208,51 @@ class Client:
params=[nick, server_name, "bouncer-0.1", "o", "o"], params=[nick, server_name, "bouncer-0.1", "o", "o"],
)) ))
# Send channel state for joined channels # Send namespaced channel state from every network
for channel in self._network.channels: 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
topic = self._network.topics.get(channel, "") topic = network.topics.get(channel, "")
if topic: if topic:
self._send_msg(IRCMessage( self._send_msg(IRCMessage(
command=RPL_TOPIC, prefix=server_name, command=RPL_TOPIC, prefix=server_name,
params=[nick, channel, topic], params=[nick, ns_channel, topic],
)) ))
# Names # Names
names = self._network.names.get(channel, set()) names = network.names.get(channel, set())
if names: if names:
name_str = " ".join(sorted(names)) name_str = " ".join(sorted(names))
self._send_msg(IRCMessage( self._send_msg(IRCMessage(
command=RPL_NAMREPLY, prefix=server_name, command=RPL_NAMREPLY, prefix=server_name,
params=[nick, "=", channel, name_str], params=[nick, "=", ns_channel, name_str],
)) ))
self._send_msg(IRCMessage( self._send_msg(IRCMessage(
command=RPL_ENDOFNAMES, prefix=server_name, 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: def _send_msg(self, msg: IRCMessage) -> None:
"""Send an IRCMessage to this client.""" """Send an IRCMessage to this client."""
self.write(msg.format()) self.write(msg.format())
@@ -242,9 +262,8 @@ class Client:
self._send_msg(IRCMessage(command="ERROR", params=[text])) self._send_msg(IRCMessage(command="ERROR", params=[text]))
async def _cleanup(self) -> None: 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) log.info("client disconnected from %s", self._addr)
if self._network_name: await self._router.detach_all(self)
await self._router.detach(self, self._network_name)
if not self._writer.is_closing(): if not self._writer.is_closing():
self._writer.close() self._writer.close()
+948
View File
@@ -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
View File
@@ -43,8 +43,12 @@ class NetworkConfig:
user: str = "" user: str = ""
realname: str = "" realname: str = ""
channels: list[str] = field(default_factory=list) 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 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) @dataclass(slots=True)
@@ -56,6 +60,47 @@ class BouncerConfig:
password: str = "changeme" password: str = "changeme"
backlog: BacklogConfig = field(default_factory=BacklogConfig) 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) @dataclass(slots=True)
class Config: class Config:
@@ -79,6 +124,31 @@ def load(path: Path) -> Config:
port=bouncer_raw.get("port", 6667), port=bouncer_raw.get("port", 6667),
password=bouncer_raw.get("password", "changeme"), password=bouncer_raw.get("password", "changeme"),
backlog=BacklogConfig(**backlog_raw), 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", {}) proxy_raw = raw.get("proxy", {})
@@ -98,11 +168,22 @@ def load(path: Path) -> Config:
user=net_raw.get("user", ""), user=net_raw.get("user", ""),
realname=net_raw.get("realname", ""), realname=net_raw.get("realname", ""),
channels=net_raw.get("channels", []), channels=net_raw.get("channels", []),
channel_keys=dict(net_raw.get("channel_keys", {})),
autojoin=net_raw.get("autojoin", True), autojoin=net_raw.get("autojoin", True),
password=net_raw.get("password"), 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: if not networks:
raise ValueError("at least one network must be configured") 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) return Config(bouncer=bouncer, proxy=proxy, networks=networks)
+767
View File
@@ -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
+241
View File
@@ -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
+134
View File
@@ -0,0 +1,134 @@
"""Network namespace encoding/decoding for multi-network multiplexing.
Channels and nicks are suffixed with /network so a single client connection
can see traffic from all networks at once.
Client sees: Wire:
#libera/libera <-> #libera (on libera network)
user123/libera <-> user123 (on libera network)
"""
from __future__ import annotations
from bouncer.irc import IRCMessage, parse_prefix
SEPARATOR = "/"
def encode_channel(channel: str, network: str) -> str:
"""Suffix a channel with its network name. ``#ch`` -> ``#ch/net``."""
return f"{channel}{SEPARATOR}{network}"
def decode_channel(namespaced: str) -> tuple[str, str | None]:
"""Split ``#ch/net`` -> ``('#ch', 'net')``. No separator returns ``(channel, None)``."""
idx = namespaced.rfind(SEPARATOR)
if idx < 0:
return namespaced, None
return namespaced[:idx], namespaced[idx + 1:]
def encode_nick(
nick: str,
network: str,
own_nicks: dict[str, str],
client_nick: str | None = None,
) -> str:
"""Suffix a nick unless it belongs to us on *any* network.
If *client_nick* is set, own nicks are rewritten to match the client's
registered nick so IRC clients recognise them as "self".
"""
if nick in own_nicks.values():
return client_nick if client_nick else nick
return f"{nick}{SEPARATOR}{network}"
def encode_prefix(
prefix: str,
network: str,
own_nicks: dict[str, str],
client_nick: str | None = None,
) -> str:
"""Namespace the nick portion of ``nick!user@host``."""
nick, user, host = parse_prefix(prefix)
enc = encode_nick(nick, network, own_nicks, client_nick=client_nick)
if user and host:
return f"{enc}!{user}@{host}"
if user:
return f"{enc}!{user}"
if host:
return f"{enc}@{host}"
return enc
def decode_target(target: str) -> tuple[str, str | None]:
"""Decode a namespaced target (channel or nick).
``#ch/net`` -> ``('#ch', 'net')``
``nick/net`` -> ``('nick', 'net')``
``#ch`` -> ``('#ch', None)``
"""
idx = target.rfind(SEPARATOR)
if idx < 0:
return target, None
return target[:idx], target[idx + 1:]
def _is_channel(target: str) -> bool:
"""Check if a target looks like a channel name."""
return target.startswith(("#", "&", "+", "!"))
def encode_message(
msg: IRCMessage,
network: str,
own_nicks: dict[str, str],
client_nick: str | None = None,
) -> IRCMessage:
"""Namespace an entire IRC message for delivery to a client.
- Channels in params get ``/network`` suffix
- Prefix nick gets ``/network`` suffix (unless it's our own)
- Own nicks rewritten to *client_nick* when set
- Special handling for 353 (NAMREPLY nick list) and NICK (new nick in trailing)
"""
prefix = msg.prefix
if prefix:
prefix = encode_prefix(prefix, network, own_nicks, client_nick=client_nick)
params = list(msg.params)
if msg.command == "353" and len(params) >= 4:
# RPL_NAMREPLY: params = [nick, "=", #channel, "nick1 @nick2 +nick3"]
params[2] = encode_channel(params[2], network)
nicks = params[3].split()
encoded = []
for n in nicks:
# Strip mode prefixes (@, +, %, ~, &)
mode_prefix = ""
bare = n
while bare and bare[0] in "@+%~&":
mode_prefix += bare[0]
bare = bare[1:]
encoded.append(mode_prefix + encode_nick(
bare, network, own_nicks, client_nick=client_nick,
))
params[3] = " ".join(encoded)
elif msg.command == "NICK" and params:
# NICK: params[0] is the new nick
new_nick = params[0]
params[0] = encode_nick(new_nick, network, own_nicks, client_nick=client_nick)
else:
# Generic: namespace channel-like params[0]
if params and _is_channel(params[0]):
params[0] = encode_channel(params[0], network)
return IRCMessage(
command=msg.command,
params=params,
prefix=prefix,
tags=msg.tags,
)
+848 -58
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -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
View File
@@ -6,6 +6,7 @@ import asyncio
import logging import logging
import socket import socket
import ssl import ssl
from pathlib import Path
from python_socks.async_.asyncio import Proxy 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]: 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() loop = asyncio.get_running_loop()
try:
infos = await loop.getaddrinfo( infos = await loop.getaddrinfo(
host, port, family=socket.AF_INET, type=socket.SOCK_STREAM, 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: if not infos:
raise OSError(f"could not resolve {host}") return []
# Deduplicate while preserving order # Deduplicate while preserving order
seen: set[str] = set() seen: set[str] = set()
addrs: list[str] = [] addrs: list[str] = []
@@ -34,49 +43,85 @@ async def _resolve_all(host: str, port: int) -> list[str]:
return addrs return addrs
async def connect( async def _connect_once(
host: str,
port: int,
proxy_cfg: ProxyConfig, 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]: ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
"""Open a TCP connection through the SOCKS5 proxy. """Connect through SOCKS5 to a single destination."""
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:
proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}") proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}")
log.debug( log.debug(
"trying %s (%s):%d via socks5://%s:%d", "trying %s:%d via socks5://%s:%d",
host, dest_ip, port, proxy_cfg.host, proxy_cfg.port, dest_host, dest_port, proxy_cfg.host, proxy_cfg.port,
) )
try: sock = await proxy.connect(dest_host=dest_host, dest_port=dest_port)
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
ssl_ctx: ssl.SSLContext | None = None ssl_ctx: ssl.SSLContext | None = None
if tls: if tls:
ssl_ctx = ssl.create_default_context() 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( reader, writer = await asyncio.open_connection(
host=None, host=None,
port=None, port=None,
sock=sock, sock=sock,
ssl=ssl_ctx, 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 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 raise OSError(f"all addresses for {host} failed via SOCKS5") from last_err
+268 -48
View File
@@ -4,12 +4,17 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bouncer.backlog import Backlog from bouncer.backlog import Backlog
from bouncer.config import Config from bouncer.config import Config, NetworkConfig, ProxyConfig
from bouncer.irc import IRCMessage, parse_prefix 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.network import Network
from bouncer.notify import Notifier
if TYPE_CHECKING: if TYPE_CHECKING:
from bouncer.client import Client from bouncer.client import Client
@@ -19,97 +24,275 @@ log = logging.getLogger(__name__)
# Commands worth storing in backlog # Commands worth storing in backlog
BACKLOG_COMMANDS = {"PRIVMSG", "NOTICE", "TOPIC", "KICK", "MODE"} 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: class Router:
"""Central message hub linking clients to networks.""" """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.config = config
self.backlog = backlog self.backlog = backlog
self.data_dir = data_dir
self.networks: dict[str, Network] = {} self.networks: dict[str, Network] = {}
self.clients: dict[str, list[Client]] = {} # network_name -> clients self.clients: list[Client] = []
self._lock = asyncio.Lock() 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: async def start_networks(self) -> None:
"""Connect to all configured networks.""" """Connect to all configured networks."""
for name, net_cfg in self.config.networks.items(): for name, net_cfg in self.config.networks.items():
network = Network( network = Network(
cfg=net_cfg, 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_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.networks[name] = network
self.clients[name] = []
asyncio.create_task(network.start()) asyncio.create_task(network.start())
await self._farm.start()
async def stop_networks(self) -> None: async def stop_networks(self) -> None:
"""Disconnect all networks.""" """Disconnect all networks."""
await self._farm.stop()
for network in self.networks.values(): for network in self.networks.values():
await network.stop() await network.stop()
async def attach(self, client: Client, network_name: str) -> Network | None: async def attach_all(self, client: Client) -> None:
"""Attach a client to a network. Returns the network or None if not found.""" """Attach a client to all networks."""
if network_name not in self.networks:
return None
async with self._lock: async with self._lock:
self.clients[network_name].append(client) self.clients.append(client)
network = self.networks[network_name] log.info("client attached to all networks (%d clients)", len(self.clients))
client_count = len(self.clients[network_name])
log.info("client attached to %s (%d clients)", network_name, client_count)
# Replay backlog async def detach_all(self, client: Client) -> None:
if self.config.bouncer.backlog.replay_on_connect: """Detach a client from all networks."""
await self._replay_backlog(client, network_name)
return network
async def detach(self, client: Client, network_name: str) -> None:
"""Detach a client from a network."""
async with self._lock: async with self._lock:
if network_name in self.clients:
try: try:
self.clients[network_name].remove(client) self.clients.remove(client)
except ValueError: except ValueError:
pass pass
remaining = len(self.clients.get(network_name, [])) remaining = len(self.clients)
log.info("client detached from %s (%d remaining)", network_name, remaining) log.info("client detached (%d remaining)", remaining)
if remaining == 0: # Future: record disconnect for backlog replay
await self.backlog.record_disconnect(network_name)
async def client_to_network(self, network_name: str, msg: IRCMessage) -> None: async def route_client_message(self, msg: IRCMessage) -> None:
"""Forward a client command to the network.""" """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) network = self.networks.get(network_name)
if network and network.connected: if network and network.connected:
await network.send(msg) 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: def _on_network_message(self, network_name: str, msg: IRCMessage) -> None:
"""Handle a message from an IRC server (called synchronously from network).""" """Handle a message from an IRC server (called synchronously from network)."""
asyncio.create_task(self._dispatch(network_name, msg)) 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: async def _dispatch(self, network_name: str, msg: IRCMessage) -> None:
"""Dispatch a network message to attached clients and backlog.""" """Dispatch a network message to attached clients and backlog."""
# Store in backlog for relevant commands if _suppress(msg):
if msg.command in BACKLOG_COMMANDS and msg.params: 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] target = msg.params[0]
sender = parse_prefix(msg.prefix)[0] if msg.prefix else "" text = msg.params[1]
content = msg.params[1] if len(msg.params) > 1 else "" network = self.networks.get(network_name)
await self.backlog.store(network_name, target, sender, msg.command, content) 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 # Namespace and forward to all clients (per-client: own nicks -> client nick)
max_msgs = self.config.bouncer.backlog.max_messages own_nicks = self.get_own_nicks()
if max_msgs > 0: for client in self.clients:
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:
try: try:
client.write(data) namespaced = encode_message(
msg, network_name, own_nicks, client_nick=client.nick,
)
client.write(namespaced.format())
except Exception: except Exception:
log.exception("failed to write to client") log.exception("failed to write to client")
@@ -123,22 +306,54 @@ class Router:
log.info("replaying %d messages for %s", len(entries), network_name) log.info("replaying %d messages for %s", len(entries), network_name)
own_nicks = self.get_own_nicks()
for entry in entries: for entry in entries:
ts = datetime.fromtimestamp(entry.timestamp, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
)
msg = IRCMessage( msg = IRCMessage(
command=entry.command, command=entry.command,
params=[entry.target, entry.content], params=[entry.target, entry.content],
prefix=entry.sender, prefix=entry.sender,
tags={"time": ts},
)
if _suppress(msg):
continue
namespaced = encode_message(
msg, network_name, own_nicks, client_nick=client.nick,
) )
try: try:
client.write(msg.format()) client.write(namespaced.format())
except Exception: except Exception:
log.exception("failed to replay to client") log.exception("failed to replay to client")
break break
# Mark the latest as seen
if entries: if entries:
await self.backlog.mark_seen(network_name, entries[-1].id) 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]: def network_names(self) -> list[str]:
"""Return available network names.""" """Return available network names."""
return list(self.networks.keys()) return list(self.networks.keys())
@@ -146,3 +361,8 @@ class Router:
def get_network(self, name: str) -> Network | None: def get_network(self, name: str) -> Network | None:
"""Get a network by name.""" """Get a network by name."""
return self.networks.get(name) return self.networks.get(name)
@property
def farm(self) -> RegistrationManager:
"""Access the background account farming manager."""
return self._farm
+9 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import ssl
from bouncer.client import Client from bouncer.client import Client
from bouncer.config import BouncerConfig from bouncer.config import BouncerConfig
@@ -12,7 +13,11 @@ from bouncer.router import Router
log = logging.getLogger(__name__) 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.""" """Start the client listener and return the server object."""
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
@@ -26,9 +31,11 @@ async def start(config: BouncerConfig, router: Router) -> asyncio.Server:
_handle, _handle,
host=config.bind, host=config.bind,
port=config.port, port=config.port,
ssl=ssl_ctx,
) )
proto = "tls" if ssl_ctx else "plaintext"
addrs = ", ".join(str(s.getsockname()) for s in server.sockets) 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 return server
+37
View File
@@ -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"
+180
View File
@@ -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
+90
View File
@@ -97,6 +97,19 @@ password = "x"
with pytest.raises(ValueError, match="at least one network"): with pytest.raises(ValueError, match="at least one network"):
load(_write_config(config)) 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): def test_tls_default_port(self):
config = """\ config = """\
[bouncer] [bouncer]
@@ -110,3 +123,80 @@ tls = true
""" """
cfg = load(_write_config(config)) cfg = load(_write_config(config))
assert cfg.networks["test"].port == 6697 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
+295
View File
@@ -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
+168
View File
@@ -0,0 +1,168 @@
"""Tests for network namespace encoding/decoding."""
from bouncer.irc import IRCMessage
from bouncer.namespace import (
decode_channel,
decode_target,
encode_channel,
encode_message,
encode_nick,
encode_prefix,
)
OWN = {"libera": "mybot", "oftc": "mybot2"}
class TestEncodeChannel:
def test_basic(self):
assert encode_channel("#libera", "libera") == "#libera/libera"
def test_preserves_prefix(self):
assert encode_channel("&local", "net") == "&local/net"
class TestDecodeChannel:
def test_basic(self):
assert decode_channel("#libera/libera") == ("#libera", "libera")
def test_no_separator(self):
assert decode_channel("#libera") == ("#libera", None)
def test_channel_with_slash_in_name(self):
# Edge: channel name itself contains slash -- rfind picks the last one
assert decode_channel("#a/b/net") == ("#a/b", "net")
class TestEncodeNick:
def test_foreign_nick(self):
assert encode_nick("user123", "libera", OWN) == "user123/libera"
def test_own_nick_bare(self):
assert encode_nick("mybot", "libera", OWN) == "mybot"
def test_own_nick_other_network(self):
# Own nick on another network still shown bare
assert encode_nick("mybot2", "libera", OWN) == "mybot2"
def test_own_nick_rewritten_to_client_nick(self):
assert encode_nick("mybot", "libera", OWN, client_nick="tester") == "tester"
def test_foreign_nick_unaffected_by_client_nick(self):
assert encode_nick("user123", "libera", OWN, client_nick="tester") == "user123/libera"
class TestEncodePrefix:
def test_full_prefix(self):
result = encode_prefix("user!ident@host", "libera", OWN)
assert result == "user/libera!ident@host"
def test_own_prefix(self):
result = encode_prefix("mybot!ident@host", "libera", OWN)
assert result == "mybot!ident@host"
def test_nick_only(self):
result = encode_prefix("server.example.com", "libera", OWN)
assert result == "server.example.com/libera"
class TestDecodeTarget:
def test_channel(self):
assert decode_target("#libera/libera") == ("#libera", "libera")
def test_nick(self):
assert decode_target("user123/libera") == ("user123", "libera")
def test_bare(self):
assert decode_target("#libera") == ("#libera", None)
class TestEncodeMessage:
def test_privmsg_channel(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="user!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "#test/libera"
assert out.prefix == "user/libera!ident@host"
def test_privmsg_own_prefix(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="mybot!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.prefix == "mybot!ident@host"
def test_namreply(self):
msg = IRCMessage(
command="353",
params=["mybot", "=", "#test", "@op +voice regular mybot"],
)
out = encode_message(msg, "libera", OWN)
assert out.params[2] == "#test/libera"
names = out.params[3].split()
assert names[0] == "@op/libera"
assert names[1] == "+voice/libera"
assert names[2] == "regular/libera"
assert names[3] == "mybot" # own nick stays bare
def test_nick_change(self):
msg = IRCMessage(
command="NICK",
params=["newnick"],
prefix="oldnick!user@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "newnick/libera"
assert out.prefix == "oldnick/libera!user@host"
def test_join(self):
msg = IRCMessage(
command="JOIN",
params=["#test"],
prefix="user!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "#test/libera"
def test_non_channel_target_untouched(self):
msg = IRCMessage(
command="PRIVMSG",
params=["someuser", "hi"],
prefix="other!ident@host",
)
out = encode_message(msg, "libera", OWN)
# Private message to a nick -- params[0] is not a channel, left as-is
assert out.params[0] == "someuser"
def test_no_raw_preserved(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="user!ident@host",
raw=b":user!ident@host PRIVMSG #test :hello\r\n",
)
out = encode_message(msg, "libera", OWN)
assert out.raw is None # raw must not carry over
def test_own_prefix_rewritten_to_client_nick(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="mybot!ident@host",
)
out = encode_message(msg, "libera", OWN, client_nick="tester")
assert out.prefix == "tester!ident@host"
def test_namreply_own_nick_rewritten(self):
msg = IRCMessage(
command="353",
params=["mybot", "=", "#test", "@op mybot"],
)
out = encode_message(msg, "libera", OWN, client_nick="tester")
names = out.params[3].split()
assert names[0] == "@op/libera"
assert names[1] == "tester"
File diff suppressed because it is too large Load Diff
+185
View File
@@ -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
+982
View File
@@ -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()
+162
View File
@@ -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()