diff --git a/ROADMAP.md b/ROADMAP.md index 427b80d..b2c2d65 100644 --- a/ROADMAP.md +++ b/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 diff --git a/TASKS.md b/TASKS.md index acfcb99..a946eb8 100644 --- a/TASKS.md +++ b/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 diff --git a/TODO.md b/TODO.md index 43115a1..b86d71e 100644 --- a/TODO.md +++ b/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 diff --git a/config/bouncer.example.toml b/config/bouncer.example.toml index 6e9c015..54c4b2c 100644 --- a/config/bouncer.example.toml +++ b/config/bouncer.example.toml @@ -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 diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index a50d7d7..d31bffb 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -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 diff --git a/docs/USAGE.md b/docs/USAGE.md index 9ff43f7..6f80e80 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 diff --git a/src/bouncer/__main__.py b/src/bouncer/__main__.py index 4ce55b8..cf3623e 100644 --- a/src/bouncer/__main__.py +++ b/src/bouncer/__main__.py @@ -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() diff --git a/src/bouncer/cert.py b/src/bouncer/cert.py index 9cb27cd..4f7b3a4 100644 --- a/src/bouncer/cert.py +++ b/src/bouncer/cert.py @@ -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" diff --git a/src/bouncer/config.py b/src/bouncer/config.py index 13da124..518f8e3 100644 --- a/src/bouncer/config.py +++ b/src/bouncer/config.py @@ -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), diff --git a/src/bouncer/server.py b/src/bouncer/server.py index 5e49155..fed6316 100644 --- a/src/bouncer/server.py +++ b/src/bouncer/server.py @@ -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 diff --git a/tests/test_cert.py b/tests/test_cert.py index 685832a..7639e13 100644 --- a/tests/test_cert.py +++ b/tests/test_cert.py @@ -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") diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..29e6842 --- /dev/null +++ b/tests/test_server.py @@ -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()