feat: multi-network namespace multiplexing
Multiplex all networks onto a single client connection using /network suffixes on channels and nicks. PASS is now just the password (no network prefix). Channels appear as #channel/network, foreign nicks as nick/network, own nicks stay bare. New namespace.py module with pure encode/decode functions. Router tracks clients globally (not per-network), namespaces messages before delivery. Client attaches to all networks on connect, sends synthetic JOIN/TOPIC/NAMES for every channel across all networks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,11 +35,12 @@ DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY
|
||||
|--------|---------------|
|
||||
| `irc.py` | IRC protocol parser/formatter (RFC 2812 subset) |
|
||||
| `config.py` | TOML configuration loading and validation |
|
||||
| `namespace.py` | `/network` suffix encode/decode for multi-network multiplexing |
|
||||
| `proxy.py` | SOCKS5 async connector with local DNS + multi-IP failover |
|
||||
| `network.py` | Server connection state machine, stealth registration |
|
||||
| `server.py` | TCP listener accepting IRC client connections |
|
||||
| `client.py` | Per-client session, PASS/NICK/USER handshake |
|
||||
| `router.py` | Message routing between clients and networks |
|
||||
| `client.py` | Per-client session, PASS/NICK/USER handshake, multi-network attach |
|
||||
| `router.py` | Namespaced message routing between clients and networks |
|
||||
| `backlog.py` | SQLite message storage and replay |
|
||||
|
||||
### Key Decisions
|
||||
|
||||
16
README.md
16
README.md
@@ -4,12 +4,12 @@ IRC bouncer with SOCKS5 proxy support and persistent message backlog.
|
||||
|
||||
## Features
|
||||
|
||||
- Connect to multiple IRC networks simultaneously
|
||||
- **Multi-network multiplexing**: single client connection sees all networks via `/network` suffixes
|
||||
- All outbound connections routed through SOCKS5 proxy
|
||||
- Stealth connect: registers with a random pronounceable nick and generic identity
|
||||
- Probation window: waits 15s after registration to detect K-lines before revealing real nick
|
||||
- Persistent message backlog (SQLite) with replay on reconnect
|
||||
- Multiple clients can attach to the same network session
|
||||
- Multiple clients can attach simultaneously
|
||||
- Password authentication
|
||||
- TLS support for IRC server connections
|
||||
- Automatic reconnection with exponential backoff
|
||||
@@ -32,10 +32,18 @@ bouncer -c config/bouncer.toml -v
|
||||
From your IRC client, connect to `127.0.0.1:6667` with:
|
||||
|
||||
```
|
||||
PASS networkname:yourpassword
|
||||
PASS yourpassword
|
||||
```
|
||||
|
||||
Where `networkname` matches a `[networks.NAME]` section in your config.
|
||||
A single connection gives you all configured networks. Channels and nicks
|
||||
appear with a `/network` suffix:
|
||||
|
||||
```
|
||||
Client sees: Server sends/receives:
|
||||
#libera/libera <-> #libera (on libera network)
|
||||
#debian/oftc <-> #debian (on oftc network)
|
||||
user123/libera <-> user123 (on libera network)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- [x] Stealth connect (random markov-generated identity)
|
||||
- [x] Probation window (K-line detection before revealing nick)
|
||||
- [x] Verified end-to-end on Libera.Chat via SOCKS5
|
||||
- [x] Multi-network namespace multiplexing (`/network` suffixes)
|
||||
|
||||
## v0.2.0
|
||||
|
||||
|
||||
1
TASKS.md
1
TASKS.md
@@ -11,6 +11,7 @@
|
||||
- [x] P1: Integration testing with live IRC server (Libera.Chat)
|
||||
- [x] P1: Verified SOCKS5 proxy connectivity end-to-end
|
||||
- [x] P1: Documentation update
|
||||
- [x] P1: Multi-network namespace multiplexing (`/network` suffixes)
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -11,11 +11,16 @@ replay_on_connect = true
|
||||
host = "127.0.0.1"
|
||||
port = 1080
|
||||
|
||||
# Client PASS is just the password (no network prefix).
|
||||
# A single client connection sees all networks via /network suffixes:
|
||||
# #libera/libera, #debian/oftc, user123/libera
|
||||
#
|
||||
# Registration uses a random nick and generic ident/realname.
|
||||
# After surviving the probation window (no k-line), the bouncer
|
||||
# derives a stable nick from the exit endpoint hostname. The same
|
||||
# exit IP always produces the same nick across reconnects.
|
||||
# Set nick to override (optional, used as fallback only).
|
||||
# Network names must not contain '/' (reserved for namespace separator).
|
||||
|
||||
[networks.libera]
|
||||
host = "irc.libera.chat"
|
||||
|
||||
@@ -38,8 +38,21 @@ make clean # rm .venv, build artifacts
|
||||
## Client Auth
|
||||
|
||||
```
|
||||
PASS <network>:<password> # select network + authenticate
|
||||
PASS <password> # use first network
|
||||
PASS <password> # authenticate (all networks)
|
||||
```
|
||||
|
||||
## Namespacing
|
||||
|
||||
```
|
||||
#channel/network # channel on a specific network
|
||||
nick/network # foreign nick on a specific network
|
||||
own-nick # own nicks shown without suffix
|
||||
```
|
||||
|
||||
```
|
||||
/msg #libera/libera hello # send to #libera on libera network
|
||||
/join #test/oftc # join #test on oftc
|
||||
/join #a/libera,#b/oftc # comma-separated, different networks
|
||||
```
|
||||
|
||||
## Connection States
|
||||
@@ -107,6 +120,7 @@ src/bouncer/
|
||||
cli.py # argparse
|
||||
config.py # TOML loader
|
||||
irc.py # IRC message parse/format
|
||||
namespace.py # /network encode/decode for multiplexing
|
||||
proxy.py # SOCKS5 connector (local DNS, multi-IP)
|
||||
network.py # server connection + state machine
|
||||
client.py # client session handler
|
||||
|
||||
@@ -82,59 +82,78 @@ Configure your IRC client to connect to the bouncer:
|
||||
|---------|-------|
|
||||
| Server | `127.0.0.1` |
|
||||
| Port | `6667` (or as configured) |
|
||||
| Password | `networkname:yourpassword` |
|
||||
| Password | `yourpassword` |
|
||||
|
||||
### Password Format
|
||||
|
||||
```
|
||||
PASS <network>:<password>
|
||||
PASS <password>
|
||||
```
|
||||
|
||||
- `network` -- matches a `[networks.NAME]` section in config
|
||||
- `password` -- the `bouncer.password` value from config
|
||||
|
||||
If you omit the network prefix (`PASS yourpassword`), the first configured
|
||||
network is used.
|
||||
The password is the `bouncer.password` value from config. A single connection
|
||||
automatically attaches to **all** configured networks.
|
||||
|
||||
### Client Examples
|
||||
|
||||
**irssi:**
|
||||
```
|
||||
/connect -password libera:mypassword 127.0.0.1 6667
|
||||
/connect -password mypassword 127.0.0.1 6667
|
||||
```
|
||||
|
||||
**weechat:**
|
||||
```
|
||||
/server add bouncer 127.0.0.1/6667 -password=libera:mypassword
|
||||
/server add bouncer 127.0.0.1/6667 -password=mypassword
|
||||
/connect bouncer
|
||||
```
|
||||
|
||||
**hexchat:**
|
||||
|
||||
Set server password to `libera:mypassword` in the network settings.
|
||||
Set server password to `mypassword` in the network settings.
|
||||
|
||||
## Multiple Networks
|
||||
## Multi-Network Namespacing
|
||||
|
||||
Define multiple `[networks.*]` sections in the config. Each gets its own
|
||||
persistent server connection through the SOCKS5 proxy.
|
||||
|
||||
Connect your client with the appropriate network prefix:
|
||||
All configured networks are multiplexed onto a single client connection. Channels
|
||||
and nicks carry a `/network` suffix so you can tell which network they belong to:
|
||||
|
||||
```
|
||||
PASS libera:mypassword # connects to [networks.libera]
|
||||
PASS oftc:mypassword # connects to [networks.oftc]
|
||||
Client sees: Server wire:
|
||||
#libera/libera <-> #libera (on libera network)
|
||||
#debian/oftc <-> #debian (on oftc network)
|
||||
user123/libera <-> user123 (on libera network)
|
||||
```
|
||||
|
||||
Multiple clients can attach to the same network simultaneously. All receive
|
||||
the same messages in real time.
|
||||
### Rules
|
||||
|
||||
- **Channels**: `#channel/network` in client, `#channel` on wire
|
||||
- **Foreign nicks**: `nick/network` in client, `nick` on wire
|
||||
- **Own nicks**: shown without suffix (prevents client confusion)
|
||||
- **Sending messages**: include the `/network` suffix in the target
|
||||
|
||||
```
|
||||
/msg #libera/libera hello -> sends "hello" to #libera on libera network
|
||||
/join #test/oftc -> joins #test on oftc network
|
||||
/msg user123/libera hi -> private message to user123 on libera
|
||||
```
|
||||
|
||||
### Comma-Separated JOIN/PART
|
||||
|
||||
Targets can span networks:
|
||||
|
||||
```
|
||||
/join #a/libera,#b/oftc -> joins #a on libera AND #b on oftc
|
||||
```
|
||||
|
||||
Multiple clients can attach simultaneously. All receive the same namespaced
|
||||
messages in real time.
|
||||
|
||||
## What Clients Receive on Connect
|
||||
|
||||
When a client authenticates and attaches to a network:
|
||||
When a client authenticates:
|
||||
|
||||
1. **Backlog replay** -- missed messages since last disconnect
|
||||
2. **Synthetic welcome** -- 001-004 numeric replies from the bouncer
|
||||
3. **Channel state** -- TOPIC and NAMES for each joined channel
|
||||
1. **Backlog replay** -- missed messages (namespaced) from all networks
|
||||
2. **Synthetic welcome** -- 001-004 numeric replies listing all networks
|
||||
3. **Channel state** -- synthetic JOIN, TOPIC, and NAMES for every joined
|
||||
channel across all networks (all namespaced with `/network` suffix)
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bouncer.irc import IRCMessage, parse
|
||||
from bouncer.namespace import encode_channel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bouncer.network import Network
|
||||
from bouncer.router import Router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -45,8 +45,6 @@ class Client:
|
||||
self._writer = writer
|
||||
self._router = router
|
||||
self._password = password
|
||||
self._network_name: str | None = None
|
||||
self._network: Network | None = None
|
||||
self._nick: str = "*"
|
||||
self._user: str = "unknown"
|
||||
self._realname: str = ""
|
||||
@@ -106,8 +104,8 @@ class Client:
|
||||
if msg.command == "QUIT":
|
||||
return
|
||||
|
||||
if msg.command in FORWARD_COMMANDS and self._network_name:
|
||||
await self._router.client_to_network(self._network_name, msg)
|
||||
if msg.command in FORWARD_COMMANDS:
|
||||
await self._router.route_client_message(msg)
|
||||
|
||||
async def _handle_registration(self, msg: IRCMessage) -> None:
|
||||
"""Handle PASS/NICK/USER registration sequence."""
|
||||
@@ -137,17 +135,11 @@ class Client:
|
||||
await self._complete_registration()
|
||||
|
||||
async def _complete_registration(self) -> None:
|
||||
"""Validate credentials and attach to network."""
|
||||
# Parse PASS: "network:password" or just "password" (use first network)
|
||||
network_name: str | None = None
|
||||
password: str = ""
|
||||
"""Validate credentials and attach to all networks."""
|
||||
password = self._pass_raw if self._got_pass else ""
|
||||
|
||||
if self._got_pass and ":" in self._pass_raw:
|
||||
network_name, password = self._pass_raw.split(":", 1)
|
||||
elif self._got_pass:
|
||||
password = self._pass_raw
|
||||
else:
|
||||
self._send_error("Password required (PASS network:password)")
|
||||
if not password:
|
||||
self._send_error("Password required (PASS <password>)")
|
||||
self._writer.close()
|
||||
return
|
||||
|
||||
@@ -156,46 +148,27 @@ class Client:
|
||||
self._writer.close()
|
||||
return
|
||||
|
||||
# Resolve network
|
||||
if not network_name:
|
||||
# Default to first configured network
|
||||
names = self._router.network_names()
|
||||
if names:
|
||||
network_name = names[0]
|
||||
|
||||
if not network_name:
|
||||
self._send_error("No network specified and none configured")
|
||||
self._writer.close()
|
||||
return
|
||||
|
||||
self._authenticated = True
|
||||
self._registered = True
|
||||
self._network_name = network_name
|
||||
|
||||
# Attach to network
|
||||
self._network = await self._router.attach(self, network_name)
|
||||
if not self._network:
|
||||
self._send_error(f"Unknown network: {network_name}")
|
||||
self._writer.close()
|
||||
return
|
||||
# Attach to all networks at once
|
||||
await self._router.attach_all(self)
|
||||
|
||||
log.info(
|
||||
"client %s authenticated for network %s (nick=%s)",
|
||||
self._addr, network_name, self._nick,
|
||||
)
|
||||
log.info("client %s authenticated (nick=%s)", self._addr, self._nick)
|
||||
|
||||
# Send synthetic welcome
|
||||
await self._send_welcome()
|
||||
|
||||
async def _send_welcome(self) -> None:
|
||||
"""Send IRC welcome sequence and channel state to client."""
|
||||
assert self._network is not None
|
||||
"""Send IRC welcome sequence and channel state from all networks."""
|
||||
server_name = "bouncer"
|
||||
nick = self._network.nick
|
||||
# Pick a representative nick for the welcome numerics
|
||||
own_nicks = self._router.get_own_nicks()
|
||||
nick = next(iter(own_nicks.values()), self._nick)
|
||||
|
||||
networks = ", ".join(self._router.network_names())
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_WELCOME, prefix=server_name,
|
||||
params=[nick, f"Welcome to bouncer ({self._network_name})"],
|
||||
params=[nick, f"Welcome to bouncer (networks: {networks})"],
|
||||
))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_YOURHOST, prefix=server_name,
|
||||
@@ -210,28 +183,39 @@ class Client:
|
||||
params=[nick, server_name, "bouncer-0.1", "o", "o"],
|
||||
))
|
||||
|
||||
# Send channel state for joined channels
|
||||
for channel in self._network.channels:
|
||||
# Topic
|
||||
topic = self._network.topics.get(channel, "")
|
||||
if topic:
|
||||
# Send namespaced channel state from every network
|
||||
for net_name, network in sorted(self._router.networks.items()):
|
||||
net_nick = network.nick
|
||||
for channel in sorted(network.channels):
|
||||
ns_channel = encode_channel(channel, net_name)
|
||||
|
||||
# Synthetic JOIN so the client knows we're in this channel
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_TOPIC, prefix=server_name,
|
||||
params=[nick, channel, topic],
|
||||
command="JOIN",
|
||||
params=[ns_channel],
|
||||
prefix=net_nick,
|
||||
))
|
||||
|
||||
# Names
|
||||
names = self._network.names.get(channel, set())
|
||||
if names:
|
||||
name_str = " ".join(sorted(names))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_NAMREPLY, prefix=server_name,
|
||||
params=[nick, "=", channel, name_str],
|
||||
))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_ENDOFNAMES, prefix=server_name,
|
||||
params=[nick, channel, "End of /NAMES list"],
|
||||
))
|
||||
# Topic
|
||||
topic = network.topics.get(channel, "")
|
||||
if topic:
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_TOPIC, prefix=server_name,
|
||||
params=[nick, ns_channel, topic],
|
||||
))
|
||||
|
||||
# Names
|
||||
names = network.names.get(channel, set())
|
||||
if names:
|
||||
name_str = " ".join(sorted(names))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_NAMREPLY, prefix=server_name,
|
||||
params=[nick, "=", ns_channel, name_str],
|
||||
))
|
||||
self._send_msg(IRCMessage(
|
||||
command=RPL_ENDOFNAMES, prefix=server_name,
|
||||
params=[nick, ns_channel, "End of /NAMES list"],
|
||||
))
|
||||
|
||||
def _send_msg(self, msg: IRCMessage) -> None:
|
||||
"""Send an IRCMessage to this client."""
|
||||
@@ -242,9 +226,8 @@ class Client:
|
||||
self._send_msg(IRCMessage(command="ERROR", params=[text]))
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
"""Detach from network and close connection."""
|
||||
"""Detach from all networks and close connection."""
|
||||
log.info("client disconnected from %s", self._addr)
|
||||
if self._network_name:
|
||||
await self._router.detach(self, self._network_name)
|
||||
await self._router.detach_all(self)
|
||||
if not self._writer.is_closing():
|
||||
self._writer.close()
|
||||
|
||||
@@ -105,4 +105,11 @@ def load(path: Path) -> Config:
|
||||
if not networks:
|
||||
raise ValueError("at least one network must be configured")
|
||||
|
||||
for name in networks:
|
||||
if "/" in name:
|
||||
raise ValueError(
|
||||
f"network name {name!r} must not contain '/' "
|
||||
"(reserved for namespace separator)"
|
||||
)
|
||||
|
||||
return Config(bouncer=bouncer, proxy=proxy, networks=networks)
|
||||
|
||||
112
src/bouncer/namespace.py
Normal file
112
src/bouncer/namespace.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Network namespace encoding/decoding for multi-network multiplexing.
|
||||
|
||||
Channels and nicks are suffixed with /network so a single client connection
|
||||
can see traffic from all networks at once.
|
||||
|
||||
Client sees: Wire:
|
||||
#libera/libera <-> #libera (on libera network)
|
||||
user123/libera <-> user123 (on libera network)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bouncer.irc import IRCMessage, parse_prefix
|
||||
|
||||
SEPARATOR = "/"
|
||||
|
||||
|
||||
def encode_channel(channel: str, network: str) -> str:
|
||||
"""Suffix a channel with its network name. ``#ch`` -> ``#ch/net``."""
|
||||
return f"{channel}{SEPARATOR}{network}"
|
||||
|
||||
|
||||
def decode_channel(namespaced: str) -> tuple[str, str | None]:
|
||||
"""Split ``#ch/net`` -> ``('#ch', 'net')``. No separator returns ``(channel, None)``."""
|
||||
idx = namespaced.rfind(SEPARATOR)
|
||||
if idx < 0:
|
||||
return namespaced, None
|
||||
return namespaced[:idx], namespaced[idx + 1:]
|
||||
|
||||
|
||||
def encode_nick(nick: str, network: str, own_nicks: dict[str, str]) -> str:
|
||||
"""Suffix a nick unless it belongs to us on *any* network."""
|
||||
if nick in own_nicks.values():
|
||||
return nick
|
||||
return f"{nick}{SEPARATOR}{network}"
|
||||
|
||||
|
||||
def encode_prefix(prefix: str, network: str, own_nicks: dict[str, str]) -> str:
|
||||
"""Namespace the nick portion of ``nick!user@host``."""
|
||||
nick, user, host = parse_prefix(prefix)
|
||||
enc = encode_nick(nick, network, own_nicks)
|
||||
if user and host:
|
||||
return f"{enc}!{user}@{host}"
|
||||
if user:
|
||||
return f"{enc}!{user}"
|
||||
if host:
|
||||
return f"{enc}@{host}"
|
||||
return enc
|
||||
|
||||
|
||||
def decode_target(target: str) -> tuple[str, str | None]:
|
||||
"""Decode a namespaced target (channel or nick).
|
||||
|
||||
``#ch/net`` -> ``('#ch', 'net')``
|
||||
``nick/net`` -> ``('nick', 'net')``
|
||||
``#ch`` -> ``('#ch', None)``
|
||||
"""
|
||||
idx = target.rfind(SEPARATOR)
|
||||
if idx < 0:
|
||||
return target, None
|
||||
return target[:idx], target[idx + 1:]
|
||||
|
||||
|
||||
def _is_channel(target: str) -> bool:
|
||||
"""Check if a target looks like a channel name."""
|
||||
return target.startswith(("#", "&", "+", "!"))
|
||||
|
||||
|
||||
def encode_message(msg: IRCMessage, network: str, own_nicks: dict[str, str]) -> IRCMessage:
|
||||
"""Namespace an entire IRC message for delivery to a client.
|
||||
|
||||
- Channels in params get ``/network`` suffix
|
||||
- Prefix nick gets ``/network`` suffix (unless it's our own)
|
||||
- Special handling for 353 (NAMREPLY nick list) and NICK (new nick in trailing)
|
||||
"""
|
||||
prefix = msg.prefix
|
||||
if prefix:
|
||||
prefix = encode_prefix(prefix, network, own_nicks)
|
||||
|
||||
params = list(msg.params)
|
||||
|
||||
if msg.command == "353" and len(params) >= 4:
|
||||
# RPL_NAMREPLY: params = [nick, "=", #channel, "nick1 @nick2 +nick3"]
|
||||
params[2] = encode_channel(params[2], network)
|
||||
nicks = params[3].split()
|
||||
encoded = []
|
||||
for n in nicks:
|
||||
# Strip mode prefixes (@, +, %, ~, &)
|
||||
mode_prefix = ""
|
||||
bare = n
|
||||
while bare and bare[0] in "@+%~&":
|
||||
mode_prefix += bare[0]
|
||||
bare = bare[1:]
|
||||
encoded.append(mode_prefix + encode_nick(bare, network, own_nicks))
|
||||
params[3] = " ".join(encoded)
|
||||
|
||||
elif msg.command == "NICK" and params:
|
||||
# NICK: params[0] is the new nick
|
||||
new_nick = params[0]
|
||||
params[0] = encode_nick(new_nick, network, own_nicks)
|
||||
|
||||
else:
|
||||
# Generic: namespace channel-like params[0]
|
||||
if params and _is_channel(params[0]):
|
||||
params[0] = encode_channel(params[0], network)
|
||||
|
||||
return IRCMessage(
|
||||
command=msg.command,
|
||||
params=params,
|
||||
prefix=prefix,
|
||||
tags=msg.tags,
|
||||
)
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
|
||||
from bouncer.backlog import Backlog
|
||||
from bouncer.config import Config
|
||||
from bouncer.irc import IRCMessage, parse_prefix
|
||||
from bouncer.namespace import decode_target, encode_message
|
||||
from bouncer.network import Network
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -19,6 +20,11 @@ log = logging.getLogger(__name__)
|
||||
# Commands worth storing in backlog
|
||||
BACKLOG_COMMANDS = {"PRIVMSG", "NOTICE", "TOPIC", "KICK", "MODE"}
|
||||
|
||||
# Commands where params[0] is a target to decode
|
||||
_TARGET0_COMMANDS = {
|
||||
"PRIVMSG", "NOTICE", "JOIN", "PART", "MODE", "TOPIC", "NAMES", "WHO", "WHOIS",
|
||||
}
|
||||
|
||||
|
||||
class Router:
|
||||
"""Central message hub linking clients to networks."""
|
||||
@@ -27,7 +33,7 @@ class Router:
|
||||
self.config = config
|
||||
self.backlog = backlog
|
||||
self.networks: dict[str, Network] = {}
|
||||
self.clients: dict[str, list[Client]] = {} # network_name -> clients
|
||||
self.clients: list[Client] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def start_networks(self) -> None:
|
||||
@@ -39,7 +45,6 @@ class Router:
|
||||
on_message=self._on_network_message,
|
||||
)
|
||||
self.networks[name] = network
|
||||
self.clients[name] = []
|
||||
asyncio.create_task(network.start())
|
||||
|
||||
async def stop_networks(self) -> None:
|
||||
@@ -47,67 +52,135 @@ class Router:
|
||||
for network in self.networks.values():
|
||||
await network.stop()
|
||||
|
||||
async def attach(self, client: Client, network_name: str) -> Network | None:
|
||||
"""Attach a client to a network. Returns the network or None if not found."""
|
||||
if network_name not in self.networks:
|
||||
return None
|
||||
|
||||
async def attach_all(self, client: Client) -> None:
|
||||
"""Attach a client to all networks and replay backlogs."""
|
||||
async with self._lock:
|
||||
self.clients[network_name].append(client)
|
||||
self.clients.append(client)
|
||||
|
||||
network = self.networks[network_name]
|
||||
client_count = len(self.clients[network_name])
|
||||
log.info("client attached to %s (%d clients)", network_name, client_count)
|
||||
log.info("client attached to all networks (%d clients)", len(self.clients))
|
||||
|
||||
# Replay backlog
|
||||
if self.config.bouncer.backlog.replay_on_connect:
|
||||
await self._replay_backlog(client, network_name)
|
||||
for name in self.networks:
|
||||
await self._replay_backlog(client, name)
|
||||
|
||||
return network
|
||||
|
||||
async def detach(self, client: Client, network_name: str) -> None:
|
||||
"""Detach a client from a network."""
|
||||
async def detach_all(self, client: Client) -> None:
|
||||
"""Detach a client from all networks."""
|
||||
async with self._lock:
|
||||
if network_name in self.clients:
|
||||
try:
|
||||
self.clients[network_name].remove(client)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
self.clients.remove(client)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
remaining = len(self.clients.get(network_name, []))
|
||||
log.info("client detached from %s (%d remaining)", network_name, remaining)
|
||||
remaining = len(self.clients)
|
||||
log.info("client detached (%d remaining)", remaining)
|
||||
|
||||
if remaining == 0:
|
||||
await self.backlog.record_disconnect(network_name)
|
||||
for name in self.networks:
|
||||
await self.backlog.record_disconnect(name)
|
||||
|
||||
async def client_to_network(self, network_name: str, msg: IRCMessage) -> None:
|
||||
"""Forward a client command to the network."""
|
||||
async def route_client_message(self, msg: IRCMessage) -> None:
|
||||
"""Decode namespace from a client message and forward to the right network.
|
||||
|
||||
The target in params[0] (or params[1] for some commands) carries a
|
||||
``/network`` suffix that tells us where to route.
|
||||
"""
|
||||
if not msg.params:
|
||||
return
|
||||
|
||||
if msg.command == "KICK" and len(msg.params) >= 2:
|
||||
# KICK #channel/net nick/net :reason
|
||||
raw_chan, net = decode_target(msg.params[0])
|
||||
raw_nick, _ = decode_target(msg.params[1])
|
||||
if not net:
|
||||
return
|
||||
fwd = IRCMessage(
|
||||
command=msg.command,
|
||||
params=[raw_chan, raw_nick] + msg.params[2:],
|
||||
prefix=msg.prefix,
|
||||
tags=msg.tags,
|
||||
)
|
||||
await self._send_to_network(net, fwd)
|
||||
return
|
||||
|
||||
if msg.command == "INVITE" and len(msg.params) >= 2:
|
||||
# INVITE nick/net #channel/net
|
||||
raw_nick, net1 = decode_target(msg.params[0])
|
||||
raw_chan, net2 = decode_target(msg.params[1])
|
||||
net = net1 or net2
|
||||
if not net:
|
||||
return
|
||||
fwd = IRCMessage(
|
||||
command=msg.command,
|
||||
params=[raw_nick, raw_chan] + msg.params[2:],
|
||||
prefix=msg.prefix,
|
||||
tags=msg.tags,
|
||||
)
|
||||
await self._send_to_network(net, fwd)
|
||||
return
|
||||
|
||||
if msg.command in ("JOIN", "PART") and msg.params:
|
||||
# May be comma-separated: JOIN #a/net1,#b/net2
|
||||
targets = msg.params[0].split(",")
|
||||
by_network: dict[str, list[str]] = {}
|
||||
for t in targets:
|
||||
raw, net = decode_target(t)
|
||||
if net:
|
||||
by_network.setdefault(net, []).append(raw)
|
||||
for net, chans in by_network.items():
|
||||
fwd = IRCMessage(
|
||||
command=msg.command,
|
||||
params=[",".join(chans)] + msg.params[1:],
|
||||
prefix=msg.prefix,
|
||||
tags=msg.tags,
|
||||
)
|
||||
await self._send_to_network(net, fwd)
|
||||
return
|
||||
|
||||
if msg.command in _TARGET0_COMMANDS and msg.params:
|
||||
raw_target, net = decode_target(msg.params[0])
|
||||
if not net:
|
||||
return
|
||||
fwd = IRCMessage(
|
||||
command=msg.command,
|
||||
params=[raw_target] + msg.params[1:],
|
||||
prefix=msg.prefix,
|
||||
tags=msg.tags,
|
||||
)
|
||||
await self._send_to_network(net, fwd)
|
||||
return
|
||||
|
||||
async def _send_to_network(self, network_name: str, msg: IRCMessage) -> None:
|
||||
"""Forward a message to a specific network."""
|
||||
network = self.networks.get(network_name)
|
||||
if network and network.connected:
|
||||
await network.send(msg)
|
||||
|
||||
def get_own_nicks(self) -> dict[str, str]:
|
||||
"""Return ``{network_name: current_nick}`` for all networks."""
|
||||
return {name: net.nick for name, net in self.networks.items()}
|
||||
|
||||
def _on_network_message(self, network_name: str, msg: IRCMessage) -> None:
|
||||
"""Handle a message from an IRC server (called synchronously from network)."""
|
||||
asyncio.create_task(self._dispatch(network_name, msg))
|
||||
|
||||
async def _dispatch(self, network_name: str, msg: IRCMessage) -> None:
|
||||
"""Dispatch a network message to attached clients and backlog."""
|
||||
# Store in backlog for relevant commands
|
||||
# Store in backlog (raw, un-namespaced)
|
||||
if msg.command in BACKLOG_COMMANDS and msg.params:
|
||||
target = msg.params[0]
|
||||
sender = parse_prefix(msg.prefix)[0] if msg.prefix else ""
|
||||
content = msg.params[1] if len(msg.params) > 1 else ""
|
||||
await self.backlog.store(network_name, target, sender, msg.command, content)
|
||||
|
||||
# Prune if configured
|
||||
max_msgs = self.config.bouncer.backlog.max_messages
|
||||
if max_msgs > 0:
|
||||
await self.backlog.prune(network_name, keep=max_msgs)
|
||||
|
||||
# Forward to all attached clients (prefer raw bytes from server)
|
||||
clients = self.clients.get(network_name, [])
|
||||
data = msg.raw if msg.raw else msg.format()
|
||||
for client in clients:
|
||||
# Namespace and forward to all clients
|
||||
own_nicks = self.get_own_nicks()
|
||||
namespaced = encode_message(msg, network_name, own_nicks)
|
||||
data = namespaced.format()
|
||||
for client in self.clients:
|
||||
try:
|
||||
client.write(data)
|
||||
except Exception:
|
||||
@@ -123,19 +196,20 @@ class Router:
|
||||
|
||||
log.info("replaying %d messages for %s", len(entries), network_name)
|
||||
|
||||
own_nicks = self.get_own_nicks()
|
||||
for entry in entries:
|
||||
msg = IRCMessage(
|
||||
command=entry.command,
|
||||
params=[entry.target, entry.content],
|
||||
prefix=entry.sender,
|
||||
)
|
||||
namespaced = encode_message(msg, network_name, own_nicks)
|
||||
try:
|
||||
client.write(msg.format())
|
||||
client.write(namespaced.format())
|
||||
except Exception:
|
||||
log.exception("failed to replay to client")
|
||||
break
|
||||
|
||||
# Mark the latest as seen
|
||||
if entries:
|
||||
await self.backlog.mark_seen(network_name, entries[-1].id)
|
||||
|
||||
|
||||
@@ -97,6 +97,19 @@ password = "x"
|
||||
with pytest.raises(ValueError, match="at least one network"):
|
||||
load(_write_config(config))
|
||||
|
||||
def test_slash_in_network_name_raises(self):
|
||||
config = """\
|
||||
[bouncer]
|
||||
password = "x"
|
||||
|
||||
[proxy]
|
||||
|
||||
[networks."lib/era"]
|
||||
host = "irc.example.com"
|
||||
"""
|
||||
with pytest.raises(ValueError, match="must not contain '/'"):
|
||||
load(_write_config(config))
|
||||
|
||||
def test_tls_default_port(self):
|
||||
config = """\
|
||||
[bouncer]
|
||||
|
||||
143
tests/test_namespace.py
Normal file
143
tests/test_namespace.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Tests for network namespace encoding/decoding."""
|
||||
|
||||
from bouncer.irc import IRCMessage
|
||||
from bouncer.namespace import (
|
||||
decode_channel,
|
||||
decode_target,
|
||||
encode_channel,
|
||||
encode_message,
|
||||
encode_nick,
|
||||
encode_prefix,
|
||||
)
|
||||
|
||||
OWN = {"libera": "mybot", "oftc": "mybot2"}
|
||||
|
||||
|
||||
class TestEncodeChannel:
|
||||
def test_basic(self):
|
||||
assert encode_channel("#libera", "libera") == "#libera/libera"
|
||||
|
||||
def test_preserves_prefix(self):
|
||||
assert encode_channel("&local", "net") == "&local/net"
|
||||
|
||||
|
||||
class TestDecodeChannel:
|
||||
def test_basic(self):
|
||||
assert decode_channel("#libera/libera") == ("#libera", "libera")
|
||||
|
||||
def test_no_separator(self):
|
||||
assert decode_channel("#libera") == ("#libera", None)
|
||||
|
||||
def test_channel_with_slash_in_name(self):
|
||||
# Edge: channel name itself contains slash -- rfind picks the last one
|
||||
assert decode_channel("#a/b/net") == ("#a/b", "net")
|
||||
|
||||
|
||||
class TestEncodeNick:
|
||||
def test_foreign_nick(self):
|
||||
assert encode_nick("user123", "libera", OWN) == "user123/libera"
|
||||
|
||||
def test_own_nick_bare(self):
|
||||
assert encode_nick("mybot", "libera", OWN) == "mybot"
|
||||
|
||||
def test_own_nick_other_network(self):
|
||||
# Own nick on another network still shown bare
|
||||
assert encode_nick("mybot2", "libera", OWN) == "mybot2"
|
||||
|
||||
|
||||
class TestEncodePrefix:
|
||||
def test_full_prefix(self):
|
||||
result = encode_prefix("user!ident@host", "libera", OWN)
|
||||
assert result == "user/libera!ident@host"
|
||||
|
||||
def test_own_prefix(self):
|
||||
result = encode_prefix("mybot!ident@host", "libera", OWN)
|
||||
assert result == "mybot!ident@host"
|
||||
|
||||
def test_nick_only(self):
|
||||
result = encode_prefix("server.example.com", "libera", OWN)
|
||||
assert result == "server.example.com/libera"
|
||||
|
||||
|
||||
class TestDecodeTarget:
|
||||
def test_channel(self):
|
||||
assert decode_target("#libera/libera") == ("#libera", "libera")
|
||||
|
||||
def test_nick(self):
|
||||
assert decode_target("user123/libera") == ("user123", "libera")
|
||||
|
||||
def test_bare(self):
|
||||
assert decode_target("#libera") == ("#libera", None)
|
||||
|
||||
|
||||
class TestEncodeMessage:
|
||||
def test_privmsg_channel(self):
|
||||
msg = IRCMessage(
|
||||
command="PRIVMSG",
|
||||
params=["#test", "hello"],
|
||||
prefix="user!ident@host",
|
||||
)
|
||||
out = encode_message(msg, "libera", OWN)
|
||||
assert out.params[0] == "#test/libera"
|
||||
assert out.prefix == "user/libera!ident@host"
|
||||
|
||||
def test_privmsg_own_prefix(self):
|
||||
msg = IRCMessage(
|
||||
command="PRIVMSG",
|
||||
params=["#test", "hello"],
|
||||
prefix="mybot!ident@host",
|
||||
)
|
||||
out = encode_message(msg, "libera", OWN)
|
||||
assert out.prefix == "mybot!ident@host"
|
||||
|
||||
def test_namreply(self):
|
||||
msg = IRCMessage(
|
||||
command="353",
|
||||
params=["mybot", "=", "#test", "@op +voice regular mybot"],
|
||||
)
|
||||
out = encode_message(msg, "libera", OWN)
|
||||
assert out.params[2] == "#test/libera"
|
||||
names = out.params[3].split()
|
||||
assert names[0] == "@op/libera"
|
||||
assert names[1] == "+voice/libera"
|
||||
assert names[2] == "regular/libera"
|
||||
assert names[3] == "mybot" # own nick stays bare
|
||||
|
||||
def test_nick_change(self):
|
||||
msg = IRCMessage(
|
||||
command="NICK",
|
||||
params=["newnick"],
|
||||
prefix="oldnick!user@host",
|
||||
)
|
||||
out = encode_message(msg, "libera", OWN)
|
||||
assert out.params[0] == "newnick/libera"
|
||||
assert out.prefix == "oldnick/libera!user@host"
|
||||
|
||||
def test_join(self):
|
||||
msg = IRCMessage(
|
||||
command="JOIN",
|
||||
params=["#test"],
|
||||
prefix="user!ident@host",
|
||||
)
|
||||
out = encode_message(msg, "libera", OWN)
|
||||
assert out.params[0] == "#test/libera"
|
||||
|
||||
def test_non_channel_target_untouched(self):
|
||||
msg = IRCMessage(
|
||||
command="PRIVMSG",
|
||||
params=["someuser", "hi"],
|
||||
prefix="other!ident@host",
|
||||
)
|
||||
out = encode_message(msg, "libera", OWN)
|
||||
# Private message to a nick -- params[0] is not a channel, left as-is
|
||||
assert out.params[0] == "someuser"
|
||||
|
||||
def test_no_raw_preserved(self):
|
||||
msg = IRCMessage(
|
||||
command="PRIVMSG",
|
||||
params=["#test", "hello"],
|
||||
prefix="user!ident@host",
|
||||
raw=b":user!ident@host PRIVMSG #test :hello\r\n",
|
||||
)
|
||||
out = encode_message(msg, "libera", OWN)
|
||||
assert out.raw is None # raw must not carry over
|
||||
Reference in New Issue
Block a user