diff --git a/config/bouncer.example.toml b/config/bouncer.example.toml index c1a6ee8..6e9c015 100644 --- a/config/bouncer.example.toml +++ b/config/bouncer.example.toml @@ -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 diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 8663c54..a50d7d7 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -98,6 +98,15 @@ PASS # 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 diff --git a/docs/USAGE.md b/docs/USAGE.md index 5770d4d..9ff43f7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 [nick]` | Delete a client certificate | +### Account Farming + +| Command | Description | +|---------|-------------| +| `FARM` | Global farming status (enabled/disabled, per-network stats) | +| `FARM ` | 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 stats + triggers an immediate registration attempt | +| `ACCOUNTS` | List all stored accounts with verified/pending counts | +| `ACCOUNTS ` | 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 diff --git a/src/bouncer/backlog.py b/src/bouncer/backlog.py index da736f3..979c14d 100644 --- a/src/bouncer/backlog.py +++ b/src/bouncer/backlog.py @@ -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]]: diff --git a/src/bouncer/commands.py b/src/bouncer/commands.py index 1644787..998fbc4 100644 --- a/src/bouncer/commands.py +++ b/src/bouncer/commands.py @@ -45,6 +45,8 @@ _COMMANDS: dict[str, str] = { "GENCERT": "Generate client cert (GENCERT [nick])", "CERTFP": "Show cert fingerprints (CERTFP [network])", "DELCERT": "Delete client cert (DELCERT [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 diff --git a/src/bouncer/config.py b/src/bouncer/config.py index e29bd46..13da124 100644 --- a/src/bouncer/config.py +++ b/src/bouncer/config.py @@ -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", {}) diff --git a/src/bouncer/farm.py b/src/bouncer/farm.py new file mode 100644 index 0000000..85f28aa --- /dev/null +++ b/src/bouncer/farm.py @@ -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 diff --git a/src/bouncer/network.py b/src/bouncer/network.py index fd2b34d..cde5cad 100644 --- a/src/bouncer/network.py +++ b/src/bouncer/network.py @@ -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 diff --git a/src/bouncer/router.py b/src/bouncer/router.py index dc3002d..7efc178 100644 --- a/src/bouncer/router.py +++ b/src/bouncer/router.py @@ -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 diff --git a/tests/test_farm.py b/tests/test_farm.py new file mode 100644 index 0000000..cf57277 --- /dev/null +++ b/tests/test_farm.py @@ -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 diff --git a/tests/test_network.py b/tests/test_network.py index f142255..3391bcb 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -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