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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"),

View File

@@ -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: