merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

90
decnet/cli/__init__.py Normal file
View File

@@ -0,0 +1,90 @@
"""
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
Layout: each command module exports ``register(app)`` which attaches its
commands to the passed Typer app. ``__init__.py`` builds the root app,
calls every module's ``register`` in order, then runs the master-only
gate. The gate must fire LAST so it sees the fully-populated dispatch
table before filtering.
"""
from __future__ import annotations
import typer
from . import (
agent,
api,
bus,
canary,
db,
deploy,
forwarder,
geoip,
init,
inventory,
lifecycle,
listener,
orchestrator,
profiler,
realism,
reconciler,
sniffer,
swarm,
swarmctl,
topology,
updater,
web,
webhook,
workers,
)
from .gating import _gate_commands_by_mode
from .utils import console as console, log as log
app = typer.Typer(
name="decnet",
help="Deploy a deception network of honeypot deckies on your LAN.",
no_args_is_help=True,
)
# Order matches the old flat layout so `decnet --help` reads the same.
for _mod in (
api, swarmctl, agent, updater, listener, forwarder,
swarm,
deploy, lifecycle, workers, inventory,
web, profiler, orchestrator, realism, reconciler, sniffer, db,
topology, bus, geoip, init, webhook, canary,
):
_mod.register(app)
_gate_commands_by_mode(app)
# Backwards-compat re-exports. Tests and third-party tooling import these
# directly from ``decnet.cli``; the refactor must keep them resolvable.
from .db import _db_reset_mysql_async # noqa: E402,F401
from .gating import ( # noqa: E402,F401
MASTER_ONLY_COMMANDS,
MASTER_ONLY_GROUPS,
_agent_mode_active,
_require_master_mode,
)
from .utils import ( # noqa: E402,F401
_daemonize,
_http_request,
_is_running,
_kill_all_services,
_pid_dir,
_service_registry,
_spawn_detached,
_swarmctl_base_url,
)
if __name__ == "__main__": # pragma: no cover
app()

64
decnet/cli/agent.py Normal file
View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import os
import pathlib as _pathlib
import sys as _sys
from typing import Optional
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command()
def agent(
port: int = typer.Option(8765, "--port", help="Port for the worker agent"),
host: str = typer.Option("0.0.0.0", "--host", help="Bind address for the worker agent"), # nosec B104
agent_dir: Optional[str] = typer.Option(None, "--agent-dir", help="Worker cert bundle dir (default: ~/.decnet/agent, expanded under the running user's HOME — set this when running as sudo/root)"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
no_forwarder: bool = typer.Option(False, "--no-forwarder", help="Do not auto-spawn the log forwarder alongside the agent"),
) -> None:
"""Run the DECNET SWARM worker agent (requires a cert bundle in ~/.decnet/agent/).
By default, `decnet agent` auto-spawns `decnet forwarder` as a fully-
detached sibling process so worker logs start flowing to the master
without a second manual invocation. The forwarder survives agent
restarts and crashes — if it dies on its own, restart it manually
with `decnet forwarder --daemon …`. Pass --no-forwarder to skip.
"""
from decnet.agent import server as _agent_server
from decnet.env import DECNET_SWARM_MASTER_HOST, DECNET_AGENT_LOG_FILE
from decnet.swarm import pki as _pki
resolved_dir = _pathlib.Path(agent_dir) if agent_dir else _pki.DEFAULT_AGENT_DIR
if daemon:
log.info("agent daemonizing host=%s port=%d", host, port)
_utils._daemonize()
if not no_forwarder and DECNET_SWARM_MASTER_HOST:
fw_argv = [
_sys.executable, "-m", "decnet", "forwarder",
"--master-host", DECNET_SWARM_MASTER_HOST,
"--master-port", str(int(os.environ.get("DECNET_SWARM_SYSLOG_PORT", "6514"))),
"--agent-dir", str(resolved_dir),
"--log-file", str(DECNET_AGENT_LOG_FILE),
"--daemon",
]
try:
pid = _utils._spawn_detached(fw_argv, _utils._pid_dir() / "forwarder.pid")
log.info("agent auto-spawned forwarder pid=%d master=%s", pid, DECNET_SWARM_MASTER_HOST)
console.print(f"[dim]Auto-spawned forwarder (pid {pid}) → {DECNET_SWARM_MASTER_HOST}.[/]")
except Exception as e: # noqa: BLE001
log.warning("agent could not auto-spawn forwarder: %s", e)
console.print(f"[yellow]forwarder auto-spawn skipped: {e}[/]")
elif not no_forwarder:
log.info("agent skipping forwarder auto-spawn (DECNET_SWARM_MASTER_HOST unset)")
log.info("agent command invoked host=%s port=%d dir=%s", host, port, resolved_dir)
console.print(f"[green]Starting DECNET worker agent on {host}:{port} (mTLS)...[/]")
rc = _agent_server.run(host, port, agent_dir=resolved_dir)
if rc != 0:
raise typer.Exit(rc)

53
decnet/cli/api.py Normal file
View File

@@ -0,0 +1,53 @@
from __future__ import annotations
import os
import signal
import subprocess # nosec B404
import sys
import typer
from decnet.env import DECNET_API_HOST, DECNET_API_PORT, DECNET_INGEST_LOG_FILE
from . import utils as _utils
from .gating import _require_master_mode
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command()
def api(
port: int = typer.Option(DECNET_API_PORT, "--port", help="Port for the backend API"),
host: str = typer.Option(DECNET_API_HOST, "--host", help="Host IP for the backend API"),
log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", help="Path to the DECNET log file to monitor"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
workers: int = typer.Option(1, "--workers", "-w", min=1, help="Number of uvicorn worker processes"),
) -> None:
"""Run the DECNET API and Web Dashboard in standalone mode."""
_require_master_mode("api")
if daemon:
log.info("API daemonizing host=%s port=%d workers=%d", host, port, workers)
_utils._daemonize()
log.info("API command invoked host=%s port=%d workers=%d", host, port, workers)
console.print(f"[green]Starting DECNET API on {host}:{port} (workers={workers})...[/]")
_env: dict[str, str] = os.environ.copy()
_env["DECNET_INGEST_LOG_FILE"] = str(log_file)
_cmd = [sys.executable, "-m", "uvicorn", "decnet.web.api:app",
"--host", host, "--port", str(port), "--workers", str(workers)]
try:
proc = subprocess.Popen(_cmd, env=_env, start_new_session=True) # nosec B603 B404
try:
proc.wait()
except KeyboardInterrupt:
try:
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
except ProcessLookupError:
pass
except (FileNotFoundError, subprocess.SubprocessError):
console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]")

45
decnet/cli/bus.py Normal file
View File

@@ -0,0 +1,45 @@
from __future__ import annotations
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command(name="bus")
def bus_cmd(
socket_path: str = typer.Option(
None, "--socket", "-s",
help="UNIX socket path (defaults to DECNET_BUS_SOCKET env var, "
"then /run/decnet/bus.sock, then ~/.decnet/bus.sock).",
),
group: str = typer.Option(
"decnet", "--group", "-g",
help="POSIX group to chown the socket to (falls back to process "
"group if the named group does not exist).",
),
heartbeat: int = typer.Option(
10, "--heartbeat", "-H",
help="Seconds between system.bus.health heartbeat events.",
),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process."),
) -> None:
"""Run the DECNET ServiceBus worker (host-local UNIX-socket pub/sub)."""
import asyncio
from decnet.bus.factory import _default_socket_path
from decnet.bus.worker import bus_worker
resolved = socket_path or _default_socket_path()
if daemon:
log.info("bus daemonizing socket=%s", resolved)
_utils._daemonize()
log.info("bus starting socket=%s group=%s heartbeat=%ds", resolved, group, heartbeat)
console.print(f"[bold cyan]Bus starting[/] (socket: {resolved}, heartbeat: {heartbeat}s)")
try:
asyncio.run(bus_worker(resolved, group=group, heartbeat_interval=heartbeat))
except KeyboardInterrupt:
console.print("\n[yellow]Bus stopped.[/]")

42
decnet/cli/canary.py Normal file
View File

@@ -0,0 +1,42 @@
"""``decnet canary`` — HTTP + DNS callback receiver for canary tokens.
Worker process. Mirrors the shape of :mod:`decnet.cli.webhook`: a
``@app.command(name="canary")`` Typer entry point that delegates to
:func:`decnet.canary.worker.run`.
Not master-only — any host that hosts deckies can run its own
canary worker (the bus events stay local; the webhook worker on
each host fans them out to SIEMs independently per the design
in ``development/let-s-move-to-the-enumerated-pike.md``).
"""
from __future__ import annotations
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command(name="canary")
def canary_cmd(
daemon: bool = typer.Option(
False, "--daemon", "-d", help="Detach to background as a daemon process",
),
) -> None:
"""Run the canary HTTP + DNS callback receiver."""
import asyncio
from decnet.canary.worker import run
if daemon:
log.info("canary daemonizing")
_utils._daemonize()
log.info("canary starting")
console.print("[bold cyan]Canary callback receiver starting[/]")
try:
asyncio.run(run())
except KeyboardInterrupt:
console.print("\n[yellow]Canary worker stopped.[/]")

141
decnet/cli/db.py Normal file
View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from typing import Optional
import typer
from rich.table import Table
from .utils import console, log
def _decnet_tables() -> tuple[str, ...]:
"""Every DECNET-managed table, ordered child-first for DROP safety.
Source is ``SQLModel.metadata.sorted_tables`` — the same registry that
drives ``create_all`` — so adding a new model automatically enrolls
its table in ``db-reset`` with no manual step. (Previous hardcoded
list drifted multiple times; ``webhook_subscriptions`` /
``session_profile`` / ``smtp_targets`` all got missed.)
``sorted_tables`` returns parent-first (topological order that makes
``CREATE`` safe). For ``DROP`` we need the reverse: children first,
so FK constraints drop before their parents. ``SET FOREIGN_KEY_CHECKS
= 0`` below makes this order-insensitive for MySQL, but the reverse
order keeps the code honest for any backend that doesn't support
disabling the FK check.
"""
from sqlmodel import SQLModel
# Importing the models package registers every table on SQLModel.metadata.
import decnet.web.db.models # noqa: F401
return tuple(
t.name for t in reversed(SQLModel.metadata.sorted_tables)
)
async def _db_reset_mysql_async(dsn: str, mode: str, confirm: bool) -> None:
"""Inspect + (optionally) wipe a MySQL database. Pulled out of the CLI
wrapper so tests can drive it without spawning a Typer runner."""
from urllib.parse import urlparse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
db_name = urlparse(dsn).path.lstrip("/") or "(default)"
engine = create_async_engine(dsn)
tables = _decnet_tables()
try:
rows: dict[str, int] = {}
async with engine.connect() as conn:
for tbl in tables:
try:
result = await conn.execute(text(f"SELECT COUNT(*) FROM `{tbl}`")) # nosec B608
rows[tbl] = result.scalar() or 0
except Exception: # noqa: BLE001 — ProgrammingError for missing table varies by driver
rows[tbl] = -1
summary = Table(title=f"DECNET MySQL reset — database `{db_name}` (mode={mode})")
summary.add_column("Table", style="cyan")
summary.add_column("Rows", justify="right")
for tbl, count in rows.items():
summary.add_row(tbl, "[dim]missing[/]" if count < 0 else f"{count:,}")
console.print(summary)
if not confirm:
console.print(
"[yellow]Dry-run only. Re-run with [bold]--i-know-what-im-doing[/] "
"to actually execute.[/]"
)
return
async with engine.begin() as conn:
await conn.execute(text("SET FOREIGN_KEY_CHECKS = 0"))
for tbl in tables:
if rows.get(tbl, -1) < 0:
continue
if mode == "truncate":
await conn.execute(text(f"TRUNCATE TABLE `{tbl}`"))
console.print(f"[green]✓ TRUNCATE {tbl}[/]")
else:
await conn.execute(text(f"DROP TABLE `{tbl}`"))
console.print(f"[green]✓ DROP TABLE {tbl}[/]")
await conn.execute(text("SET FOREIGN_KEY_CHECKS = 1"))
console.print(f"[bold green]Done. Database `{db_name}` reset ({mode}).[/]")
finally:
await engine.dispose()
def register(app: typer.Typer) -> None:
@app.command(name="db-reset")
def db_reset(
i_know: bool = typer.Option(
False,
"--i-know-what-im-doing",
help="Required to actually execute. Without it, the command runs in dry-run mode.",
),
mode: str = typer.Option(
"truncate",
"--mode",
help="truncate (wipe rows, keep schema) | drop-tables (DROP TABLE for each DECNET table)",
),
url: Optional[str] = typer.Option(
None,
"--url",
help="Override DECNET_DB_URL for this invocation (e.g. when cleanup needs admin creds).",
),
) -> None:
"""Wipe the MySQL database used by the DECNET dashboard.
Destructive. Runs dry by default — pass --i-know-what-im-doing to commit.
Only supported against MySQL; refuses to operate on SQLite.
"""
import asyncio
import os
if mode not in ("truncate", "drop-tables"):
console.print(f"[red]Invalid --mode '{mode}'. Expected: truncate | drop-tables.[/]")
raise typer.Exit(2)
db_type = os.environ.get("DECNET_DB_TYPE", "sqlite").lower()
if db_type != "mysql":
console.print(
f"[red]db-reset is MySQL-only (DECNET_DB_TYPE='{db_type}'). "
f"For SQLite, just delete the decnet.db file.[/]"
)
raise typer.Exit(2)
dsn = url or os.environ.get("DECNET_DB_URL")
if not dsn:
from decnet.web.db.mysql.database import build_mysql_url
try:
dsn = build_mysql_url()
except ValueError as e:
console.print(f"[red]{e}[/]")
raise typer.Exit(2) from e
log.info("db-reset invoked mode=%s confirm=%s", mode, i_know)
try:
asyncio.run(_db_reset_mysql_async(dsn, mode=mode, confirm=i_know))
except Exception as e: # noqa: BLE001
console.print(f"[red]db-reset failed: {e}[/]")
raise typer.Exit(1) from e

