From 8a101892f28cd285ce359865420e19924948dfc4 Mon Sep 17 00:00:00 2001 From: DAProgs Date: Sun, 8 Mar 2026 13:28:31 -0400 Subject: [PATCH] first commit --- .gitignore | 4 + README.md | 690 +++++++++++++++++ portspoof.service | 31 + portspoof_py/__init__.py | 2 + portspoof_py/__main__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 272 bytes .../__pycache__/__main__.cpython-312.pyc | Bin 0 -> 332 bytes .../__pycache__/admin.cpython-312.pyc | Bin 0 -> 31625 bytes portspoof_py/__pycache__/cli.cpython-312.pyc | Bin 0 -> 8971 bytes .../__pycache__/config.cpython-312.pyc | Bin 0 -> 3562 bytes .../__pycache__/iptables.cpython-312.pyc | Bin 0 -> 4201 bytes .../__pycache__/logger.cpython-312.pyc | Bin 0 -> 4832 bytes .../__pycache__/notifier.cpython-312.pyc | Bin 0 -> 9152 bytes .../__pycache__/revregex.cpython-312.pyc | Bin 0 -> 12612 bytes .../__pycache__/server.cpython-312.pyc | Bin 0 -> 4887 bytes .../__pycache__/stats.cpython-312.pyc | Bin 0 -> 5539 bytes portspoof_py/admin.py | 702 ++++++++++++++++++ portspoof_py/cli.py | 146 ++++ portspoof_py/config.py | 74 ++ portspoof_py/iptables.py | 102 +++ portspoof_py/logger.py | 90 +++ portspoof_py/notifier.py | 163 ++++ portspoof_py/revregex.py | 301 ++++++++ portspoof_py/server.py | 100 +++ portspoof_py/stats.py | 89 +++ pyproject.toml | 18 + tools/portspoof.conf | 1 + tools/portspoof_signatures | 1 + 28 files changed, 2519 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 portspoof.service create mode 100644 portspoof_py/__init__.py create mode 100644 portspoof_py/__main__.py create mode 100644 portspoof_py/__pycache__/__init__.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/__main__.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/admin.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/cli.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/config.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/iptables.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/logger.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/notifier.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/revregex.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/server.cpython-312.pyc create mode 100644 portspoof_py/__pycache__/stats.cpython-312.pyc create mode 100644 portspoof_py/admin.py create mode 100644 portspoof_py/cli.py create mode 100644 portspoof_py/config.py create mode 100644 portspoof_py/iptables.py create mode 100644 portspoof_py/logger.py create mode 100644 portspoof_py/notifier.py create mode 100644 portspoof_py/revregex.py create mode 100644 portspoof_py/server.py create mode 100644 portspoof_py/stats.py create mode 100644 pyproject.toml create mode 120000 tools/portspoof.conf create mode 120000 tools/portspoof_signatures diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ba5619 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +#Password files +admin.passwd +email.json + diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b63e1f --- /dev/null +++ b/README.md @@ -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 +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 +# All ports should appear open +``` + +Stop with Ctrl-C or `kill `. 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 -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. diff --git a/portspoof.service b/portspoof.service new file mode 100644 index 0000000..ed36a79 --- /dev/null +++ b/portspoof.service @@ -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 diff --git a/portspoof_py/__init__.py b/portspoof_py/__init__.py new file mode 100644 index 0000000..34ba2fb --- /dev/null +++ b/portspoof_py/__init__.py @@ -0,0 +1,2 @@ +"""portspoof_py — asyncio Python rewrite of the portspoof TCP honeypot.""" +__version__ = '1.0.0' diff --git a/portspoof_py/__main__.py b/portspoof_py/__main__.py new file mode 100644 index 0000000..84ebc1b --- /dev/null +++ b/portspoof_py/__main__.py @@ -0,0 +1,5 @@ +"""Entry point: python -m portspoof_py""" +import sys +from .cli import main + +sys.exit(main()) diff --git a/portspoof_py/__pycache__/__init__.cpython-312.pyc b/portspoof_py/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b43556ba89b300a12aded004d96f60fac1bd6363 GIT binary patch literal 272 zcmX@j%ge<81hbB=$xH^)k3k$5V1zP0^8gvs8B!Rc7*ZIc7%Q1HnM>V(LIwFnCB+5# z`DyV5l?smF|3A(x}OJizPQPGf$KC7JGbrN`7*D{4M6<%Hmth$vK%tAZOlU zNv+5%S;_Djr0JKHenx(7s(wmhK~a8sv3_z+VrfdMKCGAP;1(m-zY;yBc zN^?@}igyY85tRGGHBjr;JV8o`hbC>fw`Tdk)uKoKYq0k-E; Ar2qf` literal 0 HcmV?d00001 diff --git a/portspoof_py/__pycache__/admin.cpython-312.pyc b/portspoof_py/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d72480373f5f6a13177c3a5b89041998f3a31ace GIT binary patch literal 31625 zcmeHwdvF}bdFRZ&uur_-0Lj4u#FKcFAPEAHNRS{YQWQl}FUk_Mzzm25fn8{JL6G3$ zjI(_iSE-e$I?GJz za=)+Vu@5Xr$#$;tM~1{qPfz#PUw{4e*WKUub?^Ulx$GRS@6UbDTXK%${ujMySB@IE z`;R=wUFSsZEGP1!VU$11v!~&#fjy09jqGVUYhq9HSu>u-QOj7y*$kfYm`1H*wzIY| z`&m1CH;+2ToM)Y5uCp$@Tf~gf%(1MqSuAWF%^u4+o5RAk(cH1Tvv~;HrF_vL;V$J5 z8A^cIkV$lY$#k|rbm2>8;uoYV9I}X6_*x`ppDh-1&X$O|XG_HfF;B`70YNN~3Xv** z$SM|o$$YjCgl@mDr7glR@pdlhgd1`BadiAjX%XI#2=5pYatzCa;@?to48$pFB*n2#cE() z!{BQe)aV%00?*{!btvnkRzmy4)R#sfQq@d;THJb_ETL9bspa^}p*=GLaxCcFKRPaO9n zd!W-U2!{`y5t@aK*O4=i9v4JUV0ggqkwu}_GZFMR%F>W51%{=7AYG8;OTsQ8P^ZCo zXxuLcyH6xSI;dxF5C%LxpCk*Te*gK2aRE>!MuWnTUlu5va6*IP8TU2^f}UU?4(E~6 zkDU;dxPZ?y9vJoq6H^cR0eCRz_4`;JmRgnuC0|fT{8anUsCUc@1b2WsLmn!9SBs83 z6{}#VSAzjku};AALu#uNb$rxx(i0r6GeoVAjZ@*C(Wv>%#Q3O0QR5ytAoaf(h*~G) zQTFm78jnLRORwKIS2S zLcoZ>yO~S?1odwr?hQ3OLf=!aH`MnLbIJ$2YTu?ILPGpV#^xzR*3B>Qa%Rw`r3!H` zaiZ}RgI4+%J{61+hn7G76nPEC=TL)b!?bbQ6f($$kSUN_o++t3q!==s=IYE-eWxUk zDAY^@s1uAyos61(S*#I;WdE2D9F~M3uN(*pKEJPVVASI~FN}J9lHeZ_5(wXPkMEQX zKy0d|6Fq7iyd|ZF>h~qUBuG^LMD!@HXEY#}0x10jzK+`r_j5lpR}f9=~feWZ7e!!Df$HxGfd0K6c}= zaCzgBXSKX#t-N!!yz^#zxcs42Q_0McSwM zapcWCz2exiV%l;SSaDH@?3wJBr57ip0EkNH2L`E8wabD4>q#H8o)qFn^uDAe!N8&O zq-YS0rQAX8xmDuLkm0lTGSQ@QcYwcIiTvqGGmC~dR2-GQcE8>@Z44RH^=Hwdmt^3% zOBpsU-dji5)FNI#3WbcKmFlMD8gV6mr#I22eMO%%jcC)5goL75OTl`tdD=3a5wZjr z1|j})sMWM}+7`^yvW(g--i@c1oLXJV)Pje>JyOIlL zYOt#~*~D|ta72$u?xOJ-Zj!IFPd$8!Nm${GJRu3*A=a5ymJtR$vh2k;cfm926@>$y zfOk;XKY{+6&=Z>O8C-VRic(HYWnz3B_RHQW4|UE?ISa8<7A0Tbhs167%oD+(#yxco z^l|FkQS+#OQj*ctgXq(NNpEmiCdx)F1D=4?u`_BL=-4SrgMJZXf%>!^x;QAY;YKC` zMoq(Gp22A5pnnWds6`Qd zQI{s_`q{TE{Z;**;Xki@u_2(u1xNtJ$7w;3X*Lvf^bZb8gXjB+BJwW8)Zj1he{q}P z?qrv(Wml|bS1eYCv+I^BmfS1ZjlbZs&2_V;h$HJ-=atT{b*(wvs}A={W&691ossyOMovJhC@AI`5{d~r3uZY{rgHNQEW-#U9Jl2^QzSGk&3xp?GuUgLX3S)JqRal&Q%^M#=aKjw^aQ0&l^Zc;jE;4?brG&i3*9a|W*9*v zZai~o9EoXoXdWN+czyeX!C{)01iKj{#uFZweCLA0okD9Hi&JM)oxMpB%T-_m*hdn%Q$+YQV^@4 zM&csIdMsZG9x57M-}prE*&zB!H+7Tec3@GZ$M`131|&H-PMcUI$4X-!@91;FWkw;D z6s;{S+c)9i`;cAZ$3*tld3!gSsg)5}?QL4?u-YW`Dz9%?lD)xXK4Cp!+MZBFR_V?m z|KJ2xiwW^+)zvSFIdlS~$)%7%`JfWRKf&g(ooYW)@z!do^*Zp&FqWeaVDrRw4Eo1A z1+_j+vVW3#n9fT>qY^mfoCittUc}PqTuCYLk@YV&RyE@LMi3bK&-og?L1_$KfEJXJ ztis)srnhUnoj^wh8!{2RA!vNDi?ZsXh`o6R33#JMmbUgi9qmj+HF?x^?IyKL6grF~ zN{^)KHBQPz0J>8(2PPnrd_W^f5NIW(;m9`0)3OtCN<(T0;!&3XaS_0HlB9z+0wnR| z;Dj6?am}<1AYL56r{-1#@y=mC^bH%(S8>#;*A#%{_K6-SCQ>Wf+Sbt)7i(IjG-;>3 ztKCkgR~%q^1ho8MU{z6YIZhhts)E84>H2GmL~3P3+#uF<$W8w9LTasAhT1%>9*y)# za$IX!t!C{*1ABL|L0HAKNs>3xQ*3Q(O+PLS?b+43b8iAWsPEJEi9g3Iu&d(WW4&jd zIeADJ4vvlX*t_Te#R)9q&6r(2XRcn2kAxFLy!m9S^DF)-*EmAYFMFjyUrN zB?T232wp zFboY@x4YThQ$30W#J;!!t*hD7)73oQV@J7{6=vxtB2807m*~BqL81Yh)dbPQZ9Ru* zY|&9i1d`(mjeY`1IqVl{3K#&X6j_17NteZH+zjeL*92%RQ%X5BM<=c z%f5I|U^BP`Bn9CR!z8Iy)e54D&8%b%ldfivHf{ZBL`Z;|*aU3ylJE^s6`-ddJ#$hR z2DMX4*+4t0qDHsCSa7_bGP!9nIRW;*tVd0|1=Vf zJ*XhMHew3}DcHB$J?7D7V|yWg5+LZ6IEjw+ApDgHKvE5q{+Lt)ib4&bjP7=~;_12A z?e5sMt9@5Ou}O$;-WvKSoS5}~+{j4CR?flEfWnjUx64}aK3u6Kach+OjoI>vfFztg zv;WkYGsjPVc79A{xu=r7#^ur7|7GJuF@Hj6r#w#CDrE*Zq*y zrik}PR!f!4W2)XVrB_@YpX^WQCew*LqDx?!LAra6OP&i7RuiP#4f=)ik~GfLI?_dN zAc*AvhG#`J+Qfvh+M9l>LZXj!%~5Vnc!3iluqDi5|$OF)bfn~*Qs~VpHgczBDKzJ z&?ma>kN?xJi)V( zA5`;ithx3@t9DBQUjWyXYCj4KanL&kSttc3CCP_{A2ke{7Y6QA&CR(%U5h4^+NG{4 zmH8s3z_72ndrm_FcQq@Y;ti8FE!lsez$T4((!4bYEjH%<>eO0W)FqTG2&d6tP}jk# z#;TNISu};ru$r2KI@}Sm>!w^G-S&jEmRHPxV@zic z5;NXVZAlk-vbUV$rY)j%M7Pk!!HTvoaUsh=-Qug|gQX@tWIOC%sy%Aj8nU7W4%EO2 zJC1HgN@p#K+g(DN;q21N2Fy&r1j~#nafKLr|>hw(fMu@iWZ5*8bvorQ6iRVDc&r5L$y1?l3#uz%0&(S z^HC#|6jOPLiyc+Xa?jxInX)L|IO<9oaqk&+2pfDR+R+Q{w&8XRHbCmN&%r)7%_sOz z=)LWC`RhE-UCn_Fk^7!;l7F2iXAdLH@^I`hG_^$e@c?xS)!KE>)^$$u!B{5uOjW?( z?87>)Us-oIbwQ~OgLzMrp5#4b43OpSAGl9uxLYMHi;urtx!`%VdbOnGR{@d&UnnTz zqb|0((Wa^s)rhav-Y`@Jna9uQ#;I=Il&uybjFQwvGt`xNG=nX!Br$5!*PUvRrUD-x zLv7R&wKdTh^*hZGqc(jO8qH9rX3@NaVOSffKI{UT(QIv)(xZ1kJk`0PMrc{0js(p| z)RCY%kdGp>Og>prr-lVB-sDFqDl>TjAfKSbkI}7%J_D*gV~AQc1nR8OtaDOOZH)ea zXGns*6oNi#k)Saem!d{|j+#c`3Kezs_da%_@95!va(jwe*tisgB~y*`oS+z!}z59G`l+bjvNcg#B$dS5+!2x@!HCDzsx83D3ot7Av zX|~8^pmfv-ACV+cp*w~AEk47)k`HlkcQSb4QjmV2C+|v4gefi?aHBH9yOJL>Ok*(i za&Xh&(>r@;Ll&G$8X=5Kq9tUyz`;Gl3KxqsZdh^0j?ZD@FwI;n3|z<%N)W{CU3bNx z4tcfN1Q!no)r_e_C&~INc*fy<07Eh+UCq!HO-?ovw>QF3P4bbmfe2MT?6lPD1&;s{ zG1ozO>@i=OrYRFygqha^^OAyabWZqQobU%FEYc?e_xS32e0QBmb`y!606Xd(Xu<*o zvgACXnBzj7Rrb=StY2_Oa|d}LWX@H6bzcOm<(u~{o(>n*T{%3vfA+<>iubD9mRrB+pY5CP zTI9d+&@E?WECYFBR?e9_^Qgj8R+9Nd+a$)K0fT0HYz%tftdV)*O(D5xpgS7Rcp^aL z)V%~?c)$!;5*~wFfb6m#SIg`a0`*9?(X!vNiD4g&XHiRG4Cx zQVfc)LMeU(8!7u>UjikM;vb?}2U*!V;hF6VRAq%UVfZIgFto~=TS4nCW+qLt+QLfG zH0&82LP|Y8Axm2avyEynhbEv+iQ8bAZCCJqr<%@Rw7(BD5-PMq9=6CTq(IEnU zvbV+Nd8wjuC$&Q3;IJ2dFAP#jk(wtFQYyx5B~hZ2Q|lm**~1!_CXv8KBiqpKJ*^#z zOMrJAaxng7_uj!4@>~KpGoShRmrY5Hq{Gt1L?E7BB6ELAfw5B<_KI)~j;Cb}5dSP;{2vc^1N(%@VYpH-zZ`Ih&#$>Az#uK216#4ebSDn4 zkNIq=enCmXTx&5xEXA_UTtf6Fq*jK5Lb4Z(UN5zG5RWJhP6Db#E_S1hD*KF~W~1;d znzlFBYa176t|Zhq!^4RtO`D5X){)aXkfcah#iJ+5Ek|{~QAv_icWoe3>YZfrSDg_U z88yDq92#_z#QRAK*HosGTUVwUHvZ z_@>bT8Zxx@-L#LVFq!5|m;wx9KpBEGc$y%f%>#`C@Gw0O*F1WlON=rhlL8@lF~}e! zHKE3JNgaM%hTEbh78>Dx1s_+x!g3^CbdM<2g(*HJCiyqAv5aO9tU#5dEOMhr*QnbV z&xGb=8!t8`AsNv-1#b{NcQAns(HP{;iizLDSdRSGyj_Na!SP0{tGpMw-Rjn=1eX`Z zjIKD${Pz7~R7aM7HD?&sK=4~myJ<|QCfVv&2j@h;8FVw~w-hcTpP9{4YOXCz`>*@Q zY@S}@&fA0$U8mV8ls_<^=ornc$ryl^7-waMl$!fy%UIp9$!3n)Sx1nhq{J z&|paFxl9k}BPCyI0VR&I(U{HYT7Iif*!@!EGYMM1&4c%o`lERFbf{>GWRvSG~*-dU^E8;w;Q4I|5Q(UEVr!WH7|01lr zq-le~g}F;=0MeRIdyZo>EmlgYHcw!k=)DoQdlIzE3LmS^hZLhHjR`Pa4+NjACgk&62G|w>m8bUnVUTcO5&~#!d zN2$I@(?23pw|gpEHv+ zP|t}SjB<&{r`t;@8epq7rm^KwNsv$ zuhSzF<&~!thS%w=P?lUA$`U!Tpp+ZgwlRfRC>Ci@#A512@KY$6&JGG%+L1~v6n_S* zv@psgyKTya*t(P`ylPa%*M`l%CQz6k*^wrlO2G%TYqmydcxPC^!CUX2KG>|46z6UY z0(owPG7|98{W?OKZO_B|TjC0J6e-;7Vj*m8AyNmg0gUH40`6Fa;Nx70dYWR==9BFvPtlImx!`^Qs zdw-n?9tUigftS?+IDeW&5IhNXdT zHm_8)E!W-Lf3yAO$ZhA*kBmsDIuFz}@E=mmryM>%_8tU1!X+t~o{ChEPQ~m*DzZT* zl|H!%^eIT851R<}$X^6Rg*Nguf+|tugB1OraQk)XvP^WLexcE&9CXQ_(;B z7uy%T%luMcx&F4Z8{{b0$Wd4f>n$>V_%Hv0{nr_zFwFTHTca7W#3p2~ll6;^RR~P& zvM0`ql_`>{@?jI3q^|>*?3~2lCrsI}JY{n^VX9IXP^Jg8e6uIdEEPQqTvoQk=UNHV za7BrN&n|t!KtQIBCzZ+$PSJ5n`a}pF^Eu7RCf}%l$n;*eZ?jMr-OA;&4K*2hc z2mYi3@Z%%Y(Y8m_cw8a#%yWXrnmfwj=CN z!_Egna-~4Z{B${R)I(PMIE0tDpl*vt=}9oftn*-QxQk^&5as|s!ZlaLFYlFXRkVB; zUB|#gOa2DY5)8O|qBdG>v+3-K59x%2D1YW2FHGfY5+_j(MJyX6C}xgHuG&JjP)e&6JXsO zHhx_K%3meCjrat)$YdceB~Ca$gBbi<>hAEJ(7L<5+#bv2a&luiyld4@3e>O9}90ixw7T)7{{OD6A~YJy#X#ND_eHs!=AiI zUjE$K`R=)9yt##lP_bD5nrq?ncx$d+ude^j`Pa@bS1cd@esj3`z{>XCx7vOz{>Awp zo?kh2dgaUutEW6G1JcT%v~p@F+;?u}(C}?}V&%eQjN>lxhYTNaI9sC6a2Ivp4;v8q zh~c>57xZ@AuXH=>v5GwfTX)ThY#{R-M&sqOafL!YQy~wj)FLY$f%j=mf*28J{Gko3LIIY z4>nyj5_Kl35u%y&Mtq~F733L8?MPTVMxBYvx2R2D1=SUkpBU3f$onwcY)>>&WA zez zvn8MW-{_6`d@1j*(YpcyLYU6-pkuG14#@a^sK9AwW7JA7%pc8)S}Gy9ztARN`J0sA zM0JvX55WKlY0M>(FU1WeXTzjI{u8ABJ?cMo5WO>8%*kctUmLqJ_BH>^;rB8!;YLOd zS|4$kG2AH;J~rcdmow(suVj2|=km5JJih1(=QhpK0U0@k*R$rc!r2uwN8iiHj^r1T zcNKD$+wT-tAZIz{EU;g(A!ot1g%=ka!}%?ntBp4rmuxrd z78=8)2WO9IZeshwMa{EEw6~7$biUU4&8~1!%j}Vu!LY65PD#areX;J%-et=h$CkR$ zrS=v@inh&-AXE_#WyEaeGW+bYSU#7Tx8~Zu>e{|I5O&qg^u3qi(rc^8p2G5v^ANhr z*;##yS$$XB9pU`lv$i{q>@|n5>JVlQzPG1q&NO#=ZeY%)9&%C%Pq^%crJ-BdJKx$q zbM)tq>}y?Dy4Ic9k<7en=dYZPICIvVwX4qBu(LjrmA{s?Z8d9KIIChkGiR>y`i}V> z;mmECOPn{HTN^1Tj}!@!hSrsa?yJFTm#=YgUlX6_wx z&XH2%J0(2sXM4z!ZjCQuvhk)x%Y`*&Mq2-O-BC`o(jZMf z?P=5L>sNTXJG8pQSGA)3JbIVhVB>6D`c=EOp2LYme7ZHe_Fb=^QK=u!C*tDg9)&VO zx^^S*G$5r}j|U4i{In?(E0e01a&0J6r74eE{A<)BWK*b|ZndxB0>245q=;Qe&!%=- zJ*FKY$H-RgYy3G<5f5u2(W&K9_CR2df-_jDeFdBcCku~2<0!ds45%-&pLNysG@ zQv)dVe~Ej=fZV0ixj`D@*oBj>azeRpfV1gBIc3vXC?^CA$_axxUjE~dUEHFE{}A|W z3F>--k$NRx;$7S-Zu9DW2oQzoEEW65gK#fZuDGk{be>od%DcdY@<#Oay_O1?yTx3s zHw9a@_)XGQid9-#mHJg0ZN=^PDWf`G#wL(!9+;x`fhp=9n4&(OBCRxN&}ws#yOuv_ zIL8edp4*F7%May`v}?49KQ-PS*%gn|LfSXEB!Q~;g`$-`Z@9Y8gp#N86LO|+yB%U< zN_#YFP#&1KNo-EZ+pOgkTV8fd7lh0syS3WHpP>RR6_Y;ctSDLAKf-4Oc#BMzP%G05Q;*D8bliVw6ON%IbZ1}bD6k|*rlb`p8tkq z1RJ5{M%oeYDM!DCv!gfT6BZTE>?skPP1=+>!M^1wRLPPCLORH8xBo=PkDt^?B^ zhUtICdMG}FN{`_l@uAHyoGyh@u{6Ps_^@h6q%CmlVs^xYH|({1T>BP(hDvn|%N|hE z(oosR>3B{pq|IFt+ObT7i5$9;R7;OptLc1+fuHacMB1qb5b`2RdDqj8RM#^6OhXnY4@t`{XR@TlYO~d%C`3kXW)5SD zV)@JTYQ^my-#le$YiSW4J2q89-ur2fi=E0)j}x2fK9q1K865eU7aq(0BCsp}M9i*Upxm0?xh{`si#|W?DFdBav}xggBLT8i#k{ z9FBm%-kVSt7)Edg!_<~D2&hMX;jj+uah|}biGcvQ9$jjJN|n;lc5xR=ID{j#Bpe*u zH&x7PMEidC>Br8oQ|G2~Hk_1(GyNYs4P=;6a4KIr4T9BC-K;#7ow$E;B;fbSG}y)W zFU!a1^*G&V#E|>wM*SeZVfh}vLq16{B&g+6bR&UV=ST&0UnmL)$~~2(HqAqfYTd1f zh#vw+lmSWlRS4vZ6#rf-Lw%8qY&Bx^yZFqIpR(T+EsD%xxpWOFn_{h5rYbrR>r z^urY}Y9i&QVwP|dsy7H#GdmMG>R_o@Sd^i#;n+FXQ8Uw%MqT|+oY?=wnIn&#I(qid z!Khi3ar$!9%+9KnX`rM{uEAlbSJ`3zGHFz)eejDKY#TWCq=T=JZ(?jbAd`5LpQqcm z>6Sx@GrR$u^$l{nCdOgrm()$}Y=|CZdBa9*Pcj+VPgXQngZHaEL(`^#BsFNJ;AX*f2On$HKe|(DEww2hd1G4|T zGnss z!MKT?$oyWh&co!_^X*@wOSS-IDyu1qb|EY;0Ug|l{I4>ESd zPt8x^jPxR$k-l2AW7ZkTE?>)buV%X!+r!y4v*vY2=Iq4RIv4n__C%byu_=?umV>~x z<5!MfJrT(*Udydo&8-UOR6`RDOuRQ$ls_9=Ty#GA~{9Z%jV10aw=DI zDi?dhIkmTPnwNTKO_Z$q)g3o>gmY?U4R>-%DD93}1B^z_?76+S9N4j4w34+gQuO4U zDN<7Vb<3T?;yGiaWXqfd$E+_5%y&mh8|Hf>1zQ*0^Zk+1y%gTI*tS}*JyKq`Y zb}QwQQHkijdu7Lg6?bpA=pZGpnKLk4eqL0wWC|BG&YA8s?7V4OY3PbHH2=Y#*Y_+R zdn-5GaB#h$X({*z7hk`4)A82naP!ey%_rYE9d39$($INRTxmFvoLYN(?5$Jbh9laW z0!e#2dQ%QJ9Jtf6^9R=NTW@ZA#~p5Ybgkv|Y6}d}Pla2a{y#?Jj>1U8j!0SgtGjRP zerfMo*^bq+9k5sN#mF42de1Xd;S<9|o&8}XYyq(<~$u7L^oOizb z%u3Ck@Rq%|vpXY&<=02&N5A1)%dT38M(~dm4fQ9t9q#e?bERD z(=^1#7OuGV-gD2Q=e;18c_GTdY4# ze7FD8wBpaz&Q`nT?p$?uhTUCnJ$}3BFzaz&Cy|huJ9ql4&qfN0u0J{d?v zLBrDi)qv=`jJLfwWq*upojKL0U z{DJfP&hU=z@YbHUUc8-ml#*9oq2wzCHMd+f>-j}58y60|+IORGare@hrM_@^`||$P z@{Vxv?wfhx{O;L9I>@!H9jjYAmM6knyTf@saY%E|-Ez5M?she-mu+2;U%hbS!s5_U zU`YyZ+qwMs>bBkCvOROwNKx^^k=sR$OXgcejq9aVEBRG0fEPC@!AME@t6OhuU9yHt zTIVdPEQ(Y%L@MhPBlmh`_2PwZ?xXka*SeQW-rBp^9j<)zBb&K0_bz8H&YLsA;$K)h zZ-`}b`P+b9LDg*Eor03<&(1%)SQIX(o3-D`ELyP4U4$`Q*b>>YZQ&r9#kZ|*b1!zj z^!b&oaEURrUoFnH-_19`+F3y$(h_mr%?*G8|5 ze$BULEnl&g$1)$lB}dI%)?xlvF<6;hHTTxDipdh4^PUr@MBsTZcFDxKDt;BafXBwq z|NM)7l40ZtwEb7HM~vyyNCrxi%@rL$IZl?e&yX(ZKYhFxR{U?-R`TlMXo*(SB>&Qh z6*oK#*f_xifBiQsIOW6Da4%NH*|z-Z=gvHgl&enRw!7ocvQ`{NPzkf^J*#uwk*@_& zAG7OT%#MP8m0Hbzie=n~Ot+lXpQPsam%v__o4>#h?BTyu_rgBzd;Ie?rr+OhNAQQ8 zmCrYFZ*SqBuQ$ED)kI-8rF*+_C%*ouh=0Dz^rK=Eg|}Ar8@M0y{PPc)erzyN*g`3P zoN;IeKL2HVPk&DSU$yRk2rqx#!uJ=N{<@X&{q^q3{&MbbO8Nd0)8CYtD7=mG{f*F@ zi_a@Brl0Jkj6dnA^fYikt>8U%rk}b^6t1R>Kds^M z^{4ebYW&ki+w*&kKkYD3{2rdddyN$CVrd@YQP=kkw*E}x`wmL;zKf@DCZ%~l#{xL- zm++|9`{lO&TI2iMS(+N2!nG_-1549R(BALhf&BY>Z7-OOcMO#Bj)|wRnNr@d*1ynU zywhex$~(Jw3U^Q~?(Ey@sWyI4V(@fwACy@sT*<=Qc?wq>DZY+J?LKI*d3GB=*h!E- z=-?^5n;?JC$q(!@e$Z7pP;L4d=5K#e1GqBvg3E$IH3CD4EE?Z5y#dt*T~KkEnJb4ub>+YYiL{O&8F2Bpj?j`Qvz{V+@4j*q-0mqzTA=Z@iQ)lG0Lo2$wq)zxRrt1%I(1~xa3Bi5k ziusD+it&o+4Yft$O43W5ydMDJ=KZ&LL>>5HELw>MJ%RIVAyj9H@;Jz8QgNM&FZXB( z$Q&C5VFs7Fgi=>fQ>Ca@KdW%uLtg#%`(U_$fZU(VaO>8rHEY?bwe01?@c({sY5Q{C zvUKz5aCzUm*28SC(v+>WZFf@({6>~5z&sn zyQH8`$VITb_m_ z`iX)!i72$8MmbTipm*uT-x*w}Am6!{n%4^JR||U8y#bZm`H2c7e3nu7IbjmNBnnSV z?Kecf{B`_xLvXyaxp^u}OG1o=ExY)cLe|8}$$Z#IOFYPYJEIv?0aOF0-^lD~v+30*#W(j&s_=hD z-hW~>{$(L=KEh)~fF*Wb&1zmv#F`ax<=k=!bI;<5W4u1Lne#2bX|l@CPbQ%bf`Qu%17#-SWc*BZZst4>TCxHt+{(EN>f46wYuU{_R};K%MFByc`N| z=jmR<)7Lr{Zm=Ba&Um|(Kd{sJc3TF8_hujs*s2rdol*F%KAO?rFZu`j`&oO++wn=R zqZ?_)xe~%KpNw;s)?LW}%PjHJm zPX{4z#!fL+jrR7G;-^S@n5g*>o$$mppX@qqX-VzZ?6 z^=9~p@>^r2d3?^o=@^H{QUUg*^e$a|eH(kddFC#~#+oeVd`$iM%viBI%QWMN9pOEE zwrR%s(Gg=QZ&`Rc#^JHl%N{pH_IPLTE=9&V9enNr3dUox>MlKEGh8OYNl6G@8rZXSkTJZvtu?E zv?F#dZ*Fj|_{u{u2aAHga?#w>YNk8Jz@oca?02)+C$Jw%%PQlyFeLG0IN}NL`1Q~B tD~+)Xdb4tl%$SWr$pughmITGuUGuJ(i{3Kz(g-Y;4Ho;yIS4UM`F|<>wEX}8 literal 0 HcmV?d00001 diff --git a/portspoof_py/__pycache__/cli.cpython-312.pyc b/portspoof_py/__pycache__/cli.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..154d5249a540520128f45e7f2307a284b4609e4f GIT binary patch literal 8971 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 Q^$JDVE=Y@r)apS0586N<0RR91 literal 0 HcmV?d00001 diff --git a/portspoof_py/__pycache__/config.cpython-312.pyc b/portspoof_py/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..919573b3344b8d543f4626e98752d806675714ba GIT binary patch literal 3562 zcmahLTWlN0aqq!5r4uE~@UQa+CDi@HA-NCh0&rw4PPR45lth2h(e19~JEO+`7xBf`(^@h)nwICz6| zzfzqzL?j$0Qbxp)55!a#5qb0y!qE>9?zq9bR5!Mdree600PSx0#!?FklUT-&yWn(p ziBH7lZ-&om*}Q5MDUp+M+Q?_LtenwwBI|~VwR~1q^H?qz)H*Vw=JSNgIkiv#{BSrt zQ`B^Cx*j!+51i>csqnuOHJ#lY1+Pw zMs%FuZ1Hu~nuR@aQcGKQaJt}1a+hfkwqpfqq={)NruXLgJ>QAHgAXU-W5Ie93xH)*MsttuG71=&^DKw?dHAkyK*vr# zG1_z$zKqITncsuXqJ&VK2EwTe7FE-hTu@8QAfOcMJRx~75d~#2t$_wCd1zor{$k;} zoYN>Zs3}hzJ}hU6rI^JT)1qK5<(#S)$?!vNfChmr=*Xw#F}VQGbJm1FncFi}0%LeO za5b5*vMfLRW^&fZkt9|@e_1mLN-tuPWQI2jhLKSUrN=*9HySRK>@J144^XxrKzo2^ zH~h^cObh5vwCl>L#Zxu&(sW&2kM3WX+5pU}i?7y&Mznunaw8(ud^Nll*}Eq0-GU=1 z%_eV%+rZW&0LzFV2t<_P-(=w=5b=|i2~s}dCjk=7@GKxm2=gak#R3Un5wIfmK}dv4 z{zSm`PdHHk{vu(LQH#2zWWWRlE!jcO*=>XnKGr?VPb*B>%JE$mmHAfW14l7E| zz(t+GQAPP)QPtfY${uWs7{bjFkFpGD3y=kD-%QCOrY$K7$3wV7GAF6+DP43JHFnzuOXgjikxq>ZZ;7tE5!$Fam01b z+d+tHg(WXL#9Yt;A2Sb=&N0tvn3s{&idrvgvIwL1&u%E*i1IiH7XukFa43$(Ew z)Z!_Wg-LWpO!HZAmUn@&B9=vyyX-HEOZ?@Kh$;eL1XHr0-navU93Sym__5&K|2}6i zZ*v9~J?tcU7Zg_USsmVSCS@35L{uS{& zN`#8P1y(0nPas>f#OWEn_`%h7*YIdJpqHS3fYKr#9hP6o$X?vaS?wIjKkM`!4Cyu| zrc>vj&;vp?@hwm&^;&WxXaX-YXWW&3if1&!4~cHEbxMzP@3)kFs!0u+riypYahp-gC5)8^n;utg@3 zTjjZ&&FhBE&uX?n)$_LBwUWuQ2(wCSxK*X|@lKD90~vEU^CBeB1@xdBMPirY3lsPJ zDA=_r_0(Rz^xiFLXk8jw4K<`=x1<-=r575~SXJEEyZ9*9f z+1+2ixO!n#|1`1o%w(f`YArgo8QWPaUhBV)xX=_=9pBuS{FVRL9lz}O6gQrmXzZJ; zPF#6y@wIyFN7MHNM)Dwty8FBn1J&`3eFxekO&}S#7ev7r6YrHv7jH@Nbt%5O8%iEX zwPV-v^wM-=$D#Wu8cI|rH+uS(k1rj+cA`3YyQi-{`jfE_$5ux^+ne0z+O^rWds$kN zR>Vfvq3ZbUo*fL=-CI+avrE~!dTs7nvU+mk(BRMC{`l?HH-477ez3iDJ$ds?_2g#v zz{+T&J6@gG>>XS&8@-3CFK+hksUNG;mGKYHHF^i((H)5zw=oc}3Cq!?X#I_=61=h_ z(d+~Hn*As$Eu8w#{coVIL6cR5-vnM95dXl5FeiHKfTCa{4W;%u*s^&_>`1FuCMIRY z&8^#k0<_DzHbWh^^;;#@*gGYhvU+9*DLF?;mRxWhHIO!P1*ooclx+&&p^n$Np3i)O z4zh`LD~{hh44!bihHueRfCFDa=KC;#F*xoX@8iU+DB?Q*ib9{G_}|d3&(VQ@qMNw%F3_M117myvU6*?C6#NpH1hpGr>t4ojYsh cVy-Cwd`If53yYPe58$RB`J)d4068-L7s$m+lK=n! literal 0 HcmV?d00001 diff --git a/portspoof_py/__pycache__/iptables.cpython-312.pyc b/portspoof_py/__pycache__/iptables.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e06b106c6db81b31d319ec5d54a0f6c890420b5 GIT binary patch literal 4201 zcmb^!OKcR$wW>eknVye-;F#a00t@zlKg%ZyvCS$hW5P!a;Fyb()u`Q7o@p3&kE(kB zTZ80CE5&4ya5jfNM=EBHHV@N@l=|YYd6+lB36dGVq@G8$8)ZJeT-6(+8GYTb0Up-8{(P zOq+!&pPcLYeZ;X|uE>?kW8+C;=7Cer_JJECIoDw`1y^Xvq%^iq~*KI;RWGwa5wvv432(ofr-CzK}- zA=Gtxe8NrJV@cyH@mkYK9E2|yeA@*j1|be7a>o*odQ)K{zKs*meTsZ|3)OaAsn1@H z6tC3ax3QW``mLO8VU}@=exc~Y%?+ij=LIS+I)E7mOo{FZISAoVL8^h_9WE{-?&5Vo z2N%*3ze)c(y>Mc&xH|rYHYD$20jSXg3n~74F!?U?t<=aUzR`;im`+J^!2sk`TrX(J z!V@dnl#4(okE)7hNUdeK>-t;>w+B8?NGdcQzdv+$=;M*aqmQC{*R{P>ZAbIA2TwdB zY#GGZZFus&TzFe_uK^D!{3kGJ7QQa%9VLtIKrqeX60W|uESd$}63SvRrqG8;gzji1 zt)$$<9=;bYDc5VZ0dS+0wEDZnsH9#OHh-lxW86EU0hUnD?8!0dF@!Q&EHFx3P8q~7 zBJMXtetGOzA2|#$ikn`a+^gOB!-225;DDqWsNm#*3UMF^)%;xN-2mbudIy@vm?PZM zE9U(G7d#=Eh!nL)5zEwcC(h&h0r*4sylc?Sp?|k^{$8I;mE)~*XUmDU`SFEcJ>J=~ zH1;UoyL@r^)T8*xuaT(b66P8OJ7pCIgVi*@K@VP|tcZ68?T%j3 z!D>A)3;7K@lvJuv6;h&3L$oE^QqqKVY1nU(M9`H&bqmr6HL?<3-@)i!QbXU#EkdI- z*2EFj>0jk0B|NZ=gmEDuP0-diNEfej2FVV-RCcefH1i9glIpIj5Qq zttZ3u>1vwx$dt=TBZxv063?kAsavKG{_K%PYOQDDUXg%&NGr{$wvlY>WO>&uOq$k> zMgs4xB}?Y{A`{)R{TvY~bFev2%Ct?$W}?5}cHkefx_D!oNRPy6d@$6T8d%4n0(tQ!A0x6Kmb;dp{7HXYe-&M^d<= z3i=meq(|lt%=-)17E?=otJ*4CkBpR~I|0?`D(~92IJ-RjnYwoI$;or;-RH&b1^fbG z{UWXyH7PEE-n4^p|$$%ioLdD?REvuJ}=L{AkPLiTh8uXPOTm+8;OPZ zqQ5k;y5}?N^CM5*PXn28MUd%NaW#cCVgo3x5nDd`|HKxN5&q}cLZc63^_T@-3>o1q z;!B(+wvR7cIm#Gwa2rWmWsGTqVt@Mx7~jh8Ue9YKe90+7x0CJ_PBR_J&e_vrvKS?k zP>?qVkEVcAEjfEVJ1=TkdkTtR&P+N(vQYmf2RK{~@k646V2{ATQyhv8#KAm>3uoa+ zMAs*}e(267v8;;tfY=!nov3N}QPCZPF3pdN>5rlFzU7Ru=IWV`0L}%0x4-I-vA6a| zRp0%6-0@jw_kVOHvb&-JgBQm4@W*GZJHF8sy{qPt-;(1^4*7=D-{_HWCP=M!NuNw& zC}Y#oDkjgOoeO8~o~r0#0a3bh&*F*S4OAo&lu03DBO)OwTqoa) XyCgM?4Pux#(TbHIeG!3~)b@V?^1S}a literal 0 HcmV?d00001 diff --git a/portspoof_py/__pycache__/logger.cpython-312.pyc b/portspoof_py/__pycache__/logger.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..672aa9797310fc38f5995f0b1d074658c0adbf1e GIT binary patch literal 4832 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 VDYxrWuH(PXBMbv62G-`J{|Dh4{U`tc literal 0 HcmV?d00001 diff --git a/portspoof_py/__pycache__/notifier.cpython-312.pyc b/portspoof_py/__pycache__/notifier.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c5d6ec75ab0cf03462879f51031a1578b54b3ca GIT binary patch literal 9152 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 literal 0 HcmV?d00001 diff --git a/portspoof_py/__pycache__/server.cpython-312.pyc b/portspoof_py/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..684225f876384c3f3be6672477eef0ce1d235b93 GIT binary patch literal 4887 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 literal 0 HcmV?d00001 diff --git a/portspoof_py/__pycache__/stats.cpython-312.pyc b/portspoof_py/__pycache__/stats.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..32c932af122f0dc17be389d89c04034fedec2001 GIT binary patch literal 5539 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 literal 0 HcmV?d00001 diff --git a/portspoof_py/admin.py b/portspoof_py/admin.py new file mode 100644 index 0000000..dcdfc05 --- /dev/null +++ b/portspoof_py/admin.py @@ -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 = """\ + + + + + + portspoof — email alerts + + + +

