
Experimental Decoy Broker
A containerized network decoy (honeypot) that advertises SSH, RDP, and SMB, observes every inbound connection with eBPF, and reverse-proxies each session into an isolated decoy container.
The design separates two concerns:
This is a defensive tool for detecting and studying unauthorized activity on networks you own or are authorized to monitor. Deploy it only where you have that authority.
flowchart TB
A["Attacker / Scanner"]
subgraph host["Decoy Host"]
direction TB
NIC["broker eth0<br/>published: 22, 3389, 445"]
subgraph brk["broker container"]
direction TB
E["eBPF TC classifier<br/>logs every SYN<br/>sees true source IP"]
P["reverse proxy<br/>CONNECT to backend"]
L["structured JSON logs"]
end
subgraph dec["decoynet (internal, no host route)"]
direction LR
S["ssh-decoy<br/>OpenCanary ssh<br/>port 2222"]
D["rdp-decoy<br/>OpenCanary rdp<br/>port 3389"]
M["smb-decoy<br/>Impacket SMB server<br/>port 445"]
end
end
A --> NIC
NIC --> E
NIC --> P
E --> L
P --> S
P --> D
P --> M
Four containers in total:
| Container | Role | Network |
|---|---|---|
broker | Public front door: eBPF observation plus reverse proxy | edge + decoynet |
ssh-decoy | OpenCanary ssh module (real handshake, captures creds) | decoynet only |
rdp-decoy | OpenCanary rdp module (NLA mimic, captures usernames) | decoynet only |
smb-decoy | Impacket SimpleSMBServer (real SMB2/3, captures auth) | decoynet only |
The decoys live on an internal Docker network (decoynet) with no route to
the host or the outside world. Only the broker can reach them. Nothing an
attacker does inside a decoy can reach the host network directly.
The broker publishes ports 22, 3389, and 445 to the host, so inbound packets
arrive on the broker's eth0. Two things then happen to each packet:
broker/bpf/decoy.bpf.c) parses the
Ethernet, IP, and TCP headers, and for each new connection attempt (SYN set,
ACK clear) writes a conn_event to a ring buffer: source IP and port,
destination port, TCP flags, and whether the port is an advertised service.
The packet is passed through unchanged (TC_ACT_OK).CONNECT to the decoy backend for that service,
then relays bytes both ways.The advertised_ports eBPF map is populated at startup from config.yaml, so
the classifier can tag whether a probe hit a served port or an unsolicited one.
This makes horizontal port scans visible even though only three ports are
proxied.
If you want to advertise "everything is open" and funnel arbitrary destination
ports into the broker, extend the classifier to rewrite the destination port or
use a TPROXY / bpf_sk_assign redirect. The current version keeps the packet
path untouched and limits itself to observation, which is the safer default.
cyber-decoy/
├── README.md
├── docker-compose.yml # 4-container stack
├── docker-compose.override.yml # local macOS dev: no eBPF caps, port 22 remap
├── Makefile # build / up / down / bpf helpers
├── LICENSE
├── scripts/
│ └── setup.sh # host preflight checks
├── broker/
│ ├── Dockerfile # compiles eBPF object + Go binary
│ ├── config.yaml # advertised services (configurable)
│ ├── go.mod
│ ├── main.go # entrypoint
│ ├── bpf/
│ │ └── decoy.bpf.c # eBPF TC classifier
│ └── internal/
│ ├── config/config.go # config loader
│ ├── proxy/proxy.go # TCP reverse proxy
│ └── bpf/loader.go # loads + attaches eBPF, streams events
└── decoys/ # all three run OpenCanary
├── ssh/
│ ├── Dockerfile
│ └── opencanary.conf # ssh module, port 2222
├── rdp/
│ ├── Dockerfile
│ └── opencanary.conf # rdp module, port 3389
└── smb/
├── Dockerfile # single Python process, non-root
├── smb_decoy.py # Impacket SimpleSMBServer + JSON logging
└── requirements.txt # impacket (pinned)
docker-compose.override.yml, which relies on the !reset / !override tags).sudo mount -t bpf bpf /sys/fs/bpf.The broker image detects its build architecture and passes the matching
__TARGET_ARCH_* macro to clang, so it builds on both x86_64 and aarch64
(Apple Silicon, Graviton). Note that gcc-multilib is deliberately not
installed: it is an x86-only package with no arm64 candidate, and including it
breaks the build on arm64 with apt exit code 100. Only clang and libbpf-dev
are needed to compile the eBPF object.
Docker Desktop on macOS runs containers inside a LinuxKit VM rather than on your
host kernel, so TC/TCX eBPF attach generally will not work there. This is not
fatal: eBPF is best effort by design, so the broker logs ebpf disabled: attach failed and the reverse proxy plus all three decoys run and log normally. You can
develop and test the entire proxy path locally, then get real eBPF observation
when you deploy to a Linux host.
docker-compose.override.yml is loaded automatically and makes this pleasant: it
drops the eBPF capabilities (useless in the VM) and remaps host port 22 to 2022,
since the Mac's own sshd owns 22.
docker compose up --build # local dev, override applied
docker compose -f docker-compose.yml up -d # real deployment, override bypassed
Run the preflight check first:
./scripts/setup.sh
# 1. Build all four images (compiles the eBPF object inside the broker image)
make build
# 2. Start the stack
make up
# 3. Watch what happens
make logs
Then probe it from another machine (or localhost for a smoke test):
ssh -p 22 user@DECOY_HOST # hits the SSH decoy
nc DECOY_HOST 3389 # hits the RDP decoy
nc DECOY_HOST 445 # hits the SMB decoy
nc DECOY_HOST 8080 # unadvertised: observed by eBPF, no proxy
The broker emits JSON for eBPF probe events and proxied sessions; each decoy emits OpenCanary JSON events. To watch credentials land:
docker compose logs -f ssh-decoy | grep 4002
Unlike a banner-only stub, ssh -p 22 user@DECOY_HOST now completes a real key
exchange and prompts for a password. Every attempt is captured. Verify the
service fingerprint holds up under version detection:
nmap -sV -p 22,3389,445 DECOY_HOST
Tear down with:
make down
Services are defined in broker/config.yaml. Each entry is independently
toggleable and remappable: