merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
90
decnet/cli/__init__.py
Normal file
90
decnet/cli/__init__.py
Normal 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
64
decnet/cli/agent.py
Normal 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
53
decnet/cli/api.py
Normal 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
45
decnet/cli/bus.py
Normal 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
42
decnet/cli/canary.py
Normal 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
141
decnet/cli/db.py
Normal 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
307
decnet/cli/deploy.py
Normal 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
74
decnet/cli/forwarder.py
Normal 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
73
decnet/cli/gating.py
Normal 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
59
decnet/cli/geoip.py
Normal 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
843
decnet/cli/init.py
Normal 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
52
decnet/cli/inventory.py
Normal 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
147
decnet/cli/lifecycle.py
Normal 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
57
decnet/cli/listener.py
Normal 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
|
||||
55
decnet/cli/orchestrator.py
Normal file
55
decnet/cli/orchestrator.py
Normal 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
34
decnet/cli/profiler.py
Normal 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
111
decnet/cli/realism.py
Normal 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
62
decnet/cli/reconciler.py
Normal 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
31
decnet/cli/sniffer.py
Normal 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
346
decnet/cli/swarm.py
Normal 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
104
decnet/cli/swarmctl.py
Normal 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
348
decnet/cli/topology.py
Normal 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
46
decnet/cli/updater.py
Normal 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
217
decnet/cli/utils.py
Normal 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
153
decnet/cli/web.py
Normal 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
35
decnet/cli/webhook.py
Normal 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
297
decnet/cli/workers.py
Normal 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.[/]")
|
||||
Reference in New Issue
Block a user