diff --git a/development/BUG_FIXES.md b/development/BUG_FIXES.md
new file mode 100644
index 0000000..675cd55
--- /dev/null
+++ b/development/BUG_FIXES.md
@@ -0,0 +1,333 @@
+# Bug Fixes — Non-Feature Realism Issues
+
+> These are fingerprint leaks and broken protocol handlers that don't need new
+> interaction design — just targeted fixes. All severity High or above from REALISM_AUDIT.md.
+
+---
+
+## 1. HTTP — Werkzeug header leak (High)
+
+### Problem
+
+Every response has two `Server:` headers:
+```
+Server: Werkzeug/3.1.3 Python/3.11.2
+Server: Apache/2.4.54 (Debian)
+```
+
+nmap correctly IDs Apache from the second header, but any attacker that does
+`curl -I` or runs Burp sees the Werkzeug leak immediately. Port 6443 (K8s) also
+leaks Werkzeug in every response.
+
+### Fix — WSGI middleware to strip/replace the header
+
+In `templates/http/server.py` (Flask app), add a `@app.after_request` hook:
+
+```python
+@app.after_request
+def _fix_server_header(response):
+ response.headers["Server"] = os.environ.get("HTTP_SERVER_HEADER", "Apache/2.4.54 (Debian)")
+ return response
+```
+
+Flask sets `Server: Werkzeug/...` by default. The `after_request` hook runs after
+Werkzeug's own header injection, so it overwrites it.
+
+Same fix applies to the K8s server if it's also Flask-based.
+
+### Fix — Apache 403 page body
+
+Current response body: `
403 Forbidden
`
+
+Replace with the actual Apache 2.4 default 403 page:
+
+```html
+
+
+403 Forbidden
+
+Forbidden
+You don't have permission to access this resource.
+
+Apache/2.4.54 (Debian) Server at {hostname} Port 80
+
+```
+
+Env var `HTTP_SERVER_HEADER` and `NODE_NAME` fill the address line.
+
+### Env vars
+
+| Var | Default |
+|-----|---------|
+| `HTTP_SERVER_HEADER` | `Apache/2.4.54 (Debian)` |
+
+---
+
+## 2. FTP — Twisted banner (High)
+
+### Problem
+
+```
+220 Twisted 25.5.0 FTP Server
+```
+
+This is Twisted's built-in FTP server banner. Immediately identifies the framework.
+
+### Fix
+
+Override the banner. The Twisted FTP server class has a `factory.welcomeMessage` or
+the protocol's `sendLine()` for the greeting. Simplest fix: subclass the protocol
+and override `lineReceived` to intercept the `220` line before it goes out, OR
+use a `_FTPFactory` subclass that sets `welcomeMessage`:
+
+```python
+from twisted.protocols.ftp import FTPFactory, FTPAnonymousShell
+from twisted.internet import reactor
+import os
+
+NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
+BANNER = os.environ.get("FTP_BANNER", f"220 (vsFTPd 3.0.3)")
+
+factory = FTPFactory(portal)
+factory.welcomeMessage = BANNER # overrides the Twisted default
+```
+
+If `FTPFactory.welcomeMessage` is not directly settable, patch it at class level:
+
+```python
+FTPFactory.welcomeMessage = BANNER
+```
+
+### Anonymous login + fake directory
+
+The current server rejects everything after login. Fix:
+
+- Use `FTPAnonymousShell` pointed at a `MemoryFilesystem` with fake entries:
+ ```
+ /
+ ├── backup.tar.gz (0 bytes, but listable)
+ ├── db_dump.sql (0 bytes)
+ ├── config.ini (0 bytes)
+ └── credentials.txt (0 bytes)
+ ```
+- `RETR` any file → return 1–3 lines of plausible fake content, then close.
+- Log every `RETR` with filename and client IP.
+
+### Env vars
+
+| Var | Default |
+|-----|---------|
+| `FTP_BANNER` | `220 (vsFTPd 3.0.3)` |
+
+---
+
+## 3. MSSQL — Silent on TDS pre-login (High)
+
+### Problem
+
+No response to standard TDS pre-login packets. Connection is dropped silently.
+nmap barely recognizes port 1433 (`ms-sql-s?`).
+
+### Diagnosis
+
+The nmap fingerprint shows `\x04\x01\x00\x2b...` which is a valid TDS 7.x pre-login
+response fragment. So the server is sending _something_ — but it may be truncated or
+malformed enough that nmap can't complete its probe.
+
+Check `templates/mssql/server.py`: look for the raw bytes being sent in response to
+`\x12\x01` (TDS pre-login type). Common bugs:
+- Wrong packet length field (bytes 2-3 of TDS header)
+- Missing `\xff` terminator on the pre-login option list
+- Status byte 0x01 instead of 0x00 in the TDS header (signaling last packet)
+
+### Correct TDS 7.x pre-login response structure
+
+```
+Byte 0: 0x04 (packet type: tabular result)
+Byte 1: 0x01 (status: last packet)
+Bytes 2-3: 0x00 0x2b (total length including header = 43)
+Bytes 4-5: 0x00 0x00 (SPID)
+Byte 6: 0x01 (packet ID)
+Byte 7: 0x00 (window)
+--- TDS pre-login payload ---
+[VERSION] option: type=0x00, offset=0x001a, length=0x0006
+[ENCRYPTION] option: type=0x01, offset=0x0020, length=0x0001
+[INSTOPT] option: type=0x02, offset=0x0021, length=0x0001
+[THREADID] option: type=0x03, offset=0x0022, length=0x0004
+[MARS] option: type=0x04, offset=0x0026, length=0x0001
+Terminator: 0xff
+VERSION: 0x0e 0x00 0x07 0xd0 0x00 0x00 (14.0.2000 = SQL Server 2017)
+ENCRYPTION: 0x02 (ENCRYPT_NOT_SUP)
+INSTOPT: 0x00
+THREADID: 0x00 0x00 0x00 0x00
+MARS: 0x00
+```
+
+Verify the current implementation's bytes match this exactly. Fix the length field if off.
+
+---
+
+## 4. MongoDB — Silent on OP_MSG (High)
+
+### Problem
+
+No response to `OP_MSG isMaster` command. nmap shows `mongod?` (partial recognition).
+
+### Diagnosis
+
+MongoDB wire protocol since 3.6 uses `OP_MSG` (opcode 2013). Older clients use
+`OP_QUERY` (opcode 2004) against `admin.$cmd`. Check which one `templates/mongodb/server.py`
+handles, and whether the response's `responseTo` field matches the request's `requestID`.
+
+Common bugs:
+- Handling `OP_QUERY` but not `OP_MSG`
+- Wrong `responseTo` in the response header (must echo the request's requestID)
+- Missing `flagBits` field in OP_MSG response (must be 0x00000000)
+
+### Correct OP_MSG `hello` response
+
+```python
+import struct, bson
+
+def _op_msg_hello_response(request_id: int) -> bytes:
+ doc = {
+ "ismaster": True,
+ "maxBsonObjectSize": 16777216,
+ "maxMessageSizeBytes": 48000000,
+ "maxWriteBatchSize": 100000,
+ "localTime": {"$date": int(time.time() * 1000)},
+ "logicalSessionTimeoutMinutes": 30,
+ "connectionId": 1,
+ "minWireVersion": 0,
+ "maxWireVersion": 17,
+ "readOnly": False,
+ "ok": 1.0,
+ }
+ payload = b"\x00" + bson.encode(doc) # section type 0 = body
+ flag_bits = struct.pack(" Scenario: attacker finds MQTT broker on a water treatment plant, subscribes to
+> sensor topics, publishes commands trying to "open the valve" or "disable chlorination".
+
+---
+
+## Services in scope
+
+| Service | Port | Current state | Target state |
+|---------|------|--------------|-------------|
+| MQTT | 1883 | CONNACK 0x05 (reject all) | CONNACK 0x00, fake sensor topics |
+| SNMP | 161/UDP | Functional, generic sysDescr | sysDescr tuned per archetype |
+| Conpot | 502 | Not responding | Investigate + fix port mapping |
+
+---
+
+## MQTT — water plant persona
+
+### Current behavior
+
+Every CONNECT gets `CONNACK 0x05` (Not Authorized) and the connection is closed.
+An ICS attacker immediately moves on — there's nothing to interact with.
+
+### Target behavior
+
+Accept all connections (`CONNACK 0x00`). Publish retained sensor data on
+realistic SCADA topics. Log every PUBLISH command (attacker trying to control plant).
+
+### Topic tree
+
+```
+plant/water/tank1/level → "73.4" (percent full)
+plant/water/tank1/pressure → "2.81" (bar)
+plant/water/pump1/status → "RUNNING"
+plant/water/pump1/rpm → "1420"
+plant/water/pump2/status → "STANDBY"
+plant/water/chlorine/dosing → "1.2" (mg/L)
+plant/water/chlorine/residual → "0.8" (mg/L)
+plant/water/valve/inlet/state → "OPEN"
+plant/water/valve/drain/state → "CLOSED"
+plant/alarm/high_pressure → "0"
+plant/alarm/low_chlorine → "0"
+plant/alarm/pump_fault → "0"
+plant/$SYS/broker/version → "Mosquitto 2.0.15"
+plant/$SYS/broker/uptime → "2847392 seconds"
+```
+
+All topics have `retain=True` so subscribers immediately receive the last value.
+
+### Protocol changes needed
+
+Add handling for:
+
+- **SUBSCRIBE (pkt_type=8)**: Parse topic filter + QoS pairs. For each matching topic,
+ send SUBACK then immediately send a PUBLISH with the retained value.
+- **PUBLISH (pkt_type=3)**: Log the topic + payload (this is the attacker "sending a command").
+ Return PUBACK for QoS 1. Do NOT update the retained value (the plant ignores the command).
+- **PINGREQ (pkt_type=12)**: Already handled. Keep alive.
+- **DISCONNECT (pkt_type=14)**: Close cleanly.
+
+Do NOT implement: UNSUBSCRIBE, QoS 2. Return SUBACK with QoS 1 for all subscriptions.
+
+### CONNACK change
+
+```python
+_CONNACK_ACCEPTED = b"\x20\x02\x00\x00" # session_present=0, return_code=0
+```
+
+### Env vars
+
+| Var | Default | Description |
+|-----|---------|-------------|
+| `MQTT_PERSONA` | `water_plant` | Topic tree preset |
+| `MQTT_ACCEPT_ALL` | `1` | Accept all connections |
+| `NODE_NAME` | `mqtt-broker` | Hostname in logs |
+
+---
+
+## SUBSCRIBE packet parsing
+
+```python
+def _parse_subscribe(payload: bytes):
+ """Returns (packet_id, [(topic, qos), ...])"""
+ pos = 0
+ packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
+ pos += 2
+ topics = []
+ while pos < len(payload):
+ topic, pos = _read_utf8(payload, pos)
+ qos = payload[pos] & 0x03
+ pos += 1
+ topics.append((topic, qos))
+ return packet_id, topics
+```
+
+### SUBACK
+
+```python
+def _suback(packet_id: int, granted_qos: list[int]) -> bytes:
+ payload = struct.pack(">H", packet_id) + bytes(granted_qos)
+ return bytes([0x90, len(payload)]) + payload
+```
+
+### PUBLISH (server → client, retained)
+
+```python
+def _publish(topic: str, value: str, retain: bool = True) -> bytes:
+ topic_bytes = topic.encode()
+ topic_len = struct.pack(">H", len(topic_bytes))
+ payload = value.encode()
+ # Fixed header: type=3, retain flag, no QoS (fire and forget for retained)
+ fixed = 0x31 if retain else 0x30
+ remaining = len(topic_len) + len(topic_bytes) + len(payload)
+ return bytes([fixed, remaining]) + topic_len + topic_bytes + payload
+```
+
+---
+
+## SNMP — sysDescr per archetype
+
+Current `sysDescr` is a generic Linux string. It should reflect the decky's persona.
+
+### Archetype strings
+
+| Archetype | sysDescr |
+|-----------|---------|
+| water_plant | `Linux scada-plc01 4.19.0-18-amd64 #1 SMP Debian 4.19.208-1 (2021-09-29) x86_64` |
+| factory | `VxWorks 6.9 (Rockwell Automation Allen-Bradley ControlLogix 5580)` |
+| substation | `SEL Real-Time Automation Controller RTAC SEL-3555 firmware 1.9.7.0` |
+| hospital | `Linux medlogic-srv01 5.10.0-21-amd64 #1 SMP Debian 5.10.162-1 x86_64` |
+| default | `Linux decky-host 5.15.0-91-generic #101-Ubuntu SMP Tue Nov 14 13:30:08 UTC 2023 x86_64` |
+
+Env var `SNMP_ARCHETYPE` selects the string. The SNMP server should also tune:
+
+- `sysContact.0` → `ICS Admin `
+- `sysLocation.0` → `Water Treatment Facility — Pump Room B`
+- `sysName.0` → `scada-plc01` (from `NODE_NAME`)
+
+---
+
+## Conpot — Modbus TCP (port 502)
+
+### Current state
+
+Port 502 shows `CLOSED` in nmap. Conpot is deployed as a service container but
+is either not binding to 502 or the port mapping is wrong.
+
+### Diagnosis steps
+
+1. Check the compose fragment: `decnet services conpot` — what port does it expose?
+2. `docker exec decky-01-conpot netstat -tlnp` or `ss -tlnp` — is Conpot listening on 502?
+3. Check Conpot's default config — it may listen on a non-standard port (e.g. 5020) and
+ expect a host-level iptables REDIRECT rule to map 502 → 5020.
+
+### Fix options
+
+**Option A** (preferred): Configure Conpot to listen directly on 502 by editing its
+`default.xml` template and setting `502`.
+
+**Option B**: Add `iptables -t nat -A PREROUTING -p tcp --dport 502 -j REDIRECT --to-port 5020`
+to the base container entrypoint. Fragile — prefer A.
+
+### What Modbus should respond
+
+Conpot's default Modbus template already implements a plausible PLC. The key registers
+to tune for water-plant persona:
+
+| Register | Address | Value | Description |
+|----------|---------|-------|-------------|
+| Coil | 0 | 1 | Pump 1 running |
+| Coil | 1 | 0 | Pump 2 standby |
+| Holding | 0 | 734 | Tank level (73.4%) |
+| Holding | 1 | 281 | Pressure (2.81 bar × 100) |
+| Holding | 2 | 12 | Chlorine dosing (1.2 mg/L × 10) |
+
+These values should be consistent with the MQTT topic tree so an attacker who
+probes both sees a coherent picture.
+
+---
+
+## Log events
+
+### MQTT
+
+| event_type | Fields | Trigger |
+|------------|--------|---------|
+| `connect` | src, src_port, client_id, username | CONNECT packet |
+| `subscribe` | src, topics | SUBSCRIBE packet |
+| `publish` | src, topic, payload | PUBLISH from client (attacker command!) |
+| `disconnect` | src | DISCONNECT or connection lost |
+
+### SNMP
+
+No changes to event structure — sysDescr is just a config string.
+
+---
+
+## Files to change
+
+| File | Change |
+|------|--------|
+| `templates/mqtt/server.py` | Accept connections, SUBSCRIBE handler, retained PUBLISH, PUBLISH log |
+| `templates/snmp/server.py` | Add `SNMP_ARCHETYPE` env var, tune sysDescr/sysContact/sysLocation |
+| `templates/conpot/` | Investigate port config, fix 502 binding |
+| `tests/test_mqtt.py` | New: connect accepted, subscribe → retained publish, attacker publish logged |
+| `tests/test_snmp.py` | Extend: sysDescr per archetype |
+
+---
+
+## Verification against live decky
+
+```bash
+# MQTT: connect and subscribe
+mosquitto_sub -h 192.168.1.200 -t "plant/#" -v
+
+# Expected output:
+# plant/water/tank1/level 73.4
+# plant/water/pump1/status RUNNING
+# ...
+
+# MQTT: attacker sends a command (should be logged)
+mosquitto_pub -h 192.168.1.200 -t "plant/water/valve/inlet/state" -m "CLOSED"
+
+# Modbus: read coil 0 (pump status)
+# (requires mbpoll or similar)
+mbpoll -a 1 -r 1 -c 2 192.168.1.200
+
+# SNMP: sysDescr check
+snmpget -v2c -c public 192.168.1.200 1.3.6.1.2.1.1.1.0
+# Expected: STRING: "Linux scada-plc01 4.19.0..."
+```
diff --git a/development/IMAP_BAIT.md b/development/IMAP_BAIT.md
new file mode 100644
index 0000000..f7eff41
--- /dev/null
+++ b/development/IMAP_BAIT.md
@@ -0,0 +1,220 @@
+# IMAP Bait Mailboxes — Plan
+
+> Scenario: attacker credential-stuffs IMAP, logs in as `admin`/`admin`,
+> browses mail, finds juicy internal communications and credential leaks.
+
+---
+
+## Current state
+
+Both IMAP and POP3 reject **all** credentials with a hard-coded failure.
+No mailbox commands are implemented. An attacker that successfully guesses
+credentials (which they can't, ever) would have nothing to read anyway.
+
+This is the biggest missed opportunity in the whole stack.
+
+---
+
+## Design
+
+### Credential policy
+
+Accept a configurable set of username/password pairs. Defaults baked into
+the image — typical attacker wordlist winners:
+
+```
+admin / admin
+admin / password
+admin / 123456
+root / root
+mail / mail
+user / user
+```
+
+Env var override: `IMAP_USERS=admin:admin,root:toor,user:letmein`
+
+Wrong credentials → `NO [AUTHENTICATIONFAILED] Invalid credentials` (log the attempt).
+Right credentials → `OK` + full session.
+
+### Fake mailboxes
+
+One static mailbox tree, same for all users (honeypot doesn't need per-user isolation):
+
+```
+INBOX (12 messages)
+ Sent (8 messages)
+ Drafts (1 message)
+ Archive (3 messages)
+```
+
+### Bait email content
+
+Bait emails are seeded at startup from a `MAIL_SEED` list embedded in the server.
+Content is designed to reward the attacker for staying in the session:
+
+**INBOX messages (selected)**
+
+| # | From | Subject | Bait payload |
+|---|------|---------|-------------|
+| 1 | devops@company.internal | AWS credentials rotation | `AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` |
+| 2 | monitoring@company.internal | DB password changed | `mysql://admin:Sup3rS3cr3t!@10.0.1.5:3306/production` |
+| 3 | noreply@github.com | Your personal access token | `ghp_16C7e42F292c6912E7710c838347Ae178B4a` |
+| 4 | admin@company.internal | VPN config attached | `vpn.company.internal:1194 user=vpnadmin pass=VpnP@ss2024` |
+| 5 | sysadmin@company.internal | Root password | New root pw: `r00tM3T00!` — change after first login |
+| 6 | backup@company.internal | Backup job failed | Backup to `192.168.1.50:/mnt/nas` — credentials in /etc/backup.conf |
+| 7 | alerts@company.internal | SSH brute-force alert | 47 attempts from 185.220.101.x against root — all blocked |
+
+**Sent messages**
+
+| # | To | Subject | Bait payload |
+|---|-----|---------|-------------|
+| 1 | vendor@external.com | API credentials | API key: `sk_live_xK3mF2...9aP` |
+| 2 | helpdesk@company.internal | Need access reset | My password is `Winter2024!` — please reset MFA |
+
+**Drafts**
+
+| # | Subject | Bait payload |
+|---|---------|-------------|
+| 1 | DO NOT SEND - k8s secrets | `kubectl get secret admin-token -n kube-system -o yaml` output pasted in |
+
+---
+
+## Protocol implementation
+
+### IMAP4rev1 commands to implement
+
+```
+CAPABILITY → * CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN
+LOGIN → authenticate or reject
+SELECT → select INBOX / Sent / Drafts / Archive
+LIST → return folder tree
+LSUB → same as LIST (subscribed)
+STATUS → return EXISTS / RECENT / UNSEEN for a mailbox
+FETCH → return message headers or full body
+UID FETCH → same with UID addressing
+SEARCH → stub: return all UIDs (we don't need real search)
+EXAMINE → read-only SELECT
+CLOSE → deselect current mailbox
+LOGOUT → BYE + OK
+NOOP → OK
+```
+
+Commands NOT needed (return `BAD`): `STORE`, `COPY`, `APPEND`, `EXPUNGE`.
+Attackers rarely run these. Logging `BAD` is fine if they do.
+
+### Banner
+
+Change from:
+```
+* OK [omega-decky] IMAP4rev1 Service Ready
+```
+To:
+```
+* OK Dovecot ready.
+```
+
+nmap currently says "(unrecognized)". Dovecot banner makes it ID correctly.
+
+### CAPABILITY advertisement
+
+```
+* CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN
+```
+
+### SELECT response
+
+```
+* 12 EXISTS
+* 0 RECENT
+* OK [UNSEEN 7] Message 7 is first unseen
+* OK [UIDVALIDITY 1712345678] UIDs valid
+* OK [UIDNEXT 13] Predicted next UID
+* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
+* OK [PERMANENTFLAGS (\Deleted \Seen \*)] Limited
+A3 OK [READ-WRITE] SELECT completed
+```
+
+### FETCH envelope/body
+
+Message structs stored as Python dataclasses at startup. `FETCH 1:* (FLAGS ENVELOPE)` returns
+envelope tuples in RFC 3501 format. `FETCH N BODY[]` returns the raw RFC 2822 message.
+
+---
+
+## POP3 parity
+
+POP3 is much simpler. Same credential list. After successful PASS:
+
+```
+STAT → +OK 12 48000 (12 messages, total ~48 KB)
+LIST → +OK 12 messages\r\n1 3912\r\n2 2048\r\n...\r\n.
+RETR N → +OK \r\n\r\n.
+TOP N L → +OK\r\n\r\n.
+UIDL → +OK\r\n1 \r\n...\r\n.
+DELE N → +OK Message deleted (just log it, don't actually remove)
+CAPA → +OK\r\nTOP\r\nUSER\r\nUIDL\r\nRESP-CODES\r\nAUTH-RESP-CODE\r\nSASL\r\n.
+```
+
+---
+
+## State machine (IMAP)
+
+```
+NOT_AUTHENTICATED
+ → LOGIN success → AUTHENTICATED
+ → LOGIN fail → NOT_AUTHENTICATED (log, stay open for retries)
+
+AUTHENTICATED
+ → SELECT / EXAMINE → SELECTED
+ → LIST / LSUB / STATUS / LOGOUT / NOOP → stay AUTHENTICATED
+
+SELECTED
+ → FETCH / UID FETCH / SEARCH / EXAMINE / SELECT → stay SELECTED
+ → CLOSE / LOGOUT → AUTHENTICATED or closed
+```
+
+---
+
+## Files to change
+
+| File | Change |
+|------|--------|
+| `templates/imap/server.py` | Full rewrite: state machine, credential check, mailbox commands, bait emails |
+| `templates/pop3/server.py` | Extend: credential check, STAT/LIST/RETR/UIDL/TOP/DELE/CAPA |
+| `tests/test_imap.py` | New: login flow, SELECT, FETCH, bad creds, all mailboxes |
+| `tests/test_pop3.py` | New: login flow, STAT, LIST, RETR, CAPA |
+
+---
+
+## Implementation notes
+
+- All bait emails are hardcoded Python strings — no files to load, no I/O.
+- Use a module-level `MESSAGES: list[dict]` list with fields: `uid`, `flags`, `size`, `date`,
+ `from_`, `to`, `subject`, `body` (full RFC 2822 string).
+- `_format_envelope()` builds the IMAP ENVELOPE tuple string from the message dict.
+- Thread safety: all state per-connection in the Protocol class. No shared mutable state.
+
+---
+
+## Env vars
+
+| Var | Default | Description |
+|-----|---------|-------------|
+| `IMAP_USERS` | `admin:admin,root:root,mail:mail` | Accepted credentials (user:pass,...) |
+| `IMAP_BANNER` | `* OK Dovecot ready.` | Greeting line |
+| `NODE_NAME` | `mailserver` | Hostname in responses |
+
+---
+
+## Verification against live decky
+
+```bash
+# Credential test (should accept)
+printf "A1 LOGIN admin admin\r\nA2 SELECT INBOX\r\nA3 FETCH 1:3 (FLAGS ENVELOPE)\r\nA4 FETCH 5 BODY[]\r\nA5 LOGOUT\r\n" | nc 192.168.1.200 143
+
+# Credential test (should reject)
+printf "A1 LOGIN admin wrongpass\r\n" | nc 192.168.1.200 143
+
+# nmap fingerprint check (expect "Dovecot imapd")
+nmap -p 143 -sV 192.168.1.200
+```
diff --git a/development/SMTP_RELAY.md b/development/SMTP_RELAY.md
new file mode 100644
index 0000000..bfa6a5d
--- /dev/null
+++ b/development/SMTP_RELAY.md
@@ -0,0 +1,195 @@
+# SMTP Open Relay — Plan
+
+> Priority: **P0** — DATA handler is broken (502 on every body line).
+> Scenario: attacker finds open relay, sends mail through it.
+
+---
+
+## What's broken today
+
+`templates/smtp/server.py` sends `354 End data with .` on `DATA`, then
+falls back to `_handle_line()` for every subsequent line. Because those lines don't start
+with a recognized SMTP verb, every line gets:
+
+```
+502 5.5.2 Error: command not recognized
+```
+
+The session never completes. The message is silently dropped.
+
+---
+
+## Fix: DATA state machine
+
+Add a `_in_data` flag. Once `DATA` is received, accumulate raw body lines until the
+terminator `\r\n.\r\n`. On terminator: log the message, return `250`, flip flag back.
+
+### State variables added to `SMTPProtocol.__init__`
+
+```python
+self._in_data = False
+self._data_buf = [] # accumulate body lines
+self._mail_from = ""
+self._rcpt_to = []
+```
+
+### Modified `data_received`
+
+No change — still splits on `\r\n`.
+
+### Modified `_handle_line`
+
+```python
+def _handle_line(self, line: str) -> None:
+ # DATA body accumulation mode
+ if self._in_data:
+ if line == ".":
+ # end of message
+ body = "\r\n".join(self._data_buf)
+ msg_id = _rand_msg_id()
+ _log("message_accepted",
+ src=self._peer[0],
+ mail_from=self._mail_from,
+ rcpt_to=",".join(self._rcpt_to),
+ body_bytes=len(body),
+ msg_id=msg_id)
+ self._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode())
+ self._in_data = False
+ self._data_buf = []
+ else:
+ # RFC 5321 dot-stuffing: leading dot means literal dot, strip it
+ self._data_buf.append(line[1:] if line.startswith("..") else line)
+ return
+
+ cmd = line.split()[0].upper() if line.split() else ""
+ # ... existing handlers ...
+ elif cmd == "MAIL":
+ self._mail_from = line.split(":", 1)[1].strip() if ":" in line else line
+ _log("mail_from", src=self._peer[0], value=self._mail_from)
+ self._transport.write(b"250 2.0.0 Ok\r\n")
+ elif cmd == "RCPT":
+ rcpt = line.split(":", 1)[1].strip() if ":" in line else line
+ self._rcpt_to.append(rcpt)
+ _log("rcpt_to", src=self._peer[0], value=rcpt)
+ self._transport.write(b"250 2.1.5 Ok\r\n")
+ elif cmd == "DATA":
+ if not self._mail_from or not self._rcpt_to:
+ self._transport.write(b"503 5.5.1 Error: need MAIL command\r\n")
+ else:
+ self._in_data = True
+ self._transport.write(b"354 End data with .\r\n")
+ elif cmd == "RSET":
+ self._mail_from = ""
+ self._rcpt_to = []
+ self._in_data = False
+ self._data_buf = []
+ self._transport.write(b"250 2.0.0 Ok\r\n")
+```
+
+### Helper
+
+```python
+import random, string
+
+def _rand_msg_id() -> str:
+ """Return a Postfix-style 12-char hex queue ID."""
+ return "".join(random.choices("0123456789ABCDEF", k=12))
+```
+
+---
+
+## Open relay behavior
+
+The current server already returns `250 2.1.5 Ok` for any `RCPT TO` regardless of domain.
+That's correct — do NOT gate on the domain. The attacker's goal is to relay spam. We let
+them "succeed" and log everything.
+
+Remove the `AUTH` rejection + close. An open relay doesn't require authentication. Replace:
+
+```python
+elif cmd == "AUTH":
+ _log("auth_attempt", src=self._peer[0], command=line)
+ self._transport.write(b"535 5.7.8 Error: authentication failed: ...\r\n")
+ self._transport.close()
+```
+
+With:
+
+```python
+elif cmd == "AUTH":
+ # Log the attempt but advertise that auth succeeds (open relay bait)
+ _log("auth_attempt", src=self._peer[0], command=line)
+ self._transport.write(b"235 2.7.0 Authentication successful\r\n")
+```
+
+Some scanners probe AUTH before DATA. Accepting it keeps them engaged.
+
+---
+
+## Banner / persona
+
+Current banner is already perfect: `220 omega-decky ESMTP Postfix (Debian/GNU)`.
+
+The `SMTP_BANNER` env var lets per-decky customization happen at deploy time via the
+persona config — no code change needed.
+
+---
+
+## Log events emitted
+
+| event_type | Fields |
+|------------------|---------------------------------------------------|
+| `connect` | src, src_port |
+| `ehlo` | src, domain |
+| `auth_attempt` | src, command |
+| `mail_from` | src, value |
+| `rcpt_to` | src, value (one event per recipient) |
+| `message_accepted` | src, mail_from, rcpt_to, body_bytes, msg_id |
+| `disconnect` | src |
+
+---
+
+## Files to change
+
+| File | Change |
+|------|--------|
+| `templates/smtp/server.py` | DATA state machine, open relay AUTH accept, RSET fix |
+| `tests/test_smtp.py` | New: DATA → 250 flow, multi-recipient, dot-stuffing, RSET |
+
+---
+
+## Test cases (pytest)
+
+```python
+# full send flow
+conn → EHLO → MAIL FROM → RCPT TO → DATA → body lines → "." → 250 2.0.0 Ok: queued as ...
+
+# multi-recipient
+RCPT TO x3 → DATA → body → "." → 250
+
+# dot-stuffing
+..real dot → body line stored as ".real dot"
+
+# RSET mid-session
+MAIL FROM → RCPT TO → RSET → assert _mail_from == "" and _rcpt_to == []
+
+# AUTH accept
+AUTH PLAIN base64 → 235
+
+# 503 if DATA before MAIL
+DATA (no prior MAIL) → 503
+```
+
+---
+
+## Verification against live decky
+
+```bash
+# Full relay test
+printf "EHLO test.com\r\nMAIL FROM:\r\nRCPT TO:\r\nDATA\r\nSubject: hello\r\n\r\nBody line 1\r\nBody line 2\r\n.\r\nQUIT\r\n" | nc 192.168.1.200 25
+
+# Expected final lines:
+# 354 End data with ...
+# 250 2.0.0 Ok: queued as
+# 221 2.0.0 Bye
+```
diff --git a/templates/smtp/server.py b/templates/smtp/server.py
index 5b826b8..6bfa8f0 100644
--- a/templates/smtp/server.py
+++ b/templates/smtp/server.py
@@ -1,22 +1,39 @@
#!/usr/bin/env python3
"""
SMTP server — emulates a realistic ESMTP server (Postfix-style).
-Logs EHLO/AUTH/MAIL FROM/RCPT TO attempts as JSON, then denies auth.
+
+Two modes of operation, controlled by SMTP_OPEN_RELAY:
+
+ SMTP_OPEN_RELAY=0 (default) — credential harvester
+ AUTH attempts are logged and rejected (535).
+ RCPT TO is rejected with 554 (relay denied) for all recipients.
+ This captures credential stuffing and scanning activity.
+
+ SMTP_OPEN_RELAY=1 — open relay bait
+ AUTH is accepted for any credentials (235).
+ RCPT TO is accepted for any domain (250).
+ DATA is fully buffered until CRLF.CRLF and acknowledged with a
+ queued-as message ID. Attractive to spam relay operators.
+
+The DATA state machine (and the 502-per-line bug) is fixed in both modes.
"""
import asyncio
+import base64
import os
-from decnet_logging import syslog_line, write_syslog_file, forward_syslog
+import random
+import string
+from decnet_logging import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
-SERVICE_NAME = "smtp"
+SERVICE_NAME = "smtp"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
+OPEN_RELAY = os.environ.get("SMTP_OPEN_RELAY", "0").strip() == "1"
+
_SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)")
_SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME)
-
-
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
print(line, flush=True)
@@ -24,11 +41,42 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
forward_syslog(line, LOG_TARGET)
+def _rand_msg_id() -> str:
+ """Return a Postfix-style 12-char alphanumeric queue ID."""
+ chars = string.ascii_uppercase + string.digits
+ return "".join(random.choices(chars, k=12)) # noqa: S311
+
+
+def _decode_auth_plain(blob: str) -> tuple[str, str]:
+ """Decode SASL PLAIN: base64(authzid\0authcid\0passwd) → (user, pass)."""
+ try:
+ decoded = base64.b64decode(blob + "==").decode(errors="replace")
+ parts = decoded.split("\x00")
+ if len(parts) >= 3:
+ return parts[1], parts[2]
+ if len(parts) == 2:
+ return parts[0], parts[1]
+ except Exception:
+ pass
+ return blob, ""
+
+
class SMTPProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
- self._peer = ("?", 0)
- self._buf = b""
+ self._peer = ("?", 0)
+ self._buf = b""
+ # per-transaction state
+ self._mail_from = ""
+ self._rcpt_to: list[str] = []
+ # DATA accumulation
+ self._in_data = False
+ self._data_buf: list[str] = []
+ # AUTH multi-step state (LOGIN mechanism sends user/pass in separate lines)
+ self._auth_state = "" # "" | "await_user" | "await_pass"
+ self._auth_user = ""
+
+ # ── asyncio.Protocol ──────────────────────────────────────────────────────
def connection_made(self, transport):
self._transport = transport
@@ -40,14 +88,55 @@ class SMTPProtocol(asyncio.Protocol):
self._buf += data
while b"\r\n" in self._buf:
line, self._buf = self._buf.split(b"\r\n", 1)
- self._handle_line(line.decode(errors="replace").strip())
+ self._handle_line(line.decode(errors="replace"))
+
+ def connection_lost(self, exc):
+ _log("disconnect", src=self._peer[0] if self._peer else "?")
+
+ # ── Command dispatch ──────────────────────────────────────────────────────
def _handle_line(self, line: str) -> None:
- cmd = line.split()[0].upper() if line.split() else ""
+ # ── DATA body accumulation ────────────────────────────────────────────
+ if self._in_data:
+ if line == ".":
+ body = "\r\n".join(self._data_buf)
+ msg_id = _rand_msg_id()
+ _log("message_accepted",
+ src=self._peer[0],
+ mail_from=self._mail_from,
+ rcpt_to=",".join(self._rcpt_to),
+ body_bytes=len(body),
+ msg_id=msg_id)
+ self._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode())
+ self._in_data = False
+ self._data_buf = []
+ self._mail_from = ""
+ self._rcpt_to = []
+ else:
+ # RFC 5321 dot-stuffing: strip leading dot
+ self._data_buf.append(line[1:] if line.startswith(".") else line)
+ return
+
+ # ── AUTH multi-step (LOGIN mechanism) ─────────────────────────────────
+ if self._auth_state == "await_user":
+ self._auth_user = base64.b64decode(line + "==").decode(errors="replace")
+ self._auth_state = "await_pass"
+ self._transport.write(b"334 UGFzc3dvcmQ6\r\n") # "Password:"
+ return
+ if self._auth_state == "await_pass":
+ password = base64.b64decode(line + "==").decode(errors="replace")
+ self._finish_auth(self._auth_user, password)
+ self._auth_state = ""
+ self._auth_user = ""
+ return
+
+ # ── Normal command dispatch ───────────────────────────────────────────
+ parts = line.split(None, 1)
+ cmd = parts[0].upper() if parts else ""
+ args = parts[1] if len(parts) > 1 else ""
if cmd in ("EHLO", "HELO"):
- domain = line.split(None, 1)[1] if " " in line else ""
- _log("ehlo", src=self._peer[0], domain=domain)
+ _log("ehlo", src=self._peer[0], domain=args)
self._transport.write(
f"250-{_SMTP_MTA}\r\n"
f"250-PIPELINING\r\n"
@@ -59,41 +148,102 @@ class SMTPProtocol(asyncio.Protocol):
f"250-8BITMIME\r\n"
f"250 DSN\r\n".encode()
)
+
elif cmd == "AUTH":
- _log("auth_attempt", src=self._peer[0], command=line)
- self._transport.write(b"535 5.7.8 Error: authentication failed: UGFzc3dvcmQ6\r\n")
- self._transport.close()
+ self._handle_auth(args)
+
elif cmd == "MAIL":
- _log("mail_from", src=self._peer[0], value=line)
+ addr = args.split(":", 1)[1].strip() if ":" in args else args
+ self._mail_from = addr
+ _log("mail_from", src=self._peer[0], value=addr)
self._transport.write(b"250 2.1.0 Ok\r\n")
+
elif cmd == "RCPT":
- _log("rcpt_to", src=self._peer[0], value=line)
- self._transport.write(b"250 2.1.5 Ok\r\n")
+ addr = args.split(":", 1)[1].strip() if ":" in args else args
+ if OPEN_RELAY:
+ self._rcpt_to.append(addr)
+ _log("rcpt_to", src=self._peer[0], value=addr)
+ self._transport.write(b"250 2.1.5 Ok\r\n")
+ else:
+ _log("rcpt_denied", src=self._peer[0], value=addr,
+ severity=SEVERITY_WARNING)
+ self._transport.write(
+ b"554 5.7.1 <" + addr.encode() + b">: Relay access denied\r\n"
+ )
+
elif cmd == "DATA":
- self._transport.write(b"354 End data with .\r\n")
+ if not self._rcpt_to:
+ self._transport.write(b"503 5.5.1 Error: need RCPT command\r\n")
+ else:
+ self._in_data = True
+ self._transport.write(b"354 End data with .\r\n")
+
+ elif cmd == "RSET":
+ self._mail_from = ""
+ self._rcpt_to = []
+ self._in_data = False
+ self._data_buf = []
+ self._auth_state = ""
+ self._auth_user = ""
+ self._transport.write(b"250 2.0.0 Ok\r\n")
+
elif cmd == "VRFY":
- _log("vrfy", src=self._peer[0], value=line)
+ _log("vrfy", src=self._peer[0], value=args)
self._transport.write(b"252 2.0.0 Cannot VRFY user\r\n")
+
+ elif cmd == "NOOP":
+ self._transport.write(b"250 2.0.0 Ok\r\n")
+
+ elif cmd == "STARTTLS":
+ self._transport.write(b"454 4.7.0 TLS not available due to local problem\r\n")
+
elif cmd == "QUIT":
self._transport.write(b"221 2.0.0 Bye\r\n")
self._transport.close()
- elif cmd == "NOOP":
- self._transport.write(b"250 2.0.0 Ok\r\n")
- elif cmd == "RSET":
- self._transport.write(b"250 2.0.0 Ok\r\n")
- elif cmd == "STARTTLS":
- # Pretend we don't support upgrading mid-session
- self._transport.write(b"454 4.7.0 TLS not available due to local problem\r\n")
+
else:
- _log("unknown_command", src=self._peer[0], command=line)
+ _log("unknown_command", src=self._peer[0], command=line[:128])
self._transport.write(b"502 5.5.2 Error: command not recognized\r\n")
- def connection_lost(self, exc):
- _log("disconnect", src=self._peer[0] if self._peer else "?")
+ # ── AUTH helpers ──────────────────────────────────────────────────────────
+
+ def _handle_auth(self, args: str) -> None:
+ parts = args.split(None, 1)
+ mech = parts[0].upper() if parts else ""
+ initial = parts[1] if len(parts) > 1 else ""
+
+ if mech == "PLAIN":
+ if initial:
+ user, password = _decode_auth_plain(initial)
+ self._finish_auth(user, password)
+ else:
+ # Client will send credentials on next line
+ self._auth_state = "await_plain"
+ self._transport.write(b"334 \r\n")
+ elif mech == "LOGIN":
+ if initial:
+ self._auth_user = base64.b64decode(initial + "==").decode(errors="replace")
+ self._auth_state = "await_pass"
+ self._transport.write(b"334 UGFzc3dvcmQ6\r\n") # "Password:"
+ else:
+ self._auth_state = "await_user"
+ self._transport.write(b"334 VXNlcm5hbWU6\r\n") # "Username:"
+ else:
+ self._transport.write(b"504 5.5.4 Unrecognized authentication mechanism\r\n")
+
+ def _finish_auth(self, username: str, password: str) -> None:
+ _log("auth_attempt", src=self._peer[0],
+ username=username, password=password,
+ severity=SEVERITY_WARNING)
+ if OPEN_RELAY:
+ self._transport.write(b"235 2.7.0 Authentication successful\r\n")
+ else:
+ self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")
async def main():
- _log("startup", msg=f"SMTP server starting as {NODE_NAME}")
+ mode = "open-relay" if OPEN_RELAY else "credential-harvester"
+ _log("startup", msg=f"SMTP server starting as {NODE_NAME} ({mode})")
loop = asyncio.get_running_loop()
server = await loop.create_server(SMTPProtocol, "0.0.0.0", 25) # nosec B104
async with server:
diff --git a/tests/test_smtp.py b/tests/test_smtp.py
new file mode 100644
index 0000000..8a6e93a
--- /dev/null
+++ b/tests/test_smtp.py
@@ -0,0 +1,303 @@
+"""
+Tests for templates/smtp/server.py
+
+Exercises both modes:
+ - credential-harvester (SMTP_OPEN_RELAY=0, default)
+ - open relay (SMTP_OPEN_RELAY=1)
+
+Uses asyncio transport/protocol directly — no network socket needed.
+"""
+
+import base64
+import importlib.util
+import sys
+from types import ModuleType
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _make_fake_decnet_logging() -> ModuleType:
+ """Return a stub decnet_logging module that does nothing."""
+ mod = ModuleType("decnet_logging")
+ mod.syslog_line = MagicMock(return_value="")
+ mod.write_syslog_file = MagicMock()
+ mod.forward_syslog = MagicMock()
+ mod.SEVERITY_WARNING = 4
+ mod.SEVERITY_INFO = 6
+ return mod
+
+
+def _load_smtp(open_relay: bool):
+ """Import smtp server module with desired OPEN_RELAY value.
+
+ Injects a stub decnet_logging into sys.modules so the template can import
+ it without needing the real file on sys.path.
+ """
+ env = {"SMTP_OPEN_RELAY": "1" if open_relay else "0", "NODE_NAME": "testhost"}
+ for key in list(sys.modules):
+ if key in ("smtp_server", "decnet_logging"):
+ del sys.modules[key]
+
+ sys.modules["decnet_logging"] = _make_fake_decnet_logging()
+
+ spec = importlib.util.spec_from_file_location("smtp_server", "templates/smtp/server.py")
+ mod = importlib.util.module_from_spec(spec)
+ with patch.dict("os.environ", env, clear=False):
+ spec.loader.exec_module(mod)
+ return mod
+
+
+def _make_protocol(mod):
+ """Return a (protocol, transport, written) triple. Banner is already discarded."""
+ proto = mod.SMTPProtocol()
+ transport = MagicMock()
+ written: list[bytes] = []
+ transport.write.side_effect = written.append
+ proto.connection_made(transport)
+ written.clear()
+ return proto, transport, written
+
+
+def _send(proto, *lines: str) -> None:
+ """Feed CRLF-terminated lines to the protocol."""
+ for line in lines:
+ proto.data_received((line + "\r\n").encode())
+
+
+def _replies(written: list[bytes]) -> list[str]:
+ """Flatten written bytes into a list of non-empty response lines."""
+ result = []
+ for chunk in written:
+ for line in chunk.decode().split("\r\n"):
+ if line:
+ result.append(line)
+ return result
+
+
+# ── Fixtures ──────────────────────────────────────────────────────────────────
+
+@pytest.fixture
+def relay_mod():
+ return _load_smtp(open_relay=True)
+
+@pytest.fixture
+def harvester_mod():
+ return _load_smtp(open_relay=False)
+
+
+# ── Banner ────────────────────────────────────────────────────────────────────
+
+def test_banner_is_220(relay_mod):
+ proto, transport, written = _make_protocol(relay_mod)
+ # written was cleared — re-trigger banner check via a fresh instance
+ proto2 = relay_mod.SMTPProtocol()
+ t2 = MagicMock()
+ w2: list[bytes] = []
+ t2.write.side_effect = w2.append
+ proto2.connection_made(t2)
+ banner = b"".join(w2).decode()
+ assert banner.startswith("220")
+ assert "ESMTP" in banner
+
+
+# ── EHLO ──────────────────────────────────────────────────────────────────────
+
+def test_ehlo_returns_250_multiline(relay_mod):
+ proto, _, written = _make_protocol(relay_mod)
+ _send(proto, "EHLO attacker.com")
+ combined = b"".join(written).decode()
+ assert "250" in combined
+ assert "AUTH" in combined
+ assert "PIPELINING" in combined
+
+
+# ── OPEN RELAY MODE ───────────────────────────────────────────────────────────
+
+class TestOpenRelay:
+
+ @staticmethod
+ def _session(relay_mod, *lines):
+ proto, _, written = _make_protocol(relay_mod)
+ _send(proto, *lines)
+ return _replies(written)
+
+ def test_auth_plain_accepted(self, relay_mod):
+ creds = base64.b64encode(b"\x00admin\x00password").decode()
+ replies = self._session(relay_mod, f"AUTH PLAIN {creds}")
+ assert any(r.startswith("235") for r in replies)
+
+ def test_auth_login_multistep_accepted(self, relay_mod):
+ proto, _, written = _make_protocol(relay_mod)
+ _send(proto, "AUTH LOGIN")
+ _send(proto, base64.b64encode(b"admin").decode())
+ _send(proto, base64.b64encode(b"password").decode())
+ replies = _replies(written)
+ assert any(r.startswith("235") for r in replies)
+
+ def test_rcpt_to_any_domain_accepted(self, relay_mod):
+ replies = self._session(
+ relay_mod,
+ "EHLO x.com",
+ "MAIL FROM:",
+ "RCPT TO:",
+ )
+ assert any(r.startswith("250 2.1.5") for r in replies)
+
+ def test_full_relay_flow(self, relay_mod):
+ replies = self._session(
+ relay_mod,
+ "EHLO attacker.com",
+ "MAIL FROM:",
+ "RCPT TO:",
+ "DATA",
+ "Subject: hello",
+ "",
+ "Body line 1",
+ "Body line 2",
+ ".",
+ "QUIT",
+ )
+ assert any(r.startswith("354") for r in replies), "Expected 354 after DATA"
+ assert any("queued as" in r for r in replies), "Expected queued-as ID"
+ assert any(r.startswith("221") for r in replies), "Expected 221 on QUIT"
+
+ def test_multi_recipient(self, relay_mod):
+ replies = self._session(
+ relay_mod,
+ "EHLO x.com",
+ "MAIL FROM:",
+ "RCPT TO:",
+ "RCPT TO:",
+ "RCPT TO:",
+ "DATA",
+ "Subject: spam",
+ "",
+ "hello",
+ ".",
+ )
+ assert len([r for r in replies if r.startswith("250 2.1.5")]) == 3
+
+ def test_dot_stuffing_stripped(self, relay_mod):
+ """Leading dot on a body line must be stripped per RFC 5321."""
+ proto, _, written = _make_protocol(relay_mod)
+ _send(proto,
+ "EHLO x.com",
+ "MAIL FROM:",
+ "RCPT TO:",
+ "DATA",
+ "..real dot line",
+ "normal line",
+ ".",
+ )
+ replies = _replies(written)
+ assert any("queued as" in r for r in replies)
+
+ def test_data_rejected_without_rcpt(self, relay_mod):
+ replies = self._session(relay_mod, "EHLO x.com", "MAIL FROM:", "DATA")
+ assert any(r.startswith("503") for r in replies)
+
+ def test_rset_clears_transaction_state(self, relay_mod):
+ proto, _, _ = _make_protocol(relay_mod)
+ _send(proto, "EHLO x.com", "MAIL FROM:", "RCPT TO:", "RSET")
+ assert proto._mail_from == ""
+ assert proto._rcpt_to == []
+ assert proto._in_data is False
+
+ def test_second_send_after_rset(self, relay_mod):
+ """A new transaction started after RSET must complete successfully."""
+ replies = self._session(
+ relay_mod,
+ "EHLO x.com",
+ "MAIL FROM:",
+ "RCPT TO:",
+ "RSET",
+ "MAIL FROM:",
+ "RCPT TO:",
+ "DATA",
+ "body",
+ ".",
+ )
+ assert any("queued as" in r for r in replies)
+
+
+# ── CREDENTIAL HARVESTER MODE ─────────────────────────────────────────────────
+
+class TestCredentialHarvester:
+
+ @staticmethod
+ def _session(harvester_mod, *lines):
+ proto, _, written = _make_protocol(harvester_mod)
+ _send(proto, *lines)
+ return _replies(written)
+
+ def test_auth_plain_rejected_535(self, harvester_mod):
+ creds = base64.b64encode(b"\x00admin\x00password").decode()
+ replies = self._session(harvester_mod, f"AUTH PLAIN {creds}")
+ assert any(r.startswith("535") for r in replies)
+
+ def test_auth_rejected_connection_stays_open(self, harvester_mod):
+ """After 535 the connection must stay alive — old code closed it immediately."""
+ proto, transport, _ = _make_protocol(harvester_mod)
+ creds = base64.b64encode(b"\x00admin\x00password").decode()
+ _send(proto, f"AUTH PLAIN {creds}")
+ transport.close.assert_not_called()
+
+ def test_rcpt_to_denied_554(self, harvester_mod):
+ replies = self._session(
+ harvester_mod,
+ "EHLO x.com",
+ "MAIL FROM:",
+ "RCPT TO:",
+ )
+ assert any(r.startswith("554") for r in replies)
+
+ def test_relay_denied_blocks_data(self, harvester_mod):
+ """With all RCPT TO rejected, DATA must return 503."""
+ replies = self._session(
+ harvester_mod,
+ "EHLO x.com",
+ "MAIL FROM:",
+ "RCPT TO:",
+ "DATA",
+ )
+ assert any(r.startswith("503") for r in replies)
+
+ def test_noop_and_quit(self, harvester_mod):
+ replies = self._session(harvester_mod, "NOOP", "QUIT")
+ assert any(r.startswith("250") for r in replies)
+ assert any(r.startswith("221") for r in replies)
+
+ def test_unknown_command_502(self, harvester_mod):
+ replies = self._session(harvester_mod, "BADCMD foo")
+ assert any(r.startswith("502") for r in replies)
+
+ def test_starttls_declined_454(self, harvester_mod):
+ replies = self._session(harvester_mod, "STARTTLS")
+ assert any(r.startswith("454") for r in replies)
+
+
+# ── Queue ID ──────────────────────────────────────────────────────────────────
+
+def test_rand_msg_id_format(relay_mod):
+ for _ in range(50):
+ mid = relay_mod._rand_msg_id()
+ assert len(mid) == 12
+ assert mid.isalnum()
+
+
+# ── AUTH PLAIN decode ─────────────────────────────────────────────────────────
+
+def test_decode_auth_plain_normal(relay_mod):
+ blob = base64.b64encode(b"\x00alice\x00s3cr3t").decode()
+ user, pw = relay_mod._decode_auth_plain(blob)
+ assert user == "alice"
+ assert pw == "s3cr3t"
+
+
+def test_decode_auth_plain_garbage_no_raise(relay_mod):
+ user, pw = relay_mod._decode_auth_plain("!!!notbase64!!!")
+ assert isinstance(user, str)
+ assert isinstance(pw, str)