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:
+20
-10
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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