feat: per-network proxy override, CERT ADD timing fix

config: add optional proxy_host/proxy_port to NetworkConfig
router: resolve per-network proxy via _proxy_for() helper
commands: trigger REHASH reconnect on proxy config changes
network: send CERT ADD before CAP END to beat K-line race

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 02:25:39 +01:00
parent 15f0d374d2
commit 0e06a18851
4 changed files with 23 additions and 8 deletions

View File

@@ -476,7 +476,9 @@ async def _cmd_rehash(router: Router) -> list[str]:
new_net_cfg = new_cfg.networks[name] new_net_cfg = new_cfg.networks[name]
if (old_net.cfg.host != new_net_cfg.host if (old_net.cfg.host != new_net_cfg.host
or old_net.cfg.port != new_net_cfg.port or old_net.cfg.port != new_net_cfg.port
or old_net.cfg.tls != new_net_cfg.tls): or old_net.cfg.tls != new_net_cfg.tls
or old_net.cfg.proxy_host != new_net_cfg.proxy_host
or old_net.cfg.proxy_port != new_net_cfg.proxy_port):
await router.remove_network(name) await router.remove_network(name)
await router.add_network(new_net_cfg) await router.add_network(new_net_cfg)
lines.append(f" reconnected: {name}") lines.append(f" reconnected: {name}")

View File

@@ -43,8 +43,10 @@ class NetworkConfig:
user: str = "" user: str = ""
realname: str = "" realname: str = ""
channels: list[str] = field(default_factory=list) channels: list[str] = field(default_factory=list)
autojoin: bool = True autojoin: bool = False
password: str | None = None password: str | None = None
proxy_host: str | None = None
proxy_port: int | None = None
@dataclass(slots=True) @dataclass(slots=True)
@@ -100,6 +102,8 @@ def load(path: Path) -> Config:
channels=net_raw.get("channels", []), channels=net_raw.get("channels", []),
autojoin=net_raw.get("autojoin", True), autojoin=net_raw.get("autojoin", True),
password=net_raw.get("password"), password=net_raw.get("password"),
proxy_host=net_raw.get("proxy_host"),
proxy_port=net_raw.get("proxy_port"),
) )
if not networks: if not networks:

View File

@@ -827,11 +827,11 @@ class Network:
log.info("[%s] SASL %s authentication successful", self.cfg.name, self._sasl_mechanism) log.info("[%s] SASL %s authentication successful", self.cfg.name, self._sasl_mechanism)
self._status(f"SASL {self._sasl_mechanism} authenticated as {self._sasl_nick}") self._status(f"SASL {self._sasl_mechanism} authenticated as {self._sasl_nick}")
self._sasl_complete.set() self._sasl_complete.set()
await self.send_raw("CAP", "END") # Register cert fingerprint BEFORE CAP END so NickServ processes
# If authenticated via PLAIN but a cert exists, register fingerprint # it while we're still in capability negotiation (before K-line)
# immediately (before K-line can disconnect us)
if self._sasl_mechanism == "PLAIN" and self.data_dir: if self._sasl_mechanism == "PLAIN" and self.data_dir:
await self._register_cert_fingerprint() await self._register_cert_fingerprint()
await self.send_raw("CAP", "END")
return return
if msg.command in ("902", "904", "905"): if msg.command in ("902", "904", "905"):

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bouncer.backlog import Backlog from bouncer.backlog import Backlog
from bouncer.config import Config, NetworkConfig from bouncer.config import Config, NetworkConfig, ProxyConfig
from bouncer.irc import IRCMessage from bouncer.irc import IRCMessage
from bouncer.namespace import decode_target, encode_message from bouncer.namespace import decode_target, encode_message
from bouncer.network import Network from bouncer.network import Network
@@ -91,12 +91,21 @@ class Router:
self.clients: list[Client] = [] self.clients: list[Client] = []
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
def _proxy_for(self, net_cfg: NetworkConfig) -> ProxyConfig:
"""Return the effective proxy config for a network."""
if net_cfg.proxy_host is not None:
return ProxyConfig(
host=net_cfg.proxy_host,
port=net_cfg.proxy_port or self.config.proxy.port,
)
return self.config.proxy
async def start_networks(self) -> None: async def start_networks(self) -> None:
"""Connect to all configured networks.""" """Connect to all configured networks."""
for name, net_cfg in self.config.networks.items(): for name, net_cfg in self.config.networks.items():
network = Network( network = Network(
cfg=net_cfg, cfg=net_cfg,
proxy_cfg=self.config.proxy, proxy_cfg=self._proxy_for(net_cfg),
backlog=self.backlog, backlog=self.backlog,
on_message=self._on_network_message, on_message=self._on_network_message,
on_status=self._on_network_status, on_status=self._on_network_status,
@@ -280,7 +289,7 @@ class Router:
"""Create and start a new network at runtime.""" """Create and start a new network at runtime."""
network = Network( network = Network(
cfg=cfg, cfg=cfg,
proxy_cfg=self.config.proxy, proxy_cfg=self._proxy_for(cfg),
backlog=self.backlog, backlog=self.backlog,
on_message=self._on_network_message, on_message=self._on_network_message,
on_status=self._on_network_status, on_status=self._on_network_status,