Initial commit: DECNET honeypot/deception network framework

Core CLI, service plugins (SSH/SMB/FTP/HTTP/RDP), Docker Compose
orchestration, MACVLAN networking, and Logstash log forwarding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 18:56:25 -03:00
commit 3e98c71ca4
37 changed files with 1822 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
decnet-compose.yml
decnet-state.json
*.ini
.env

48
CLAUDE.md Normal file
View File

@@ -0,0 +1,48 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Install (dev)
pip install -e .
# List registered service plugins
decnet services
# Dry-run (generates compose, no containers)
decnet deploy --mode unihost --deckies 3 --randomize-services --dry-run
# Full deploy (requires root for MACVLAN)
sudo decnet deploy --mode unihost --deckies 5 --interface eth0 --randomize-services
sudo decnet deploy --mode unihost --deckies 3 --services ssh,smb --log-target 192.168.1.5:5140
# Status / teardown
decnet status
sudo decnet teardown --all
sudo decnet teardown --id decky-01
```
## Project Overview
DECNET is a honeypot/deception network framework. It deploys fake machines (called **deckies**) with realistic services (RDP, SMB, SSH, FTP, etc.) to lure and profile attackers. All attacker interactions are aggregated to an isolated logging network (ELK stack / SIEM).
## Deployment Models
**UNIHOST** — one real host spins up _n_ deckies via a container orchestrator. Simpler, single-machine deployment.
**SWARM (MULTIHOST)**_n_ real hosts each running deckies. Orchestrated via Ansible/sshpass or similar tooling.
## Core Technology Choices
- **Containers**: Docker Compose is the starting point but other orchestration frameworks should be evaluated if they serve the project better. `debian:bookworm-slim` is the default base image; mixing in Ubuntu, CentOS, or other distros is encouraged to make the decoy network look heterogeneous.
- **Networking**: Deckies need to appear as real machines on the LAN (own MACs/IPs). MACVLAN and IPVLAN are candidates; the right driver depends on the host environment. WSL has known limitations — bare metal or a VM is preferred for testing.
- **Log pipeline**: Logstash → ELK stack → SIEM (isolated network, not reachable from decoy network)
## Architecture Constraints
- The decoy network must be reachable from the outside (attacker-facing).
- The logging/aggregation network must be isolated from the decoy network.
- A publicly accessible real server acts as the bridge between the two networks.
- Deckies should differ in exposed services and OS fingerprints to appear as a heterogeneous network.

53
NOTES.md Normal file
View File

@@ -0,0 +1,53 @@
# Initial steps
# Architecture
## DECNET-UNIHOST model
The unihost model is a mode in which DECNET deploys an _n_ amount of machines from a single one. This execution model lives in a decoy network which is accessible to an attacker from the outside.
Each decky (the son of the DECNET unihost) should have different services (RDP, SMB, SSH, FTP, etc) and all of them should communicate with an external, isolated network, which aggregates data and allows
visualizations to be made. Think of the ELK stack. That data is then passed back via Logstash or other methods to a SIEM device or something else that may be beneficiated by this collected data.
## DECNET-MULTIHOST (SWARM) model
The SWARM model is similar to the UNIHOST model, but the difference is that instead of one real machine, we have n>1 machines. Same thought process really, but deployment may be different.
A low cost option and fairly automatable one is the usage of Ansible, sshpass, or other tools.
# Modus operandi
## Docker-Compose
I will use Docker Compose extensively for this project. The reasons are:
- Easily managed.
- Easily extensible.
- Less overhead.
To be completely transparent: I asked Deepseek to write the initial `docker-compose.yml` file. It was mostly boilerplate, and most of it mainly modified or deleted. It doesn't exist anymore.
## Distro to use.
I will be using the `debian:bookworm-slim` image for all the containers. I might think about mixing in there some Ubuntu or a Centos, but for now, Debian will do just fine.
The distro I'm running is WSL Kali Linux. Let's hope this doesn't cause any problems down the road.
## Networking
It was a hussle, but I think MACVLAN or IPVLAN (thanks @Deepseek!) might work. The reasoning behind picking this networking driver is that for the project to work, it requires having containers the entire container accessible from the network. This is to attempt to masquarede them as real, live machines.
Now, we will need a publicly accesible, real server that has access to this "internal" network. I'll try MACVLAN first.
### MACVLAN Tests
I will first use the default network to see what happens.
```
docker network create -d macvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
-o parent=eth0 localnet
```
#### Issues
This initial test doesn't seem to be working. Might be that I'm using WSL, so I downloaded a Ubuntu 22.04 Server ISO. I'll try the MACVLAN network on it. Now, if that doesn't work, I don't see how the 802.1q would work, at least on _my network_. Perhaps if I had a switch I could make it work, but currently I don't have one :c

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
# DECNET
A honeypot/deception network framework. Deploys fake machines (**deckies**) with realistic services (SSH, SMB, RDP, FTP, HTTP) that appear as real LAN hosts — complete with their own MACs and IPs — to lure, detect, and profile attackers. All interactions are forwarded to an isolated logging pipeline (ELK / SIEM).
```
attacker ──► decoy network (deckies)
└──► log forwarder ──► isolated SIEM (ELK)
```
---
## Requirements
- Python ≥ 3.11
- Docker + Docker Compose
- Root / `sudo` for MACVLAN networking (bare metal or VM recommended; WSL has known limitations)
---
## Install
```bash
pip install -e .
```
---
## Usage
```bash
# List available honeypot service plugins
decnet services
# Dry-run — generate compose file, no containers started
decnet deploy --mode unihost --deckies 3 --randomize-services --dry-run
# Deploy 5 deckies with random services
sudo decnet deploy --mode unihost --deckies 5 --interface eth0 --randomize-services
# Deploy with specific services and log forwarding
sudo decnet deploy --mode unihost --deckies 3 --services ssh,smb --log-target 192.168.1.5:5140
# Deploy from an INI config file
sudo decnet deploy --config decnet.ini
# Status
decnet status
# Teardown
sudo decnet teardown --all
sudo decnet teardown --id decky-01
```
### Key flags
| Flag | Description |
|---|---|
| `--mode` | `unihost` (single host) or `swarm` (multi-host) |
| `--deckies N` | Number of fake machines to spin up |
| `--interface` | Host NIC (auto-detected if omitted) |
| `--subnet` | LAN subnet CIDR (auto-detected if omitted) |
| `--ip-start` | First decky IP (auto if omitted) |
| `--services` | Comma-separated list: `ssh,smb,rdp,ftp,http` |
| `--randomize-services` | Assign random service mix to each decky |
| `--log-target` | Forward logs to `ip:port` (e.g. Logstash) |
| `--dry-run` | Generate compose file without starting containers |
| `--no-cache` | Force rebuild all images |
| `--config` | Path to INI config file |
---
## Deployment Modes
**UNIHOST** — one real host spins up _n_ deckies via Docker Compose. Simplest setup, single machine.
**SWARM (MULTIHOST)**_n_ real hosts each running deckies. Orchestrated via Ansible or similar tooling.
---
## Architecture
- **Containers**: Docker Compose with `debian:bookworm-slim` as the default base image. Mixing Ubuntu, CentOS, and other distros is encouraged to make the decoy network look heterogeneous.
- **Networking**: MACVLAN/IPVLAN — each decky gets its own MAC and IP, appearing as a distinct real machine on the LAN.
- **Log pipeline**: Logstash → ELK stack → SIEM on an isolated network unreachable from the decoy network.
- **Services**: Plugin-based registry (`decnet/services/`). Each plugin declares its ports, default image, and container config.
```
decnet/
├── cli.py # Typer CLI — deploy, status, teardown, services
├── config.py # Pydantic models (DecnetConfig, DeckyConfig)
├── composer.py # Docker Compose YAML generator
├── deployer.py # Container lifecycle management
├── network.py # IP allocation, interface/subnet detection
├── ini_loader.py # INI config file support
├── logging/
│ └── forwarder.py # Log target probe + forwarding
└── services/
├── registry.py # Plugin registry
├── ssh.py
├── smb.py
├── rdp.py
├── ftp.py
└── http.py
```
---
## INI Config
You can describe a fully custom decoy fleet in an INI file instead of CLI flags:
```ini
[global]
interface = eth0
log_target = 192.168.1.5:5140
[decky-01]
services = ssh,smb
base_image = debian:bookworm-slim
hostname = DESKTOP-A1B2C3
[decky-02]
services = rdp,http
base_image = ubuntu:22.04
hostname = WIN-SERVER-02
```
```bash
sudo decnet deploy --config decnet.ini
```
---
## Adding a Service Plugin
1. Create `decnet/services/yourservice.py` implementing the `BaseService` interface.
2. Register it in `decnet/services/registry.py`.
3. Verify with `decnet services`.

0
decnet/__init__.py Normal file
View File

280
decnet/cli.py Normal file
View File

@@ -0,0 +1,280 @@
"""
DECNET CLI — entry point for all commands.
Usage:
decnet deploy --mode unihost --deckies 5 --randomize-services
decnet status
decnet teardown [--all | --id decky-01]
decnet services
"""
import random
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from decnet.config import (
BASE_IMAGES,
DeckyConfig,
DecnetConfig,
random_hostname,
)
from decnet.ini_loader import IniConfig, load_ini
from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip
from decnet.services.registry import all_services
app = typer.Typer(
name="decnet",
help="Deploy a deception network of honeypot deckies on your LAN.",
no_args_is_help=True,
)
console = Console()
ALL_SERVICE_NAMES = ["ssh", "smb", "rdp", "http", "ftp"]
def _build_deckies(
n: int,
ips: list[str],
services_explicit: list[str] | None,
randomize: bool,
) -> list[DeckyConfig]:
deckies = []
used_combos: set[frozenset] = set()
for i, ip in enumerate(ips):
name = f"decky-{i + 1:02d}"
base_image = BASE_IMAGES[i % len(BASE_IMAGES)]
hostname = random_hostname()
if services_explicit:
svc_list = services_explicit
elif randomize:
# Pick 1-3 random services, try to avoid exact duplicates
attempts = 0
while True:
count = random.randint(1, min(3, len(ALL_SERVICE_NAMES)))
chosen = frozenset(random.sample(ALL_SERVICE_NAMES, count))
attempts += 1
if chosen not in used_combos or attempts > 20:
break
svc_list = list(chosen)
used_combos.add(chosen)
else:
typer.echo("Error: provide --services or --randomize-services.", err=True)
raise typer.Exit(1)
deckies.append(
DeckyConfig(
name=name,
ip=ip,
services=svc_list,
base_image=base_image,
hostname=hostname,
)
)
return deckies
def _build_deckies_from_ini(
ini: IniConfig,
subnet_cidr: str,
gateway: str,
host_ip: str,
randomize: bool,
) -> list[DeckyConfig]:
"""Build DeckyConfig list from an IniConfig, auto-allocating missing IPs."""
from ipaddress import IPv4Address, IPv4Network
explicit_ips: set[IPv4Address] = {
IPv4Address(s.ip) for s in ini.deckies if s.ip
}
# Build an IP iterator that skips reserved + explicit addresses
net = IPv4Network(subnet_cidr, strict=False)
reserved = {
net.network_address,
net.broadcast_address,
IPv4Address(gateway),
IPv4Address(host_ip),
} | explicit_ips
auto_pool = (str(addr) for addr in net.hosts() if addr not in reserved)
deckies: list[DeckyConfig] = []
for i, spec in enumerate(ini.deckies):
base_image = BASE_IMAGES[i % len(BASE_IMAGES)]
hostname = random_hostname()
ip = spec.ip or next(auto_pool, None)
if ip is None:
raise RuntimeError(
f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'."
)
if spec.services:
known = set(ALL_SERVICE_NAMES)
unknown = [s for s in spec.services if s not in known]
if unknown:
console.print(
f"[red]Unknown service(s) in [{spec.name}]: {unknown}. "
f"Available: {ALL_SERVICE_NAMES}[/]"
)
raise typer.Exit(1)
svc_list = spec.services
elif randomize:
import random as _random
count = _random.randint(1, min(3, len(ALL_SERVICE_NAMES)))
svc_list = _random.sample(ALL_SERVICE_NAMES, count)
else:
console.print(
f"[red]Decky '[{spec.name}]' has no services= in config. "
"Add services= or use --randomize-services.[/]"
)
raise typer.Exit(1)
deckies.append(DeckyConfig(
name=spec.name,
ip=ip,
services=svc_list,
base_image=base_image,
hostname=hostname,
))
return deckies
@app.command()
def deploy(
mode: str = typer.Option("unihost", "--mode", "-m", help="Deployment mode: unihost | swarm"),
deckies: Optional[int] = typer.Option(None, "--deckies", "-n", help="Number of deckies to deploy (required without --config)", min=1),
interface: Optional[str] = typer.Option(None, "--interface", "-i", help="Host NIC (auto-detected if omitted)"),
subnet: Optional[str] = typer.Option(None, "--subnet", help="LAN subnet CIDR (auto-detected if omitted)"),
ip_start: Optional[str] = typer.Option(None, "--ip-start", help="First decky IP (auto if omitted)"),
services: Optional[str] = typer.Option(None, "--services", help="Comma-separated services, e.g. ssh,smb,rdp"),
randomize_services: bool = typer.Option(False, "--randomize-services", help="Assign random services to each decky"),
log_target: Optional[str] = typer.Option(None, "--log-target", help="Forward logs to ip:port (e.g. 192.168.1.5:5140)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"),
no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"),
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"),
) -> None:
"""Deploy deckies to the LAN."""
if mode not in ("unihost", "swarm"):
console.print("[red]--mode must be 'unihost' or 'swarm'[/]")
raise typer.Exit(1)
# ------------------------------------------------------------------ #
# Config-file path #
# ------------------------------------------------------------------ #
if config_file:
try:
ini = load_ini(config_file)
except FileNotFoundError as e:
console.print(f"[red]{e}[/]")
raise typer.Exit(1)
# CLI flags override INI values when explicitly provided
iface = interface or ini.interface or detect_interface()
subnet_cidr = subnet or ini.subnet
effective_gateway = ini.gateway
if subnet_cidr is None:
subnet_cidr, effective_gateway = detect_subnet(iface)
elif effective_gateway is None:
_, effective_gateway = detect_subnet(iface)
host_ip = get_host_ip(iface)
console.print(f"[dim]Config:[/] {config_file} [dim]Interface:[/] {iface} "
f"[dim]Subnet:[/] {subnet_cidr} [dim]Gateway:[/] {effective_gateway} "
f"[dim]Host IP:[/] {host_ip}")
effective_log_target = log_target or ini.log_target
decky_configs = _build_deckies_from_ini(
ini, subnet_cidr, effective_gateway, host_ip, randomize_services
)
# ------------------------------------------------------------------ #
# Classic CLI path #
# ------------------------------------------------------------------ #
else:
if deckies is None:
console.print("[red]--deckies is required when --config is not used.[/]")
raise typer.Exit(1)
services_list = [s.strip() for s in services.split(",")] if services else None
if services_list:
known = set(ALL_SERVICE_NAMES)
unknown = [s for s in services_list if s not in known]
if unknown:
console.print(f"[red]Unknown service(s): {unknown}. Available: {ALL_SERVICE_NAMES}[/]")
raise typer.Exit(1)
if not services_list and not randomize_services:
console.print("[red]Specify --services or --randomize-services.[/]")
raise typer.Exit(1)
iface = interface or detect_interface()
if subnet is None:
subnet_cidr, effective_gateway = detect_subnet(iface)
else:
subnet_cidr = subnet
_, effective_gateway = detect_subnet(iface)
host_ip = get_host_ip(iface)
console.print(f"[dim]Interface:[/] {iface} [dim]Subnet:[/] {subnet_cidr} "
f"[dim]Gateway:[/] {effective_gateway} [dim]Host IP:[/] {host_ip}")
ips = allocate_ips(subnet_cidr, effective_gateway, host_ip, deckies, ip_start)
decky_configs = _build_deckies(deckies, ips, services_list, randomize_services)
effective_log_target = log_target
config = DecnetConfig(
mode=mode,
interface=iface,
subnet=subnet_cidr,
gateway=effective_gateway,
deckies=decky_configs,
log_target=effective_log_target,
)
if effective_log_target and not dry_run:
from decnet.logging.forwarder import probe_log_target
if not probe_log_target(effective_log_target):
console.print(f"[yellow]Warning: log target {effective_log_target} is unreachable. "
"Logs will be lost if it stays down.[/]")
from decnet.deployer import deploy as _deploy
_deploy(config, dry_run=dry_run, no_cache=no_cache)
@app.command()
def status() -> None:
"""Show running deckies and their status."""
from decnet.deployer import status as _status
_status()
@app.command()
def teardown(
all_: bool = typer.Option(False, "--all", help="Tear down all deckies and remove network"),
id_: Optional[str] = typer.Option(None, "--id", help="Tear down a specific decky by name"),
) -> None:
"""Stop and remove deckies."""
if not all_ and not id_:
console.print("[red]Specify --all or --id <name>.[/]")
raise typer.Exit(1)
from decnet.deployer import teardown as _teardown
_teardown(decky_id=id_)
@app.command(name="services")
def list_services() -> None:
"""List all registered honeypot service plugins."""
svcs = all_services()
table = Table(title="Available Services", show_lines=True)
table.add_column("Name", style="bold cyan")
table.add_column("Ports")
table.add_column("Image")
for name, svc in sorted(svcs.items()):
table.add_row(name, ", ".join(str(p) for p in svc.ports), svc.default_image)
console.print(table)

87
decnet/composer.py Normal file
View File

@@ -0,0 +1,87 @@
"""
Generates a docker-compose.yml from a DecnetConfig.
Network model:
Each decky gets ONE "base" container that holds the MACVLAN IP.
All service containers for that decky share the base's network namespace
via `network_mode: "service:<base>"`. From the outside, every service on
a given decky appears to come from the same IP — exactly like a real host.
"""
from pathlib import Path
import yaml
from decnet.config import DecnetConfig
from decnet.network import MACVLAN_NETWORK_NAME
from decnet.services.registry import get_service
_LOG_NETWORK = "decnet_logs"
# Minimal image for the base container — just needs to stay alive.
_BASE_IMAGE = "debian:bookworm-slim"
def generate_compose(config: DecnetConfig) -> dict:
"""Build and return the full docker-compose data structure."""
services: dict = {}
for decky in config.deckies:
base_key = decky.name # e.g. "decky-01"
# --- Base container: owns the MACVLAN IP, runs nothing but sleep ---
base: dict = {
"image": _BASE_IMAGE,
"container_name": base_key,
"hostname": decky.hostname,
"command": ["sleep", "infinity"],
"restart": "unless-stopped",
"networks": {
MACVLAN_NETWORK_NAME: {
"ipv4_address": decky.ip,
}
},
}
if config.log_target:
base["networks"][_LOG_NETWORK] = {}
services[base_key] = base
# --- Service containers: share base network namespace ---
for svc_name in decky.services:
svc = get_service(svc_name)
fragment = svc.compose_fragment(decky.name, log_target=config.log_target)
fragment.setdefault("environment", {})
fragment["environment"]["HOSTNAME"] = decky.hostname
# Share the base container's network — no own IP needed
fragment["network_mode"] = f"service:{base_key}"
fragment["depends_on"] = [base_key]
# hostname must not be set when using network_mode
fragment.pop("hostname", None)
fragment.pop("networks", None)
services[f"{decky.name}-{svc_name}"] = fragment
# Network definitions
networks: dict = {
MACVLAN_NETWORK_NAME: {
"external": True, # created by network.py before compose up
}
}
if config.log_target:
networks[_LOG_NETWORK] = {"driver": "bridge", "internal": True}
return {
"version": "3.8",
"services": services,
"networks": networks,
}
def write_compose(config: DecnetConfig, output_path: Path) -> Path:
"""Write the docker-compose.yml to output_path and return it."""
data = generate_compose(config)
output_path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
return output_path

82
decnet/config.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Pydantic models for DECNET configuration and runtime state.
State is persisted to decnet-state.json in the working directory.
"""
import json
import random
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, field_validator
STATE_FILE = Path("decnet-state.json")
BASE_IMAGES = [
"debian:bookworm-slim",
"ubuntu:22.04",
]
DECKY_NAME_WORDS = [
"alpha", "bravo", "charlie", "delta", "echo",
"foxtrot", "golf", "hotel", "india", "juliet",
"kilo", "lima", "mike", "nova", "oscar",
]
def random_hostname() -> str:
return f"SRV-{random.choice(DECKY_NAME_WORDS).upper()}-{random.randint(10, 99)}"
class DeckyConfig(BaseModel):
name: str
ip: str
services: list[str]
base_image: str
hostname: str
@field_validator("services")
@classmethod
def services_not_empty(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("A decky must have at least one service.")
return v
class DecnetConfig(BaseModel):
mode: Literal["unihost", "swarm"]
interface: str
subnet: str
gateway: str
deckies: list[DeckyConfig]
log_target: str | None = None # "ip:port" or None
@field_validator("log_target")
@classmethod
def validate_log_target(cls, v: str | None) -> str | None:
if v is None:
return v
parts = v.rsplit(":", 1)
if len(parts) != 2 or not parts[1].isdigit():
raise ValueError("log_target must be in ip:port format, e.g. 192.168.1.5:5140")
return v
def save_state(config: DecnetConfig, compose_path: Path) -> None:
payload = {
"config": config.model_dump(),
"compose_path": str(compose_path),
}
STATE_FILE.write_text(json.dumps(payload, indent=2))
def load_state() -> tuple[DecnetConfig, Path] | None:
if not STATE_FILE.exists():
return None
data = json.loads(STATE_FILE.read_text())
return DecnetConfig(**data["config"]), Path(data["compose_path"])
def clear_state() -> None:
if STATE_FILE.exists():
STATE_FILE.unlink()

147
decnet/deployer.py Normal file
View File

@@ -0,0 +1,147 @@
"""
Deploy, teardown, and status via Docker SDK + subprocess docker compose.
"""
import subprocess
from pathlib import Path
import docker
from rich.console import Console
from rich.table import Table
from decnet.config import DecnetConfig, clear_state, load_state, save_state
from decnet.composer import write_compose
from decnet.network import (
MACVLAN_NETWORK_NAME,
allocate_ips,
create_macvlan_network,
detect_interface,
detect_subnet,
get_host_ip,
ips_to_range,
remove_macvlan_network,
setup_host_macvlan,
teardown_host_macvlan,
)
console = Console()
COMPOSE_FILE = Path("decnet-compose.yml")
def _compose(*args: str, compose_file: Path = COMPOSE_FILE) -> None:
cmd = ["docker", "compose", "-f", str(compose_file), *args]
subprocess.run(cmd, check=True)
def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False) -> None:
client = docker.from_env()
# --- Network setup ---
ip_list = [d.ip for d in config.deckies]
decky_range = ips_to_range(ip_list)
host_ip = get_host_ip(config.interface)
console.print(f"[bold cyan]Creating MACVLAN network[/] ({MACVLAN_NETWORK_NAME}) on {config.interface}")
if not dry_run:
create_macvlan_network(
client,
interface=config.interface,
subnet=config.subnet,
gateway=config.gateway,
ip_range=decky_range,
)
setup_host_macvlan(config.interface, host_ip, decky_range)
# --- Compose generation ---
compose_path = write_compose(config, COMPOSE_FILE)
console.print(f"[bold cyan]Compose file written[/] → {compose_path}")
if dry_run:
console.print("[yellow]Dry run — no containers started.[/]")
return
# --- Save state before bring-up ---
save_state(config, compose_path)
# --- Bring up ---
console.print("[bold cyan]Building images and starting deckies...[/]")
if no_cache:
_compose("build", "--no-cache", compose_file=compose_path)
_compose("up", "--build", "-d", compose_file=compose_path)
# --- Status summary ---
_print_status(config)
def teardown(decky_id: str | None = None) -> None:
state = load_state()
if state is None:
console.print("[red]No active deployment found (no decnet-state.json).[/]")
return
config, compose_path = state
client = docker.from_env()
if decky_id:
# Bring down only the services matching this decky
svc_names = [f"{decky_id}-{svc}" for svc in [d.services for d in config.deckies if d.name == decky_id]]
if not svc_names:
console.print(f"[red]Decky '{decky_id}' not found in current deployment.[/]")
return
_compose("stop", *svc_names, compose_file=compose_path)
_compose("rm", "-f", *svc_names, compose_file=compose_path)
else:
_compose("down", compose_file=compose_path)
ip_list = [d.ip for d in config.deckies]
decky_range = ips_to_range(ip_list)
teardown_host_macvlan(decky_range)
remove_macvlan_network(client)
clear_state()
console.print("[green]All deckies torn down. MACVLAN network removed.[/]")
def status() -> None:
state = load_state()
if state is None:
console.print("[yellow]No active deployment.[/]")
return
config, _ = state
client = docker.from_env()
table = Table(title="DECNET Deckies", show_lines=True)
table.add_column("Decky", style="bold")
table.add_column("IP")
table.add_column("Services")
table.add_column("Hostname")
table.add_column("Status")
running = {c.name: c.status for c in client.containers.list(all=True)}
for decky in config.deckies:
statuses = []
for svc in decky.services:
cname = f"{decky.name}-{svc}"
st = running.get(cname, "absent")
color = "green" if st == "running" else "red"
statuses.append(f"[{color}]{svc}({st})[/{color}]")
table.add_row(
decky.name,
decky.ip,
" ".join(statuses),
decky.hostname,
"[green]up[/]" if all("running" in s for s in statuses) else "[red]degraded[/]",
)
console.print(table)
def _print_status(config: DecnetConfig) -> None:
table = Table(title="Deployed Deckies", show_lines=True)
table.add_column("Decky")
table.add_column("IP")
table.add_column("Services")
for decky in config.deckies:
table.add_row(decky.name, decky.ip, ", ".join(decky.services))
console.print(table)

68
decnet/ini_loader.py Normal file
View File

@@ -0,0 +1,68 @@
"""
Parse DECNET INI deployment config files.
Format:
[general]
net=192.168.1.0/24
gw=192.168.1.1
interface=wlp6s0
log_target=192.168.1.5:5140 # optional
[hostname-1]
ip=192.168.1.82 # optional
services=ssh,smb # optional; falls back to --randomize-services
[hostname-2]
services=ssh
[hostname-3]
ip=192.168.1.32
"""
import configparser
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class DeckySpec:
name: str
ip: str | None = None
services: list[str] | None = None
@dataclass
class IniConfig:
subnet: str | None = None
gateway: str | None = None
interface: str | None = None
log_target: str | None = None
deckies: list[DeckySpec] = field(default_factory=list)
def load_ini(path: str | Path) -> IniConfig:
"""Parse a DECNET INI file and return an IniConfig."""
cp = configparser.ConfigParser()
read = cp.read(str(path))
if not read:
raise FileNotFoundError(f"Config file not found: {path}")
cfg = IniConfig()
if cp.has_section("general"):
g = cp["general"]
cfg.subnet = g.get("net")
cfg.gateway = g.get("gw")
cfg.interface = g.get("interface")
cfg.log_target = g.get("log_target") or g.get("log-target")
for section in cp.sections():
if section == "general":
continue
s = cp[section]
ip = s.get("ip")
svc_raw = s.get("services")
services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None
cfg.deckies.append(DeckySpec(name=section, ip=ip, services=services))
return cfg

View File

View File

@@ -0,0 +1,36 @@
"""
Log forwarding helpers.
DECNET is agnostic to what receives logs — any TCP/UDP listener works
(Logstash, Splunk, Graylog, netcat, etc.).
Each service plugin handles the actual forwarding by injecting the
LOG_TARGET environment variable into its container. This module provides
shared utilities for validating and parsing the log_target string.
"""
import socket
def parse_log_target(log_target: str) -> tuple[str, int]:
"""
Parse "ip:port" into (host, port).
Raises ValueError on bad format.
"""
parts = log_target.rsplit(":", 1)
if len(parts) != 2 or not parts[1].isdigit():
raise ValueError(f"Invalid log_target '{log_target}'. Expected format: ip:port")
return parts[0], int(parts[1])
def probe_log_target(log_target: str, timeout: float = 2.0) -> bool:
"""
Return True if the log target is reachable (TCP connect succeeds).
Non-fatal — just used to warn the user before deployment.
"""
try:
host, port = parse_log_target(log_target)
with socket.create_connection((host, port), timeout=timeout):
return True
except (OSError, ValueError):
return False

214
decnet/network.py Normal file
View File

@@ -0,0 +1,214 @@
"""
Network management for DECNET.
Handles:
- Auto-detection of the host's active interface + subnet + gateway
- MACVLAN Docker network creation
- Host-side macvlan interface (hairpin fix so the deployer can reach deckies)
- IP allocation (sequential, skipping reserved addresses)
"""
import ipaddress
import os
import shutil
import socket
import subprocess
from ipaddress import IPv4Address, IPv4Interface, IPv4Network
import docker
MACVLAN_NETWORK_NAME = "decnet_lan"
HOST_MACVLAN_IFACE = "decnet_macvlan0"
# ---------------------------------------------------------------------------
# Interface / subnet auto-detection
# ---------------------------------------------------------------------------
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, check=check)
def detect_interface() -> str:
"""Return the name of the default outbound interface."""
result = _run(["ip", "route", "show", "default"])
for line in result.stdout.splitlines():
parts = line.split()
if "dev" in parts:
return parts[parts.index("dev") + 1]
raise RuntimeError("Could not auto-detect network interface. Use --interface.")
def detect_subnet(interface: str) -> tuple[str, str]:
"""
Return (subnet_cidr, gateway) for the given interface.
e.g. ("192.168.1.0/24", "192.168.1.1")
"""
result = _run(["ip", "addr", "show", interface])
subnet_cidr = None
for line in result.stdout.splitlines():
line = line.strip()
if line.startswith("inet ") and not line.startswith("inet6"):
# e.g. "inet 192.168.1.5/24 brd 192.168.1.255 scope global eth0"
addr_cidr = line.split()[1]
iface = IPv4Interface(addr_cidr)
subnet_cidr = str(iface.network)
break
if subnet_cidr is None:
raise RuntimeError(f"Could not detect subnet for interface {interface}.")
gw_result = _run(["ip", "route", "show", "default"])
gateway = None
for line in gw_result.stdout.splitlines():
parts = line.split()
if "via" in parts:
gateway = parts[parts.index("via") + 1]
break
if gateway is None:
raise RuntimeError("Could not detect gateway.")
return subnet_cidr, gateway
def get_host_ip(interface: str) -> str:
"""Return the host's IP on the given interface."""
result = _run(["ip", "addr", "show", interface])
for line in result.stdout.splitlines():
line = line.strip()
if line.startswith("inet ") and not line.startswith("inet6"):
return line.split()[1].split("/")[0]
raise RuntimeError(f"Could not determine host IP for interface {interface}.")
# ---------------------------------------------------------------------------
# IP allocation
# ---------------------------------------------------------------------------
def allocate_ips(
subnet: str,
gateway: str,
host_ip: str,
count: int,
ip_start: str | None = None,
) -> list[str]:
"""
Return a list of `count` available IPs from the subnet,
skipping network addr, broadcast, gateway, and host IP.
Starts from ip_start if given, else from the first usable host.
"""
net = IPv4Network(subnet, strict=False)
reserved = {
net.network_address,
net.broadcast_address,
IPv4Address(gateway),
IPv4Address(host_ip),
}
start_addr = IPv4Address(ip_start) if ip_start else net.network_address + 1
allocated: list[str] = []
for addr in net.hosts():
if addr < start_addr:
continue
if addr in reserved:
continue
allocated.append(str(addr))
if len(allocated) == count:
break
if len(allocated) < count:
raise RuntimeError(
f"Not enough free IPs in {subnet} for {count} deckies "
f"(found {len(allocated)})."
)
return allocated
# ---------------------------------------------------------------------------
# Docker MACVLAN network
# ---------------------------------------------------------------------------
def create_macvlan_network(
client: docker.DockerClient,
interface: str,
subnet: str,
gateway: str,
ip_range: str,
) -> None:
"""Create the MACVLAN Docker network. No-op if it already exists."""
existing = [n.name for n in client.networks.list()]
if MACVLAN_NETWORK_NAME in existing:
return
client.networks.create(
name=MACVLAN_NETWORK_NAME,
driver="macvlan",
options={"parent": interface},
ipam=docker.types.IPAMConfig(
driver="default",
pool_configs=[
docker.types.IPAMPool(
subnet=subnet,
gateway=gateway,
iprange=ip_range,
)
],
),
)
def remove_macvlan_network(client: docker.DockerClient) -> None:
nets = [n for n in client.networks.list() if n.name == MACVLAN_NETWORK_NAME]
for n in nets:
n.remove()
# ---------------------------------------------------------------------------
# Host-side macvlan interface (hairpin fix)
# ---------------------------------------------------------------------------
def _require_root() -> None:
if os.geteuid() != 0:
raise PermissionError(
"MACVLAN host-side interface setup requires root. Run with sudo."
)
def setup_host_macvlan(interface: str, host_macvlan_ip: str, decky_ip_range: str) -> None:
"""
Create a macvlan interface on the host so the deployer can reach deckies.
Idempotent — skips steps that are already done.
"""
_require_root()
# Check if interface already exists
result = _run(["ip", "link", "show", HOST_MACVLAN_IFACE], check=False)
if result.returncode != 0:
_run(["ip", "link", "add", HOST_MACVLAN_IFACE, "link", interface, "type", "macvlan", "mode", "bridge"])
_run(["ip", "addr", "add", f"{host_macvlan_ip}/32", "dev", HOST_MACVLAN_IFACE], check=False)
_run(["ip", "link", "set", HOST_MACVLAN_IFACE, "up"])
_run(["ip", "route", "add", decky_ip_range, "dev", HOST_MACVLAN_IFACE], check=False)
def teardown_host_macvlan(decky_ip_range: str) -> None:
_require_root()
_run(["ip", "route", "del", decky_ip_range, "dev", HOST_MACVLAN_IFACE], check=False)
_run(["ip", "link", "del", HOST_MACVLAN_IFACE], check=False)
# ---------------------------------------------------------------------------
# Compute an ip_range CIDR that covers a list of IPs
# ---------------------------------------------------------------------------
def ips_to_range(ips: list[str]) -> str:
"""
Given a list of IPs, return the tightest /N CIDR that covers them all.
Used as the --ip-range for MACVLAN so Docker assigns exactly those IPs.
"""
addrs = [IPv4Address(ip) for ip in ips]
network = IPv4Network(
(int(min(addrs)), 32 - (int(max(addrs)) - int(min(addrs))).bit_length()),
strict=False,
)
return str(network)

View File

36
decnet/services/base.py Normal file
View File

@@ -0,0 +1,36 @@
from abc import ABC, abstractmethod
from pathlib import Path
class BaseService(ABC):
"""
Contract every honeypot service plugin must implement.
To add a new service: subclass BaseService in a new file under decnet/services/.
The registry auto-discovers all subclasses at import time.
"""
name: str # unique slug, e.g. "ssh", "smb"
ports: list[int] # ports this service listens on inside the container
default_image: str # Docker image tag, or "build" if a Dockerfile is needed
@abstractmethod
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
"""
Return the docker-compose service dict for this service on a given decky.
Networking keys (networks, ipv4_address) are injected by the composer —
do NOT include them here. Include: image/build, environment, volumes,
restart, and any service-specific options.
Args:
decky_name: unique identifier for the decky (e.g. "decky-01")
log_target: "ip:port" string if log forwarding is enabled, else None
"""
def dockerfile_context(self) -> Path | None:
"""
Return path to the build context directory if this service needs a custom
image built. Return None if default_image is used directly.
"""
return None

26
decnet/services/ftp.py Normal file
View File

@@ -0,0 +1,26 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "ftp"
class FTPService(BaseService):
name = "ftp"
ports = [21]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-ftp",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
return fragment
def dockerfile_context(self) -> Path | None:
return TEMPLATES_DIR

26
decnet/services/http.py Normal file
View File

@@ -0,0 +1,26 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "http"
class HTTPService(BaseService):
name = "http"
ports = [80, 443]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-http",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
return fragment
def dockerfile_context(self) -> Path | None:
return TEMPLATES_DIR

26
decnet/services/rdp.py Normal file
View File

@@ -0,0 +1,26 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "rdp"
class RDPService(BaseService):
name = "rdp"
ports = [3389]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-rdp",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
return fragment
def dockerfile_context(self) -> Path | None:
return TEMPLATES_DIR

View File

@@ -0,0 +1,43 @@
"""
Service plugin registry.
Auto-discovers all BaseService subclasses by importing every module in the
services package. Adding a new service requires nothing beyond dropping a
new .py file here that subclasses BaseService.
"""
import importlib
import pkgutil
from pathlib import Path
from decnet.services.base import BaseService
_registry: dict[str, BaseService] = {}
_loaded = False
def _load_plugins() -> None:
global _loaded
if _loaded:
return
package_dir = Path(__file__).parent
for module_info in pkgutil.iter_modules([str(package_dir)]):
if module_info.name in ("base", "registry"):
continue
importlib.import_module(f"decnet.services.{module_info.name}")
for cls in BaseService.__subclasses__():
instance = cls()
_registry[instance.name] = instance
_loaded = True
def get_service(name: str) -> BaseService:
_load_plugins()
if name not in _registry:
raise KeyError(f"Unknown service: '{name}'. Available: {list(_registry)}")
return _registry[name]
def all_services() -> dict[str, BaseService]:
_load_plugins()
return dict(_registry)

27
decnet/services/smb.py Normal file
View File

@@ -0,0 +1,27 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "smb"
class SMBService(BaseService):
name = "smb"
ports = [445, 139]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-smb",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": {
"HONEYPOT_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
return fragment
def dockerfile_context(self) -> Path | None:
return TEMPLATES_DIR

30
decnet/services/ssh.py Normal file
View File

@@ -0,0 +1,30 @@
from decnet.services.base import BaseService
class SSHService(BaseService):
name = "ssh"
ports = [22, 2222]
default_image = "cowrie/cowrie"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
env: dict = {
# Override [honeypot] and [ssh] listen_endpoints to also bind port 22
"COWRIE_HONEYPOT_HOSTNAME": decky_name,
"COWRIE_HONEYPOT_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0",
"COWRIE_SSH_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0",
}
if log_target:
host, port = log_target.rsplit(":", 1)
env["COWRIE_OUTPUT_TCP_ENABLED"] = "true"
env["COWRIE_OUTPUT_TCP_HOST"] = host
env["COWRIE_OUTPUT_TCP_PORT"] = port
return {
"image": "cowrie/cowrie",
"container_name": f"{decky_name}-ssh",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": env,
}
def dockerfile_context(self):
return None

23
pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "decnet"
version = "0.1.0"
description = "Deception network: deploy honeypot deckies that appear as real LAN hosts"
requires-python = ">=3.11"
dependencies = [
"typer[all]>=0.12",
"pydantic>=2.0",
"docker>=7.0",
"pyyaml>=6.0",
"jinja2>=3.1",
]
[project.scripts]
decnet = "decnet.cli:app"
[tool.setuptools.packages.find]
where = ["."]
include = ["decnet*"]

View File

@@ -0,0 +1,32 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \
libssl-dev libffi-dev \
git authbind \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash cowrie
WORKDIR /home/cowrie
RUN python3 -m venv cowrie-env \
&& cowrie-env/bin/pip install --no-cache-dir cowrie jinja2
# Authbind to bind port 22 as non-root
RUN touch /etc/authbind/byport/22 /etc/authbind/byport/2222 \
&& chmod 500 /etc/authbind/byport/22 /etc/authbind/byport/2222 \
&& chown cowrie /etc/authbind/byport/22 /etc/authbind/byport/2222
RUN mkdir -p /home/cowrie/cowrie-env/etc \
/home/cowrie/cowrie-env/var/log/cowrie \
/home/cowrie/cowrie-env/var/run \
&& chown -R cowrie /home/cowrie/cowrie-env/etc \
/home/cowrie/cowrie-env/var
COPY cowrie.cfg.j2 /home/cowrie/cowrie.cfg.j2
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
USER cowrie
EXPOSE 22 2222
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,26 @@
[honeypot]
hostname = {{ COWRIE_HOSTNAME | default('svr01') }}
listen_endpoints = tcp:2222:interface=0.0.0.0
[ssh]
enabled = true
listen_endpoints = tcp:2222:interface=0.0.0.0
{% if COWRIE_LOG_HOST is defined and COWRIE_LOG_HOST %}
[output_jsonlog]
enabled = true
logfile = cowrie.json
[output_localsocket]
enabled = false
# Forward JSON events to SIEM/aggregator
[output_tcp]
enabled = true
host = {{ COWRIE_LOG_HOST }}
port = {{ COWRIE_LOG_PORT | default('5140') }}
{% else %}
[output_jsonlog]
enabled = true
logfile = cowrie.json
{% endif %}

View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
# Render Jinja2 template using the venv's python (has jinja2)
/home/cowrie/cowrie-env/bin/python3 - <<'EOF'
import os
from jinja2 import Template
with open("/home/cowrie/cowrie.cfg.j2") as f:
tpl = Template(f.read())
rendered = tpl.render(**os.environ)
with open("/home/cowrie/cowrie-env/etc/cowrie.cfg", "w") as f:
f.write(rendered)
EOF
exec authbind --deep /home/cowrie/cowrie-env/bin/twistd -n --pidfile= cowrie

14
templates/ftp/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir --break-system-packages twisted jinja2
COPY ftp_honeypot.py /opt/ftp_honeypot.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 21
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,3 @@
#!/bin/bash
set -e
exec python3 /opt/ftp_honeypot.py

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
FTP honeypot using Twisted's FTP server infrastructure.
Accepts any credentials, logs all commands and file requests,
forwards events as JSON to LOG_TARGET if set.
"""
import json
import os
import socket
import sys
from datetime import datetime, timezone
from twisted.internet import defer, protocol, reactor
from twisted.protocols.ftp import FTP, FTPFactory
from twisted.python import log as twisted_log
HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "ftpserver")
LOG_TARGET = os.environ.get("LOG_TARGET", "")
def _forward(event: dict) -> None:
if not LOG_TARGET:
return
try:
host, port = LOG_TARGET.rsplit(":", 1)
with socket.create_connection((host, int(port)), timeout=3) as s:
s.sendall((json.dumps(event) + "\n").encode())
except Exception:
pass
def _log(event_type: str, **kwargs) -> None:
event = {
"ts": datetime.now(timezone.utc).isoformat(),
"service": "ftp",
"host": HONEYPOT_NAME,
"event": event_type,
**kwargs,
}
print(json.dumps(event), flush=True)
_forward(event)
class HoneypotFTP(FTP):
def connectionMade(self):
peer = self.transport.getPeer()
_log("connection", src_ip=peer.host, src_port=peer.port)
super().connectionMade()
def ftp_USER(self, username):
self._honeypot_user = username
_log("user", username=username)
return super().ftp_USER(username)
def ftp_PASS(self, password):
_log("auth_attempt", username=getattr(self, "_honeypot_user", "?"), password=password)
# Accept everything — we're a honeypot
self.state = self.AUTHED
self._user = getattr(self, "_honeypot_user", "anonymous")
return defer.succeed((230, "Login successful."))
def ftp_RETR(self, path):
_log("download_attempt", path=path)
self.sendLine(b"550 File unavailable.")
return defer.succeed(None)
def connectionLost(self, reason):
peer = self.transport.getPeer()
_log("disconnect", src_ip=peer.host, src_port=peer.port)
super().connectionLost(reason)
class HoneypotFTPFactory(FTPFactory):
protocol = HoneypotFTP
if __name__ == "__main__":
twisted_log.startLogging(sys.stdout)
_log("startup", msg=f"FTP honeypot starting as {HONEYPOT_NAME} on port 21")
reactor.listenTCP(21, HoneypotFTPFactory())
reactor.run()

14
templates/http/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir --break-system-packages flask jinja2
COPY http_honeypot.py /opt/http_honeypot.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80 443
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,3 @@
#!/bin/bash
set -e
exec python3 /opt/http_honeypot.py

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
HTTP honeypot using Flask.
Accepts all requests, logs every detail (method, path, headers, body),
and responds with convincing but empty pages. Forwards events as JSON
to LOG_TARGET if set.
"""
import json
import os
import socket
from datetime import datetime, timezone
from flask import Flask, request
HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "webserver")
LOG_TARGET = os.environ.get("LOG_TARGET", "")
app = Flask(__name__)
def _forward(event: dict) -> None:
if not LOG_TARGET:
return
try:
host, port = LOG_TARGET.rsplit(":", 1)
with socket.create_connection((host, int(port)), timeout=3) as s:
s.sendall((json.dumps(event) + "\n").encode())
except Exception:
pass
def _log(event_type: str, **kwargs) -> None:
event = {
"ts": datetime.now(timezone.utc).isoformat(),
"service": "http",
"host": HONEYPOT_NAME,
"event": event_type,
**kwargs,
}
print(json.dumps(event), flush=True)
_forward(event)
@app.before_request
def log_request():
_log(
"request",
method=request.method,
path=request.path,
remote_addr=request.remote_addr,
headers=dict(request.headers),
body=request.get_data(as_text=True)[:512],
)
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
def catch_all(path):
return (
"<html><body><h1>403 Forbidden</h1></body></html>",
403,
{"Server": "Apache/2.4.54 (Debian)", "Content-Type": "text/html"},
)
if __name__ == "__main__":
_log("startup", msg=f"HTTP honeypot starting as {HONEYPOT_NAME}")
app.run(host="0.0.0.0", port=80, debug=False)

14
templates/rdp/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir --break-system-packages twisted jinja2
COPY rdp_honeypot.py /opt/rdp_honeypot.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 3389
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,3 @@
#!/bin/bash
set -e
exec python3 /opt/rdp_honeypot.py

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
Minimal RDP honeypot using Twisted.
Listens on port 3389, logs connection attempts and any credentials sent
in the initial RDP negotiation request. Forwards events as JSON to
LOG_TARGET if set.
"""
import json
import os
import socket
import sys
from datetime import datetime, timezone
from twisted.internet import protocol, reactor
from twisted.python import log as twisted_log
HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "WORKSTATION")
LOG_TARGET = os.environ.get("LOG_TARGET", "")
def _forward(event: dict) -> None:
if not LOG_TARGET:
return
try:
host, port = LOG_TARGET.rsplit(":", 1)
with socket.create_connection((host, int(port)), timeout=3) as s:
s.sendall((json.dumps(event) + "\n").encode())
except Exception:
pass
def _log(event_type: str, **kwargs) -> None:
event = {
"ts": datetime.now(timezone.utc).isoformat(),
"service": "rdp",
"host": HONEYPOT_NAME,
"event": event_type,
**kwargs,
}
print(json.dumps(event), flush=True)
_forward(event)
class RDPHoneypotProtocol(protocol.Protocol):
def connectionMade(self):
peer = self.transport.getPeer()
_log("connection", src_ip=peer.host, src_port=peer.port)
# Send a minimal RDP Connection Confirm PDU to keep clients talking
# X.224 Connection Confirm: length=0x0e, type=0xd0 (CC), dst=0, src=0, class=0
self.transport.write(b"\x03\x00\x00\x0b\x06\xd0\x00\x00\x00\x00\x00")
def dataReceived(self, data: bytes):
peer = self.transport.getPeer()
_log("data", src_ip=peer.host, src_port=peer.port, bytes=len(data), hex=data[:64].hex())
# Drop the connection after receiving data — we're just a logger
self.transport.loseConnection()
def connectionLost(self, reason):
peer = self.transport.getPeer()
_log("disconnect", src_ip=peer.host, src_port=peer.port)
class RDPHoneypotFactory(protocol.ServerFactory):
protocol = RDPHoneypotProtocol
if __name__ == "__main__":
twisted_log.startLogging(sys.stdout)
_log("startup", msg=f"RDP honeypot starting as {HONEYPOT_NAME} on port 3389")
reactor.listenTCP(3389, RDPHoneypotFactory())
reactor.run()

14
templates/smb/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir --break-system-packages impacket jinja2
COPY smb_honeypot.py /opt/smb_honeypot.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 445 139
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,4 @@
#!/bin/bash
set -e
mkdir -p /tmp/smb_share
exec python3 /opt/smb_honeypot.py

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Minimal SMB honeypot using Impacket's SimpleSMBServer.
Logs all connection attempts, optionally forwarding them as JSON to LOG_TARGET.
"""
import json
import os
import socket
from datetime import datetime, timezone
from impacket import smbserver
HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "WORKSTATION")
LOG_TARGET = os.environ.get("LOG_TARGET", "")
def _forward(event: dict) -> None:
if not LOG_TARGET:
return
try:
host, port = LOG_TARGET.rsplit(":", 1)
with socket.create_connection((host, int(port)), timeout=3) as s:
s.sendall((json.dumps(event) + "\n").encode())
except Exception:
pass
def _log(event_type: str, **kwargs) -> None:
event = {
"ts": datetime.now(timezone.utc).isoformat(),
"service": "smb",
"host": HONEYPOT_NAME,
"event": event_type,
**kwargs,
}
print(json.dumps(event), flush=True)
_forward(event)
if __name__ == "__main__":
_log("startup", msg=f"SMB honeypot starting as {HONEYPOT_NAME}")
os.makedirs("/tmp/smb_share", exist_ok=True)
server = smbserver.SimpleSMBServer(listenAddress="0.0.0.0", listenPort=445)
server.setSMB2Support(True)
server.setSMBChallenge("")
server.addShare("SHARE", "/tmp/smb_share", "Shared Documents")
try:
server.start()
except KeyboardInterrupt:
_log("shutdown")