307
decnet/cli/deploy.py Normal file
View File

@@ -0,0 +1,307 @@
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.[/]")

74
decnet/cli/forwarder.py Normal file
View File

@@ -0,0 +1,74 @@
from __future__ import annotations
import asyncio
import pathlib
import signal
from typing import Optional
import typer
from decnet.env import DECNET_INGEST_LOG_FILE
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command()
def forwarder(
master_host: Optional[str] = typer.Option(None, "--master-host", help="Master listener hostname/IP (default: $DECNET_SWARM_MASTER_HOST)"),
master_port: int = typer.Option(6514, "--master-port", help="Master listener TCP port (RFC 5425 default 6514)"),
log_file: Optional[str] = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", help="Local RFC 5424 file to tail and forward"),
agent_dir: Optional[str] = typer.Option(None, "--agent-dir", help="Worker cert bundle dir (default: ~/.decnet/agent)"),
state_db: Optional[str] = typer.Option(None, "--state-db", help="Forwarder offset SQLite path (default: <agent_dir>/forwarder.db)"),
poll_interval: float = typer.Option(0.5, "--poll-interval", help="Seconds between log file stat checks"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
) -> None:
"""Run the worker-side syslog-over-TLS forwarder (RFC 5425, mTLS to master:6514)."""
from decnet.env import DECNET_SWARM_MASTER_HOST
from decnet.swarm import pki
from decnet.swarm.log_forwarder import ForwarderConfig, run_forwarder
resolved_host = master_host or DECNET_SWARM_MASTER_HOST
if not resolved_host:
console.print("[red]--master-host is required (or set DECNET_SWARM_MASTER_HOST).[/]")
raise typer.Exit(2)
resolved_agent_dir = pathlib.Path(agent_dir) if agent_dir else pki.DEFAULT_AGENT_DIR
if not (resolved_agent_dir / "worker.crt").exists():
console.print(f"[red]No worker cert bundle at {resolved_agent_dir} — enroll from the master first.[/]")
raise typer.Exit(2)
if not log_file:
console.print("[red]--log-file is required.[/]")
raise typer.Exit(2)
cfg = ForwarderConfig(
log_path=pathlib.Path(log_file),
master_host=resolved_host,
master_port=master_port,
agent_dir=resolved_agent_dir,
state_db=pathlib.Path(state_db) if state_db else None,
)
if daemon:
log.info("forwarder daemonizing master=%s:%d log=%s", resolved_host, master_port, log_file)
_utils._daemonize()
log.info("forwarder command invoked master=%s:%d log=%s", resolved_host, master_port, log_file)
console.print(f"[green]Starting DECNET forwarder → {resolved_host}:{master_port} (mTLS)...[/]")
async def _main() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, stop.set)
except (NotImplementedError, RuntimeError): # pragma: no cover
pass
await run_forwarder(cfg, poll_interval=poll_interval, stop_event=stop)
try:
asyncio.run(_main())
except KeyboardInterrupt:
pass

73
decnet/cli/gating.py Normal file
View File

@@ -0,0 +1,73 @@
"""Role-based CLI gating.
MAINTAINERS: when you add a new Typer command (or add_typer group) that is
master-only, register its name in MASTER_ONLY_COMMANDS / MASTER_ONLY_GROUPS
below. The gate is the only thing that:
(a) hides the command from `decnet --help` on worker hosts, and
(b) prevents a misconfigured worker from invoking master-side logic.
Forgetting to register a new command is a role-boundary bug. Grep for
MASTER_ONLY when touching command registration.
Worker-legitimate commands (NOT in these sets): agent, updater, forwarder,
status, collect, probe, sniffer. Agents run deckies locally and should be
able to inspect them + run the per-host microservices (collector streams
container logs, prober characterizes attackers hitting this host, sniffer
captures traffic). Mutator and Profiler stay master-only: the mutator
orchestrates respawns across the swarm; the profiler rebuilds attacker
profiles against the master DB (no per-host DB exists).
"""
from __future__ import annotations
import os
import typer
from .utils import console
MASTER_ONLY_COMMANDS: frozenset[str] = frozenset({
"api", "swarmctl", "deploy", "redeploy", "teardown",
"mutate", "listener", "profiler",
"services", "distros", "correlate", "archetypes", "web",
"db-reset", "init", "webhook", "clusterer", "campaign-clusterer",
})
MASTER_ONLY_GROUPS: frozenset[str] = frozenset(
{"swarm", "topology", "geoip", "realism"}
)
def _agent_mode_active() -> bool:
"""True when the host is configured as an agent AND master commands are
disallowed (the default for agents). Workers overriding this explicitly
set DECNET_DISALLOW_MASTER=false to opt into hybrid use."""
mode = os.environ.get("DECNET_MODE", "master").lower()
disallow = os.environ.get("DECNET_DISALLOW_MASTER", "true").lower() == "true"
return mode == "agent" and disallow
def _require_master_mode(command_name: str) -> None:
"""Defence-in-depth: called at the top of every master-only command body.
The registration-time gate in _gate_commands_by_mode() already hides
these commands from Typer's dispatch table, but this check protects
against direct function imports (e.g. from tests or third-party tools)
that would bypass Typer entirely."""
if _agent_mode_active():
console.print(
f"[red]`decnet {command_name}` is a master-only command; this host "
f"is configured as an agent (DECNET_MODE=agent).[/]"
)
raise typer.Exit(1)
def _gate_commands_by_mode(_app: typer.Typer) -> None:
if not _agent_mode_active():
return
_app.registered_commands = [
c for c in _app.registered_commands
if (c.name or c.callback.__name__) not in MASTER_ONLY_COMMANDS
]
_app.registered_groups = [
g for g in _app.registered_groups
if g.name not in MASTER_ONLY_GROUPS
]

59
decnet/cli/geoip.py Normal file
View File

@@ -0,0 +1,59 @@
"""GeoIP CLI — refresh and lookup subcommands (master-only).
Usage::
decnet geoip refresh # re-download RIR files and rebuild the index
decnet geoip lookup 8.8.8.8 # one-shot IP -> country dump
"""
from __future__ import annotations
import typer
from .gating import _require_master_mode
from .utils import console, log
_group = typer.Typer(
name="geoip",
help="GeoIP provider management (master only).",
no_args_is_help=True,
)
@_group.command("refresh")
def _refresh() -> None:
"""Force re-download of the GeoIP provider data and rebuild the index."""
_require_master_mode("geoip refresh")
from decnet.geoip import get_lookup
from decnet.geoip.factory import get_provider
provider = get_provider()
log.info("geoip: forcing refresh via %s provider", provider.name)
console.print(f"[bold cyan]Refreshing {provider.name} GeoIP data…[/]")
try:
lookup = get_lookup(force_refresh=True)
except Exception as exc: # noqa: BLE001
console.print(f"[red]refresh failed: {exc}[/]")
raise typer.Exit(1) from exc
console.print(
f"[green]OK[/] {provider.name} index rebuilt "
f"({len(lookup)} ranges)."
)
@_group.command("lookup")
def _lookup(
ip: str = typer.Argument(..., help="IP address to resolve."),
) -> None:
"""Print the country code for an IP (or 'unknown')."""
_require_master_mode("geoip lookup")
from decnet.geoip import enrich_ip
cc, source = enrich_ip(ip)
if cc is None:
console.print(f"{ip} [yellow]unknown[/]")
raise typer.Exit(0)
console.print(f"{ip} [green]cc={cc}[/] source={source}")
def register(app: typer.Typer) -> None:
app.add_typer(_group, name="geoip")

843
decnet/cli/init.py Normal file
View File

@@ -0,0 +1,843 @@
"""
`decnet init` — one-shot master-host bootstrap.
Idempotent: running it twice is a no-op on already-configured items.
Takes a freshly ``pip install``'d DECNET and turns it into a ready-to-
run master host: creates the ``decnet`` system user/group, installs
the systemd units + polkit rule + tmpfiles.d entry, seeds the
directory layout, drops a placeholder config, and starts the
``decnet.target`` grouping unit.
Requires root. Uses ``subprocess.run`` (never ``shell=True``) for every
privileged call so the full argv surface is auditable.
"""
from __future__ import annotations
import grp
import hashlib
import os
import pwd
import shutil
import subprocess # nosec B404
import sys
from pathlib import Path
from typing import Callable, List, Optional
import typer
from jinja2 import Environment, FileSystemLoader, StrictUndefined
import decnet as _decnet_pkg
from .gating import _require_master_mode
from .utils import console, log
_CONFIG_PLACEHOLDER = """\
# /etc/decnet/decnet.ini — DECNET host config.
#
# Every key is OPTIONAL. Absent keys fall through to env-var defaults
# defined in decnet/env.py. Real env vars always win over this file
# (precedence: env > INI > default), so systemd EnvironmentFile= and
# one-off `DECNET_FOO=bar decnet ...` invocations always take effect.
#
# Secrets (JWT, admin password, DB password) intentionally DO NOT
# live here. Put them in /opt/decnet/.env.local or the systemd
# EnvironmentFile= — never in a group-readable INI.
[decnet]
# mode = master # or "agent"
# [api]
# host = 127.0.0.1
# port = 8000
# [web]
# host = 127.0.0.1
# port = 8080
# admin-user = admin
# cors-origins = http://localhost:8080 # comma-separated
# [database]
# type = sqlite # or "mysql"
# url = mysql+asyncmy://user@host:3306/decnet # if set, wins over host/port/name/user
# host = localhost
# port = 3306
# name = decnet
# user = decnet
# [bus]
# enabled = true
# type = unix # or "fake"
# socket = /run/decnet/bus.sock
# group = decnet
# [swarm]
# master-host = 10.0.0.1
# syslog-port = 6514
# swarmctl-port = 8770
# [logging]
# system-log = /var/log/decnet/decnet.system.log
# ingest-log = /var/log/decnet/decnet.log
# agent-log = /var/log/decnet/agent.log
# [ingester]
# batch-size = 100
# batch-max-wait-ms = 250
# [tracing]
# enabled = false
# otel-endpoint = http://localhost:4317
# [agent]
# Managed by the enroll bundle — do NOT edit by hand on an agent host.
"""
def _deploy_root() -> Path:
"""Resolve the on-disk ``deploy/`` directory of the installed package.
Editable install (``pip install -e .``): sibling of the ``decnet``
package at repo root. Wheel installs aren't supported yet — the
error message tells the operator to use an editable install.
"""
root = Path(_decnet_pkg.__file__).resolve().parent.parent / "deploy"
if not (root / "decnet.target").is_file():
raise RuntimeError(
f"cannot locate deploy/ directory (looked at {root}); "
"are you on a wheel install that didn't bundle deploy/? "
"use `pip install -e .` from a git checkout"
)
return root
def _sha256(path: Path) -> str:
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()
def _run(argv: List[str], *, dry_run: bool) -> None:
if dry_run:
console.print(f" [dim]would run:[/] {' '.join(argv)}")
return
log.info("init: exec %s", argv)
subprocess.run(argv, check=True) # nosec B603
def _step(label: str, action: Callable[[], str]) -> bool:
"""Run ``action``, print a checklist line.
The callable returns the human-readable outcome verb:
``"ok"`` → ``[ OK ] <label>``,
``"skip: <reason>"`` → ``[SKIP] <label> (<reason>)``.
Any exception becomes ``[FAIL] <label>: <err>`` and re-raises.
"""
try:
result = action()
except Exception as exc: # noqa: BLE001
console.print(f"[red][FAIL][/] {label}: {exc}")
raise
if result.startswith("skip:"):
reason = result[len("skip:") :].strip()
console.print(f"[yellow][SKIP][/] {label} ({reason})")
else:
console.print(f"[green][ OK ][/] {label}")
return True
def _ensure_group(group: str, *, dry_run: bool) -> str:
try:
grp.getgrnam(group)
return f"skip: group {group} already exists"
except KeyError:
_run(["groupadd", "--system", group], dry_run=dry_run)
return "ok"
def _ensure_user(user: str, group: str, install_dir: str, *, dry_run: bool) -> str:
try:
pwd.getpwnam(user)
return f"skip: user {user} already exists"
except KeyError:
_run(
[
"useradd", "--system",
"--gid", group,
"--home-dir", install_dir,
"--shell", "/usr/sbin/nologin",
"--comment", "DECNET honeypot",
user,
],
dry_run=dry_run,
)
return "ok"
def _ensure_dir(
path: Path, *, mode: int, owner: str, group: str, dry_run: bool
) -> str:
existed = path.exists()
if dry_run:
console.print(
f" [dim]would ensure dir:[/] {path} (mode={oct(mode)}, "
f"owner={owner}:{group})"
)
return "skip: dry-run" if existed else "ok"
path.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path, mode)
uid = pwd.getpwnam(owner).pw_uid
gid = grp.getgrnam(group).gr_gid
os.chown(path, uid, gid)
except (KeyError, PermissionError):
# owner/group not yet created, or we're not root (--prefix tests).
# mkdir is the load-bearing part; perm bits come back on the real
# root run.
pass
return f"skip: {path} already present" if existed else "ok"
def _ensure_config(path: Path, group: str, *, dry_run: bool) -> str:
if path.exists():
return f"skip: {path} already present"
if dry_run:
console.print(f" [dim]would write:[/] {path}")
return "ok"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_CONFIG_PLACEHOLDER)
try:
os.chmod(path, 0o640)
gid = grp.getgrnam(group).gr_gid
os.chown(path, 0, gid)
except (KeyError, PermissionError):
pass
return "ok"
def _copy_if_changed(
src: Path, dst: Path, *, mode: int, force: bool, dry_run: bool
) -> str:
if dst.exists() and not force and _sha256(src) == _sha256(dst):
return f"skip: {dst} up to date"
if dry_run:
console.print(f" [dim]would install:[/] {src} -> {dst} (mode={oct(mode)})")
return "ok"
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
try:
os.chmod(dst, mode)
os.chown(dst, 0, 0)
except PermissionError:
pass
return "ok"
def _render_template(src: Path, context: dict[str, str]) -> str:
"""Render a Jinja2 .j2 template with the given context.
StrictUndefined: a missing context variable is an error, not a
silent empty-string substitution — that way a typo in the template
fails loudly instead of shipping a broken systemd unit.
"""
env = Environment(
loader=FileSystemLoader(str(src.parent)),
undefined=StrictUndefined,
keep_trailing_newline=True,
autoescape=False, # nosec B701 — rendering systemd INI, not HTML
)
template = env.get_template(src.name)
return template.render(**context)
def _write_rendered_if_changed(
src: Path, dst: Path, rendered: str, *, mode: int, force: bool, dry_run: bool
) -> str:
"""Write *rendered* content to *dst* only if it differs from what's there.
SHA compares rendered-output ↔ on-disk bytes (NOT source-template ↔
on-disk) so operators who customise their install_dir get idempotent
re-runs instead of every ``decnet init`` rewriting files.
"""
rendered_bytes = rendered.encode("utf-8")
if dst.exists() and not force:
if hashlib.sha256(dst.read_bytes()).hexdigest() == hashlib.sha256(rendered_bytes).hexdigest():
return f"skip: {dst} up to date"
if dry_run:
console.print(f" [dim]would render:[/] {src} -> {dst} (mode={oct(mode)})")
return "ok"
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(rendered_bytes)
try:
os.chmod(dst, mode)
os.chown(dst, 0, 0)
except PermissionError:
pass
return "ok"
def _resolve_venv_dir(install_dir: str, explicit: str | None) -> str:
"""Pick the virtualenv systemd units should ExecStart out of.
Priority:
1. ``--venv-dir`` flag (explicit; absolute path required).
2. ``VIRTUAL_ENV`` env var, but only when it lives under
``install_dir`` (refuse to bake /home/user/.venv into a system
service — that directory is user-owned and may vanish).
3. ``{install_dir}/venv`` — what ``enroll_bootstrap.sh`` creates
on fresh agents; the production default.
4. First hit from a short list of dev-box conventions under
``install_dir``: ``.venv``, ``.311``, ``.312``, ``.313``.
Raises RuntimeError with an operator-friendly message if none of
those resolve to a directory containing ``bin/decnet``. Failing loud
at init time beats systemd spamming journalctl with
'Failed at step EXEC spawning .../venv/bin/decnet: No such file or
directory' on every auto-restart.
"""
install_path = Path(install_dir)
candidates: list[Path] = []
if explicit:
if not explicit.startswith("/"):
raise RuntimeError(
f"--venv-dir must be an absolute path, got {explicit!r}"
)
candidates.append(Path(explicit))
else:
virtual_env = os.environ.get("VIRTUAL_ENV")
if virtual_env:
ve_path = Path(virtual_env)
try:
ve_path.relative_to(install_path)
candidates.append(ve_path)
except ValueError:
# VIRTUAL_ENV lives outside install_dir — don't bake a
# user-home venv into a root-owned systemd unit.
pass
candidates.append(install_path / "venv")
for name in (".venv", ".311", ".312", ".313"):
candidates.append(install_path / name)
for cand in candidates:
if (cand / "bin" / "decnet").is_file():
return str(cand)
searched = ", ".join(str(c) for c in candidates)
raise RuntimeError(
"Could not find a DECNET venv. Create one first (e.g. "
f"`python -m venv {install_path}/venv && "
f"{install_path}/venv/bin/pip install -e {install_path}[dev]`) "
"or pass --venv-dir. Searched: " + searched
)
def _install_units(
deploy: Path,
systemd_dir: Path,
*,
install_dir: str,
venv_dir: str,
user: str,
group: str,
force: bool,
dry_run: bool,
) -> str:
"""Render decnet-*.service.j2 → systemd_dir/decnet-*.service, and copy
the static decnet.target (no templating needed — it has no install
path references)."""
context = {
"install_dir": install_dir,
"venv_dir": venv_dir,
"user": user,
"group": group,
}
templates = sorted(deploy.glob("decnet-*.service.j2"))
static = [deploy / "decnet.target"]
touched = 0
for src in templates:
rendered = _render_template(src, context)
# decnet-api.service.j2 → decnet-api.service
dst_name = src.name[: -len(".j2")]
result = _write_rendered_if_changed(
src, systemd_dir / dst_name, rendered,
mode=0o644, force=force, dry_run=dry_run,
)
if not result.startswith("skip:"):
touched += 1
for src in static:
result = _copy_if_changed(
src, systemd_dir / src.name,
mode=0o644, force=force, dry_run=dry_run,
)
if not result.startswith("skip:"):
touched += 1
total = len(templates) + len(static)
if touched == 0:
return f"skip: {total} unit files up to date"
return f"ok ({touched}/{total} installed)"
def _install_polkit(
deploy: Path, rules_dir: Path, *, group: str, force: bool, dry_run: bool
) -> str:
"""Render the group-scoped polkit rule to /etc/polkit-1/rules.d/.
The rule has to reference the same POSIX group passed via --group —
otherwise the API (running as that user) can't
systemctl start/stop decnet-*.service without an interactive auth
prompt that never gets answered in a daemon context.
"""
src = deploy / "polkit" / "50-decnet-workers.rules.j2"
if not src.is_file():
raise RuntimeError(f"missing polkit rule template at {src}")
rendered = _render_template(src, {"group": group})
# 50-decnet-workers.rules.j2 → 50-decnet-workers.rules
dst_name = src.name[: -len(".j2")]
return _write_rendered_if_changed(
src, rules_dir / dst_name, rendered,
mode=0o644, force=force, dry_run=dry_run,
)
def _run_allow_fail(argv: List[str], *, dry_run: bool) -> str:
"""Like ``_run`` but tolerates non-zero exits (stop/disable on an
already-absent unit is fine during deinit)."""
if dry_run:
console.print(f" [dim]would run (allow fail):[/] {' '.join(argv)}")
return "ok"
log.info("init: exec (allow fail) %s", argv)
result = subprocess.run(argv, check=False) # nosec B603
if result.returncode != 0:
return f"skip: rc={result.returncode} (already absent)"
return "ok"
def _remove_file(path: Path, *, dry_run: bool) -> str:
if not path.exists() and not path.is_symlink():
return f"skip: {path} already absent"
if dry_run:
console.print(f" [dim]would remove:[/] {path}")
return "ok"
path.unlink()
return "ok"
def _uninstall_units(systemd_dir: Path, *, dry_run: bool) -> str:
removed = 0
present = sorted(systemd_dir.glob("decnet-*.service"))
target = systemd_dir / "decnet.target"
if target.exists():
present.append(target)
for path in present:
if dry_run:
console.print(f" [dim]would remove:[/] {path}")
removed += 1
continue
path.unlink()
removed += 1
if removed == 0:
return "skip: no decnet unit files present"
return f"ok ({removed} removed)"
def _remove_user(user: str, *, dry_run: bool) -> str:
try:
pwd.getpwnam(user)
except KeyError:
return f"skip: user {user} already absent"
# userdel returns non-zero if the user still owns running
# processes; that's the operator's problem to sort out, not ours.
return _run_allow_fail(["userdel", user], dry_run=dry_run)
def _remove_group(group: str, *, dry_run: bool) -> str:
try:
grp.getgrnam(group)
except KeyError:
return f"skip: group {group} already absent"
return _run_allow_fail(["groupdel", group], dry_run=dry_run)
def _remove_dir_if_present(
path: Path, *, dry_run: bool, recursive: bool = False
) -> str:
if not path.exists():
return f"skip: {path} already absent"
if dry_run:
verb = "would rm -rf" if recursive else "would rmdir"
console.print(f" [dim]{verb}:[/] {path}")
return "ok"
if recursive:
shutil.rmtree(path, ignore_errors=True)
else:
try:
path.rmdir()
except OSError as exc:
return f"skip: {path} not empty ({exc.strerror})"
return "ok"
def _install_tmpfiles(
deploy: Path, tmpfiles_dir: Path, *, force: bool, dry_run: bool
) -> str:
src = deploy / "tmpfiles.d" / "decnet.conf"
if not src.is_file():
raise RuntimeError(f"missing tmpfiles.d entry at {src}")
result = _copy_if_changed(
src, tmpfiles_dir / src.name,
mode=0o644, force=force, dry_run=dry_run,
)
# Apply immediately so /run/decnet exists before daemon-reload.
_run(["systemd-tmpfiles", "--create", str(tmpfiles_dir / src.name)], dry_run=dry_run)
return result
def _install_logrotate(
deploy: Path, logrotate_dir: Path, *, force: bool, dry_run: bool
) -> str:
"""Drop the logrotate config into ``/etc/logrotate.d/decnet``.
The ingester / forwarder hold the log files open via Python, so the
config uses ``copytruncate`` rather than rename+create. Without this
rule, /var/log/decnet/ grows without bound and a single noisy day of
attacker traffic fills the disk on a small VPS. Best-effort: a host
without logrotate installed (rare on systemd distros) still boots
fine — the operator just needs to wire their own rotation.
"""
src = deploy / "logrotate.d" / "decnet"
if not src.is_file():
raise RuntimeError(f"missing logrotate config at {src}")
return _copy_if_changed(
src, logrotate_dir / src.name,
mode=0o644, force=force, dry_run=dry_run,
)
def register(app: typer.Typer) -> None:
@app.command(name="init")
def init_cmd(
dry_run: bool = typer.Option(
False, "--dry-run",
help="Print every action; make no changes.",
),
no_start: bool = typer.Option(
False, "--no-start",
help="Install everything but don't `systemctl enable --now decnet.target`.",
),
force: bool = typer.Option(
False, "--force",
help="Overwrite unit / polkit / tmpfiles entries even if identical.",
),
deinit: bool = typer.Option(
False, "--deinit",
help="Undo a previous init: stop + disable decnet.target, remove "
"unit files, polkit rule, tmpfiles.d entry, /etc/decnet. "
"Preserves /var/lib/decnet, /var/log/decnet, and the "
"service user/group — pass --purge to remove those too.",
),
purge: bool = typer.Option(
False, "--purge",
help="With --deinit, also wipe /var/lib/decnet, "
"/var/log/decnet, AND the service user/group. "
"Destructive — operator data is gone, and if --user "
"points at your own login account, that account goes "
"with it. Only use when the user/group was created by "
"`decnet init` in the first place.",
),
user: str = typer.Option(
"decnet", "--user",
help="System user to own DECNET processes.",
),
group: str = typer.Option(
"decnet", "--group",
help="Primary group of the DECNET user.",
),
install_dir: str = typer.Option(
"/opt/decnet", "--install-dir",
help="Absolute path where DECNET is installed. Default "
"/opt/decnet; distros that reserve /opt can point this "
"at /srv/decnet, /usr/local/decnet, etc. Gets rendered "
"into every systemd unit via Jinja2 and used as the "
"decnet user's home directory.",
),
venv_dir: Optional[str] = typer.Option(
None, "--venv-dir",
help="Absolute path to the Python venv systemd should "
"ExecStart from. If omitted, auto-detected in order: "
"$VIRTUAL_ENV (if under --install-dir), "
"{install-dir}/venv, then {install-dir}/{.venv,.311,"
".312,.313}. Init aborts if none exists.",
),
prefix: str = typer.Option(
"", "--prefix", hidden=True,
help="Filesystem prefix for tests (e.g. tmp_path). Empty = real root.",
),
) -> None:
"""One-shot bootstrap of a DECNET master host.
Creates the `decnet` user/group, installs systemd units,
polkit rules, tmpfiles.d entries, seeds directories and
drops a placeholder config, then starts decnet.target.
"""
_require_master_mode("init")
if purge and not deinit:
console.print("[red]--purge only applies with --deinit[/]")
raise typer.Exit(1)
# Root check — skip when --prefix is set (tests don't run as root).
if not prefix and os.geteuid() != 0:
verb = "deinit" if deinit else "init"
console.print(f"[red]decnet {verb}: must run as root (use sudo)[/]")
raise typer.Exit(1)
if not install_dir.startswith("/"):
console.print(
f"[red]decnet init: --install-dir must be absolute, got {install_dir!r}[/]"
)
raise typer.Exit(1)
# Strip leading slash so pfx-joining works under --prefix test mode
# (Path("/"). / "/opt/decnet" == Path("/opt/decnet"), dropping pfx).
_install_rel = install_dir.lstrip("/")
required_tools = ("systemctl",) if deinit else (
"systemctl", "useradd", "groupadd", "systemd-tmpfiles",
)
if deinit:
required_tools = required_tools + ("userdel", "groupdel")
for tool in required_tools:
if shutil.which(tool) is None and not dry_run:
verb = "deinit" if deinit else "init"
console.print(f"[red]decnet {verb}: {tool!r} is required on PATH[/]")
raise typer.Exit(1)
pfx = Path(prefix) if prefix else Path("/")
systemd_dir = pfx / "etc/systemd/system"
polkit_dir = pfx / "etc/polkit-1/rules.d"
tmpfiles_dir = pfx / "etc/tmpfiles.d"
logrotate_dir = pfx / "etc/logrotate.d"
etc_decnet = pfx / "etc/decnet"
if deinit:
console.print(
f"[bold cyan]DECNET deinit[/] "
f"(dry_run={dry_run}, purge={purge})"
)
_step(
"systemctl stop + disable decnet.target",
lambda: _run_allow_fail(
["systemctl", "disable", "--now", "decnet.target"],
dry_run=dry_run,
),
)
_step(
"remove systemd unit files",
lambda: _uninstall_units(systemd_dir, dry_run=dry_run),
)
_step(
"remove polkit rule",
lambda: _remove_file(
polkit_dir / "50-decnet-workers.rules",
dry_run=dry_run,
),
)
_step(
"remove tmpfiles.d entry",
lambda: _remove_file(
tmpfiles_dir / "decnet.conf",
dry_run=dry_run,
),
)
_step(
"remove logrotate config",
lambda: _remove_file(
logrotate_dir / "decnet",
dry_run=dry_run,
),
)
_step(
"systemctl daemon-reload",
lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1],
)
_step(
f"remove {etc_decnet / 'decnet.ini'}",
lambda: _remove_file(etc_decnet / "decnet.ini", dry_run=dry_run),
)
# Legacy name from pre-domain-sections placeholder era.
# Harmless if absent (the _remove_file step logs skip).
_step(
f"remove legacy {etc_decnet / 'config.ini'}",
lambda: _remove_file(etc_decnet / "config.ini", dry_run=dry_run),
)
_step(
f"remove {etc_decnet}",
lambda: _remove_dir_if_present(etc_decnet, dry_run=dry_run),
)
_step(
f"remove {pfx / 'run/decnet'}",
lambda: _remove_dir_if_present(
pfx / "run/decnet", dry_run=dry_run,
),
)
_step(
f"remove {pfx / _install_rel}",
lambda: _remove_dir_if_present(
pfx / _install_rel, dry_run=dry_run,
),
)
if purge:
_step(
f"purge {pfx / 'var/lib/decnet'}",
lambda: _remove_dir_if_present(
pfx / "var/lib/decnet",
dry_run=dry_run, recursive=True,
),
)
_step(
f"purge {pfx / 'var/log/decnet'}",
lambda: _remove_dir_if_present(
pfx / "var/log/decnet",
dry_run=dry_run, recursive=True,
),
)
else:
console.print(
f"[dim]preserved {pfx / 'var/lib/decnet'} and "
f"{pfx / 'var/log/decnet'} (operator data); "
"re-run with --purge to remove.[/]"
)
# User / group removal is also gated on --purge. In dev the
# operator may have passed their own login user via
# `--user $USER` to avoid ownership churn; an unconditional
# `userdel anti` during deinit would nuke their account.
if purge:
_step(
f"remove user {user!r}",
lambda: _remove_user(user, dry_run=dry_run),
)
_step(
f"remove group {group!r}",
lambda: _remove_group(group, dry_run=dry_run),
)
else:
console.print(
f"[dim]preserved user {user!r} and group {group!r}; "
"re-run with --purge to remove (only do this if "
"they were created by `decnet init`).[/]"
)
console.print("[bold green]DECNET deinit complete.[/]")
return
try:
deploy = _deploy_root()
except RuntimeError as exc:
console.print(f"[red]decnet init: {exc}[/]")
raise typer.Exit(1) from exc
# Resolve venv BEFORE any file writes — fails loud if the
# operator hasn't created one yet, instead of shipping broken
# systemd units that journalctl spams forever. Skipped under
# --prefix (test mode) because the test harness doesn't build a
# real venv and the rendered string is asserted on directly.
if prefix:
resolved_venv = venv_dir or f"{install_dir}/venv"
else:
try:
resolved_venv = _resolve_venv_dir(install_dir, venv_dir)
except RuntimeError as exc:
console.print(f"[red]decnet init: {exc}[/]")
raise typer.Exit(1) from exc
console.print(f"[dim]using venv: {resolved_venv}[/]")
dirs = [
(pfx / _install_rel, 0o755, user, group),
(pfx / "var/lib/decnet", 0o750, user, group),
(pfx / "var/lib/decnet/geoip", 0o755, user, group),
(pfx / "var/log/decnet", 0o750, user, group),
(etc_decnet, 0o755, "root", group),
(pfx / "run/decnet", 0o755, "root", group),
]
console.print(
f"[bold cyan]DECNET init[/] "
f"(dry_run={dry_run}, no_start={no_start}, force={force})"
)
_step(
f"ensure group {group!r}",
lambda: _ensure_group(group, dry_run=dry_run),
)
_step(
f"ensure user {user!r}",
lambda: _ensure_user(user, group, install_dir, dry_run=dry_run),
)
for path, mode, d_owner, d_group in dirs:
_step(
f"ensure dir {path}",
lambda p=path, m=mode, o=d_owner, g=d_group:
_ensure_dir(p, mode=m, owner=o, group=g, dry_run=dry_run),
)
_step(
f"write {etc_decnet / 'decnet.ini'}",
lambda: _ensure_config(etc_decnet / "decnet.ini", group, dry_run=dry_run),
)
_step(
"install systemd units",
lambda: _install_units(
deploy, systemd_dir,
install_dir=install_dir, venv_dir=resolved_venv,
user=user, group=group,
force=force, dry_run=dry_run,
),
)
_step(
"install polkit rule",
lambda: _install_polkit(
deploy, polkit_dir, group=group,
force=force, dry_run=dry_run,
),
)
_step(
"install tmpfiles.d entry",
lambda: _install_tmpfiles(
deploy, tmpfiles_dir, force=force, dry_run=dry_run,
),
)
_step(
"install logrotate config",
lambda: _install_logrotate(
deploy, logrotate_dir, force=force, dry_run=dry_run,
),
)
_step(
"systemctl daemon-reload",
lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1],
)
if no_start:
console.print("[yellow]--no-start: skipping decnet.target start[/]")
return
try:
_step(
"systemctl enable --now decnet.target",
lambda: (
_run(
["systemctl", "enable", "--now", "decnet.target"],
dry_run=dry_run,
),
"ok",
)[1],
)
except subprocess.CalledProcessError as exc:
console.print(
f"[red]decnet.target failed to start (rc={exc.returncode}); "
"inspect `systemctl status decnet.target` and individual "
"`decnet-*.service` units.[/]"
)
raise typer.Exit(1) from exc
console.print("[bold green]DECNET init complete.[/] "
"Check `decnet status` or the Workers panel.")
sys.stdout.flush()

