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
+20 -10
View File
@@ -1,6 +1,6 @@
# Roadmap # Roadmap
## v0.1.0 (current) ## v0.1.0 (done)
- [x] IRC protocol parser/formatter - [x] IRC protocol parser/formatter
- [x] TOML configuration - [x] TOML configuration
@@ -11,28 +11,38 @@
- [x] Backlog replay on reconnect - [x] Backlog replay on reconnect
- [x] Automatic reconnection with exponential backoff - [x] Automatic reconnection with exponential backoff
- [x] Nick collision handling - [x] Nick collision handling
- [x] TLS support - [x] TLS support (server-side)
- [x] Stealth connect (random markov-generated identity) - [x] Stealth connect (random markov-generated identity)
- [x] Probation window (K-line detection before revealing nick) - [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) - [x] Multi-network namespace multiplexing (`/network` suffixes)
## v0.2.0 ## v0.2.0 (done)
- [ ] Client-side TLS (accept TLS from clients) - [x] NickServ auto-registration + email verification
- [ ] SASL authentication to IRC servers - [x] SASL PLAIN authentication
- [ ] CTCP VERSION/PING response - [x] SASL EXTERNAL (CertFP) authentication
- [ ] Channel key support (JOIN #channel key) - [x] Client certificate generation + management
- [ ] Configurable probation duration - [x] hCaptcha auto-solving (NoCaptchaAI)
- [ ] Configurable backlog timestamp format - [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 ## v0.3.0
- [x] Client-side TLS (accept TLS from clients)
- [ ] Channel key support (JOIN #channel key)
- [ ] Hot config reload (SIGHUP) - [ ] Hot config reload (SIGHUP)
- [ ] Systemd service file - [ ] Systemd service file
## v0.4.0
- [ ] Per-client backlog tracking (multi-user) - [ ] Per-client backlog tracking (multi-user)
- [ ] Web status page - [ ] Web status page
- [ ] DCC passthrough - [ ] DCC passthrough
- [ ] Containerfile for podman deployment
## v1.0.0 ## v1.0.0
+13 -5
View File
@@ -12,12 +12,20 @@
- [x] P1: Verified SOCKS5 proxy connectivity end-to-end - [x] P1: Verified SOCKS5 proxy connectivity end-to-end
- [x] P1: Documentation update - [x] P1: Documentation update
- [x] P1: Multi-network namespace multiplexing (`/network` suffixes) - [x] P1: Multi-network namespace multiplexing (`/network` suffixes)
- [x] P1: Bouncer control commands (25+ commands via `/msg *bouncer`)
- [x] P1: Bouncer control commands (`/msg *bouncer STATUS/INFO/UPTIME/NETWORKS/CREDS/HELP`) - [x] P1: NickServ auto-registration + email verification
- [x] P1: Extended control commands (CONNECT/DISCONNECT/RECONNECT/NICK/RAW/CHANNELS/CLIENTS/BACKLOG/VERSION/REHASH/ADDNETWORK/DELNETWORK/AUTOJOIN/IDENTIFY/REGISTER/DROPCREDS) - [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 ## Next
- [ ] P2: Client-side TLS support - [x] P2: Client-side TLS support
- [ ] P2: SASL authentication - [ ] P2: Channel key support
- [ ] P3: Systemd service file - [ ] P3: Systemd service file
- [ ] P3: Containerfile for podman deployment
+5 -6
View File
@@ -2,14 +2,13 @@
## Features ## Features
- [ ] Client TLS (accept encrypted client connections) - [ ] Channel key support (JOIN #channel key)
- [ ] SASL PLAIN/EXTERNAL for IRC server auth
- [ ] Channel key support
- [ ] CTCP VERSION/PING responses
- [ ] Hot config reload on SIGHUP - [ ] Hot config reload on SIGHUP
- [ ] Configurable probation duration
- [ ] Web status dashboard - [ ] Web status dashboard
- [ ] DCC passthrough - [ ] DCC passthrough
- [ ] Per-client backlog tracking (multi-user)
- [ ] Farm: configurable ephemeral deadline
- [ ] Farm: per-network enable/disable override
## Infrastructure ## Infrastructure
@@ -23,4 +22,4 @@
- [ ] SOCKS5 proxy failure tests - [ ] SOCKS5 proxy failure tests
- [ ] Backlog replay edge cases - [ ] Backlog replay edge cases
- [ ] Concurrent client attach/detach - [ ] Concurrent client attach/detach
- [ ] Probation timeout / K-line detection tests - [ ] Farm ephemeral lifecycle integration tests
+5
View File
@@ -3,6 +3,11 @@ bind = "127.0.0.1"
port = 6667 port = 6667
password = "changeme" 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 watchdog -- detect stale server connections
# ping_interval = 120 # seconds of silence before sending PING # ping_interval = 120 # seconds of silence before sending PING
# ping_timeout = 30 # seconds to wait for PONG after PING # ping_timeout = 30 # seconds to wait for PONG after PING
+3
View File
@@ -179,6 +179,8 @@ Only fires when no clients are attached.
```toml ```toml
[bouncer] [bouncer]
bind / port / password 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_api_key # NoCaptchaAI key (optional)
captcha_poll_interval / captcha_poll_timeout captcha_poll_interval / captcha_poll_timeout
probation_seconds / nick_timeout / rejoin_delay probation_seconds / nick_timeout / rejoin_delay
@@ -209,6 +211,7 @@ password # optional, IRC server PASS
| `config/bouncer.toml` | Active config (gitignored) | | `config/bouncer.toml` | Active config (gitignored) |
| `config/bouncer.example.toml` | Example template | | `config/bouncer.example.toml` | Example template |
| `config/bouncer.db` | SQLite backlog (auto-created) | | `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) | | `{data_dir}/certs/{net}/{nick}.pem` | Client certificates (auto-created) |
## Backlog Queries ## Backlog Queries
+58
View File
@@ -111,6 +111,59 @@ automatically attaches to **all** configured networks.
Set server password to `mypassword` in the network settings. 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 ## Multi-Network Namespacing
All configured networks are multiplexed onto a single client connection. Channels 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 port = 6667 # listen port
password = "changeme" # client authentication password 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 solving (NoCaptchaAI)
captcha_api_key = "" # API key (optional, for auto-verification) captcha_api_key = "" # API key (optional, for auto-verification)
captcha_poll_interval = 3 # seconds between solve polls captcha_poll_interval = 3 # seconds between solve polls
+28 -2
View File
@@ -5,14 +5,16 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import signal import signal
import ssl
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from bouncer import commands from bouncer import commands
from bouncer.backlog import Backlog from bouncer.backlog import Backlog
from bouncer.cert import fingerprint, generate_listener_cert
from bouncer.cli import parse_args from bouncer.cli import parse_args
from bouncer.config import load from bouncer.config import BouncerConfig, load
from bouncer.router import Router from bouncer.router import Router
from bouncer.server import start from bouncer.server import start
@@ -31,6 +33,26 @@ def _setup_logging(verbose: bool) -> None:
logging.getLogger().addHandler(fh) 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: async def _run(config_path: Path, verbose: bool) -> None:
_setup_logging(verbose) _setup_logging(verbose)
@@ -51,7 +73,11 @@ async def _run(config_path: Path, verbose: bool) -> None:
router = Router(cfg, backlog, data_dir=data_dir) router = Router(cfg, backlog, data_dir=data_dir)
await router.start_networks() 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 # Graceful shutdown on SIGINT/SIGTERM
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
+52
View File
@@ -17,6 +17,58 @@ log = logging.getLogger(__name__)
DEFAULT_VALIDITY_DAYS = 3650 # ~10 years 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: def cert_path(data_dir: Path, network: str, nick: str) -> Path:
"""Return the PEM file path for a (network, nick) pair.""" """Return the PEM file path for a (network, nick) pair."""
return data_dir / "certs" / network / f"{nick}.pem" return data_dir / "certs" / network / f"{nick}.pem"
+8
View File
@@ -90,6 +90,11 @@ class BouncerConfig:
notify_cooldown: int = 60 # min seconds between notifications notify_cooldown: int = 60 # min seconds between notifications
notify_proxy: bool = False # route notifications through SOCKS5 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 # Background account farming
farm_enabled: bool = False farm_enabled: bool = False
farm_interval: int = 3600 # seconds between attempts per network 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_on_privmsg=bouncer_raw.get("notify_on_privmsg", True),
notify_cooldown=bouncer_raw.get("notify_cooldown", 60), notify_cooldown=bouncer_raw.get("notify_cooldown", 60),
notify_proxy=bouncer_raw.get("notify_proxy", False), 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_enabled=bouncer_raw.get("farm_enabled", False),
farm_interval=bouncer_raw.get("farm_interval", 3600), farm_interval=bouncer_raw.get("farm_interval", 3600),
farm_max_accounts=bouncer_raw.get("farm_max_accounts", 10), farm_max_accounts=bouncer_raw.get("farm_max_accounts", 10),
+9 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import ssl
from bouncer.client import Client from bouncer.client import Client
from bouncer.config import BouncerConfig from bouncer.config import BouncerConfig
@@ -12,7 +13,11 @@ from bouncer.router import Router
log = logging.getLogger(__name__) 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.""" """Start the client listener and return the server object."""
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
@@ -26,9 +31,11 @@ async def start(config: BouncerConfig, router: Router) -> asyncio.Server:
_handle, _handle,
host=config.bind, host=config.bind,
port=config.port, port=config.port,
ssl=ssl_ctx,
) )
proto = "tls" if ssl_ctx else "plaintext"
addrs = ", ".join(str(s.getsockname()) for s in server.sockets) 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 return server
+37
View File
@@ -11,8 +11,10 @@ from bouncer.cert import (
delete_cert, delete_cert,
fingerprint, fingerprint,
generate_cert, generate_cert,
generate_listener_cert,
has_cert, has_cert,
list_certs, list_certs,
listener_cert_path,
) )
@@ -22,6 +24,41 @@ def data_dir(tmp_path: Path) -> Path:
return tmp_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: class TestCertPath:
def test_standard_path(self, data_dir: Path) -> None: def test_standard_path(self, data_dir: Path) -> None:
p = cert_path(data_dir, "libera", "fabesune") p = cert_path(data_dir, "libera", "fabesune")
+162
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()