feat: implement dynamic decky mutation and fix dot-separated INI sections

This commit is contained in:
2026-04-08 00:16:57 -04:00
parent 1f5c6604d6
commit 18de381a43
401 changed files with 938 additions and 74 deletions

View File

@@ -116,9 +116,12 @@ def _build_deckies_from_ini(
gateway: str,
host_ip: str,
randomize: bool,
cli_mutate_interval: int | None = None,
) -> list[DeckyConfig]:
"""Build DeckyConfig list from an IniConfig, auto-allocating missing IPs."""
from ipaddress import IPv4Address, IPv4Network
import time
now = time.time()
explicit_ips: set[IPv4Address] = {
IPv4Address(s.ip) for s in ini.deckies if s.ip
@@ -181,6 +184,12 @@ def _build_deckies_from_ini(
# nmap_os priority: explicit INI key > archetype default > "linux"
resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "linux")
# mutation interval priority: CLI > per-decky INI > global INI
decky_mutate_interval = cli_mutate_interval
if decky_mutate_interval is None:
decky_mutate_interval = spec.mutate_interval if spec.mutate_interval is not None else ini.mutate_interval
deckies.append(DeckyConfig(
name=spec.name,
ip=ip,
@@ -192,8 +201,10 @@ def _build_deckies_from_ini(
archetype=arch.slug if arch else None,
service_config=spec.service_config,
nmap_os=resolved_nmap_os,
mutate_interval=decky_mutate_interval,
last_mutated=now,
))
return deckies
return deckies
@app.command()
@@ -210,6 +221,7 @@ def deploy(
log_target: Optional[str] = typer.Option(None, "--log-target", help="Forward logs to ip:port (e.g. 192.168.1.5:5140)"),
log_file: Optional[str] = typer.Option(None, "--log-file", help="Write RFC 5424 syslog to this path inside containers (e.g. /var/log/decnet/decnet.log)"),
archetype_name: Optional[str] = typer.Option(None, "--archetype", "-a", help="Machine archetype slug (e.g. linux-server, windows-workstation)"),
mutate_interval: Optional[int] = typer.Option(30, "--mutate-interval", help="Automatically rotate services every N minutes"),
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"),
ipvlan: bool = typer.Option(False, "--ipvlan", help="Use IPvlan L2 instead of MACVLAN (required on WiFi interfaces)"),
@@ -264,7 +276,7 @@ def deploy(
effective_log_target = log_target or ini.log_target
effective_log_file = log_file
decky_configs = _build_deckies_from_ini(
ini, subnet_cidr, effective_gateway, host_ip, randomize_services
ini, subnet_cidr, effective_gateway, host_ip, randomize_services, cli_mutate_interval=mutate_interval
)
# ------------------------------------------------------------------ #
# Classic CLI path #
@@ -319,7 +331,7 @@ def deploy(
decky_configs = _build_deckies(
deckies, ips, services_list, randomize_services,
distros_explicit=distros_list, randomize_distros=randomize_distros,
archetype=arch,
archetype=arch, mutate_interval=mutate_interval,
)
effective_log_target = log_target
effective_log_file = log_file
@@ -338,6 +350,7 @@ def deploy(
log_target=effective_log_target,
log_file=effective_log_file,
ipvlan=ipvlan,
mutate_interval=mutate_interval,
)
if effective_log_target and not dry_run:
@@ -349,6 +362,19 @@ def deploy(
from decnet.deployer import deploy as _deploy
_deploy(config, dry_run=dry_run, no_cache=no_cache)
if mutate_interval is not None and not dry_run:
import subprocess
import sys
console.print(f"[green]Starting DECNET Mutator watcher in the background (interval: {mutate_interval}m)...[/]")
try:
subprocess.Popen(
[sys.executable, "-m", "decnet.cli", "mutate", "--watch"],
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT
)
except (FileNotFoundError, subprocess.SubprocessError):
console.print("[red]Failed to start mutator watcher.[/]")
if api and not dry_run:
import subprocess
import sys
@@ -367,6 +393,27 @@ def deploy(
console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]")
@app.command()
def mutate(
watch: bool = typer.Option(False, "--watch", "-w", help="Run continuously and mutate deckies according to their interval"),
decky_name: Optional[str] = typer.Option(None, "--decky", "-d", help="Force mutate a specific decky immediately"),
force_all: bool = typer.Option(False, "--all", help="Force mutate all deckies immediately"),
) -> None:
"""Manually trigger or continuously watch for decky mutation."""
from decnet.mutator import mutate_decky, mutate_all, run_watch_loop
if watch:
run_watch_loop()
return
if decky_name:
mutate_decky(decky_name)
elif force_all:
mutate_all(force=True)
else:
mutate_all(force=False)
@app.command()
def status() -> None:
"""Show running deckies and their status."""

View File

@@ -14,6 +14,7 @@ from decnet.distros import random_hostname as _random_hostname
# Calculate absolute path to the project root (where the config file resides)
_ROOT: Path = Path(__file__).parent.parent.absolute()
STATE_FILE: Path = _ROOT / "decnet-state.json"
DEFAULT_MUTATE_INTERVAL: int = 30 # default rotation interval in minutes
def random_hostname(distro_slug: str = "debian") -> str:
@@ -31,6 +32,8 @@ class DeckyConfig(BaseModel):
archetype: str | None = None # archetype slug if spawned from an archetype profile
service_config: dict[str, dict] = {} # optional per-service persona config
nmap_os: str = "linux" # OS family for TCP/IP stack spoofing (see os_fingerprint.py)
mutate_interval: int | None = None # automatic rotation interval in minutes
last_mutated: float = 0.0 # timestamp of last mutation
@field_validator("services")
@classmethod
@@ -49,6 +52,7 @@ class DecnetConfig(BaseModel):
log_target: str | None = None # "ip:port" or None
log_file: str | None = None # path for RFC 5424 syslog file output
ipvlan: bool = False # use IPvlan L2 instead of MACVLAN (WiFi-friendly)
mutate_interval: int | None = DEFAULT_MUTATE_INTERVAL # global automatic rotation interval in minutes
@field_validator("log_target")
@classmethod

View File

@@ -132,7 +132,7 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False)
def _kill_api() -> None:
"""Find and kill any running DECNET API (uvicorn) processes."""
"""Find and kill any running DECNET API (uvicorn) or mutator processes."""
import psutil
import signal
import os
@@ -141,15 +141,21 @@ def _kill_api() -> None:
for _proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
_cmd = _proc.info['cmdline']
if _cmd and "uvicorn" in _cmd and "decnet.web.api:app" in _cmd:
if not _cmd:
continue
if "uvicorn" in _cmd and "decnet.web.api:app" in _cmd:
console.print(f"[yellow]Stopping DECNET API (PID {_proc.info['pid']})...[/]")
os.kill(_proc.info['pid'], signal.SIGTERM)
_killed = True
elif "decnet.cli" in _cmd and "mutate" in _cmd and "--watch" in _cmd:
console.print(f"[yellow]Stopping DECNET Mutator Watcher (PID {_proc.info['pid']})...[/]")
os.kill(_proc.info['pid'], signal.SIGTERM)
_killed = True
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if _killed:
console.print("[green]API stopped.[/]")
console.print("[green]Background processes stopped.[/]")
def teardown(decky_id: str | None = None) -> None:

View File

@@ -54,6 +54,7 @@ class DeckySpec:
archetype: str | None = None
service_config: dict[str, dict] = field(default_factory=dict)
nmap_os: str | None = None # explicit OS family override (linux/windows/bsd/embedded/cisco)
mutate_interval: int | None = None
@dataclass
@@ -71,6 +72,7 @@ class IniConfig:
gateway: str | None = None
interface: str | None = None
log_target: str | None = None
mutate_interval: int | None = None
deckies: list[DeckySpec] = field(default_factory=list)
custom_services: list[CustomServiceSpec] = field(default_factory=list)
@@ -91,12 +93,23 @@ def load_ini(path: str | Path) -> IniConfig:
cfg.interface = g.get("interface")
cfg.log_target = g.get("log_target") or g.get("log-target")
from decnet.services.registry import all_services
known_services = set(all_services().keys())
# First pass: collect decky sections and custom service definitions
for section in cp.sections():
if section == "general":
continue
# A service sub-section is identified if the section name has at least one dot
# AND the last segment is a known service name.
# e.g. "decky-01.ssh" -> sub-section
# e.g. "decky.webmail" -> decky section (if "webmail" is not a service)
if "." in section:
continue # subsections handled in second pass
_, _, last_segment = section.rpartition(".")
if last_segment in known_services:
continue # sub-section handled in second pass
if section.startswith("custom-"):
# Bring-your-own service definition
s = cp[section]
@@ -115,6 +128,15 @@ def load_ini(path: str | Path) -> IniConfig:
services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None
archetype = s.get("archetype")
nmap_os = s.get("nmap_os") or s.get("nmap-os") or None
mi_raw = s.get("mutate_interval") or s.get("mutate-interval")
mutate_interval = None
if mi_raw:
try:
mutate_interval = int(mi_raw)
except ValueError:
raise ValueError(f"[{section}] mutate_interval= must be an integer, got '{mi_raw}'")
amount_raw = s.get("amount", "1")
try:
amount = int(amount_raw)
@@ -125,7 +147,7 @@ def load_ini(path: str | Path) -> IniConfig:
if amount == 1:
cfg.deckies.append(DeckySpec(
name=section, ip=ip, services=services, archetype=archetype, nmap_os=nmap_os,
name=section, ip=ip, services=services, archetype=archetype, nmap_os=nmap_os, mutate_interval=mutate_interval,
))
else:
# Expand into N deckies; explicit ip is ignored (can't share one IP)
@@ -141,6 +163,7 @@ def load_ini(path: str | Path) -> IniConfig:
services=services,
archetype=archetype,
nmap_os=nmap_os,
mutate_interval=mutate_interval,
))
# Second pass: collect per-service subsections [decky-name.service]
@@ -149,7 +172,11 @@ def load_ini(path: str | Path) -> IniConfig:
for section in cp.sections():
if "." not in section:
continue
decky_name, _, svc_name = section.partition(".")
decky_name, dot, svc_name = section.rpartition(".")
if svc_name not in known_services:
continue # not a service sub-section
svc_cfg = {k: v for k, v in cp[section].items()}
if decky_name in decky_map:
# Direct match — single decky

152
decnet/mutator.py Normal file
View File

@@ -0,0 +1,152 @@
"""
Mutation Engine for DECNET.
Handles dynamic rotation of exposed honeypot services over time.
"""
import random
import subprocess
import time
from pathlib import Path
from typing import Optional
from rich.console import Console
from decnet.archetypes import get_archetype
from decnet.cli import _all_service_names
from decnet.composer import write_compose
from decnet.config import DeckyConfig, load_state, save_state
from decnet.deployer import COMPOSE_FILE
console = Console()
def _compose_with_retry(
*args: str,
compose_file: Path = COMPOSE_FILE,
retries: int = 3,
delay: float = 5.0,
) -> None:
"""Run a docker compose command, retrying on transient failures."""
last_exc: subprocess.CalledProcessError | None = None
cmd = ["docker", "compose", "-f", str(compose_file), *args]
for attempt in range(1, retries + 1):
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
if result.stdout:
print(result.stdout, end="")
return
last_exc = subprocess.CalledProcessError(
result.returncode, cmd, result.stdout, result.stderr
)
if attempt < retries:
time.sleep(delay)
delay *= 2
raise last_exc
def mutate_decky(decky_name: str) -> bool:
"""
Perform an Intra-Archetype Shuffle for a specific decky.
Returns True if mutation succeeded, False otherwise.
"""
state = load_state()
if state is None:
console.print("[red]No active deployment found (no decnet-state.json).[/]")
return False
config, compose_path = state
decky: Optional[DeckyConfig] = next((d for d in config.deckies if d.name == decky_name), None)
if not decky:
console.print(f"[red]Decky '{decky_name}' not found in state.[/]")
return False
# Determine allowed services pool
if decky.archetype:
try:
arch = get_archetype(decky.archetype)
svc_pool = list(arch.services)
except ValueError:
svc_pool = _all_service_names()
else:
svc_pool = _all_service_names()
if not svc_pool:
console.print(f"[yellow]No services available for mutating '{decky_name}'.[/]")
return False
# Prevent mutating to the exact same set if possible
current_services = set(decky.services)
attempts = 0
while True:
count = random.randint(1, min(3, len(svc_pool)))
chosen = set(random.sample(svc_pool, count))
attempts += 1
if chosen != current_services or attempts > 20:
break
decky.services = list(chosen)
decky.last_mutated = time.time()
# Save new state
save_state(config, compose_path)
# Regenerate compose file
write_compose(config, compose_path)
console.print(f"[cyan]Mutating '{decky_name}' to services: {', '.join(decky.services)}[/]")
# Bring up the new services and remove old orphans
try:
_compose_with_retry("up", "-d", "--remove-orphans", compose_file=compose_path)
except subprocess.CalledProcessError as e:
console.print(f"[red]Failed to mutate '{decky_name}': {e.stderr}[/]")
return False
return True
def mutate_all(force: bool = False) -> None:
"""
Check all deckies and mutate those that are due.
If force=True, mutates all deckies regardless of schedule.
"""
state = load_state()
if state is None:
console.print("[red]No active deployment found.[/]")
return
config, _ = state
now = time.time()
mutated_count = 0
for decky in config.deckies:
interval_mins = decky.mutate_interval or config.mutate_interval
if interval_mins is None and not force:
continue
if force:
due = True
else:
elapsed_secs = now - decky.last_mutated
due = elapsed_secs >= (interval_mins * 60)
if due:
success = mutate_decky(decky.name)
if success:
mutated_count += 1
# Re-load state for next decky just in case, but mutate_decky saves it.
# However, mutate_decky operates on its own loaded state.
# Since mutate_decky loads and saves the state, our loop over `config.deckies`
# has an outdated `last_mutated` if we don't reload. It's fine because we process one by one.
if mutated_count == 0 and not force:
console.print("[dim]No deckies are due for mutation.[/]")
def run_watch_loop(poll_interval_secs: int = 10) -> None:
"""Run an infinite loop checking for deckies that need mutation."""
console.print(f"[green]DECNET Mutator Watcher started (polling every {poll_interval_secs}s).[/]")
try:
while True:
mutate_all(force=False)
time.sleep(poll_interval_secs)
except KeyboardInterrupt:
console.print("\n[dim]Mutator watcher stopped.[/]")

View File

@@ -177,3 +177,31 @@ async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str,
@app.get("/api/v1/deckies")
async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]:
return await repo.get_deckies()
class MutateIntervalRequest(BaseModel):
mutate_interval: int | None
@app.post("/api/v1/deckies/{decky_name}/mutate")
async def api_mutate_decky(decky_name: str, current_user: str = Depends(get_current_user)) -> dict[str, str]:
from decnet.mutator import mutate_decky
success = mutate_decky(decky_name)
if success:
return {"message": f"Successfully mutated {decky_name}"}
raise HTTPException(status_code=404, detail=f"Decky {decky_name} not found or failed to mutate")
@app.put("/api/v1/deckies/{decky_name}/mutate-interval")
async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
from decnet.config import load_state, save_state
state = load_state()
if not state:
raise HTTPException(status_code=500, detail="No active deployment")
config, compose_path = state
decky = next((d for d in config.deckies if d.name == decky_name), None)
if not decky:
raise HTTPException(status_code=404, detail="Decky not found")
decky.mutate_interval = req.mutate_interval
save_state(config, compose_path)
return {"message": "Mutation interval updated"}