52
decnet/cli/inventory.py Normal file
View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import typer
from rich.table import Table
from decnet.archetypes import all_archetypes
from decnet.distros import all_distros
from decnet.services.registry import all_services
from .utils import console
def register(app: typer.Typer) -> None:
@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)
@app.command(name="distros")
def list_distros() -> None:
"""List all available OS distro profiles for deckies."""
table = Table(title="Available Distro Profiles", show_lines=True)
table.add_column("Slug", style="bold cyan")
table.add_column("Display Name")
table.add_column("Docker Image", style="dim")
for slug, profile in sorted(all_distros().items()):
table.add_row(slug, profile.display_name, profile.image)
console.print(table)
@app.command(name="archetypes")
def list_archetypes() -> None:
"""List all machine archetype profiles."""
table = Table(title="Machine Archetypes", show_lines=True)
table.add_column("Slug", style="bold cyan")
table.add_column("Display Name")
table.add_column("Default Services", style="green")
table.add_column("Description", style="dim")
for slug, arch in sorted(all_archetypes().items()):
table.add_row(
slug,
arch.display_name,
", ".join(arch.services),
arch.description,
)
console.print(table)

147
decnet/cli/lifecycle.py Normal file
View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import subprocess # nosec B404
from typing import Optional
import typer
from rich.table import Table
from decnet.env import DECNET_INGEST_LOG_FILE
from . import utils as _utils
from .gating import _agent_mode_active, _require_master_mode
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command()
def redeploy(
log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", "-f", help="Path to the DECNET log file"),
) -> None:
"""Check running DECNET services and relaunch any that are down."""
log.info("redeploy: checking services")
registry = _utils._service_registry(str(log_file))
table = Table(title="DECNET Services", show_lines=True)
table.add_column("Service", style="bold cyan")
table.add_column("Status")
table.add_column("PID", style="dim")
table.add_column("Action")
relaunched = 0
for name, match_fn, launch_args in registry:
pid = _utils._is_running(match_fn)
if pid is not None:
table.add_row(name, "[green]UP[/]", str(pid), "")
else:
try:
subprocess.Popen( # nosec B603
launch_args,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
start_new_session=True,
)
table.add_row(name, "[red]DOWN[/]", "", "[green]relaunched[/]")
relaunched += 1
except (FileNotFoundError, subprocess.SubprocessError) as exc:
table.add_row(name, "[red]DOWN[/]", "", f"[red]failed: {exc}[/]")
console.print(table)
if relaunched:
console.print(f"[green]{relaunched} service(s) relaunched.[/]")
else:
console.print("[green]All services running.[/]")
@app.command()
def status() -> None:
"""Show running deckies and the state of every ``decnet-*`` unit.
Prefers systemd (``systemctl list-units 'decnet-*.service'``) so
agents, masters and mixed hosts all get one consistent view of
what's installed, loaded, and active. Falls back to the psutil
cmdline registry on boxes without systemd (dev laptops, CI
containers, non-systemd init) so `decnet status` is still useful
there.
"""
log.info("status command invoked")
from decnet.engine import status as _status
_status()
units = _utils._systemd_units()
if units is not None:
_render_systemd_units(units)
else:
_render_psutil_fallback()
def _render_systemd_units(units: list[dict]) -> None:
svc_table = Table(title="DECNET Services (systemd)", show_lines=True)
svc_table.add_column("Unit", style="bold cyan")
svc_table.add_column("Load")
svc_table.add_column("Active")
svc_table.add_column("Sub")
svc_table.add_column("Description", style="dim")
if not units:
console.print(
"[yellow]No decnet-* systemd units loaded. "
"Run `sudo decnet init` to install them.[/]"
)
return
def _active_style(active: str) -> str:
if active == "active":
return "[green]active[/]"
if active == "failed":
return "[red]failed[/]"
return f"[yellow]{active}[/]"
for u in sorted(units, key=lambda x: x.get("unit", "")):
svc_table.add_row(
u.get("unit", ""),
u.get("load", ""),
_active_style(u.get("active", "")),
u.get("sub", ""),
u.get("description", ""),
)
console.print(svc_table)
def _render_psutil_fallback() -> None:
registry = _utils._service_registry(str(DECNET_INGEST_LOG_FILE))
if _agent_mode_active():
registry = [r for r in registry if r[0] not in {"Mutator", "Profiler", "API"}]
svc_table = Table(
title="DECNET Services (psutil fallback — systemd unavailable)",
show_lines=True,
)
svc_table.add_column("Service", style="bold cyan")
svc_table.add_column("Status")
svc_table.add_column("PID", style="dim")
for name, match_fn, _launch_args in registry:
pid = _utils._is_running(match_fn)
if pid is not None:
svc_table.add_row(name, "[green]UP[/]", str(pid))
else:
svc_table.add_row(name, "[red]DOWN[/]", "")
console.print(svc_table)
@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."""
_require_master_mode("teardown")
if not all_ and not id_:
console.print("[red]Specify --all or --id <name>.[/]")
raise typer.Exit(1)
log.info("teardown command invoked all=%s id=%s", all_, id_)
from decnet.engine import teardown as _teardown
_teardown(decky_id=id_)
log.info("teardown complete all=%s id=%s", all_, id_)
if all_:
_utils._kill_all_services()

57
decnet/cli/listener.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import asyncio
import pathlib
import signal
from typing import Optional
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command()
def listener(
bind_host: str = typer.Option("0.0.0.0", "--host", help="Bind address for the master syslog-TLS listener"), # nosec B104
bind_port: int = typer.Option(6514, "--port", help="Listener TCP port (RFC 5425 default 6514)"),
log_path: Optional[str] = typer.Option(None, "--log-path", help="RFC 5424 forensic sink (default: ./master.log)"),
json_path: Optional[str] = typer.Option(None, "--json-path", help="Parsed-JSON ingest sink (default: ./master.json)"),
ca_dir: Optional[str] = typer.Option(None, "--ca-dir", help="DECNET CA dir (default: ~/.decnet/ca)"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
) -> None:
"""Run the master-side syslog-over-TLS listener (RFC 5425, mTLS)."""
from decnet.swarm import pki
from decnet.swarm.log_listener import ListenerConfig, run_listener
resolved_ca_dir = pathlib.Path(ca_dir) if ca_dir else pki.DEFAULT_CA_DIR
resolved_log = pathlib.Path(log_path) if log_path else pathlib.Path("master.log")
resolved_json = pathlib.Path(json_path) if json_path else pathlib.Path("master.json")
cfg = ListenerConfig(
log_path=resolved_log, json_path=resolved_json,
bind_host=bind_host, bind_port=bind_port, ca_dir=resolved_ca_dir,
)
if daemon:
log.info("listener daemonizing host=%s port=%d", bind_host, bind_port)
_utils._daemonize()
log.info("listener command invoked host=%s port=%d", bind_host, bind_port)
console.print(f"[green]Starting DECNET log listener on {bind_host}:{bind_port} (mTLS)...[/]")
async def _main() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, stop.set)
except (NotImplementedError, RuntimeError): # pragma: no cover
pass
await run_listener(cfg, stop_event=stop)
try:
asyncio.run(_main())
except KeyboardInterrupt:
pass

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Optional
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command(name="orchestrate")
def orchestrate_cmd(
interval: int = typer.Option(
60, "--interval", "-i",
help="Seconds between synthetic activity ticks",
),
daemon: bool = typer.Option(
False, "--daemon", "-d",
help="Detach to background as a daemon process",
),
llm: Optional[bool] = typer.Option(
None, "--llm/--no-llm",
help=(
"Enable / disable LLM enrichment of user-class file "
"bodies. Default reads $DECNET_REALISM_LLM (any "
"non-empty value enables; 'off' / unset disables)."
),
),
) -> None:
"""Inject synthetic life (inter-decky traffic + file ops + email) into the fleet."""
import asyncio
from decnet.orchestrator import orchestrator_worker
from decnet.web.dependencies import repo
if daemon:
log.info("orchestrator daemonizing interval=%d", interval)
_utils._daemonize()
log.info(
"orchestrator starting interval=%d llm=%s",
interval, "default" if llm is None else ("on" if llm else "off"),
)
console.print(
f"[bold cyan]Orchestrator starting[/] (interval: {interval}s)"
)
async def _run() -> None:
await repo.initialize()
await orchestrator_worker(repo, interval=interval, llm_enabled=llm)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Orchestrator stopped.[/]")

34
decnet/cli/profiler.py Normal file
View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command(name="profiler")
def profiler_cmd(
interval: int = typer.Option(30, "--interval", "-i", help="Seconds between profile rebuild cycles"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
) -> None:
"""Run the attacker profiler as a standalone microservice."""
import asyncio
from decnet.profiler import attacker_profile_worker
from decnet.web.dependencies import repo
if daemon:
log.info("profiler daemonizing interval=%d", interval)
_utils._daemonize()
log.info("profiler starting interval=%d", interval)
console.print(f"[bold cyan]Profiler starting[/] (interval: {interval}s)")
async def _run() -> None:
await repo.initialize()
await attacker_profile_worker(repo, interval=interval)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Profiler stopped.[/]")

111
decnet/cli/realism.py Normal file
View File

@@ -0,0 +1,111 @@
"""``decnet realism ...`` — content-engine maintenance commands.
After stage 5 of the realism migration, this is the only remaining
CLI surface from the realism library / former emailgen. ``decnet
realism run`` does not exist (the orchestrator runs the unified
worker via ``decnet orchestrate``); the only sub-command is
``import-personas``, which validates + installs the host-wide global
persona pool consumed by fleet (MACVLAN/IPVLAN) and SWARM-shard
deckies.
Topology personas live on ``Topology.email_personas`` and are
managed via the dashboard or the topology API; this command does
not touch them.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import typer
from .gating import _require_master_mode
from .utils import console, log
def register(app: typer.Typer) -> None:
realism_app = typer.Typer(
name="realism",
help=(
"Maintain the realism content engine (persona pool import, "
"future content-class tuning)."
),
)
app.add_typer(realism_app, name="realism")
@realism_app.command("import-personas")
def realism_import_personas(
path: Path = typer.Argument(
..., exists=True, file_okay=True, dir_okay=False, readable=True,
help="JSON file containing a list of EmailPersona objects",
),
output: Optional[Path] = typer.Option(
None, "--output", "-o",
help=(
"Override the destination path. Defaults to the canonical "
"global pool (DECNET_REALISM_PERSONAS, /etc/decnet/"
"email_personas.json, or ~/.decnet/email_personas.json)."
),
),
) -> None:
"""Validate + install a personas JSON file as the global pool.
Use this when deploying with IMAP/POP3 services on fleet
(MACVLAN/IPVLAN) or SWARM-shard mail deckies — those have no
parent topology row, so they read this host-wide list.
MazeNET topology mail deckies use ``Topology.email_personas``
instead and this command does not touch them.
"""
_require_master_mode("realism import-personas")
from decnet.realism import personas_pool as global_pool
from decnet.realism.personas import parse_personas
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
console.print(f"[red]Cannot read {path}:[/] {exc}")
raise typer.Exit(code=1) from exc
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
console.print(f"[red]Invalid JSON in {path}:[/] {exc}")
raise typer.Exit(code=1) from exc
if not isinstance(payload, list):
console.print(
f"[red]{path} must contain a JSON list of personas, "
f"got {type(payload).__name__}[/]"
)
raise typer.Exit(code=1)
personas = parse_personas(payload)
if not personas:
console.print(
f"[red]No valid personas in {path}.[/] "
"Check the schema (name, email, role, tone, mannerisms)."
)
raise typer.Exit(code=1)
if len(personas) < 2:
console.print(
f"[yellow]Warning: only {len(personas)} valid persona(s) — "
"the worker requires at least 2 to send mail; importing "
"anyway in case more are added later.[/]"
)
dest = output or global_pool.resolve_path()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(
[p.model_dump(exclude_none=False) for p in personas],
indent=2,
ensure_ascii=False,
),
encoding="utf-8",
)
global_pool.reset_cache()
console.print(
f"[green]Imported {len(personas)} personas to[/] {dest}"
)
if path != dest:
log.info("realism import-personas src=%s dest=%s", path, dest)

