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 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-19 22:22:16 +01:00
parent 2a55620ccc
commit 280d0c3949
6 changed files with 67 additions and 25 deletions
+1
View File
@@ -7,6 +7,7 @@ RUN pip install --no-cache-dir \
"aiosqlite>=0.19" "aiosqlite>=0.19"
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONPATH=/app/src ENV PYTHONPATH=/app/src
VOLUME /app/src VOLUME /app/src
+1 -1
View File
@@ -33,7 +33,7 @@ run: ## Run bouncer locally
$(BOUNCER) --config config/bouncer.toml $(BOUNCER) --config config/bouncer.toml
build: ## Build container image build: ## Build container image
podman build -t $(APP_NAME) -f Containerfile . podman-compose build
up: ## Start container (podman-compose) up: ## Start container (podman-compose)
podman-compose up -d podman-compose up -d
+4
View File
@@ -8,6 +8,10 @@ services:
network_mode: host network_mode: host
logging: logging:
driver: k8s-file driver: k8s-file
environment:
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONPATH: /app/src
volumes: volumes:
- ./src:/app/src:Z,ro - ./src:/app/src:Z,ro
- ./config:/data:Z - ./config:/data:Z
+4 -3
View File
@@ -13,13 +13,15 @@ port = 1080
# Registration uses a random nick and generic ident/realname. # Registration uses a random nick and generic ident/realname.
# After surviving the probation window (no k-line), the bouncer # 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] [networks.libera]
host = "irc.libera.chat" host = "irc.libera.chat"
port = 6697 port = 6697
tls = true tls = true
nick = "mynick" # nick = "mynick" # optional: override host-derived nick
channels = ["#test"] channels = ["#test"]
autojoin = true autojoin = true
@@ -27,5 +29,4 @@ autojoin = true
# host = "irc.oftc.net" # host = "irc.oftc.net"
# port = 6697 # port = 6697
# tls = true # tls = true
# nick = "mynick"
# channels = ["#debian"] # channels = ["#debian"]
+6 -6
View File
@@ -39,9 +39,9 @@ class NetworkConfig:
host: str host: str
port: int = 6667 port: int = 6667
tls: bool = False tls: bool = False
nick: str = "bouncer" nick: str = ""
user: str = "bouncer" user: str = ""
realname: str = "bouncer" realname: str = ""
channels: list[str] = field(default_factory=list) channels: list[str] = field(default_factory=list)
autojoin: bool = True autojoin: bool = True
password: str | None = None password: str | None = None
@@ -94,9 +94,9 @@ def load(path: Path) -> Config:
host=net_raw["host"], host=net_raw["host"],
port=net_raw.get("port", 6697 if net_raw.get("tls", False) else 6667), port=net_raw.get("port", 6697 if net_raw.get("tls", False) else 6667),
tls=net_raw.get("tls", False), tls=net_raw.get("tls", False),
nick=net_raw.get("nick", "bouncer"), nick=net_raw.get("nick", ""),
user=net_raw.get("user", net_raw.get("nick", "bouncer")), user=net_raw.get("user", ""),
realname=net_raw.get("realname", "bouncer"), realname=net_raw.get("realname", ""),
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"),
+50 -14
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import logging import logging
import random import random
from enum import Enum, auto from enum import Enum, auto
@@ -98,15 +99,41 @@ def _random_nick() -> str:
return base return base
def _random_user() -> str: _GENERIC_IDENTS = ["user", "ident"]
"""Generate a pronounceable ident.""" _GENERIC_REALNAMES = ["realname", "unknown"]
return _markov_word(4, 7)
def _random_realname() -> str: def _nick_for_host(host: str) -> str:
"""Generate a plausible first-name-like realname.""" """Generate a deterministic pronounceable nick from a hostname.
name = _markov_word(4, 7)
return name[0].upper() + name[1:] 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: class Network:
@@ -121,7 +148,7 @@ class Network:
self.cfg = cfg self.cfg = cfg
self.proxy_cfg = proxy_cfg self.proxy_cfg = proxy_cfg
self.on_message = on_message self.on_message = on_message
self.nick: str = cfg.nick self.nick: str = cfg.nick or "*"
self.channels: set[str] = set() self.channels: set[str] = set()
self.state: State = State.DISCONNECTED self.state: State = State.DISCONNECTED
self._reader: asyncio.StreamReader | None = None self._reader: asyncio.StreamReader | None = None
@@ -201,7 +228,10 @@ class Network:
if self.cfg.password: if self.cfg.password:
await self.send_raw("PASS", self.cfg.password) await self.send_raw("PASS", self.cfg.password)
await self.send_raw("NICK", self._connect_nick) 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 # Start reading
self._read_task = asyncio.create_task(self._read_loop()) self._read_task = asyncio.create_task(self._read_loop())
@@ -293,13 +323,19 @@ class Network:
await self._go_ready() await self._go_ready()
async def _go_ready(self) -> None: 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 self.state = State.READY
# Switch from random nick to desired nick # Derive a stable nick from the exit endpoint
if self._connect_nick != self.cfg.nick: if self.visible_host:
log.info("[%s] switching nick: %s -> %s", self.cfg.name, self.nick, self.cfg.nick) desired = _nick_for_host(self.visible_host)
await self.send_raw("NICK", self.cfg.nick) 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 # nick will be updated when we get the NICK confirmation from server
# Join configured channels # Join configured channels