feat: background account farming with ephemeral connections

Add RegistrationManager that periodically spawns ephemeral Network
connections to register new NickServ accounts across all configured
networks. Ephemeral connections reuse the existing registration
lifecycle (random nick, email verification, captcha solving) with
two new Network parameters: cred_network (redirect credential storage
to the real network name) and ephemeral (suppress status broadcasts,
skip SASL/IDENTIFY, go straight to REGISTER).

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 18:17:22 +01:00
parent ae8de25b27
commit bfcebad6dd
11 changed files with 886 additions and 13 deletions

View File

@@ -14,6 +14,11 @@ password = "changeme"
# notify_cooldown = 60 # min seconds between notifications
# notify_proxy = false # route notifications through SOCKS5
# Background account farming -- grow a pool of verified accounts
# farm_enabled = false # enable background registration
# farm_interval = 3600 # seconds between attempts per network
# farm_max_accounts = 10 # max verified accounts per network
[bouncer.backlog]
max_messages = 10000
replay_on_connect = true

View File

@@ -98,6 +98,15 @@ PASS <password> # authenticate (all networks)
/msg *bouncer DELCERT libera nick # delete cert (specific nick)
```
### Account Farming
```
/msg *bouncer FARM # global farming status
/msg *bouncer FARM libera # network stats + trigger attempt
/msg *bouncer ACCOUNTS # list all stored accounts
/msg *bouncer ACCOUNTS libera # accounts for one network
```
## Namespacing
```
@@ -179,6 +188,8 @@ cert_validity_days
ping_interval / ping_timeout # PING watchdog
notify_url / notify_on_highlight / notify_on_privmsg
notify_cooldown / notify_proxy # push notifications
farm_enabled / farm_interval # background account farming
farm_max_accounts
[bouncer.backlog]
max_messages / replay_on_connect
@@ -227,7 +238,8 @@ src/bouncer/
client.py # client session handler
cert.py # client certificate generation + management
captcha.py # hCaptcha solver via NoCaptchaAI
commands.py # 25 bouncer control commands (/msg *bouncer)
farm.py # background account farming
commands.py # bouncer control commands (/msg *bouncer)
notify.py # push notifications (ntfy/webhook)
router.py # message routing + backlog trigger + server-time
server.py # TCP listener

View File

@@ -287,6 +287,11 @@ notify_on_privmsg = true # notify on private messages
notify_cooldown = 60 # min seconds between notifications
notify_proxy = false # route notifications through SOCKS5
# Background account farming
farm_enabled = false # enable background registration
farm_interval = 3600 # seconds between attempts per network
farm_max_accounts = 10 # max verified accounts per network
[bouncer.backlog]
max_messages = 10000 # per network, 0 = unlimited
replay_on_connect = true # replay missed messages on client connect
@@ -446,6 +451,14 @@ Responses arrive as NOTICE messages from `*bouncer`.
| `CERTFP [network]` | Show certificate fingerprints (all or per-network) |
| `DELCERT <network> [nick]` | Delete a client certificate |
### Account Farming
| Command | Description |
|---------|-------------|
| `FARM` | Global farming status (enabled/disabled, per-network stats) |
| `FARM <network>` | Network stats + trigger an immediate registration attempt |
| `ACCOUNTS [network]` | List all stored accounts with verified/pending counts |
### Examples
```
@@ -476,6 +489,10 @@ Responses arrive as NOTICE messages from `*bouncer`.
/msg *bouncer CERTFP libera
/msg *bouncer DELCERT libera
/msg *bouncer DELCERT libera fabesune
/msg *bouncer FARM
/msg *bouncer FARM libera
/msg *bouncer ACCOUNTS
/msg *bouncer ACCOUNTS libera
```
### Example Output
@@ -501,6 +518,55 @@ Responses arrive as NOTICE messages from `*bouncer`.
DB size: 2.1 MB
```
## Background Account Farming
The bouncer can automatically grow a pool of verified NickServ accounts across
all configured networks. Primary connections stay active with SASL-authenticated
identities while ephemeral connections register new nicks in the background.
### Setup
```toml
[bouncer]
farm_enabled = true
farm_interval = 3600 # seconds between attempts per network
farm_max_accounts = 10 # max verified accounts per network
```
### How It Works
1. A sweep loop runs every 60 seconds (after an initial 60s stabilization delay)
2. For each NickServ-enabled network, it checks:
- Is there already an active farming attempt? (skip)
- Has the cooldown (`farm_interval`) elapsed since the last attempt? (skip)
- Are there already `farm_max_accounts` verified accounts? (skip)
3. If eligible, an ephemeral connection is spawned with a random nick
4. The ephemeral goes through the full registration lifecycle: REGISTER, email
verification (or captcha), and credential storage
5. Credentials are saved under the real network name, not the ephemeral's
internal `_farm_` prefix
6. Each ephemeral has a 15-minute deadline before being terminated
7. Ephemeral connections are invisible to IRC clients (no status broadcasts,
no channel joins)
### Commands
| Command | What it does |
|---------|-------------|
| `FARM` | Global overview: enabled/disabled, interval, per-network stats |
| `FARM <network>` | Network stats + triggers an immediate registration attempt |
| `ACCOUNTS` | List all stored accounts with verified/pending counts |
| `ACCOUNTS <network>` | Accounts for a specific network |
### Configuration Reference
```toml
[bouncer]
farm_enabled = false # enable background registration (default: off)
farm_interval = 3600 # seconds between attempts per network
farm_max_accounts = 10 # stop farming when this many verified accounts exist
```
## Stopping
Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing

View File

@@ -270,6 +270,17 @@ class Backlog:
await self._db.commit()
log.info("marked verified: %s/%s", network, nick)
async def count_verified_creds(self, network: str) -> int:
"""Count verified NickServ credentials for a network."""
assert self._db is not None
cursor = await self._db.execute(
"SELECT COUNT(*) FROM nickserv_creds "
"WHERE network = ? AND status = 'verified'",
(network,),
)
row = await cursor.fetchone()
return row[0] if row else 0
async def list_nickserv_creds(
self, network: str | None = None,
) -> list[tuple[str, str, str, str, float, str, str]]:

View File

@@ -45,6 +45,8 @@ _COMMANDS: dict[str, str] = {
"GENCERT": "Generate client cert (GENCERT <network> [nick])",
"CERTFP": "Show cert fingerprints (CERTFP [network])",
"DELCERT": "Delete client cert (DELCERT <network> [nick])",
"FARM": "Account farming status/trigger (FARM [network])",
"ACCOUNTS": "List stored accounts (ACCOUNTS [network])",
}
@@ -107,6 +109,10 @@ async def dispatch(text: str, router: Router, client: Client) -> list[str]:
return _cmd_certfp(router, arg or None)
if cmd == "DELCERT":
return _cmd_delcert(router, arg)
if cmd == "FARM":
return await _cmd_farm(router, arg or None)
if cmd == "ACCOUNTS":
return await _cmd_accounts(router, arg or None)
return [f"Unknown command: {cmd}", "Use HELP for available commands."]
@@ -779,3 +785,113 @@ def _cmd_delcert(router: Router, arg: str) -> list[str]:
return [f"[DELCERT] deleted cert for {net_name}/{nick}"]
else:
return [f"[DELCERT] no cert found for {net_name}/{nick}"]
# --- Account Farming ---
async def _cmd_farm(router: Router, network_name: str | None) -> list[str]:
"""Show farming status or trigger an immediate attempt."""
farm = router.farm
lines = ["[FARM]"]
if not farm.enabled:
lines.append(" status: disabled")
lines.append(" enable with farm_enabled = true in [bouncer]")
return lines
lines.append(" status: enabled")
lines.append(f" interval: {farm.interval}s")
lines.append(f" max accounts: {farm.max_accounts}")
if network_name:
name = network_name.lower()
if name not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
# Trigger immediate attempt
triggered = farm.trigger(name)
stats_map = farm.status(name)
stats = stats_map.get(name)
if router.backlog:
verified = await router.backlog.count_verified_creds(name)
else:
verified = 0
lines.append(f" --- {name} ---")
lines.append(f" verified: {verified}/{farm.max_accounts}")
if stats:
lines.append(f" attempts: {stats.attempts}")
lines.append(f" successes: {stats.successes}")
lines.append(f" failures: {stats.failures}")
if stats.last_error:
lines.append(f" last error: {stats.last_error}")
if triggered:
lines.append(" triggered registration attempt")
else:
lines.append(" already active or unknown")
else:
# Global overview
all_stats = farm.status()
if not all_stats:
lines.append(" (no farming activity yet)")
else:
for name in sorted(all_stats):
s = all_stats[name]
if router.backlog:
verified = await router.backlog.count_verified_creds(name)
else:
verified = 0
lines.append(
f" {name} {verified}/{farm.max_accounts} verified"
f" {s.attempts}a/{s.successes}s/{s.failures}f"
)
return lines
async def _cmd_accounts(router: Router, network_name: str | None) -> list[str]:
"""List all stored NickServ accounts with counts."""
if not router.backlog:
return ["[ACCOUNTS] backlog not available"]
net_filter = network_name.lower() if network_name else None
if net_filter and net_filter not in router.networks:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
rows = await router.backlog.list_nickserv_creds(net_filter)
if not rows:
scope = net_filter or "any network"
return [f"[ACCOUNTS] no stored accounts for {scope}"]
lines = ["[ACCOUNTS]"]
# Tally per-network
counts: dict[str, dict[str, int]] = {}
for net, nick, email, host, registered_at, status, verify_url in rows:
c = counts.setdefault(net, {"verified": 0, "pending": 0})
if status == "verified":
c["verified"] += 1
else:
c["pending"] += 1
# Summary line per network
for net in sorted(counts):
c = counts[net]
lines.append(f" {net} {c['verified']} verified {c['pending']} pending")
lines.append("")
# Detail per account
for net, nick, email, host, registered_at, status, verify_url in rows:
indicator = "+" if status == "verified" else "~"
email_display = email if email else "--"
lines.append(f" {indicator} {net} {nick} {status} {email_display}")
lines.append("")
lines.append(" + verified ~ pending")
return lines

View File

@@ -90,6 +90,11 @@ class BouncerConfig:
notify_cooldown: int = 60 # min seconds between notifications
notify_proxy: bool = False # route notifications through SOCKS5
# Background account farming
farm_enabled: bool = False
farm_interval: int = 3600 # seconds between attempts per network
farm_max_accounts: int = 10 # max verified accounts per network
@dataclass(slots=True)
class Config:
@@ -132,6 +137,9 @@ def load(path: Path) -> Config:
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),
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", {})

241
src/bouncer/farm.py Normal file
View File

@@ -0,0 +1,241 @@
"""Background account farming -- register ephemeral nicks across networks."""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from bouncer.backlog import Backlog
from bouncer.config import BouncerConfig, NetworkConfig, ProxyConfig
from bouncer.network import Network
log = logging.getLogger(__name__)
# How often the sweep loop checks for eligible networks.
_SWEEP_INTERVAL = 60
# Hard deadline for a single ephemeral registration attempt.
_EPHEMERAL_DEADLINE = 900 # 15 minutes
# Poll interval while waiting for ephemeral completion.
_POLL_INTERVAL = 5
@dataclass(slots=True)
class FarmStats:
"""Per-network farming statistics."""
attempts: int = 0
successes: int = 0
failures: int = 0
last_attempt: float = 0.0
last_success: float = 0.0
last_error: str = ""
class RegistrationManager:
"""Periodically spawns ephemeral connections to farm NickServ accounts."""
def __init__(
self,
bouncer_cfg: BouncerConfig,
networks: dict[str, NetworkConfig],
proxy_resolver: Callable[[NetworkConfig], ProxyConfig],
backlog: Backlog,
data_dir: Path | None = None,
) -> None:
self._cfg = bouncer_cfg
self._networks = networks
self._proxy_resolver = proxy_resolver
self._backlog = backlog
self._data_dir = data_dir
self._stats: dict[str, FarmStats] = {}
self._active: dict[str, asyncio.Task[None]] = {}
self._loop_task: asyncio.Task[None] | None = None
# -- lifecycle -------------------------------------------------------------
async def start(self) -> None:
"""Start the farming loop. No-op if farming is disabled."""
if not self._cfg.farm_enabled:
log.debug("farm disabled, skipping start")
return
log.info("farm starting (interval=%ds, max=%d)",
self._cfg.farm_interval, self._cfg.farm_max_accounts)
self._loop_task = asyncio.create_task(self._loop())
async def stop(self) -> None:
"""Cancel all active ephemerals and the sweep loop."""
if self._loop_task and not self._loop_task.done():
self._loop_task.cancel()
try:
await self._loop_task
except asyncio.CancelledError:
pass
self._loop_task = None
# Stop active ephemerals
for name, task in list(self._active.items()):
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._active.clear()
log.info("farm stopped")
# -- main loop -------------------------------------------------------------
async def _loop(self) -> None:
"""Sweep loop: check all networks periodically."""
try:
# Initial delay -- let primary connections stabilize
await asyncio.sleep(_SWEEP_INTERVAL)
while True:
for name, net_cfg in self._networks.items():
await self._maybe_spawn(name, net_cfg)
await asyncio.sleep(_SWEEP_INTERVAL)
except asyncio.CancelledError:
return
async def _maybe_spawn(self, name: str, net_cfg: NetworkConfig) -> None:
"""Decide whether to spawn an ephemeral for this network."""
# Only farm NickServ-enabled networks
if net_cfg.auth_service not in ("nickserv",):
return
# One at a time per network
if name in self._active and not self._active[name].done():
return
# Respect cooldown
stats = self._stats.get(name)
if stats and (time.time() - stats.last_attempt) < self._cfg.farm_interval:
return
# Check account cap
count = await self._backlog.count_verified_creds(name)
if count >= self._cfg.farm_max_accounts:
return
self._spawn_ephemeral(name, net_cfg)
# -- ephemeral management --------------------------------------------------
def _spawn_ephemeral(self, name: str, net_cfg: NetworkConfig) -> None:
"""Create and start an ephemeral Network for registration."""
farm_name = f"_farm_{name}"
eph_cfg = NetworkConfig(
name=farm_name,
host=net_cfg.host,
port=net_cfg.port,
tls=net_cfg.tls,
nick="",
channels=[],
autojoin=False,
password=net_cfg.password,
proxy_host=net_cfg.proxy_host,
proxy_port=net_cfg.proxy_port,
auth_service="nickserv",
)
proxy_cfg = self._proxy_resolver(net_cfg)
eph = Network(
cfg=eph_cfg,
proxy_cfg=proxy_cfg,
backlog=self._backlog,
on_message=None,
on_status=None,
data_dir=self._data_dir,
bouncer_cfg=self._cfg,
cred_network=name,
ephemeral=True,
)
stats = self._stats.setdefault(name, FarmStats())
stats.attempts += 1
stats.last_attempt = time.time()
task = asyncio.create_task(self._run_ephemeral(name, eph))
self._active[name] = task
log.info("[farm] spawned ephemeral for %s (%s:%d)",
name, net_cfg.host, net_cfg.port)
async def _run_ephemeral(self, name: str, eph: Network) -> None:
"""Run one ephemeral registration attempt with a hard deadline."""
stats = self._stats.setdefault(name, FarmStats())
before = await self._backlog.count_verified_creds(name)
try:
await eph.start()
deadline = time.monotonic() + _EPHEMERAL_DEADLINE
while time.monotonic() < deadline:
if eph._nickserv_done.is_set():
break
await asyncio.sleep(_POLL_INTERVAL)
# Check if a new verified cred appeared
after = await self._backlog.count_verified_creds(name)
if after > before:
stats.successes += 1
stats.last_success = time.time()
log.info("[farm] %s: new verified account (%d total)", name, after)
else:
stats.failures += 1
stats.last_error = "no new verified account"
log.info("[farm] %s: attempt finished, no new account", name)
except asyncio.CancelledError:
log.debug("[farm] %s: ephemeral cancelled", name)
raise
except Exception as exc:
stats.failures += 1
stats.last_error = str(exc)
log.warning("[farm] %s: ephemeral error: %s", name, exc)
finally:
await eph.stop()
self._active.pop(name, None)
# -- public API ------------------------------------------------------------
def trigger(self, network: str) -> bool:
"""Manually trigger an immediate registration attempt.
Bypasses cooldown. Returns False if the network is unknown or
already has an active ephemeral.
"""
net_cfg = self._networks.get(network)
if not net_cfg:
return False
if network in self._active and not self._active[network].done():
return False
# Reset cooldown so _maybe_spawn won't skip
stats = self._stats.setdefault(network, FarmStats())
stats.last_attempt = 0.0
self._spawn_ephemeral(network, net_cfg)
return True
def status(self, network: str | None = None) -> dict[str, FarmStats]:
"""Return farming stats, optionally filtered by network."""
if network:
s = self._stats.get(network)
return {network: s} if s else {}
return dict(self._stats)
@property
def enabled(self) -> bool:
return self._cfg.farm_enabled
@property
def interval(self) -> int:
return self._cfg.farm_interval
@property
def max_accounts(self) -> int:
return self._cfg.farm_max_accounts

View File

@@ -198,6 +198,8 @@ class Network:
on_status: Callable[[str, str], None] | None = None,
data_dir: Path | None = None,
bouncer_cfg: BouncerConfig | None = None,
cred_network: str = "",
ephemeral: bool = False,
) -> None:
self.cfg = cfg
self.proxy_cfg = proxy_cfg
@@ -206,6 +208,8 @@ class Network:
self.on_status = on_status # (network_name, status_text)
self.data_dir = data_dir
self.bouncer_cfg = bouncer_cfg or _DEFAULT_BOUNCER_CFG
self.cred_network = cred_network or cfg.name
self.ephemeral = ephemeral
self.nick: str = cfg.nick or "*"
self.channels: set[str] = set()
self.state: State = State.DISCONNECTED
@@ -248,6 +252,9 @@ class Network:
def _status(self, text: str) -> None:
"""Emit a status message to attached clients."""
if self.ephemeral:
log.info("[%s] (ephemeral) %s", self.cfg.name, text)
return
if self.on_status:
self.on_status(self.cfg.name, text)
@@ -317,17 +324,17 @@ class Network:
# Check for stored creds to decide SASL strategy
use_sasl = False
client_cert = None
if self.backlog:
creds = await self.backlog.get_nickserv_creds_by_network(self.cfg.name)
if self.backlog and not self.ephemeral:
creds = await self.backlog.get_nickserv_creds_by_network(self.cred_network)
if creds:
self._sasl_nick, self._sasl_pass = creds
self._connect_nick = self._sasl_nick
use_sasl = True
# Prefer EXTERNAL if a cert exists for this nick
if self.data_dir and has_cert(self.data_dir, self.cfg.name, self._sasl_nick):
if self.data_dir and has_cert(self.data_dir, self.cred_network, self._sasl_nick):
self._sasl_mechanism = "EXTERNAL"
client_cert = cert_path(self.data_dir, self.cfg.name, self._sasl_nick)
client_cert = cert_path(self.data_dir, self.cred_network, self._sasl_nick)
log.info("[%s] stored creds + cert for %s, will use SASL EXTERNAL",
self.cfg.name, self._sasl_nick)
else:
@@ -488,10 +495,10 @@ class Network:
from bouncer.cert import fingerprint, has_cert, cert_path
nick = self._sasl_nick or self.nick
if not has_cert(self.data_dir, self.cfg.name, nick):
if not has_cert(self.data_dir, self.cred_network, nick):
return
pem = cert_path(self.data_dir, self.cfg.name, nick)
pem = cert_path(self.data_dir, self.cred_network, nick)
fp = fingerprint(pem)
log.info("[%s] registering cert fingerprint with NickServ: %s",
self.cfg.name, fp)
@@ -535,6 +542,13 @@ class Network:
self._last_recv = time.monotonic()
self._ping_task = asyncio.create_task(self._ping_watchdog())
# Ephemeral: skip SASL/IDENTIFY, go straight to REGISTER
if self.ephemeral:
self._nickserv_done = asyncio.Event()
await self._nickserv_register()
await self._nickserv_done.wait()
return
# SASL already authenticated -- skip NickServ entirely
if self._sasl_complete.is_set():
self._status(f"ready as {self.nick} (SASL)")
@@ -576,7 +590,7 @@ class Network:
# Look up stored credentials by network + host
if self.backlog and host:
creds = await self.backlog.get_nickserv_creds_by_host(
self.cfg.name, host,
self.cred_network, host,
)
if creds:
stored_nick, stored_pass = creds
@@ -650,7 +664,7 @@ class Network:
await self._nickserv_complete()
return
creds = await self.backlog.get_nickserv_creds_by_network(self.cfg.name)
creds = await self.backlog.get_nickserv_creds_by_network(self.cred_network)
if not creds:
log.info("[%s] no stored Q creds, skipping auth", self.cfg.name)
self._status("no Q account (register at quakenet.org)")
@@ -742,7 +756,7 @@ class Network:
log.info("[%s] NickServ IDENTIFY succeeded", self.cfg.name)
if self.backlog and self._nickserv_password:
await self.backlog.save_nickserv_creds(
self.cfg.name, self.nick,
self.cred_network, self.nick,
self._nickserv_password, "",
self.visible_host or "",
verify_url="",
@@ -934,7 +948,7 @@ class Network:
# Persist pending state for cross-session resume
if self.backlog and self._nickserv_password and self._nickserv_email:
await self.backlog.save_nickserv_creds(
self.cfg.name, self.nick,
self.cred_network, self.nick,
self._nickserv_password, self._nickserv_email,
self.visible_host or "",
status="pending",
@@ -953,7 +967,7 @@ class Network:
self._status(f"verified {self.nick} -- SASL ready")
log.info("[%s] nick %s fully verified, saving credentials", self.cfg.name, self.nick)
if self.backlog and self._nickserv_password:
await self.backlog.mark_nickserv_verified(self.cfg.name, self.nick)
await self.backlog.mark_nickserv_verified(self.cred_network, self.nick)
self._nickserv_pending = ""
await self._nickserv_complete()
@@ -968,7 +982,7 @@ class Network:
if not self.backlog:
return False
pending = await self.backlog.get_pending_registration(self.cfg.name)
pending = await self.backlog.get_pending_registration(self.cred_network)
if not pending:
return False

View File

@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
from bouncer.backlog import Backlog
from bouncer.config import Config, NetworkConfig, ProxyConfig
from bouncer.farm import RegistrationManager
from bouncer.irc import IRCMessage
from bouncer.namespace import decode_target, encode_message
from bouncer.network import Network
@@ -93,6 +94,13 @@ class Router:
self.clients: list[Client] = []
self._lock = asyncio.Lock()
self._notifier = Notifier(config.bouncer, config.proxy)
self._farm = RegistrationManager(
bouncer_cfg=config.bouncer,
networks=config.networks,
proxy_resolver=self._proxy_for,
backlog=backlog,
data_dir=data_dir,
)
def _proxy_for(self, net_cfg: NetworkConfig) -> ProxyConfig:
"""Return the effective proxy config for a network."""
@@ -117,9 +125,11 @@ class Router:
)
self.networks[name] = network
asyncio.create_task(network.start())
await self._farm.start()
async def stop_networks(self) -> None:
"""Disconnect all networks."""
await self._farm.stop()
for network in self.networks.values():
await network.stop()
@@ -342,3 +352,8 @@ class Router:
def get_network(self, name: str) -> Network | None:
"""Get a network by name."""
return self.networks.get(name)
@property
def farm(self) -> RegistrationManager:
"""Access the background account farming manager."""
return self._farm

295
tests/test_farm.py Normal file
View File

@@ -0,0 +1,295 @@
"""Tests for background account farming."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bouncer.config import BouncerConfig, NetworkConfig, ProxyConfig
from bouncer.farm import FarmStats, RegistrationManager
# -- helpers -----------------------------------------------------------------
def _bouncer(**overrides: object) -> BouncerConfig:
defaults: dict[str, object] = {
"farm_enabled": True,
"farm_interval": 3600,
"farm_max_accounts": 10,
"probation_seconds": 1,
"backoff_steps": [0],
}
defaults.update(overrides)
return BouncerConfig(**defaults) # type: ignore[arg-type]
def _net_cfg(name: str = "testnet", auth_service: str = "nickserv") -> NetworkConfig:
return NetworkConfig(
name=name,
host="irc.test.net",
port=6697,
tls=True,
auth_service=auth_service,
)
def _proxy_resolver(net_cfg: NetworkConfig) -> ProxyConfig:
return ProxyConfig(host="127.0.0.1", port=1080)
def _mock_backlog(verified_count: int = 0) -> AsyncMock:
bl = AsyncMock()
bl.count_verified_creds = AsyncMock(return_value=verified_count)
return bl
def _manager(
networks: dict[str, NetworkConfig] | None = None,
backlog: AsyncMock | None = None,
**bouncer_kw: object,
) -> RegistrationManager:
nets = networks or {"testnet": _net_cfg()}
return RegistrationManager(
bouncer_cfg=_bouncer(**bouncer_kw),
networks=nets,
proxy_resolver=_proxy_resolver,
backlog=backlog or _mock_backlog(),
)
# -- tests -------------------------------------------------------------------
class TestFarmDisabled:
@pytest.mark.asyncio
async def test_start_noop_when_disabled(self) -> None:
"""start() is a no-op when farm_enabled=False."""
mgr = _manager(farm_enabled=False)
await mgr.start()
assert mgr._loop_task is None
@pytest.mark.asyncio
async def test_stop_safe_when_not_started(self) -> None:
mgr = _manager(farm_enabled=False)
await mgr.stop() # should not raise
class TestFarmSkipsNonNickserv:
@pytest.mark.asyncio
async def test_skips_qbot(self) -> None:
"""Networks with auth_service='qbot' are skipped."""
nets = {"quake": _net_cfg("quake", auth_service="qbot")}
mgr = _manager(networks=nets)
await mgr._maybe_spawn("quake", nets["quake"])
assert "quake" not in mgr._active
@pytest.mark.asyncio
async def test_skips_none(self) -> None:
"""Networks with auth_service='none' are skipped."""
nets = {"anon": _net_cfg("anon", auth_service="none")}
mgr = _manager(networks=nets)
await mgr._maybe_spawn("anon", nets["anon"])
assert "anon" not in mgr._active
class TestFarmMaxAccounts:
@pytest.mark.asyncio
async def test_skips_when_at_max(self) -> None:
"""No spawn when verified count >= farm_max_accounts."""
bl = _mock_backlog(verified_count=10)
mgr = _manager(backlog=bl, farm_max_accounts=10)
net_cfg = _net_cfg()
await mgr._maybe_spawn("testnet", net_cfg)
assert "testnet" not in mgr._active
@pytest.mark.asyncio
async def test_spawns_below_max(self) -> None:
"""Spawn when verified count < farm_max_accounts."""
bl = _mock_backlog(verified_count=5)
mgr = _manager(backlog=bl, farm_max_accounts=10)
net_cfg = _net_cfg()
with patch.object(mgr, "_spawn_ephemeral") as mock_spawn:
await mgr._maybe_spawn("testnet", net_cfg)
mock_spawn.assert_called_once_with("testnet", net_cfg)
class TestFarmInterval:
@pytest.mark.asyncio
async def test_respects_cooldown(self) -> None:
"""Cooldown enforced between attempts."""
import time
bl = _mock_backlog(verified_count=0)
mgr = _manager(backlog=bl, farm_interval=3600)
# Simulate recent attempt
mgr._stats["testnet"] = FarmStats(
attempts=1, last_attempt=time.time(),
)
net_cfg = _net_cfg()
with patch.object(mgr, "_spawn_ephemeral") as mock_spawn:
await mgr._maybe_spawn("testnet", net_cfg)
mock_spawn.assert_not_called()
class TestFarmSpawnEphemeral:
@pytest.mark.asyncio
async def test_creates_correct_config(self) -> None:
"""Ephemeral Network gets correct config (cred_network, ephemeral, no channels)."""
bl = _mock_backlog()
mgr = _manager(backlog=bl)
net_cfg = _net_cfg()
with patch("bouncer.farm.Network") as MockNetwork:
mock_eph = MagicMock()
mock_eph._nickserv_done = asyncio.Event()
mock_eph.start = AsyncMock()
mock_eph.stop = AsyncMock()
MockNetwork.return_value = mock_eph
mgr._spawn_ephemeral("testnet", net_cfg)
# Verify Network was constructed with correct params
call_kwargs = MockNetwork.call_args[1]
assert call_kwargs["cred_network"] == "testnet"
assert call_kwargs["ephemeral"] is True
assert call_kwargs["on_message"] is None
assert call_kwargs["on_status"] is None
eph_cfg = MockNetwork.call_args[1]["cfg"]
assert eph_cfg.name == "_farm_testnet"
assert eph_cfg.channels == []
assert eph_cfg.host == "irc.test.net"
assert "testnet" in mgr._active
# Cleanup spawned task
mgr._active["testnet"].cancel()
try:
await mgr._active["testnet"]
except asyncio.CancelledError:
pass
class TestFarmOneAtATime:
@pytest.mark.asyncio
async def test_blocks_second_spawn(self) -> None:
"""Second spawn blocked while first is active."""
bl = _mock_backlog(verified_count=0)
mgr = _manager(backlog=bl)
net_cfg = _net_cfg()
# Simulate an active task
mgr._active["testnet"] = asyncio.create_task(asyncio.sleep(999))
try:
with patch.object(mgr, "_spawn_ephemeral") as mock_spawn:
await mgr._maybe_spawn("testnet", net_cfg)
mock_spawn.assert_not_called()
finally:
mgr._active["testnet"].cancel()
try:
await mgr._active["testnet"]
except asyncio.CancelledError:
pass
class TestFarmCleanup:
@pytest.mark.asyncio
async def test_stop_cancels_active(self) -> None:
"""All active ephemerals stopped on stop()."""
mgr = _manager()
task = asyncio.create_task(asyncio.sleep(999))
mgr._active["testnet"] = task
mgr._loop_task = asyncio.create_task(asyncio.sleep(999))
await mgr.stop()
assert task.cancelled()
assert not mgr._active
assert mgr._loop_task is None
class TestFarmStatsTracking:
@pytest.mark.asyncio
async def test_stats_updated_on_success(self) -> None:
"""FarmStats updated on success."""
bl = AsyncMock()
# Before: 0 verified, after: 1 verified
bl.count_verified_creds = AsyncMock(side_effect=[0, 1])
mgr = _manager(backlog=bl)
mock_eph = MagicMock()
done_event = asyncio.Event()
done_event.set()
mock_eph._nickserv_done = done_event
mock_eph.start = AsyncMock()
mock_eph.stop = AsyncMock()
await mgr._run_ephemeral("testnet", mock_eph)
stats = mgr._stats["testnet"]
assert stats.successes == 1
assert stats.last_success > 0
@pytest.mark.asyncio
async def test_stats_updated_on_failure(self) -> None:
"""FarmStats updated on failure."""
bl = AsyncMock()
# Before: 0, after: still 0
bl.count_verified_creds = AsyncMock(side_effect=[0, 0])
mgr = _manager(backlog=bl)
mock_eph = MagicMock()
done_event = asyncio.Event()
done_event.set()
mock_eph._nickserv_done = done_event
mock_eph.start = AsyncMock()
mock_eph.stop = AsyncMock()
await mgr._run_ephemeral("testnet", mock_eph)
stats = mgr._stats["testnet"]
assert stats.failures == 1
assert stats.last_error == "no new verified account"
class TestFarmManualTrigger:
@pytest.mark.asyncio
async def test_trigger_bypasses_cooldown(self) -> None:
"""trigger() bypasses cooldown for a specific network."""
bl = _mock_backlog()
mgr = _manager(backlog=bl)
import time
mgr._stats["testnet"] = FarmStats(
attempts=1, last_attempt=time.time(),
)
with patch("bouncer.farm.Network") as MockNetwork:
mock_eph = MagicMock()
mock_eph._nickserv_done = asyncio.Event()
mock_eph.start = AsyncMock()
mock_eph.stop = AsyncMock()
MockNetwork.return_value = mock_eph
result = mgr.trigger("testnet")
assert result is True
assert "testnet" in mgr._active
# Cleanup
mgr._active["testnet"].cancel()
try:
await mgr._active["testnet"]
except asyncio.CancelledError:
pass
def test_trigger_unknown_network(self) -> None:
"""trigger() returns False for unknown network."""
mgr = _manager()
assert mgr.trigger("nonexistent") is False
@pytest.mark.asyncio
async def test_trigger_already_active(self) -> None:
"""trigger() returns False when ephemeral already active."""
mgr = _manager()
mgr._active["testnet"] = asyncio.create_task(asyncio.sleep(999))
try:
assert mgr.trigger("testnet") is False
finally:
mgr._active["testnet"].cancel()
try:
await mgr._active["testnet"]
except asyncio.CancelledError:
pass

View File

@@ -1471,3 +1471,93 @@ class TestCapServerTime:
assert net.server_time is False
net._server_time = True
assert net.server_time is True
# -- cred_network + ephemeral -----------------------------------------------
class TestCredNetwork:
def test_defaults_to_cfg_name(self) -> None:
"""cred_network defaults to cfg.name when not overridden."""
net = _net()
assert net.cred_network == "testnet"
def test_override(self) -> None:
"""cred_network uses explicit value when provided."""
net = Network(
cfg=_cfg(name="_farm_libera"),
proxy_cfg=_proxy(),
bouncer_cfg=_bouncer(),
cred_network="libera",
)
assert net.cred_network == "libera"
class TestEphemeral:
def test_status_suppressed(self) -> None:
"""Ephemeral _status() logs but doesn't call on_status."""
status_cb = MagicMock()
net = Network(
cfg=_cfg(),
proxy_cfg=_proxy(),
bouncer_cfg=_bouncer(),
on_status=status_cb,
ephemeral=True,
)
net._status("test message")
status_cb.assert_not_called()
@pytest.mark.asyncio
async def test_go_ready_registers_directly(self) -> None:
"""Ephemeral _go_ready() calls _nickserv_register() directly."""
net = Network(
cfg=_cfg(),
proxy_cfg=_proxy(),
bouncer_cfg=_bouncer(),
ephemeral=True,
)
net.state = State.PROBATION
net._running = True
net.nick = "ephemeral_nick"
register_called = False
async def mock_register() -> None:
nonlocal register_called
register_called = True
# Signal done so _go_ready doesn't block forever
net._nickserv_done.set()
with patch.object(net, "_nickserv_register", side_effect=mock_register):
await net._go_ready()
assert register_called
assert net.state == State.READY
@pytest.mark.asyncio
async def test_skips_sasl(self) -> None:
"""Ephemeral _connect() uses random nick, no SASL."""
bl = _mock_backlog(creds_by_network=("stored_nick", "secret"))
net = Network(
cfg=_cfg(),
proxy_cfg=_proxy(),
backlog=bl,
bouncer_cfg=_bouncer(),
ephemeral=True,
)
reader = MagicMock()
writer = MagicMock()
writer.is_closing.return_value = False
writer.drain = AsyncMock()
with patch("bouncer.proxy.connect", new_callable=AsyncMock,
return_value=(reader, writer)):
with patch.object(net, "_read_loop", new_callable=AsyncMock):
await net._connect()
# Should NOT have looked up creds (ephemeral skips SASL)
bl.get_nickserv_creds_by_network.assert_not_called()
assert net._sasl_mechanism == ""
# Should have sent NICK with random nick, not stored
calls = [c.args[0] for c in writer.write.call_args_list]
cap_sasl = any(b"CAP REQ sasl" in c for c in calls)
assert not cap_sasl