first commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#Password files
|
||||||
|
admin.passwd
|
||||||
|
email.json
|
||||||
|
|
||||||
690
README.md
Normal file
690
README.md
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
# portspoof-py
|
||||||
|
|
||||||
|
A Python asyncio rewrite of [portspoof](https://github.com/drk1wi/portspoof) — a TCP honeypot that makes every port on a machine appear open and running a real service.
|
||||||
|
|
||||||
|
When a port scanner connects to any TCP port, it receives a plausible fake service banner (SSH, HTTP, FTP, etc.). The banners are randomised from a signature library at startup, so the scanner cannot fingerprint the deception from banner patterns alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
1. [How it works](#how-it-works)
|
||||||
|
2. [Requirements](#requirements)
|
||||||
|
3. [Installation](#installation)
|
||||||
|
4. [Quick start](#quick-start)
|
||||||
|
5. [CLI reference](#cli-reference)
|
||||||
|
6. [Signature file](#signature-file)
|
||||||
|
7. [Config file](#config-file)
|
||||||
|
8. [iptables rules](#iptables-rules)
|
||||||
|
9. [JSON log](#json-log)
|
||||||
|
10. [Web admin interface](#web-admin-interface)
|
||||||
|
11. [Email alerts](#email-alerts)
|
||||||
|
12. [systemd service](#systemd-service)
|
||||||
|
13. [Recipes](#recipes)
|
||||||
|
14. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
attacker kernel portspoof-py
|
||||||
|
│ │ │
|
||||||
|
│─── TCP SYN → port 443 ──►│ │
|
||||||
|
│ │ iptables REDIRECT │
|
||||||
|
│ │──► port 4444 ────────────►│
|
||||||
|
│◄── TCP SYN-ACK ──────────│◄──────────────────────────│
|
||||||
|
│─── data ────────────────►│──────────────────────────►│
|
||||||
|
│ │ SO_ORIGINAL_DST │
|
||||||
|
│ │ recovers port 443 │
|
||||||
|
│◄── fake HTTPS banner ────│◄──────────────────────────│
|
||||||
|
│─── FIN ─────────────────►│──────────────────────────►│
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **iptables PREROUTING REDIRECT** — on startup, three rules are added to the `nat` table. All incoming TCP traffic (except port 22 and the listener port itself) is transparently redirected to the single listener port.
|
||||||
|
2. **Single asyncio listener** — one coroutine handles every connection. There is no thread pool and no polling loop.
|
||||||
|
3. **SO_ORIGINAL_DST** — the Linux kernel preserves the originally-intended destination port in the socket. The handler reads it with `getsockopt(SOL_IP, SO_ORIGINAL_DST)`.
|
||||||
|
4. **Pre-computed banner map** — at startup, each of the 65,536 ports is assigned a banner generated by processing a randomly-chosen regex pattern from the signature file. Banner generation is done once; serving is instant.
|
||||||
|
5. **Graceful shutdown** — SIGTERM/SIGINT triggers the stop event, the server drains, the log queue is flushed, and the iptables rules are removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Requirement | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Python 3.11+ | Uses `asyncio`, `struct`, `socket`, `pathlib`, stdlib only |
|
||||||
|
| Linux | `SO_ORIGINAL_DST` and `iptables` are Linux-specific |
|
||||||
|
| Root | Required for iptables management; `--no-iptables` bypasses this |
|
||||||
|
| iptables | Must be installed (`apt install iptables` or equivalent) |
|
||||||
|
|
||||||
|
No PyPI packages are needed at runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Run directly from the source tree (recommended for a lab machine):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd portspoof_py
|
||||||
|
sudo python3 -m portspoof_py -s tools/portspoof_signatures
|
||||||
|
```
|
||||||
|
|
||||||
|
**Install as a package:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install . # installs the portspoof-py entry point
|
||||||
|
portspoof-py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
**Install build tool only if needed:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install flit
|
||||||
|
flit build # produces a wheel in dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Test mode (no iptables, no root)
|
||||||
|
|
||||||
|
Useful for verifying the installation or developing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m portspoof_py \
|
||||||
|
--no-iptables \
|
||||||
|
-p 4444 \
|
||||||
|
-s tools/portspoof_signatures \
|
||||||
|
-v
|
||||||
|
|
||||||
|
# In another terminal — should receive a random service banner
|
||||||
|
nc localhost 4444
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full honeypot mode (as root)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 -m portspoof_py \
|
||||||
|
-p 4444 \
|
||||||
|
-s /etc/portspoof/portspoof_signatures \
|
||||||
|
-c /etc/portspoof/portspoof.conf \
|
||||||
|
-l /var/log/portspoof/portspoof.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
After startup you can verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo iptables -t nat -L PREROUTING --line-numbers
|
||||||
|
# Should show three portspoof rules
|
||||||
|
|
||||||
|
nmap -p 1-1000 <your-ip>
|
||||||
|
# All ports should appear open
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop with Ctrl-C or `kill <pid>`. Rules are removed automatically.
|
||||||
|
|
||||||
|
### With the web admin interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a credentials file first
|
||||||
|
echo "admin:changeme" > admin.passwd
|
||||||
|
|
||||||
|
sudo python3 -m portspoof_py \
|
||||||
|
-p 4444 \
|
||||||
|
-s /etc/portspoof/portspoof_signatures \
|
||||||
|
-l /var/log/portspoof/portspoof.jsonl \
|
||||||
|
--admin-port 8080
|
||||||
|
|
||||||
|
# Open http://127.0.0.1:8080 in a browser (HTTP Basic Auth prompt will appear)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI reference
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m portspoof_py [OPTIONS]
|
||||||
|
portspoof-py [OPTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listener
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-p PORT` / `--port PORT` | `4444` | TCP port the honeypot listens on. This port is excluded from the iptables REDIRECT rule so it does not redirect to itself. |
|
||||||
|
| `-i IP` / `--bind-ip IP` | all interfaces | Bind the listener to a specific IP address. |
|
||||||
|
|
||||||
|
### Signatures and configuration
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-s FILE` / `--signatures FILE` | — | Path to the signature file. Each line is a regex-like pattern used to generate a banner. At startup, one pattern is chosen randomly for each of the 65,536 ports. Without this flag every port returns an empty banner (the port appears open but silent). |
|
||||||
|
| `-c FILE` / `--config FILE` | — | Path to the config file. Overrides specific ports or port ranges with a fixed payload after the signature map is built. |
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-l FILE` / `--log-file FILE` | — | Append JSON connection events to this file. One line per connection. |
|
||||||
|
| `-v` / `--verbose` | off | Print each connection event as JSON to stdout. |
|
||||||
|
|
||||||
|
### iptables
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `--iface IFACE` | all interfaces | Restrict the iptables REDIRECT rule to a specific network interface (e.g. `eth0`). Useful when one interface is internal and should not be intercepted. |
|
||||||
|
| `--no-iptables` | off | Skip iptables setup and teardown entirely. The listener still runs on `-p PORT`. Use for local testing or when you manage iptables externally. |
|
||||||
|
|
||||||
|
### Web admin
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `--admin-port PORT` | disabled | Start the web admin interface on this port. |
|
||||||
|
| `--admin-host HOST` | `127.0.0.1` | Address the admin interface binds to. Set to `0.0.0.0` to expose it on all interfaces (protect with a firewall). |
|
||||||
|
| `--admin-passwd FILE` | `admin.passwd` | File containing `username:password` on a single line. Required when `--admin-port` is used. |
|
||||||
|
| `--email-config FILE` | `email.json` | JSON file where email alert settings are stored. Created automatically when you first save settings from the admin UI. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signature file
|
||||||
|
|
||||||
|
The signature file contains one pattern per line. Blank lines are ignored. The bundled file `tools/portspoof_signatures` contains 8,962 patterns derived from the nmap service detection database.
|
||||||
|
|
||||||
|
Each pattern is passed through a three-pass regex materialiser at startup to produce a concrete banner string:
|
||||||
|
|
||||||
|
**Pass 1** — group and bracket expansion
|
||||||
|
- `(...)` groups are stripped, their content kept.
|
||||||
|
- `[abc]`, `[a-z]`, `[0-9]`, `[^\0]` etc. are expanded: a random character (or random-length sequence for `*`/`+` quantifiers) is chosen from the defined set.
|
||||||
|
|
||||||
|
**Pass 2** — special sequence substitution
|
||||||
|
- `\w` → a random lowercase letter `a`–`z`
|
||||||
|
- `\d` → a random digit `0`–`9`
|
||||||
|
- `.` → a random lowercase letter
|
||||||
|
- `\n`, `\r`, `\t` → literal newline, carriage return, tab
|
||||||
|
|
||||||
|
**Pass 3** — hex escape decoding
|
||||||
|
- `\xNN` → the byte with hex value `NN`
|
||||||
|
|
||||||
|
**Example patterns and what they can produce:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# Input pattern → Example output
|
||||||
|
SSH-2.0-OpenSSH_([\w._-]+)\r\n → SSH-2g0-OpenSSH_QpKz\r\n
|
||||||
|
220 ([-.\w]+) ESMTP\r\n → 220 mxprod ESMTP\r\n
|
||||||
|
HTTP/1.\d \d\d\d [\w ]+\r\n → HTTP/1.4 200 OK\r\n
|
||||||
|
\x80\x01\x00\x80 → (four literal bytes)
|
||||||
|
AMServer → AMServer
|
||||||
|
```
|
||||||
|
|
||||||
|
The file is read with latin-1 encoding so binary-like content in patterns does not cause decode errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config file
|
||||||
|
|
||||||
|
The config file lets you pin specific ports or port ranges to a fixed payload, overriding the random assignment from the signature file. It is applied after the signature map is built.
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
Lines beginning with `#` are comments and are ignored. Each active line has the form:
|
||||||
|
|
||||||
|
```
|
||||||
|
PORT "payload"
|
||||||
|
START-END "payload"
|
||||||
|
```
|
||||||
|
|
||||||
|
The payload is everything between the **first** and **last** double-quote on the line. It is processed through the same three-pass materialiser as signature patterns, so hex escapes and regex metacharacters work.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
# Single port — port 22 returns a specific SSH banner
|
||||||
|
22 "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6\r\n"
|
||||||
|
|
||||||
|
# Port range — ports 80 through 443 return an HTTP 200
|
||||||
|
80-443 "HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Length: 0\r\n\r\n"
|
||||||
|
|
||||||
|
# Hex payload — raw bytes
|
||||||
|
9200 "\x7b\x22\x6e\x61\x6d\x65\x22\x3a\x22\x65\x6c\x61\x73\x74\x69\x63\x22\x7d"
|
||||||
|
```
|
||||||
|
|
||||||
|
The bundled `tools/portspoof.conf` shows the full comment syntax and more examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## iptables rules
|
||||||
|
|
||||||
|
When running without `--no-iptables`, three rules are appended to the `nat` PREROUTING chain at startup and removed at shutdown.
|
||||||
|
|
||||||
|
### Rules added (example with `-p 4444 --admin-port 8080`)
|
||||||
|
|
||||||
|
```
|
||||||
|
iptables -t nat -A PREROUTING -p tcp --dport 22 -j RETURN
|
||||||
|
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j RETURN
|
||||||
|
iptables -t nat -A PREROUTING -p tcp --dport 4444 -j RETURN
|
||||||
|
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-port 4444
|
||||||
|
```
|
||||||
|
|
||||||
|
The exempt ports are always added **before** the REDIRECT rule in this order:
|
||||||
|
|
||||||
|
1. Port 22 (SSH) — always first, so you cannot lock yourself out.
|
||||||
|
2. `--admin-port` — exempted automatically if specified, so the admin interface remains reachable.
|
||||||
|
3. The listener port (`-p`) — prevents a redirect loop.
|
||||||
|
|
||||||
|
Without `--admin-port`, the middle rule is omitted.
|
||||||
|
|
||||||
|
With `--iface eth0`, each rule gains `--in-interface eth0`.
|
||||||
|
|
||||||
|
### Verifying the rules
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo iptables -t nat -L PREROUTING -v --line-numbers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual cleanup
|
||||||
|
|
||||||
|
If the process is killed with SIGKILL (skipping the shutdown handler), the systemd `ExecStopPost` directives remove the rules. You can also remove them manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo iptables -t nat -D PREROUTING -p tcp --dport 22 -j RETURN
|
||||||
|
sudo iptables -t nat -D PREROUTING -p tcp --dport 4444 -j RETURN
|
||||||
|
sudo iptables -t nat -D PREROUTING -p tcp -j REDIRECT --to-port 4444
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistence across reboots
|
||||||
|
|
||||||
|
iptables rules are not persistent by default. The systemd service re-adds them on start. If you want them to survive without the service running, use `iptables-save` / `iptables-restore` or `netfilter-persistent`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JSON log
|
||||||
|
|
||||||
|
With `-l FILE`, each accepted connection appends one JSON object followed by a newline to the log file.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `timestamp` | string | ISO 8601 UTC timestamp with microseconds |
|
||||||
|
| `src_ip` | string | Source IP address of the scanner |
|
||||||
|
| `src_port` | integer | Source (ephemeral) port of the scanner |
|
||||||
|
| `dst_port` | integer | Port the scanner originally connected to (recovered via `SO_ORIGINAL_DST`) |
|
||||||
|
| `banner_hex` | string | Hex-encoded bytes of the banner that was sent |
|
||||||
|
| `banner_len` | integer | Length of the banner in bytes |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"timestamp": "2025-06-01T14:32:07.841203+00:00", "src_ip": "198.51.100.42", "src_port": 54312, "dst_port": 443, "banner_hex": "485454502f312e31203230300d0a", "banner_len": 14}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying the log
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show all source IPs sorted by frequency
|
||||||
|
jq -r .src_ip portspoof.jsonl | sort | uniq -c | sort -rn | head -20
|
||||||
|
|
||||||
|
# Show all targeted ports sorted by frequency
|
||||||
|
jq -r .dst_port portspoof.jsonl | sort | uniq -c | sort -rn | head -20
|
||||||
|
|
||||||
|
# Show connections from a specific IP
|
||||||
|
jq 'select(.src_ip == "198.51.100.42")' portspoof.jsonl
|
||||||
|
|
||||||
|
# Decode a banner from hex
|
||||||
|
jq -r .banner_hex portspoof.jsonl | head -1 | xxd -r -p
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web admin interface
|
||||||
|
|
||||||
|
Enable with `--admin-port PORT`. The interface binds to `127.0.0.1` by default; use `--admin-host 0.0.0.0` to expose it externally (ensure it is firewalled).
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Every request requires HTTP Basic Auth. Create a credentials file before starting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "admin:changeme" > admin.passwd
|
||||||
|
```
|
||||||
|
|
||||||
|
The file must contain a single line in `username:password` format. Pass a custom path with `--admin-passwd FILE`.
|
||||||
|
|
||||||
|
### Dashboard — `GET /`
|
||||||
|
|
||||||
|
A dark-themed HTML page that auto-refreshes every 5 seconds. Sections:
|
||||||
|
|
||||||
|
- **Stat cards** — total connections, connections in the last 60 seconds, uptime, ports mapped, and last connection time.
|
||||||
|
- **Top source IPs** — the 10 most active scanner addresses since startup.
|
||||||
|
- **Top targeted ports** — the 10 most-probed ports since startup.
|
||||||
|
- **Banner lookup** — enter any port number (0–65535) and see the hex and text preview of the banner that port will send. The result persists across auto-refreshes when using the `?port=N` query parameter.
|
||||||
|
- **Recent connections** — the 50 most-recent connections, newest first, with timestamp, source, destination port, banner hex excerpt, and banner length.
|
||||||
|
|
||||||
|
### Banner lookup shortcut
|
||||||
|
|
||||||
|
Append `?port=N` to the dashboard URL to pre-populate the lookup:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8080/?port=443
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON API
|
||||||
|
|
||||||
|
All endpoints require Basic Auth and return `Content-Type: application/json`.
|
||||||
|
|
||||||
|
#### `GET /api/stats`
|
||||||
|
|
||||||
|
Current statistics snapshot.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uptime": "2h 14m 37s",
|
||||||
|
"total_connections": 18432,
|
||||||
|
"last_connection": "2025-06-01T14:32:07.841203+00:00",
|
||||||
|
"connections_per_min": 312,
|
||||||
|
"ports_mapped": 65536,
|
||||||
|
"top_ips": [
|
||||||
|
{"ip": "198.51.100.42", "count": 9821},
|
||||||
|
{"ip": "203.0.113.7", "count": 4102}
|
||||||
|
],
|
||||||
|
"top_ports": [
|
||||||
|
{"port": 80, "count": 3201},
|
||||||
|
{"port": 443, "count": 2987},
|
||||||
|
{"port": 22, "count": 2104}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/connections?limit=N`
|
||||||
|
|
||||||
|
The N most-recent connection events, newest first. `limit` defaults to 50, maximum 500.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": "2025-06-01T14:32:07.841203+00:00",
|
||||||
|
"src_ip": "198.51.100.42",
|
||||||
|
"src_port": 54312,
|
||||||
|
"dst_port": 443,
|
||||||
|
"banner_hex": "485454502f312e31203230300d0a",
|
||||||
|
"banner_len": 14
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/banner?port=N`
|
||||||
|
|
||||||
|
The pre-computed banner for port N.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"port": 443,
|
||||||
|
"banner_hex": "485454502f312e31203230300d0a",
|
||||||
|
"banner_len": 14,
|
||||||
|
"banner_text": "HTTP/1.1 200\r\n"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`banner_text` is the banner decoded as latin-1. Non-printable bytes will appear as replacement characters in some terminals.
|
||||||
|
|
||||||
|
#### `GET /api/email/config`
|
||||||
|
|
||||||
|
Current email alert configuration (password is masked).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email alerts
|
||||||
|
|
||||||
|
portspoof can send email digest alerts when connections are detected. Configuration is done entirely through the admin UI — no restart required.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Start with `--admin-port` (the email config page is always available).
|
||||||
|
2. Open `http://127.0.0.1:8080/config` (or click **email alerts** in the dashboard header).
|
||||||
|
3. Fill in your SMTP details and click **Save**.
|
||||||
|
4. Click **Send test email** to verify delivery.
|
||||||
|
|
||||||
|
### How batching works
|
||||||
|
|
||||||
|
Rather than sending one email per connection, portspoof collects events and sends a single digest:
|
||||||
|
|
||||||
|
1. When a matching connection arrives, it is added to a pending list and a flush timer starts.
|
||||||
|
2. Any further matching connections within the **batch delay** window are added to the same list.
|
||||||
|
3. After the batch delay elapses, one email is sent summarising all collected events.
|
||||||
|
4. The **cooldown** prevents a new batch from being sent until that many seconds have passed since the last one, avoiding email floods during sustained scans.
|
||||||
|
|
||||||
|
### Configuration options
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Enabled | off | Master switch — no emails are sent when disabled. |
|
||||||
|
| SMTP host | — | Hostname of your SMTP server (e.g. `smtp.gmail.com`). |
|
||||||
|
| SMTP port | `587` | Port to connect to. |
|
||||||
|
| Use STARTTLS | on | Upgrade the connection with STARTTLS. Disable for plain SMTP or SSL-only servers. |
|
||||||
|
| SMTP username | — | Login username for SMTP authentication. |
|
||||||
|
| SMTP password | — | Login password. Stored in the config file; masked in the UI. |
|
||||||
|
| From address | — | Sender address. Defaults to the SMTP username if blank. |
|
||||||
|
| To address | — | Recipient address for alert emails. |
|
||||||
|
| Trigger ports | (all) | Comma-separated list of ports that trigger alerts. Leave blank to alert on every port. |
|
||||||
|
| Batch delay | `60` s | How long to wait and collect events before sending one digest email. |
|
||||||
|
| Cooldown | `300` s | Minimum time between batch emails. |
|
||||||
|
|
||||||
|
### Config file
|
||||||
|
|
||||||
|
Settings are saved to `email.json` (or the path given by `--email-config`) as JSON. The file is created on first save. You can edit it directly; changes take effect at the next batch send.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"smtp_host": "smtp.gmail.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_starttls": true,
|
||||||
|
"smtp_user": "you@gmail.com",
|
||||||
|
"smtp_password": "app-password-here",
|
||||||
|
"from_addr": "",
|
||||||
|
"to_addr": "alerts@example.com",
|
||||||
|
"trigger_ports": [22, 3389],
|
||||||
|
"batch_delay_seconds": 60,
|
||||||
|
"cooldown_seconds": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example digest email
|
||||||
|
|
||||||
|
```
|
||||||
|
Subject: [portspoof] 3 connections detected
|
||||||
|
|
||||||
|
portspoof alert — 3 connections
|
||||||
|
|
||||||
|
2025-06-01 14:32:07 198.51.100.42:54312 → port 22 (14 B 485353482d322e302d4f70656e5353…)
|
||||||
|
2025-06-01 14:32:09 198.51.100.42:54398 → port 23 (6 B deadbeef0102)
|
||||||
|
2025-06-01 14:32:11 203.0.113.7:61204 → port 3389 (0 B )
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## systemd service
|
||||||
|
|
||||||
|
The included `portspoof.service` file runs portspoof as a system service that starts after the network is up, restarts on failure, and removes iptables rules even if it is killed with SIGKILL.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy files
|
||||||
|
sudo cp portspoof.service /etc/systemd/system/
|
||||||
|
sudo mkdir -p /etc/portspoof /var/log/portspoof
|
||||||
|
sudo cp tools/portspoof_signatures /etc/portspoof/
|
||||||
|
sudo cp tools/portspoof.conf /etc/portspoof/
|
||||||
|
|
||||||
|
# Enable and start
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable portspoof
|
||||||
|
sudo systemctl start portspoof
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
sudo systemctl status portspoof
|
||||||
|
sudo journalctl -u portspoof -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding the admin interface to the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create credentials file
|
||||||
|
echo "admin:changeme" | sudo tee /etc/portspoof/admin.passwd
|
||||||
|
sudo chmod 600 /etc/portspoof/admin.passwd
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `/etc/systemd/system/portspoof.service` and append the flags to `ExecStart`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ExecStart=/usr/bin/python3 -m portspoof_py \
|
||||||
|
-s /etc/portspoof/portspoof_signatures \
|
||||||
|
-c /etc/portspoof/portspoof.conf \
|
||||||
|
-l /var/log/portspoof/portspoof.jsonl \
|
||||||
|
--admin-port 8080 \
|
||||||
|
--admin-passwd /etc/portspoof/admin.passwd \
|
||||||
|
--email-config /etc/portspoof/email.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart portspoof
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stopping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop portspoof
|
||||||
|
# iptables rules are removed during graceful shutdown
|
||||||
|
# ExecStopPost directives handle the SIGKILL case
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipes
|
||||||
|
|
||||||
|
### Honeypot on a dedicated interface only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 -m portspoof_py \
|
||||||
|
-p 4444 \
|
||||||
|
--iface eth1 \
|
||||||
|
-s /etc/portspoof/portspoof_signatures \
|
||||||
|
-l /var/log/portspoof/portspoof.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Only traffic arriving on `eth1` is redirected. Traffic on other interfaces (e.g. a management interface) is unaffected.
|
||||||
|
|
||||||
|
### Monitor in real time with the admin API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Poll stats every second
|
||||||
|
watch -n1 'curl -su admin:changeme http://127.0.0.1:8080/api/stats | python3 -m json.tool'
|
||||||
|
|
||||||
|
# Tail recent connections
|
||||||
|
watch -n2 'curl -su admin:changeme "http://127.0.0.1:8080/api/connections?limit=10" | python3 -m json.tool'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inspect what a specific port would send
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -su admin:changeme "http://127.0.0.1:8080/api/banner?port=22" | python3 -m json.tool
|
||||||
|
|
||||||
|
# Decode banner bytes directly
|
||||||
|
curl -su admin:changeme "http://127.0.0.1:8080/api/banner?port=22" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['banner_text'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom banners for a port range
|
||||||
|
|
||||||
|
Create `/etc/portspoof/portspoof.conf`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Make ports 8080-8090 look like nginx
|
||||||
|
8080-8090 "HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Length: 612\r\n\r\n"
|
||||||
|
|
||||||
|
# Make port 3306 look like MySQL
|
||||||
|
3306 "\x4a\x00\x00\x00\x0a\x38\x2e\x30\x2e\x33\x36\x00"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyse a scan after the fact
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Top 10 IPs
|
||||||
|
jq -r .src_ip /var/log/portspoof/portspoof.jsonl \
|
||||||
|
| sort | uniq -c | sort -rn | head -10
|
||||||
|
|
||||||
|
# All ports targeted in the last hour
|
||||||
|
jq -r 'select(.timestamp > "2025-06-01T13:00:00") | .dst_port' \
|
||||||
|
/var/log/portspoof/portspoof.jsonl | sort -n | uniq -c | sort -rn | head -20
|
||||||
|
|
||||||
|
# Check if a specific IP hit you
|
||||||
|
grep '"src_ip": "198.51.100.42"' /var/log/portspoof/portspoof.jsonl | wc -l
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `ERROR: root required for iptables management`
|
||||||
|
|
||||||
|
Run with `sudo`, or pass `--no-iptables` if you are testing locally.
|
||||||
|
|
||||||
|
### `ERROR: iptables setup failed`
|
||||||
|
|
||||||
|
Check that iptables is installed and the `nat` table is available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo iptables -t nat -L
|
||||||
|
```
|
||||||
|
|
||||||
|
On some minimal systems you may need:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install iptables # Debian/Ubuntu
|
||||||
|
sudo modprobe ip_tables
|
||||||
|
sudo modprobe iptable_nat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Banners contain `000` instead of null bytes
|
||||||
|
|
||||||
|
This is expected. The signature file uses `\0` to represent null bytes in the nmap probe format, but the banner materialiser does not interpret `\0` as a null byte — it outputs the ASCII character `0` (0x30). Use `\x00` in signature patterns or config payloads when you need an actual null byte.
|
||||||
|
|
||||||
|
### Port 22 is redirected and SSH breaks
|
||||||
|
|
||||||
|
The iptables setup adds a `RETURN` rule for port 22 before the `REDIRECT` rule, so SSH should always be exempt. If you are still locked out, verify the rule order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo iptables -t nat -L PREROUTING --line-numbers
|
||||||
|
```
|
||||||
|
|
||||||
|
The `RETURN` rules for port 22 and the listener port must appear **before** the `REDIRECT` rule. If they are in the wrong order, remove all portspoof rules and restart the service.
|
||||||
|
|
||||||
|
### `SO_ORIGINAL_DST` returns the listener port instead of the original port
|
||||||
|
|
||||||
|
This happens when there is no `REDIRECT` rule in effect (e.g. `--no-iptables` mode, or the rule is missing). The server falls back to reporting the listener port as the destination. Banners are still sent; the logged `dst_port` will be the listener port rather than the port the scanner connected to.
|
||||||
|
|
||||||
|
### Admin interface is not accessible from a remote machine
|
||||||
|
|
||||||
|
By default `--admin-host` is `127.0.0.1` (localhost only). To expose it remotely:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--admin-host 0.0.0.0 --admin-port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Then allow the port in your firewall and restrict access to trusted IPs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo iptables -A INPUT -p tcp --dport 8080 -s <your-ip> -j ACCEPT
|
||||||
|
sudo iptables -A INPUT -p tcp --dport 8080 -j DROP
|
||||||
|
```
|
||||||
|
|
||||||
|
### Startup is slow (~4–5 seconds)
|
||||||
|
|
||||||
|
The banner map is built synchronously at startup: 65,536 ports × one signature materialisation each. This is expected and is a one-time cost. After startup, every connection is served from the pre-computed map with no per-connection processing.
|
||||||
31
portspoof.service
Normal file
31
portspoof.service
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=portspoof asyncio honeypot
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 -m portspoof_py \
|
||||||
|
-s /etc/portspoof/portspoof_signatures \
|
||||||
|
-c /etc/portspoof/portspoof.conf \
|
||||||
|
-l /var/log/portspoof/portspoof.jsonl \
|
||||||
|
--admin-port 8080
|
||||||
|
User=root
|
||||||
|
TimeoutStopSec=30
|
||||||
|
KillMode=mixed
|
||||||
|
# Emergency cleanup if SIGKILL'd before graceful shutdown.
|
||||||
|
# Must mirror the exact rules added by add_rules() in iptables.py,
|
||||||
|
# including any --admin-port exempt rule (adjust ports if changed above).
|
||||||
|
ExecStopPost=/usr/sbin/iptables -t nat -D PREROUTING -p tcp --dport 22 -j RETURN
|
||||||
|
ExecStopPost=/usr/sbin/iptables -t nat -D PREROUTING -p tcp --dport 8080 -j RETURN
|
||||||
|
ExecStopPost=/usr/sbin/iptables -t nat -D PREROUTING -p tcp --dport 4444 -j RETURN
|
||||||
|
ExecStopPost=/usr/sbin/iptables -t nat -D PREROUTING -p tcp -j REDIRECT --to-port 4444
|
||||||
|
ExecStopPost=/usr/sbin/iptables -t nat -D OUTPUT -p tcp -d 127.0.0.0/8 --dport 22 -j RETURN
|
||||||
|
ExecStopPost=/usr/sbin/iptables -t nat -D OUTPUT -p tcp -d 127.0.0.0/8 --dport 8080 -j RETURN
|
||||||
|
ExecStopPost=/usr/sbin/iptables -t nat -D OUTPUT -p tcp -d 127.0.0.0/8 --dport 4444 -j RETURN
|
||||||
|
ExecStopPost=/usr/sbin/iptables -t nat -D OUTPUT -p tcp -d 127.0.0.0/8 -j REDIRECT --to-port 4444
|
||||||
|
Restart=on-failure
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ReadWritePaths=/var/log/portspoof
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
2
portspoof_py/__init__.py
Normal file
2
portspoof_py/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""portspoof_py — asyncio Python rewrite of the portspoof TCP honeypot."""
|
||||||
|
__version__ = '1.0.0'
|
||||||
5
portspoof_py/__main__.py
Normal file
5
portspoof_py/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Entry point: python -m portspoof_py"""
|
||||||
|
import sys
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
BIN
portspoof_py/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/__main__.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/__main__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/admin.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/cli.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/cli.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/config.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/iptables.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/iptables.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/logger.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/logger.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/notifier.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/notifier.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/revregex.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/revregex.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/server.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/server.cpython-312.pyc
Normal file
Binary file not shown.
BIN
portspoof_py/__pycache__/stats.cpython-312.pyc
Normal file
BIN
portspoof_py/__pycache__/stats.cpython-312.pyc
Normal file
Binary file not shown.
702
portspoof_py/admin.py
Normal file
702
portspoof_py/admin.py
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
"""
|
||||||
|
Web admin interface — zero external deps, pure asyncio HTTP/1.1.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET / HTML dashboard (auto-refreshes every 5 s)
|
||||||
|
GET /?port=N Dashboard + banner lookup result for port N
|
||||||
|
GET /api/stats JSON stats snapshot
|
||||||
|
GET /api/connections JSON recent connections (?limit=N, default 50)
|
||||||
|
GET /api/banner?port=N JSON banner for port N
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .notifier import Notifier
|
||||||
|
from .stats import Stats
|
||||||
|
|
||||||
|
# ── credentials ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_credentials(passwd_file: str) -> Tuple[str, str]:
|
||||||
|
"""Read 'username:password' from the first non-blank line of passwd_file."""
|
||||||
|
text = Path(passwd_file).read_text().strip()
|
||||||
|
if ':' not in text.splitlines()[0]:
|
||||||
|
raise ValueError(
|
||||||
|
f"{passwd_file}: expected 'username:password' on the first line"
|
||||||
|
)
|
||||||
|
username, _, password = text.splitlines()[0].partition(':')
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError(f"{passwd_file}: username and password must both be non-empty")
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
|
||||||
|
def _check_auth(raw_request: str, creds: Tuple[str, str]) -> bool:
|
||||||
|
"""Return True if the request carries valid Basic Auth credentials."""
|
||||||
|
for line in raw_request.split('\r\n')[1:]:
|
||||||
|
if line.lower().startswith('authorization:'):
|
||||||
|
value = line.split(':', 1)[1].strip()
|
||||||
|
if not value.startswith('Basic '):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(value[6:]).decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
user, sep, passwd = decoded.partition(':')
|
||||||
|
if not sep:
|
||||||
|
return False
|
||||||
|
expected_user, expected_passwd = creds
|
||||||
|
# constant-time comparison prevents timing attacks
|
||||||
|
ok_user = hmac.compare_digest(user, expected_user)
|
||||||
|
ok_passwd = hmac.compare_digest(passwd, expected_passwd)
|
||||||
|
return ok_user and ok_passwd
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_UNAUTHORIZED = (
|
||||||
|
b'HTTP/1.1 401 Unauthorized\r\n'
|
||||||
|
b'WWW-Authenticate: Basic realm="portspoof admin"\r\n'
|
||||||
|
b'Content-Type: text/plain; charset=utf-8\r\n'
|
||||||
|
b'Content-Length: 12\r\n'
|
||||||
|
b'Connection: close\r\n'
|
||||||
|
b'\r\n'
|
||||||
|
b'Unauthorized'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── config page ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CONFIG_CSS = """
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Courier New', monospace; background: #0d1117; color: #c9d1d9;
|
||||||
|
padding: 24px; font-size: 13px; line-height: 1.6; }
|
||||||
|
a { color: #58a6ff; text-decoration: none; }
|
||||||
|
h1 { color: #58a6ff; font-size: 20px; margin-bottom: 4px; }
|
||||||
|
h2 { color: #e6edf3; font-size: 14px; margin: 24px 0 12px; }
|
||||||
|
.sub { color: #8b949e; font-size: 12px; margin-bottom: 24px; }
|
||||||
|
.card { background: #161b22; border: 1px solid #30363d; border-radius: 6px;
|
||||||
|
padding: 20px; max-width: 600px; }
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
label { display: block; color: #8b949e; font-size: 11px; text-transform: uppercase;
|
||||||
|
letter-spacing: 1px; margin-bottom: 4px; }
|
||||||
|
input[type=text], input[type=number], input[type=password], input[type=email] {
|
||||||
|
width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||||||
|
padding: 7px 10px; border-radius: 4px; font-family: inherit; font-size: 13px; }
|
||||||
|
input[type=text]:focus, input[type=number]:focus,
|
||||||
|
input[type=password]:focus, input[type=email]:focus {
|
||||||
|
outline: none; border-color: #58a6ff; }
|
||||||
|
.hint { color: #8b949e; font-size: 11px; margin-top: 3px; }
|
||||||
|
.row2 { display: flex; gap: 12px; }
|
||||||
|
.row2 .field { flex: 1; }
|
||||||
|
.check-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||||
|
input[type=checkbox] { width: 15px; height: 15px; accent-color: #238636; }
|
||||||
|
.actions { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; }
|
||||||
|
button { background: #238636; border: 1px solid #2ea043; color: #fff;
|
||||||
|
padding: 7px 16px; border-radius: 4px; cursor: pointer;
|
||||||
|
font-family: inherit; font-size: 13px; }
|
||||||
|
button:hover { background: #2ea043; }
|
||||||
|
button.secondary { background: #21262d; border-color: #30363d; color: #c9d1d9; }
|
||||||
|
button.secondary:hover { background: #30363d; }
|
||||||
|
.banner { padding: 10px 14px; border-radius: 4px; margin-bottom: 16px; font-size: 12px; }
|
||||||
|
.banner.ok { background: #0f2a1a; border: 1px solid #238636; color: #3fb950; }
|
||||||
|
.banner.err { background: #2d1212; border: 1px solid #f85149; color: #f85149; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CONFIG_HTML = """\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>portspoof — email alerts</title>
|
||||||
|
<style>{css}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>portspoof admin</h1>
|
||||||
|
<p class="sub"><a href="/">← dashboard</a></p>
|
||||||
|
|
||||||
|
{banner_html}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Email alerts</h2>
|
||||||
|
<form method="post" action="/api/email/config">
|
||||||
|
|
||||||
|
<div class="check-row">
|
||||||
|
<input type="checkbox" name="enabled" id="enabled" {enabled_chk}>
|
||||||
|
<label for="enabled" style="text-transform:none;letter-spacing:0;font-size:13px;">
|
||||||
|
Enable email alerts
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row2">
|
||||||
|
<div class="field">
|
||||||
|
<label>SMTP host</label>
|
||||||
|
<input type="text" name="smtp_host" value="{smtp_host}" placeholder="smtp.gmail.com">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="max-width:90px">
|
||||||
|
<label>Port</label>
|
||||||
|
<input type="number" name="smtp_port" value="{smtp_port}" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="check-row">
|
||||||
|
<input type="checkbox" name="smtp_starttls" id="starttls" {starttls_chk}>
|
||||||
|
<label for="starttls" style="text-transform:none;letter-spacing:0;font-size:13px;">
|
||||||
|
Use STARTTLS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>SMTP username</label>
|
||||||
|
<input type="text" name="smtp_user" value="{smtp_user}" placeholder="user@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>SMTP password</label>
|
||||||
|
<input type="password" name="smtp_password" placeholder="{pw_placeholder}">
|
||||||
|
<p class="hint">Leave blank to keep the existing password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row2">
|
||||||
|
<div class="field">
|
||||||
|
<label>From address</label>
|
||||||
|
<input type="text" name="from_addr" value="{from_addr}" placeholder="portspoof@example.com">
|
||||||
|
<p class="hint">Defaults to SMTP username if blank.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>To address</label>
|
||||||
|
<input type="text" name="to_addr" value="{to_addr}" placeholder="admin@example.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Trigger ports</label>
|
||||||
|
<input type="text" name="trigger_ports" value="{trigger_ports}"
|
||||||
|
placeholder="e.g. 22, 80, 443 (blank = all ports)">
|
||||||
|
<p class="hint">Comma-separated list. Leave blank to alert on every port.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row2">
|
||||||
|
<div class="field">
|
||||||
|
<label>Batch delay (seconds)</label>
|
||||||
|
<input type="number" name="batch_delay_seconds" value="{batch_delay}" min="1">
|
||||||
|
<p class="hint">Wait this long to group events before sending.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Cooldown (seconds)</label>
|
||||||
|
<input type="number" name="cooldown_seconds" value="{cooldown}" min="0">
|
||||||
|
<p class="hint">Min time between batch emails.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="submit" formaction="/api/email/test" class="secondary">
|
||||||
|
Send test email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_config(notifier: Notifier, msg: str = '', msg_ok: bool = True) -> str:
|
||||||
|
cfg = notifier.get_config_safe()
|
||||||
|
banner_html = ''
|
||||||
|
if msg:
|
||||||
|
cls = 'ok' if msg_ok else 'err'
|
||||||
|
banner_html = f'<div class="banner {cls}">{html.escape(msg)}</div>'
|
||||||
|
|
||||||
|
ports = cfg.get('trigger_ports') or []
|
||||||
|
ports_str = ', '.join(str(p) for p in ports)
|
||||||
|
pw = cfg.get('smtp_password', '')
|
||||||
|
pw_placeholder = '(password set — leave blank to keep)' if pw else '(not set)'
|
||||||
|
|
||||||
|
return _CONFIG_HTML.format(
|
||||||
|
css=_CONFIG_CSS,
|
||||||
|
banner_html=banner_html,
|
||||||
|
enabled_chk='checked' if cfg.get('enabled') else '',
|
||||||
|
smtp_host=html.escape(cfg.get('smtp_host', '')),
|
||||||
|
smtp_port=cfg.get('smtp_port', 587),
|
||||||
|
starttls_chk='checked' if cfg.get('smtp_starttls', True) else '',
|
||||||
|
smtp_user=html.escape(cfg.get('smtp_user', '')),
|
||||||
|
pw_placeholder=html.escape(pw_placeholder),
|
||||||
|
from_addr=html.escape(cfg.get('from_addr', '')),
|
||||||
|
to_addr=html.escape(cfg.get('to_addr', '')),
|
||||||
|
trigger_ports=html.escape(ports_str),
|
||||||
|
batch_delay=cfg.get('batch_delay_seconds', 60),
|
||||||
|
cooldown=cfg.get('cooldown_seconds', 300),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_post_body(raw: str) -> dict:
|
||||||
|
"""Parse application/x-www-form-urlencoded body from a raw HTTP request."""
|
||||||
|
if '\r\n\r\n' not in raw:
|
||||||
|
return {}
|
||||||
|
body = raw.split('\r\n\r\n', 1)[1]
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
result = {}
|
||||||
|
for pair in body.split('&'):
|
||||||
|
if '=' in pair:
|
||||||
|
k, _, v = pair.partition('=')
|
||||||
|
result[unquote_plus(k)] = unquote_plus(v)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect(location: str) -> bytes:
|
||||||
|
loc = location.encode()
|
||||||
|
return (
|
||||||
|
b'HTTP/1.1 303 See Other\r\n'
|
||||||
|
b'Location: ' + loc + b'\r\n'
|
||||||
|
b'Content-Length: 0\r\n'
|
||||||
|
b'Connection: close\r\n'
|
||||||
|
b'\r\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML template ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CSS = """
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #0d1117; color: #c9d1d9;
|
||||||
|
padding: 24px; font-size: 13px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
a { color: #58a6ff; text-decoration: none; }
|
||||||
|
h1 { color: #58a6ff; font-size: 20px; margin-bottom: 4px; }
|
||||||
|
.sub { color: #8b949e; font-size: 12px; margin-bottom: 24px; }
|
||||||
|
.row { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
|
||||||
|
.card {
|
||||||
|
background: #161b22; border: 1px solid #30363d;
|
||||||
|
border-radius: 6px; padding: 16px;
|
||||||
|
}
|
||||||
|
.card.stat { min-width: 155px; flex: 1; }
|
||||||
|
.card.half { flex: 1; min-width: 260px; }
|
||||||
|
.card.full { width: 100%; }
|
||||||
|
.card h3 {
|
||||||
|
color: #8b949e; font-size: 11px;
|
||||||
|
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card .val { font-size: 28px; color: #58a6ff; font-weight: bold; }
|
||||||
|
.card .val.last { font-size: 13px; margin-top: 4px; }
|
||||||
|
h2 { color: #e6edf3; font-size: 14px; margin-bottom: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th {
|
||||||
|
color: #8b949e; font-size: 11px; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px; padding: 6px 10px; text-align: left;
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
td { padding: 5px 10px; border-bottom: 1px solid #21262d; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: #1c2128; }
|
||||||
|
.port { color: #f78166; }
|
||||||
|
.ip { color: #79c0ff; }
|
||||||
|
.ts { color: #8b949e; font-size: 11px; }
|
||||||
|
.hex { color: #a5d6ff; font-size: 11px;
|
||||||
|
max-width: 240px; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; background: #21262d;
|
||||||
|
border-radius: 3px; padding: 1px 7px; font-size: 11px;
|
||||||
|
margin-left: 6px; vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* lookup form */
|
||||||
|
.form-row { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
|
||||||
|
input[type=number] {
|
||||||
|
background: #0d1117; border: 1px solid #30363d;
|
||||||
|
color: #c9d1d9; padding: 6px 10px; border-radius: 4px;
|
||||||
|
width: 110px; font-family: inherit; font-size: 13px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #238636; border: 1px solid #2ea043;
|
||||||
|
color: #fff; padding: 6px 14px; border-radius: 4px;
|
||||||
|
cursor: pointer; font-family: inherit; font-size: 13px;
|
||||||
|
}
|
||||||
|
button:hover { background: #2ea043; }
|
||||||
|
.lookup-result {
|
||||||
|
background: #0d1117; border: 1px solid #30363d;
|
||||||
|
border-radius: 4px; padding: 10px; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.lookup-result .lbl { color: #8b949e; font-size: 11px; margin-bottom: 2px; }
|
||||||
|
.lookup-result .hexval { color: #a5d6ff; word-break: break-all; }
|
||||||
|
.lookup-result .txtval {
|
||||||
|
color: #c9d1d9; white-space: pre; overflow-x: auto;
|
||||||
|
max-height: 120px; font-size: 12px; margin-top: 6px;
|
||||||
|
border-top: 1px solid #21262d; padding-top: 6px;
|
||||||
|
}
|
||||||
|
.empty { color: #8b949e; font-style: italic; padding: 8px 0; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
_HTML = """\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>portspoof admin</title>
|
||||||
|
<style>{css}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>portspoof admin</h1>
|
||||||
|
<p class="sub">
|
||||||
|
Auto-refreshes every 5 s ·
|
||||||
|
<a href="/">reset lookup</a> ·
|
||||||
|
<a href="/config">email alerts</a> ·
|
||||||
|
<a href="/api/stats" target="_blank">JSON stats</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- stat cards -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="card stat"><h3>Total connections</h3><div class="val">{total}</div></div>
|
||||||
|
<div class="card stat"><h3>Connections / min</h3><div class="val">{cpm}</div></div>
|
||||||
|
<div class="card stat"><h3>Uptime</h3><div class="val">{uptime}</div></div>
|
||||||
|
<div class="card stat"><h3>Ports mapped</h3><div class="val">{ports}</div></div>
|
||||||
|
<div class="card stat"><h3>Last connection</h3><div class="val last">{last_seen}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- top ips / top ports / banner lookup -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="card half">
|
||||||
|
<h2>Top source IPs</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>IP</th><th>Hits</th></tr>
|
||||||
|
{top_ips_rows}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card half">
|
||||||
|
<h2>Top targeted ports</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Port</th><th>Hits</th></tr>
|
||||||
|
{top_ports_rows}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card half">
|
||||||
|
<h2>Banner lookup</h2>
|
||||||
|
<form method="get" action="/">
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="number" name="port" min="0" max="65535"
|
||||||
|
placeholder="0–65535" value="{port_q}" required>
|
||||||
|
<button type="submit">Look up</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{lookup_html}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- recent connections -->
|
||||||
|
<div class="card full">
|
||||||
|
<h2>Recent connections <span class="badge">{recent_count} shown</span></h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Timestamp (UTC)</th><th>Source IP</th><th>Src port</th>
|
||||||
|
<th>Dst port</th><th>Banner (hex)</th><th>Bytes</th></tr>
|
||||||
|
{conn_rows}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_row(cols: int, msg: str) -> str:
|
||||||
|
return f'<tr><td colspan="{cols}" class="empty">{msg}</td></tr>'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_dashboard(
|
||||||
|
stats: Stats,
|
||||||
|
cfg: Config,
|
||||||
|
port_q: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
# ── top IPs ──
|
||||||
|
top_ips = stats.top_ips()
|
||||||
|
if top_ips:
|
||||||
|
ip_rows = ''.join(
|
||||||
|
f'<tr><td class="ip">{html.escape(ip)}</td><td>{c}</td></tr>'
|
||||||
|
for ip, c in top_ips
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ip_rows = _empty_row(2, 'no data yet')
|
||||||
|
|
||||||
|
# ── top ports ──
|
||||||
|
top_ports = stats.top_ports()
|
||||||
|
if top_ports:
|
||||||
|
port_rows = ''.join(
|
||||||
|
f'<tr><td class="port">{p}</td><td>{c}</td></tr>'
|
||||||
|
for p, c in top_ports
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
port_rows = _empty_row(2, 'no data yet')
|
||||||
|
|
||||||
|
# ── banner lookup ──
|
||||||
|
lookup_html = ''
|
||||||
|
if port_q is not None:
|
||||||
|
try:
|
||||||
|
port_num = int(port_q)
|
||||||
|
if not 0 <= port_num <= 65535:
|
||||||
|
raise ValueError
|
||||||
|
banner = cfg.get_banner(port_num)
|
||||||
|
txt_preview = banner.decode('latin-1', errors='replace')
|
||||||
|
txt_safe = html.escape(txt_preview)
|
||||||
|
hex_safe = html.escape(banner.hex())
|
||||||
|
lookup_html = f"""
|
||||||
|
<div class="lookup-result">
|
||||||
|
<div class="lbl">Port {port_num} — {len(banner)} bytes</div>
|
||||||
|
<div class="hexval">{hex_safe}</div>
|
||||||
|
<div class="txtval">{txt_safe}</div>
|
||||||
|
</div>"""
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
lookup_html = '<div class="lookup-result"><div class="lbl">Invalid port number.</div></div>'
|
||||||
|
|
||||||
|
# ── recent connections ──
|
||||||
|
recent = stats.recent_connections(50)
|
||||||
|
if recent:
|
||||||
|
conn_rows = ''.join(
|
||||||
|
'<tr>'
|
||||||
|
f'<td class="ts">{html.escape(e["timestamp"][:19].replace("T", " "))}</td>'
|
||||||
|
f'<td class="ip">{html.escape(e["src_ip"])}</td>'
|
||||||
|
f'<td>{e["src_port"]}</td>'
|
||||||
|
f'<td class="port">{e["dst_port"]}</td>'
|
||||||
|
f'<td class="hex" title="{html.escape(e["banner_hex"])}">{html.escape(e["banner_hex"][:48])}{"…" if len(e["banner_hex"]) > 48 else ""}</td>'
|
||||||
|
f'<td>{e["banner_len"]}</td>'
|
||||||
|
'</tr>'
|
||||||
|
for e in recent
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn_rows = _empty_row(6, 'no connections yet')
|
||||||
|
|
||||||
|
last = stats.last_connection
|
||||||
|
last_seen = last[:19].replace('T', ' ') + ' UTC' if last else '—'
|
||||||
|
|
||||||
|
return _HTML.format(
|
||||||
|
css=_CSS,
|
||||||
|
total=stats.total,
|
||||||
|
cpm=stats.connections_per_minute(),
|
||||||
|
uptime=stats.uptime_str(),
|
||||||
|
ports=len(cfg.port_map),
|
||||||
|
last_seen=html.escape(last_seen),
|
||||||
|
top_ips_rows=ip_rows,
|
||||||
|
top_ports_rows=port_rows,
|
||||||
|
port_q=html.escape(str(port_q)) if port_q is not None else '',
|
||||||
|
lookup_html=lookup_html,
|
||||||
|
recent_count=len(recent),
|
||||||
|
conn_rows=conn_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP server ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle(
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
stats: Stats,
|
||||||
|
cfg: Config,
|
||||||
|
creds: Tuple[str, str],
|
||||||
|
notifier: Optional['Notifier'] = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(reader.read(16384), timeout=10.0)
|
||||||
|
text = raw.decode('utf-8', errors='replace')
|
||||||
|
request_line = text.split('\r\n')[0]
|
||||||
|
parts = request_line.split(' ')
|
||||||
|
if len(parts) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _check_auth(text, creds):
|
||||||
|
writer.write(_UNAUTHORIZED)
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
method = parts[0].upper()
|
||||||
|
parsed = urlparse(parts[1])
|
||||||
|
path = parsed.path.rstrip('/') or '/'
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
status = '200 OK'
|
||||||
|
ct = 'text/plain; charset=utf-8'
|
||||||
|
|
||||||
|
# ── email config routes (require notifier) ────────────────────────────
|
||||||
|
if path == '/config':
|
||||||
|
if notifier is None:
|
||||||
|
body = 'Email alerts not configured (start with --email-config FILE).'
|
||||||
|
status = '404 Not Found'
|
||||||
|
else:
|
||||||
|
msg = ''
|
||||||
|
msg_ok = True
|
||||||
|
if 'saved' in qs:
|
||||||
|
msg = 'Settings saved.'
|
||||||
|
elif 'test_ok' in qs:
|
||||||
|
msg = 'Test email sent successfully.'
|
||||||
|
elif 'test_err' in qs:
|
||||||
|
msg = f'Test failed: {qs["test_err"][0]}'
|
||||||
|
msg_ok = False
|
||||||
|
body = _render_config(notifier, msg, msg_ok)
|
||||||
|
ct = 'text/html; charset=utf-8'
|
||||||
|
|
||||||
|
elif path == '/api/email/config' and method == 'POST':
|
||||||
|
if notifier is None:
|
||||||
|
body = json.dumps({'error': 'notifier not enabled'})
|
||||||
|
ct = 'application/json'
|
||||||
|
status = '404 Not Found'
|
||||||
|
else:
|
||||||
|
form = _parse_post_body(text)
|
||||||
|
new_cfg = {
|
||||||
|
'enabled': 'enabled' in form,
|
||||||
|
'smtp_host': form.get('smtp_host', ''),
|
||||||
|
'smtp_port': int(form.get('smtp_port', 587) or 587),
|
||||||
|
'smtp_starttls': 'smtp_starttls' in form,
|
||||||
|
'smtp_user': form.get('smtp_user', ''),
|
||||||
|
'smtp_password': form.get('smtp_password', ''),
|
||||||
|
'from_addr': form.get('from_addr', ''),
|
||||||
|
'to_addr': form.get('to_addr', ''),
|
||||||
|
'trigger_ports': [
|
||||||
|
int(p.strip()) for p in form.get('trigger_ports', '').split(',')
|
||||||
|
if p.strip().isdigit()
|
||||||
|
],
|
||||||
|
'batch_delay_seconds': int(form.get('batch_delay_seconds', 60) or 60),
|
||||||
|
'cooldown_seconds': int(form.get('cooldown_seconds', 300) or 300),
|
||||||
|
}
|
||||||
|
notifier.update_config(new_cfg)
|
||||||
|
writer.write(_redirect('/config?saved=1'))
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
elif path == '/api/email/test' and method == 'POST':
|
||||||
|
if notifier is None:
|
||||||
|
body = json.dumps({'error': 'notifier not enabled'})
|
||||||
|
ct = 'application/json'
|
||||||
|
status = '404 Not Found'
|
||||||
|
else:
|
||||||
|
# Save form data first (same as /api/email/config POST)
|
||||||
|
form = _parse_post_body(text)
|
||||||
|
new_cfg = {
|
||||||
|
'enabled': 'enabled' in form,
|
||||||
|
'smtp_host': form.get('smtp_host', ''),
|
||||||
|
'smtp_port': int(form.get('smtp_port', 587) or 587),
|
||||||
|
'smtp_starttls': 'smtp_starttls' in form,
|
||||||
|
'smtp_user': form.get('smtp_user', ''),
|
||||||
|
'smtp_password': form.get('smtp_password', ''),
|
||||||
|
'from_addr': form.get('from_addr', ''),
|
||||||
|
'to_addr': form.get('to_addr', ''),
|
||||||
|
'trigger_ports': [
|
||||||
|
int(p.strip()) for p in form.get('trigger_ports', '').split(',')
|
||||||
|
if p.strip().isdigit()
|
||||||
|
],
|
||||||
|
'batch_delay_seconds': int(form.get('batch_delay_seconds', 60) or 60),
|
||||||
|
'cooldown_seconds': int(form.get('cooldown_seconds', 300) or 300),
|
||||||
|
}
|
||||||
|
notifier.update_config(new_cfg)
|
||||||
|
err = await asyncio.to_thread(notifier.send_test)
|
||||||
|
if err:
|
||||||
|
from urllib.parse import quote
|
||||||
|
writer.write(_redirect(f'/config?test_err={quote(err)}'))
|
||||||
|
else:
|
||||||
|
writer.write(_redirect('/config?test_ok=1'))
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
elif path == '/api/email/config' and method == 'GET':
|
||||||
|
if notifier is None:
|
||||||
|
body = json.dumps({'error': 'notifier not enabled'})
|
||||||
|
ct = 'application/json'
|
||||||
|
status = '404 Not Found'
|
||||||
|
else:
|
||||||
|
body = json.dumps(notifier.get_config_safe(), indent=2)
|
||||||
|
ct = 'application/json'
|
||||||
|
|
||||||
|
# ── standard routes ───────────────────────────────────────────────────
|
||||||
|
elif path == '/':
|
||||||
|
port_q = qs.get('port', [None])[0]
|
||||||
|
body = _render_dashboard(stats, cfg, port_q)
|
||||||
|
ct = 'text/html; charset=utf-8'
|
||||||
|
|
||||||
|
elif path == '/api/stats':
|
||||||
|
body = json.dumps(stats.as_dict(len(cfg.port_map)), indent=2)
|
||||||
|
ct = 'application/json'
|
||||||
|
|
||||||
|
elif path == '/api/connections':
|
||||||
|
limit = min(int(qs.get('limit', ['50'])[0]), 500)
|
||||||
|
body = json.dumps(stats.recent_connections(limit))
|
||||||
|
ct = 'application/json'
|
||||||
|
|
||||||
|
elif path == '/api/banner':
|
||||||
|
try:
|
||||||
|
port_num = int(qs.get('port', ['0'])[0])
|
||||||
|
if not 0 <= port_num <= 65535:
|
||||||
|
raise ValueError
|
||||||
|
banner = cfg.get_banner(port_num)
|
||||||
|
body = json.dumps({
|
||||||
|
'port': port_num,
|
||||||
|
'banner_hex': banner.hex(),
|
||||||
|
'banner_len': len(banner),
|
||||||
|
'banner_text': banner.decode('latin-1', errors='replace'),
|
||||||
|
})
|
||||||
|
ct = 'application/json'
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
body = json.dumps({'error': 'invalid port'})
|
||||||
|
ct = 'application/json'
|
||||||
|
status = '400 Bad Request'
|
||||||
|
|
||||||
|
else:
|
||||||
|
body = 'Not Found'
|
||||||
|
status = '404 Not Found'
|
||||||
|
|
||||||
|
body_bytes = body.encode('utf-8')
|
||||||
|
header = (
|
||||||
|
f'HTTP/1.1 {status}\r\n'
|
||||||
|
f'Content-Type: {ct}\r\n'
|
||||||
|
f'Content-Length: {len(body_bytes)}\r\n'
|
||||||
|
f'Connection: close\r\n'
|
||||||
|
f'\r\n'
|
||||||
|
).encode()
|
||||||
|
writer.write(header + body_bytes)
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
except (asyncio.TimeoutError, ConnectionResetError, BrokenPipeError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def run_admin(
|
||||||
|
stats: Stats,
|
||||||
|
cfg: Config,
|
||||||
|
host: Optional[str],
|
||||||
|
port: int,
|
||||||
|
creds: Tuple[str, str],
|
||||||
|
stop_event: asyncio.Event,
|
||||||
|
notifier: Optional['Notifier'] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Start the admin HTTP server and run until stop_event is set."""
|
||||||
|
|
||||||
|
def handler(r, w):
|
||||||
|
asyncio.create_task(_handle(r, w, stats, cfg, creds, notifier))
|
||||||
|
|
||||||
|
server = await asyncio.start_server(
|
||||||
|
handler,
|
||||||
|
host=host or '127.0.0.1',
|
||||||
|
port=port,
|
||||||
|
reuse_address=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
addrs = ', '.join(str(s.getsockname()) for s in server.sockets)
|
||||||
|
print(f'[admin] web interface → http://{addrs}')
|
||||||
|
|
||||||
|
async with server:
|
||||||
|
await stop_event.wait()
|
||||||
|
|
||||||
|
print('[admin] stopped')
|
||||||
146
portspoof_py/cli.py
Normal file
146
portspoof_py/cli.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
CLI entry point — argument parsing, signal handling, startup/shutdown.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .admin import load_credentials, run_admin
|
||||||
|
from .config import build_port_map
|
||||||
|
from .iptables import add_rules, check_root, remove_rules
|
||||||
|
from .logger import Logger
|
||||||
|
from .notifier import Notifier
|
||||||
|
from .server import run_server
|
||||||
|
from .stats import Stats
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args(argv=None):
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog='portspoof-py',
|
||||||
|
description='Python asyncio portspoof honeypot — emulates services on all TCP ports.',
|
||||||
|
)
|
||||||
|
p.add_argument('-p', '--port', type=int, default=4444,
|
||||||
|
help='Port to listen on (default: 4444)')
|
||||||
|
p.add_argument('-i', '--bind-ip', default='',
|
||||||
|
help='IP address to bind to (default: all interfaces)')
|
||||||
|
p.add_argument('-s', '--signatures', metavar='FILE',
|
||||||
|
help='Portspoof signature file (regex patterns)')
|
||||||
|
p.add_argument('-c', '--config', metavar='FILE',
|
||||||
|
help='Portspoof config file (port→payload overrides)')
|
||||||
|
p.add_argument('-l', '--log-file', metavar='FILE',
|
||||||
|
help='JSON log output file')
|
||||||
|
p.add_argument('--iface', metavar='IFACE',
|
||||||
|
help='Network interface for iptables rules (e.g. eth0)')
|
||||||
|
p.add_argument('--no-iptables', action='store_true',
|
||||||
|
help='Skip iptables rule setup/teardown')
|
||||||
|
p.add_argument('-v', '--verbose', action='store_true',
|
||||||
|
help='Print each connection to stdout')
|
||||||
|
p.add_argument('--admin-port', type=int, default=None, metavar='PORT',
|
||||||
|
help='Enable web admin interface on this port (default: disabled)')
|
||||||
|
p.add_argument('--admin-host', default='127.0.0.1', metavar='HOST',
|
||||||
|
help='Admin interface bind address (default: 127.0.0.1)')
|
||||||
|
p.add_argument('--admin-passwd', default='admin.passwd', metavar='FILE',
|
||||||
|
help='File containing "username:password" for the admin interface '
|
||||||
|
'(default: admin.passwd)')
|
||||||
|
p.add_argument('--email-config', default='email.json', metavar='FILE',
|
||||||
|
help='JSON file for email alert config (default: email.json)')
|
||||||
|
return p.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = _parse_args(argv)
|
||||||
|
|
||||||
|
if not args.no_iptables and not check_root():
|
||||||
|
print("ERROR: root required for iptables management. Use --no-iptables or run as root.",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Build port map synchronously (CPU-bound, ~65 k iterations)
|
||||||
|
print("[portspoof] building port→banner map …", flush=True)
|
||||||
|
try:
|
||||||
|
cfg = build_port_map(args.signatures, args.config)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"[portspoof] port map ready ({len(cfg.port_map)} entries)", flush=True)
|
||||||
|
|
||||||
|
# iptables setup
|
||||||
|
exempt = [args.admin_port] if args.admin_port else []
|
||||||
|
if not args.no_iptables:
|
||||||
|
exempt_desc = ', '.join(str(p) for p in [22] + exempt + [args.port])
|
||||||
|
iface_desc = args.iface or 'all'
|
||||||
|
print(f"[portspoof] adding iptables rules (listener={args.port}, exempt={exempt_desc}, iface={iface_desc})")
|
||||||
|
try:
|
||||||
|
add_rules(args.port, args.iface, exempt)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: iptables setup failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Load admin credentials before starting (fail fast if file is missing/bad)
|
||||||
|
creds = None
|
||||||
|
if args.admin_port:
|
||||||
|
try:
|
||||||
|
creds = load_credentials(args.admin_passwd)
|
||||||
|
print(f"[admin] credentials loaded from {args.admin_passwd}")
|
||||||
|
except (FileNotFoundError, ValueError) as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
f"Create {args.admin_passwd!r} with a single line: username:password",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
notifier: Notifier = Notifier(args.email_config)
|
||||||
|
print(f'[portspoof] email config: {args.email_config}')
|
||||||
|
|
||||||
|
stats = Stats()
|
||||||
|
log = Logger(args.log_file, verbose=args.verbose, stats=stats, notifier=notifier)
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _signal_handler():
|
||||||
|
if not stop_event.is_set():
|
||||||
|
print("\n[portspoof] shutdown signal received")
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
loop.add_signal_handler(sig, _signal_handler)
|
||||||
|
|
||||||
|
await log.start()
|
||||||
|
tasks = []
|
||||||
|
try:
|
||||||
|
bind_ip: Optional[str] = args.bind_ip or None
|
||||||
|
tasks.append(asyncio.create_task(
|
||||||
|
run_server(cfg, log, bind_ip, args.port, args.verbose, stop_event)
|
||||||
|
))
|
||||||
|
if args.admin_port:
|
||||||
|
tasks.append(asyncio.create_task(
|
||||||
|
run_admin(stats, cfg, args.admin_host, args.admin_port, creds, stop_event,
|
||||||
|
notifier=notifier)
|
||||||
|
))
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
finally:
|
||||||
|
await log.stop()
|
||||||
|
|
||||||
|
rc = 0
|
||||||
|
try:
|
||||||
|
asyncio.run(_run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
finally:
|
||||||
|
if not args.no_iptables:
|
||||||
|
print("[portspoof] removing iptables rules …")
|
||||||
|
try:
|
||||||
|
remove_rules(args.port, args.iface, exempt)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"WARNING: iptables cleanup error: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
return rc
|
||||||
74
portspoof_py/config.py
Normal file
74
portspoof_py/config.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Signature / config file loading and port-banner mapping.
|
||||||
|
|
||||||
|
build_port_map() is intentionally synchronous (CPU-bound, ~65 k iterations)
|
||||||
|
and must be called before asyncio.run().
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from .revregex import process_signature
|
||||||
|
|
||||||
|
_RANGE_RE = re.compile(r'^(\d+)(?:-(\d+))?\s+"(.*)"')
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_payload(line: str) -> str:
|
||||||
|
"""Extract payload between first and last '"' — mirrors C++ get_substring_value."""
|
||||||
|
first = line.index('"') + 1
|
||||||
|
last = line.rindex('"')
|
||||||
|
return line[first:last]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
port_map: Dict[int, bytes] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def get_banner(self, port: int) -> bytes:
|
||||||
|
return self.port_map.get(port % 65536, b'')
|
||||||
|
|
||||||
|
|
||||||
|
def build_port_map(
|
||||||
|
sig_file: Optional[str],
|
||||||
|
conf_file: Optional[str],
|
||||||
|
) -> Config:
|
||||||
|
"""Build the port→banner map synchronously.
|
||||||
|
|
||||||
|
1. If sig_file given, load signatures and assign one randomly to each port.
|
||||||
|
2. If conf_file given, override specific ports/ranges.
|
||||||
|
"""
|
||||||
|
cfg = Config()
|
||||||
|
|
||||||
|
# ── Step 1: signature file ───────────────────────────────────────────────
|
||||||
|
if sig_file:
|
||||||
|
sigs = Path(sig_file).read_text('latin-1').splitlines()
|
||||||
|
sigs = [s for s in sigs if s] # drop blank lines
|
||||||
|
if not sigs:
|
||||||
|
raise ValueError(f"Signature file {sig_file!r} is empty")
|
||||||
|
for p in range(65536):
|
||||||
|
cfg.port_map[p] = process_signature(random.choice(sigs))
|
||||||
|
else:
|
||||||
|
# No sig file: every port gets an empty banner (open-port mode)
|
||||||
|
for p in range(65536):
|
||||||
|
cfg.port_map[p] = b''
|
||||||
|
|
||||||
|
# ── Step 2: config file overrides ───────────────────────────────────────
|
||||||
|
if conf_file:
|
||||||
|
text = Path(conf_file).read_text('latin-1')
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
m = _RANGE_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
lo = int(m.group(1))
|
||||||
|
hi = int(m.group(2)) if m.group(2) else lo
|
||||||
|
raw = _extract_payload(line)
|
||||||
|
banner = process_signature(raw)
|
||||||
|
for p in range(lo, hi + 1):
|
||||||
|
cfg.port_map[p] = banner
|
||||||
|
|
||||||
|
return cfg
|
||||||
102
portspoof_py/iptables.py
Normal file
102
portspoof_py/iptables.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
iptables PREROUTING + OUTPUT rule management.
|
||||||
|
|
||||||
|
Two chains are managed in parallel:
|
||||||
|
|
||||||
|
PREROUTING — redirects traffic arriving from other machines (external scanners).
|
||||||
|
OUTPUT — redirects traffic from the local machine to 127.0.0.0/8, so that
|
||||||
|
local tools (nmap localhost, nc localhost PORT) also hit the honeypot.
|
||||||
|
|
||||||
|
add_rules() — idempotent setup (append)
|
||||||
|
remove_rules() — teardown (delete, check=False so missing rules don't raise)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _ipt() -> str:
|
||||||
|
return shutil.which('iptables') or '/usr/sbin/iptables'
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args: list, check: bool = True) -> None:
|
||||||
|
subprocess.run(args, check=check, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def check_root() -> bool:
|
||||||
|
return os.geteuid() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _exempt_list(listen_port: int, exempt_ports: Optional[list]) -> list:
|
||||||
|
"""Return deduped ordered list: [22, *extras, listen_port]."""
|
||||||
|
seen: set = set()
|
||||||
|
result = []
|
||||||
|
for p in [22] + (exempt_ports or []) + [listen_port]:
|
||||||
|
if p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
result.append(p)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def add_rules(
|
||||||
|
listen_port: int,
|
||||||
|
iface: Optional[str] = None,
|
||||||
|
exempt_ports: Optional[list] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Insert PREROUTING and OUTPUT NAT rules for portspoof.
|
||||||
|
|
||||||
|
PREROUTING catches external traffic (remote scanners).
|
||||||
|
OUTPUT catches loopback traffic (local testing with nmap/nc localhost).
|
||||||
|
"""
|
||||||
|
ipt = _ipt()
|
||||||
|
iface_args = ['--in-interface', iface] if iface else []
|
||||||
|
ports = _exempt_list(listen_port, exempt_ports)
|
||||||
|
|
||||||
|
# ── PREROUTING: external traffic ─────────────────────────────────────────
|
||||||
|
for port in ports:
|
||||||
|
_run([ipt, '-t', 'nat', '-A', 'PREROUTING', '-p', 'tcp']
|
||||||
|
+ iface_args + ['--dport', str(port), '-j', 'RETURN'])
|
||||||
|
|
||||||
|
_run([ipt, '-t', 'nat', '-A', 'PREROUTING', '-p', 'tcp']
|
||||||
|
+ iface_args + ['-j', 'REDIRECT', '--to-port', str(listen_port)])
|
||||||
|
|
||||||
|
# ── OUTPUT: loopback traffic (127.0.0.0/8) ───────────────────────────────
|
||||||
|
# No --in-interface here; OUTPUT applies to locally-generated packets.
|
||||||
|
for port in ports:
|
||||||
|
_run([ipt, '-t', 'nat', '-A', 'OUTPUT', '-p', 'tcp',
|
||||||
|
'-d', '127.0.0.0/8', '--dport', str(port), '-j', 'RETURN'])
|
||||||
|
|
||||||
|
_run([ipt, '-t', 'nat', '-A', 'OUTPUT', '-p', 'tcp',
|
||||||
|
'-d', '127.0.0.0/8', '-j', 'REDIRECT', '--to-port', str(listen_port)])
|
||||||
|
|
||||||
|
|
||||||
|
def remove_rules(
|
||||||
|
listen_port: int,
|
||||||
|
iface: Optional[str] = None,
|
||||||
|
exempt_ports: Optional[list] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Remove both PREROUTING and OUTPUT rules (silent if already gone)."""
|
||||||
|
ipt = _ipt()
|
||||||
|
iface_args = ['--in-interface', iface] if iface else []
|
||||||
|
ports = _exempt_list(listen_port, exempt_ports)
|
||||||
|
|
||||||
|
# ── PREROUTING ────────────────────────────────────────────────────────────
|
||||||
|
for port in ports:
|
||||||
|
_run([ipt, '-t', 'nat', '-D', 'PREROUTING', '-p', 'tcp']
|
||||||
|
+ iface_args + ['--dport', str(port), '-j', 'RETURN'],
|
||||||
|
check=False)
|
||||||
|
|
||||||
|
_run([ipt, '-t', 'nat', '-D', 'PREROUTING', '-p', 'tcp']
|
||||||
|
+ iface_args + ['-j', 'REDIRECT', '--to-port', str(listen_port)],
|
||||||
|
check=False)
|
||||||
|
|
||||||
|
# ── OUTPUT ────────────────────────────────────────────────────────────────
|
||||||
|
for port in ports:
|
||||||
|
_run([ipt, '-t', 'nat', '-D', 'OUTPUT', '-p', 'tcp',
|
||||||
|
'-d', '127.0.0.0/8', '--dport', str(port), '-j', 'RETURN'],
|
||||||
|
check=False)
|
||||||
|
|
||||||
|
_run([ipt, '-t', 'nat', '-D', 'OUTPUT', '-p', 'tcp',
|
||||||
|
'-d', '127.0.0.0/8', '-j', 'REDIRECT', '--to-port', str(listen_port)],
|
||||||
|
check=False)
|
||||||
90
portspoof_py/logger.py
Normal file
90
portspoof_py/logger.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Async JSON log writer.
|
||||||
|
|
||||||
|
log_connection() is non-blocking (puts event on a queue).
|
||||||
|
The _writer() coroutine drains the queue and writes to disk.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .notifier import Notifier
|
||||||
|
from .stats import Stats
|
||||||
|
|
||||||
|
|
||||||
|
class Logger:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
log_file: Optional[str],
|
||||||
|
verbose: bool = False,
|
||||||
|
stats: Optional['Stats'] = None,
|
||||||
|
notifier: Optional['Notifier'] = None,
|
||||||
|
):
|
||||||
|
self._log_file = log_file
|
||||||
|
self._verbose = verbose
|
||||||
|
self._stats = stats
|
||||||
|
self._notifier = notifier
|
||||||
|
self._queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._fh = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._log_file:
|
||||||
|
Path(self._log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._fh = open(self._log_file, 'a', buffering=1) # line-buffered
|
||||||
|
self._task = asyncio.create_task(self._writer())
|
||||||
|
|
||||||
|
async def _writer(self) -> None:
|
||||||
|
while True:
|
||||||
|
event = await self._queue.get()
|
||||||
|
line = json.dumps(event)
|
||||||
|
try:
|
||||||
|
if self._stats:
|
||||||
|
self._stats.record(event)
|
||||||
|
if self._notifier:
|
||||||
|
self._notifier.notify(event)
|
||||||
|
if self._fh:
|
||||||
|
self._fh.write(line + '\n')
|
||||||
|
if self._verbose:
|
||||||
|
print(line, flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[logger] write error: {exc}", file=sys.stderr)
|
||||||
|
finally:
|
||||||
|
self._queue.task_done()
|
||||||
|
|
||||||
|
def log_connection(
|
||||||
|
self,
|
||||||
|
src_ip: str,
|
||||||
|
src_port: int,
|
||||||
|
dst_port: int,
|
||||||
|
banner: bytes,
|
||||||
|
) -> None:
|
||||||
|
"""Non-blocking: enqueue a JSON log event."""
|
||||||
|
event = {
|
||||||
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'src_ip': src_ip,
|
||||||
|
'src_port': src_port,
|
||||||
|
'dst_port': dst_port,
|
||||||
|
'banner_hex': banner.hex(),
|
||||||
|
'banner_len': len(banner),
|
||||||
|
}
|
||||||
|
self._queue.put_nowait(event)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Drain the queue then cancel the writer task."""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._queue.join(), timeout=5.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if self._fh:
|
||||||
|
self._fh.close()
|
||||||
163
portspoof_py/notifier.py
Normal file
163
portspoof_py/notifier.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Email alert notifier — batched delivery.
|
||||||
|
|
||||||
|
notify() is called from the logger writer coroutine and adds matching events
|
||||||
|
to a pending list. A single flush task waits for batch_delay_seconds then
|
||||||
|
sends one digest email summarising all accumulated events. A cooldown
|
||||||
|
prevents a new batch from being scheduled sooner than cooldown_seconds after
|
||||||
|
the previous one was sent.
|
||||||
|
|
||||||
|
Config file is JSON, loaded at startup, saved via update_config().
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import smtplib
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULTS: dict = {
|
||||||
|
'enabled': False,
|
||||||
|
'smtp_host': '',
|
||||||
|
'smtp_port': 587,
|
||||||
|
'smtp_starttls': True,
|
||||||
|
'smtp_user': '',
|
||||||
|
'smtp_password': '',
|
||||||
|
'from_addr': '',
|
||||||
|
'to_addr': '',
|
||||||
|
'trigger_ports': [], # empty = alert on every port
|
||||||
|
'batch_delay_seconds': 60, # wait this long to collect events before sending
|
||||||
|
'cooldown_seconds': 300, # min time between batch emails
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Notifier:
|
||||||
|
def __init__(self, config_file: str):
|
||||||
|
self._config_file = config_file
|
||||||
|
self._config: dict = {}
|
||||||
|
self._pending: List[dict] = [] # events waiting to be sent
|
||||||
|
self._flush_task: Optional[asyncio.Task] = None
|
||||||
|
self._last_sent: float = 0.0 # monotonic time of last send
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
# ── config I/O ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
try:
|
||||||
|
self._config = {**_DEFAULTS, **json.loads(Path(self._config_file).read_text())}
|
||||||
|
except FileNotFoundError:
|
||||||
|
self._config = dict(_DEFAULTS)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[notifier] config load error: {exc}", file=sys.stderr)
|
||||||
|
self._config = dict(_DEFAULTS)
|
||||||
|
|
||||||
|
def get_config_safe(self) -> dict:
|
||||||
|
"""Config dict with the SMTP password replaced by a placeholder."""
|
||||||
|
cfg = dict(self._config)
|
||||||
|
if cfg.get('smtp_password'):
|
||||||
|
cfg['smtp_password'] = '••••••••'
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
def update_config(self, new_config: dict) -> None:
|
||||||
|
"""Merge new_config over current config, preserve password if blank, save."""
|
||||||
|
new_pw = new_config.get('smtp_password', '')
|
||||||
|
if not new_pw or new_pw == '••••••••':
|
||||||
|
new_config['smtp_password'] = self._config.get('smtp_password', '')
|
||||||
|
self._config = {**_DEFAULTS, **new_config}
|
||||||
|
Path(self._config_file).write_text(json.dumps(self._config, indent=2))
|
||||||
|
|
||||||
|
# ── notification ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def notify(self, event: dict) -> None:
|
||||||
|
"""Enqueue an event for batched delivery.
|
||||||
|
|
||||||
|
Called from the logger writer coroutine (asyncio context).
|
||||||
|
"""
|
||||||
|
cfg = self._config
|
||||||
|
if not cfg.get('enabled'):
|
||||||
|
return
|
||||||
|
if not cfg.get('smtp_host') or not cfg.get('to_addr'):
|
||||||
|
return
|
||||||
|
|
||||||
|
trigger_ports = cfg.get('trigger_ports') or []
|
||||||
|
if trigger_ports and event['dst_port'] not in trigger_ports:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._pending.append(event)
|
||||||
|
|
||||||
|
# Schedule a flush if one isn't already waiting
|
||||||
|
if self._flush_task is None or self._flush_task.done():
|
||||||
|
delay = int(cfg.get('batch_delay_seconds', 60))
|
||||||
|
self._flush_task = asyncio.create_task(self._flush_after(delay))
|
||||||
|
|
||||||
|
async def _flush_after(self, delay: int) -> None:
|
||||||
|
"""Wait for the batch window, then send the digest."""
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
if not self._pending:
|
||||||
|
return
|
||||||
|
|
||||||
|
cooldown = int(self._config.get('cooldown_seconds', 300))
|
||||||
|
if time.monotonic() - self._last_sent < cooldown:
|
||||||
|
# Still in cooldown — reschedule for the remaining time
|
||||||
|
wait = cooldown - (time.monotonic() - self._last_sent)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
|
||||||
|
events = list(self._pending)
|
||||||
|
self._pending.clear()
|
||||||
|
self._last_sent = time.monotonic()
|
||||||
|
await asyncio.to_thread(self._send_batch, events, dict(self._config))
|
||||||
|
|
||||||
|
def _send_batch(self, events: List[dict], cfg: dict) -> None:
|
||||||
|
"""Build and deliver one digest email (blocking — runs in thread pool)."""
|
||||||
|
n = len(events)
|
||||||
|
try:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg['Subject'] = f"[portspoof] {n} connection{'s' if n != 1 else ''} detected"
|
||||||
|
msg['From'] = cfg.get('from_addr') or cfg.get('smtp_user') or 'portspoof'
|
||||||
|
msg['To'] = cfg['to_addr']
|
||||||
|
|
||||||
|
lines = [f"portspoof alert — {n} connection{'s' if n != 1 else ''}\n"]
|
||||||
|
for e in events:
|
||||||
|
ts = e['timestamp'][:19].replace('T', ' ')
|
||||||
|
hex_preview = e['banner_hex'][:40] + ('…' if len(e['banner_hex']) > 40 else '')
|
||||||
|
lines.append(
|
||||||
|
f" {ts} {e['src_ip']}:{e['src_port']}"
|
||||||
|
f" → port {e['dst_port']}"
|
||||||
|
f" ({e['banner_len']} B {hex_preview})"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg.set_content('\n'.join(lines) + '\n')
|
||||||
|
|
||||||
|
host = cfg['smtp_host']
|
||||||
|
pport = int(cfg.get('smtp_port', 587))
|
||||||
|
with smtplib.SMTP(host, pport, timeout=15) as smtp:
|
||||||
|
if cfg.get('smtp_starttls', True):
|
||||||
|
smtp.starttls()
|
||||||
|
user, pw = cfg.get('smtp_user', ''), cfg.get('smtp_password', '')
|
||||||
|
if user and pw:
|
||||||
|
smtp.login(user, pw)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
print(f"[notifier] batch alert sent → {cfg['to_addr']} ({n} events)", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[notifier] send failed: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
def send_test(self) -> Optional[str]:
|
||||||
|
"""Send a test email synchronously. Returns error string or None on success."""
|
||||||
|
import datetime
|
||||||
|
fake = {
|
||||||
|
'timestamp': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
'src_ip': '127.0.0.1',
|
||||||
|
'src_port': 12345,
|
||||||
|
'dst_port': 80,
|
||||||
|
'banner_hex': 'deadbeef',
|
||||||
|
'banner_len': 4,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self._send_batch([fake], self._config)
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
return str(exc)
|
||||||
301
portspoof_py/revregex.py
Normal file
301
portspoof_py/revregex.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Faithful Python port of Revregex.cpp — three-pass regex materializer.
|
||||||
|
|
||||||
|
Pipeline: _revregexn → _fill_specialchars → _escape_hex
|
||||||
|
All passes operate on list[int] (byte values 0-255).
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
_BS = ord('\\')
|
||||||
|
_LB = ord('[')
|
||||||
|
_RB = ord(']')
|
||||||
|
_LP = ord('(')
|
||||||
|
_RP = ord(')')
|
||||||
|
|
||||||
|
|
||||||
|
def _process_bracket(chars: List[int], start: int, end: int) -> List[int]:
|
||||||
|
"""Expand bracket class chars[start..end] (inclusive).
|
||||||
|
|
||||||
|
chars[start] == '[', chars[end] == ']'.
|
||||||
|
Reads chars[end+1] for '*' or '+' quantifier.
|
||||||
|
Replicates C++ revregex_process_bracket() exactly, including quirks:
|
||||||
|
- Range `a-z` consumes an extra character (i+=3 inside for-loop that also i++)
|
||||||
|
- Only the first character_class bit wins (if/else if chain)
|
||||||
|
- Without * or +, finsize=0 → empty output
|
||||||
|
"""
|
||||||
|
nnot = False
|
||||||
|
character_set = bytearray(256)
|
||||||
|
character_class = 0
|
||||||
|
|
||||||
|
i = start + 1 # skip '['
|
||||||
|
if i < end and chars[i] == ord('^'):
|
||||||
|
nnot = True
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
while i < end:
|
||||||
|
c = chars[i]
|
||||||
|
# Escape sequence: \X — C++ does i++ inside + for-loop i++ = advance 2 total
|
||||||
|
if c == _BS and i + 1 != end:
|
||||||
|
nc = chars[i + 1]
|
||||||
|
if nc == ord('c'): character_class |= 1 << 1
|
||||||
|
elif nc == ord('s'): character_class |= 2 << 1
|
||||||
|
elif nc == ord('S'): character_class |= 1 << 3
|
||||||
|
elif nc == ord('d'): character_class |= 1 << 4
|
||||||
|
elif nc == ord('D'): character_class |= 1 << 5
|
||||||
|
elif nc == ord('w'): character_class |= 1 << 6
|
||||||
|
elif nc == ord('W'): character_class |= 1 << 7
|
||||||
|
elif nc == ord('n'): character_set[0x0a] = 1
|
||||||
|
elif nc == ord('r'): character_set[0x0d] = 1
|
||||||
|
elif nc == ord('t'): character_set[0x09] = 1
|
||||||
|
elif nc == ord('v'): character_set[0x0b] = 1
|
||||||
|
elif nc == ord('f'): character_set[0x0c] = 1
|
||||||
|
elif nc == ord('0'): character_set[0x00] = 1
|
||||||
|
else: character_set[nc] = 1
|
||||||
|
i += 2 # i++ in body + i++ in loop
|
||||||
|
# Alpha range e.g. a-z: C++ does i+=3 in body + i++ in loop = advance 4 total
|
||||||
|
elif (chr(c).isalpha()
|
||||||
|
and i + 1 < end and chars[i + 1] == ord('-')
|
||||||
|
and i + 2 < end and chr(chars[i + 2]).isalpha()):
|
||||||
|
for j in range(c, chars[i + 2] + 1):
|
||||||
|
character_set[j] = 1
|
||||||
|
i += 4 # i+=3 in body + i++ in loop (C++ quirk: skips one extra char)
|
||||||
|
# Digit range e.g. 0-9
|
||||||
|
elif (chr(c).isdigit()
|
||||||
|
and i + 1 < end and chars[i + 1] == ord('-')
|
||||||
|
and i + 2 < end and chr(chars[i + 2]).isdigit()):
|
||||||
|
for j in range(c, chars[i + 2] + 1):
|
||||||
|
character_set[j] = 1
|
||||||
|
i += 4 # same C++ quirk
|
||||||
|
elif c == ord('.'):
|
||||||
|
character_class |= 1 << 8
|
||||||
|
i += 1
|
||||||
|
elif c == ord('|'):
|
||||||
|
character_class |= 1 << 9
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
character_set[c] = 1
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Read quantifier from char immediately after ']'
|
||||||
|
endmeta = chars[end + 1] if end + 1 < len(chars) else 0
|
||||||
|
|
||||||
|
# Fill character pool from character_class — C++ uses if/else if chain,
|
||||||
|
# so only the FIRST matching class contributes.
|
||||||
|
if character_class & (1 << 1): pass # \c — nothing
|
||||||
|
elif character_class & (1 << 2): pass
|
||||||
|
elif character_class & (1 << 3): pass # \S — nothing
|
||||||
|
elif character_class & (1 << 4): # \d
|
||||||
|
for j in range(ord('0'), ord('9') + 1):
|
||||||
|
character_set[j] = 1
|
||||||
|
elif character_class & (1 << 5): pass # \D — nothing
|
||||||
|
elif character_class & (1 << 6): # \w
|
||||||
|
for j in range(ord('a'), ord('z') + 1):
|
||||||
|
character_set[j] = 1
|
||||||
|
for j in range(ord('A'), ord('Z') + 1):
|
||||||
|
character_set[j] = 1
|
||||||
|
elif character_class & (1 << 7): pass # \W — nothing
|
||||||
|
elif character_class & (1 << 8): pass # . — nothing (commented out in C++)
|
||||||
|
elif character_class & (1 << 9): pass # | — nothing
|
||||||
|
|
||||||
|
# Repetition count
|
||||||
|
if endmeta == ord('*'): finsize = random.randint(0, 9)
|
||||||
|
elif endmeta == ord('+'): finsize = random.randint(1, 9)
|
||||||
|
else: finsize = 0
|
||||||
|
|
||||||
|
# Build pool
|
||||||
|
pool: List[int] = []
|
||||||
|
for idx in range(256):
|
||||||
|
if idx in (ord('['), ord(']')):
|
||||||
|
continue
|
||||||
|
if nnot:
|
||||||
|
if character_set[idx] == 0:
|
||||||
|
pool.append(idx)
|
||||||
|
else:
|
||||||
|
if character_set[idx] != 0:
|
||||||
|
pool.append(idx)
|
||||||
|
|
||||||
|
if pool and finsize > 0:
|
||||||
|
return [random.choice(pool) for _ in range(finsize)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _revregexn(chars: List[int]) -> List[int]:
|
||||||
|
"""Pass 1 — remove unescaped (...) groups; expand [...] bracket classes.
|
||||||
|
|
||||||
|
Replicates C++ revregexn() with its goto-based repeat-until-clean loops.
|
||||||
|
"""
|
||||||
|
result = list(chars)
|
||||||
|
|
||||||
|
# ── Phase 1a: strip parentheses ─────────────────────────────────────────
|
||||||
|
while True:
|
||||||
|
action = False
|
||||||
|
in_bracket = False
|
||||||
|
for i, c in enumerate(result):
|
||||||
|
# Track bracket context (simple: checks only immediate prev char)
|
||||||
|
if c == _LB and (i == 0 or result[i - 1] != _BS):
|
||||||
|
in_bracket = True
|
||||||
|
if c == _RB and (i == 0 or result[i - 1] != _BS):
|
||||||
|
in_bracket = False
|
||||||
|
|
||||||
|
if c == _LP and not in_bracket and (i == 0 or result[i - 1] != _BS):
|
||||||
|
# Find matching ')'
|
||||||
|
in_bracket_j = False
|
||||||
|
for j in range(i, len(result)):
|
||||||
|
cj = result[j]
|
||||||
|
if cj == _LB and j > 0 and result[j - 1] != _BS:
|
||||||
|
in_bracket_j = True
|
||||||
|
if cj == _RB and j > 0 and result[j - 1] != _BS:
|
||||||
|
in_bracket_j = False
|
||||||
|
if (cj == _RP and not in_bracket and not in_bracket_j
|
||||||
|
and (j == 0 or result[j - 1] != _BS)):
|
||||||
|
result = result[:i] + result[i + 1:j] + result[j + 1:]
|
||||||
|
action = True
|
||||||
|
break
|
||||||
|
if action:
|
||||||
|
break
|
||||||
|
if not action:
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Phase 1b: expand bracket classes ────────────────────────────────────
|
||||||
|
while True:
|
||||||
|
action = False
|
||||||
|
for i, c in enumerate(result):
|
||||||
|
if c == _LB and (i == 0 or result[i - 1] != _BS):
|
||||||
|
# Find matching ']'
|
||||||
|
for j in range(i, len(result)):
|
||||||
|
cj = result[j]
|
||||||
|
if cj == _RB and (j == 0 or result[j - 1] != _BS):
|
||||||
|
expanded = _process_bracket(result, i, j)
|
||||||
|
# Skip j+1 (quantifier char) — same as C++ cutvector(j+2,...)
|
||||||
|
result = result[:i] + expanded + result[j + 2:]
|
||||||
|
action = True
|
||||||
|
break
|
||||||
|
if action:
|
||||||
|
break
|
||||||
|
if not action:
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_specialchars(chars: List[int]) -> List[int]:
|
||||||
|
"""Pass 2 — expand \\w, \\d, \\n, \\r, \\t, and bare '.' metacharacters.
|
||||||
|
|
||||||
|
Replicates C++ fill_specialchars(). Key quirks:
|
||||||
|
- \\w+ and \\d+ skip one extra char after the quantifier (C++ i+=2 + loop i++)
|
||||||
|
- .+ skips the quantifier only (C++ i++ + loop i++)
|
||||||
|
- \\0 has NO special handling → '\' falls to else, '0' falls to else
|
||||||
|
"""
|
||||||
|
result: List[int] = []
|
||||||
|
i = 0
|
||||||
|
n = len(chars)
|
||||||
|
|
||||||
|
while i < n:
|
||||||
|
c = chars[i]
|
||||||
|
prev_is_bs = i > 0 and chars[i - 1] == _BS
|
||||||
|
|
||||||
|
if (c == _BS and i + 1 < n and chars[i + 1] == ord('w') and not prev_is_bs):
|
||||||
|
result.append(ord('a') + random.randint(0, 24)) # 97 + rand()%25
|
||||||
|
i += 1 # i++ in body (now at 'w')
|
||||||
|
if i + 1 < n and chars[i + 1] in (ord('+'), ord('*')):
|
||||||
|
i += 2 # C++ quirk: i+=2 skips quantifier + one more
|
||||||
|
i += 1 # loop i++
|
||||||
|
|
||||||
|
elif (c == _BS and i + 1 < n and chars[i + 1] == ord('d') and not prev_is_bs):
|
||||||
|
result.append(ord('0') + random.randint(0, 9)) # 48 + rand()%10
|
||||||
|
i += 1
|
||||||
|
if i + 1 < n and chars[i + 1] in (ord('+'), ord('*')):
|
||||||
|
i += 2
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
elif (c == _BS and i + 1 < n and chars[i + 1] == ord('n') and not prev_is_bs):
|
||||||
|
result.append(0x0a) # '\n'
|
||||||
|
i += 2 # i++ in body + loop i++
|
||||||
|
|
||||||
|
elif (c == _BS and i + 1 < n and chars[i + 1] == ord('r') and not prev_is_bs):
|
||||||
|
result.append(0x0d) # '\r'
|
||||||
|
i += 2
|
||||||
|
|
||||||
|
elif (c == _BS and i + 1 < n and chars[i + 1] == ord('t') and not prev_is_bs):
|
||||||
|
result.append(0x09) # '\t'
|
||||||
|
i += 2
|
||||||
|
|
||||||
|
elif (c == ord('.') and i + 1 < n and not prev_is_bs):
|
||||||
|
result.append(ord('a') + random.randint(0, 24))
|
||||||
|
# C++: if str[i+1]=='+'/='*' then i++ (inner), then loop i++
|
||||||
|
# Net: .+ → advance 3 (consume '.', '+', next char)
|
||||||
|
# . → advance 2 (consume '.', next char) — BUT wait...
|
||||||
|
# Actually C++ check is: if(i<=end_offset && (str[i+1]=='+' || ...)) i++
|
||||||
|
# 'i' is at '.', so str[i+1] is char after '.'. If quantifier: i++ (to quantifier), loop i++ (past).
|
||||||
|
# No quantifier: loop i++ only (past '.').
|
||||||
|
if chars[i + 1] in (ord('+'), ord('*')):
|
||||||
|
i += 1 # inner i++ moves to quantifier
|
||||||
|
i += 1 # loop i++
|
||||||
|
|
||||||
|
else:
|
||||||
|
result.append(c)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hex(c: int) -> bool:
|
||||||
|
return (ord('0') <= c <= ord('9')
|
||||||
|
or ord('A') <= c <= ord('F')
|
||||||
|
or ord('a') <= c <= ord('f'))
|
||||||
|
|
||||||
|
|
||||||
|
def _char2hex(hb: int, lb: int) -> int:
|
||||||
|
"""Convert two hex digit ints to a byte value (replicates C++ char2hex)."""
|
||||||
|
value = 0
|
||||||
|
for ch in (hb, lb):
|
||||||
|
if ord('0') <= ch <= ord('9'):
|
||||||
|
value = (value << 4) + (ch - ord('0'))
|
||||||
|
elif ord('A') <= ch <= ord('F'):
|
||||||
|
value = (value << 4) + (ch - ord('A') + 10)
|
||||||
|
elif ord('a') <= ch <= ord('f'):
|
||||||
|
value = (value << 4) + (ch - ord('a') + 10)
|
||||||
|
else:
|
||||||
|
return value # early return on invalid hex char (C++ behaviour)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_hex(chars: List[int]) -> List[int]:
|
||||||
|
r"""Pass 3 — convert \xNN hex escapes to byte values.
|
||||||
|
|
||||||
|
Replicates C++ escape_hex(). Key quirk:
|
||||||
|
When '\' is seen but NOT followed by xNN, the '\' is silently DROPPED
|
||||||
|
(consumed by the outer if with no push, then loop advances).
|
||||||
|
"""
|
||||||
|
result: List[int] = []
|
||||||
|
i = 0
|
||||||
|
n = len(chars)
|
||||||
|
|
||||||
|
while i < n:
|
||||||
|
c = chars[i]
|
||||||
|
if c == _BS and (i == 0 or chars[i - 1] != _BS):
|
||||||
|
if (i + 1 < n and chars[i + 1] == ord('x')
|
||||||
|
and i + 2 < n and _is_hex(chars[i + 2])
|
||||||
|
and i + 3 < n and _is_hex(chars[i + 3])):
|
||||||
|
result.append(_char2hex(chars[i + 2], chars[i + 3]))
|
||||||
|
i += 4 # i+=3 in body + loop i++
|
||||||
|
continue
|
||||||
|
# else: '\' is consumed/dropped; loop advances past it
|
||||||
|
else:
|
||||||
|
result.append(c)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def process_signature(sig: str) -> bytes:
|
||||||
|
"""Run the three-pass pipeline on one signature string → banner bytes."""
|
||||||
|
try:
|
||||||
|
data = list(sig.encode('latin-1'))
|
||||||
|
data = _revregexn(data)
|
||||||
|
data = _fill_specialchars(data)
|
||||||
|
data = _escape_hex(data)
|
||||||
|
return bytes(data)
|
||||||
|
except Exception:
|
||||||
|
return b''
|
||||||
100
portspoof_py/server.py
Normal file
100
portspoof_py/server.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
asyncio TCP server — connection handler with SO_ORIGINAL_DST support.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .logger import Logger
|
||||||
|
|
||||||
|
# Linux kernel constants
|
||||||
|
SOL_IP = 0
|
||||||
|
SO_ORIGINAL_DST = 80
|
||||||
|
|
||||||
|
|
||||||
|
def get_original_dst(sock: socket.socket) -> Optional[int]:
|
||||||
|
"""Return the original destination port via SO_ORIGINAL_DST getsockopt.
|
||||||
|
|
||||||
|
Struct layout (sockaddr_in, 16 bytes):
|
||||||
|
!HH4s8x → family(2) + port(2) + addr(4) + padding(8)
|
||||||
|
Returns port number, or None on failure (e.g. no iptables REDIRECT).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = sock.getsockopt(SOL_IP, SO_ORIGINAL_DST, 16)
|
||||||
|
_family, port, _addr = struct.unpack('!HH4s8x', raw)
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle(
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
cfg: Config,
|
||||||
|
log: Logger,
|
||||||
|
verbose: bool,
|
||||||
|
) -> None:
|
||||||
|
sock = writer.get_extra_info('socket')
|
||||||
|
peername = writer.get_extra_info('peername') or ('?.?.?.?', 0)
|
||||||
|
src_ip, src_port = peername[0], peername[1]
|
||||||
|
|
||||||
|
original_port = get_original_dst(sock)
|
||||||
|
if original_port is None:
|
||||||
|
# No REDIRECT in place — use the local port as a fallback
|
||||||
|
sockname = writer.get_extra_info('sockname') or ('', 0)
|
||||||
|
original_port = sockname[1]
|
||||||
|
|
||||||
|
banner = cfg.get_banner(original_port)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"[conn] {src_ip}:{src_port} → port {original_port}"
|
||||||
|
f" banner={banner[:32].hex()}{'…' if len(banner) > 32 else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if banner:
|
||||||
|
writer.write(banner)
|
||||||
|
await writer.drain()
|
||||||
|
except (ConnectionResetError, BrokenPipeError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log.log_connection(src_ip, src_port, original_port, banner)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_server(
|
||||||
|
cfg: Config,
|
||||||
|
log: Logger,
|
||||||
|
host: Optional[str],
|
||||||
|
port: int,
|
||||||
|
verbose: bool,
|
||||||
|
stop_event: asyncio.Event,
|
||||||
|
) -> None:
|
||||||
|
"""Start the asyncio TCP server and run until stop_event is set."""
|
||||||
|
|
||||||
|
def handler(r, w):
|
||||||
|
asyncio.create_task(_handle(r, w, cfg, log, verbose))
|
||||||
|
|
||||||
|
server = await asyncio.start_server(
|
||||||
|
handler,
|
||||||
|
host=host or None,
|
||||||
|
port=port,
|
||||||
|
reuse_address=True,
|
||||||
|
backlog=256,
|
||||||
|
)
|
||||||
|
|
||||||
|
addrs = ', '.join(str(s.getsockname()) for s in server.sockets)
|
||||||
|
print(f"[portspoof] listening on {addrs}")
|
||||||
|
|
||||||
|
async with server:
|
||||||
|
await stop_event.wait()
|
||||||
|
|
||||||
|
print("[portspoof] server stopped")
|
||||||
89
portspoof_py/stats.py
Normal file
89
portspoof_py/stats.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
In-memory connection statistics.
|
||||||
|
|
||||||
|
All methods are called from the single asyncio event loop — no locking needed.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from collections import Counter, deque
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_uptime(seconds: float) -> str:
|
||||||
|
s = int(seconds)
|
||||||
|
d, s = divmod(s, 86400)
|
||||||
|
h, s = divmod(s, 3600)
|
||||||
|
m, s = divmod(s, 60)
|
||||||
|
if d:
|
||||||
|
return f'{d}d {h}h {m}m'
|
||||||
|
if h:
|
||||||
|
return f'{h}h {m}m {s}s'
|
||||||
|
if m:
|
||||||
|
return f'{m}m {s}s'
|
||||||
|
return f'{s}s'
|
||||||
|
|
||||||
|
|
||||||
|
class Stats:
|
||||||
|
def __init__(self, max_recent: int = 500):
|
||||||
|
self._start = time.monotonic()
|
||||||
|
self._total = 0
|
||||||
|
self._last_ts: str | None = None # ISO timestamp of last connection
|
||||||
|
self._recent: deque = deque(maxlen=max_recent)
|
||||||
|
self._timestamps: deque = deque() # monotonic timestamps for rolling rate
|
||||||
|
self._top_ips = Counter()
|
||||||
|
self._top_ports = Counter()
|
||||||
|
|
||||||
|
# ── write side (called from logger writer coroutine) ────────────────────
|
||||||
|
|
||||||
|
def record(self, event: dict) -> None:
|
||||||
|
self._total += 1
|
||||||
|
self._recent.append(event)
|
||||||
|
now = time.monotonic()
|
||||||
|
self._timestamps.append(now)
|
||||||
|
# Trim entries older than 60 s while we have them
|
||||||
|
cutoff = now - 60
|
||||||
|
while self._timestamps and self._timestamps[0] < cutoff:
|
||||||
|
self._timestamps.popleft()
|
||||||
|
self._last_ts = event['timestamp']
|
||||||
|
self._top_ips[event['src_ip']] += 1
|
||||||
|
self._top_ports[event['dst_port']] += 1
|
||||||
|
|
||||||
|
# ── read side (called from admin HTTP handler) ───────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return self._total
|
||||||
|
|
||||||
|
def connections_per_minute(self) -> int:
|
||||||
|
cutoff = time.monotonic() - 60
|
||||||
|
while self._timestamps and self._timestamps[0] < cutoff:
|
||||||
|
self._timestamps.popleft()
|
||||||
|
return len(self._timestamps)
|
||||||
|
|
||||||
|
def uptime_str(self) -> str:
|
||||||
|
return _fmt_uptime(time.monotonic() - self._start)
|
||||||
|
|
||||||
|
def recent_connections(self, limit: int = 50) -> List[dict]:
|
||||||
|
"""Return up to `limit` most-recent connections, newest first."""
|
||||||
|
items = list(self._recent)
|
||||||
|
return list(reversed(items[-limit:]))
|
||||||
|
|
||||||
|
def top_ips(self, n: int = 10) -> List[Tuple[str, int]]:
|
||||||
|
return self._top_ips.most_common(n)
|
||||||
|
|
||||||
|
def top_ports(self, n: int = 10) -> List[Tuple[int, int]]:
|
||||||
|
return self._top_ports.most_common(n)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_connection(self) -> str | None:
|
||||||
|
return self._last_ts
|
||||||
|
|
||||||
|
def as_dict(self, ports_mapped: int = 0) -> dict:
|
||||||
|
return {
|
||||||
|
'uptime': self.uptime_str(),
|
||||||
|
'total_connections': self.total,
|
||||||
|
'last_connection': self._last_ts,
|
||||||
|
'connections_per_min': self.connections_per_minute(),
|
||||||
|
'ports_mapped': ports_mapped,
|
||||||
|
'top_ips': [{'ip': ip, 'count': c} for ip, c in self.top_ips()],
|
||||||
|
'top_ports': [{'port': p, 'count': c} for p, c in self.top_ports()],
|
||||||
|
}
|
||||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["flit_core>=3.2"]
|
||||||
|
build-backend = "flit_core.buildapi"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "portspoof-py"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Python asyncio rewrite of the portspoof TCP honeypot"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = {text = "GPL-2.0-or-later"}
|
||||||
|
dependencies = [] # zero runtime deps — stdlib only
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
portspoof-py = "portspoof_py.cli:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Source = "https://github.com/drk1wi/portspoof"
|
||||||
1
tools/portspoof.conf
Symbolic link
1
tools/portspoof.conf
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/daprogs/claude/portspoof/tools/portspoof.conf
|
||||||
1
tools/portspoof_signatures
Symbolic link
1
tools/portspoof_signatures
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/daprogs/claude/portspoof/tools/portspoof_signatures
|
||||||
Reference in New Issue
Block a user