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: