Skip to content

Commit a272a67

Browse files
committed
Merge feature/multiprotocol: multi-protocol support
Adds SSH, Telnet, SMTP, RDP, FTP, SIP, SMB, MAIL honeypot support with protocol switcher, per-protocol leaderboards, Proto Stats pane, classic mode for single-protocol servers, and ?show= URL filtering. Preserves blocklist features from master.
2 parents d856c77 + 9d18bcb commit a272a67

29 files changed

Lines changed: 6662 additions & 424 deletions

CLAUDE.md

Lines changed: 85 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Project Overview
66

7-
Knock-Knock is an SSH honeypot monitoring system that captures unauthorized SSH login attempts and displays real-time attack data through a live web dashboard. It can be deployed via Docker or as two coordinated systemd services.
7+
Knock-Knock is a multi-protocol honeypot monitoring system that captures unauthorized login attempts on SSH (port 22), Telnet (port 23), and SMTP (port 587), and displays real-time attack data through a live web dashboard. It can be deployed via Docker or as two coordinated systemd services.
88

99
## Commands
1010

@@ -32,10 +32,12 @@ docker compose logs -f
3232
```bash
3333
source .venv/bin/activate
3434

35-
# SSH honeypot (port 22)
36-
python honeypot.py
35+
# Individual honeypots (ports 22, 23, 587)
36+
python ssh_honeypot.py
37+
python telnet_honeypot.py
38+
python smtp_honeypot.py
3739

38-
# Log monitor + geo-enricher — spawns honeypot.py as a subprocess
40+
# Log monitor + geo-enricher — spawns all three honeypots as subprocesses
3941
# Add --save-knocks to store individual knocks in SQLite
4042
python monitor.py
4143

@@ -64,43 +66,55 @@ sqlite3 data/knock_knock.db "SELECT * FROM knocks ORDER BY id DESC LIMIT 10;"
6466

6567
# Redis connectivity
6668
redis-cli ping
69+
70+
# Check per-protocol feed lists
71+
redis-cli llen knock:recent:ssh
72+
redis-cli llen knock:recent:tnet
73+
redis-cli llen knock:recent:smtp
74+
75+
# Watch for SMTP connections (even without AUTH — honeypot logs every connect)
76+
journalctl -u knock-monitor -f | grep SMTP
6777
```
6878

6979
## Architecture
7080

7181
```
72-
SSH Attacker → honeypot.py (port 22) → stdout
73-
74-
monitor.py (spawns honeypot, parses output, GeoIP lookup)
75-
76-
SQLite DB (data/) + Redis pub/sub
77-
78-
main.py (FastAPI, port 80/443)
79-
80-
Browser WebSocket → Live Dashboard
82+
SSH Attacker → ssh_honeypot.py (port 22) ─┐
83+
Telnet Attacker → telnet_honeypot.py (port 23) ─┼→ stdout → monitor.py
84+
SMTP Attacker → smtp_honeypot.py (port 587) ─┘ (GeoIP, DB, Redis)
85+
86+
SQLite DB (data/) + Redis pub/sub
87+
88+
main.py (FastAPI, port 80/443)
89+
90+
Browser WebSocket → Live Dashboard
8191
```
8292

8393
**Two Services:**
84-
- `honeypot.py` + `monitor.py`: Combined into a single systemd unit. Monitor spawns honeypot as a subprocess and reads its stdout. Performs GeoIP lookups, updates intel tables in SQLite, publishes to Redis. Individual knocks are only saved to SQLite with `--save-knocks`. Honeypot checks `knock:blocked` Redis set on each connection to reject blocked IPs instantly.
85-
- `main.py`: FastAPI server with WebSocket endpoint `/ws`, subscribes to Redis, broadcasts to all connected browsers
94+
- `monitor.py`: Spawns all three honeypots as subprocesses, merges their stdout via a shared `queue.Queue`, performs GeoIP lookups, updates SQLite intel tables, publishes to Redis. Individual knocks saved to SQLite only with `--save-knocks`. Honeypots check `knock:blocked` Redis set on each connection to reject blocked IPs instantly.
95+
- `main.py`: FastAPI server with WebSocket endpoint `/ws`, subscribes to Redis, broadcasts to all connected browsers.
8696

