A low-interaction honeypot with a live attack map, weekly reports and a public blocklist. A small Rust sensor pretends to be an SSH server and a forgotten nginx box. Bots find it on their own. Every login attempt and web probe goes to a Cloudflare Worker, and xivlabs.tech shows them on a world map as they happen.
bots ──▶ :22 / :80 ──▶ hive-sensor (Rust, VM) ──1 signed request/min──▶ hive Worker (Rust/WASM) ──▶ D1
counts every event, stats snapshot + 1 snapshot row
hourly buckets on disk 10 newest events + newest 500 events
▲
xivlabs.tech ── GET /stats, /recent (cached 30s)
- Nobody ever gets in. Every SSH login is rejected, so there's no shell, channel or command execution. The HTTP side never reads request bodies and always returns the same static page.
- Bounded: at most 256 open connections (10 per IP), 30–60s session limits, capped string lengths, and an in-memory queue that drops the oldest events when it's full.
- Sandboxed: runs as an unprivileged
hiveuser under systemd with a read-only filesystem, no new privileges and a syscall filter (systemd-analyze securityexposure: 1.5). It getsCAP_NET_BIND_SERVICEonly to bind ports 22 and 80. - Isolated: runs on its own VM, not on a home network or a machine that holds anything else.
- Privacy: the live map and the public API only show masked networks (
203.0.113.x) and countries. Full addresses leave the VM only for the blocklist: addresses that attacked the honeypot at leastHIVE_BLOCKLIST_MINtimes in the last 7 days are sent to the Worker once an hour, kept in one D1 row behind/export, and published every Monday in hive-blocklist. Private and reserved ranges and anything inHIVE_IGNORE_IPSare never listed.
The Worker runs on Cloudflare's free plan, where D1 allows 100k rows written per day across the whole account. A public port 22 can draw tens of thousands of attempts a day, so storing one row per attempt would use that up, and break other projects sharing the account.
So the sensor counts every event in memory instead. It keeps hourly buckets for 7 days, saves them to
stats.json so restarts don't lose history, and caps distinct keys per bucket so memory stays bounded.
Once a minute it sends a single signed request with the stats snapshot and the 10 newest events, and once an
hour a second one with the blocklist. The Worker
upserts one snapshot row, inserts those events and prunes the feed back to 500 rows, and the blocklist is one more upsert an hour. That's at most about
21 rows written per minute (~30k a day) however hard the honeypot is hit. /stats reads one row and
/recent reads only the rows it returns, both behind a 30s edge cache.
| Path | What |
|---|---|
sensor/ |
The Rust honeypot: SSH (russh), HTTP (hyper), GeoLite2 lookups, signed shipping |
sensor/hive-sensor.service |
systemd unit |
worker/ |
Cloudflare Worker (Rust): POST /ingest, GET /stats, GET /recent |
test/worker.sh |
Tests for the Worker's auth, validation, IP masking, CORS and export |
| Endpoint | |
|---|---|
POST /ingest |
Sensor only. Body is {sent_at, events[≤50], stats?}, signed with X-Hive-Signature: hex(HMAC-SHA256(secret, body)). Requests more than 5 min off the Worker's clock are rejected. The snapshot is size-capped and cleaned before it's stored. |
GET /recent?limit=50 |
Latest events (max 100) |
GET /export |
Needs Authorization: Bearer <HIVE_EXPORT_TOKEN>. The latest snapshot plus the blocklist (ip, ssh, http, last_seen) and when it was last updated. Used by hive-blocklist's weekly Action. |
GET /stats |
The latest snapshot: 24h/7d totals, unique sources, top usernames, passwords, paths, countries and user agents, and map points |
The public endpoints are cached at the edge for 30s and only send CORS headers to origins in ALLOWED_ORIGINS.
cd worker
wrangler d1 create hive-db # put the id in wrangler.toml
wrangler d1 execute hive-db --remote --file=schema.sql
openssl rand -hex 32 # the shared secret, keep it for the sensor too
wrangler secret put HIVE_SECRET
openssl rand -hex 32 # a second token, for hive-blocklist's weekly Action
wrangler secret put HIVE_EXPORT_TOKEN
wrangler deploy # serves on hive.xivlabs.tech- Create an Always Free VM running Ubuntu 24.04 or 22.04: an Ampere A1 (1 OCPU / 6 GB) if you have ARM allowance left, or an AMD VM.Standard.E2.1.Micro (1 GB is plenty; use the x86 binary).
- Create a Network Security Group with ingress TCP 22 and 80 from
0.0.0.0/0and TCP 2200 from your own IP, and attach it to the VM's VNIC. An NSG only affects this VM, while a subnet security list would also open the ports on every other VM in the subnet. - Move your real SSH to port 2200 before anything else. Oracle's Ubuntu images also firewall
with iptables, so open the ports there too:
Check that
# Insert the rules just before the image's catch-all REJECT (its position varies between images). reject=$(sudo iptables -L INPUT --line-numbers -n | awk '/REJECT/ {print $1; exit}') for p in 80 2200; do sudo iptables -I INPUT "$reject" -p tcp --dport $p -m state --state NEW -j ACCEPT; done sudo iptables -L INPUT -n --line-numbers # 22 (already allowed by the image), 2200 and 80 must be above REJECT sudo netfilter-persistent save sudo sed -i 's/^#\?Port .*/Port 2200/' /etc/ssh/sshd_config # 24.04 starts sshd through ssh.socket, 22.04 through ssh.service. Never start the socket on 22.04: # it would listen on port 22 and take it from the honeypot. if systemctl is-enabled --quiet ssh.socket 2>/dev/null; then sudo systemctl daemon-reload && sudo systemctl restart ssh.socket else sudo systemctl restart ssh fi
ssh -p 2200 ubuntu@<vm-ip>works from a second terminal before you close the first. Then check that nothing is left on 22:sudo ss -tlnp | grep -E ':(22|2200)\b' # expect sshd on 2200 only
sudo apt install unattended-upgradesso the VM patches itself.
Download the binary for your VM from the latest release (aarch64 for Ampere, x86_64 for the AMD
Micro), or build it on the VM with cargo build --release in sensor/. Then:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin hive
sudo install -m 755 hive-sensor-* /usr/local/bin/hive-sensor
sudo install -d -m 750 -o root -g hive /etc/hive
sudo tee /etc/hive/env >/dev/null <<'ENV'
HIVE_INGEST_URL=https://hive.xivlabs.tech/ingest
HIVE_SECRET=<the secret from step 1>
HIVE_GEOIP_DB=/etc/hive/GeoLite2-City.mmdb
ENV
sudo chmod 640 /etc/hive/env && sudo chgrp hive /etc/hive/envFor locations on the map, the sensor needs a city database in GeoIP2 format. The easiest is DB-IP City Lite, which is free, needs no account, and can be downloaded on the VM (it's updated monthly, so change the month in the URL):
curl -L https://download.db-ip.com/free/dbip-city-lite-2026-09.mmdb.gz | gunzip > city.mmdb
sudo install -m 644 -o root -g hive city.mmdb /etc/hive/GeoLite2-City.mmdb && sudo systemctl restart hive-sensorMaxMind's GeoLite2-City works too (free account required). The file is memory-mapped, so it barely
counts toward the sensor's RAM. Always replace it with install as above and then restart; never
edit it in place while the sensor runs. Without a database the sensor still works, just without map
points.
sudo cp hive-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now hive-sensor
journalctl -u hive-sensor -fFrom your own machine, ssh root@<vm-ip> should show up in the journal right away, and on the map within about 2 minutes (one flush plus the 30s cache).
| Variable | Default | |
|---|---|---|
HIVE_INGEST_URL |
required | Worker ingest URL |
HIVE_SECRET |
required | Shared HMAC secret |
HIVE_SSH_ADDR |
0.0.0.0:22 |
|
HIVE_HTTP_ADDR |
0.0.0.0:80 |
|
HIVE_STATE_DIR |
/var/lib/hive |
Holds the SSH host key and stats.json |
HIVE_GEOIP_DB |
unset | Path to GeoLite2-City .mmdb |
HIVE_BLOCKLIST_MIN |
3 |
Attempts in 7 days before an address goes on the blocklist |
HIVE_IGNORE_IPS |
unset | Comma-separated IPs or CIDRs that are never listed, e.g. your own address |
# Worker
cd worker && cp .dev.vars.example .dev.vars # set HIVE_SECRET and HIVE_EXPORT_TOKEN
wrangler d1 execute hive-db --local --file=schema.sql && wrangler dev
HIVE_SECRET=... HIVE_EXPORT_TOKEN=... ../test/worker.sh
# Sensor, on unprivileged ports
cd sensor && cargo test
HIVE_INGEST_URL=http://localhost:8787/ingest HIVE_SECRET=... \
HIVE_SSH_ADDR=127.0.0.1:2222 HIVE_HTTP_ADDR=127.0.0.1:8080 HIVE_STATE_DIR=/tmp/hive cargo runIP geolocation by DB-IP (CC BY 4.0), or GeoLite2 data created by MaxMind, available from https://www.maxmind.com.