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:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
48
CLAUDE.md
Normal 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
53
NOTES.md
Normal 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
139
README.md
Normal 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
0
decnet/__init__.py
Normal file
280
decnet/cli.py
Normal file
280
decnet/cli.py
Normal 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
87
decnet/composer.py
Normal 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
82
decnet/config.py
Normal 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
147
decnet/deployer.py
Normal 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
68
decnet/ini_loader.py
Normal 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
|
||||||
0
decnet/logging/__init__.py
Normal file
0
decnet/logging/__init__.py
Normal file
36
decnet/logging/forwarder.py
Normal file
36
decnet/logging/forwarder.py
Normal 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
214
decnet/network.py
Normal 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)
|
||||||
0
decnet/services/__init__.py
Normal file
0
decnet/services/__init__.py
Normal file
36
decnet/services/base.py
Normal file
36
decnet/services/base.py
Normal 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
26
decnet/services/ftp.py
Normal 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
26
decnet/services/http.py
Normal 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
26
decnet/services/rdp.py
Normal 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
|
||||||
43
decnet/services/registry.py
Normal file
43
decnet/services/registry.py
Normal 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
27
decnet/services/smb.py
Normal 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
30
decnet/services/ssh.py
Normal 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
23
pyproject.toml
Normal 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*"]
|
||||||
32
templates/cowrie/Dockerfile
Normal file
32
templates/cowrie/Dockerfile
Normal 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"]
|
||||||
26
templates/cowrie/cowrie.cfg.j2
Normal file
26
templates/cowrie/cowrie.cfg.j2
Normal 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 %}
|
||||||
18
templates/cowrie/entrypoint.sh
Normal file
18
templates/cowrie/entrypoint.sh
Normal 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
14
templates/ftp/Dockerfile
Normal 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"]
|
||||||
3
templates/ftp/entrypoint.sh
Normal file
3
templates/ftp/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
exec python3 /opt/ftp_honeypot.py
|
||||||
82
templates/ftp/ftp_honeypot.py
Normal file
82
templates/ftp/ftp_honeypot.py
Normal 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
14
templates/http/Dockerfile
Normal 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"]
|
||||||
3
templates/http/entrypoint.sh
Normal file
3
templates/http/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
exec python3 /opt/http_honeypot.py
|
||||||
69
templates/http/http_honeypot.py
Normal file
69
templates/http/http_honeypot.py
Normal 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
14
templates/rdp/Dockerfile
Normal 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"]
|
||||||
3
templates/rdp/entrypoint.sh
Normal file
3
templates/rdp/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
exec python3 /opt/rdp_honeypot.py
|
||||||
72
templates/rdp/rdp_honeypot.py
Normal file
72
templates/rdp/rdp_honeypot.py
Normal 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
14
templates/smb/Dockerfile
Normal 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"]
|
||||||
4
templates/smb/entrypoint.sh
Normal file
4
templates/smb/entrypoint.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
mkdir -p /tmp/smb_share
|
||||||
|
exec python3 /opt/smb_honeypot.py
|
||||||
52
templates/smb/smb_honeypot.py
Normal file
52
templates/smb/smb_honeypot.py
Normal 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")
|
||||||
Reference in New Issue
Block a user