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:
30
ROADMAP.md
30
ROADMAP.md
@@ -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
|
||||
|
||||
|
||||
18
TASKS.md
18
TASKS.md
@@ -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
11
TODO.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
162
tests/test_server.py
Normal 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()
|
||||
Reference in New Issue
Block a user