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

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()