8797
**Data Flow:**
88-
- Monitor spawns honeypot as a subprocess and reads its stdout (both systemd and Docker)
98+
- Monitor spawns honeypots as subprocesses and reads their stdout (both systemd and Docker)
99+
- Each honeypot emits JSON: `{"type": "KNOCK", "proto": "SSH"|"TNET"|"SMTP", "ip": ..., "user": ..., "pass": ...}`
89100
- Inter-service communication via Redis pub/sub channel `radiation_stream`
90-
- Stats cached in memory (10-min refresh), periodic sync every 60 seconds
101+
- Stats cached in memory, refreshed every 60 seconds and broadcast to all clients
91102
- SQLite databases in `data/` directory for persistence
92103

93104
**Deployment modes:**
94-
- **Docker:** `docker compose up -d` — monitor spawns honeypot internally
95-
- **Systemd:** Two unit files in `systemd/` — monitor spawns honeypot internally
105+
- **Docker:** `docker compose up -d` — monitor spawns all honeypots internally
106+
- **Systemd:** Two unit files in `systemd/` — monitor spawns all honeypots internally
96107

97108
## Key Files
98109

99110
| File | Purpose |
100111
|------|---------|
101-
| `honeypot.py` | SSH honeypot with `SSHHoneypot` class |
102-
| `monitor.py` | Log parser, GeoIP enrichment, DB writes, Redis publish |
112+
| `ssh_honeypot.py` | SSH honeypot (port 22) using paramiko |
113+
| `telnet_honeypot.py` | Telnet honeypot (port 23), raw socket with IAC negotiation |
114+
| `smtp_honeypot.py` | SMTP honeypot (port 587), AUTH LOGIN + AUTH PLAIN |
115+
| `monitor.py` | Spawns honeypots, GeoIP enrichment, DB writes, Redis publish |
103116
| `main.py` | FastAPI server, `ConnectionManager`, `GlobalStatsCache`, WebSocket |
117+
| `constants.py` | Shared protocol enum: `PROTO` dict and `PROTO_NAME` reverse lookup |
104118
| `index.html` | Single-page dashboard with WebSocket client |
105119
| `restart.sh` | Service orchestration (systemd and Docker) |
106120
| `Dockerfile` | Single image for honeypot-monitor and web containers |
@@ -126,50 +140,78 @@ All persistent data lives in `data/`:
126140
| `ENABLE_SSL` | unset | Set to `true` in `docker-compose.yml` for HTTPS |
127141
| `LOG_VISITORS` | unset | Set to `true` to log dashboard visitors to `visitors.db` |
128142

143+
## Protocol Enum
144+
145+
Defined in `constants.py`, imported by both `monitor.py` and `main.py`:
146+
147+
```python
148+
PROTO = {'SSH': 0, 'TNET': 1, 'SMTP': 2, 'RDP': 3}
149+
PROTO_NAME = {v: k for k, v in PROTO.items()}
150+
```
151+
129152
## Database Schema
130153

131154
```sql
132155
-- Main attack log (only populated with --save-knocks)
133-
knocks(id, timestamp, ip_address, iso_code, city, region, country, isp, asn, username, password)
134-
135-
-- Intelligence tables (aggregated counts with indexed hits for fast top-N queries)
136-
user_intel(username PRIMARY KEY, hits, last_seen) -- INDEX on hits DESC
137-
pass_intel(password PRIMARY KEY, hits, last_seen) -- INDEX on hits DESC
138-
country_intel(iso_code PRIMARY KEY, country, hits, last_seen) -- INDEX on hits DESC
139-
isp_intel(isp PRIMARY KEY, hits, last_seen, asn) -- INDEX on hits DESC
140-
ip_intel(ip PRIMARY KEY, hits, last_seen, lat, lng) -- INDEX on hits DESC, stores coordinates
156+
knocks(id, timestamp, ip_address, iso_code, city, region, country, isp, asn, username, password, proto INTEGER)
157+
158+
-- ALL intel tables (aggregated counts, indexed hits for fast top-N queries)
159+
user_intel(username PRIMARY KEY, hits, last_seen) -- INDEX on hits DESC
160+
pass_intel(password PRIMARY KEY, hits, last_seen) -- INDEX on hits DESC
161+
country_intel(iso_code PRIMARY KEY, country, hits, last_seen) -- INDEX on hits DESC
162+
isp_intel(isp PRIMARY KEY, hits, last_seen, asn) -- INDEX on hits DESC
163+
ip_intel(ip PRIMARY KEY, hits, last_seen, lat, lng) -- INDEX on hits DESC
164+
165+
-- Per-protocol intel tables (same structure, composite PK)
166+
user_intel_proto(username, proto INTEGER, hits, last_seen) -- INDEX on (proto, hits DESC)
167+
pass_intel_proto(password, proto INTEGER, hits, last_seen) -- INDEX on (proto, hits DESC)
168+
country_intel_proto(iso_code, proto INTEGER, country, hits, last_seen)
169+
isp_intel_proto(isp, proto INTEGER, hits, last_seen, asn)
170+
ip_intel_proto(ip, proto INTEGER, hits, last_seen, lat, lng)
141171

142172
-- Uptime tracking for KPM calculation
143-
monitor_heartbeats(id, timestamp)
173+
monitor_heartbeats(id, uptime_minutes)
144174
```
145175

146-
Intel tables are updated on each knock via `INSERT ... ON CONFLICT DO UPDATE`. Top-N queries use the hits index (~100 rows) instead of GROUP BY on knocks (all rows).
147-
148-
## External Dependencies
149-
150-
- Redis server (localhost:6379 or via `REDIS_HOST` env var)
151-
- GeoIP databases at `/usr/share/GeoIP/GeoLite2-{City,ASN}.mmdb`
152-
- SSL certificates in `certs/` directory (optional, for HTTPS)
153-
- Python 3.12 with `uv` virtual environment (systemd) or Docker
176+
Each knock writes 10 upserts: 5 to ALL tables + 5 to `_proto` tables. ALL tables serve as fast rollup for the ALL leaderboard; `_proto` tables serve per-protocol leaderboards.
154177

155178
## Redis Keys
156179

157-
- `knock:total_global` - Total attack count
180+
- `knock:total_global` - Total attack count (all protocols)
181+
- `knock:uptime_minutes` - Monitor uptime in minutes
158182
- `knock:last_time` - Unix timestamp of last knock
159183
- `knock:last_lat` - Latitude of last knock location
160184
- `knock:last_lng` - Longitude of last knock location
161-
- `knock:recent` - Last 100 knocks (JSON list, used for initial page load)
185+
- `knock:recent` - Last 100 knocks, all protocols (JSON list)
186+
- `knock:recent:ssh` - Last 100 SSH knocks
187+
- `knock:recent:tnet` - Last 100 Telnet knocks
188+
- `knock:recent:smtp` - Last 100 SMTP knocks
162189
- `knock:blocked` - Set of blocked IPs (seeded from `blocklist.txt` on startup; checked by honeypot on each connection)
163190
- `radiation_stream` - Pub/sub channel for real-time events
164191

192+
## Globe Rendering Rules
193+
194+
The pane globes are paused when idle (`pauseAnimation()`). **Any change to globe scene state (polygon data, point data, styles) will NOT be visible until the animation loop runs a frame.** Always follow scene changes with:
195+
```javascript
196+
if (paneGlobeDesktop && paneGlobeVisible.desktop) paneGlobeDesktop.resumeAnimation();
197+
if (paneGlobeMobile && paneGlobeVisible.mobile) paneGlobeMobile.resumeAnimation();
198+
schedulePaneGlobePause();
199+
```
200+
`refreshHeatGlobe()` and `applyGlobeStyle()` already do this. Any new function that modifies pane globe state must too.
201+
202+
Additionally, `polygonsData(sameRef)` may be short-circuited by globe.gl — always pass `[...countriesData]` to guarantee the polygon digest runs and accessor functions are re-evaluated.
203+
165204
## Frontend Features
166205

167-
- **3D Globe** (globe.gl): Displays attack location, rotates on new knocks; includes heat map mode
168-
- **Live Feed**: Real-time attack log with username/password/location
169-
- **Leaderboards**: Top countries, usernames, passwords, ISPs, IPs
206+
- **3D Globe** (globe.gl): Displays attack location, rotates on new knocks; heat map mode extrudes countries by hit count
207+
- **Protocol Filter**: Cycles ALL → SSH → TNET → SMTP → ALL; filters live feed, leaderboards, globe rotation, and heat map
208+
- **Live Feed**: Real-time attack log with protocol badge, username/password/location
209+
- **Leaderboards**: Top countries, usernames, passwords, ISPs, IPs — per-protocol or ALL
170210
- **Trivia & Jokes**: Context about why usernames/passwords are chosen, plus knock-knock jokes
171211
- **Sound Effects**: Optional audio notifications for new knocks
172212
- **About**: Project info section
213+
- **Classic Mode**: Automatically activates when only one protocol is active — hides protocol switcher, cycle buttons, proto badges, proto chip pulses, and Proto Stats pane for a clean single-protocol UI. Header label changes from "Total Knocks" to "[PROTO] Knocks"
214+
- **`?show` URL Parameter**: Subset which protocols are visible (e.g., `?show=SSH`, `?show=SSH,RDP`). Intersected with server's enabled protocols; invalid values fall back to all enabled. Single-protocol `?show` triggers classic mode. When filtered, header stats (total, KPM, ago) reflect only the active protocols, computed client-side from `protoBreakdownCache` and `lastKnockTimeByProto`
173215
- **Debug Mode**: Overlay via `?debug` URL parameter
174216
- **Responsive**: Mobile carousel with swipe navigation, desktop grid layout
175217
- **WebSocket**: Auto-reconnect, live updates without polling

Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ FROM python:3.12-slim
22

33
RUN pip install --no-cache-dir \
44
paramiko \
5+
impacket \
56
geoip2 \
67
redis \
78
fastapi \
89
"uvicorn[standard]"
910

1011
WORKDIR /app
11-
COPY honeypot.py monitor.py main.py index.html ./
12+
COPY monitor.py main.py constants.py index.html ./
13+
COPY honeypots/ honeypots/
1214
COPY static/ static/
13-
RUN python -c "import paramiko; paramiko.RSAKey.generate(2048).write_private_key_file('server.key')"

INSTALL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ docker compose logs -f honeypot-monitor
9393
# Should show:
9494
# ⏳ Waiting for GeoIP databases... (briefly, during first-time download)
9595
# ✅ GeoIP databases loaded
96-
# 🚀 Maximalist Monitor Active...
96+
# 🚀 Knock-Knock Monitor Active...
9797

9898
docker compose logs web # Should show uvicorn startup
9999
```

MERGE_PLAN.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Merge Strategy: Master vs Feature/Multiprotocol
2+
3+
## Context
4+
5+
knock-knock.net (master) runs an SSH-only honeypot with a clean, focused UI — 11 panes, ~1,800 lines of frontend code. beta.knock-knock.net (feature/multiprotocol) extends this to 8 protocols (SSH, Telnet, SMTP, RDP, MAIL, FTP, SIP, SMB) with protocol cycling buttons, per-protocol leaderboards, protocol badges, and a Proto Stats pane — 12 panes, ~2,800 lines of frontend. The feature branch is 54 commits ahead; master has 0 commits not in feature (clean fast-forward).
6+
7+
The dilemma: the simple UI is elegant, the multiprotocol data is valuable, and maintaining two divergent codebases is unsustainable for a solo developer.
8+
9+
## Recommendation: Merge + Auto-Detecting Classic Mode
10+
11+
**Insight:** The multiprotocol frontend *already works identically to the classic view* when the protocol filter is on "ALL." The only visual differences are protocol cycle buttons, the Proto Stats pane, and protocol badges in the feed. When only one protocol is enabled, those elements are meaningless — so hide them automatically.
12+
13+
**The rule:** The frontend already receives `enabled_protocols` from the server on WebSocket connect. If `enabled_protocols.length === 1`, apply classic mode. If `> 1`, show the full multiprotocol UI. No toggles, no URL params, no user decisions — the UI adapts to the server config.
14+
15+
## Implementation
16+
17+
### Step 1: Merge feature/multiprotocol into master
18+
```bash
19+
git checkout master
20+
git merge --ff-only feature/multiprotocol
21+
```
22+
23+
### Step 2: Add auto-detecting classic mode (~25 lines in index.html)
24+
25+
**CSS (~10 lines):**
26+
```css
27+
body.classic-mode .proto-cycle-btn { display: none !important; }
28+
body.classic-mode #d-box-proto { display: none !important; }
29+
body.classic-mode #m-pane-proto { display: none !important; }
30+
body.classic-mode .proto-badge { display: none !important; }
31+
/* Hide Proto Stats nav items in both desktop and mobile nav */
32+
```
33+
34+
**JS (~15 lines):**
35+
In the WebSocket `init_stats` handler (where `enabled_protocols` is already received):
36+
```javascript
37+
// Auto-detect classic mode based on server config
38+
const classicMode = (data.enabled_protocols || []).length <= 1;
39+
document.body.classList.toggle('classic-mode', classicMode);
40+
// Adjust pane count for mobile dots / desktop nav if needed
41+
```
42+
43+
No localStorage, no toggle button, no URL params. Pure server-driven.
44+
45+
### Step 3: Adjust navigation pane counts
46+
47+
When classic mode is active, the Proto Stats pane is hidden. The desktop nav and mobile dot indicators need to account for this:
48+
- Desktop: hide the Proto Stats nav item (CSS handles this)
49+
- Mobile: hide the corresponding dot and adjust swipe/snap behavior
50+
- `dJump()` / mobile pane index may need a small guard if Proto Stats pane index is referenced
51+
52+
## Why this wins
53+
54+
1. **Zero code duplication** — one index.html, one backend, one branch
55+
2. **Zero configuration** — no toggles, no URL params; UI auto-adapts
56+
3. **Correct by construction** — single-protocol deployments get a clean UI because the protocol UI *has nothing to show*
57+
4. **Fast-forward merge** — no conflicts, all 54 commits ship to production
58+
5. **Trivial implementation**~25 lines of CSS+JS
59+
6. **Zero maintenance overhead** — every future fix applies to both modes automatically
60+
61+
## Files to Modify
62+
63+
| File | Change |
64+
|------|--------|
65+
| `index.html` | ~10 lines CSS for `.classic-mode` rules, ~15 lines JS for auto-detection in `init_stats` handler |
66+
| No backend changes | main.py, monitor.py, constants.py already complete on feature branch |
67+
68+
## Verification
69+
70+
1. Merge: `git checkout master && git merge --ff-only feature/multiprotocol`
71+
2. Multi-protocol test: deploy with multiple protocols enabled → confirm full UI (cycle buttons, Proto Stats, badges all visible)
72+
3. Single-protocol test: set `ENABLED_PROTOCOLS=SSH` in env → restart monitor → reload page → confirm classic mode (no cycle buttons, no Proto Stats pane, no protocol badges)
73+
4. Mobile: confirm Proto Stats nav dot hidden in classic mode, swipe navigation still works correctly
74+
5. Verify leaderboards still aggregate correctly in both modes

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Knock-Knock supports three different installation methods, with docker being the
6363
```
6464
SSH Attacker
6565
66-
honeypot.py (port 22)
66+
ssh_honeypot.py (port 22)
6767
6868
monitor.py (GeoIP lookup)
6969

constants.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Protocol enum — stored as INTEGER in knocks/proto intel tables
2+
PROTO = {'SSH': 0, 'TNET': 1, 'SMTP': 2, 'RDP': 3, 'MAIL': 4, 'FTP': 5, 'SIP': 6, 'SMB': 7}
3+
PROTO_NAME = {v: k for k, v in PROTO.items()} # reverse lookup: 0->'SSH' etc.
4+
5+
# Canonical protocol order for UI controls and displays.
6+
PROTOCOL_UI_ORDER = ['SSH', 'TNET', 'FTP', 'RDP', 'SMB', 'SIP', 'SMTP', 'MAIL']
7+
8+
# Declarative protocol metadata for monitor/web UI.
9+
PROTOCOL_META = {
10+
'SSH': {
11+
'proto_int': 0,
12+
'color': '#00ff41',
13+
'supports_user_panel': True,
14+
'supports_pass_panel': True,
15+
'honeypot_script': 'honeypots/ssh_honeypot.py',
16+
},
17+
'TNET': {
18+
'proto_int': 1,
19+
'color': '#00fbff',
20+
'supports_user_panel': True,
21+
'supports_pass_panel': True,
22+
'honeypot_script': 'honeypots/telnet_honeypot.py',
23+
},
24+
'SMTP': {
25+
'proto_int': 2,
26+
'color': '#ff00ff',
27+
'supports_user_panel': True,
28+
'supports_pass_panel': True,
29+
'honeypot_script': 'honeypots/smtp_honeypot.py',
30+
},
31+
'RDP': {
32+
'proto_int': 3,
33+
'color': '#ff1a1a',
34+
'supports_user_panel': True,
35+
'supports_pass_panel': False,
36+
'honeypot_script': 'honeypots/rdp_honeypot.py',
37+
},
38+
'MAIL': {
39+
'proto_int': 4,
40+
'color': '#00ffaa',
41+
'supports_user_panel': False,
42+
'supports_pass_panel': False,
43+
'honeypot_script': 'honeypots/smtp25_honeypot.py',
44+
},
45+
'FTP': {
46+
'proto_int': 5,
47+
'color': '#FFFF77',
48+
'supports_user_panel': True,
49+
'supports_pass_panel': True,
50+
'honeypot_script': 'honeypots/ftp_honeypot.py',
51+
},
52+
'SIP': {
53+
'proto_int': 6,
54+
'color': '#ff7a00',
55+
'supports_user_panel': False,
56+
'supports_pass_panel': False,
57+
'honeypot_script': 'honeypots/sip_honeypot.py',
58+
},
59+
'SMB': {
60+
'proto_int': 7,
61+
'color': '#d6d9df',
62+
'supports_user_panel': True,
63+
'supports_pass_panel': False,
64+
'honeypot_script': 'honeypots/smb_honeypot.py',
65+
},
66+
}
67+
68+
def sort_protocols_for_ui(protocols):
69+
normalized = [str(p or '').upper() for p in (protocols or [])]
70+
unique = []
71+
for name in normalized:
72+
if name in PROTO and name not in unique:
73+
unique.append(name)
74+
preferred = [name for name in PROTOCOL_UI_ORDER if name in unique]
75+
extras = sorted([name for name in unique if name not in preferred])
76+
return preferred + extras
77+
78+
79+
DEFAULT_ENABLED_PROTOCOLS = list(PROTOCOL_UI_ORDER)

0 commit comments

Comments
 (0)