The 1,878-line cli.py held every Typer command plus process/HTTP helpers and mode-gating logic. Split into one module per command using a register(app) pattern so submodules never import app at module scope, eliminating circular-import risk. - utils.py: process helpers, _http_request, _kill_all_services, console, log - gating.py: MASTER_ONLY_* sets, _require_master_mode, _gate_commands_by_mode - deploy.py: deploy + _deploy_swarm (tightly coupled) - lifecycle.py: status, teardown, redeploy - workers.py: probe, collect, mutate, correlate - inventory.py, swarm.py, db.py, and one file per remaining command __init__.py calls register(app) on each module then runs the mode gate last, and re-exports the private symbols tests patch against (_db_reset_mysql_async, _kill_all_services, _require_master_mode, etc.). Test patches retargeted to the submodule where each name now resolves. Enroll-bundle tarball test updated to assert decnet/cli/__init__.py. No behavioral change.
308 lines
15 KiB
Python
308 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
import typer
|
|
from rich.table import Table
|
|
|
|
from decnet.archetypes import Archetype, get_archetype
|
|
from decnet.config import DecnetConfig
|
|
from decnet.distros import get_distro
|
|
from decnet.env import DECNET_API_HOST, DECNET_INGEST_LOG_FILE
|
|
from decnet.fleet import all_service_names, build_deckies, build_deckies_from_ini
|
|
from decnet.ini_loader import load_ini
|
|
from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip
|
|
|
|
from . import utils as _utils
|
|
from .gating import _require_master_mode
|
|
from .utils import console, log
|
|
|
|
|
|
def _deploy_swarm(config: "DecnetConfig", *, dry_run: bool, no_cache: bool) -> None:
|
|
"""Shard deckies round-robin across enrolled workers and POST to swarmctl."""
|
|
base = _utils._swarmctl_base_url(None)
|
|
resp = _utils._http_request("GET", base + "/swarm/hosts?host_status=enrolled")
|
|
enrolled = resp.json()
|
|
resp2 = _utils._http_request("GET", base + "/swarm/hosts?host_status=active")
|
|
active = resp2.json()
|
|
workers = [*enrolled, *active]
|
|
if not workers:
|
|
console.print("[red]No enrolled workers — run `decnet swarm enroll ...` first.[/]")
|
|
raise typer.Exit(1)
|
|
|
|
assigned: list = []
|
|
for idx, d in enumerate(config.deckies):
|
|
target = workers[idx % len(workers)]
|
|
assigned.append(d.model_copy(update={"host_uuid": target["uuid"]}))
|
|
config = config.model_copy(update={"deckies": assigned})
|
|
|
|
body = {"config": config.model_dump(mode="json"), "dry_run": dry_run, "no_cache": no_cache}
|
|
console.print(f"[cyan]Dispatching {len(config.deckies)} deckies across {len(workers)} worker(s)...[/]")
|
|
resp3 = _utils._http_request("POST", base + "/swarm/deploy", json_body=body, timeout=900.0)
|
|
results = resp3.json().get("results", [])
|
|
|
|
table = Table(title="SWARM deploy results")
|
|
for col in ("worker", "host_uuid", "ok", "detail"):
|
|
table.add_column(col)
|
|
any_failed = False
|
|
for r in results:
|
|
ok = bool(r.get("ok"))
|
|
if not ok:
|
|
any_failed = True
|
|
detail = r.get("detail")
|
|
if isinstance(detail, dict):
|
|
detail = detail.get("status") or "ok"
|
|
table.add_row(
|
|
str(r.get("host_name") or ""),
|
|
str(r.get("host_uuid") or ""),
|
|
"[green]yes[/]" if ok else "[red]no[/]",
|
|
str(detail)[:80],
|
|
)
|
|
console.print(table)
|
|
if any_failed:
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def register(app: typer.Typer) -> None:
|
|
@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"),
|
|
distro: Optional[str] = typer.Option(None, "--distro", help="Comma-separated distro slugs, e.g. debian,ubuntu22,rocky9"),
|
|
randomize_distros: bool = typer.Option(False, "--randomize-distros", help="Assign a random distro to each decky"),
|
|
log_file: Optional[str] = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", help="Host path for the collector to write RFC 5424 logs (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"),
|
|
parallel: bool = typer.Option(False, "--parallel", help="Build all images concurrently (enables BuildKit, separates build from up)"),
|
|
ipvlan: bool = typer.Option(False, "--ipvlan", help="Use IPvlan L2 instead of MACVLAN (required on WiFi interfaces)"),
|
|
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"),
|
|
api: bool = typer.Option(False, "--api", help="Start the FastAPI backend to ingest and serve logs"),
|
|
api_port: int = typer.Option(8000, "--api-port", help="Port for the backend API"),
|
|
daemon: bool = typer.Option(False, "--daemon", help="Detach to background as a daemon process"),
|
|
) -> None:
|
|
"""Deploy deckies to the LAN."""
|
|
import os
|
|
import subprocess # nosec B404
|
|
import sys
|
|
from pathlib import Path as _Path
|
|
|
|
_require_master_mode("deploy")
|
|
if daemon:
|
|
log.info("deploy daemonizing mode=%s deckies=%s", mode, deckies)
|
|
_utils._daemonize()
|
|
|
|
log.info("deploy command invoked mode=%s deckies=%s dry_run=%s", mode, deckies, dry_run)
|
|
if mode not in ("unihost", "swarm"):
|
|
console.print("[red]--mode must be 'unihost' or 'swarm'[/]")
|
|
raise typer.Exit(1)
|
|
|
|
if config_file:
|
|
try:
|
|
ini = load_ini(config_file)
|
|
except FileNotFoundError as e:
|
|
console.print(f"[red]{e}[/]")
|
|
raise typer.Exit(1)
|
|
|
|
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}")
|
|
|
|
if ini.custom_services:
|
|
from decnet.custom_service import CustomService
|
|
from decnet.services.registry import register_custom_service
|
|
for cs in ini.custom_services:
|
|
register_custom_service(
|
|
CustomService(
|
|
name=cs.name,
|
|
image=cs.image,
|
|
exec_cmd=cs.exec_cmd,
|
|
ports=cs.ports,
|
|
)
|
|
)
|
|
|
|
effective_log_file = log_file
|
|
try:
|
|
decky_configs = build_deckies_from_ini(
|
|
ini, subnet_cidr, effective_gateway, host_ip, randomize_services, cli_mutate_interval=mutate_interval
|
|
)
|
|
except ValueError as e:
|
|
console.print(f"[red]{e}[/]")
|
|
raise typer.Exit(1)
|
|
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)
|
|
|
|
arch: Archetype | None = None
|
|
if archetype_name:
|
|
try:
|
|
arch = get_archetype(archetype_name)
|
|
except ValueError as e:
|
|
console.print(f"[red]{e}[/]")
|
|
raise typer.Exit(1)
|
|
|
|
if not services_list and not randomize_services and not arch:
|
|
console.print("[red]Specify --services, --archetype, 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}")
|
|
|
|
distros_list = [d.strip() for d in distro.split(",")] if distro else None
|
|
if distros_list:
|
|
try:
|
|
for slug in distros_list:
|
|
get_distro(slug)
|
|
except ValueError as e:
|
|
console.print(f"[red]{e}[/]")
|
|
raise typer.Exit(1)
|
|
|
|
ips = allocate_ips(subnet_cidr, effective_gateway, host_ip, deckies, ip_start)
|
|
decky_configs = build_deckies(
|
|
deckies, ips, services_list, randomize_services,
|
|
distros_explicit=distros_list, randomize_distros=randomize_distros,
|
|
archetype=arch, mutate_interval=mutate_interval,
|
|
)
|
|
effective_log_file = log_file
|
|
|
|
if api and not effective_log_file:
|
|
effective_log_file = os.path.join(os.getcwd(), "decnet.log")
|
|
console.print(f"[cyan]API mode enabled: defaulting log-file to {effective_log_file}[/]")
|
|
|
|
config = DecnetConfig(
|
|
mode=mode,
|
|
interface=iface,
|
|
subnet=subnet_cidr,
|
|
gateway=effective_gateway,
|
|
deckies=decky_configs,
|
|
log_file=effective_log_file,
|
|
ipvlan=ipvlan,
|
|
mutate_interval=mutate_interval,
|
|
)
|
|
|
|
log.debug("deploy: config built deckies=%d interface=%s subnet=%s", len(config.deckies), config.interface, config.subnet)
|
|
|
|
if mode == "swarm":
|
|
_deploy_swarm(config, dry_run=dry_run, no_cache=no_cache)
|
|
if dry_run:
|
|
log.info("deploy: swarm dry-run complete, no workers dispatched")
|
|
else:
|
|
log.info("deploy: swarm deployment complete deckies=%d", len(config.deckies))
|
|
return
|
|
|
|
from decnet.engine import deploy as _deploy
|
|
_deploy(config, dry_run=dry_run, no_cache=no_cache, parallel=parallel)
|
|
if dry_run:
|
|
log.info("deploy: dry-run complete, no containers started")
|
|
else:
|
|
log.info("deploy: deployment complete deckies=%d", len(config.deckies))
|
|
|
|
if mutate_interval is not None and not dry_run:
|
|
console.print(f"[green]Starting DECNET Mutator watcher in the background (interval: {mutate_interval}m)...[/]")
|
|
try:
|
|
subprocess.Popen( # nosec B603
|
|
[sys.executable, "-m", "decnet.cli", "mutate", "--watch"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
except (FileNotFoundError, subprocess.SubprocessError):
|
|
console.print("[red]Failed to start mutator watcher.[/]")
|
|
|
|
if effective_log_file and not dry_run and not api:
|
|
_collector_err = _Path(effective_log_file).with_suffix(".collector.log")
|
|
console.print(f"[bold cyan]Starting log collector[/] → {effective_log_file}")
|
|
subprocess.Popen( # nosec B603
|
|
[sys.executable, "-m", "decnet.cli", "collect", "--log-file", str(effective_log_file)],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=open(_collector_err, "a"),
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
|
|
if api and not dry_run:
|
|
console.print(f"[green]Starting DECNET API on port {api_port}...[/]")
|
|
_env: dict[str, str] = os.environ.copy()
|
|
_env["DECNET_INGEST_LOG_FILE"] = str(effective_log_file or "")
|
|
try:
|
|
subprocess.Popen( # nosec B603
|
|
[sys.executable, "-m", "uvicorn", "decnet.web.api:app", "--host", DECNET_API_HOST, "--port", str(api_port)],
|
|
env=_env,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.STDOUT
|
|
)
|
|
console.print(f"[dim]API running at http://{DECNET_API_HOST}:{api_port}[/]")
|
|
except (FileNotFoundError, subprocess.SubprocessError):
|
|
console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]")
|
|
|
|
if effective_log_file and not dry_run:
|
|
console.print("[bold cyan]Starting DECNET-PROBER[/] (auto-discovers attackers from log stream)")
|
|
try:
|
|
subprocess.Popen( # nosec B603
|
|
[sys.executable, "-m", "decnet.cli", "probe", "--daemon", "--log-file", str(effective_log_file)],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
except (FileNotFoundError, subprocess.SubprocessError):
|
|
console.print("[red]Failed to start DECNET-PROBER.[/]")
|
|
|
|
if effective_log_file and not dry_run:
|
|
console.print("[bold cyan]Starting DECNET-PROFILER[/] (builds attacker profiles from log stream)")
|
|
try:
|
|
subprocess.Popen( # nosec B603
|
|
[sys.executable, "-m", "decnet.cli", "profiler", "--daemon"],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
except (FileNotFoundError, subprocess.SubprocessError):
|
|
console.print("[red]Failed to start DECNET-PROFILER.[/]")
|
|
|
|
if effective_log_file and not dry_run:
|
|
console.print("[bold cyan]Starting DECNET-SNIFFER[/] (passive network capture)")
|
|
try:
|
|
subprocess.Popen( # nosec B603
|
|
[sys.executable, "-m", "decnet.cli", "sniffer", "--daemon", "--log-file", str(effective_log_file)],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
except (FileNotFoundError, subprocess.SubprocessError):
|
|
console.print("[red]Failed to start DECNET-SNIFFER.[/]")
|