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