62
decnet/cli/reconciler.py Normal file
View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command(name="reconcile")
def reconcile_cmd(
once: bool = typer.Option(
False, "--once",
help="Run a single reconcile pass and exit (no daemon loop).",
),
interval: int = typer.Option(
30, "--interval", "-i",
help="Seconds between reconcile passes (ignored with --once).",
),
daemon: bool = typer.Option(
False, "--daemon", "-d",
help="Detach to background as a daemon process (long-lived only).",
),
) -> None:
"""Converge fleet state across decnet-state.json, the DB, and docker."""
import asyncio
from decnet.web.dependencies import repo
if once:
from decnet.fleet.reconciler import reconcile_once
async def _one() -> None:
await repo.initialize()
counts = await reconcile_once(repo)
console.print(
f"[bold cyan]reconcile:[/] "
f"inserted={counts['inserted']} "
f"deleted={counts['deleted']} "
f"state_updated={counts['state_updated']}"
)
asyncio.run(_one())
return
from decnet.fleet.reconciler_worker import fleet_reconciler_worker
if daemon:
log.info("reconciler daemonizing interval=%d", interval)
_utils._daemonize()
log.info("reconciler starting interval=%d", interval)
console.print(
f"[bold cyan]Fleet reconciler starting[/] (interval: {interval}s)"
)
async def _run() -> None:
await repo.initialize()
await fleet_reconciler_worker(repo, interval=interval)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Reconciler stopped.[/]")

31
decnet/cli/sniffer.py Normal file
View File

@@ -0,0 +1,31 @@
from __future__ import annotations
import typer
from decnet.env import DECNET_INGEST_LOG_FILE
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command(name="sniffer")
def sniffer_cmd(
log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", "-f", help="Path to write captured syslog + JSON records"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
) -> None:
"""Run the network sniffer as a standalone microservice."""
import asyncio
from decnet.sniffer import sniffer_worker
if daemon:
log.info("sniffer daemonizing log_file=%s", log_file)
_utils._daemonize()
log.info("sniffer starting log_file=%s", log_file)
console.print(f"[bold cyan]Sniffer starting[/] → {log_file}")
try:
asyncio.run(sniffer_worker(log_file))
except KeyboardInterrupt:
console.print("\n[yellow]Sniffer stopped.[/]")

346
decnet/cli/swarm.py Normal file
View File

