diff --git a/PROJECT.md b/PROJECT.md index 73ace48..c19328c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/README.md b/README.md index 9b73903..8c88992 100644 --- a/README.md +++ b/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 diff --git a/ROADMAP.md b/ROADMAP.md index 1d03f19..427b80d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/TASKS.md b/TASKS.md index d3b4e40..e27d539 100644 --- a/TASKS.md +++ b/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 diff --git a/config/bouncer.example.toml b/config/bouncer.example.toml index 075899d..15840a8 100644 --- a/config/bouncer.example.toml +++ b/config/bouncer.example.toml @@ -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" diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 80d12f0..538c819 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -38,8 +38,21 @@ make clean # rm .venv, build artifacts ## Client Auth ``` -PASS : # select network + authenticate -PASS # use first network +PASS # 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 diff --git a/docs/USAGE.md b/docs/USAGE.md index a3d4661..d0dfee7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 : +PASS ``` -- `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 diff --git a/src/bouncer/client.py b/src/bouncer/client.py index ed95548..cee8674 100644 --- a/src/bouncer/client.py +++ b/src/bouncer/client.py @@ -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 )") 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() diff --git a/src/bouncer/config.py b/src/bouncer/config.py index a5ec96b..4ffe75c 100644 --- a/src/bouncer/config.py +++ b/src/bouncer/config.py @@ -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) diff --git a/src/bouncer/namespace.py b/src/bouncer/namespace.py new file mode 100644 index 0000000..bb5730d --- /dev/null +++ b/src/bouncer/namespace.py @@ -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, + ) diff --git a/src/bouncer/router.py b/src/bouncer/router.py index bcbe78e..2092394 100644 --- a/src/bouncer/router.py +++ b/src/bouncer/router.py @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py index 614e700..cf2489f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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] diff --git a/tests/test_namespace.py b/tests/test_namespace.py new file mode 100644 index 0000000..be7c5c2 --- /dev/null +++ b/tests/test_namespace.py @@ -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