Warning
When I started this project I had never touched go before, and so a large part of the codebase has currently been written with the heavy help of AI autocomplete. While I've read it all, and understand what it's doing, the entire project will likely undergo a huge rework in the future now that I have an understanding of the needed architecture and go's development patterns.
That'll likely come about once I've finished my degree and have some more spare time, but it'd feel disingenuous not to mention it.
ShadowTrap is a service to automatically deploy honeypots across a network. It places pots on unused IP addresses either synced from an external IPAM or discovered by sweep, runs each pot as a short-lived KVM guest behind a kernel-enforced fail-closed network filter, and forwards structured threat telemetry to external consumers.
The project is split into two Go modules:
controller/— the central control plane (HTTP API, web UI, libvirt driver, PostgreSQL persistence, pot scheduler).agent/— the in-guest agent that receives config over virtio-serial and drives emulated services.
This README covers what you need to run the controller on a single
Linux host. Anything prefixed with # is run as root.
All development has been against Arch Linux; any recent Linux host with KVM and libvirt will work.
- Linux kernel 5.15+ with KVM support (
kvm-intelorkvm-amd). - libvirt 9.0+ and QEMU/KVM — provides the socket at
/var/run/libvirt/libvirt-sockthat the controller talks to. - qemu-img — used at image-prep time to create the qcow2 base images.
- nftables — the controller installs a bridge-family table
(
shadowtrap) at startup as the host-side stateful backstop on top of the per-NIC libvirt nwfilter. Thenftbinary must be onPATH. - PostgreSQL 14+ — a single database for controller state.
- Go 1.22+ — to build the controller and agent binaries.
On Arch:
# pacman -S libvirt qemu-full dnsmasq postgresql nftables go
# systemctl enable --now libvirtd.socket
# systemctl enable --now postgresqlThe controller needs CAP_NET_ADMIN (to manage bridges and install the
nftables backstop) and CAP_SYSLOG (to read /dev/kmsg for host-firewall
drop attribution). Running as root is the simplest path; otherwise:
# setcap cap_net_admin,cap_syslog+ep /usr/local/bin/shadowtrap-controller
# usermod -aG libvirt $USERcd shadowtrap/controller && go build -o shadowtrap-controller
cd shadowtrap/agent && go build -o shadowtrap-agentThe controller is a single static binary. The UI is embedded via
//go:embed, so there is nothing else to deploy.
Create a database and apply the schema:
$ createdb shadowtrap
$ psql -d shadowtrap -f controller/db/schema.sqlAny libpq-style DSN works; export it as SHADOWTRAP_DB_DSN or pass it
via --db:
export SHADOWTRAP_DB_DSN='postgres://shadowtrap@/shadowtrap?sslmode=disable'The controller expects qcow2 base images in --images-dir (default
/var/lib/shadowtrap/images), with a matching row in the images
table describing the base OS and emulated services.
Minimum working example — an Ubuntu cloud image with a Cowrie-style SSH
emulator baked into the agent. (Debian 12's genericcloud image ships
with a broken cloud-init at the time of writing, so Ubuntu is the
known-good default.)
# mkdir -p /var/lib/shadowtrap/images
# curl -o /var/lib/shadowtrap/images/ubuntu-24.04.qcow2 \
https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.imgRegister it in the database:
INSERT INTO images (id, base, version, os, features) VALUES
('ubuntu-24.04-ssh', 'ubuntu-24.04.qcow2', '24.04', 'linux',
'[{"name":"ssh","version":"1"}]');Build the agent (shadowtrap-agent) into the image and install a
systemd unit that runs it against /dev/vport0p0. An example unit:
[Unit]
Description=ShadowTrap agent
After=network.target
[Service]
ExecStart=/usr/local/bin/shadowtrap-agent
Restart=always
[Install]
WantedBy=multi-user.targetThe controller adds a virtio-serial channel to every pot it launches; no configuration is needed inside the guest beyond enabling this unit.
A minimal first-run:
export SHADOWTRAP_DB_DSN='postgres://shadowtrap@/shadowtrap?sslmode=disable'
export SHADOWTRAP_API_KEY="$(openssl rand -hex 32)"
./shadowtrap-controller \
--addr :8080 \
--images-dir /var/lib/shadowtrap/images \
--run-dir /run/shadowtrap \
--libvirt /var/run/libvirt/libvirt-sock \
--interval 30sFlags:
| Flag | Env | Default | Meaning |
|---|---|---|---|
--addr |
— | :8080 |
HTTP listen address for API + UI |
--db |
SHADOWTRAP_DB_DSN |
— | PostgreSQL DSN (required) |
--api-key |
SHADOWTRAP_API_KEY |
— | admin master key (required on first boot) |
--images-dir |
— | /var/lib/shadowtrap/images |
base qcow2 location |
--run-dir |
— | /run/shadowtrap |
runtime state for pot sockets |
--libvirt |
— | /var/run/libvirt/libvirt-sock |
libvirt UNIX socket |
--interval |
— | 30s |
scheduler reconcile cadence |
On startup the controller:
- Opens two libvirt connections (domain + network driver).
- Loads
br_netfilter/nf_conntrack/nf_conntrack_bridgeand installs thebridge shadowtrapnftables table. This is the host- side stateful backstop: any pot-originated frame (identified by the02:73:74:**:**:**MAC OUI) that is not part of an inbound-initiated conntrack flow is logged with ashadowtrap-drop:prefix, counted inpot_egress_drops, and dropped. Ifnftis missing or the ruleset will not load the controller refuses to start. - Installs the
shadowtrap-potlibvirt nwfilter — the primary, per-NIC stateful filter (default-deny egress, anti-MAC-spoofing, anti-ARP-spoofing, drop-all-IPv6). Same fail-closed posture. - Scans host interfaces and seeds the
interfacestable. - Reconnects to any pots that survived a restart.
- Starts the scheduler (reconcile + TTL reaper loop), the containment
watchdog (samples per-pot interface counters every 5 s), and the
drop watcher (tails
/dev/kmsgforshadowtrap-drop:lines, maps the source MAC back to a pot, and rebuilds it). - Serves the API under
/api/and the admin UI at/.
For persistent operation, drop this in
/etc/systemd/system/shadowtrap-controller.service:
[Unit]
Description=ShadowTrap controller
After=network.target libvirtd.service postgresql.service
Wants=libvirtd.service postgresql.service
[Service]
Type=simple
User=shadowtrap
Group=libvirt
EnvironmentFile=/etc/shadowtrap/controller.env
ExecStart=/usr/local/bin/shadowtrap-controller --addr :8080
Restart=on-failure
[Install]
WantedBy=multi-user.targetWith /etc/shadowtrap/controller.env holding SHADOWTRAP_DB_DSN and
SHADOWTRAP_API_KEY. Mode 0600, owner shadowtrap.
Open http://<host>:8080/. Paste the master API key into the header
input and click Use — it is stored in localStorage and sent as
the api_key header on every API call.
Tabs:
- Dashboard — live list of pots (auto-refresh every 10s) showing status, IP, credential hint, and TTL expiry.
- Deployments — create, edit, and delete deployments. A
deployment pins an image set, a network set, an IPAM source
(
sweep/phpipam/netbox), a target pot count, and an optional per-pot TTL. - Images — view registered base images and their features.
- Networks — host-facing network configuration (mode, static addressing), the host interface inventory, and the configured VLANs / native networks used by deployments.
- API keys — manage additional keys. Each key has a role
(
viewer,operator,admin); the raw key is shown once at creation time and is not recoverable afterwards.
The master key (--api-key) always has admin scope. In production
you should create a named admin key via the UI and rotate the master
key out of the environment.
- Fail-closed is non-negotiable. If the nwfilter fails to install, or libvirt is unreachable, the controller exits. Do not patch this out — uncontained honeypots are an ethics and legal problem, not an inconvenience.
- TTL-reaped pots lose state. Set
ttl_minutes = 0if you want long-lived pots; any interaction telemetry is forwarded before the reap regardless. - Credentials are shown only once. The controller stores a
fingerprint hint (
user@hosttruncated) for display, never the full credential. - Images are read-only to pots. Each pot boots from a qcow2 overlay; the base image is never mutated.
libvirt domain socket: permission denied— check thelibvirtgroup membership; log out and back in.libvirt start: Cannot access storage file ... Permission denied (as uid:956 ...)— QEMU runs as a separate user (qemu) and cannot traverse$XDG_RUNTIME_DIR(mode 0700). Point--run-dirat a shared path whose group is one libvirt's QEMU process can read:ShadowTrap inherits the parent directory's gid onto every pot directory (mode 2770) and the cloned disk image (mode 0660), so whatever group you set above is the group that gets read/write access to all pot artefacts. Alternatively, make libvirt's QEMU run as your own user by adding# mkdir -p /var/lib/shadowtrap/run # chown $USER:libvirt /var/lib/shadowtrap/run # chmod 0770 /var/lib/shadowtrap/run
user = "<you>"andgroup = "<you>"to/etc/libvirt/qemu.confand restartinglibvirtd. The controller refuses to start with a--run-dirunder/run/user/for this reason.scheduler bootstrap: ... NwfilterDefineXML— libvirt is too old or nwfilter is compiled out. Upgrade libvirt.http: listen tcp :8080: bind: permission denied— pick a port above 1024 or grantCAP_NET_BIND_SERVICEto the binary.- UI loads but API calls 401 — the
api_keyheader is missing or the key has been deleted. Re-paste it.