Compare commits
11 Commits
b8d8c22dc8
...
1ea72011b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ea72011b7 | ||
|
|
0064e52fee | ||
|
|
f4f3132b6b | ||
|
|
638f12dbb3 | ||
|
|
2ab5f95476 | ||
|
|
c11bd5555a | ||
|
|
bf4a589fc5 | ||
|
|
bfcebad6dd | ||
|
|
ae8de25b27 | ||
|
|
0d762ced49 | ||
|
|
4dd817ea75 |
35
ROADMAP.md
35
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
## v0.1.0 (current)
|
||||
## v0.1.0 (done)
|
||||
|
||||
- [x] IRC protocol parser/formatter
|
||||
- [x] TOML configuration
|
||||
@@ -11,28 +11,37 @@
|
||||
- [x] Backlog replay on reconnect
|
||||
- [x] Automatic reconnection with exponential backoff
|
||||
- [x] Nick collision handling
|
||||
- [x] TLS support
|
||||
- [x] TLS support (server-side)
|
||||
- [x] Stealth connect (random markov-generated identity)
|
||||
- [x] Probation window (K-line detection before revealing nick)
|
||||
- [x] Verified end-to-end on Libera.Chat via SOCKS5
|
||||
- [x] Multi-network namespace multiplexing (`/network` suffixes)
|
||||
|
||||
## v0.2.0
|
||||
## v0.2.0 (done)
|
||||
|
||||
- [ ] Client-side TLS (accept TLS from clients)
|
||||
- [ ] SASL authentication to IRC servers
|
||||
- [ ] CTCP VERSION/PING response
|
||||
- [ ] Channel key support (JOIN #channel key)
|
||||
- [ ] Configurable probation duration
|
||||
- [ ] Configurable backlog timestamp format
|
||||
- [x] NickServ auto-registration + email verification
|
||||
- [x] SASL PLAIN authentication
|
||||
- [x] SASL EXTERNAL (CertFP) authentication
|
||||
- [x] Client certificate generation + management
|
||||
- [x] hCaptcha auto-solving (NoCaptchaAI)
|
||||
- [x] Configurable operational constants (probation, backoff, etc.)
|
||||
- [x] PING watchdog (stale connection detection)
|
||||
- [x] IRCv3 server-time capability
|
||||
- [x] Push notifications (ntfy/webhook)
|
||||
- [x] Background account farming (ephemeral connections)
|
||||
- [x] 25+ bouncer control commands
|
||||
|
||||
## v0.3.0
|
||||
|
||||
- [ ] Hot config reload (SIGHUP)
|
||||
- [ ] Systemd service file
|
||||
- [x] Client-side TLS (accept TLS from clients)
|
||||
- [x] Channel key support (JOIN #channel key)
|
||||
- [x] Hot config reload (SIGHUP)
|
||||
- [x] Systemd service file
|
||||
|
||||
## v0.4.0
|
||||
|
||||
- [ ] Per-client backlog tracking (multi-user)
|
||||
- [ ] Web status page
|
||||
- [ ] DCC passthrough
|
||||
- [ ] Containerfile for podman deployment
|
||||
|
||||
## v1.0.0
|
||||
|
||||
|
||||
21
TASKS.md
21
TASKS.md
@@ -12,12 +12,21 @@
|
||||
- [x] P1: Verified SOCKS5 proxy connectivity end-to-end
|
||||
- [x] P1: Documentation update
|
||||
- [x] P1: Multi-network namespace multiplexing (`/network` suffixes)
|
||||
|
||||
- [x] P1: Bouncer control commands (`/msg *bouncer STATUS/INFO/UPTIME/NETWORKS/CREDS/HELP`)
|
||||
- [x] P1: Extended control commands (CONNECT/DISCONNECT/RECONNECT/NICK/RAW/CHANNELS/CLIENTS/BACKLOG/VERSION/REHASH/ADDNETWORK/DELNETWORK/AUTOJOIN/IDENTIFY/REGISTER/DROPCREDS)
|
||||
- [x] P1: Bouncer control commands (25+ commands via `/msg *bouncer`)
|
||||
- [x] P1: NickServ auto-registration + email verification
|
||||
- [x] P1: SASL PLAIN + EXTERNAL (CertFP) authentication
|
||||
- [x] P1: Client certificate generation + fingerprint management
|
||||
- [x] P1: PING watchdog (stale connection detection)
|
||||
- [x] P1: IRCv3 server-time capability
|
||||
- [x] P1: Push notifications (ntfy/webhook)
|
||||
- [x] P1: hCaptcha auto-solving (NoCaptchaAI)
|
||||
- [x] P1: Background account farming (ephemeral connections)
|
||||
- [x] P1: Configurable operational constants
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] P2: Client-side TLS support
|
||||
- [ ] P2: SASL authentication
|
||||
- [ ] P3: Systemd service file
|
||||
- [x] P2: Client-side TLS support
|
||||
- [x] P2: Channel key support
|
||||
- [x] P2: Hot config reload (SIGHUP + REHASH refactor)
|
||||
- [x] P3: Systemd service file
|
||||
- [ ] P3: Containerfile for podman deployment
|
||||
|
||||
13
TODO.md
13
TODO.md
@@ -2,18 +2,13 @@
|
||||
|
||||
## Features
|
||||
|
||||
- [ ] Client TLS (accept encrypted client connections)
|
||||
- [ ] SASL PLAIN/EXTERNAL for IRC server auth
|
||||
- [ ] Channel key support
|
||||
- [ ] CTCP VERSION/PING responses
|
||||
- [ ] Hot config reload on SIGHUP
|
||||
- [ ] Configurable probation duration
|
||||
- [ ] Web status dashboard
|
||||
- [ ] DCC passthrough
|
||||
- [ ] Per-client backlog tracking (multi-user)
|
||||
- [ ] Farm: configurable ephemeral deadline
|
||||
- [ ] Farm: per-network enable/disable override
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- [ ] Systemd unit file
|
||||
- [ ] Containerfile for podman deployment
|
||||
- [ ] PyPI packaging
|
||||
|
||||
@@ -23,4 +18,4 @@
|
||||
- [ ] SOCKS5 proxy failure tests
|
||||
- [ ] Backlog replay edge cases
|
||||
- [ ] Concurrent client attach/detach
|
||||
- [ ] Probation timeout / K-line detection tests
|
||||
- [ ] Farm ephemeral lifecycle integration tests
|
||||
|
||||
@@ -3,6 +3,27 @@ bind = "127.0.0.1"
|
||||
port = 6667
|
||||
password = "changeme"
|
||||
|
||||
# Client TLS -- encrypt client-to-bouncer connections
|
||||
# client_tls = false # enable TLS for client listener
|
||||
# client_tls_cert = "" # path to PEM cert (auto-generated if empty)
|
||||
# client_tls_key = "" # path to PEM key (or same file as cert)
|
||||
|
||||
# PING watchdog -- detect stale server connections
|
||||
# ping_interval = 120 # seconds of silence before sending PING
|
||||
# ping_timeout = 30 # seconds to wait for PONG after PING
|
||||
|
||||
# Push notifications -- alerts when no clients are attached
|
||||
# notify_url = "" # ntfy or webhook URL (empty = disabled)
|
||||
# notify_on_highlight = true
|
||||
# notify_on_privmsg = true
|
||||
# notify_cooldown = 60 # min seconds between notifications
|
||||
# notify_proxy = false # route notifications through SOCKS5
|
||||
|
||||
# Background account farming -- grow a pool of verified accounts
|
||||
# farm_enabled = false # enable background registration
|
||||
# farm_interval = 3600 # seconds between attempts per network
|
||||
# farm_max_accounts = 10 # max verified accounts per network
|
||||
|
||||
[bouncer.backlog]
|
||||
max_messages = 10000
|
||||
replay_on_connect = true
|
||||
@@ -28,6 +49,7 @@ port = 6697
|
||||
tls = true
|
||||
# nick = "mynick" # optional: override host-derived nick
|
||||
channels = ["#test"]
|
||||
# channel_keys = { "#secret" = "hunter2" } # keys for +k channels
|
||||
autojoin = true
|
||||
|
||||
# [networks.oftc]
|
||||
|
||||
36
config/bouncer.service
Normal file
36
config/bouncer.service
Normal 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
|
||||
@@ -9,6 +9,18 @@ bouncer --version # version
|
||||
bouncer --help # help
|
||||
```
|
||||
|
||||
## Systemd
|
||||
|
||||
```bash
|
||||
systemctl --user enable bouncer # enable at boot
|
||||
systemctl --user start bouncer # start
|
||||
systemctl --user stop bouncer # stop
|
||||
systemctl --user restart bouncer # restart
|
||||
systemctl --user reload bouncer # hot reload (SIGHUP)
|
||||
systemctl --user status bouncer # status
|
||||
journalctl --user -u bouncer -f # follow logs
|
||||
```
|
||||
|
||||
## Podman
|
||||
|
||||
```bash
|
||||
@@ -74,8 +86,9 @@ PASS <password> # authenticate (all networks)
|
||||
/msg *bouncer REHASH # reload config file
|
||||
/msg *bouncer ADDNETWORK name host=h port=N tls=yes nick=n channels=#a,#b
|
||||
/msg *bouncer DELNETWORK name # remove network
|
||||
/msg *bouncer AUTOJOIN net +#chan # add to autojoin
|
||||
/msg *bouncer AUTOJOIN net -#chan # remove from autojoin
|
||||
/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
|
||||
@@ -98,6 +111,15 @@ PASS <password> # authenticate (all networks)
|
||||
/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
|
||||
|
||||
```
|
||||
@@ -134,7 +156,48 @@ SASL EXTERNAL (cert + creds) > SASL PLAIN (creds) > NickServ IDENTIFY
|
||||
## Reconnect Backoff
|
||||
|
||||
```
|
||||
5s -> 10s -> 30s -> 60s -> 120s -> 300s (cap)
|
||||
1s (flat, no escalation)
|
||||
```
|
||||
|
||||
## PING Watchdog
|
||||
|
||||
Detects stale connections where TCP stays open but server stops responding.
|
||||
|
||||
```toml
|
||||
ping_interval = 120 # silence before PING (seconds)
|
||||
ping_timeout = 30 # wait for PONG (seconds)
|
||||
```
|
||||
|
||||
Total detection time: `ping_interval + ping_timeout` (default 150s).
|
||||
|
||||
## server-time (IRCv3)
|
||||
|
||||
Automatic -- no config needed. Timestamps injected on all messages.
|
||||
Backlog replay includes original timestamps.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
```toml
|
||||
notify_url = "https://ntfy.sh/my-topic" # ntfy or generic webhook
|
||||
notify_on_highlight = true # channel mentions
|
||||
notify_on_privmsg = true # private messages
|
||||
notify_cooldown = 60 # rate limit (seconds)
|
||||
notify_proxy = false # use SOCKS5 for notifications
|
||||
```
|
||||
|
||||
Only fires when no clients are attached.
|
||||
|
||||
## Security
|
||||
|
||||
- DCC/CTCP stripped both directions (prevents IP leaks). ACTION preserved.
|
||||
- All server connections routed through SOCKS5 proxy.
|
||||
- Stealth connect: random nick/user/realname on every connection.
|
||||
|
||||
## Hot Reload
|
||||
|
||||
```bash
|
||||
kill -HUP $(pidof bouncer) # reload config via signal
|
||||
/msg *bouncer REHASH # reload config via command
|
||||
```
|
||||
|
||||
## Config Skeleton
|
||||
@@ -142,12 +205,19 @@ SASL EXTERNAL (cert + creds) > SASL PLAIN (creds) > NickServ IDENTIFY
|
||||
```toml
|
||||
[bouncer]
|
||||
bind / port / password
|
||||
client_tls / client_tls_cert # client-side TLS
|
||||
client_tls_key # separate key file (optional)
|
||||
captcha_api_key # NoCaptchaAI key (optional)
|
||||
captcha_poll_interval / captcha_poll_timeout
|
||||
probation_seconds / nick_timeout / rejoin_delay
|
||||
backoff_steps / http_timeout
|
||||
email_poll_interval / email_max_polls / email_request_timeout
|
||||
cert_validity_days
|
||||
ping_interval / ping_timeout # PING watchdog
|
||||
notify_url / notify_on_highlight / notify_on_privmsg
|
||||
notify_cooldown / notify_proxy # push notifications
|
||||
farm_enabled / farm_interval # background account farming
|
||||
farm_max_accounts
|
||||
[bouncer.backlog]
|
||||
max_messages / replay_on_connect
|
||||
|
||||
@@ -157,6 +227,7 @@ host / port
|
||||
[networks.<name>] # repeatable
|
||||
host / port / tls
|
||||
nick / channels / autojoin
|
||||
channel_keys # keys for +k channels
|
||||
password # optional, IRC server PASS
|
||||
```
|
||||
|
||||
@@ -166,7 +237,9 @@ password # optional, IRC server PASS
|
||||
|------|---------|
|
||||
| `config/bouncer.toml` | Active config (gitignored) |
|
||||
| `config/bouncer.example.toml` | Example template |
|
||||
| `config/bouncer.service` | Systemd user service unit |
|
||||
| `config/bouncer.db` | SQLite backlog (auto-created) |
|
||||
| `{data_dir}/bouncer.pem` | Listener TLS cert (auto-created) |
|
||||
| `{data_dir}/certs/{net}/{nick}.pem` | Client certificates (auto-created) |
|
||||
|
||||
## Backlog Queries
|
||||
@@ -196,8 +269,10 @@ src/bouncer/
|
||||
client.py # client session handler
|
||||
cert.py # client certificate generation + management
|
||||
captcha.py # hCaptcha solver via NoCaptchaAI
|
||||
commands.py # 25 bouncer control commands (/msg *bouncer)
|
||||
router.py # message routing + backlog trigger
|
||||
farm.py # background account farming
|
||||
commands.py # bouncer control commands (/msg *bouncer)
|
||||
notify.py # push notifications (ntfy/webhook)
|
||||
router.py # message routing + backlog trigger + server-time
|
||||
server.py # TCP listener
|
||||
backlog.py # SQLite store/replay/prune
|
||||
```
|
||||
|
||||
@@ -68,6 +68,45 @@ Verify:
|
||||
which bouncer
|
||||
```
|
||||
|
||||
## Systemd (User Service)
|
||||
|
||||
Install and enable the bouncer as a user service (no root required):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp config/bouncer.service ~/.config/systemd/user/bouncer.service
|
||||
```
|
||||
|
||||
Edit `ExecStart=` paths if your install differs from the defaults:
|
||||
|
||||
```bash
|
||||
$EDITOR ~/.config/systemd/user/bouncer.service
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable bouncer
|
||||
systemctl --user start bouncer
|
||||
```
|
||||
|
||||
Enable lingering so the service runs without an active login session:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
### Management
|
||||
|
||||
```bash
|
||||
systemctl --user status bouncer # check status
|
||||
systemctl --user restart bouncer # restart
|
||||
systemctl --user stop bouncer # stop
|
||||
journalctl --user -u bouncer -f # follow logs
|
||||
systemctl --user reload bouncer # hot reload config (SIGHUP)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Installed automatically by `make dev`:
|
||||
|
||||
313
docs/USAGE.md
313
docs/USAGE.md
@@ -57,14 +57,8 @@ Once probation passes without incident:
|
||||
On any disconnection, the bouncer reconnects with exponential backoff
|
||||
(configurable via `backoff_steps`):
|
||||
|
||||
| Attempt | Default Delay |
|
||||
|---------|---------------|
|
||||
| 1 | 5s |
|
||||
| 2 | 10s |
|
||||
| 3 | 30s |
|
||||
| 4 | 60s |
|
||||
| 5 | 120s |
|
||||
| 6+ | 300s |
|
||||
Reconnection delay is **1 second** (flat, no escalation). Each attempt gets a
|
||||
fresh random identity and potentially a different exit IP.
|
||||
|
||||
Each reconnection uses a fresh random identity.
|
||||
|
||||
@@ -111,6 +105,59 @@ automatically attaches to **all** configured networks.
|
||||
|
||||
Set server password to `mypassword` in the network settings.
|
||||
|
||||
## Client TLS
|
||||
|
||||
The bouncer can accept TLS-encrypted connections from IRC clients. This
|
||||
encrypts the password and all traffic between your client and the bouncer.
|
||||
|
||||
### Setup
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
client_tls = true
|
||||
```
|
||||
|
||||
On first start with `client_tls = true`, the bouncer auto-generates a
|
||||
self-signed EC P-256 certificate at `{data_dir}/bouncer.pem` (10-year validity).
|
||||
The certificate fingerprint is logged at startup.
|
||||
|
||||
### Custom Certificate
|
||||
|
||||
To use your own certificate (e.g. from Let's Encrypt):
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
client_tls = true
|
||||
client_tls_cert = "/path/to/fullchain.pem"
|
||||
client_tls_key = "/path/to/privkey.pem"
|
||||
```
|
||||
|
||||
If the cert and key are in the same PEM file, set only `client_tls_cert`.
|
||||
|
||||
### Client Examples
|
||||
|
||||
**irssi:**
|
||||
```
|
||||
/connect -tls -tls_verify no -password mypassword 127.0.0.1 6667
|
||||
```
|
||||
|
||||
**weechat:**
|
||||
```
|
||||
/server add bouncer 127.0.0.1/6667 -password=mypassword -ssl -ssl_verify=0
|
||||
/connect bouncer
|
||||
```
|
||||
|
||||
**hexchat:**
|
||||
|
||||
Enable "Use SSL for all the servers on this network" and accept the
|
||||
self-signed certificate.
|
||||
|
||||
### Verify with openssl
|
||||
|
||||
```bash
|
||||
openssl s_client -connect 127.0.0.1:6667
|
||||
```
|
||||
|
||||
## Multi-Network Namespacing
|
||||
|
||||
All configured networks are multiplexed onto a single client connection. Channels
|
||||
@@ -171,6 +218,83 @@ replay_on_connect = true # set false to disable replay
|
||||
|
||||
Stored commands: `PRIVMSG`, `NOTICE`, `TOPIC`, `KICK`, `MODE`.
|
||||
|
||||
## PING Watchdog
|
||||
|
||||
The bouncer sends periodic PING messages to detect stale server connections
|
||||
(socket open but no data flowing). If no data is received within the configured
|
||||
interval, a PING is sent. If the server doesn't respond within the timeout,
|
||||
the connection is dropped and a reconnect is scheduled.
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
ping_interval = 120 # seconds of silence before sending PING
|
||||
ping_timeout = 30 # seconds to wait for PONG after PING
|
||||
```
|
||||
|
||||
The watchdog starts automatically when a network enters the READY state.
|
||||
Any received data (not just PONG) resets the timer.
|
||||
|
||||
## IRCv3 server-time
|
||||
|
||||
The bouncer requests the `server-time` IRCv3 capability on every connection.
|
||||
When enabled by the server, timestamps on incoming messages are preserved and
|
||||
forwarded to clients. When the server does not provide a timestamp, the bouncer
|
||||
injects one using the current UTC time.
|
||||
|
||||
Backlog replay also includes timestamps from when messages were originally
|
||||
stored, so clients that support `server-time` see accurate times on replayed
|
||||
messages.
|
||||
|
||||
No client configuration is needed -- timestamps appear automatically if the
|
||||
client supports IRCv3 message tags.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
When no IRC clients are connected to the bouncer, highlights and private
|
||||
messages can trigger push notifications via [ntfy](https://ntfy.sh) or a
|
||||
generic webhook.
|
||||
|
||||
### Setup
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
notify_url = "https://ntfy.sh/my-bouncer-topic"
|
||||
notify_on_highlight = true # mentions of your nick in channels
|
||||
notify_on_privmsg = true # private messages
|
||||
notify_cooldown = 60 # min seconds between notifications
|
||||
notify_proxy = false # route notifications through SOCKS5
|
||||
```
|
||||
|
||||
### ntfy Example
|
||||
|
||||
```toml
|
||||
notify_url = "https://ntfy.sh/my-secret-topic"
|
||||
```
|
||||
|
||||
Install the ntfy app on your phone and subscribe to the topic. Notifications
|
||||
include the sender, target, and message text.
|
||||
|
||||
### Generic Webhook
|
||||
|
||||
Any URL that does not contain `ntfy` in the hostname is treated as a generic
|
||||
webhook. The bouncer POSTs JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"network": "libera",
|
||||
"sender": "user",
|
||||
"target": "#channel",
|
||||
"text": "hey mynick, check this out"
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- Notifications only fire when **no clients** are attached
|
||||
- The cooldown prevents notification floods (one per `notify_cooldown` seconds)
|
||||
- When `notify_proxy = true`, notification requests are routed through the
|
||||
configured SOCKS5 proxy
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
```toml
|
||||
@@ -179,6 +303,11 @@ bind = "127.0.0.1" # listen address
|
||||
port = 6667 # listen port
|
||||
password = "changeme" # client authentication password
|
||||
|
||||
# Client TLS
|
||||
client_tls = false # enable TLS for client listener
|
||||
client_tls_cert = "" # path to PEM cert (auto-generated if empty)
|
||||
client_tls_key = "" # path to PEM key (or same file as cert)
|
||||
|
||||
# Captcha solving (NoCaptchaAI)
|
||||
captcha_api_key = "" # API key (optional, for auto-verification)
|
||||
captcha_poll_interval = 3 # seconds between solve polls
|
||||
@@ -186,7 +315,7 @@ captcha_poll_timeout = 120 # max seconds to wait for solve
|
||||
|
||||
# Connection tuning
|
||||
probation_seconds = 45 # post-connect watch period for k-lines
|
||||
backoff_steps = [5, 10, 30, 60, 120, 300] # reconnect delays
|
||||
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
|
||||
@@ -199,6 +328,22 @@ email_request_timeout = 20 # per-request timeout for email APIs
|
||||
# Certificate generation
|
||||
cert_validity_days = 3650 # client cert validity (~10 years)
|
||||
|
||||
# PING watchdog
|
||||
ping_interval = 120 # seconds of silence before sending PING
|
||||
ping_timeout = 30 # seconds to wait for PONG after PING
|
||||
|
||||
# Push notifications
|
||||
notify_url = "" # ntfy or webhook URL (empty = disabled)
|
||||
notify_on_highlight = true # notify on nick mentions
|
||||
notify_on_privmsg = true # notify on private messages
|
||||
notify_cooldown = 60 # min seconds between notifications
|
||||
notify_proxy = false # route notifications through SOCKS5
|
||||
|
||||
# Background account farming
|
||||
farm_enabled = false # enable background registration
|
||||
farm_interval = 3600 # seconds between attempts per network
|
||||
farm_max_accounts = 10 # max verified accounts per network
|
||||
|
||||
[bouncer.backlog]
|
||||
max_messages = 10000 # per network, 0 = unlimited
|
||||
replay_on_connect = true # replay missed messages on client connect
|
||||
@@ -213,6 +358,7 @@ port = 6697 # server port (default: 6697 if tls, 6667 otherwise)
|
||||
tls = true # use TLS for server connection
|
||||
nick = "mynick" # desired IRC nick (set after probation)
|
||||
channels = ["#test"] # channels to join (after probation)
|
||||
channel_keys = { "#secret" = "hunter2" } # keys for +k channels (optional)
|
||||
autojoin = true # auto-join channels on ready (default: true)
|
||||
password = "" # IRC server password (optional, for PASS command)
|
||||
```
|
||||
@@ -337,10 +483,11 @@ Responses arrive as NOTICE messages from `*bouncer`.
|
||||
| `REHASH` | Reload config file, add/remove/reconnect networks |
|
||||
| `ADDNETWORK <name> key=val ...` | Create a network at runtime |
|
||||
| `DELNETWORK <name>` | Stop and remove a network |
|
||||
| `AUTOJOIN <network> +/-#channel` | Add or remove channel from autojoin list |
|
||||
| `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), `password`.
|
||||
`channels` (comma-separated), `channel_keys` (`#chan=key,...`), `password`.
|
||||
|
||||
### NickServ
|
||||
|
||||
@@ -358,6 +505,14 @@ Responses arrive as NOTICE messages from `*bouncer`.
|
||||
| `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
|
||||
|
||||
```
|
||||
@@ -377,6 +532,7 @@ Responses arrive as NOTICE messages from `*bouncer`.
|
||||
/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
|
||||
@@ -388,6 +544,10 @@ Responses arrive as NOTICE messages from `*bouncer`.
|
||||
/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
|
||||
@@ -413,6 +573,137 @@ Responses arrive as NOTICE messages from `*bouncer`.
|
||||
DB size: 2.1 MB
|
||||
```
|
||||
|
||||
## Background Account Farming
|
||||
|
||||
The bouncer can automatically grow a pool of verified NickServ accounts across
|
||||
all configured networks. Primary connections stay active with SASL-authenticated
|
||||
identities while ephemeral connections register new nicks in the background.
|
||||
|
||||
### Setup
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
farm_enabled = true
|
||||
farm_interval = 3600 # seconds between attempts per network
|
||||
farm_max_accounts = 10 # max verified accounts per network
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. A sweep loop runs every 60 seconds (after an initial 60s stabilization delay)
|
||||
2. For each NickServ-enabled network, it checks:
|
||||
- Is there already an active farming attempt? (skip)
|
||||
- Has the cooldown (`farm_interval`) elapsed since the last attempt? (skip)
|
||||
- Are there already `farm_max_accounts` verified accounts? (skip)
|
||||
3. If eligible, an ephemeral connection is spawned with a random nick
|
||||
4. The ephemeral goes through the full registration lifecycle: REGISTER, email
|
||||
verification (or captcha), and credential storage
|
||||
5. Credentials are saved under the real network name, not the ephemeral's
|
||||
internal `_farm_` prefix
|
||||
6. Each ephemeral has a 15-minute deadline before being terminated
|
||||
7. Ephemeral connections are invisible to IRC clients (no status broadcasts,
|
||||
no channel joins)
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| `FARM` | Global overview: enabled/disabled, interval, per-network stats |
|
||||
| `FARM <network>` | Network stats + triggers an immediate registration attempt |
|
||||
| `ACCOUNTS` | List all stored accounts with verified/pending counts |
|
||||
| `ACCOUNTS <network>` | Accounts for a specific network |
|
||||
|
||||
### Configuration Reference
|
||||
|
||||
```toml
|
||||
[bouncer]
|
||||
farm_enabled = false # enable background registration (default: off)
|
||||
farm_interval = 3600 # seconds between attempts per network
|
||||
farm_max_accounts = 10 # stop farming when this many verified accounts exist
|
||||
```
|
||||
|
||||
## Channel Keys
|
||||
|
||||
Channels with mode `+k` require a key to join. Configure keys in TOML:
|
||||
|
||||
```toml
|
||||
[networks.libera]
|
||||
channels = ["#secret", "#public"]
|
||||
channel_keys = { "#secret" = "hunter2" }
|
||||
```
|
||||
|
||||
Keys are used automatically during autojoin and KICK rejoin. To add a keyed
|
||||
channel at runtime:
|
||||
|
||||
```
|
||||
/msg *bouncer AUTOJOIN libera +#secret hunter2
|
||||
```
|
||||
|
||||
Removing a channel also clears its key:
|
||||
|
||||
```
|
||||
/msg *bouncer AUTOJOIN libera -#secret
|
||||
```
|
||||
|
||||
## DCC Stripping
|
||||
|
||||
DCC requests (`DCC SEND`, `DCC CHAT`) embed the sender's real IP address in the
|
||||
protocol payload. The bouncer strips all DCC and non-ACTION CTCP messages in
|
||||
both directions:
|
||||
|
||||
- **Inbound** (server to client): silently dropped, logged as warning
|
||||
- **Outbound** (client to server): blocked before reaching the network
|
||||
|
||||
ACTION (`/me`) is preserved. This is a hard security boundary -- there is no
|
||||
config toggle to disable it.
|
||||
|
||||
## Hot Reload
|
||||
|
||||
The bouncer reloads its config file on `SIGHUP` or via the `REHASH` command.
|
||||
Both use the same logic: re-read TOML, diff networks (add/remove/reconnect),
|
||||
and update mutable fields (channels, channel_keys, nick, password).
|
||||
|
||||
### SIGHUP
|
||||
|
||||
```bash
|
||||
kill -HUP $(pidof bouncer)
|
||||
```
|
||||
|
||||
Results are logged (no client connection needed). Useful for headless
|
||||
operation (systemd, containers).
|
||||
|
||||
### REHASH command
|
||||
|
||||
```
|
||||
/msg *bouncer REHASH
|
||||
```
|
||||
|
||||
Results are sent back as NOTICE messages.
|
||||
|
||||
### What changes on reload
|
||||
|
||||
| Field | Effect |
|
||||
|-------|--------|
|
||||
| Network host/port/tls/proxy | Network reconnected |
|
||||
| channels, channel_keys, nick, password | Updated in-place |
|
||||
| notify_url, notify_cooldown, etc. | Notifier recreated |
|
||||
| farm_enabled, farm_interval, etc. | Farm started/stopped |
|
||||
| bind, port, password, client_tls | Warning logged (restart required) |
|
||||
|
||||
## Systemd
|
||||
|
||||
The bouncer ships with a systemd user service file. See [INSTALL.md](INSTALL.md)
|
||||
for setup. Key operations:
|
||||
|
||||
```bash
|
||||
systemctl --user start bouncer # start
|
||||
systemctl --user stop bouncer # stop
|
||||
systemctl --user reload bouncer # hot reload (SIGHUP)
|
||||
journalctl --user -u bouncer -f # follow logs
|
||||
```
|
||||
|
||||
The service restarts automatically on failure (`RestartSec=10`).
|
||||
|
||||
## Stopping
|
||||
|
||||
Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing
|
||||
|
||||
@@ -5,14 +5,16 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from bouncer import commands
|
||||
from bouncer.backlog import Backlog
|
||||
from bouncer.cert import fingerprint, generate_listener_cert
|
||||
from bouncer.cli import parse_args
|
||||
from bouncer.config import load
|
||||
from bouncer.config import BouncerConfig, load
|
||||
from bouncer.router import Router
|
||||
from bouncer.server import start
|
||||
|
||||
@@ -31,6 +33,26 @@ def _setup_logging(verbose: bool) -> None:
|
||||
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:
|
||||
_setup_logging(verbose)
|
||||
|
||||
@@ -51,7 +73,11 @@ async def _run(config_path: Path, verbose: bool) -> None:
|
||||
router = Router(cfg, backlog, data_dir=data_dir)
|
||||
await router.start_networks()
|
||||
|
||||
server = await start(cfg.bouncer, router)
|
||||
ssl_ctx = None
|
||||
if cfg.bouncer.client_tls:
|
||||
ssl_ctx = _build_client_ssl_ctx(cfg.bouncer, data_dir)
|
||||
|
||||
server = await start(cfg.bouncer, router, ssl_ctx=ssl_ctx)
|
||||
|
||||
# Graceful shutdown on SIGINT/SIGTERM
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -64,6 +90,21 @@ async def _run(config_path: Path, verbose: bool) -> None:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
|
||||
# Hot reload on SIGHUP
|
||||
async def _sighup_rehash() -> None:
|
||||
try:
|
||||
lines = await commands.rehash(router, config_path)
|
||||
for line in lines:
|
||||
log.info("REHASH: %s", line)
|
||||
except Exception:
|
||||
log.exception("SIGHUP rehash failed")
|
||||
|
||||
def _sighup_handler() -> None:
|
||||
log.info("SIGHUP received, reloading config...")
|
||||
asyncio.create_task(_sighup_rehash())
|
||||
|
||||
loop.add_signal_handler(signal.SIGHUP, _sighup_handler)
|
||||
|
||||
await stop_event.wait()
|
||||
|
||||
server.close()
|
||||
|
||||
@@ -187,7 +187,8 @@ class Backlog:
|
||||
"""
|
||||
assert self._db is not None
|
||||
await self._db.execute(
|
||||
"INSERT INTO nickserv_creds (network, nick, password, email, registered_at, host, status, verify_url) "
|
||||
"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, "
|
||||
@@ -231,7 +232,10 @@ class Backlog:
|
||||
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."""
|
||||
"""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 "
|
||||
@@ -244,20 +248,21 @@ class Backlog:
|
||||
|
||||
async def get_pending_registration(
|
||||
self, network: str,
|
||||
) -> tuple[str, str, str, str] | None:
|
||||
) -> tuple[str, str, str, str, str] | None:
|
||||
"""Get a pending (unverified) registration for a network.
|
||||
|
||||
Returns (nick, password, email, host) or None.
|
||||
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 FROM nickserv_creds "
|
||||
"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]) if row else None
|
||||
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."""
|
||||
@@ -269,6 +274,17 @@ class Backlog:
|
||||
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]]:
|
||||
|
||||
@@ -17,6 +17,58 @@ 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"
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -45,6 +46,8 @@ _COMMANDS: dict[str, str] = {
|
||||
"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])",
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +110,10 @@ async def dispatch(text: str, router: Router, client: Client) -> list[str]:
|
||||
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."]
|
||||
|
||||
@@ -442,14 +449,15 @@ def _cmd_version() -> list[str]:
|
||||
# --- Config Management ---
|
||||
|
||||
|
||||
async def _cmd_rehash(router: Router) -> list[str]:
|
||||
"""Reload config, add/remove networks (proxy/bind unchanged)."""
|
||||
if not CONFIG_PATH:
|
||||
return ["[REHASH] config path not set"]
|
||||
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)
|
||||
new_cfg = load(config_path)
|
||||
except Exception as exc:
|
||||
return [f"[REHASH] config error: {exc}"]
|
||||
|
||||
@@ -487,16 +495,48 @@ async def _cmd_rehash(router: Router) -> list[str]:
|
||||
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
|
||||
@@ -504,7 +544,7 @@ async def _cmd_addnetwork(router: Router, arg: str) -> list[str]:
|
||||
parts = arg.split()
|
||||
if not parts:
|
||||
return ["Usage: ADDNETWORK <name> host=<host> [port=N] [tls=yes|no]",
|
||||
" [nick=N] [channels=#a,#b] [password=P]"]
|
||||
" [nick=N] [channels=#a,#b] [channel_keys=#c=key,...] [password=P]"]
|
||||
|
||||
name = parts[0].lower()
|
||||
if "/" in name:
|
||||
@@ -527,6 +567,14 @@ async def _cmd_addnetwork(router: Router, arg: str) -> list[str]:
|
||||
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"],
|
||||
@@ -534,6 +582,7 @@ async def _cmd_addnetwork(router: Router, arg: str) -> list[str]:
|
||||
tls=tls,
|
||||
nick=kvs.get("nick", ""),
|
||||
channels=channels,
|
||||
channel_keys=channel_keys,
|
||||
password=kvs.get("password"),
|
||||
auth_service=kvs.get("auth_service", "nickserv"),
|
||||
)
|
||||
@@ -558,15 +607,15 @@ async def _cmd_delnetwork(router: Router, arg: str) -> list[str]:
|
||||
|
||||
def _cmd_autojoin(router: Router, arg: str) -> list[str]:
|
||||
"""Add or remove a channel from a network's autojoin list."""
|
||||
parts = arg.split(None, 1)
|
||||
parts = arg.split()
|
||||
if len(parts) < 2:
|
||||
return ["Usage: AUTOJOIN <network> +#channel | -#channel"]
|
||||
return ["Usage: AUTOJOIN <network> +#channel [key] | -#channel"]
|
||||
|
||||
net, err = _resolve_network(router, parts[0])
|
||||
if err:
|
||||
return err
|
||||
|
||||
spec = parts[1].strip()
|
||||
spec = parts[1]
|
||||
if not spec or spec[0] not in ("+", "-"):
|
||||
return ["Channel must start with + (add) or - (remove)"]
|
||||
|
||||
@@ -575,15 +624,22 @@ def _cmd_autojoin(router: Router, arg: str) -> list[str]:
|
||||
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:
|
||||
asyncio.create_task(net.send_raw("JOIN", channel))
|
||||
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:
|
||||
@@ -591,6 +647,7 @@ def _cmd_autojoin(router: Router, arg: str) -> list[str]:
|
||||
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
|
||||
@@ -779,3 +836,113 @@ def _cmd_delcert(router: Router, arg: str) -> list[str]:
|
||||
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
|
||||
|
||||
@@ -43,6 +43,7 @@ class NetworkConfig:
|
||||
user: str = ""
|
||||
realname: str = ""
|
||||
channels: list[str] = field(default_factory=list)
|
||||
channel_keys: dict[str, str] = field(default_factory=dict)
|
||||
autojoin: bool = False
|
||||
password: str | None = None
|
||||
proxy_host: str | None = None
|
||||
@@ -66,7 +67,7 @@ class BouncerConfig:
|
||||
|
||||
# Connection tuning
|
||||
probation_seconds: int = 45
|
||||
backoff_steps: list[int] = field(default_factory=lambda: [5, 10, 30, 60, 120, 300])
|
||||
backoff_steps: list[int] = field(default_factory=lambda: [1])
|
||||
nick_timeout: int = 10
|
||||
rejoin_delay: int = 3
|
||||
http_timeout: int = 15
|
||||
@@ -79,6 +80,27 @@ class BouncerConfig:
|
||||
# Certificate generation
|
||||
cert_validity_days: int = 3650
|
||||
|
||||
# PING watchdog
|
||||
ping_interval: int = 120 # seconds of silence before sending PING
|
||||
ping_timeout: int = 30 # seconds to wait for PONG after PING
|
||||
|
||||
# Push notifications
|
||||
notify_url: str = "" # ntfy/webhook URL (empty = disabled)
|
||||
notify_on_highlight: bool = True
|
||||
notify_on_privmsg: bool = True
|
||||
notify_cooldown: int = 60 # min seconds between notifications
|
||||
notify_proxy: bool = False # route notifications through SOCKS5
|
||||
|
||||
# Client TLS
|
||||
client_tls: bool = False # enable TLS for client listener
|
||||
client_tls_cert: str = "" # path to PEM cert (auto-generated if empty)
|
||||
client_tls_key: str = "" # path to PEM key (or same file as cert)
|
||||
|
||||
# Background account farming
|
||||
farm_enabled: bool = False
|
||||
farm_interval: int = 3600 # seconds between attempts per network
|
||||
farm_max_accounts: int = 10 # max verified accounts per network
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Config:
|
||||
@@ -106,7 +128,7 @@ def load(path: Path) -> Config:
|
||||
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", [5, 10, 30, 60, 120, 300]),
|
||||
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),
|
||||
@@ -114,6 +136,19 @@ def load(path: Path) -> Config:
|
||||
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", {})
|
||||
@@ -133,6 +168,7 @@ def load(path: Path) -> Config:
|
||||
user=net_raw.get("user", ""),
|
||||
realname=net_raw.get("realname", ""),
|
||||
channels=net_raw.get("channels", []),
|
||||
channel_keys=dict(net_raw.get("channel_keys", {})),
|
||||
autojoin=net_raw.get("autojoin", True),
|
||||
password=net_raw.get("password"),
|
||||
proxy_host=net_raw.get("proxy_host"),
|
||||
|
||||
241
src/bouncer/farm.py
Normal file
241
src/bouncer/farm.py
Normal 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
|
||||
@@ -7,6 +7,7 @@ import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
@@ -197,6 +198,8 @@ class Network:
|
||||
on_status: Callable[[str, str], None] | None = None,
|
||||
data_dir: Path | None = None,
|
||||
bouncer_cfg: BouncerConfig | None = None,
|
||||
cred_network: str = "",
|
||||
ephemeral: bool = False,
|
||||
) -> None:
|
||||
self.cfg = cfg
|
||||
self.proxy_cfg = proxy_cfg
|
||||
@@ -205,6 +208,8 @@ class Network:
|
||||
self.on_status = on_status # (network_name, status_text)
|
||||
self.data_dir = data_dir
|
||||
self.bouncer_cfg = bouncer_cfg or _DEFAULT_BOUNCER_CFG
|
||||
self.cred_network = cred_network or cfg.name
|
||||
self.ephemeral = ephemeral
|
||||
self.nick: str = cfg.nick or "*"
|
||||
self.channels: set[str] = set()
|
||||
self.state: State = State.DISCONNECTED
|
||||
@@ -215,6 +220,9 @@ class Network:
|
||||
self._read_task: asyncio.Task[None] | None = None
|
||||
self._reconnect_task: asyncio.Task[None] | None = None
|
||||
self._probation_task: asyncio.Task[None] | None = None
|
||||
self._ping_task: asyncio.Task[None] | None = None
|
||||
# PING watchdog state
|
||||
self._last_recv: float = 0.0
|
||||
# Transient nick used during registration/probation
|
||||
self._connect_nick: str = ""
|
||||
# Visible hostname reported by server
|
||||
@@ -236,14 +244,24 @@ class Network:
|
||||
self._sasl_pass: str = ""
|
||||
self._sasl_mechanism: str = "" # "EXTERNAL" or "PLAIN"
|
||||
self._sasl_complete: asyncio.Event = asyncio.Event()
|
||||
# IRCv3 capability negotiation
|
||||
self._caps_pending: int = 0
|
||||
self._server_time: bool = False
|
||||
# URL for manual verification (e.g. OFTC captcha)
|
||||
self._verify_url: str = ""
|
||||
|
||||
def _status(self, text: str) -> None:
|
||||
"""Emit a status message to attached clients."""
|
||||
if self.ephemeral:
|
||||
log.info("[%s] (ephemeral) %s", self.cfg.name, text)
|
||||
return
|
||||
if self.on_status:
|
||||
self.on_status(self.cfg.name, text)
|
||||
|
||||
@property
|
||||
def server_time(self) -> bool:
|
||||
return self._server_time
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self.state not in (State.DISCONNECTED, State.CONNECTING)
|
||||
@@ -264,7 +282,11 @@ class Network:
|
||||
async def stop(self) -> None:
|
||||
"""Disconnect and stop reconnection."""
|
||||
self._running = False
|
||||
for task in (self._read_task, self._reconnect_task, self._probation_task, self._verify_task):
|
||||
tasks = (
|
||||
self._read_task, self._reconnect_task, self._probation_task,
|
||||
self._verify_task, self._ping_task,
|
||||
)
|
||||
for task in tasks:
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
await self._disconnect()
|
||||
@@ -296,21 +318,23 @@ class Network:
|
||||
self._sasl_pass = ""
|
||||
self._sasl_mechanism = ""
|
||||
self._sasl_complete = asyncio.Event()
|
||||
self._caps_pending = 0
|
||||
self._server_time = False
|
||||
|
||||
# Check for stored creds to decide SASL strategy
|
||||
use_sasl = False
|
||||
client_cert = None
|
||||
if self.backlog:
|
||||
creds = await self.backlog.get_nickserv_creds_by_network(self.cfg.name)
|
||||
if self.backlog and not self.ephemeral:
|
||||
creds = await self.backlog.get_nickserv_creds_by_network(self.cred_network)
|
||||
if creds:
|
||||
self._sasl_nick, self._sasl_pass = creds
|
||||
self._connect_nick = self._sasl_nick
|
||||
use_sasl = True
|
||||
|
||||
# Prefer EXTERNAL if a cert exists for this nick
|
||||
if self.data_dir and has_cert(self.data_dir, self.cfg.name, self._sasl_nick):
|
||||
if self.data_dir and has_cert(self.data_dir, self.cred_network, self._sasl_nick):
|
||||
self._sasl_mechanism = "EXTERNAL"
|
||||
client_cert = cert_path(self.data_dir, self.cfg.name, self._sasl_nick)
|
||||
client_cert = cert_path(self.data_dir, self.cred_network, self._sasl_nick)
|
||||
log.info("[%s] stored creds + cert for %s, will use SASL EXTERNAL",
|
||||
self.cfg.name, self._sasl_nick)
|
||||
else:
|
||||
@@ -336,12 +360,17 @@ class Network:
|
||||
)
|
||||
self.state = State.REGISTERING
|
||||
|
||||
# Always request server-time capability
|
||||
await self.send_raw("CAP", "REQ", "server-time")
|
||||
self._caps_pending += 1
|
||||
|
||||
if use_sasl:
|
||||
self._status(
|
||||
f"connected, authenticating as {self._connect_nick}"
|
||||
f" (SASL {self._sasl_mechanism})"
|
||||
)
|
||||
await self.send_raw("CAP", "REQ", "sasl")
|
||||
self._caps_pending += 1
|
||||
else:
|
||||
self._status(f"connected, registering as {self._connect_nick}")
|
||||
|
||||
@@ -369,6 +398,9 @@ class Network:
|
||||
if self._probation_task and not self._probation_task.done():
|
||||
self._probation_task.cancel()
|
||||
self._probation_task = None
|
||||
if self._ping_task and not self._ping_task.done():
|
||||
self._ping_task.cancel()
|
||||
self._ping_task = None
|
||||
if self._writer and not self._writer.is_closing():
|
||||
try:
|
||||
self._writer.close()
|
||||
@@ -405,6 +437,7 @@ class Network:
|
||||
try:
|
||||
while self._running and self.state != State.DISCONNECTED:
|
||||
data = await self._reader.read(4096)
|
||||
self._last_recv = time.monotonic()
|
||||
if not data:
|
||||
log.warning("[%s] server closed connection", self.cfg.name)
|
||||
break
|
||||
@@ -459,19 +492,42 @@ class Network:
|
||||
Called immediately after SASL PLAIN success so the fingerprint is
|
||||
registered before a potential K-line disconnects us.
|
||||
"""
|
||||
from bouncer.cert import fingerprint, has_cert, cert_path
|
||||
from bouncer.cert import cert_path, fingerprint, has_cert
|
||||
|
||||
nick = self._sasl_nick or self.nick
|
||||
if not has_cert(self.data_dir, self.cfg.name, nick):
|
||||
if not has_cert(self.data_dir, self.cred_network, nick):
|
||||
return
|
||||
|
||||
pem = cert_path(self.data_dir, self.cfg.name, nick)
|
||||
pem = cert_path(self.data_dir, self.cred_network, nick)
|
||||
fp = fingerprint(pem)
|
||||
log.info("[%s] registering cert fingerprint with NickServ: %s",
|
||||
self.cfg.name, fp)
|
||||
self._status(f"registering cert fingerprint for {nick}")
|
||||
await self.send_raw("PRIVMSG", "NickServ", f"CERT ADD {fp}")
|
||||
|
||||
async def _ping_watchdog(self) -> None:
|
||||
"""Send PING if no data received within ping_interval; disconnect on timeout."""
|
||||
interval = self.bouncer_cfg.ping_interval
|
||||
timeout = self.bouncer_cfg.ping_timeout
|
||||
try:
|
||||
while self._running and self.state == State.READY:
|
||||
await asyncio.sleep(interval)
|
||||
if self.state != State.READY or not self._running:
|
||||
break
|
||||
elapsed = time.monotonic() - self._last_recv
|
||||
if elapsed >= interval:
|
||||
await self.send_raw("PING", "bouncer")
|
||||
await asyncio.sleep(timeout)
|
||||
if time.monotonic() - self._last_recv >= interval + timeout:
|
||||
log.warning("[%s] ping timeout, reconnecting", self.cfg.name)
|
||||
self._status("ping timeout, reconnecting")
|
||||
await self._disconnect()
|
||||
if self._running:
|
||||
self._schedule_reconnect()
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def _go_ready(self) -> None:
|
||||
"""Transition to ready: skip NickServ if SASL succeeded, otherwise register.
|
||||
|
||||
@@ -482,6 +538,17 @@ class Network:
|
||||
log.info("[%s] ready as %s (host=%s)", self.cfg.name, self.nick,
|
||||
self.visible_host or "unknown")
|
||||
|
||||
# Start PING watchdog
|
||||
self._last_recv = time.monotonic()
|
||||
self._ping_task = asyncio.create_task(self._ping_watchdog())
|
||||
|
||||
# Ephemeral: skip SASL/IDENTIFY, go straight to REGISTER
|
||||
if self.ephemeral:
|
||||
self._nickserv_done = asyncio.Event()
|
||||
await self._nickserv_register()
|
||||
await self._nickserv_done.wait()
|
||||
return
|
||||
|
||||
# SASL already authenticated -- skip NickServ entirely
|
||||
if self._sasl_complete.is_set():
|
||||
self._status(f"ready as {self.nick} (SASL)")
|
||||
@@ -523,18 +590,23 @@ class Network:
|
||||
# Look up stored credentials by network + host
|
||||
if self.backlog and host:
|
||||
creds = await self.backlog.get_nickserv_creds_by_host(
|
||||
self.cfg.name, host,
|
||||
self.cred_network, host,
|
||||
)
|
||||
if creds:
|
||||
stored_nick, stored_pass = creds
|
||||
log.info("[%s] found stored creds for nick %s, switching", self.cfg.name, stored_nick)
|
||||
log.info("[%s] found stored creds for nick %s, switching",
|
||||
self.cfg.name, stored_nick)
|
||||
# Switch to the registered nick first
|
||||
self._nick_confirmed.clear()
|
||||
await self.send_raw("NICK", stored_nick)
|
||||
try:
|
||||
await asyncio.wait_for(self._nick_confirmed.wait(), timeout=self.bouncer_cfg.nick_timeout)
|
||||
await asyncio.wait_for(
|
||||
self._nick_confirmed.wait(),
|
||||
timeout=self.bouncer_cfg.nick_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning("[%s] nick change to %s not confirmed", self.cfg.name, stored_nick)
|
||||
log.warning("[%s] nick change to %s not confirmed",
|
||||
self.cfg.name, stored_nick)
|
||||
|
||||
self._nickserv_password = stored_pass
|
||||
self._nickserv_pending = "identify"
|
||||
@@ -597,7 +669,7 @@ class Network:
|
||||
await self._nickserv_complete()
|
||||
return
|
||||
|
||||
creds = await self.backlog.get_nickserv_creds_by_network(self.cfg.name)
|
||||
creds = await self.backlog.get_nickserv_creds_by_network(self.cred_network)
|
||||
if not creds:
|
||||
log.info("[%s] no stored Q creds, skipping auth", self.cfg.name)
|
||||
self._status("no Q account (register at quakenet.org)")
|
||||
@@ -620,7 +692,7 @@ class Network:
|
||||
return
|
||||
|
||||
if "you are now logged in" in lower:
|
||||
self._status(f"Q auth successful")
|
||||
self._status("Q auth successful")
|
||||
log.info("[%s] Q AUTH succeeded", self.cfg.name)
|
||||
self._nickserv_pending = ""
|
||||
# Switch to configured nick if set
|
||||
@@ -628,7 +700,10 @@ class Network:
|
||||
self._nick_confirmed.clear()
|
||||
await self.send_raw("NICK", self.cfg.nick)
|
||||
try:
|
||||
await asyncio.wait_for(self._nick_confirmed.wait(), timeout=self.bouncer_cfg.nick_timeout)
|
||||
await asyncio.wait_for(
|
||||
self._nick_confirmed.wait(),
|
||||
timeout=self.bouncer_cfg.nick_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning("[%s] nick change to %s not confirmed",
|
||||
self.cfg.name, self.cfg.nick)
|
||||
@@ -689,7 +764,7 @@ class Network:
|
||||
log.info("[%s] NickServ IDENTIFY succeeded", self.cfg.name)
|
||||
if self.backlog and self._nickserv_password:
|
||||
await self.backlog.save_nickserv_creds(
|
||||
self.cfg.name, self.nick,
|
||||
self.cred_network, self.nick,
|
||||
self._nickserv_password, "",
|
||||
self.visible_host or "",
|
||||
verify_url="",
|
||||
@@ -731,7 +806,7 @@ class Network:
|
||||
self._nickserv_pending = ""
|
||||
await self._nickserv_register()
|
||||
elif "too soon" in lower or "wait" in lower or "too many" in lower:
|
||||
self._status(f"REGISTER rejected (too soon/rate limited)")
|
||||
self._status("REGISTER rejected (too soon/rate limited)")
|
||||
log.warning("[%s] NickServ rate limited: %s", self.cfg.name, text)
|
||||
self._nickserv_pending = ""
|
||||
await self._nickserv_complete()
|
||||
@@ -770,7 +845,7 @@ class Network:
|
||||
url = match.group(1)
|
||||
token = url.rsplit("/verify/", 1)[-1] if "/verify/" in url else ""
|
||||
log.info("[%s] visiting verification URL: %s", self.cfg.name, url)
|
||||
self._status(f"visiting verification URL...")
|
||||
self._status("visiting verification URL...")
|
||||
try:
|
||||
import aiohttp
|
||||
from aiohttp_socks import ProxyConnector
|
||||
@@ -881,7 +956,7 @@ class Network:
|
||||
# Persist pending state for cross-session resume
|
||||
if self.backlog and self._nickserv_password and self._nickserv_email:
|
||||
await self.backlog.save_nickserv_creds(
|
||||
self.cfg.name, self.nick,
|
||||
self.cred_network, self.nick,
|
||||
self._nickserv_password, self._nickserv_email,
|
||||
self.visible_host or "",
|
||||
status="pending",
|
||||
@@ -900,8 +975,9 @@ class Network:
|
||||
self._status(f"verified {self.nick} -- SASL ready")
|
||||
log.info("[%s] nick %s fully verified, saving credentials", self.cfg.name, self.nick)
|
||||
if self.backlog and self._nickserv_password:
|
||||
await self.backlog.mark_nickserv_verified(self.cfg.name, self.nick)
|
||||
await self.backlog.mark_nickserv_verified(self.cred_network, self.nick)
|
||||
self._nickserv_pending = ""
|
||||
await self._nickserv_complete()
|
||||
|
||||
async def _resume_pending_verification(self) -> bool:
|
||||
"""Check for a pending registration from a previous session and resume.
|
||||
@@ -914,13 +990,13 @@ class Network:
|
||||
if not self.backlog:
|
||||
return False
|
||||
|
||||
pending = await self.backlog.get_pending_registration(self.cfg.name)
|
||||
pending = await self.backlog.get_pending_registration(self.cred_network)
|
||||
if not pending:
|
||||
return False
|
||||
|
||||
p_nick, p_pass, p_email, p_host = pending
|
||||
log.info("[%s] found pending registration: nick=%s email=%s",
|
||||
self.cfg.name, p_nick, p_email)
|
||||
p_nick, p_pass, p_email, p_host, p_url = pending
|
||||
log.info("[%s] found pending registration: nick=%s email=%s url=%s",
|
||||
self.cfg.name, p_nick, p_email, p_url or "(none)")
|
||||
|
||||
# If we're already SASL'd as a different nick, we can't verify
|
||||
# for the pending nick on this connection -- just resume email check
|
||||
@@ -928,6 +1004,7 @@ class Network:
|
||||
|
||||
self._nickserv_password = p_pass
|
||||
self._nickserv_email = p_email
|
||||
self._verify_url = p_url
|
||||
self._nickserv_pending = "verify"
|
||||
self._status(f"resuming verification for {p_nick} ({p_email})")
|
||||
|
||||
@@ -936,7 +1013,10 @@ class Network:
|
||||
self._nick_confirmed.clear()
|
||||
await self.send_raw("NICK", p_nick)
|
||||
try:
|
||||
await asyncio.wait_for(self._nick_confirmed.wait(), timeout=self.bouncer_cfg.nick_timeout)
|
||||
await asyncio.wait_for(
|
||||
self._nick_confirmed.wait(),
|
||||
timeout=self.bouncer_cfg.nick_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning("[%s] could not switch to pending nick %s",
|
||||
self.cfg.name, p_nick)
|
||||
@@ -948,6 +1028,16 @@ class Network:
|
||||
)
|
||||
return True
|
||||
|
||||
def _cap_resolved(self) -> bool:
|
||||
"""Decrement pending cap count, return True if all caps are resolved."""
|
||||
self._caps_pending = max(0, self._caps_pending - 1)
|
||||
return self._caps_pending == 0
|
||||
|
||||
async def _maybe_cap_end(self) -> None:
|
||||
"""Send CAP END if no caps are still pending and SASL is not in-flight."""
|
||||
if self._caps_pending <= 0:
|
||||
await self.send_raw("CAP", "END")
|
||||
|
||||
async def _handle(self, msg: IRCMessage) -> None:
|
||||
"""Handle an IRC message from the server."""
|
||||
if msg.command == "PING":
|
||||
@@ -960,21 +1050,34 @@ class Network:
|
||||
log.warning("[%s] server ERROR: %s", self.cfg.name, reason)
|
||||
return
|
||||
|
||||
# --- SASL capability negotiation ---
|
||||
# --- IRCv3 capability negotiation ---
|
||||
if msg.command == "CAP" and len(msg.params) >= 3:
|
||||
subcommand = msg.params[1].upper()
|
||||
caps = msg.params[2].strip().lower()
|
||||
if subcommand == "ACK" and "sasl" in caps:
|
||||
log.info("[%s] SASL capability acknowledged, using %s",
|
||||
self.cfg.name, self._sasl_mechanism)
|
||||
await self.send_raw("AUTHENTICATE", self._sasl_mechanism or "PLAIN")
|
||||
elif subcommand == "NAK" and "sasl" in caps:
|
||||
log.warning("[%s] SASL not supported by server", self.cfg.name)
|
||||
self._status("SASL not supported, falling back")
|
||||
self._sasl_nick = ""
|
||||
self._sasl_pass = ""
|
||||
self._sasl_mechanism = ""
|
||||
await self.send_raw("CAP", "END")
|
||||
if subcommand == "ACK":
|
||||
if "server-time" in caps:
|
||||
self._server_time = True
|
||||
log.info("[%s] server-time capability enabled", self.cfg.name)
|
||||
self._cap_resolved()
|
||||
if "sasl" in caps:
|
||||
log.info("[%s] SASL capability acknowledged, using %s",
|
||||
self.cfg.name, self._sasl_mechanism)
|
||||
# Don't decrement yet -- SASL auth flow will resolve this cap
|
||||
await self.send_raw("AUTHENTICATE", self._sasl_mechanism or "PLAIN")
|
||||
return
|
||||
await self._maybe_cap_end()
|
||||
elif subcommand == "NAK":
|
||||
if "server-time" in caps:
|
||||
log.info("[%s] server-time not supported", self.cfg.name)
|
||||
self._cap_resolved()
|
||||
if "sasl" in caps:
|
||||
log.warning("[%s] SASL not supported by server", self.cfg.name)
|
||||
self._status("SASL not supported, falling back")
|
||||
self._sasl_nick = ""
|
||||
self._sasl_pass = ""
|
||||
self._sasl_mechanism = ""
|
||||
self._cap_resolved()
|
||||
await self._maybe_cap_end()
|
||||
return
|
||||
|
||||
if msg.command == "AUTHENTICATE" and msg.params and msg.params[0] == "+":
|
||||
@@ -1002,7 +1105,8 @@ class Network:
|
||||
# it while we're still in capability negotiation (before K-line)
|
||||
if self._sasl_mechanism == "PLAIN" and self.data_dir:
|
||||
await self._register_cert_fingerprint()
|
||||
await self.send_raw("CAP", "END")
|
||||
self._cap_resolved()
|
||||
await self._maybe_cap_end()
|
||||
return
|
||||
|
||||
if msg.command in ("902", "904", "905"):
|
||||
@@ -1021,12 +1125,14 @@ class Network:
|
||||
self._sasl_nick = ""
|
||||
self._sasl_pass = ""
|
||||
self._sasl_mechanism = ""
|
||||
await self.send_raw("CAP", "END")
|
||||
self._cap_resolved()
|
||||
await self._maybe_cap_end()
|
||||
return
|
||||
|
||||
if msg.command in ("906", "908"):
|
||||
# ERR_SASLABORTED / RPL_SASLMECHS
|
||||
await self.send_raw("CAP", "END")
|
||||
self._cap_resolved()
|
||||
await self._maybe_cap_end()
|
||||
return
|
||||
|
||||
if msg.command == "001":
|
||||
@@ -1137,7 +1243,11 @@ class Network:
|
||||
# Rejoin after a brief delay
|
||||
await asyncio.sleep(self.bouncer_cfg.rejoin_delay)
|
||||
if channel in set(self.cfg.channels) and self._running and self.ready:
|
||||
await self.send_raw("JOIN", channel)
|
||||
key = self.cfg.channel_keys.get(channel, "")
|
||||
if key:
|
||||
await self.send_raw("JOIN", channel, key)
|
||||
else:
|
||||
await self.send_raw("JOIN", channel)
|
||||
|
||||
# Forward to router
|
||||
if self.on_message:
|
||||
|
||||
134
src/bouncer/notify.py
Normal file
134
src/bouncer/notify.py
Normal 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)
|
||||
@@ -4,14 +4,17 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bouncer.backlog import Backlog
|
||||
from bouncer.config import Config, NetworkConfig, ProxyConfig
|
||||
from bouncer.farm import RegistrationManager
|
||||
from bouncer.irc import IRCMessage
|
||||
from bouncer.namespace import decode_target, encode_message
|
||||
from bouncer.network import Network
|
||||
from bouncer.notify import Notifier
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bouncer.client import Client
|
||||
@@ -64,12 +67,14 @@ def _suppress(msg: IRCMessage) -> bool:
|
||||
# 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)
|
||||
@@ -90,6 +95,14 @@ class Router:
|
||||
self.networks: dict[str, Network] = {}
|
||||
self.clients: list[Client] = []
|
||||
self._lock = asyncio.Lock()
|
||||
self._notifier = Notifier(config.bouncer, config.proxy)
|
||||
self._farm = RegistrationManager(
|
||||
bouncer_cfg=config.bouncer,
|
||||
networks=config.networks,
|
||||
proxy_resolver=self._proxy_for,
|
||||
backlog=backlog,
|
||||
data_dir=data_dir,
|
||||
)
|
||||
|
||||
def _proxy_for(self, net_cfg: NetworkConfig) -> ProxyConfig:
|
||||
"""Return the effective proxy config for a network."""
|
||||
@@ -114,9 +127,11 @@ class Router:
|
||||
)
|
||||
self.networks[name] = network
|
||||
asyncio.create_task(network.start())
|
||||
await self._farm.start()
|
||||
|
||||
async def stop_networks(self) -> None:
|
||||
"""Disconnect all networks."""
|
||||
await self._farm.stop()
|
||||
for network in self.networks.values():
|
||||
await network.stop()
|
||||
|
||||
@@ -149,6 +164,13 @@ class Router:
|
||||
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])
|
||||
@@ -244,6 +266,25 @@ class Router:
|
||||
if _suppress(msg):
|
||||
return
|
||||
|
||||
# Inject server-time tag if not present
|
||||
if "time" not in msg.tags:
|
||||
msg.tags["time"] = datetime.now(timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
)
|
||||
|
||||
# Push notification when no clients are attached
|
||||
if not self.clients and self._notifier.enabled:
|
||||
if msg.command == "PRIVMSG" and msg.prefix and len(msg.params) >= 2:
|
||||
sender_nick = msg.prefix.split("!")[0]
|
||||
target = msg.params[0]
|
||||
text = msg.params[1]
|
||||
network = self.networks.get(network_name)
|
||||
own_nick = network.nick if network else ""
|
||||
if self._notifier.should_notify(sender_nick, target, text, own_nick):
|
||||
asyncio.create_task(
|
||||
self._notifier.send(network_name, sender_nick, target, text)
|
||||
)
|
||||
|
||||
# Namespace and forward to all clients (per-client: own nicks -> client nick)
|
||||
own_nicks = self.get_own_nicks()
|
||||
for client in self.clients:
|
||||
@@ -267,10 +308,14 @@ class Router:
|
||||
|
||||
own_nicks = self.get_own_nicks()
|
||||
for entry in entries:
|
||||
ts = datetime.fromtimestamp(entry.timestamp, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
)
|
||||
msg = IRCMessage(
|
||||
command=entry.command,
|
||||
params=[entry.target, entry.content],
|
||||
prefix=entry.sender,
|
||||
tags={"time": ts},
|
||||
)
|
||||
if _suppress(msg):
|
||||
continue
|
||||
@@ -316,3 +361,8 @@ class Router:
|
||||
def get_network(self, name: str) -> Network | None:
|
||||
"""Get a network by name."""
|
||||
return self.networks.get(name)
|
||||
|
||||
@property
|
||||
def farm(self) -> RegistrationManager:
|
||||
"""Access the background account farming manager."""
|
||||
return self._farm
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
|
||||
from bouncer.client import Client
|
||||
from bouncer.config import BouncerConfig
|
||||
@@ -12,7 +13,11 @@ from bouncer.router import Router
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start(config: BouncerConfig, router: Router) -> asyncio.Server:
|
||||
async def start(
|
||||
config: BouncerConfig,
|
||||
router: Router,
|
||||
ssl_ctx: ssl.SSLContext | None = None,
|
||||
) -> asyncio.Server:
|
||||
"""Start the client listener and return the server object."""
|
||||
|
||||
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
@@ -26,9 +31,11 @@ async def start(config: BouncerConfig, router: Router) -> asyncio.Server:
|
||||
_handle,
|
||||
host=config.bind,
|
||||
port=config.port,
|
||||
ssl=ssl_ctx,
|
||||
)
|
||||
|
||||
proto = "tls" if ssl_ctx else "plaintext"
|
||||
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
||||
log.info("listening on %s", addrs)
|
||||
log.info("listening on %s (%s)", addrs, proto)
|
||||
|
||||
return server
|
||||
|
||||
@@ -11,8 +11,10 @@ from bouncer.cert import (
|
||||
delete_cert,
|
||||
fingerprint,
|
||||
generate_cert,
|
||||
generate_listener_cert,
|
||||
has_cert,
|
||||
list_certs,
|
||||
listener_cert_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +24,41 @@ def data_dir(tmp_path: Path) -> Path:
|
||||
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")
|
||||
@@ -54,7 +91,6 @@ class TestGenerateCert:
|
||||
assert fp1 != fp2 # New cert = new fingerprint
|
||||
|
||||
def test_custom_validity_days(self, data_dir: Path) -> None:
|
||||
import datetime
|
||||
from cryptography import x509 as x509_mod
|
||||
pem = generate_cert(data_dir, "libera", "testnick", validity_days=365)
|
||||
cert_data = pem.read_bytes()
|
||||
|
||||
@@ -14,7 +14,8 @@ from bouncer.network import State
|
||||
|
||||
def _make_network(name: str, state: State, nick: str = "testnick",
|
||||
host: str | None = None, channels: set[str] | None = None,
|
||||
topics: dict[str, str] | None = None) -> MagicMock:
|
||||
topics: dict[str, str] | None = None,
|
||||
channel_keys: dict[str, str] | None = None) -> MagicMock:
|
||||
"""Create a mock Network."""
|
||||
net = MagicMock()
|
||||
net.cfg.name = name
|
||||
@@ -22,6 +23,7 @@ def _make_network(name: str, state: State, nick: str = "testnick",
|
||||
net.cfg.port = 6697
|
||||
net.cfg.tls = True
|
||||
net.cfg.channels = list(channels) if channels else []
|
||||
net.cfg.channel_keys = dict(channel_keys) if channel_keys else {}
|
||||
net.cfg.nick = nick
|
||||
net.cfg.password = None
|
||||
net.state = state
|
||||
@@ -514,6 +516,11 @@ class TestRehash:
|
||||
|
||||
old_net = _make_network("libera", State.READY)
|
||||
router = _make_router(old_net)
|
||||
router._notifier = MagicMock()
|
||||
router._farm = MagicMock()
|
||||
router._farm._cfg = BouncerConfig()
|
||||
router._farm.start = AsyncMock()
|
||||
router._farm.stop = AsyncMock()
|
||||
|
||||
new_cfg = Config(
|
||||
bouncer=BouncerConfig(),
|
||||
@@ -535,6 +542,101 @@ class TestRehash:
|
||||
router.add_network.assert_awaited()
|
||||
|
||||
|
||||
class TestRehashFunction:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rehash_function_directly(self) -> None:
|
||||
from bouncer.commands import rehash
|
||||
from bouncer.config import BouncerConfig, Config, NetworkConfig, ProxyConfig
|
||||
|
||||
old_net = _make_network("libera", State.READY)
|
||||
router = _make_router(old_net)
|
||||
router._notifier = MagicMock()
|
||||
router._farm = MagicMock()
|
||||
router._farm._cfg = BouncerConfig()
|
||||
router._farm.start = AsyncMock()
|
||||
router._farm.stop = AsyncMock()
|
||||
|
||||
new_cfg = Config(
|
||||
bouncer=BouncerConfig(),
|
||||
proxy=ProxyConfig(),
|
||||
networks={
|
||||
"oftc": NetworkConfig(name="oftc", host="irc.oftc.net", port=6697, tls=True),
|
||||
},
|
||||
)
|
||||
|
||||
with patch("bouncer.config.load", return_value=new_cfg):
|
||||
lines = await rehash(router, Path("/tmp/test.toml"))
|
||||
|
||||
assert lines[0] == "[REHASH]"
|
||||
assert any("removed: libera" in line for line in lines)
|
||||
assert any("added: oftc" in line for line in lines)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rehash_updates_bouncer_config(self) -> None:
|
||||
from bouncer.commands import rehash
|
||||
from bouncer.config import BouncerConfig, Config, NetworkConfig, ProxyConfig
|
||||
|
||||
net = _make_network("libera", State.READY)
|
||||
router = _make_router(net)
|
||||
router._notifier = MagicMock()
|
||||
router._farm = MagicMock()
|
||||
router._farm._cfg = BouncerConfig()
|
||||
router._farm.start = AsyncMock()
|
||||
router._farm.stop = AsyncMock()
|
||||
|
||||
new_cfg = Config(
|
||||
bouncer=BouncerConfig(notify_url="https://ntfy.sh/test"),
|
||||
proxy=ProxyConfig(),
|
||||
networks={
|
||||
"libera": NetworkConfig(name="libera", host="irc.libera.chat",
|
||||
port=6697, tls=True,
|
||||
channel_keys={"#secret": "key"}),
|
||||
},
|
||||
)
|
||||
|
||||
with patch("bouncer.config.load", return_value=new_cfg):
|
||||
result = await rehash(router, Path("/tmp/test.toml"))
|
||||
|
||||
assert result[0] == "[REHASH]"
|
||||
assert router.config == new_cfg
|
||||
# Notifier was replaced (new instance)
|
||||
assert router._notifier is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rehash_propagates_channel_keys(self) -> None:
|
||||
from bouncer.commands import rehash
|
||||
from bouncer.config import BouncerConfig, Config, NetworkConfig, ProxyConfig
|
||||
|
||||
net = _make_network("libera", State.READY)
|
||||
net.cfg.host = "irc.libera.chat"
|
||||
net.cfg.port = 6697
|
||||
net.cfg.tls = True
|
||||
net.cfg.proxy_host = None
|
||||
net.cfg.proxy_port = None
|
||||
net.cfg.channel_keys = {}
|
||||
router = _make_router(net)
|
||||
router._notifier = MagicMock()
|
||||
router._farm = MagicMock()
|
||||
router._farm._cfg = BouncerConfig()
|
||||
router._farm.start = AsyncMock()
|
||||
router._farm.stop = AsyncMock()
|
||||
|
||||
new_cfg = Config(
|
||||
bouncer=BouncerConfig(),
|
||||
proxy=ProxyConfig(),
|
||||
networks={
|
||||
"libera": NetworkConfig(name="libera", host="irc.libera.chat",
|
||||
port=6697, tls=True,
|
||||
channel_keys={"#secret": "key123"}),
|
||||
},
|
||||
)
|
||||
|
||||
with patch("bouncer.config.load", return_value=new_cfg):
|
||||
await rehash(router, Path("/tmp/test.toml"))
|
||||
|
||||
assert net.cfg.channel_keys == {"#secret": "key123"}
|
||||
|
||||
|
||||
class TestAddNetwork:
|
||||
@pytest.mark.asyncio
|
||||
async def test_addnetwork_missing_args(self) -> None:
|
||||
@@ -648,6 +750,33 @@ class TestAutojoin:
|
||||
lines = await commands.dispatch("AUTOJOIN libera -#missing", router, client)
|
||||
assert any("not in autojoin" in line for line in lines)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autojoin_with_key(self) -> None:
|
||||
net = _make_network("libera", State.READY)
|
||||
net.cfg.channels = []
|
||||
net.cfg.channel_keys = {}
|
||||
router = _make_router(net)
|
||||
client = _make_client()
|
||||
lines = await commands.dispatch("AUTOJOIN libera +#secret hunter2", router, client)
|
||||
assert "[AUTOJOIN]" in lines[0]
|
||||
assert any("added: #secret" in line for line in lines)
|
||||
assert "#secret" in net.cfg.channels
|
||||
assert net.cfg.channel_keys["#secret"] == "hunter2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autojoin_remove_clears_key(self) -> None:
|
||||
net = _make_network("libera", State.READY,
|
||||
channels={"#secret"},
|
||||
channel_keys={"#secret": "hunter2"})
|
||||
net.cfg.channels = ["#secret"]
|
||||
net.cfg.channel_keys = {"#secret": "hunter2"}
|
||||
router = _make_router(net)
|
||||
client = _make_client()
|
||||
lines = await commands.dispatch("AUTOJOIN libera -#secret", router, client)
|
||||
assert any("removed: #secret" in line for line in lines)
|
||||
assert "#secret" not in net.cfg.channels
|
||||
assert "#secret" not in net.cfg.channel_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autojoin_invalid_spec(self) -> None:
|
||||
net = _make_network("libera", State.READY)
|
||||
|
||||
@@ -124,12 +124,34 @@ tls = true
|
||||
cfg = load(_write_config(config))
|
||||
assert cfg.networks["test"].port == 6697
|
||||
|
||||
def test_channel_keys_parsed(self):
|
||||
config = """\
|
||||
[bouncer]
|
||||
password = "x"
|
||||
|
||||
[proxy]
|
||||
|
||||
[networks.test]
|
||||
host = "irc.example.com"
|
||||
channels = ["#secret", "#public"]
|
||||
channel_keys = { "#secret" = "hunter2" }
|
||||
"""
|
||||
cfg = load(_write_config(config))
|
||||
net = cfg.networks["test"]
|
||||
assert net.channel_keys == {"#secret": "hunter2"}
|
||||
assert "#secret" in net.channels
|
||||
|
||||
def test_channel_keys_default_empty(self):
|
||||
cfg = load(_write_config(MINIMAL_CONFIG))
|
||||
net = cfg.networks["test"]
|
||||
assert net.channel_keys == {}
|
||||
|
||||
def test_operational_defaults(self):
|
||||
"""Ensure all operational values have sane defaults."""
|
||||
cfg = load(_write_config(MINIMAL_CONFIG))
|
||||
b = cfg.bouncer
|
||||
assert b.probation_seconds == 45
|
||||
assert b.backoff_steps == [5, 10, 30, 60, 120, 300]
|
||||
assert b.backoff_steps == [1]
|
||||
assert b.nick_timeout == 10
|
||||
assert b.rejoin_delay == 3
|
||||
assert b.http_timeout == 15
|
||||
|
||||
295
tests/test_farm.py
Normal file
295
tests/test_farm.py
Normal 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
|
||||
1584
tests/test_network.py
Normal file
1584
tests/test_network.py
Normal file
File diff suppressed because it is too large
Load Diff
185
tests/test_notify.py
Normal file
185
tests/test_notify.py
Normal 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
tests/test_router.py
Normal file
982
tests/test_router.py
Normal 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
tests/test_server.py
Normal file
162
tests/test_server.py
Normal 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()
|
||||
Reference in New Issue
Block a user