Add main CLI entry point and full test suite
- bigbrother.py: Click CLI with start/stop/status/activate/deactivate/ kill/config/selftest/modules commands plus Rich interactive menu. Module discovery scans modules/stealth/ for BaseModule subclasses. Supports --daemon mode with PID file and --passive-only flag. - tests/conftest.py: Shared fixtures (tmp_dir, mock_config, mock_bus, mock_state, hardware_tier_override, project_root) - tests/test_bus.py: 6 tests for EventBus pub/sub, filtering, wildcards - tests/test_engine.py: 5 tests for Engine lifecycle, deps, tier, phase - tests/test_crypto.py: 5 tests for AES-256-GCM, file encryption, KDF - tests/test_resource.py: 7 tests for hardware detection, memory, disk - tests/test_tool_manager.py: 6 tests for subprocess management - tests/test_stealth_modules.py: 4 parametrized test classes covering all 12 stealth modules (import, inheritance, attributes, instantiation)
This commit is contained in:
Executable
+625
@@ -0,0 +1,625 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BigBrother — Network surveillance implant CLI.
|
||||
|
||||
Main entry point: Click-based CLI + Rich interactive menu.
|
||||
Manages module lifecycle, status monitoring, and kill switch.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import logging
|
||||
import inspect
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich.prompt import Prompt
|
||||
|
||||
# Ensure project root is on sys.path
|
||||
PROJECT_ROOT = str(Path(__file__).resolve().parent)
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from core.bus import EventBus
|
||||
from core.state import StateManager
|
||||
from core.engine import Engine, detect_hardware_tier, HARDWARE_TIERS
|
||||
from core.tool_manager import ToolManager
|
||||
from core.scheduler import Scheduler
|
||||
from core.resource_monitor import ResourceMonitor
|
||||
from core.kill_switch import KillSwitch
|
||||
from modules.base import BaseModule
|
||||
from utils.config_loader import load_config, ConfigLoader
|
||||
from utils.resource import (
|
||||
detect_hardware,
|
||||
get_resource_usage,
|
||||
get_hardware_tier,
|
||||
get_cpu_temperature,
|
||||
get_disk_usage,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
logger = logging.getLogger("bb.cli")
|
||||
|
||||
# PID file for daemon mode
|
||||
PID_FILE = "/tmp/.bb.pid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def discover_stealth_modules() -> Dict[str, Type[BaseModule]]:
|
||||
"""Scan modules/stealth/ for BaseModule subclasses."""
|
||||
modules: Dict[str, Type[BaseModule]] = {}
|
||||
stealth_dir = os.path.join(PROJECT_ROOT, "modules", "stealth")
|
||||
|
||||
if not os.path.isdir(stealth_dir):
|
||||
return modules
|
||||
|
||||
for filename in sorted(os.listdir(stealth_dir)):
|
||||
if filename.startswith("_") or not filename.endswith(".py"):
|
||||
continue
|
||||
|
||||
module_name = filename[:-3]
|
||||
dotted = f"modules.stealth.{module_name}"
|
||||
|
||||
try:
|
||||
mod = importlib.import_module(dotted)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to import %s: %s", dotted, exc)
|
||||
continue
|
||||
|
||||
for attr_name in dir(mod):
|
||||
obj = getattr(mod, attr_name)
|
||||
if (
|
||||
isinstance(obj, type)
|
||||
and issubclass(obj, BaseModule)
|
||||
and obj is not BaseModule
|
||||
and hasattr(obj, "name")
|
||||
and obj.name != "unnamed"
|
||||
):
|
||||
modules[obj.name] = obj
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def get_module_config(module_name: str, config: dict) -> dict:
|
||||
"""Extract module-specific config from the master config."""
|
||||
# Check for module-specific section
|
||||
mod_cfg = config.get("modules", {}).get(module_name, {})
|
||||
if mod_cfg:
|
||||
# Merge with top-level config for access to device, network, etc.
|
||||
merged = dict(config)
|
||||
merged.update(mod_cfg)
|
||||
return merged
|
||||
return dict(config)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def cli(ctx):
|
||||
"""BigBrother — Network surveillance implant."""
|
||||
ctx.ensure_object(dict)
|
||||
if ctx.invoked_subcommand is None:
|
||||
interactive_menu()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--daemon", is_flag=True, help="Fork to background, write PID file")
|
||||
@click.option("--passive-only", is_flag=True, help="Force passive-only engagement phase")
|
||||
def start(daemon, passive_only):
|
||||
"""Start BigBrother — initialize all core systems and enabled modules."""
|
||||
if daemon:
|
||||
_daemonize()
|
||||
|
||||
console.print("[bold green]Starting BigBrother...[/bold green]")
|
||||
|
||||
try:
|
||||
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Config error:[/red] {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
if passive_only:
|
||||
config.setdefault("engagement_phase", {})["mode"] = "passive_only"
|
||||
|
||||
# Initialize core components
|
||||
bus = EventBus()
|
||||
bus.start()
|
||||
|
||||
state = StateManager()
|
||||
state.start()
|
||||
|
||||
engine = Engine(bus=bus, state=state, config=config)
|
||||
tool_manager = ToolManager(bus=bus)
|
||||
tool_manager.start_monitoring()
|
||||
|
||||
# Discover and register stealth modules
|
||||
discovered = discover_stealth_modules()
|
||||
console.print(f" Discovered [cyan]{len(discovered)}[/cyan] stealth modules")
|
||||
|
||||
enabled_modules = config.get("modules", {}).get("enabled", list(discovered.keys()))
|
||||
for name, cls in discovered.items():
|
||||
if name in enabled_modules or not config.get("modules", {}).get("enabled"):
|
||||
mod_config = get_module_config(name, config)
|
||||
engine.register(cls, mod_config)
|
||||
|
||||
# Start modules in dependency order
|
||||
results = engine.start_all()
|
||||
for name, success in results.items():
|
||||
status_str = "[green]OK[/green]" if success else "[red]FAILED[/red]"
|
||||
console.print(f" {name}: {status_str}")
|
||||
|
||||
# Start resource monitor
|
||||
res_mon = ResourceMonitor(bus=bus, state=state, config=config,
|
||||
engine=engine, tool_manager=tool_manager)
|
||||
res_mon.start()
|
||||
|
||||
console.print(f"\n[bold green]BigBrother running[/bold green] (tier={engine.tier}, phase={engine.phase})")
|
||||
|
||||
if daemon:
|
||||
# Write PID file
|
||||
with open(PID_FILE, "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
# Block main thread
|
||||
try:
|
||||
while True:
|
||||
time.sleep(60)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
else:
|
||||
console.print("Press Ctrl+C to stop")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
# Shutdown
|
||||
console.print("\n[yellow]Shutting down...[/yellow]")
|
||||
res_mon.stop()
|
||||
engine.stop_all()
|
||||
tool_manager.stop_all()
|
||||
state.stop()
|
||||
bus.stop()
|
||||
console.print("[green]BigBrother stopped.[/green]")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def stop():
|
||||
"""Graceful shutdown — stop all modules in reverse dependency order."""
|
||||
if os.path.exists(PID_FILE):
|
||||
try:
|
||||
with open(PID_FILE, "r") as f:
|
||||
pid = int(f.read().strip())
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
console.print(f"[green]Sent SIGTERM to BigBrother (PID {pid})[/green]")
|
||||
os.unlink(PID_FILE)
|
||||
except (ProcessLookupError, ValueError):
|
||||
console.print("[yellow]BigBrother not running (stale PID file)[/yellow]")
|
||||
os.unlink(PID_FILE)
|
||||
except PermissionError:
|
||||
console.print("[red]Permission denied — try with sudo[/red]")
|
||||
else:
|
||||
console.print("[yellow]No PID file found — BigBrother may not be running[/yellow]")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def status():
|
||||
"""Show module status as a Rich table."""
|
||||
try:
|
||||
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
state = StateManager()
|
||||
all_status = state.get_all_module_status()
|
||||
|
||||
table = Table(title="BigBrother Module Status", show_lines=True)
|
||||
table.add_column("Module", style="cyan", min_width=20)
|
||||
table.add_column("Status", min_width=10)
|
||||
table.add_column("PID", justify="right", min_width=8)
|
||||
table.add_column("Uptime", justify="right", min_width=12)
|
||||
|
||||
for name, info in sorted(all_status.items()):
|
||||
mod_status = info.get("status", "unknown")
|
||||
pid = str(info.get("pid", "-") or "-")
|
||||
started = info.get("started")
|
||||
|
||||
if mod_status == "running":
|
||||
status_text = Text("running", style="bold green")
|
||||
if started:
|
||||
uptime_sec = time.time() - started
|
||||
uptime_str = _format_uptime(uptime_sec)
|
||||
else:
|
||||
uptime_str = "-"
|
||||
elif mod_status == "stopped":
|
||||
status_text = Text("stopped", style="dim")
|
||||
uptime_str = "-"
|
||||
elif mod_status == "error":
|
||||
status_text = Text("error", style="bold red")
|
||||
uptime_str = "-"
|
||||
else:
|
||||
status_text = Text(mod_status, style="yellow")
|
||||
uptime_str = "-"
|
||||
|
||||
table.add_row(name, status_text, pid, uptime_str)
|
||||
|
||||
if not all_status:
|
||||
table.add_row("[dim]No modules registered[/dim]", "", "", "")
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("module_name")
|
||||
def activate(module_name):
|
||||
"""Enable and start a specific module."""
|
||||
try:
|
||||
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Config error:[/red] {exc}")
|
||||
return
|
||||
|
||||
discovered = discover_stealth_modules()
|
||||
if module_name not in discovered:
|
||||
console.print(f"[red]Module '{module_name}' not found.[/red]")
|
||||
console.print(f"Available: {', '.join(sorted(discovered.keys()))}")
|
||||
return
|
||||
|
||||
bus = EventBus()
|
||||
bus.start()
|
||||
state = StateManager()
|
||||
state.start()
|
||||
engine = Engine(bus=bus, state=state, config=config)
|
||||
|
||||
cls = discovered[module_name]
|
||||
mod_config = get_module_config(module_name, config)
|
||||
engine.register(cls, mod_config)
|
||||
|
||||
success = engine.start(module_name)
|
||||
if success:
|
||||
console.print(f"[green]Module '{module_name}' activated.[/green]")
|
||||
else:
|
||||
console.print(f"[red]Failed to activate '{module_name}'.[/red]")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("module_name")
|
||||
def deactivate(module_name):
|
||||
"""Stop and disable a specific module."""
|
||||
state = StateManager()
|
||||
mod_status = state.get_module_status(module_name)
|
||||
|
||||
if mod_status.get("status") == "unknown":
|
||||
console.print(f"[yellow]Module '{module_name}' is not registered.[/yellow]")
|
||||
return
|
||||
|
||||
# Send SIGTERM to the module's PID if running
|
||||
pid = mod_status.get("pid")
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
console.print(f"[green]Module '{module_name}' (PID {pid}) stopped.[/green]")
|
||||
except (ProcessLookupError, PermissionError) as exc:
|
||||
console.print(f"[yellow]Could not stop PID {pid}: {exc}[/yellow]")
|
||||
|
||||
state.set_module_status(module_name, "stopped")
|
||||
console.print(f"[green]Module '{module_name}' deactivated.[/green]")
|
||||
|
||||
|
||||
@cli.command("kill")
|
||||
def kill_switch():
|
||||
"""Activate kill switch — full wipe and shutdown."""
|
||||
console.print(Panel(
|
||||
"[bold red]KILL SWITCH[/bold red]\n\n"
|
||||
"This will:\n"
|
||||
" 1. Stop all modules and subprocesses\n"
|
||||
" 2. Send corrective ARP packets\n"
|
||||
" 3. Destroy LUKS encryption header\n"
|
||||
" 4. Shred all sensitive files\n"
|
||||
" 5. Zero-fill data partition\n"
|
||||
" 6. Clear RAM and reboot\n\n"
|
||||
"[bold]THIS ACTION IS IRREVERSIBLE.[/bold]",
|
||||
title="WARNING",
|
||||
border_style="red",
|
||||
))
|
||||
|
||||
confirm = Prompt.ask("Type WIPE to confirm", default="")
|
||||
if confirm != "WIPE":
|
||||
console.print("[yellow]Kill switch aborted.[/yellow]")
|
||||
return
|
||||
|
||||
try:
|
||||
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
bus = EventBus()
|
||||
ks = KillSwitch(bus=bus, config=config)
|
||||
console.print("[red]Executing kill switch...[/red]")
|
||||
ks.execute(reason="manual_cli")
|
||||
|
||||
|
||||
@cli.command("config")
|
||||
def show_config():
|
||||
"""Pretty-print current configuration."""
|
||||
try:
|
||||
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Config error:[/red] {exc}")
|
||||
return
|
||||
|
||||
from rich.syntax import Syntax
|
||||
import yaml
|
||||
yaml_str = yaml.dump(config, default_flow_style=False, sort_keys=False)
|
||||
syntax = Syntax(yaml_str, "yaml", theme="monokai", line_numbers=False)
|
||||
console.print(Panel(syntax, title="BigBrother Configuration", border_style="cyan"))
|
||||
|
||||
|
||||
@cli.command()
|
||||
def selftest():
|
||||
"""Run health checks: imports, tier detection, permissions, config, storage, binaries."""
|
||||
console.print("[bold]Running self-test...[/bold]\n")
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
# 1. Core imports
|
||||
tests = [
|
||||
("Core: EventBus", lambda: __import__("core.bus")),
|
||||
("Core: StateManager", lambda: __import__("core.state")),
|
||||
("Core: Engine", lambda: __import__("core.engine")),
|
||||
("Core: ToolManager", lambda: __import__("core.tool_manager")),
|
||||
("Core: KillSwitch", lambda: __import__("core.kill_switch")),
|
||||
("Core: Scheduler", lambda: __import__("core.scheduler")),
|
||||
("Core: ResourceMonitor", lambda: __import__("core.resource_monitor")),
|
||||
("Utils: crypto", lambda: __import__("utils.crypto")),
|
||||
("Utils: config_loader", lambda: __import__("utils.config_loader")),
|
||||
("Utils: resource", lambda: __import__("utils.resource")),
|
||||
]
|
||||
|
||||
for label, test_fn in tests:
|
||||
try:
|
||||
test_fn()
|
||||
console.print(f" [green]PASS[/green] {label}")
|
||||
passed += 1
|
||||
except Exception as exc:
|
||||
console.print(f" [red]FAIL[/red] {label}: {exc}")
|
||||
failed += 1
|
||||
|
||||
# 2. Hardware tier
|
||||
try:
|
||||
tier = get_hardware_tier()
|
||||
console.print(f" [green]PASS[/green] Hardware tier: {tier}")
|
||||
passed += 1
|
||||
except Exception as exc:
|
||||
console.print(f" [red]FAIL[/red] Hardware tier: {exc}")
|
||||
failed += 1
|
||||
|
||||
# 3. Config loading
|
||||
try:
|
||||
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||
console.print(f" [green]PASS[/green] Config loaded ({len(config)} top-level keys)")
|
||||
passed += 1
|
||||
except Exception as exc:
|
||||
console.print(f" [red]FAIL[/red] Config: {exc}")
|
||||
failed += 1
|
||||
|
||||
# 4. Permissions
|
||||
install_path = "/opt/.cache/bb"
|
||||
if os.path.isdir(install_path):
|
||||
console.print(f" [green]PASS[/green] Install path exists: {install_path}")
|
||||
passed += 1
|
||||
else:
|
||||
console.print(f" [yellow]SKIP[/yellow] Install path not found: {install_path}")
|
||||
|
||||
# 5. Storage
|
||||
try:
|
||||
total, free, used_pct = get_disk_usage("/")
|
||||
console.print(f" [green]PASS[/green] Disk: {total:.1f}GB total, {free:.1f}GB free ({used_pct:.1f}% used)")
|
||||
passed += 1
|
||||
except Exception as exc:
|
||||
console.print(f" [red]FAIL[/red] Disk check: {exc}")
|
||||
failed += 1
|
||||
|
||||
# 6. External binaries
|
||||
binaries = ["tcpdump", "ip", "bettercap", "cryptsetup"]
|
||||
for binary in binaries:
|
||||
from shutil import which
|
||||
if which(binary):
|
||||
console.print(f" [green]PASS[/green] Binary: {binary}")
|
||||
passed += 1
|
||||
else:
|
||||
console.print(f" [yellow]SKIP[/yellow] Binary not found: {binary}")
|
||||
|
||||
# 7. Stealth modules
|
||||
discovered = discover_stealth_modules()
|
||||
console.print(f" [green]PASS[/green] Stealth modules discovered: {len(discovered)}")
|
||||
passed += 1
|
||||
|
||||
console.print(f"\n[bold]Results: {passed} passed, {failed} failed[/bold]")
|
||||
|
||||
|
||||
@cli.command("modules")
|
||||
def list_modules():
|
||||
"""List all available modules with type, enabled status, and dependencies."""
|
||||
try:
|
||||
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
discovered = discover_stealth_modules()
|
||||
enabled_list = config.get("modules", {}).get("enabled")
|
||||
|
||||
table = Table(title="Available Modules", show_lines=True)
|
||||
table.add_column("Module", style="cyan", min_width=22)
|
||||
table.add_column("Type", min_width=12)
|
||||
table.add_column("Priority", justify="right", min_width=8)
|
||||
table.add_column("Root", justify="center", min_width=5)
|
||||
table.add_column("Enabled", justify="center", min_width=7)
|
||||
table.add_column("Dependencies", min_width=15)
|
||||
|
||||
for name in sorted(discovered.keys()):
|
||||
cls = discovered[name]
|
||||
mod_type = getattr(cls, "module_type", "unknown")
|
||||
priority = str(getattr(cls, "priority", 0))
|
||||
requires_root = "Yes" if getattr(cls, "requires_root", False) else "No"
|
||||
deps = ", ".join(getattr(cls, "dependencies", [])) or "-"
|
||||
|
||||
if enabled_list is None:
|
||||
enabled = "[green]Yes[/green]"
|
||||
elif name in enabled_list:
|
||||
enabled = "[green]Yes[/green]"
|
||||
else:
|
||||
enabled = "[dim]No[/dim]"
|
||||
|
||||
table.add_row(name, mod_type, priority, requires_root, enabled, deps)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive menu
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def interactive_menu():
|
||||
"""Rich-based numbered menu with hardware info and phase display."""
|
||||
try:
|
||||
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
hw = detect_hardware()
|
||||
tier = hw.tier
|
||||
phase = config.get("engagement_phase", {}).get("mode", "passive_only")
|
||||
temp = get_cpu_temperature()
|
||||
temp_str = f"{temp:.1f}C" if temp is not None else "N/A"
|
||||
|
||||
header = (
|
||||
f"[bold cyan]BigBrother[/bold cyan] v1.0\n"
|
||||
f"Hardware: [yellow]{hw.model or 'Unknown'}[/yellow] ({tier})\n"
|
||||
f"CPU: {hw.cpu_cores} cores ({hw.architecture}) | RAM: {hw.total_ram_mb}MB | Temp: {temp_str}\n"
|
||||
f"Phase: [{'green' if phase == 'passive_only' else 'red'}]{phase}[/{'green' if phase == 'passive_only' else 'red'}]"
|
||||
)
|
||||
|
||||
while True:
|
||||
console.print()
|
||||
console.print(Panel(header, title="Dashboard", border_style="cyan"))
|
||||
console.print()
|
||||
console.print(" [bold]1.[/bold] Start all modules")
|
||||
console.print(" [bold]2.[/bold] Stop all modules")
|
||||
console.print(" [bold]3.[/bold] Module status")
|
||||
console.print(" [bold]4.[/bold] List modules")
|
||||
console.print(" [bold]5.[/bold] Credentials [dim](Available in Phase 2)[/dim]")
|
||||
console.print(" [bold]6.[/bold] Intelligence [dim](Available in Phase 2)[/dim]")
|
||||
console.print(" [bold]7.[/bold] Topology [dim](Available in Phase 2)[/dim]")
|
||||
console.print(" [bold]8.[/bold] Timeline [dim](Available in Phase 2)[/dim]")
|
||||
console.print(" [bold]9.[/bold] Self-test")
|
||||
console.print(" [bold]C.[/bold] Configuration")
|
||||
console.print(" [bold]K.[/bold] Kill switch")
|
||||
console.print(" [bold]Q.[/bold] Quit")
|
||||
console.print()
|
||||
|
||||
choice = Prompt.ask("Select", default="Q")
|
||||
choice = choice.strip().upper()
|
||||
|
||||
if choice == "1":
|
||||
ctx = click.Context(start)
|
||||
ctx.invoke(start, daemon=False, passive_only=False)
|
||||
elif choice == "2":
|
||||
ctx = click.Context(stop)
|
||||
ctx.invoke(stop)
|
||||
elif choice == "3":
|
||||
ctx = click.Context(status)
|
||||
ctx.invoke(status)
|
||||
elif choice == "4":
|
||||
ctx = click.Context(list_modules)
|
||||
ctx.invoke(list_modules)
|
||||
elif choice in ("5", "6", "7", "8"):
|
||||
labels = {"5": "Credentials", "6": "Intelligence", "7": "Topology", "8": "Timeline"}
|
||||
console.print(f"\n[yellow]{labels[choice]} viewer is available in Phase 2.[/yellow]")
|
||||
elif choice == "9":
|
||||
ctx = click.Context(selftest)
|
||||
ctx.invoke(selftest)
|
||||
elif choice == "C":
|
||||
ctx = click.Context(show_config)
|
||||
ctx.invoke(show_config)
|
||||
elif choice == "K":
|
||||
ctx = click.Context(kill_switch)
|
||||
ctx.invoke(kill_switch)
|
||||
elif choice == "Q":
|
||||
console.print("[dim]Goodbye.[/dim]")
|
||||
break
|
||||
else:
|
||||
console.print("[yellow]Invalid choice.[/yellow]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_uptime(seconds: float) -> str:
|
||||
"""Format seconds into a human-readable uptime string."""
|
||||
if seconds < 60:
|
||||
return f"{seconds:.0f}s"
|
||||
elif seconds < 3600:
|
||||
return f"{seconds / 60:.0f}m {seconds % 60:.0f}s"
|
||||
elif seconds < 86400:
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
return f"{h}h {m}m"
|
||||
else:
|
||||
d = int(seconds // 86400)
|
||||
h = int((seconds % 86400) // 3600)
|
||||
return f"{d}d {h}h"
|
||||
|
||||
|
||||
def _daemonize():
|
||||
"""Fork into background, redirect stdio, write PID file."""
|
||||
try:
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent exits
|
||||
sys.exit(0)
|
||||
except OSError as exc:
|
||||
console.print(f"[red]Fork failed:[/red] {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# Decouple from parent
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# Second fork
|
||||
try:
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
except OSError as exc:
|
||||
sys.exit(1)
|
||||
|
||||
# Redirect stdio
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
devnull = open(os.devnull, "r+b")
|
||||
os.dup2(devnull.fileno(), sys.stdin.fileno())
|
||||
os.dup2(devnull.fileno(), sys.stdout.fileno())
|
||||
os.dup2(devnull.fileno(), sys.stderr.fileno())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
Reference in New Issue
Block a user