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"
|
"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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user