feat: client-side TLS for encrypted client connections

Accept TLS-encrypted connections from IRC clients. Auto-generates a
self-signed EC P-256 listener certificate (bouncer.pem) when no custom
cert is provided. Remove CTCP response items from roadmap (stealth by
design -- router already suppresses all CTCP except ACTION).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 18:47:20 +01:00
parent bfcebad6dd
commit bf4a589fc5
12 changed files with 400 additions and 25 deletions

View File

@@ -1,6 +1,6 @@
# Roadmap
## v0.1.0 (current)
## v0.1.0 (done)
- [x] IRC protocol parser/formatter
- [x] TOML configuration
@@ -11,28 +11,38 @@
- [x] Backlog replay on reconnect
- [x] Automatic reconnection with exponential backoff
- [x] Nick collision handling
- [x] TLS support
- [x] TLS support (server-side)
- [x] Stealth connect (random markov-generated identity)
- [x] Probation window (K-line detection before revealing nick)
- [x] Verified end-to-end on Libera.Chat via SOCKS5
- [x] Multi-network namespace multiplexing (`/network` suffixes)
## v0.2.0
## v0.2.0 (done)
- [ ] Client-side TLS (accept TLS from clients)
- [ ] SASL authentication to IRC servers
- [ ] CTCP VERSION/PING response
- [ ] Channel key support (JOIN #channel key)
- [ ] Configurable probation duration
- [ ] Configurable backlog timestamp format
- [x] NickServ auto-registration + email verification
- [x] SASL PLAIN authentication
- [x] SASL EXTERNAL (CertFP) authentication
- [x] Client certificate generation + management
- [x] hCaptcha auto-solving (NoCaptchaAI)
- [x] Configurable operational constants (probation, backoff, etc.)
- [x] PING watchdog (stale connection detection)
- [x] IRCv3 server-time capability
- [x] Push notifications (ntfy/webhook)
- [x] Background account farming (ephemeral connections)
- [x] 25+ bouncer control commands
## v0.3.0
- [x] Client-side TLS (accept TLS from clients)
- [ ] Channel key support (JOIN #channel key)
- [ ] Hot config reload (SIGHUP)
- [ ] Systemd service file
## v0.4.0
- [ ] Per-client backlog tracking (multi-user)
- [ ] Web status page
- [ ] DCC passthrough
- [ ] Containerfile for podman deployment
## v1.0.0

View File

@@ -12,12 +12,20 @@
- [x] P1: Verified SOCKS5 proxy connectivity end-to-end
- [x] P1: Documentation update
- [x] P1: Multi-network namespace multiplexing (`/network` suffixes)
- [x] P1: Bouncer control commands (`/msg *bouncer STATUS/INFO/UPTIME/NETWORKS/CREDS/HELP`)
- [x] P1: Extended control commands (CONNECT/DISCONNECT/RECONNECT/NICK/RAW/CHANNELS/CLIENTS/BACKLOG/VERSION/REHASH/ADDNETWORK/DELNETWORK/AUTOJOIN/IDENTIFY/REGISTER/DROPCREDS)
- [x] P1: Bouncer control commands (25+ commands via `/msg *bouncer`)
- [x] P1: NickServ auto-registration + email verification
- [x] P1: SASL PLAIN + EXTERNAL (CertFP) authentication
- [x] P1: Client certificate generation + fingerprint management
- [x] P1: PING watchdog (stale connection detection)
- [x] P1: IRCv3 server-time capability
- [x] P1: Push notifications (ntfy/webhook)
- [x] P1: hCaptcha auto-solving (NoCaptchaAI)
- [x] P1: Background account farming (ephemeral connections)
- [x] P1: Configurable operational constants
## Next
- [ ] P2: Client-side TLS support
- [ ] P2: SASL authentication
- [x] P2: Client-side TLS support
- [ ] P2: Channel key support
- [ ] P3: Systemd service file
- [ ] P3: Containerfile for podman deployment

11
TODO.md
View File

@@ -2,14 +2,13 @@
## Features
- [ ] Client TLS (accept encrypted client connections)
- [ ] SASL PLAIN/EXTERNAL for IRC server auth
- [ ] Channel key support
- [ ] CTCP VERSION/PING responses
- [ ] Channel key support (JOIN #channel key)
- [ ] Hot config reload on SIGHUP
- [ ] Configurable probation duration
- [ ] Web status dashboard
- [ ] DCC passthrough
- [ ] Per-client backlog tracking (multi-user)
- [ ] Farm: configurable ephemeral deadline
- [ ] Farm: per-network enable/disable override
## Infrastructure
@@ -23,4 +22,4 @@
- [ ] SOCKS5 proxy failure tests
- [ ] Backlog replay edge cases
- [ ] Concurrent client attach/detach
- [ ] Probation timeout / K-line detection tests
- [ ] Farm ephemeral lifecycle integration tests

View File

@@ -3,6 +3,11 @@ bind = "127.0.0.1"
port = 6667
password = "changeme"
# Client TLS -- encrypt client-to-bouncer connections
# client_tls = false # enable TLS for client listener
# client_tls_cert = "" # path to PEM cert (auto-generated if empty)
# client_tls_key = "" # path to PEM key (or same file as cert)
# PING watchdog -- detect stale server connections
# ping_interval = 120 # seconds of silence before sending PING
# ping_timeout = 30 # seconds to wait for PONG after PING

View File

@@ -179,6 +179,8 @@ Only fires when no clients are attached.
```toml
[bouncer]
bind / port / password
client_tls / client_tls_cert # client-side TLS
client_tls_key # separate key file (optional)
captcha_api_key # NoCaptchaAI key (optional)
captcha_poll_interval / captcha_poll_timeout
probation_seconds / nick_timeout / rejoin_delay
@@ -209,6 +211,7 @@ password # optional, IRC server PASS
| `config/bouncer.toml` | Active config (gitignored) |
| `config/bouncer.example.toml` | Example template |
| `config/bouncer.db` | SQLite backlog (auto-created) |
| `{data_dir}/bouncer.pem` | Listener TLS cert (auto-created) |
| `{data_dir}/certs/{net}/{nick}.pem` | Client certificates (auto-created) |
## Backlog Queries

View File

@@ -111,6 +111,59 @@ automatically attaches to **all** configured networks.
Set server password to `mypassword` in the network settings.
## Client TLS
The bouncer can accept TLS-encrypted connections from IRC clients. This
encrypts the password and all traffic between your client and the bouncer.
### Setup
```toml
[bouncer]
client_tls = true
```
On first start with `client_tls = true`, the bouncer auto-generates a
self-signed EC P-256 certificate at `{data_dir}/bouncer.pem` (10-year validity).
The certificate fingerprint is logged at startup.
### Custom Certificate
To use your own certificate (e.g. from Let's Encrypt):
```toml
[bouncer]
client_tls = true
client_tls_cert = "/path/to/fullchain.pem"
client_tls_key = "/path/to/privkey.pem"
```
If the cert and key are in the same PEM file, set only `client_tls_cert`.
### Client Examples
**irssi:**
```
/connect -tls -tls_verify no -password mypassword 127.0.0.1 6667
```
**weechat:**
```
/server add bouncer 127.0.0.1/6667 -password=mypassword -ssl -ssl_verify=0
/connect bouncer
```
**hexchat:**
Enable "Use SSL for all the servers on this network" and accept the
self-signed certificate.
### Verify with openssl
```bash
openssl s_client -connect 127.0.0.1:6667
```
## Multi-Network Namespacing
All configured networks are multiplexed onto a single client connection. Channels
@@ -256,6 +309,11 @@ bind = "127.0.0.1" # listen address
port = 6667 # listen port
password = "changeme" # client authentication password
# Client TLS
client_tls = false # enable TLS for client listener
client_tls_cert = "" # path to PEM cert (auto-generated if empty)
client_tls_key = "" # path to PEM key (or same file as cert)
# Captcha solving (NoCaptchaAI)
captcha_api_key = "" # API key (optional, for auto-verification)
captcha_poll_interval = 3 # seconds between solve polls

View File

@@ -5,14 +5,16 @@ from __future__ import annotations
import asyncio
import logging
import signal
import ssl
import sys
import time
from pathlib import Path
from bouncer import commands
from bouncer.backlog import Backlog
from bouncer.cert import fingerprint, generate_listener_cert
from bouncer.cli import parse_args
from bouncer.config import load
from bouncer.config import BouncerConfig, load
from bouncer.router import Router
from bouncer.server import start
@@ -31,6 +33,26 @@ def _setup_logging(verbose: bool) -> None:
logging.getLogger().addHandler(fh)
def _build_client_ssl_ctx(bouncer_cfg: BouncerConfig, data_dir: Path) -> ssl.SSLContext:
"""Build an SSL context for the client listener."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
if bouncer_cfg.client_tls_cert:
cert_file = bouncer_cfg.client_tls_cert
key_file = bouncer_cfg.client_tls_key or None
else:
cert_file = str(generate_listener_cert(
data_dir, bouncer_cfg.cert_validity_days,
))
key_file = None # combined PEM
ctx.load_cert_chain(certfile=cert_file, keyfile=key_file)
fp = fingerprint(Path(cert_file))
log.info("client TLS cert: %s (SHA256:%s)", cert_file, fp)
return ctx
async def _run(config_path: Path, verbose: bool) -> None:
_setup_logging(verbose)
@@ -51,7 +73,11 @@ async def _run(config_path: Path, verbose: bool) -> None:
router = Router(cfg, backlog, data_dir=data_dir)
await router.start_networks()
server = await start(cfg.bouncer, router)
ssl_ctx = None
if cfg.bouncer.client_tls:
ssl_ctx = _build_client_ssl_ctx(cfg.bouncer, data_dir)
server = await start(cfg.bouncer, router, ssl_ctx=ssl_ctx)
# Graceful shutdown on SIGINT/SIGTERM
loop = asyncio.get_running_loop()

View File

@@ -17,6 +17,58 @@ log = logging.getLogger(__name__)
DEFAULT_VALIDITY_DAYS = 3650 # ~10 years
def listener_cert_path(data_dir: Path) -> Path:
"""Return the PEM file path for the bouncer listener certificate."""
return data_dir / "bouncer.pem"
def generate_listener_cert(
data_dir: Path,
validity_days: int = DEFAULT_VALIDITY_DAYS,
) -> Path:
"""Generate a self-signed EC P-256 certificate for the client listener.
Creates a combined PEM file (cert + key) at ``{data_dir}/bouncer.pem``.
Idempotent: skips generation if the file already exists.
Returns the path to the PEM file.
"""
pem = listener_cert_path(data_dir)
if pem.is_file():
log.info("listener cert already exists: %s", pem)
return pem
key = ec.generate_private_key(ec.SECP256R1())
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "bouncer"),
])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=validity_days))
.sign(key, hashes.SHA256())
)
key_bytes = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
cert_bytes = cert.public_bytes(serialization.Encoding.PEM)
pem.write_bytes(cert_bytes + key_bytes)
os.chmod(pem, 0o600)
log.info("generated listener cert %s (CN=bouncer)", pem)
return pem
def cert_path(data_dir: Path, network: str, nick: str) -> Path:
"""Return the PEM file path for a (network, nick) pair."""
return data_dir / "certs" / network / f"{nick}.pem"

View File

@@ -90,6 +90,11 @@ class BouncerConfig:
notify_cooldown: int = 60 # min seconds between notifications
notify_proxy: bool = False # route notifications through SOCKS5
# Client TLS
client_tls: bool = False # enable TLS for client listener
client_tls_cert: str = "" # path to PEM cert (auto-generated if empty)
client_tls_key: str = "" # path to PEM key (or same file as cert)
# Background account farming
farm_enabled: bool = False
farm_interval: int = 3600 # seconds between attempts per network
@@ -137,6 +142,9 @@ def load(path: Path) -> Config:
notify_on_privmsg=bouncer_raw.get("notify_on_privmsg", True),
notify_cooldown=bouncer_raw.get("notify_cooldown", 60),
notify_proxy=bouncer_raw.get("notify_proxy", False),
client_tls=bouncer_raw.get("client_tls", False),
client_tls_cert=bouncer_raw.get("client_tls_cert", ""),
client_tls_key=bouncer_raw.get("client_tls_key", ""),
farm_enabled=bouncer_raw.get("farm_enabled", False),
farm_interval=bouncer_raw.get("farm_interval", 3600),
farm_max_accounts=bouncer_raw.get("farm_max_accounts", 10),

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
import ssl
from bouncer.client import Client
from bouncer.config import BouncerConfig
@@ -12,7 +13,11 @@ from bouncer.router import Router
log = logging.getLogger(__name__)
async def start(config: BouncerConfig, router: Router) -> asyncio.Server:
async def start(
config: BouncerConfig,
router: Router,
ssl_ctx: ssl.SSLContext | None = None,
) -> asyncio.Server:
"""Start the client listener and return the server object."""
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
@@ -26,9 +31,11 @@ async def start(config: BouncerConfig, router: Router) -> asyncio.Server:
_handle,
host=config.bind,
port=config.port,
ssl=ssl_ctx,
)
proto = "tls" if ssl_ctx else "plaintext"
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
log.info("listening on %s", addrs)
log.info("listening on %s (%s)", addrs, proto)
return server

View File

@@ -11,8 +11,10 @@ from bouncer.cert import (
delete_cert,
fingerprint,
generate_cert,
generate_listener_cert,
has_cert,
list_certs,
listener_cert_path,
)
@@ -22,6 +24,41 @@ def data_dir(tmp_path: Path) -> Path:
return tmp_path
class TestGenerateListenerCert:
def test_creates_pem_with_cn_bouncer(self, data_dir: Path) -> None:
from cryptography import x509 as x509_mod
from cryptography.x509.oid import NameOID
pem = generate_listener_cert(data_dir)
assert pem.is_file()
assert pem == listener_cert_path(data_dir)
cert_data = pem.read_bytes()
cert_obj = x509_mod.load_pem_x509_certificate(cert_data)
cn = cert_obj.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
assert cn == "bouncer"
content = pem.read_text()
assert "BEGIN CERTIFICATE" in content
assert "BEGIN PRIVATE KEY" in content
mode = pem.stat().st_mode & 0o777
assert mode == 0o600
def test_idempotent(self, data_dir: Path) -> None:
pem1 = generate_listener_cert(data_dir)
fp1 = fingerprint(pem1)
mtime1 = pem1.stat().st_mtime
pem2 = generate_listener_cert(data_dir)
fp2 = fingerprint(pem2)
mtime2 = pem2.stat().st_mtime
assert pem1 == pem2
assert fp1 == fp2
assert mtime1 == mtime2 # file not regenerated
class TestCertPath:
def test_standard_path(self, data_dir: Path) -> None:
p = cert_path(data_dir, "libera", "fabesune")

162
tests/test_server.py Normal file
View File

@@ -0,0 +1,162 @@
"""Tests for TCP server with optional TLS."""
from __future__ import annotations
import asyncio
import ssl
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bouncer.cert import generate_listener_cert
from bouncer.config import BouncerConfig
from bouncer.server import start
def _bouncer_cfg(**overrides) -> BouncerConfig:
defaults = {"bind": "127.0.0.1", "port": 0} # port 0 = OS-assigned
defaults.update(overrides)
return BouncerConfig(**defaults)
def _mock_router() -> MagicMock:
return MagicMock()
def _make_ssl_ctx(data_dir: Path) -> ssl.SSLContext:
"""Build a server SSL context from an auto-generated listener cert."""
pem = generate_listener_cert(data_dir)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(certfile=str(pem))
return ctx
def _make_client_ssl_ctx() -> ssl.SSLContext:
"""Build a client SSL context that trusts any self-signed cert."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
return tmp_path
class TestStartPlaintext:
async def test_accepts_connection(self) -> None:
"""Plaintext listener starts and accepts a TCP connection."""
cfg = _bouncer_cfg()
router = _mock_router()
with patch("bouncer.server.Client") as mock_client_cls:
mock_client_cls.return_value.handle = AsyncMock()
server = await start(cfg, router)
addr = server.sockets[0].getsockname()
reader, writer = await asyncio.open_connection(addr[0], addr[1])
await asyncio.sleep(0.05)
assert mock_client_cls.called
writer.close()
await writer.wait_closed()
server.close()
class TestStartWithTLS:
async def test_accepts_tls_connection(self, data_dir: Path) -> None:
"""TLS listener starts and accepts a TLS connection."""
cfg = _bouncer_cfg()
router = _mock_router()
ssl_ctx = _make_ssl_ctx(data_dir)
with patch("bouncer.server.Client") as mock_client_cls:
mock_client_cls.return_value.handle = AsyncMock()
server = await start(cfg, router, ssl_ctx=ssl_ctx)
addr = server.sockets[0].getsockname()
client_ctx = _make_client_ssl_ctx()
reader, writer = await asyncio.open_connection(
addr[0], addr[1], ssl=client_ctx,
)
await asyncio.sleep(0.05)
assert mock_client_cls.called
writer.close()
await writer.wait_closed()
server.close()
async def test_tls_handshake_and_auth(self, data_dir: Path) -> None:
"""TLS handshake succeeds and IRC data flows encrypted."""
cfg = _bouncer_cfg()
router = _mock_router()
ssl_ctx = _make_ssl_ctx(data_dir)
received_lines: list[bytes] = []
async def _fake_handle(obj: MagicMock) -> None:
"""Minimal handler: read one line, echo a 001."""
data = await obj._reader.readline()
received_lines.append(data)
obj._writer.write(b":bouncer 001 test :Welcome\r\n")
await obj._writer.drain()
def _make_client(reader, writer, router_, password_):
obj = MagicMock()
obj._reader = reader
obj._writer = writer
obj.handle = lambda: _fake_handle(obj)
return obj
with patch("bouncer.server.Client", side_effect=_make_client):
server = await start(cfg, router, ssl_ctx=ssl_ctx)
addr = server.sockets[0].getsockname()
client_ctx = _make_client_ssl_ctx()
reader, writer = await asyncio.open_connection(
addr[0], addr[1], ssl=client_ctx,
)
writer.write(b"PASS testpass\r\n")
await writer.drain()
response = await asyncio.wait_for(reader.readline(), timeout=2.0)
assert b"001" in response
writer.close()
await writer.wait_closed()
server.close()
assert len(received_lines) == 1
assert b"PASS testpass" in received_lines[0]
async def test_plaintext_rejected_on_tls(self, data_dir: Path) -> None:
"""Non-TLS bytes on a TLS listener get dropped."""
cfg = _bouncer_cfg()
router = _mock_router()
ssl_ctx = _make_ssl_ctx(data_dir)
with patch("bouncer.server.Client") as mock_client_cls:
mock_client_cls.return_value.handle = AsyncMock()
server = await start(cfg, router, ssl_ctx=ssl_ctx)
addr = server.sockets[0].getsockname()
# Connect without TLS to a TLS listener
reader, writer = await asyncio.open_connection(addr[0], addr[1])
writer.write(b"PASS hello\r\n")
await writer.drain()
# Server should close the connection (EOF)
data = await asyncio.wait_for(reader.read(1024), timeout=2.0)
assert data == b""
writer.close()
await writer.wait_closed()
server.close()