@@ -0,0 +1,346 @@
"""`decnet swarm ...` — master-side operator commands (HTTP to local swarmctl)."""
from __future__ import annotations
from typing import Optional
import typer
from rich.table import Table
from . import utils as _utils
from .utils import console
def register(app: typer.Typer) -> None:
swarm_app = typer.Typer(
name="swarm",
help="Manage swarm workers (enroll, list, decommission). Requires `decnet swarmctl` running.",
no_args_is_help=True,
)
app.add_typer(swarm_app, name="swarm")
@swarm_app.command("enroll")
def swarm_enroll(
name: str = typer.Option(..., "--name", help="Short hostname for the worker (also the cert CN)"),
address: str = typer.Option(..., "--address", help="IP or DNS the master uses to reach the worker"),
agent_port: int = typer.Option(8765, "--agent-port", help="Worker agent TCP port"),
sans: Optional[str] = typer.Option(None, "--sans", help="Comma-separated extra SANs for the worker cert"),
notes: Optional[str] = typer.Option(None, "--notes", help="Free-form operator notes"),
out_dir: Optional[str] = typer.Option(None, "--out-dir", help="Write the bundle (ca.crt/worker.crt/worker.key) to this dir for scp"),
updater: bool = typer.Option(False, "--updater", help="Also issue an updater-identity cert (CN=updater@<name>) for the remote self-updater"),
url: Optional[str] = typer.Option(None, "--url", help="Override swarm controller URL (default: 127.0.0.1:8770)"),
) -> None:
"""Issue a mTLS bundle for a new worker and register it in the swarm."""
import pathlib as _pathlib
body: dict = {"name": name, "address": address, "agent_port": agent_port}
if sans:
body["sans"] = [s.strip() for s in sans.split(",") if s.strip()]
if notes:
body["notes"] = notes
if updater:
body["issue_updater_bundle"] = True
resp = _utils._http_request("POST", _utils._swarmctl_base_url(url) + "/swarm/enroll", json_body=body)
data = resp.json()
console.print(f"[green]Enrolled worker:[/] {data['name']} "
f"[dim]uuid=[/]{data['host_uuid']} "
f"[dim]fingerprint=[/]{data['fingerprint']}")
if data.get("updater"):
console.print(f"[green] + updater identity[/] "
f"[dim]fingerprint=[/]{data['updater']['fingerprint']}")
if out_dir:
target = _pathlib.Path(out_dir).expanduser()
target.mkdir(parents=True, exist_ok=True)
(target / "ca.crt").write_text(data["ca_cert_pem"])
(target / "worker.crt").write_text(data["worker_cert_pem"])
(target / "worker.key").write_text(data["worker_key_pem"])
for leaf in ("worker.key",):
try:
(target / leaf).chmod(0o600)
except OSError:
pass
console.print(f"[cyan]Agent bundle written to[/] {target}")
if data.get("updater"):
upd_target = target.parent / f"{target.name}-updater"
upd_target.mkdir(parents=True, exist_ok=True)
(upd_target / "ca.crt").write_text(data["ca_cert_pem"])
(upd_target / "updater.crt").write_text(data["updater"]["updater_cert_pem"])
(upd_target / "updater.key").write_text(data["updater"]["updater_key_pem"])
try:
(upd_target / "updater.key").chmod(0o600)
except OSError:
pass
console.print(f"[cyan]Updater bundle written to[/] {upd_target}")
console.print("[dim]Ship the agent dir to ~/.decnet/agent/ and the updater dir to ~/.decnet/updater/ on the worker.[/]")
else:
console.print("[dim]Ship this directory to the worker at ~/.decnet/agent/ (or wherever `decnet agent --agent-dir` points).[/]")
else:
console.print("[yellow]No --out-dir given — bundle PEMs are in the JSON response; persist them before leaving this shell.[/]")
@swarm_app.command("list")
def swarm_list(
host_status: Optional[str] = typer.Option(None, "--status", help="Filter by status (enrolled|active|unreachable|decommissioned)"),
url: Optional[str] = typer.Option(None, "--url", help="Override swarm controller URL"),
) -> None:
"""List enrolled workers."""
q = f"?host_status={host_status}" if host_status else ""
resp = _utils._http_request("GET", _utils._swarmctl_base_url(url) + "/swarm/hosts" + q)
rows = resp.json()
if not rows:
console.print("[dim]No workers enrolled.[/]")
return
table = Table(title="DECNET swarm workers")
for col in ("name", "address", "port", "status", "last heartbeat", "enrolled"):
table.add_column(col)
for r in rows:
table.add_row(
r.get("name") or "",
r.get("address") or "",
str(r.get("agent_port") or ""),
r.get("status") or "",
str(r.get("last_heartbeat") or ""),
str(r.get("enrolled_at") or ""),
)
console.print(table)
@swarm_app.command("check")
def swarm_check(
url: Optional[str] = typer.Option(None, "--url", help="Override swarm controller URL"),
json_out: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
) -> None:
"""Actively probe every enrolled worker and refresh status + last_heartbeat."""
resp = _utils._http_request("POST", _utils._swarmctl_base_url(url) + "/swarm/check", timeout=60.0)
payload = resp.json()
results = payload.get("results", [])
if json_out:
console.print_json(data=payload)
return
if not results:
console.print("[dim]No workers enrolled.[/]")
return
table = Table(title="DECNET swarm check")
for col in ("name", "address", "reachable", "detail"):
table.add_column(col)
for r in results:
reachable = r.get("reachable")
mark = "[green]yes[/]" if reachable else "[red]no[/]"
detail = r.get("detail")
detail_str = ""
if isinstance(detail, dict):
detail_str = detail.get("status") or ", ".join(f"{k}={v}" for k, v in detail.items())
elif detail is not None:
detail_str = str(detail)
table.add_row(
r.get("name") or "",
r.get("address") or "",
mark,
detail_str,
)
console.print(table)
@swarm_app.command("update")
def swarm_update(
host: Optional[str] = typer.Option(None, "--host", help="Target worker (name or UUID). Omit with --all."),
all_hosts: bool = typer.Option(False, "--all", help="Push to every enrolled worker."),
include_self: bool = typer.Option(False, "--include-self", help="Also push to each updater's /update-self after a successful agent update."),
root: Optional[str] = typer.Option(None, "--root", help="Source tree to tar (default: CWD)."),
exclude: list[str] = typer.Option([], "--exclude", help="Additional exclude glob. Repeatable."),
updater_port: int = typer.Option(8766, "--updater-port", help="Port the workers' updater listens on."),
dry_run: bool = typer.Option(False, "--dry-run", help="Build the tarball and print stats; no network."),
url: Optional[str] = typer.Option(None, "--url", help="Override swarm controller URL."),
) -> None:
"""Push the current working tree to workers' self-updaters (with auto-rollback on failure)."""
import asyncio
import pathlib as _pathlib
from decnet.swarm.tar_tree import tar_working_tree, detect_git_sha
from decnet.swarm.updater_client import UpdaterClient
if not (host or all_hosts):
console.print("[red]Supply --host <name> or --all.[/]")
raise typer.Exit(2)
if host and all_hosts:
console.print("[red]--host and --all are mutually exclusive.[/]")
raise typer.Exit(2)
base = _utils._swarmctl_base_url(url)
resp = _utils._http_request("GET", base + "/swarm/hosts")
rows = resp.json()
if host:
targets = [r for r in rows if r.get("name") == host or r.get("uuid") == host]
if not targets:
console.print(f"[red]No enrolled worker matching '{host}'.[/]")
raise typer.Exit(1)
else:
targets = [r for r in rows if r.get("status") != "decommissioned"]
if not targets:
console.print("[dim]No targets.[/]")
return
tree_root = _pathlib.Path(root) if root else _pathlib.Path.cwd()
sha = detect_git_sha(tree_root)
console.print(f"[dim]Tarring[/] {tree_root} [dim]sha={sha or '(not a git repo)'}[/]")
tarball = tar_working_tree(tree_root, extra_excludes=exclude)
console.print(f"[dim]Tarball size:[/] {len(tarball):,} bytes")
if dry_run:
console.print("[yellow]--dry-run: not pushing.[/]")
for t in targets:
console.print(f" would push to [cyan]{t.get('name')}[/] at {t.get('address')}:{updater_port}")
return
async def _push_one(h: dict) -> dict:
name = h.get("name") or h.get("uuid")
out: dict = {"name": name, "address": h.get("address"), "agent": None, "self": None}
try:
async with UpdaterClient(h, updater_port=updater_port) as u:
r = await u.update(tarball, sha=sha)
out["agent"] = {"status": r.status_code, "body": r.json() if r.content else {}}
if r.status_code == 200 and include_self:
rs = await u.update_self(tarball, sha=sha)
out["self"] = {"status": rs.status_code, "body": rs.json() if rs.content else {}}
except Exception as exc: # noqa: BLE001
out["error"] = f"{type(exc).__name__}: {exc}"
return out
async def _push_all() -> list[dict]:
return await asyncio.gather(*(_push_one(t) for t in targets))
results = asyncio.run(_push_all())
table = Table(title="DECNET swarm update")
for col in ("host", "address", "agent", "self", "detail"):
table.add_column(col)
any_failure = False
for r in results:
agent = r.get("agent") or {}
selff = r.get("self") or {}
err = r.get("error")
if err:
any_failure = True
table.add_row(r["name"], r.get("address") or "", "[red]error[/]", "", err)
continue
a_status = agent.get("status")
if a_status == 200:
agent_cell = "[green]updated[/]"
elif a_status == 409:
agent_cell = "[yellow]rolled-back[/]"
any_failure = True
else:
agent_cell = f"[red]{a_status}[/]"
any_failure = True
if not include_self:
self_cell = ""
elif selff.get("status") == 200 or selff.get("status") is None:
self_cell = "[green]ok[/]" if selff else "[dim]skipped[/]"
else:
self_cell = f"[red]{selff.get('status')}[/]"
detail = ""
body = agent.get("body") or {}
if isinstance(body, dict):
detail = body.get("release", {}).get("sha") or body.get("detail", {}).get("error") or ""
table.add_row(r["name"], r.get("address") or "", agent_cell, self_cell, str(detail)[:80])
console.print(table)
if any_failure:
raise typer.Exit(1)
@swarm_app.command("deckies")
def swarm_deckies(
host: Optional[str] = typer.Option(None, "--host", help="Filter by worker name or UUID"),
state: Optional[str] = typer.Option(None, "--state", help="Filter by shard state (pending|running|failed|torn_down)"),
url: Optional[str] = typer.Option(None, "--url", help="Override swarm controller URL"),
json_out: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
) -> None:
"""List deployed deckies across the swarm with their owning worker host."""
base = _utils._swarmctl_base_url(url)
host_uuid: Optional[str] = None
if host:
resp = _utils._http_request("GET", base + "/swarm/hosts")
rows = resp.json()
match = next((r for r in rows if r.get("uuid") == host or r.get("name") == host), None)
if match is None:
console.print(f"[red]No enrolled worker matching '{host}'.[/]")
raise typer.Exit(1)
host_uuid = match["uuid"]
query = []
if host_uuid:
query.append(f"host_uuid={host_uuid}")
if state:
query.append(f"state={state}")
path = "/swarm/deckies" + ("?" + "&".join(query) if query else "")
resp = _utils._http_request("GET", base + path)
rows = resp.json()
if json_out:
console.print_json(data=rows)
return
if not rows:
console.print("[dim]No deckies deployed.[/]")
return
table = Table(title="DECNET swarm deckies")
for col in ("decky", "host", "address", "state", "services"):
table.add_column(col)
for r in rows:
services = ",".join(r.get("services") or []) or ""
state_val = r.get("state") or "pending"
colored = {
"running": f"[green]{state_val}[/]",
"failed": f"[red]{state_val}[/]",
"pending": f"[yellow]{state_val}[/]",
"torn_down": f"[dim]{state_val}[/]",
}.get(state_val, state_val)
table.add_row(
r.get("decky_name") or "",
r.get("host_name") or "<unknown>",
r.get("host_address") or "",
colored,
services,
)
console.print(table)
@swarm_app.command("decommission")
def swarm_decommission(
name: Optional[str] = typer.Option(None, "--name", help="Worker hostname"),
uuid: Optional[str] = typer.Option(None, "--uuid", help="Worker UUID (skip lookup)"),
url: Optional[str] = typer.Option(None, "--url", help="Override swarm controller URL"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirmation"),
) -> None:
"""Remove a worker from the swarm (cascades decky shard rows)."""
if not (name or uuid):
console.print("[red]Supply --name or --uuid.[/]")
raise typer.Exit(2)
base = _utils._swarmctl_base_url(url)
target_uuid = uuid
target_name = name
if target_uuid is None:
resp = _utils._http_request("GET", base + "/swarm/hosts")
rows = resp.json()
match = next((r for r in rows if r.get("name") == name), None)
if match is None:
console.print(f"[red]No enrolled worker named '{name}'.[/]")
raise typer.Exit(1)
target_uuid = match["uuid"]
target_name = match.get("name") or target_name
if not yes:
confirm = typer.confirm(f"Decommission worker {target_name!r} ({target_uuid})?", default=False)
if not confirm:
console.print("[dim]Aborted.[/]")
raise typer.Exit(0)
_utils._http_request("DELETE", f"{base}/swarm/hosts/{target_uuid}")
console.print(f"[green]Decommissioned {target_name or target_uuid}.[/]")

104
decnet/cli/swarmctl.py Normal file
View File

@@ -0,0 +1,104 @@
from __future__ import annotations
import os
import signal
import subprocess # nosec B404
import sys
from typing import Optional
import typer
from . import utils as _utils
from .gating import _require_master_mode
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command()
def swarmctl(
port: int = typer.Option(8770, "--port", help="Port for the swarm controller"),
host: str = typer.Option("127.0.0.1", "--host", help="Bind address for the swarm controller"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
no_listener: bool = typer.Option(False, "--no-listener", help="Do not auto-spawn the syslog-TLS listener alongside swarmctl"),
tls: bool = typer.Option(False, "--tls", help="Serve over HTTPS with mTLS (required for cross-host worker heartbeats)"),
cert: Optional[str] = typer.Option(None, "--cert", help="BYOC: path to TLS server cert (PEM). Auto-issues from the DECNET CA if omitted."),
key: Optional[str] = typer.Option(None, "--key", help="BYOC: path to TLS server private key (PEM)."),
client_ca: Optional[str] = typer.Option(None, "--client-ca", help="CA bundle used to verify worker client certs. Defaults to the DECNET CA."),
) -> None:
"""Run the DECNET SWARM controller (master-side, separate process from `decnet api`).
By default, `decnet swarmctl` auto-spawns `decnet listener` as a fully-
detached sibling process so the master starts accepting forwarder
connections on 6514 without a second manual invocation. The listener
survives swarmctl restarts and crashes — if it dies on its own,
restart it manually with `decnet listener --daemon …`. Pass
--no-listener to skip.
Pass ``--tls`` to serve over HTTPS with mutual-TLS enforcement. By
default the server cert is auto-issued from the DECNET CA under
``~/.decnet/swarmctl/`` so enrolled workers (which already ship that
CA's ``ca.crt``) trust it out of the box. BYOC via ``--cert``/``--key``
if you need a publicly-trusted or externally-managed cert.
"""
_require_master_mode("swarmctl")
if daemon:
log.info("swarmctl daemonizing host=%s port=%d", host, port)
_utils._daemonize()
if not no_listener:
listener_host = os.environ.get("DECNET_LISTENER_HOST", "0.0.0.0") # nosec B104
listener_port = int(os.environ.get("DECNET_SWARM_SYSLOG_PORT", "6514"))
lst_argv = [
sys.executable, "-m", "decnet", "listener",
"--host", listener_host,
"--port", str(listener_port),
"--daemon",
]
try:
pid = _utils._spawn_detached(lst_argv, _utils._pid_dir() / "listener.pid")
log.info("swarmctl auto-spawned listener pid=%d bind=%s:%d",
pid, listener_host, listener_port)
console.print(f"[dim]Auto-spawned listener (pid {pid}) on {listener_host}:{listener_port}.[/]")
except Exception as e: # noqa: BLE001
log.warning("swarmctl could not auto-spawn listener: %s", e)
console.print(f"[yellow]listener auto-spawn skipped: {e}[/]")
log.info("swarmctl command invoked host=%s port=%d tls=%s", host, port, tls)
scheme = "https" if tls else "http"
console.print(f"[green]Starting DECNET SWARM controller on {scheme}://{host}:{port}...[/]")
_cmd = [sys.executable, "-m", "uvicorn", "decnet.web.swarm_api:app",
"--host", host, "--port", str(port)]
if tls:
from decnet.swarm import pki as _pki
if cert and key:
cert_path, key_path = cert, key
elif cert or key:
console.print("[red]--cert and --key must be provided together.[/]")
raise typer.Exit(code=2)
else:
auto_cert, auto_key, _auto_ca = _pki.ensure_swarmctl_cert(host)
cert_path, key_path = str(auto_cert), str(auto_key)
console.print(f"[dim]Auto-issued swarmctl server cert → {cert_path}[/]")
ca_path = client_ca or str(_pki.DEFAULT_CA_DIR / "ca.crt")
_cmd += [
"--ssl-keyfile", key_path,
"--ssl-certfile", cert_path,
"--ssl-ca-certs", ca_path,
"--ssl-cert-reqs", "2",
]
try:
proc = subprocess.Popen(_cmd, start_new_session=True) # nosec B603 B404
try:
proc.wait()
except KeyboardInterrupt:
try:
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
except ProcessLookupError:
pass
except (FileNotFoundError, subprocess.SubprocessError):
console.print("[red]Failed to start swarmctl. Ensure 'uvicorn' is installed in the current environment.[/]")

348
decnet/cli/topology.py Normal file
View File

@@ -0,0 +1,348 @@
"""MazeNET topology CLI: generate / deploy / teardown / list / show."""
from __future__ import annotations
import asyncio
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from decnet.topology.config import TopologyConfig
from decnet.topology.generator import generate
from decnet.topology.persistence import hydrate, persist
from decnet.topology.status import TopologyStatus
from .gating import _require_master_mode
_console = Console()
_group = typer.Typer(
name="topology",
help="MazeNET nested-topology commands (DECNET master only).",
no_args_is_help=True,
)
async def _repo():
from decnet.web.db.factory import get_repository
r = get_repository()
await r.initialize()
return r
@_group.command("generate")
def _generate(
name: str = typer.Option(..., "--name", help="Topology name"),
depth: int = typer.Option(3, "--depth", min=1, max=16),
branching: int = typer.Option(2, "--branching", min=1, max=8),
deckies_per_lan: str = typer.Option(
"1-3",
"--deckies-per-lan",
help="Min-max deckies per LAN, e.g. 1-3",
),
bridge_forward_probability: float = typer.Option(1.0, "--bridge-forward-p", min=0.0, max=1.0),
cross_edge_probability: float = typer.Option(0.0, "--cross-edge-p", min=0.0, max=1.0),
services: Optional[str] = typer.Option(None, "--services", help="Comma-separated explicit services"),
randomize_services: bool = typer.Option(True, "--randomize-services/--no-randomize-services"),
seed: Optional[int] = typer.Option(None, "--seed", min=0),
) -> None:
"""Generate a topology plan and persist it as pending."""
_require_master_mode("topology generate")
try:
lo, hi = (int(x) for x in deckies_per_lan.split("-", 1))
except ValueError:
_console.print("[red]--deckies-per-lan must be formatted as MIN-MAX, e.g. 1-3.[/]")
raise typer.Exit(1)
services_explicit = (
[s.strip() for s in services.split(",") if s.strip()] if services else None
)
try:
cfg = TopologyConfig(
name=name,
depth=depth,
branching_factor=branching,
deckies_per_lan_min=lo,
deckies_per_lan_max=hi,
bridge_forward_probability=bridge_forward_probability,
cross_edge_probability=cross_edge_probability,
services_explicit=services_explicit,
randomize_services=randomize_services if not services_explicit else False,
seed=seed,
)
except ValueError as e:
_console.print(f"[red]{e}[/]")
raise typer.Exit(1)
plan = generate(cfg)
async def _go() -> str:
repo = await _repo()
return await persist(repo, plan)
tid = asyncio.run(_go())
_console.print(f"[green]Topology persisted as pending[/] — id=[bold]{tid}[/]")
_console.print(
f" LANs: {len(plan.lans)} deckies: {len(plan.deckies)} edges: {len(plan.edges)}"
)
@_group.command("list")
def _list() -> None:
"""List all topologies."""
_require_master_mode("topology list")
async def _go() -> list[dict]:
repo = await _repo()
return await repo.list_topologies()
rows = asyncio.run(_go())
if not rows:
_console.print("[yellow]No topologies.[/]")
return
table = Table(title="DECNET / MazeNET Topologies")
for col in ("id", "name", "mode", "status", "created_at"):
table.add_column(col)
for r in rows:
table.add_row(
str(r["id"]),
str(r["name"]),
str(r["mode"]),
str(r["status"]),
str(r.get("created_at", "")),
)
_console.print(table)
@_group.command("show")
def _show(topology_id: str = typer.Argument(..., help="Topology id")) -> None:
"""Print a structured summary of a topology."""
_require_master_mode("topology show")
async def _go():
repo = await _repo()
return await hydrate(repo, topology_id)
hydrated = asyncio.run(_go())
if hydrated is None:
_console.print(f"[red]No such topology: {topology_id}[/]")
raise typer.Exit(1)
topo = hydrated["topology"]
_console.print(
f"[bold]{topo['name']}[/] id={topo['id']} status={topo['status']}"
f" mode={topo['mode']}"
)
def _decky_name(d: dict) -> str:
cfg = d.get("decky_config") or {}
return cfg.get("name") or d.get("name") or d["uuid"]
deckies_by_name = {_decky_name(d): d for d in hydrated["deckies"]}
edges_by_lan: dict[str, list[dict]] = {}
for e in hydrated["edges"]:
edges_by_lan.setdefault(e["lan_id"], []).append(e)
for lan in hydrated["lans"]:
dmz_tag = " [dim](DMZ)[/]" if lan["is_dmz"] else ""
_console.print(f"\n[cyan]LAN[/] {lan['name']} {lan['subnet']}{dmz_tag}")
lan_edges = edges_by_lan.get(lan["id"], [])
for e in lan_edges:
# Find the decky name via uuid.
decky = next(
(d for d in hydrated["deckies"] if d["uuid"] == e["decky_uuid"]),
None,
)
if decky is None:
continue
cfg = decky.get("decky_config") or {}
name = _decky_name(decky)
ip = (cfg.get("ips_by_lan") or {}).get(lan["name"]) or decky.get("ip") or "?"
tags = []
if e["is_bridge"]:
tags.append("bridge")
if e["forwards_l3"]:
tags.append("L3-forward")
tag_s = f" [yellow]({', '.join(tags)})[/]" if tags else ""
svcs = ",".join(cfg.get("services") or decky.get("services") or []) or "-"
_console.print(f"{name} {ip} svcs={svcs}{tag_s}")
_ = deckies_by_name # for future cross-reference extensions
@_group.command("deploy")
def _deploy(
topology_id: str = typer.Argument(..., help="Topology id (must be pending)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Write compose + create nets, skip containers"),
) -> None:
"""Deploy a pending topology."""
_require_master_mode("topology deploy")
from decnet.engine.deployer import deploy_topology
async def _go() -> None:
repo = await _repo()
await deploy_topology(repo, topology_id, dry_run=dry_run)
asyncio.run(_go())
_console.print(f"[green]Topology {topology_id} deployed.[/]")
@_group.command("teardown")
def _teardown(
topology_id: str = typer.Argument(..., help="Topology id"),
) -> None:
"""Tear down a topology. Legal from active|degraded|failed|deploying."""
_require_master_mode("topology teardown")
from decnet.engine.deployer import teardown_topology
async def _go() -> None:
repo = await _repo()
await teardown_topology(repo, topology_id)
asyncio.run(_go())
_console.print(f"[green]Topology {topology_id} torn down.[/]")
@_group.command("delete")
def _delete(
topology_id: str = typer.Argument(..., help="Topology id"),
force: bool = typer.Option(
False,
"--force",
help="Skip the confirmation prompt (required for non-interactive use).",
),
) -> None:
"""Delete a topology and all its children (LANs, deckies, edges, mutations).
Refuses while containers are running — teardown first.
"""
_require_master_mode("topology delete")
_RUNNING = {
TopologyStatus.DEPLOYING,
TopologyStatus.ACTIVE,
TopologyStatus.DEGRADED,
TopologyStatus.TEARING_DOWN,
}
async def _go() -> tuple[bool, Optional[str]]:
repo = await _repo()
topo = await repo.get_topology(topology_id)
if topo is None:
return False, "not-found"
if topo["status"] in _RUNNING:
return False, str(topo["status"])
ok = await repo.delete_topology_cascade(topology_id)
return ok, None
if not force and not typer.confirm(
f"Delete topology {topology_id} and all its children? This cannot be undone.",
default=False,
):
_console.print("[yellow]Cancelled.[/]")
raise typer.Exit(0)
ok, reason = asyncio.run(_go())
if reason == "not-found":
_console.print(f"[red]No such topology: {topology_id}[/]")
raise typer.Exit(1)
if reason is not None:
_console.print(
f"[red]Cannot delete while status={reason!r}. Run "
f"[bold]decnet topology teardown {topology_id}[/] first.[/]"
)
raise typer.Exit(1)
if not ok:
_console.print(f"[red]Delete failed: {topology_id}[/]")
raise typer.Exit(1)
_console.print(f"[green]Topology {topology_id} deleted.[/]")
@_group.command("mutate")
def _mutate(
topology_id: str = typer.Argument(..., help="Topology id (active or degraded)"),
op: str = typer.Argument(
...,
help=(
"One of: add_lan, remove_lan, add_decky, attach_decky, "
"detach_decky, remove_decky, update_decky, update_lan"
),
),
payload_json: str = typer.Option(
"{}",
"--payload-json",
help="JSON payload for the op (see mutator.ops for keys)",
),
expected_version: Optional[int] = typer.Option(
None,
"--expected-version",
help="Optimistic-concurrency guard; enqueue fails with a "
"VersionConflict if the topology has since been mutated.",
),
) -> None:
"""Enqueue a live mutation. The mutator's watch loop applies it."""
_require_master_mode("topology mutate")
import json
try:
payload = json.loads(payload_json)
except ValueError as e:
_console.print(f"[red]Invalid JSON: {e}[/]")
raise typer.Exit(1)
async def _go() -> str:
repo = await _repo()
return await repo.enqueue_topology_mutation(
topology_id, op, payload, expected_version=expected_version,
)
mid = asyncio.run(_go())
_console.print(
f"[green]Mutation enqueued[/] — id=[bold]{mid}[/] op={op} "
f"(watch for state=applied on [cyan]topology mutations {topology_id}[/])"
)
@_group.command("mutations")
def _mutations(
topology_id: str = typer.Argument(..., help="Topology id"),
state: Optional[str] = typer.Option(
None,
"--state",
help="Filter to one of pending|applying|applied|failed",
),
) -> None:
"""List queued/applied mutations for a topology."""
_require_master_mode("topology mutations")
async def _go() -> list[dict]:
repo = await _repo()
return await repo.list_topology_mutations(topology_id, state=state)
rows = asyncio.run(_go())
if not rows:
_console.print("[yellow]No mutations.[/]")
return
table = Table(title=f"Mutations — topology {topology_id}")
for col in ("id", "op", "state", "requested_at", "applied_at", "reason"):
table.add_column(col)
for r in rows:
table.add_row(
str(r["id"]),
str(r["op"]),
str(r["state"]),
str(r.get("requested_at", "")),
str(r.get("applied_at") or ""),
str(r.get("reason") or ""),
)
_console.print(table)
def register(app: typer.Typer) -> None:
app.add_typer(_group, name="topology")
__all__ = ["register", "TopologyStatus"]

46
decnet/cli/updater.py Normal file
View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import pathlib as _pathlib
from typing import Optional
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command()
def updater(
port: int = typer.Option(8766, "--port", help="Port for the self-updater daemon"),
host: str = typer.Option("0.0.0.0", "--host", help="Bind address for the updater"), # nosec B104
updater_dir: Optional[str] = typer.Option(None, "--updater-dir", help="Updater cert bundle dir (default: ~/.decnet/updater)"),
install_dir: Optional[str] = typer.Option(None, "--install-dir", help="Release install root (default: /opt/decnet)"),
agent_dir: Optional[str] = typer.Option(None, "--agent-dir", help="Worker agent cert bundle (for local /health probes; default: ~/.decnet/agent)"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
) -> None:
"""Run the DECNET self-updater (requires a bundle in ~/.decnet/updater/)."""
from decnet.swarm import pki as _pki
from decnet.updater import server as _upd_server
resolved_updater = _pathlib.Path(updater_dir) if updater_dir else _upd_server.DEFAULT_UPDATER_DIR
resolved_install = _pathlib.Path(install_dir) if install_dir else _pathlib.Path("/opt/decnet")
resolved_agent = _pathlib.Path(agent_dir) if agent_dir else _pki.DEFAULT_AGENT_DIR
if daemon:
log.info("updater daemonizing host=%s port=%d", host, port)
_utils._daemonize()
log.info(
"updater command invoked host=%s port=%d updater_dir=%s install_dir=%s",
host, port, resolved_updater, resolved_install,
)
console.print(f"[green]Starting DECNET self-updater on {host}:{port} (mTLS)...[/]")
rc = _upd_server.run(
host, port,
updater_dir=resolved_updater,
install_dir=resolved_install,
agent_dir=resolved_agent,
)
if rc != 0:
raise typer.Exit(rc)

217
decnet/cli/utils.py Normal file
View File

@@ -0,0 +1,217 @@
"""Shared CLI helpers: console, logger, process management, swarm HTTP client.
Submodules reference these as ``from . import utils`` then ``utils.foo(...)``
so tests can patch ``decnet.cli.utils.<name>`` and have every caller see it.
"""
from __future__ import annotations
import os
import signal
import subprocess # nosec B404
import sys
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from decnet.logging import get_logger
from decnet.env import DECNET_API_HOST, DECNET_API_PORT, DECNET_INGEST_LOG_FILE
log = get_logger("cli")
console = Console()
def _daemonize() -> None:
"""Fork the current process into a background daemon (Unix double-fork)."""
if os.fork() > 0:
raise SystemExit(0)
os.setsid()
if os.fork() > 0:
raise SystemExit(0)
sys.stdout = open(os.devnull, "w") # noqa: SIM115
sys.stderr = open(os.devnull, "w") # noqa: SIM115
sys.stdin = open(os.devnull, "r") # noqa: SIM115
def _pid_dir() -> Path:
"""Return the writable PID directory.
/opt/decnet when it exists and is writable (production), else
~/.decnet (dev). The directory is created if needed."""
candidates = [Path("/opt/decnet"), Path.home() / ".decnet"]
for path in candidates:
try:
path.mkdir(parents=True, exist_ok=True)
if os.access(path, os.W_OK):
return path
except (PermissionError, OSError):
continue
return Path("/tmp") # nosec B108
def _spawn_detached(argv: list[str], pid_file: Path) -> int:
"""Spawn a DECNET subcommand as a fully-independent sibling process.
The parent does NOT wait() on this child. start_new_session=True puts
the child in its own session so SIGHUP on parent exit doesn't kill it;
stdin/stdout/stderr go to /dev/null so the launching shell can close
without EIO on the child. close_fds=True prevents inherited sockets
from pinning ports we're trying to rebind.
This is deliberately NOT a supervisor — we fire-and-forget. If the
child dies, the operator restarts it manually via its own subcommand.
"""
if pid_file.exists():
try:
existing = int(pid_file.read_text().strip())
os.kill(existing, 0)
return existing
except (ValueError, ProcessLookupError, PermissionError, OSError):
pass # stale pid_file — fall through and spawn
with open(os.devnull, "rb") as dn_in, open(os.devnull, "ab") as dn_out:
proc = subprocess.Popen( # nosec B603
argv,
stdin=dn_in, stdout=dn_out, stderr=dn_out,
start_new_session=True, close_fds=True,
)
pid_file.parent.mkdir(parents=True, exist_ok=True)
pid_file.write_text(f"{proc.pid}\n")
return proc.pid
def _is_running(match_fn) -> int | None:
"""Return PID of a running DECNET process matching ``match_fn(cmdline)``, or None."""
import psutil
for proc in psutil.process_iter(["pid", "cmdline"]):
try:
cmd = proc.info["cmdline"]
if cmd and match_fn(cmd):
return proc.info["pid"]
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return None
def _service_registry(log_file: str) -> list[tuple[str, callable, list[str]]]:
"""Return the microservice registry for health-check and relaunch.
On agents these run as systemd units invoking /usr/local/bin/decnet,
which doesn't include "decnet.cli" in its cmdline. On master dev boxes
they're launched via `python -m decnet.cli`. Match either form — cmd
is a list of argv tokens, so substring-check the joined string.
"""
_py = sys.executable
def _matches(sub: str, extras: tuple[str, ...] = ()):
def _check(cmd) -> bool:
joined = " ".join(cmd) if not isinstance(cmd, str) else cmd
if "decnet" not in joined:
return False
if sub not in joined:
return False
return all(e in joined for e in extras)
return _check
return [
("Collector", _matches("collect"),
[_py, "-m", "decnet.cli", "collect", "--daemon", "--log-file", log_file]),
("Mutator", _matches("mutate", ("--watch",)),
[_py, "-m", "decnet.cli", "mutate", "--daemon", "--watch"]),
("Prober", _matches("probe"),
[_py, "-m", "decnet.cli", "probe", "--daemon", "--log-file", log_file]),
("Profiler", _matches("profiler"),
[_py, "-m", "decnet.cli", "profiler", "--daemon"]),
("Sniffer", _matches("sniffer"),
[_py, "-m", "decnet.cli", "sniffer", "--daemon", "--log-file", log_file]),
("API",
lambda cmd: "uvicorn" in cmd and "decnet.web.api:app" in cmd,
[_py, "-m", "uvicorn", "decnet.web.api:app",
"--host", DECNET_API_HOST, "--port", str(DECNET_API_PORT)]),
]
def _systemd_units(pattern: str = "decnet-*.service") -> list[dict] | None:
"""Return state of every systemd unit matching *pattern*, or ``None``
when systemctl is unavailable (non-systemd host, container lab,
PATH-stripped env, user-manager unreachable).
Output shape mirrors ``systemctl list-units --output=json``: each
dict has ``unit``, ``load``, ``active``, ``sub``, ``description``.
Empty list = systemd works but no matching units are loaded (fresh
host that never ran ``decnet init``).
"""
import json # local import — avoids paying it on every CLI startup
import shutil
if not shutil.which("systemctl"):
return None
try:
proc = subprocess.run( # nosec B603 B607 — fixed argv, no shell
[
"systemctl", "list-units",
"--type=service", "--all",
"--no-legend", "--no-pager",
"--output=json",
pattern,
],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
try:
data = json.loads(proc.stdout or "[]")
except json.JSONDecodeError:
return None
return data if isinstance(data, list) else None
def _kill_all_services() -> None:
"""Find and kill all running DECNET microservice processes."""
registry = _service_registry(str(DECNET_INGEST_LOG_FILE))
killed = 0
for name, match_fn, _launch_args in registry:
pid = _is_running(match_fn)
if pid is not None:
console.print(f"[yellow]Stopping {name} (PID {pid})...[/]")
os.kill(pid, signal.SIGTERM)
killed += 1
if killed:
console.print(f"[green]{killed} background process(es) stopped.[/]")
else:
console.print("[dim]No DECNET services were running.[/]")
_DEFAULT_SWARMCTL_URL = "http://127.0.0.1:8770"
def _swarmctl_base_url(url: Optional[str]) -> str:
return url or os.environ.get("DECNET_SWARMCTL_URL", _DEFAULT_SWARMCTL_URL)
def _http_request(method: str, url: str, *, json_body: Optional[dict] = None, timeout: float = 30.0):
"""Tiny sync wrapper around httpx; avoids leaking async into the CLI."""
import httpx
try:
resp = httpx.request(method, url, json=json_body, timeout=timeout)
except httpx.HTTPError as exc:
console.print(f"[red]Could not reach swarm controller at {url}: {exc}[/]")
console.print("[dim]Is `decnet swarmctl` running?[/]")
raise typer.Exit(2)
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception: # nosec B110
detail = resp.text
console.print(f"[red]{method} {url} failed: {resp.status_code}{detail}[/]")
raise typer.Exit(1)
return resp

153
decnet/cli/web.py Normal file
View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import typer
from decnet.env import DECNET_API_HOST, DECNET_API_PORT, DECNET_WEB_HOST, DECNET_WEB_PORT
from . import utils as _utils
from .utils import console, log
def _proxy_target(api_host: str) -> str:
"""Resolve the host the web proxy should connect to.
The API binds at ``DECNET_API_HOST``; when that's a wildcard
(``0.0.0.0`` / ``::``) we still connect over loopback because the
web and API run in the same host. When the operator binds the API
to a specific address (e.g. a Tailscale IP), the API is *only*
reachable there — loopback is closed — so the proxy must follow.
"""
wildcard = {"0.0.0.0", "::", ""} # nosec B104 — comparison only
if api_host in wildcard:
return "127.0.0.1"
return api_host
def register(app: typer.Typer) -> None:
@app.command(name="web")
def serve_web(
web_port: int = typer.Option(DECNET_WEB_PORT, "--web-port", help="Port to serve the DECNET Web Dashboard"),
host: str = typer.Option(DECNET_WEB_HOST, "--host", help="Host IP to serve the Web Dashboard"),
api_host: str = typer.Option(DECNET_API_HOST, "--api-host", help="Host the DECNET API is listening on (loopback for wildcard binds)"),
api_port: int = typer.Option(DECNET_API_PORT, "--api-port", help="Port the DECNET API is listening on"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
) -> None:
"""Serve the DECNET Web Dashboard frontend.
Proxies /api/* requests to the API server so the frontend can use
relative URLs (/api/v1/...) with no CORS configuration required.
"""
import http.client
import http.server
import os
import socketserver
from pathlib import Path
dist_dir = Path(__file__).resolve().parent.parent.parent / "decnet_web" / "dist"
if not dist_dir.exists():
console.print(f"[red]Frontend build not found at {dist_dir}. Make sure you run 'npm run build' inside 'decnet_web'.[/]")
raise typer.Exit(1)
_api_target = _proxy_target(api_host)
if daemon:
log.info(
"web daemonizing host=%s port=%d api_target=%s:%d",
host, web_port, _api_target, api_port,
)
_utils._daemonize()
_api_port = api_port
class SPAHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/api/"):
self._proxy("GET")
return
path = self.translate_path(self.path)
if not Path(path).exists() or Path(path).is_dir():
self.path = "/index.html"
return super().do_GET()
def do_POST(self):
if self.path.startswith("/api/"):
self._proxy("POST")
return
self.send_error(405)
def do_PUT(self):
if self.path.startswith("/api/"):
self._proxy("PUT")
return
self.send_error(405)
def do_DELETE(self):
if self.path.startswith("/api/"):
self._proxy("DELETE")
return
self.send_error(405)
def do_PATCH(self):
if self.path.startswith("/api/"):
self._proxy("PATCH")
return
self.send_error(405)
def do_OPTIONS(self):
if self.path.startswith("/api/"):
self._proxy("OPTIONS")
return
self.send_error(405)
def _proxy(self, method: str) -> None:
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length else None
forward = {k: v for k, v in self.headers.items()
if k.lower() not in ("host", "connection")}
try:
conn = http.client.HTTPConnection(_api_target, _api_port, timeout=120)
conn.request(method, self.path, body=body, headers=forward)
resp = conn.getresponse()
self.send_response(resp.status)
for key, val in resp.getheaders():
if key.lower() not in ("connection", "transfer-encoding"):
self.send_header(key, val)
self.end_headers()
content_type = resp.getheader("Content-Type", "")
if "text/event-stream" in content_type:
conn.sock.settimeout(None)
_read = getattr(resp, "read1", resp.read)
while True:
chunk = _read(4096)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
except Exception as exc:
log.warning("web proxy error %s %s: %s", method, self.path, exc)
self.send_error(502, f"API proxy error: {exc}")
finally:
try:
conn.close()
except Exception: # nosec B110 — best-effort conn cleanup
pass
def log_message(self, fmt: str, *args: object) -> None:
log.debug("web %s", fmt % args)
os.chdir(dist_dir)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer((host, web_port), SPAHTTPRequestHandler) as httpd:
console.print(f"[green]Serving DECNET Web Dashboard on http://{host}:{web_port}[/]")
console.print(f"[dim]Proxying /api/* → http://{_api_target}:{_api_port}[/]")
try:
httpd.serve_forever()
except KeyboardInterrupt:
console.print("\n[dim]Shutting down dashboard server.[/]")

35
decnet/cli/webhook.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command(name="webhook")
def webhook_cmd(
daemon: bool = typer.Option(
False, "--daemon", "-d", help="Detach to background as a daemon process"
),
) -> None:
"""Run the webhook dispatcher — bus consumer → external HTTP egress."""
import asyncio
from decnet.web.dependencies import repo
from decnet.webhook import webhook_worker
if daemon:
log.info("webhook daemonizing")
_utils._daemonize()
log.info("webhook starting")
console.print("[bold cyan]Webhook dispatcher starting[/]")
async def _run() -> None:
await repo.initialize()
await webhook_worker(repo)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Webhook worker stopped.[/]")

297
decnet/cli/workers.py Normal file
View File

@@ -0,0 +1,297 @@
from __future__ import annotations
from typing import Optional
import typer
from decnet.env import DECNET_INGEST_LOG_FILE
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command()
def probe(
log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", "-f", help="Path for RFC 5424 syslog + .json output (reads attackers from .json, writes results to both)"),
interval: int = typer.Option(300, "--interval", "-i", help="Seconds between probe cycles (default: 300)"),
timeout: float = typer.Option(5.0, "--timeout", help="Per-probe TCP timeout in seconds"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background (used by deploy, no console output)"),
) -> None:
"""Fingerprint attackers (JARM + HASSH + TCP/IP stack) discovered in the log stream."""
import asyncio
from decnet.prober import prober_worker
if daemon:
log.info("probe daemonizing log_file=%s interval=%d", log_file, interval)
_utils._daemonize()
asyncio.run(prober_worker(log_file, interval=interval, timeout=timeout))
return
log.info("probe command invoked log_file=%s interval=%d", log_file, interval)
console.print(f"[bold cyan]DECNET-PROBER[/] watching {log_file} for attackers (interval: {interval}s)")
console.print("[dim]Press Ctrl+C to stop[/]")
try:
asyncio.run(prober_worker(log_file, interval=interval, timeout=timeout))
except KeyboardInterrupt:
console.print("\n[yellow]DECNET-PROBER stopped.[/]")
@app.command()
def collect(
log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", "-f", help="Path to write RFC 5424 syslog lines and .json records"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
) -> None:
"""Stream Docker logs from all running decky service containers to a log file."""
import asyncio
from decnet.collector import log_collector_worker
if daemon:
log.info("collect daemonizing log_file=%s", log_file)
_utils._daemonize()
log.info("collect command invoked log_file=%s", log_file)
console.print(f"[bold cyan]Collector starting[/] → {log_file}")
asyncio.run(log_collector_worker(log_file))
@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", help="Force mutate a specific decky immediately"),
force_all: bool = typer.Option(False, "--all", help="Force mutate all deckies immediately"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
) -> None:
"""Manually trigger or continuously watch for decky mutation."""
import asyncio
from decnet.mutator import mutate_decky, mutate_all, run_watch_loop
from decnet.web.dependencies import repo
if daemon:
log.info("mutate daemonizing watch=%s", watch)
_utils._daemonize()
async def _run() -> None:
await repo.initialize()
if watch:
await run_watch_loop(repo)
elif decky_name:
await mutate_decky(decky_name, repo)
elif force_all:
await mutate_all(force=True, repo=repo)
else:
await mutate_all(force=False, repo=repo)
asyncio.run(_run())
@app.command(name="enrich")
def enrich(
poll_interval_secs: float = typer.Option(
60.0, "--poll-interval", "-i",
help="Slow-tick fallback when the bus is idle or unavailable (seconds)",
),
ttl_hours: int = typer.Option(
24, "--ttl-hours",
help="Cache lifetime per attacker IP — re-firings inside the window short-circuit before any HTTP egress",
),
daemon: bool = typer.Option(
False, "--daemon", "-d",
help="Detach to background as a daemon process",
),
) -> None:
"""Threat-intel enrichment worker — fan out per attacker IP across
configured providers (GreyNoise, AbuseIPDB, abuse.ch Feodo Tracker
+ ThreatFox), cache the verdict in ``attacker_intel``, and publish
``attacker.intel.enriched`` for SIEM-bound webhook consumers.
"""
import asyncio
from decnet.intel.worker import run_intel_loop
from decnet.web.dependencies import repo
if daemon:
log.info(
"enrich daemonizing poll=%s ttl_hours=%d",
poll_interval_secs, ttl_hours,
)
_utils._daemonize()
log.info(
"enrich command invoked poll=%s ttl_hours=%d",
poll_interval_secs, ttl_hours,
)
console.print(
f"[bold cyan]Intel enrichment starting[/] "
f"poll={poll_interval_secs}s ttl={ttl_hours}h"
)
console.print("[dim]Press Ctrl+C to stop[/]")
async def _run() -> None:
await repo.initialize()
await run_intel_loop(
repo,
poll_interval_secs=poll_interval_secs,
ttl_hours=ttl_hours,
)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Intel enrichment stopped.[/]")
@app.command(name="reuse-correlate")
def reuse_correlate(
min_targets: int = typer.Option(
2, "--min-targets", "-m",
help="Minimum distinct (decky, service) targets a secret must hit before a CredentialReuse row is persisted",
),
poll_interval_secs: float = typer.Option(
60.0, "--poll-interval", "-i",
help="Slow-tick fallback when the bus is idle or unavailable (seconds)",
),
daemon: bool = typer.Option(
False, "--daemon", "-d",
help="Detach to background as a daemon process",
),
) -> None:
"""Long-running credential-reuse correlator.
Watches the bus for ``credential.captured`` and ``attacker.observed``
events, re-runs the reuse pass on each wake, and publishes
``credential.reuse.detected`` for every new or grown
``CredentialReuse`` row.
"""
import asyncio
from decnet.correlation.reuse_worker import run_reuse_loop
from decnet.web.dependencies import repo
if daemon:
log.info(
"reuse-correlate daemonizing min_targets=%d poll=%s",
min_targets, poll_interval_secs,
)
_utils._daemonize()
log.info(
"reuse-correlate command invoked min_targets=%d poll=%s",
min_targets, poll_interval_secs,
)
console.print(
f"[bold cyan]Reuse correlator starting[/] "
f"min_targets={min_targets} poll={poll_interval_secs}s"
)
console.print("[dim]Press Ctrl+C to stop[/]")
async def _run() -> None:
await repo.initialize()
await run_reuse_loop(
repo,
poll_interval_secs=poll_interval_secs,
min_targets=min_targets,
)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Reuse correlator stopped.[/]")
@app.command(name="clusterer")
def clusterer(
poll_interval_secs: float = typer.Option(
60.0, "--poll-interval", "-i",
help="Slow-tick fallback when the bus is idle or unavailable (seconds)",
),
daemon: bool = typer.Option(
False, "--daemon", "-d",
help="Detach to background as a daemon process",
),
) -> None:
"""Identity-resolution clusterer.
Bus-woken on ``attacker.observed`` and ``attacker.scored``;
builds a similarity graph over observations, runs
connected-components, writes ``attacker_identities`` rows, and
publishes ``identity.formed`` / ``identity.observation.linked``
/ ``identity.merged`` / ``identity.unmerged``.
"""
import asyncio
from decnet.cli.gating import _require_master_mode
from decnet.clustering.worker import run_clusterer_loop
from decnet.web.dependencies import repo
_require_master_mode("clusterer")
if daemon:
log.info("clusterer daemonizing poll=%s", poll_interval_secs)
_utils._daemonize()
log.info("clusterer command invoked poll=%s", poll_interval_secs)
console.print(
f"[bold cyan]Identity clusterer starting[/] "
f"poll={poll_interval_secs}s"
)
console.print("[dim]Press Ctrl+C to stop[/]")
async def _run() -> None:
await repo.initialize()
await run_clusterer_loop(
repo, poll_interval_secs=poll_interval_secs,
)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Identity clusterer stopped.[/]")
@app.command(name="campaign-clusterer")
def campaign_clusterer(
poll_interval_secs: float = typer.Option(
60.0, "--poll-interval", "-i",
help="Slow-tick fallback when the bus is idle or unavailable (seconds)",
),
daemon: bool = typer.Option(
False, "--daemon", "-d",
help="Detach to background as a daemon process",
),
) -> None:
"""Campaign clusterer — groups identities into operations.
Bus-woken on ``identity.>`` (any identity-layer change is
potential input); reads ``AttackerIdentity`` rows, runs
connected-components over the campaign-level similarity graph
(phase-handoff / shared-infra / temporal-overlap / cohort),
writes ``campaigns`` rows + sets ``attacker_identities.campaign_id``,
and publishes ``campaign.formed`` / ``campaign.identity.assigned``
/ ``campaign.merged`` / ``campaign.unmerged`` plus the cross-family
``identity.campaign.assigned`` so identity-side subscribers see
the badge update.
"""
import asyncio
from decnet.cli.gating import _require_master_mode
from decnet.clustering.campaign.worker import (
run_campaign_clusterer_loop,
)
from decnet.web.dependencies import repo
_require_master_mode("campaign-clusterer")
if daemon:
log.info("campaign-clusterer daemonizing poll=%s", poll_interval_secs)
_utils._daemonize()
log.info(
"campaign-clusterer command invoked poll=%s", poll_interval_secs,
)
console.print(
f"[bold cyan]Campaign clusterer starting[/] "
f"poll={poll_interval_secs}s"
)
console.print("[dim]Press Ctrl+C to stop[/]")
async def _run() -> None:
await repo.initialize()
await run_campaign_clusterer_loop(
repo, poll_interval_secs=poll_interval_secs,
)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Campaign clusterer stopped.[/]")