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:
@@ -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
|
||||
|
||||
2
Makefile
2
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user