portspoof admin

+

← dashboard

+ + {banner_html} + +
+

Email alerts

+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +

Leave blank to keep the existing password.

+
+ +
+
+ + +

Defaults to SMTP username if blank.

+
+
+ + +
+
+ +
+ + +

Comma-separated list. Leave blank to alert on every port.

+
+ +
+
+ + +

Wait this long to group events before sending.

+
+
+ + +

Min time between batch emails.

+
+
+ +
+ + +
+
+
+ + +""" + + +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'' + + 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 = """\ + + + + + + + portspoof admin + + + +

portspoof admin

+

+ Auto-refreshes every 5 s · + reset lookup · + email alerts · + JSON stats +

+ + +
+

Total connections

{total}
+

Connections / min

{cpm}
+

Uptime

{uptime}
+

Ports mapped

{ports}
+

Last connection

{last_seen}
+
+ + +
+
+

Top source IPs

+ + + {top_ips_rows} +
IPHits
+
+ +
+

Top targeted ports

+ + + {top_ports_rows} +
PortHits
+
+ +
+

Banner lookup

+
+
+ + +
+
+ {lookup_html} +
+
+ + +
+

Recent connections {recent_count} shown

+ + + + {conn_rows} +
Timestamp (UTC)Source IPSrc portDst portBanner (hex)Bytes
+
+ + +""" + + +def _empty_row(cols: int, msg: str) -> str: + return f'{msg}' + + +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'{html.escape(ip)}{c}' + 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'{p}{c}' + 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""" +
+
Port {port_num} — {len(banner)} bytes
+
{hex_safe}
+
{txt_safe}
+
""" + except (ValueError, TypeError): + lookup_html = '
Invalid port number.
' + + # ── recent connections ── + recent = stats.recent_connections(50) + if recent: + conn_rows = ''.join( + '' + f'{html.escape(e["timestamp"][:19].replace("T", " "))}' + f'{html.escape(e["src_ip"])}' + f'{e["src_port"]}' + f'{e["dst_port"]}' + f'{html.escape(e["banner_hex"][:48])}{"…" if len(e["banner_hex"]) > 48 else ""}' + f'{e["banner_len"]}' + '' + 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') diff --git a/portspoof_py/cli.py b/portspoof_py/cli.py new file mode 100644 index 0000000..a65cd9e --- /dev/null +++ b/portspoof_py/cli.py @@ -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 diff --git a/portspoof_py/config.py b/portspoof_py/config.py new file mode 100644 index 0000000..62f7049 --- /dev/null +++ b/portspoof_py/config.py @@ -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 diff --git a/portspoof_py/iptables.py b/portspoof_py/iptables.py new file mode 100644 index 0000000..95cea26 --- /dev/null +++ b/portspoof_py/iptables.py @@ -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) diff --git a/portspoof_py/logger.py b/portspoof_py/logger.py new file mode 100644 index 0000000..5873070 --- /dev/null +++ b/portspoof_py/logger.py @@ -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() diff --git a/portspoof_py/notifier.py b/portspoof_py/notifier.py new file mode 100644 index 0000000..76de298 --- /dev/null +++ b/portspoof_py/notifier.py @@ -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) diff --git a/portspoof_py/revregex.py b/portspoof_py/revregex.py new file mode 100644 index 0000000..c68f744 --- /dev/null +++ b/portspoof_py/revregex.py @@ -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'' diff --git a/portspoof_py/server.py b/portspoof_py/server.py new file mode 100644 index 0000000..6417666 --- /dev/null +++ b/portspoof_py/server.py @@ -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") diff --git a/portspoof_py/stats.py b/portspoof_py/stats.py new file mode 100644 index 0000000..26cd1f6 --- /dev/null +++ b/portspoof_py/stats.py @@ -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()], + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d9428e5 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/tools/portspoof.conf b/tools/portspoof.conf new file mode 120000 index 0000000..56c455f --- /dev/null +++ b/tools/portspoof.conf @@ -0,0 +1 @@ +/home/daprogs/claude/portspoof/tools/portspoof.conf \ No newline at end of file diff --git a/tools/portspoof_signatures b/tools/portspoof_signatures new file mode 120000 index 0000000..cb0a238 --- /dev/null +++ b/tools/portspoof_signatures @@ -0,0 +1 @@ +/home/daprogs/claude/portspoof/tools/portspoof_signatures \ No newline at end of file