From 280d0c3949b49117069f80082b84da0f24437e71 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 19 Feb 2026 22:22:16 +0100 Subject: [PATCH] feat: host-derived nicks and generic identity Nick is now deterministically generated from the exit endpoint hostname via seeded markov chain. Same exit IP always produces the same nick. Config nick field is optional fallback only. Registration uses generic ident (user/ident) and realname (realname/unknown) instead of random markov words. Also fixes compose env vars and build target to use podman-compose. Co-Authored-By: Claude Opus 4.6 --- Containerfile | 1 + Makefile | 2 +- compose.yaml | 4 +++ config/bouncer.example.toml | 7 ++-- src/bouncer/config.py | 12 +++---- src/bouncer/network.py | 66 ++++++++++++++++++++++++++++--------- 6 files changed, 67 insertions(+), 25 deletions(-) diff --git a/Containerfile b/Containerfile index 4cbe913..e1e6907 100644 --- a/Containerfile +++ b/Containerfile @@ -7,6 +7,7 @@ RUN pip install --no-cache-dir \ "aiosqlite>=0.19" ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONPATH=/app/src VOLUME /app/src diff --git a/Makefile b/Makefile index 9af6065..b231c89 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ run: ## Run bouncer locally $(BOUNCER) --config config/bouncer.toml build: ## Build container image - podman build -t $(APP_NAME) -f Containerfile . + podman-compose build up: ## Start container (podman-compose) podman-compose up -d diff --git a/compose.yaml b/compose.yaml index 37e99a8..9474871 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,6 +8,10 @@ services: network_mode: host logging: driver: k8s-file + environment: + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + PYTHONPATH: /app/src volumes: - ./src:/app/src:Z,ro - ./config:/data:Z diff --git a/config/bouncer.example.toml b/config/bouncer.example.toml index 83f3caa..075899d 100644 --- a/config/bouncer.example.toml +++ b/config/bouncer.example.toml @@ -13,13 +13,15 @@ port = 1080 # Registration uses a random nick and generic ident/realname. # After surviving the probation window (no k-line), the bouncer -# switches to your configured nick and joins channels. +# 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). [networks.libera] host = "irc.libera.chat" port = 6697 tls = true -nick = "mynick" +# nick = "mynick" # optional: override host-derived nick channels = ["#test"] autojoin = true @@ -27,5 +29,4 @@ autojoin = true # host = "irc.oftc.net" # port = 6697 # tls = true -# nick = "mynick" # channels = ["#debian"] diff --git a/src/bouncer/config.py b/src/bouncer/config.py index 3bd80f8..a5ec96b 100644 --- a/src/bouncer/config.py +++ b/src/bouncer/config.py @@ -39,9 +39,9 @@ class NetworkConfig: host: str port: int = 6667 tls: bool = False - nick: str = "bouncer" - user: str = "bouncer" - realname: str = "bouncer" + nick: str = "" + user: str = "" + realname: str = "" channels: list[str] = field(default_factory=list) autojoin: bool = True password: str | None = None @@ -94,9 +94,9 @@ def load(path: Path) -> Config: host=net_raw["host"], port=net_raw.get("port", 6697 if net_raw.get("tls", False) else 6667), tls=net_raw.get("tls", False), - nick=net_raw.get("nick", "bouncer"), - user=net_raw.get("user", net_raw.get("nick", "bouncer")), - realname=net_raw.get("realname", "bouncer"), + nick=net_raw.get("nick", ""), + user=net_raw.get("user", ""), + realname=net_raw.get("realname", ""), channels=net_raw.get("channels", []), autojoin=net_raw.get("autojoin", True), password=net_raw.get("password"), diff --git a/src/bouncer/network.py b/src/bouncer/network.py index 226befb..d8173fb 100644 --- a/src/bouncer/network.py +++ b/src/bouncer/network.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import hashlib import logging import random from enum import Enum, auto @@ -98,15 +99,41 @@ def _random_nick() -> str: return base -def _random_user() -> str: - """Generate a pronounceable ident.""" - return _markov_word(4, 7) +_GENERIC_IDENTS = ["user", "ident"] +_GENERIC_REALNAMES = ["realname", "unknown"] -def _random_realname() -> str: - """Generate a plausible first-name-like realname.""" - name = _markov_word(4, 7) - return name[0].upper() + name[1:] +def _nick_for_host(host: str) -> str: + """Generate a deterministic pronounceable nick from a hostname. + + The same hostname always produces the same nick. Uses the host string + as a seed for the markov generator so nicks are stable across reconnects + to known endpoints. + """ + seed = int(hashlib.sha256(host.encode()).hexdigest(), 16) + rng = random.Random(seed) + length = rng.randint(5, 8) + ch = rng.choice(_STARTERS) + word = [ch] + consonant_run = 0 if ch in _VOWELS else 1 + + for _ in range(length - 1): + followers = _BIGRAMS.get(ch, "aeiou") + if consonant_run >= 2: + vowels = [c for c in followers if c in _VOWELS] + ch = rng.choice(vowels) if vowels else rng.choice("aeiou") + else: + ch = rng.choice(followers) + if ch in _VOWELS: + consonant_run = 0 + else: + consonant_run += 1 + word.append(ch) + + base = "".join(word) + if rng.random() < 0.3: + base += str(rng.randint(0, 99)) + return base class Network: @@ -121,7 +148,7 @@ class Network: self.cfg = cfg self.proxy_cfg = proxy_cfg self.on_message = on_message - self.nick: str = cfg.nick + self.nick: str = cfg.nick or "*" self.channels: set[str] = set() self.state: State = State.DISCONNECTED self._reader: asyncio.StreamReader | None = None @@ -201,7 +228,10 @@ class Network: if self.cfg.password: await self.send_raw("PASS", self.cfg.password) await self.send_raw("NICK", self._connect_nick) - await self.send_raw("USER", _random_user(), "0", "*", _random_realname()) + await self.send_raw( + "USER", random.choice(_GENERIC_IDENTS), "0", "*", + random.choice(_GENERIC_REALNAMES), + ) # Start reading self._read_task = asyncio.create_task(self._read_loop()) @@ -293,14 +323,20 @@ class Network: await self._go_ready() async def _go_ready(self) -> None: - """Transition to ready: switch to desired nick, then join channels.""" + """Transition to ready: switch to host-derived nick, then join channels.""" self.state = State.READY - # Switch from random nick to desired nick - if self._connect_nick != self.cfg.nick: - log.info("[%s] switching nick: %s -> %s", self.cfg.name, self.nick, self.cfg.nick) - await self.send_raw("NICK", self.cfg.nick) - # nick will be updated when we get the NICK confirmation from server + # Derive a stable nick from the exit endpoint + if self.visible_host: + desired = _nick_for_host(self.visible_host) + elif self.cfg.nick: + desired = self.cfg.nick + else: + desired = _random_nick() + log.info("[%s] switching nick: %s -> %s (host=%s)", self.cfg.name, self.nick, desired, + self.visible_host or "unknown") + await self.send_raw("NICK", desired) + # nick will be updated when we get the NICK confirmation from server # Join configured channels if self.cfg.autojoin and self.cfg.channels: