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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user