c3po: full server rewrite with modular routes and honeypot dashboard

Replace monolithic CLI and web server with route-based Flask API.
New routes: api_commands, api_build, api_can, api_monitor, api_ota,
api_tunnel. Add honeypot security dashboard with real-time SSE,
MITRE ATT&CK mapping, kill chain analysis.

New TUI with commander/help modules. Add session management,
tunnel proxy core, CAN bus data store. Docker support.
This commit is contained in:
Eun0us 2026-02-28 20:12:27 +01:00
parent c193e30671
commit 79c2a4d4bf
80 changed files with 11938 additions and 2095 deletions

View File

@ -1,5 +1,12 @@
# ESPILON C2 Configuration
# Copy this file to .env and adjust values
# ============================================================
# C3PO - ESPILON C2 Server Configuration
# ============================================================
# Copy this file to .env and change the values below:
# cp .env.example .env
#
# IMPORTANT: Change ALL default passwords and tokens before
# any deployment outside a local lab.
# ============================================================
# ===================
# C2 Server
@ -8,34 +15,51 @@ C2_HOST=0.0.0.0
C2_PORT=2626
# ===================
# Camera Server
# Camera UDP Receiver
# ===================
# UDP receiver for camera frames
UDP_HOST=0.0.0.0
UDP_PORT=5000
UDP_BUFFER_SIZE=65535
# Web server for viewing streams
# ===================
# Web Dashboard (Flask)
# ===================
WEB_HOST=0.0.0.0
WEB_PORT=8000
# ===================
# Security
# Security — CHANGE THESE VALUES
# ===================
# Token for authenticating camera frames (must match ESP firmware)
# Token for authenticating camera frames (must match ESP firmware CONFIG_CAMERA_UDP_TOKEN)
CAMERA_SECRET_TOKEN=Sup3rS3cretT0k3n
# Flask session secret (change in production!)
# Flask session secret (CHANGE in production!)
FLASK_SECRET_KEY=change_this_for_prod
# Web interface credentials
# Web interface credentials (CHANGE in production!)
WEB_USERNAME=admin
WEB_PASSWORD=admin
# MLAT API bearer token
MULTILAT_AUTH_TOKEN=multilat_secret_token
# ===================
# CORS — Allowed origins (comma-separated)
# ===================
# Leave empty to allow all origins (dev only!)
CORS_ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000
# ===================
# Rate Limiting
# ===================
# Global rate limit for all endpoints (per IP)
RATE_LIMIT_DEFAULT=200 per minute
# Login endpoint rate limit (brute-force protection)
RATE_LIMIT_LOGIN=5 per minute
# ===================
# Storage
# ===================
# Directory for camera frame storage (relative to c2 root)
IMAGE_DIR=static/streams
# ===================
@ -47,7 +71,15 @@ VIDEO_FPS=10
VIDEO_CODEC=MJPG
# ===================
# Honeypot Dashboard (optional plugin)
# Tunnel / SOCKS5 Proxy
# ===================
# SOCKS5 listen address (local proxy for proxychains/tools)
TUNNEL_SOCKS_HOST=127.0.0.1
TUNNEL_SOCKS_PORT=1080
# Port where ESP32 bots connect back for tunnel framing
TUNNEL_LISTEN_PORT=2627
# ===================
# Honeypot Dashboard (optional)
# ===================
# Path to espilon-honey-pot/tools/ directory
# HP_DASHBOARD_PATH=/path/to/espilon-honey-pot/tools

26
tools/C3PO/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM python:3.11-slim
WORKDIR /app
# System dependencies for OpenCV
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create runtime directories
RUN mkdir -p static/streams static/recordings data firmware
ENV PYTHONPATH=/app
# Default ports: C2=2626, Web=8000, UDP=5000
EXPOSE 2626 8000 5000/udp
# Generate .env from example if not mounted
ENTRYPOINT ["python", "c3po.py"]

View File

@ -1,19 +1,23 @@
#!/usr/bin/env python3
import asyncio
import logging
import socket
import threading
from concurrent.futures import ThreadPoolExecutor
import re
import os
import sys
import time
import argparse
from core.registry import DeviceRegistry
from core.keystore import KeyStore
from core.transport import Transport
from core.session import Session
from log.manager import LogManager
from cli.cli import CLI
from commands.registry import CommandRegistry
from commands.reboot import RebootCommand
from core.groups import GroupRegistry
from tui.commander import Commander
from utils.constant import HOST, PORT
from utils.display import Display
@ -23,16 +27,42 @@ FRAME_RE = re.compile(br'^[A-Za-z0-9_-]+:[A-Za-z0-9+/=]+$')
RX_BUF_SIZE = 4096
MAX_BUFFER_SIZE = 1024 * 1024 # 1MB max buffer to prevent memory exhaustion
DEVICE_TIMEOUT_SECONDS = 300 # Devices are considered inactive after 5 minutes without a heartbeat
PROBE_THRESHOLD_SECONDS = 240 # Probe devices after 4 minutes of inactivity
HEARTBEAT_CHECK_INTERVAL = 10 # Check every 10 seconds
# ============================================================
# HELLO handshake (server identity verification)
# ============================================================
def _handle_hello(sock: socket.socket, device_id: str, transport: Transport):
"""Respond to HELLO handshake with an AEAD-encrypted challenge.
The device verifies the server possesses the shared key by
decrypting and checking the AEAD tag. No new crypto primitives
are needed this reuses the existing ChaCha20-Poly1305 context.
"""
crypto = transport._get_crypto(device_id)
if crypto is None:
Display.error(f"HELLO from unknown device '{device_id}' no key in keystore")
return
challenge = os.urandom(32)
encrypted = crypto.encrypt(challenge)
b64_challenge = crypto.b64_encode(encrypted)
sock.sendall(b64_challenge + b"\n")
Display.system_message(f"HELLO handshake: challenge sent to {device_id}")
# ============================================================
# Client handler
# ============================================================
def client_thread(sock: socket.socket, addr, transport: Transport, registry: DeviceRegistry):
Display.system_message(f"Client connected from {addr}")
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
buffer = b""
device_id = None # To track which device disconnected
first_frame = True # For HELLO handshake detection
try:
while True:
@ -55,6 +85,18 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev
if not line:
continue
# HELLO handshake: server identity verification
if first_frame and line.startswith(b"HELLO:"):
first_frame = False
hello_device_id = line[6:].decode(errors="ignore").strip()
if hello_device_id:
_handle_hello(sock, hello_device_id, transport)
else:
Display.error(f"HELLO with empty device_id from {addr}")
continue
first_frame = False
# Validate frame format: device_id:base64
if not FRAME_RE.match(line):
Display.system_message(f"Ignoring invalid frame from {addr}")
@ -64,8 +106,6 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev
# Pass registry to handle_incoming to update device status
transport.handle_incoming(sock, addr, line)
# After successful handling, try to get device_id
# This is a simplification; a more robust solution might pass device_id from transport
# For now, we assume the first message will register the device
if not device_id and registry.get_device_by_sock(sock):
device_id = registry.get_device_by_sock(sock).id
except Exception as e:
@ -90,51 +130,34 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev
# Main server
# ============================================================
def main():
# Parse arguments
parser = argparse.ArgumentParser(description="C3PO - ESPILON C2 Framework")
parser.add_argument("--tui", action="store_true", help="Launch with TUI interface")
args = parser.parse_args()
header = """
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\\
$$ __$$\\ $$ ___$$\\ $$ __$$\\ $$ __$$\\
$$ / \\__|\_/ $$ |$$ | $$ |$$ / $$ |
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
$$ | \\___$$\\ $$ ____/ $$ | $$ |
$$ | $$\\ $$\\ $$ |$$ | $$ | $$ |
\\$$$$$$ |\\$$$$$$ |$$ | $$$$$$ |
\\______/ \\______/ \\__| \\______/
ESPILON C2 Framework - Command and Control Server
"""
if not args.tui:
Display.system_message(header)
Display.system_message("Initializing ESPILON C2 core...")
# ============================
# Security check
# ============================
from streams.config import validate_config
if not validate_config():
print("[C3PO] FATAL: Insecure default values detected.")
print("[C3PO] Copy .env.example to .env and change the defaults.")
print("[C3PO] To bypass (dev only): set ESPILON_ALLOW_DEFAULTS=1")
if not os.environ.get("ESPILON_ALLOW_DEFAULTS"):
sys.exit(1)
# ============================
# Core components
# ============================
registry = DeviceRegistry()
logger = LogManager()
keystore = KeyStore("keys.json")
keystore = KeyStore(os.path.join(os.path.dirname(os.path.abspath(__file__)), "keys.json"))
if not args.tui:
Display.system_message(f"Loaded {len(keystore)} device key(s) from {keystore.path}")
# Initialize CLI first, then pass it to Transport
commands = CommandRegistry()
commands.register(RebootCommand())
groups = GroupRegistry()
# Placeholder for CLI, will be properly initialized after Transport
cli_instance = None
transport = Transport(registry, logger, keystore, cli_instance)
# Create Transport, then Session, then wire them together
transport = Transport(registry, logger, keystore)
session = Session(registry, commands, groups, transport)
transport.set_session(session)
cli_instance = CLI(registry, commands, groups, transport)
transport.set_cli(cli_instance) # Set the actual CLI instance in transport
cli = cli_instance # Assign the initialized CLI to 'cli'
commander = Commander(session)
# ============================
# TCP server
@ -145,38 +168,46 @@ $$ | $$\\ $$\\ $$ |$$ | $$ | $$ |
try:
server.bind((HOST, PORT))
except OSError as e:
Display.error(f"Failed to bind server to {HOST}:{PORT}: {e}")
print(f"Failed to bind server to {HOST}:{PORT}: {e}")
sys.exit(1)
server.listen()
if not args.tui:
Display.system_message(f"Server listening on {HOST}:{PORT}")
# Function to periodically check device status
# Function to periodically check device status and probe inactive devices
def device_status_checker():
probed = set() # Track devices already probed this cycle
while True:
now = time.time()
for device in registry.all():
if now - device.last_seen > DEVICE_TIMEOUT_SECONDS:
elapsed = now - device.last_seen
if elapsed > DEVICE_TIMEOUT_SECONDS:
if device.status != "Inactive":
device.status = "Inactive"
Display.device_event(device.id, "Status changed to Inactive (timeout)")
elif device.status == "Inactive" and now - device.last_seen <= DEVICE_TIMEOUT_SECONDS:
device.status = "Connected"
Display.device_event(device.id, "Status changed to Connected (heartbeat received)")
probed.discard(device.id)
elif elapsed > PROBE_THRESHOLD_SECONDS and device.id not in probed:
try:
transport.probe_device(device)
probed.add(device.id)
Display.device_event(device.id, "Probing (no activity for 4min)")
except Exception:
pass
elif elapsed <= PROBE_THRESHOLD_SECONDS:
probed.discard(device.id)
if device.status == "Inactive":
device.status = "Connected"
Display.device_event(device.id, "Status changed to Connected")
time.sleep(HEARTBEAT_CHECK_INTERVAL)
# Function to accept client connections
# Function to accept client connections (bounded thread pool)
client_pool = ThreadPoolExecutor(max_workers=50, thread_name_prefix="c2-client")
def accept_loop():
while True:
try:
sock, addr = server.accept()
threading.Thread(
target=client_thread,
args=(sock, addr, transport, registry),
daemon=True
).start()
sock.settimeout(300) # 5 min idle timeout per connection
client_pool.submit(client_thread, sock, addr, transport, registry)
except OSError:
break
except Exception as e:
@ -188,26 +219,115 @@ $$ | $$\\ $$\\ $$ |$$ | $$ | $$ |
threading.Thread(target=accept_loop, daemon=True).start()
# ============================
# TUI or CLI mode
# Tunnel / SOCKS5 proxy server
# ============================
if args.tui:
from core.tunnel import TunnelServer
from streams.config import TUNNEL_SOCKS_HOST, TUNNEL_SOCKS_PORT, TUNNEL_LISTEN_PORT
tunnel_server = TunnelServer(
keystore=keystore,
socks_host=TUNNEL_SOCKS_HOST,
socks_port=TUNNEL_SOCKS_PORT,
tunnel_port=TUNNEL_LISTEN_PORT,
)
session.tunnel_server = tunnel_server
def run_tunnel():
logging.basicConfig(level=logging.INFO,
format="[TUNNEL] %(levelname)s %(message)s")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(tunnel_server.start())
loop.run_forever()
threading.Thread(target=run_tunnel, daemon=True, name="tunnel-server").start()
print(f"[C3PO] SOCKS5 proxy on {TUNNEL_SOCKS_HOST}:{TUNNEL_SOCKS_PORT}, "
f"tunnel listener on 0.0.0.0:{TUNNEL_LISTEN_PORT}")
# ============================
# Mode selection: TUI or headless
# ============================
headless = "--headless" in sys.argv
if headless:
# Headless mode: auto-start web server, no TUI
from streams.config import (
WEB_HOST, WEB_PORT, IMAGE_DIR, MULTILAT_AUTH_TOKEN,
DEFAULT_USERNAME, DEFAULT_PASSWORD, FLASK_SECRET_KEY
)
from web.server import UnifiedWebServer
# Initialize honeypot dashboard components
try:
from hp_dashboard import HpStore, HpCommander, HpAlertEngine, HpGeoLookup
_c3po_root = os.path.dirname(os.path.abspath(__file__))
_data_dir = os.path.join(_c3po_root, "data")
os.makedirs(_data_dir, exist_ok=True)
session.hp_geo = HpGeoLookup(
db_path=os.path.join(_data_dir, "honeypot_geo.db"))
session.hp_store = HpStore(
db_path=os.path.join(_data_dir, "honeypot_events.db"),
geo_lookup=session.hp_geo)
session.hp_alerts = HpAlertEngine(
db_path=os.path.join(_data_dir, "honeypot_alerts.db"))
session.hp_alerts.set_store(session.hp_store)
session.hp_store.set_alert_engine(session.hp_alerts)
session.hp_commander = HpCommander(
get_transport=lambda: transport,
get_registry=lambda: registry,
)
transport.hp_store = session.hp_store
transport.hp_commander = session.hp_commander
print("[C3PO] Honeypot dashboard enabled")
except ImportError:
print("[C3PO] Honeypot dashboard not available (hp_dashboard not found)")
session.web_server = UnifiedWebServer(
host=WEB_HOST,
port=WEB_PORT,
image_dir=IMAGE_DIR,
username=DEFAULT_USERNAME,
password=DEFAULT_PASSWORD,
secret_key=FLASK_SECRET_KEY,
device_registry=registry,
transport=transport,
session=session,
multilat_token=MULTILAT_AUTH_TOKEN,
hp_store=getattr(session, 'hp_store', None),
hp_commander=getattr(session, 'hp_commander', None),
hp_alerts=getattr(session, 'hp_alerts', None),
hp_geo=getattr(session, 'hp_geo', None),
)
if session.web_server.start():
print(f"[C3PO] Web server started at {session.web_server.get_url()}")
else:
print("[C3PO] Web server failed to start")
sys.exit(1)
print(f"[C3PO] C2 listening on {HOST}:{PORT}")
print(f"[C3PO] Running in headless mode (Ctrl+C to stop)")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n[C3PO] Shutting down...")
session.web_server.stop()
else:
# TUI mode
try:
from tui.app import C3POApp
Display.enable_tui_mode()
app = C3POApp(registry=registry, cli=cli)
app = C3POApp(registry=registry, session=session, commander=commander)
app.run()
except ImportError as e:
Display.error(f"TUI not available: {e}")
Display.error("Install textual: pip install textual")
print(f"TUI not available: {e}")
print("Install textual: pip install textual")
sys.exit(1)
except KeyboardInterrupt:
pass
else:
# Classic CLI mode
try:
cli.loop()
except KeyboardInterrupt:
Display.system_message("Shutdown requested. Exiting...")
server.close()

View File

View File

@ -1,11 +1,4 @@
import os
import sys
# Add tools/c2/ to sys.path to import c2_pb2
sys.path.insert(0, os.path.abspath('./tools/c2'))
from commands.base import CommandHandler
from proto import c2_pb2
class RebootCommand(CommandHandler):

View File

View File

@ -0,0 +1,113 @@
"""
CAN frame storage for C3PO server.
Stores CAN frames received from agents via AGENT_DATA messages.
Frame format: "CAN|<timestamp_ms>|<id_hex>|<dlc>|<data_hex>"
"""
import threading
import time
import collections
from typing import List, Optional, Dict
class CanFrame:
"""Parsed CAN frame from agent."""
__slots__ = ("device_id", "timestamp_ms", "can_id", "dlc", "data_hex", "received_at")
def __init__(self, device_id: str, timestamp_ms: int, can_id: int,
dlc: int, data_hex: str):
self.device_id = device_id
self.timestamp_ms = timestamp_ms
self.can_id = can_id
self.dlc = dlc
self.data_hex = data_hex
self.received_at = time.time()
def to_dict(self) -> dict:
return {
"device_id": self.device_id,
"timestamp_ms": self.timestamp_ms,
"can_id": f"0x{self.can_id:03X}",
"dlc": self.dlc,
"data": self.data_hex,
"received_at": self.received_at,
}
class CanStore:
"""Thread-safe ring buffer for CAN frames from all devices."""
def __init__(self, max_frames: int = 10000):
self.frames: collections.deque = collections.deque(maxlen=max_frames)
self.lock = threading.Lock()
self.total_count = 0
def store_frame(self, device_id: str, raw_line: str):
"""Parse 'CAN|ts|id|dlc|data' and store."""
parts = raw_line.split("|")
if len(parts) < 5 or parts[0] != "CAN":
return
try:
ts_ms = int(parts[1])
can_id = int(parts[2], 16)
dlc = int(parts[3])
data_hex = parts[4]
except (ValueError, IndexError):
return
frame = CanFrame(device_id, ts_ms, can_id, dlc, data_hex)
with self.lock:
self.frames.append(frame)
self.total_count += 1
def get_frames(self, device_id: Optional[str] = None,
can_id: Optional[int] = None,
limit: int = 100, offset: int = 0) -> List[Dict]:
"""Query frames with optional filters."""
with self.lock:
filtered = list(self.frames)
if device_id:
filtered = [f for f in filtered if f.device_id == device_id]
if can_id is not None:
filtered = [f for f in filtered if f.can_id == can_id]
# Most recent first
filtered.reverse()
# Paginate
page = filtered[offset:offset + limit]
return [f.to_dict() for f in page]
def get_stats(self, device_id: Optional[str] = None) -> Dict:
"""Get frame count stats."""
with self.lock:
frames = list(self.frames)
if device_id:
frames = [f for f in frames if f.device_id == device_id]
unique_ids = set(f.can_id for f in frames)
return {
"total_stored": len(frames),
"total_received": self.total_count,
"unique_can_ids": len(unique_ids),
"can_ids": sorted([f"0x{cid:03X}" for cid in unique_ids]),
}
def export_csv(self, device_id: Optional[str] = None) -> str:
"""Export frames as CSV string."""
with self.lock:
frames = list(self.frames)
if device_id:
frames = [f for f in frames if f.device_id == device_id]
lines = ["device_id,timestamp_ms,can_id,dlc,data"]
for f in frames:
lines.append(f"{f.device_id},{f.timestamp_ms},0x{f.can_id:03X},{f.dlc},{f.data_hex}")
return "\n".join(lines)

View File

@ -47,9 +47,16 @@ class KeyStore:
json.dump(data, f, indent=2)
def get(self, device_id: str) -> Optional[bytes]:
"""Return 32-byte master key for a device, or None if unknown."""
"""Return 32-byte master key for a device, or None if unknown.
Auto-reloads from disk if the key is not found (hot-reload support)."""
with self._lock:
return self._keys.get(device_id)
key = self._keys.get(device_id)
if key is None:
# Key not in memory — try reloading from disk (deploy.py may have added it)
self.load()
with self._lock:
key = self._keys.get(device_id)
return key
def add(self, device_id: str, master_key: bytes) -> None:
"""Register a device's master key and persist."""

View File

@ -0,0 +1,82 @@
import time
import threading
from typing import Optional
from utils.display import Display
from core.transport import Transport
from core.registry import DeviceRegistry
from core.groups import GroupRegistry
from commands.registry import CommandRegistry
from web.mlat import MlatEngine
from core.can_store import CanStore
COMMAND_TIMEOUT_S = 120 # 2 minutes
class Session:
"""Central runtime state for C3PO. No UI logic."""
def __init__(self, registry: DeviceRegistry, commands: CommandRegistry,
groups: GroupRegistry, transport: Transport):
self.registry = registry
self.commands = commands
self.groups = groups
self.transport = transport
# Active command tracking
self.active_commands: dict = {}
# Service instances
self.web_server = None
self.udp_receiver = None
self.mlat_engine = MlatEngine()
# CAN bus frame storage
self.can_store = CanStore()
# Honeypot dashboard components (created on web start)
self.hp_store = None
self.hp_commander = None
self.hp_alerts = None
self.hp_geo = None
# Stale command cleanup
self._cleanup_timer = threading.Thread(
target=self._cleanup_stale_commands, daemon=True)
self._cleanup_timer.start()
def _cleanup_stale_commands(self):
"""Periodically remove commands that never received an EOF."""
while True:
time.sleep(30)
now = time.time()
stale = [
rid for rid, info in list(self.active_commands.items())
if now - info.get("start_time", now) > COMMAND_TIMEOUT_S
]
for rid in stale:
info = self.active_commands.pop(rid, None)
if info:
Display.device_event(
info.get("device_id", "?"),
f"Command '{info.get('command_name', '?')}' timed out ({rid})"
)
# --- Callback for Transport ---
def handle_command_response(self, request_id: str, device_id: str,
payload: str, eof: bool):
"""Called by Transport when a command response arrives."""
if request_id in self.active_commands:
command_info = self.active_commands[request_id]
command_info["output"].append(payload)
if eof:
command_info["status"] = "completed"
Display.command_response(
request_id, device_id,
f"Command completed in {Display.format_duration(time.time() - command_info['start_time'])}"
)
del self.active_commands[request_id]
else:
Display.device_event(device_id,
f"Received response for unknown command {request_id}: {payload}")

View File

@ -10,16 +10,16 @@ from proto.c2_pb2 import Command, AgentMessage, AgentMsgType
# Forward declaration for type hinting to avoid circular import
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cli.cli import CLI
from core.session import Session
class Transport:
def __init__(self, registry: DeviceRegistry, logger: LogManager,
keystore: KeyStore, cli_instance: 'CLI' = None):
keystore: KeyStore, session: 'Session' = None):
self.registry = registry
self.logger = logger
self.keystore = keystore
self.cli = cli_instance
self.session = session
self.command_responses = {}
self.hp_store = None
self.hp_commander = None
@ -27,8 +27,8 @@ class Transport:
# Cache of CryptoContext per device_id (HKDF derivation is expensive)
self._crypto_cache: dict[str, CryptoContext] = {}
def set_cli(self, cli_instance: 'CLI'):
self.cli = cli_instance
def set_session(self, session: 'Session'):
self.session = session
def _get_crypto(self, device_id: str) -> CryptoContext | None:
"""Get or create a CryptoContext for the given device."""
@ -129,6 +129,14 @@ class Transport:
if is_new_device:
self._auto_query_system_info(device)
def probe_device(self, device: Device):
"""Send a system_info probe to check if the device is alive."""
cmd = Command()
cmd.device_id = device.id
cmd.command_name = "system_info"
cmd.request_id = f"probe-{device.id}"
self.send_command(device.sock, cmd, device.id)
def _auto_query_system_info(self, device: Device):
"""Send system_info command automatically when device connects."""
try:
@ -140,7 +148,7 @@ class Transport:
except Exception as e:
Display.error(f"Auto system_info failed for {device.id}: {e}")
def _parse_system_info(self, device: Device, payload: str):
def _parse_system_info(self, device: Device, payload: str, silent: bool = False):
"""Parse system_info response and update device info."""
# Format: chip=esp32 cores=2 flash=external heap=4310096 uptime=7s modules=network,fakeap
try:
@ -152,18 +160,17 @@ class Transport:
elif key == "modules":
device.modules = value
# Notify TUI about device info update
Display.device_event(device.id, f"INFO: {payload}")
if not silent:
# Notify TUI about device info update
Display.device_event(device.id, f"INFO: {payload}")
# Send special message to update TUI title
from utils.display import Display as Disp
if Disp._tui_mode:
from tui.bridge import tui_bridge, TUIMessage, MessageType
tui_bridge.post_message(TUIMessage(
msg_type=MessageType.DEVICE_INFO_UPDATED,
device_id=device.id,
payload=device.modules
))
from tui.bridge import tui_bridge, TUIMessage, MessageType
tui_bridge.post_message(TUIMessage(
msg_type=MessageType.DEVICE_INFO_UPDATED,
device_id=device.id,
payload=device.modules
))
except Exception as e:
Display.error(f"Failed to parse system_info: {e}")
@ -178,46 +185,54 @@ class Transport:
except Exception:
payload_str = repr(msg.payload)
# --- Route request_id-bearing responses to the right handler ---
req_id = msg.request_id or ""
is_probe = req_id.startswith("probe-")
is_auto = req_id.startswith("auto-sysinfo-")
is_hp = req_id.startswith("hp-")
is_user_cmd = bool(req_id) and not is_probe and not is_auto and not is_hp
# --- Type-specific handling (display, parsing, etc.) ---
if msg.type == AgentMsgType.AGENT_CMD_RESULT:
# Check if this is auto system_info response
if msg.request_id and msg.request_id.startswith("auto-sysinfo-"):
if is_probe:
self._parse_system_info(device, payload_str, silent=True)
return
if is_auto:
self._parse_system_info(device, payload_str)
elif msg.request_id and msg.request_id.startswith("hp-") and self.hp_commander:
# Route honeypot dashboard command responses
self.hp_commander.handle_response(msg.request_id, device.id, payload_str, msg.eof)
elif msg.request_id and self.cli:
self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof)
else:
Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}")
elif msg.type == AgentMsgType.AGENT_INFO:
# Check for system_info response (format: chip=... modules=...)
if "chip=" in payload_str and "modules=" in payload_str:
self._parse_system_info(device, payload_str)
return
# Check for MLAT data (format: MLAT:x;y;rssi)
elif payload_str.startswith("MLAT:") and self.cli:
mlat_data = payload_str[5:] # Remove "MLAT:" prefix
if self.cli.mlat_engine.parse_mlat_message(device.id, mlat_data):
# Recalculate position if we have enough scanners
state = self.cli.mlat_engine.get_state()
elif payload_str.startswith("MLAT:") and self.session:
mlat_data = payload_str[5:]
if self.session.mlat_engine.parse_mlat_message(device.id, mlat_data):
state = self.session.mlat_engine.get_state()
if state["scanners_count"] >= 3:
self.cli.mlat_engine.calculate_position()
self.session.mlat_engine.calculate_position()
else:
Display.device_event(device.id, f"MLAT: Invalid data format: {mlat_data}")
else:
elif not is_user_cmd:
Display.device_event(device.id, f"INFO: {payload_str}")
elif msg.type == AgentMsgType.AGENT_ERROR:
Display.device_event(device.id, f"ERROR: {payload_str}")
if not is_user_cmd:
Display.device_event(device.id, f"ERROR: {payload_str}")
elif msg.type == AgentMsgType.AGENT_LOG:
Display.device_event(device.id, f"LOG: {payload_str}")
elif msg.type == AgentMsgType.AGENT_DATA:
# Route honeypot events to hp_store
if payload_str.startswith("HP|") and self.hp_store:
if payload_str.startswith("EVT|") and self.hp_store:
self.hp_store.parse_and_store(device.id, payload_str)
Display.device_event(device.id, f"DATA: {payload_str}")
if payload_str.startswith("CAN|") and self.session and hasattr(self.session, 'can_store') and self.session.can_store:
self.session.can_store.store_frame(device.id, payload_str)
if not is_user_cmd:
Display.device_event(device.id, f"DATA: {payload_str}")
else:
Display.device_event(device.id, f"UNKNOWN Message Type ({AgentMsgType.Name(msg.type)}): {payload_str}")
# --- Forward to command originator (session or hp_commander) ---
if is_hp and self.hp_commander:
self.hp_commander.handle_response(req_id, device.id, payload_str, msg.eof)
elif is_user_cmd and self.session:
self.session.handle_command_response(req_id, device.id, payload_str, msg.eof)
# ==================================================
# TX (C2 → ESP)
# ==================================================

684
tools/C3PO/core/tunnel.py Normal file
View File

@ -0,0 +1,684 @@
"""
TunnelServer SOCKS5 proxy via ESP32 channel multiplexing.
Architecture:
- SOCKS5 server (asyncio, port 1080) accepts operator tools (proxychains, curl, nmap)
- Tunnel listener (asyncio, port 2627) accepts ESP32 bot connections
- Binary framing protocol maps SOCKS clients to numbered channels
- ESP32 performs actual TCP connections on the target network
The ESP32 never sees SOCKS5 packets only OPEN/DATA/CLOSE/ERROR frames.
"""
import asyncio
import struct
import logging
import time
import socket
from typing import Optional
from core.crypto import CryptoContext
from core.keystore import KeyStore
log = logging.getLogger("tunnel")
# ============================================================
# Protocol constants (must match tun_core.h)
# ============================================================
FRAME_HDR_SIZE = 5
FRAME_MAX_DATA = 4096
FRAME_OPEN = 0x01
FRAME_OPEN_OK = 0x02
FRAME_DATA = 0x03
FRAME_CLOSE = 0x04
FRAME_ERROR = 0x05
FRAME_PING = 0x06
FRAME_PONG = 0x07
# Authentication
TUN_MAGIC = b"TUN\x01"
TUN_AUTH_TOKEN = b"espilon-tunnel-v1"
# SOCKS5
SOCKS5_VER = 0x05
SOCKS5_CMD_CONNECT = 0x01
SOCKS5_ATYP_IPV4 = 0x01
SOCKS5_ATYP_DOMAIN = 0x03
SOCKS5_ATYP_IPV6 = 0x04
# Close reasons
CLOSE_NORMAL = 0
CLOSE_RESET = 1
CLOSE_TIMEOUT = 2
# Timeouts
OPEN_TIMEOUT_S = 10
SOCKS_READ_SIZE = 4096
TUNNEL_READ_SIZE = 8192
# ============================================================
# TunnelChannel one SOCKS5 client <-> one ESP32 channel
# ============================================================
class TunnelChannel:
__slots__ = (
"channel_id", "socks_reader", "socks_writer",
"open_event", "open_success", "error_msg",
"closed", "bytes_tx", "bytes_rx", "target",
)
def __init__(self, channel_id: int,
socks_reader: asyncio.StreamReader,
socks_writer: asyncio.StreamWriter):
self.channel_id = channel_id
self.socks_reader = socks_reader
self.socks_writer = socks_writer
self.open_event = asyncio.Event()
self.open_success = False
self.error_msg = ""
self.closed = False
self.bytes_tx = 0
self.bytes_rx = 0
self.target = ""
# ============================================================
# DeviceTunnel binary-framed TCP connection to one ESP32
# ============================================================
class DeviceTunnel:
def __init__(self, device_id: str,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
crypto: Optional[CryptoContext],
encrypted: bool = False,
max_channels: int = 8):
self.device_id = device_id
self.reader = reader
self.writer = writer
self.crypto = crypto
self.encrypted = encrypted
self.max_channels = max_channels
self.channels: dict[int, TunnelChannel] = {}
self._next_channel_id = 1
self._lock = asyncio.Lock()
self.connected = True
def allocate_channel_id(self) -> Optional[int]:
"""Return next free channel id (1-based, cycles within max_channels)."""
for _ in range(self.max_channels):
cid = self._next_channel_id
self._next_channel_id = (self._next_channel_id % self.max_channels) + 1
if cid not in self.channels:
return cid
return None
async def send_frame(self, channel_id: int, frame_type: int,
data: bytes = b""):
"""Encode and send a frame to the ESP32."""
hdr = struct.pack("!BHB H", channel_id >> 8, channel_id & 0xFF,
frame_type, len(data))
# Simpler: pack as big-endian
hdr = struct.pack(">HBH", channel_id, frame_type, len(data))
frame = hdr + data
if self.encrypted and self.crypto:
encrypted = self.crypto.encrypt(frame)
length_prefix = struct.pack(">H", len(encrypted))
self.writer.write(length_prefix + encrypted)
else:
self.writer.write(frame)
try:
await self.writer.drain()
except (ConnectionError, OSError):
self.connected = False
async def read_frame(self) -> Optional[tuple[int, int, bytes]]:
"""Read one frame from ESP32. Returns (channel_id, type, data) or None on disconnect."""
try:
if self.encrypted and self.crypto:
# Read 2-byte length prefix
len_bytes = await self.reader.readexactly(2)
enc_len = struct.unpack(">H", len_bytes)[0]
enc_data = await self.reader.readexactly(enc_len)
frame = self.crypto.decrypt(enc_data)
else:
# Read 5-byte header
hdr = await self.reader.readexactly(FRAME_HDR_SIZE)
frame = hdr
channel_id, frame_type, data_len = struct.unpack(">HBH", hdr)
if data_len > FRAME_MAX_DATA:
log.warning(f"[{self.device_id}] frame too large: {data_len}")
return None
if data_len > 0:
payload = await self.reader.readexactly(data_len)
return (channel_id, frame_type, payload)
return (channel_id, frame_type, b"")
except (asyncio.IncompleteReadError, ConnectionError, OSError):
self.connected = False
return None
# Encrypted path: parse decrypted frame
if len(frame) < FRAME_HDR_SIZE:
return None
channel_id, frame_type, data_len = struct.unpack(">HBH", frame[:5])
data = frame[5:5 + data_len]
return (channel_id, frame_type, data)
async def run_rx_loop(self, server: 'TunnelServer'):
"""Read frames from ESP32, dispatch to channels."""
log.info(f"[{self.device_id}] RX loop started")
while self.connected:
result = await self.read_frame()
if result is None:
break
channel_id, frame_type, data = result
if frame_type == FRAME_OPEN_OK:
ch = self.channels.get(channel_id)
if ch:
ch.open_success = True
ch.open_event.set()
elif frame_type == FRAME_DATA:
ch = self.channels.get(channel_id)
if ch and not ch.closed:
ch.bytes_rx += len(data)
try:
ch.socks_writer.write(data)
await ch.socks_writer.drain()
except (ConnectionError, OSError):
ch.closed = True
await self.send_frame(channel_id, FRAME_CLOSE,
bytes([CLOSE_RESET]))
elif frame_type == FRAME_CLOSE:
ch = self.channels.pop(channel_id, None)
if ch:
ch.closed = True
ch.open_event.set() # Unblock if waiting
try:
ch.socks_writer.close()
await ch.socks_writer.wait_closed()
except Exception:
pass
log.info(f"[{self.device_id}] chan {channel_id} closed "
f"(tx={ch.bytes_tx} rx={ch.bytes_rx})")
elif frame_type == FRAME_ERROR:
ch = self.channels.get(channel_id)
if ch:
ch.error_msg = data.decode(errors="ignore")
ch.open_success = False
ch.open_event.set()
log.warning(f"[{self.device_id}] chan {channel_id} error: "
f"{ch.error_msg}")
elif frame_type == FRAME_PING:
await self.send_frame(0, FRAME_PONG, data)
elif frame_type == FRAME_PONG:
pass # Keepalive acknowledged
# Connection lost — close all channels
log.info(f"[{self.device_id}] RX loop ended, closing all channels")
for cid, ch in list(self.channels.items()):
ch.closed = True
ch.open_event.set()
try:
ch.socks_writer.close()
except Exception:
pass
self.channels.clear()
self.connected = False
# ============================================================
# TunnelServer SOCKS5 + ESP32 tunnel manager
# ============================================================
class TunnelServer:
def __init__(self, keystore: KeyStore,
socks_host: str = "127.0.0.1",
socks_port: int = 1080,
tunnel_host: str = "0.0.0.0",
tunnel_port: int = 2627):
self.keystore = keystore
self.socks_host = socks_host
self.socks_port = socks_port
self.tunnel_host = tunnel_host
self.tunnel_port = tunnel_port
self._device_tunnels: dict[str, DeviceTunnel] = {}
self._active_device: Optional[str] = None
self._socks_server: Optional[asyncio.Server] = None
self._tunnel_server: Optional[asyncio.Server] = None
self._crypto_cache: dict[str, CryptoContext] = {}
def _get_crypto(self, device_id: str) -> Optional[CryptoContext]:
if device_id in self._crypto_cache:
return self._crypto_cache[device_id]
master_key = self.keystore.get(device_id)
if master_key is None:
return None
ctx = CryptoContext(master_key, device_id)
self._crypto_cache[device_id] = ctx
return ctx
# ==========================================================
# Start / Stop
# ==========================================================
async def start(self):
"""Start SOCKS5 and tunnel listener servers."""
self._tunnel_server = await asyncio.start_server(
self._handle_tunnel_connection,
self.tunnel_host, self.tunnel_port,
)
log.info(f"Tunnel listener on {self.tunnel_host}:{self.tunnel_port}")
self._socks_server = await asyncio.start_server(
self._handle_socks_client,
self.socks_host, self.socks_port,
)
log.info(f"SOCKS5 server on {self.socks_host}:{self.socks_port}")
async def stop(self):
"""Shut down all servers and tunnels."""
if self._socks_server:
self._socks_server.close()
await self._socks_server.wait_closed()
if self._tunnel_server:
self._tunnel_server.close()
await self._tunnel_server.wait_closed()
for tunnel in self._device_tunnels.values():
tunnel.connected = False
try:
tunnel.writer.close()
except Exception:
pass
self._device_tunnels.clear()
# ==========================================================
# ESP32 tunnel connection
# ==========================================================
async def _handle_tunnel_connection(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter):
addr = writer.get_extra_info("peername")
log.info(f"Tunnel connection from {addr}")
device_id = await self._authenticate_device(reader, writer)
if not device_id:
log.warning(f"Tunnel auth failed from {addr}")
writer.close()
return
# Check encryption flag from handshake (stored during auth)
encrypted = getattr(self, "_last_auth_encrypted", False)
crypto = self._get_crypto(device_id)
tunnel = DeviceTunnel(device_id, reader, writer, crypto, encrypted)
# Replace existing tunnel for this device
old = self._device_tunnels.pop(device_id, None)
if old:
old.connected = False
try:
old.writer.close()
except Exception:
pass
self._device_tunnels[device_id] = tunnel
if self._active_device is None:
self._active_device = device_id
log.info(f"Device '{device_id}' tunnel authenticated "
f"(encrypted={'yes' if encrypted else 'no'})")
# Run frame dispatch loop
await tunnel.run_rx_loop(self)
# Cleanup on disconnect
self._device_tunnels.pop(device_id, None)
if self._active_device == device_id:
# Pick another active device if available
if self._device_tunnels:
self._active_device = next(iter(self._device_tunnels))
else:
self._active_device = None
log.info(f"Device '{device_id}' tunnel disconnected")
async def _authenticate_device(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter) -> Optional[str]:
"""
Verify ESP32 handshake:
magic[4] + flags[1] + device_id_len[1] + device_id[N] + encrypted_token[45]
"""
try:
# Read magic
magic = await asyncio.wait_for(reader.readexactly(4), timeout=10)
if magic != TUN_MAGIC:
log.warning(f"Bad tunnel magic: {magic!r}")
writer.write(b"\x01")
await writer.drain()
return None
# Read flags + device_id_len
meta = await reader.readexactly(2)
flags = meta[0]
id_len = meta[1]
if id_len == 0 or id_len > 64:
writer.write(b"\x01")
await writer.drain()
return None
device_id = (await reader.readexactly(id_len)).decode(errors="ignore")
# Get crypto for this device
crypto = self._get_crypto(device_id)
if crypto is None:
log.warning(f"Unknown device in tunnel auth: '{device_id}'")
writer.write(b"\x01")
await writer.drain()
return None
# Read encrypted auth token
# The token "espilon-tunnel-v1" (17 bytes) encrypted =
# nonce(12) + ciphertext(17) + tag(16) = 45 bytes
enc_token = await reader.readexactly(45)
# Decrypt and verify
try:
plaintext = crypto.decrypt(enc_token)
except Exception as e:
log.warning(f"Tunnel auth decrypt failed for '{device_id}': {e}")
writer.write(b"\x01")
await writer.drain()
return None
if plaintext != TUN_AUTH_TOKEN:
log.warning(f"Tunnel auth token mismatch for '{device_id}'")
writer.write(b"\x01")
await writer.drain()
return None
# Store encryption preference
self._last_auth_encrypted = bool(flags & 0x01)
# Auth OK
writer.write(b"\x00")
await writer.drain()
return device_id
except (asyncio.TimeoutError, asyncio.IncompleteReadError, Exception) as e:
log.warning(f"Tunnel auth error: {e}")
try:
writer.write(b"\x01")
await writer.drain()
except Exception:
pass
return None
# ==========================================================
# SOCKS5 client handling
# ==========================================================
async def _handle_socks_client(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter):
addr = writer.get_extra_info("peername")
try:
# 1) Find active tunnel
tunnel = self._get_active_tunnel()
if not tunnel:
log.warning(f"SOCKS5 from {addr}: no active tunnel")
writer.close()
return
# 2) SOCKS5 auth negotiation
if not await self._socks5_handshake(reader, writer):
return
# 3) SOCKS5 CONNECT request
target = await self._socks5_connect(reader, writer)
if not target:
return
host, port, atyp_data = target
# 4) Allocate channel
channel_id = tunnel.allocate_channel_id()
if channel_id is None:
log.warning(f"SOCKS5: no free channels for {host}:{port}")
await self._socks5_reply(writer, 0x01) # General failure
return
channel = TunnelChannel(channel_id, reader, writer)
channel.target = f"{host}:{port}"
tunnel.channels[channel_id] = channel
# 5) Send OPEN frame to ESP32
open_payload = self._build_open_payload(host, port)
await tunnel.send_frame(channel_id, FRAME_OPEN, open_payload)
# 6) Wait for OPEN_OK or ERROR
try:
await asyncio.wait_for(channel.open_event.wait(),
timeout=OPEN_TIMEOUT_S)
except asyncio.TimeoutError:
log.warning(f"SOCKS5: OPEN timeout for {host}:{port}")
tunnel.channels.pop(channel_id, None)
await self._socks5_reply(writer, 0x04) # Host unreachable
return
if not channel.open_success:
log.warning(f"SOCKS5: OPEN failed for {host}:{port}: "
f"{channel.error_msg}")
tunnel.channels.pop(channel_id, None)
await self._socks5_reply(writer, 0x05) # Connection refused
return
# 7) SOCKS5 success reply
await self._socks5_reply(writer, 0x00)
log.info(f"SOCKS5: chan {channel_id} -> {host}:{port}")
# 8) Relay: SOCKS client -> DATA frames -> ESP32
await self._relay_socks_to_tunnel(channel, tunnel)
except Exception as e:
log.error(f"SOCKS5 error from {addr}: {e}")
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
def _get_active_tunnel(self) -> Optional[DeviceTunnel]:
"""Get the currently active device tunnel."""
if self._active_device:
tunnel = self._device_tunnels.get(self._active_device)
if tunnel and tunnel.connected:
return tunnel
# Try any connected tunnel
for did, tunnel in self._device_tunnels.items():
if tunnel.connected:
self._active_device = did
return tunnel
return None
async def _socks5_handshake(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter) -> bool:
"""SOCKS5 auth negotiation — accept NO_AUTH."""
try:
data = await asyncio.wait_for(reader.read(257), timeout=5)
if len(data) < 3 or data[0] != SOCKS5_VER:
writer.close()
return False
# Accept NO_AUTH (0x00)
n_methods = data[1]
methods = data[2:2 + n_methods]
if 0x00 in methods:
writer.write(bytes([SOCKS5_VER, 0x00]))
await writer.drain()
return True
else:
writer.write(bytes([SOCKS5_VER, 0xFF]))
await writer.drain()
writer.close()
return False
except Exception:
return False
async def _socks5_connect(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter
) -> Optional[tuple[str, int, bytes]]:
"""Parse SOCKS5 CONNECT request. Returns (host, port, raw_atyp_data)."""
try:
data = await asyncio.wait_for(reader.read(262), timeout=5)
if len(data) < 7 or data[0] != SOCKS5_VER:
await self._socks5_reply(writer, 0x01)
return None
cmd = data[1]
if cmd != SOCKS5_CMD_CONNECT:
await self._socks5_reply(writer, 0x07) # Command not supported
return None
atyp = data[3]
if atyp == SOCKS5_ATYP_IPV4:
if len(data) < 10:
await self._socks5_reply(writer, 0x01)
return None
host = socket.inet_ntoa(data[4:8])
port = struct.unpack(">H", data[8:10])[0]
return (host, port, data[3:10])
elif atyp == SOCKS5_ATYP_DOMAIN:
domain_len = data[4]
if len(data) < 5 + domain_len + 2:
await self._socks5_reply(writer, 0x01)
return None
host = data[5:5 + domain_len].decode(errors="ignore")
port = struct.unpack(">H",
data[5 + domain_len:7 + domain_len])[0]
return (host, port, data[3:7 + domain_len])
elif atyp == SOCKS5_ATYP_IPV6:
await self._socks5_reply(writer, 0x08) # Atyp not supported
return None
else:
await self._socks5_reply(writer, 0x08)
return None
except Exception:
return None
async def _socks5_reply(self, writer: asyncio.StreamWriter, status: int):
"""Send SOCKS5 reply."""
reply = struct.pack("!BBBB4sH",
SOCKS5_VER, status, 0x00,
SOCKS5_ATYP_IPV4,
b"\x00\x00\x00\x00", 0)
try:
writer.write(reply)
await writer.drain()
except Exception:
pass
def _build_open_payload(self, host: str, port: int) -> bytes:
"""Build OPEN frame payload: [IPv4:4][port:2][domain_len:1][domain:0-255]"""
# Try to parse as IPv4
try:
ipv4_bytes = socket.inet_aton(host)
domain = b""
except OSError:
# It's a domain name — send 0.0.0.0 as fallback IP,
# ESP32 will resolve via getaddrinfo
ipv4_bytes = b"\x00\x00\x00\x00"
domain = host.encode()[:255]
return (ipv4_bytes
+ struct.pack(">H", port)
+ bytes([len(domain)])
+ domain)
async def _relay_socks_to_tunnel(self, channel: TunnelChannel,
tunnel: DeviceTunnel):
"""Read from SOCKS client, send DATA frames to ESP32."""
try:
while not channel.closed and tunnel.connected:
data = await channel.socks_reader.read(SOCKS_READ_SIZE)
if not data:
break
channel.bytes_tx += len(data)
# Split into frame-sized chunks
offset = 0
while offset < len(data):
chunk = data[offset:offset + FRAME_MAX_DATA]
await tunnel.send_frame(channel.channel_id,
FRAME_DATA, chunk)
offset += len(chunk)
except (ConnectionError, OSError):
pass
finally:
if channel.channel_id in tunnel.channels:
tunnel.channels.pop(channel.channel_id, None)
await tunnel.send_frame(channel.channel_id, FRAME_CLOSE,
bytes([CLOSE_NORMAL]))
log.info(f"SOCKS5: chan {channel.channel_id} done "
f"({channel.target} tx={channel.bytes_tx} "
f"rx={channel.bytes_rx})")
# ==========================================================
# Status API (for web/TUI)
# ==========================================================
def get_status(self) -> dict:
"""Return tunnel status for API/UI."""
tunnels = []
for did, t in self._device_tunnels.items():
channels = []
total_tx = 0
total_rx = 0
for cid, ch in t.channels.items():
channels.append({
"id": cid,
"target": ch.target,
"state": "closed" if ch.closed else "open",
"bytes_tx": ch.bytes_tx,
"bytes_rx": ch.bytes_rx,
})
total_tx += ch.bytes_tx
total_rx += ch.bytes_rx
tunnels.append({
"device_id": did,
"connected": t.connected,
"encrypted": t.encrypted,
"active_channels": len(t.channels),
"max_channels": t.max_channels,
"channels": channels,
"bytes_tx": total_tx,
"bytes_rx": total_rx,
})
socks_running = self._socks_server is not None and self._socks_server.is_serving()
return {
"socks_running": socks_running,
"socks_addr": f"{self.socks_host}:{self.socks_port}",
"active_device": self._active_device,
"tunnels": tunnels,
}
def set_active_device(self, device_id: str) -> bool:
"""Switch SOCKS traffic to a different device."""
if device_id in self._device_tunnels:
self._active_device = device_id
return True
return False

View File

@ -0,0 +1,15 @@
services:
c3po:
build: .
ports:
- "2626:2626" # C2 TCP
- "8000:8000" # Web dashboard
- "5000:5000/udp" # Camera UDP
volumes:
- ./keys.json:/app/keys.json
- ./data:/app/data
- ./firmware:/app/firmware
- ./static/recordings:/app/static/recordings
env_file:
- .env
restart: unless-stopped

View File

@ -0,0 +1,8 @@
from .hp_store import HpStore
from .hp_routes import create_hp_blueprint
from .hp_commander import HpCommander
from .hp_alerts import HpAlertEngine
from .hp_geo import HpGeoLookup
__all__ = ["HpStore", "HpCommander", "HpAlertEngine", "HpGeoLookup",
"create_hp_blueprint"]

View File

@ -0,0 +1,555 @@
"""
Honeypot Alert Engine sliding window rule evaluation.
Evaluates rules against incoming events and fires alerts
when thresholds are exceeded. Supports both instant and
windowed (rate-based) rules.
"""
import time
import threading
import json
import sqlite3
import os
import socket
import ipaddress
import urllib.parse
import urllib.request
import urllib.error
from collections import defaultdict
# ============================================================
# Default alert rules
# ============================================================
DEFAULT_RULES = [
{
"id": "brute_force",
"name": "Brute Force",
"description": "Multiple auth attempts from same IP",
"event_match": {"event_type": "SVC_AUTH_ATTEMPT"},
"group_by": "src_ip",
"threshold": 5,
"window_s": 60,
"severity": "HIGH",
},
{
"id": "port_scan",
"name": "Port Scan Burst",
"description": "Connections to 3+ services from same IP",
"event_match": {"event_type": "SVC_CONNECT"},
"group_by": "src_ip",
"count_distinct": "dst_port",
"threshold": 3,
"window_s": 30,
"severity": "HIGH",
},
{
"id": "ddos_syn",
"name": "SYN Flood",
"description": "SYN flood detected",
"event_match": {"event_type": "SYN_FLOOD"},
"threshold": 1,
"window_s": 0,
"severity": "CRITICAL",
},
{
"id": "ddos_udp",
"name": "UDP Flood",
"description": "UDP flood detected",
"event_match": {"event_type": "UDP_FLOOD"},
"threshold": 1,
"window_s": 0,
"severity": "CRITICAL",
},
{
"id": "shell_access",
"name": "Shell Access",
"description": "Command executed in honeypot service",
"event_match": {"event_type": "SVC_COMMAND"},
"service_match": ["telnet", "ssh"],
"threshold": 1,
"window_s": 0,
"severity": "MEDIUM",
},
{
"id": "honeytoken",
"name": "Honeytoken Accessed",
"description": "Attacker accessed a honeytoken file",
"detail_contains": "HONEYTOKEN",
"threshold": 1,
"window_s": 0,
"severity": "CRITICAL",
},
{
"id": "malware_sig",
"name": "Malware Signature",
"description": "Known malware pattern detected",
"field_match": {"malware_tag": True},
"threshold": 1,
"window_s": 0,
"severity": "CRITICAL",
},
{
"id": "download_attempt",
"name": "Download Attempt",
"description": "Attacker attempted to download payload",
"detail_contains": "DOWNLOAD",
"threshold": 1,
"window_s": 0,
"severity": "HIGH",
},
{
"id": "scada_write",
"name": "SCADA Write",
"description": "Write attempt to Modbus/SCADA registers",
"event_match": {"event_type": "SVC_COMMAND"},
"service_match": ["modbus"],
"detail_contains": "Write",
"threshold": 1,
"window_s": 0,
"severity": "CRITICAL",
},
]
class HpAlertEngine:
"""
Evaluates alert rules against incoming events.
Maintains sliding window counters for rate-based rules.
"""
def __init__(self, db_path=None):
self.lock = threading.Lock()
self.rules = []
self._next_rule_id = 100
# Windowed counters: rule_id -> group_key -> list of timestamps
self._windows = defaultdict(lambda: defaultdict(list))
# Windowed distinct: rule_id -> group_key -> set of values
self._distinct = defaultdict(lambda: defaultdict(set))
# Active alerts: list of dicts
self.active_alerts = []
self._next_alert_id = 1
# Cooldown: rule_id:group_key -> last_alert_ts (avoid spam)
self._cooldowns = {}
# Store reference for kill chain analysis
self._store = None
# Webhook management
self.webhook_urls = [] # List of {"id": int, "url": str, "name": str, "enabled": bool}
self._next_webhook_id = 1
# Alert persistence
if db_path is None:
db_path = os.path.join(os.path.dirname(__file__), "honeypot_alerts.db")
self._alert_db_path = db_path
self._init_alert_db()
# Load defaults
for r in DEFAULT_RULES:
self.rules.append(dict(r))
def set_store(self, store):
"""Set reference to HpStore for kill chain analysis."""
self._store = store
# ============================================================
# Rule CRUD
# ============================================================
def get_rules(self) -> list:
with self.lock:
return [dict(r) for r in self.rules]
def add_rule(self, rule: dict) -> dict:
with self.lock:
rule["id"] = f"custom_{self._next_rule_id}"
self._next_rule_id += 1
self.rules.append(rule)
self._persist_rule(rule)
return rule
def delete_rule(self, rule_id: str) -> bool:
with self.lock:
before = len(self.rules)
self.rules = [r for r in self.rules if r["id"] != rule_id]
deleted = len(self.rules) < before
if deleted and rule_id.startswith("custom_"):
self._delete_persisted_rule(rule_id)
return deleted
# ============================================================
# Alert queries
# ============================================================
def get_active_alerts(self) -> list:
with self.lock:
return list(self.active_alerts)
def acknowledge_alert(self, alert_id: int) -> bool:
with self.lock:
for a in self.active_alerts:
if a["id"] == alert_id:
a["acknowledged"] = True
a["ack_at"] = time.time()
try:
conn = sqlite3.connect(self._alert_db_path)
conn.execute("UPDATE alerts SET acknowledged=1, ack_at=? WHERE id=?",
(a["ack_at"], alert_id))
conn.commit()
conn.close()
except Exception as e:
print(f"[HP_ALERTS] Failed to persist ack for alert {alert_id}: {e}")
return True
return False
def get_unacknowledged_count(self) -> int:
with self.lock:
return sum(1 for a in self.active_alerts if not a.get("acknowledged"))
# ============================================================
# Alert Persistence
# ============================================================
def _init_alert_db(self):
conn = sqlite3.connect(self._alert_db_path)
conn.execute("""CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY, rule_id TEXT, rule_name TEXT,
severity TEXT, description TEXT, src_ip TEXT, service TEXT,
detail TEXT, count INTEGER, group_key TEXT,
timestamp REAL, event_id INTEGER,
acknowledged INTEGER DEFAULT 0, ack_at REAL
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS custom_rules (
id TEXT PRIMARY KEY,
rule_json TEXT NOT NULL,
created_at REAL
)""")
conn.commit()
conn.close()
# Load existing unacknowledged alerts into memory
conn = sqlite3.connect(self._alert_db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM alerts WHERE acknowledged = 0 ORDER BY id DESC LIMIT 200"
).fetchall()
for r in rows:
self.active_alerts.append(dict(r))
if rows:
self._next_alert_id = max(r["id"] for r in rows) + 1
# Load persisted custom rules
custom_rows = conn.execute("SELECT id, rule_json FROM custom_rules").fetchall()
for cr in custom_rows:
try:
rule = json.loads(cr["rule_json"])
rule["id"] = cr["id"]
self.rules.append(rule)
if cr["id"].startswith("custom_"):
num = int(cr["id"].split("_", 1)[1])
if num >= self._next_rule_id:
self._next_rule_id = num + 1
except (json.JSONDecodeError, ValueError):
pass
conn.close()
def _persist_alert(self, alert):
try:
conn = sqlite3.connect(self._alert_db_path)
conn.execute(
"INSERT INTO alerts (id, rule_id, rule_name, severity, description, "
"src_ip, service, detail, count, group_key, timestamp, event_id, "
"acknowledged, ack_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(alert["id"], alert["rule_id"], alert["rule_name"],
alert["severity"], alert["description"], alert["src_ip"],
alert["service"], alert["detail"], alert["count"],
alert["group_key"], alert["timestamp"], alert.get("event_id"),
0, None))
conn.commit()
conn.close()
except Exception as e:
print(f"[HP_ALERTS] Failed to persist alert {alert.get('id')}: {e}")
def _persist_rule(self, rule):
"""Persist a custom rule to the database."""
try:
conn = sqlite3.connect(self._alert_db_path)
rule_copy = {k: v for k, v in rule.items() if k != "id"}
conn.execute(
"INSERT OR REPLACE INTO custom_rules (id, rule_json, created_at) VALUES (?,?,?)",
(rule["id"], json.dumps(rule_copy), time.time()))
conn.commit()
conn.close()
except Exception as e:
print(f"[HP_ALERTS] Failed to persist rule {rule.get('id')}: {e}")
def _delete_persisted_rule(self, rule_id):
"""Delete a custom rule from the database."""
try:
conn = sqlite3.connect(self._alert_db_path)
conn.execute("DELETE FROM custom_rules WHERE id = ?", (rule_id,))
conn.commit()
conn.close()
except Exception as e:
print(f"[HP_ALERTS] Failed to delete persisted rule {rule_id}: {e}")
# ============================================================
# Webhooks
# ============================================================
@staticmethod
def _is_safe_webhook_url(url):
"""Validate webhook URL to prevent SSRF attacks.
Blocks private/loopback IPs, file:// scheme, and non-http(s) schemes.
Returns (is_safe: bool, reason: str).
"""
try:
parsed = urllib.parse.urlparse(url)
except Exception:
return False, "Malformed URL"
# Only allow http and https schemes
if parsed.scheme not in ("http", "https"):
return False, f"Scheme '{parsed.scheme}' not allowed (only http/https)"
hostname = parsed.hostname
if not hostname:
return False, "No hostname in URL"
# Resolve hostname to IP and check for private/loopback
try:
resolved = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC,
socket.SOCK_STREAM)
for _family, _type, _proto, _canonname, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
return False, f"Resolved to private/loopback IP ({ip})"
except socket.gaierror:
return False, f"Cannot resolve hostname '{hostname}'"
return True, ""
def add_webhook(self, url, name=""):
safe, reason = self._is_safe_webhook_url(url)
if not safe:
return {"error": f"Unsafe webhook URL: {reason}"}
with self.lock:
wh = {"id": self._next_webhook_id, "url": url,
"name": name or url[:40], "enabled": True}
self._next_webhook_id += 1
self.webhook_urls.append(wh)
return wh
def remove_webhook(self, wh_id):
with self.lock:
before = len(self.webhook_urls)
self.webhook_urls = [w for w in self.webhook_urls if w["id"] != wh_id]
return len(self.webhook_urls) < before
def get_webhooks(self):
with self.lock:
return [dict(w) for w in self.webhook_urls if w["enabled"]]
_WEBHOOK_MIN_INTERVAL = 10 # seconds between sends per webhook
def _send_webhooks(self, alert):
"""Send alert to all configured webhooks (in background thread)."""
now = time.time()
for wh in self.webhook_urls:
if not wh["enabled"]:
continue
# Rate limit: skip if last send was less than _WEBHOOK_MIN_INTERVAL ago
last_sent = wh.get("_last_sent", 0)
if now - last_sent < self._WEBHOOK_MIN_INTERVAL:
continue
try:
payload = json.dumps({
"text": "[%s] %s: %s from %s (%s)" % (
alert["severity"], alert["rule_name"],
alert["description"], alert["src_ip"],
alert.get("service", "N/A")),
"alert": alert,
}).encode()
req = urllib.request.Request(
wh["url"], data=payload,
headers={"Content-Type": "application/json"},
method="POST")
urllib.request.urlopen(req, timeout=5)
wh["_last_sent"] = now
except Exception as e:
print(f"[HP_ALERTS] Webhook {wh.get('name', wh['url'][:40])} failed: {e}")
# ============================================================
# Event evaluation
# ============================================================
def evaluate(self, event: dict) -> list:
"""
Evaluate all rules against an event.
Returns list of newly fired alerts.
"""
fired = []
now = time.time()
with self.lock:
for rule in self.rules:
if self._matches(rule, event):
alerts = self._check_threshold(rule, event, now)
fired.extend(alerts)
# Kill chain analysis (on significant events only)
fired.extend(self._check_kill_chain(event, now))
return fired
def _check_kill_chain(self, event, now):
"""Check if attacker has reached advanced kill chain phases."""
if not self._store:
return []
ip = event.get("src_ip", "")
if not ip or ip == "0.0.0.0":
return []
# Only check on significant events
etype = event.get("event_type", "")
detail = event.get("detail", "")
if etype not in ("SVC_COMMAND", "SVC_MQTT_MSG") and "HONEYTOKEN" not in detail:
return []
# Cooldown: check kill chain per IP max once per 5 min
cooldown_key = f"kill_chain:{ip}"
last = self._cooldowns.get(cooldown_key, 0)
if now - last < 300:
return []
analysis = self._store.get_kill_chain_analysis(ip)
if analysis["score"] < 40:
return []
self._cooldowns[cooldown_key] = now
rule = {
"id": "kill_chain_advanced",
"name": "Kill Chain: Advanced Progression",
"severity": "CRITICAL",
"description": "Attacker reached phase '%s' (score %d)" % (
analysis["max_phase"], analysis["score"]),
}
return [self._fire_alert(rule, event, now, ip, analysis["score"])]
def _matches(self, rule: dict, event: dict) -> bool:
"""Check if event matches rule conditions."""
# event_match: check specific field values
em = rule.get("event_match")
if em:
for k, v in em.items():
if event.get(k) != v:
return False
# service_match: check service in list
sm = rule.get("service_match")
if sm:
if event.get("service") not in sm:
return False
# detail_contains: substring match
dc = rule.get("detail_contains")
if dc:
detail = event.get("detail", "")
if dc not in detail:
return False
# field_match: check if field exists and is truthy
fm = rule.get("field_match")
if fm:
for k, v in fm.items():
if v is True:
if not event.get(k):
return False
elif event.get(k) != v:
return False
return True
def _check_threshold(self, rule: dict, event: dict, now: float) -> list:
"""Check if threshold is met, return alerts if so."""
rule_id = rule["id"]
window = rule.get("window_s", 0)
threshold = rule.get("threshold", 1)
# Instant rules (window=0): fire immediately
if window == 0:
cooldown_key = f"{rule_id}:{event.get('src_ip', '')}"
last = self._cooldowns.get(cooldown_key, 0)
if now - last < 10: # 10s cooldown for instant rules
return []
self._cooldowns[cooldown_key] = now
return [self._fire_alert(rule, event, now)]
# Windowed rules: track timestamps per group key
group_field = rule.get("group_by", "src_ip")
group_key = event.get(group_field, "unknown")
timestamps = self._windows[rule_id][group_key]
# Prune old entries
cutoff = now - window
self._windows[rule_id][group_key] = [t for t in timestamps if t > cutoff]
timestamps = self._windows[rule_id][group_key]
timestamps.append(now)
# count_distinct: count unique values of a field
distinct_field = rule.get("count_distinct")
if distinct_field:
self._distinct[rule_id][group_key].add(event.get(distinct_field, 0))
# Prune distinct on window reset (simplified: keep set, reset when empty)
count = len(self._distinct[rule_id][group_key])
else:
count = len(timestamps)
if count >= threshold:
cooldown_key = f"{rule_id}:{group_key}"
last = self._cooldowns.get(cooldown_key, 0)
if now - last < window: # cooldown = window duration
return []
self._cooldowns[cooldown_key] = now
# Clear window after firing
self._windows[rule_id][group_key] = []
if distinct_field:
self._distinct[rule_id][group_key] = set()
return [self._fire_alert(rule, event, now, group_key, count)]
return []
def _fire_alert(self, rule: dict, event: dict, now: float,
group_key: str = None, count: int = 1) -> dict:
"""Create and store an alert."""
alert = {
"id": self._next_alert_id,
"rule_id": rule["id"],
"rule_name": rule.get("name", rule["id"]),
"severity": rule.get("severity", "HIGH"),
"description": rule.get("description", ""),
"src_ip": event.get("src_ip", ""),
"service": event.get("service", ""),
"detail": event.get("detail", ""),
"count": count,
"group_key": group_key or event.get("src_ip", ""),
"timestamp": now,
"event_id": event.get("id"),
"acknowledged": False,
}
self._next_alert_id += 1
self.active_alerts.append(alert)
# Keep max 200 alerts
if len(self.active_alerts) > 200:
self.active_alerts = self.active_alerts[-200:]
# Persist alert to database
self._persist_alert(alert)
# Dispatch webhooks in background
if self.webhook_urls:
threading.Thread(target=self._send_webhooks, args=(dict(alert),), daemon=True).start()
return alert

View File

@ -0,0 +1,299 @@
"""
Honeypot Commander builds C2 commands and tracks responses.
Uses unified command dispatch: instead of individual commands per service
(hp_ssh_start, hp_telnet_stop, ...), sends a single `hp_svc` command
with service name and action as arguments. This fits within the ESP32's
32-command budget.
Manages command dispatch to honeypot ESP32 devices via the C3PO
transport layer, tracks pending/completed commands, and caches
service state from status responses.
"""
import time
import threading
from dataclasses import dataclass, field
from collections import deque
from typing import Optional, Callable
# ============================================================
# Service and monitor definitions
# ============================================================
HP_SERVICES = {
"ssh": {"port": 22},
"telnet": {"port": 23},
"http": {"port": 80},
"ftp": {"port": 21},
}
HP_MONITORS = {
"wifi": {},
"net": {},
}
SYSTEM_COMMANDS = ["system_info", "system_reboot", "system_mem", "system_uptime", "system_health"]
OTA_COMMANDS = ["ota_update", "ota_status", "ota_rollback"]
CONFIG_COMMANDS = ["hp_config_set", "hp_config_get", "hp_config_list", "hp_config_reset"]
# Configurable banner services and threshold keys
CONFIG_BANNER_SERVICES = ["ssh", "telnet", "ftp", "http"]
CONFIG_THRESHOLDS = {
"portscan": {"label": "Port Scan", "default": 5, "min": 1, "max": 50},
"synflood": {"label": "SYN Flood", "default": 50, "min": 10, "max": 500},
"icmp": {"label": "ICMP Sweep", "default": 10, "min": 1, "max": 100},
"udpflood": {"label": "UDP Flood", "default": 100, "min": 10, "max": 1000},
"arpflood": {"label": "ARP Flood", "default": 50, "min": 10, "max": 500},
"tarpit_ms": {"label": "Tarpit (ms)", "default": 2000, "min": 500, "max": 10000},
}
# Unified command names (sent to ESP32)
ALLOWED_COMMANDS = {
"hp_svc", # hp_svc <service> <start|stop|status>
"hp_wifi", # hp_wifi <start|stop|status>
"hp_net", # hp_net <start|stop|status>
"hp_config_set", # hp_config_set <type> <key> <value>
"hp_config_get", # hp_config_get <type> <key>
"hp_config_list", # hp_config_list [type]
"hp_config_reset", # hp_config_reset
"hp_status", # hp_status
"hp_start_all", # hp_start_all — start all services + monitors
"hp_stop_all", # hp_stop_all — stop all services + monitors
}
ALLOWED_COMMANDS.update(SYSTEM_COMMANDS)
ALLOWED_COMMANDS.update(OTA_COMMANDS)
@dataclass
class CommandRecord:
"""Tracks a sent command and its response."""
request_id: str
device_id: str
command_name: str
argv: list
sent_at: float
status: str = "pending"
response: str = ""
completed_at: float = 0.0
class HpCommander:
"""
Manages command dispatch and response tracking for honeypot devices.
Constructor receives deferred lambdas (same pattern as C3PO blueprints):
get_transport: Callable -> Transport
get_registry: Callable -> DeviceRegistry
"""
def __init__(self, get_transport: Callable, get_registry: Callable,
max_history: int = 500):
self._get_transport = get_transport
self._get_registry = get_registry
self.lock = threading.Lock()
self.history: deque = deque(maxlen=max_history)
self.pending: dict = {}
# Cached service states per device:
# { device_id: { service_name: { "running": bool, "detail": {...}, "updated_at": float } } }
self.service_states: dict = {}
self._req_counter = 0
def _next_request_id(self) -> str:
self._req_counter += 1
return f"hp-{int(time.time())}-{self._req_counter}"
def get_honeypot_devices(self) -> list:
"""Return all connected devices."""
registry = self._get_registry()
if not registry:
return []
devices = []
for d in registry.all():
devices.append({
"id": d.id,
"ip": d.address[0] if d.address else "unknown",
"status": d.status,
"modules": getattr(d, "modules", ""),
"last_seen": d.last_seen,
})
return devices
def send_command(self, device_id: str, command_name: str,
argv: list = None) -> Optional[str]:
"""
Send a single command to a device.
Returns request_id or None on failure.
"""
registry = self._get_registry()
transport = self._get_transport()
if not registry or not transport:
return None
device = registry.get(device_id)
if not device or not getattr(device, "sock", None):
return None
# Lazy import — proto is available in C3PO process
from proto.c2_pb2 import Command
request_id = self._next_request_id()
cmd = Command()
cmd.device_id = device_id
cmd.command_name = command_name
cmd.request_id = request_id
if argv:
cmd.argv.extend(argv)
record = CommandRecord(
request_id=request_id,
device_id=device_id,
command_name=command_name,
argv=argv or [],
sent_at=time.time(),
)
with self.lock:
self.pending[request_id] = record
self.history.append(record)
transport.send_command(device.sock, cmd)
return request_id
def send_to_all_devices(self, command_name: str,
argv: list = None) -> list:
"""Send a command to all connected devices. Returns list of request_ids."""
request_ids = []
for dev in self.get_honeypot_devices():
if dev["status"] == "Connected":
rid = self.send_command(dev["id"], command_name, argv)
if rid:
request_ids.append(rid)
return request_ids
# ============================================================
# Unified service/monitor dispatch
# ============================================================
def send_service_command(self, device_id: str, service: str,
action: str) -> Optional[str]:
"""Send hp_svc <service> <action> to a device."""
return self.send_command(device_id, "hp_svc", [service, action])
def send_monitor_command(self, device_id: str, monitor: str,
action: str) -> Optional[str]:
"""Send hp_wifi/hp_net <action> to a device."""
return self.send_command(device_id, f"hp_{monitor}", [action])
def send_service_to_all(self, service: str, action: str) -> list:
"""Send hp_svc <service> <action> to all devices."""
return self.send_to_all_devices("hp_svc", [service, action])
def send_monitor_to_all(self, monitor: str, action: str) -> list:
"""Send hp_wifi/hp_net <action> to all devices."""
return self.send_to_all_devices(f"hp_{monitor}", [action])
# ============================================================
# Response handling
# ============================================================
def handle_response(self, request_id: str, device_id: str,
payload: str, eof: bool):
"""Called by transport layer when a command response arrives."""
with self.lock:
rec = self.pending.get(request_id)
if not rec:
return
rec.response += payload
if eof:
rec.status = "completed"
rec.completed_at = time.time()
del self.pending[request_id]
self._parse_status_response(rec)
def _parse_status_response(self, rec: CommandRecord):
"""Parse 'running=yes connections=5' style status responses."""
state = {}
for part in rec.response.split():
if "=" in part:
k, v = part.split("=", 1)
state[k] = v
if not state:
return
# Determine which service/monitor this belongs to
service_name = None
if rec.command_name == "hp_svc" and len(rec.argv) >= 2:
if rec.argv[1] == "status":
service_name = rec.argv[0]
elif rec.command_name in ("hp_wifi", "hp_net"):
if rec.argv and rec.argv[0] == "status":
service_name = rec.command_name.replace("hp_", "")
if service_name:
if rec.device_id not in self.service_states:
self.service_states[rec.device_id] = {}
self.service_states[rec.device_id][service_name] = {
"running": state.get("running") == "yes",
"detail": state,
"updated_at": time.time(),
}
def get_service_states(self, device_id: str = None) -> dict:
"""Get cached service states."""
with self.lock:
if device_id:
return dict(self.service_states.get(device_id, {}))
return {k: dict(v) for k, v in self.service_states.items()}
# ============================================================
# Runtime Config helpers
# ============================================================
def config_set(self, device_id: str, cfg_type: str, key: str,
value: str) -> Optional[str]:
"""Set a runtime config value (banner or threshold)."""
return self.send_command(device_id, "hp_config_set",
[cfg_type, key, str(value)])
def config_get(self, device_id: str, cfg_type: str,
key: str) -> Optional[str]:
"""Get a single config value."""
return self.send_command(device_id, "hp_config_get",
[cfg_type, key])
def config_list(self, device_id: str,
cfg_type: str = "") -> Optional[str]:
"""List all config values (optionally filtered by type)."""
args = [cfg_type] if cfg_type else []
return self.send_command(device_id, "hp_config_list", args)
def config_reset(self, device_id: str) -> Optional[str]:
"""Reset all runtime config to compile-time defaults."""
return self.send_command(device_id, "hp_config_reset")
def get_command_history(self, limit: int = 100) -> list:
"""Get recent command history (most recent first)."""
with self.lock:
records = list(self.history)
records.reverse()
return [
{
"request_id": r.request_id,
"device_id": r.device_id,
"command_name": r.command_name,
"argv": r.argv,
"sent_at": r.sent_at,
"status": r.status,
"response": r.response,
"completed_at": r.completed_at,
"duration": round(r.completed_at - r.sent_at, 3) if r.completed_at else 0,
}
for r in records[:limit]
]

View File

@ -0,0 +1,312 @@
"""
Honeypot Geo-IP & MAC Vendor Lookup.
Provides IP geolocation (via ip-api.com with SQLite cache)
and MAC address vendor identification (OUI database).
Background worker thread resolves public IPs without blocking.
"""
import ipaddress
import json
import os
import queue
import sqlite3
import threading
import time
import urllib.request
import urllib.error
# ============================================================
# OUI vendor map — top ~200 manufacturers by IEEE prefix
# ============================================================
OUI_MAP = {
# Apple
"AC:DE:48": "Apple", "3C:22:FB": "Apple", "F0:18:98": "Apple",
"DC:A9:04": "Apple", "A4:83:E7": "Apple", "F4:5C:89": "Apple",
"78:7B:8A": "Apple", "88:66:A5": "Apple", "40:A6:D9": "Apple",
# Samsung
"00:07:AB": "Samsung", "AC:5F:3E": "Samsung", "78:47:1D": "Samsung",
"D0:22:BE": "Samsung", "C4:73:1E": "Samsung", "50:01:BB": "Samsung",
# Espressif (ESP32/ESP8266)
"24:0A:C4": "Espressif", "30:AE:A4": "Espressif", "AC:67:B2": "Espressif",
"24:62:AB": "Espressif", "A4:CF:12": "Espressif", "CC:50:E3": "Espressif",
"84:CC:A8": "Espressif", "10:52:1C": "Espressif", "7C:DF:A1": "Espressif",
"3C:61:05": "Espressif", "EC:FA:BC": "Espressif", "08:3A:F2": "Espressif",
"34:AB:95": "Espressif", "C8:C9:A3": "Espressif", "E8:DB:84": "Espressif",
# Raspberry Pi
"B8:27:EB": "Raspberry Pi", "DC:A6:32": "Raspberry Pi",
"E4:5F:01": "Raspberry Pi", "28:CD:C1": "Raspberry Pi",
"D8:3A:DD": "Raspberry Pi",
# TP-Link
"50:C7:BF": "TP-Link", "14:CF:92": "TP-Link", "E8:DE:27": "TP-Link",
"60:E3:27": "TP-Link", "B0:BE:76": "TP-Link", "A4:2B:B0": "TP-Link",
# Cisco
"00:1A:A1": "Cisco", "00:1B:0D": "Cisco", "00:22:BD": "Cisco",
"00:25:B5": "Cisco", "00:1E:F7": "Cisco", "64:F6:9D": "Cisco",
# Intel
"00:1E:67": "Intel", "3C:97:0E": "Intel", "8C:8D:28": "Intel",
"A4:C3:F0": "Intel", "48:51:B7": "Intel", "80:86:F2": "Intel",
# Realtek
"00:E0:4C": "Realtek", "52:54:00": "Realtek/QEMU",
# Qualcomm / Atheros
"00:03:7F": "Atheros", "1C:B7:2C": "Qualcomm",
# Broadcom
"00:10:18": "Broadcom", "20:10:7A": "Broadcom",
# MediaTek
"00:0C:E7": "MediaTek", "9C:D2:1E": "MediaTek",
# Huawei
"00:E0:FC": "Huawei", "48:46:FB": "Huawei", "88:53:D4": "Huawei",
"70:8A:09": "Huawei", "CC:A2:23": "Huawei",
# Xiaomi
"64:09:80": "Xiaomi", "28:6C:07": "Xiaomi", "78:11:DC": "Xiaomi",
"F8:A4:5F": "Xiaomi", "7C:1C:4E": "Xiaomi",
# Google / Nest
"54:60:09": "Google", "F4:F5:D8": "Google", "A4:77:33": "Google",
# Amazon
"44:65:0D": "Amazon", "68:54:FD": "Amazon", "40:B4:CD": "Amazon",
# Microsoft (Xbox, Surface)
"28:18:78": "Microsoft", "7C:1E:52": "Microsoft",
# Dell
"00:14:22": "Dell", "18:03:73": "Dell", "F8:DB:88": "Dell",
# HP / HPE
"00:1A:4B": "HP", "3C:D9:2B": "HP", "00:1E:0B": "HP",
# Lenovo
"00:06:1B": "Lenovo", "28:D2:44": "Lenovo", "E8:6A:64": "Lenovo",
# Asus
"00:1A:92": "ASUS", "04:D4:C4": "ASUS", "1C:87:2C": "ASUS",
# Netgear
"00:1E:2A": "Netgear", "20:E5:2A": "Netgear", "C4:04:15": "Netgear",
# D-Link
"00:1B:11": "D-Link", "1C:7E:E5": "D-Link", "B0:C5:54": "D-Link",
# Ubiquiti
"04:18:D6": "Ubiquiti", "24:5A:4C": "Ubiquiti", "FC:EC:DA": "Ubiquiti",
"68:D7:9A": "Ubiquiti", "78:8A:20": "Ubiquiti",
# MikroTik
"00:0C:42": "MikroTik", "4C:5E:0C": "MikroTik", "64:D1:54": "MikroTik",
# Synology
"00:11:32": "Synology",
# Hikvision
"C0:56:E3": "Hikvision", "44:47:CC": "Hikvision",
# Dahua
"3C:EF:8C": "Dahua", "A0:BD:1D": "Dahua",
# Aruba / HPE Networking
"00:0B:86": "Aruba", "24:DE:C6": "Aruba",
# VMware
"00:50:56": "VMware", "00:0C:29": "VMware",
# Linux KVM / VirtualBox
"08:00:27": "VirtualBox",
# ZTE
"00:19:CB": "ZTE", "34:4B:50": "ZTE",
# Honeywell
"00:40:84": "Honeywell",
# Siemens
"00:0E:8C": "Siemens",
# ABB
"00:80:F4": "ABB",
# Schneider Electric
"00:80:F4": "Schneider",
# Sonos
"B8:E9:37": "Sonos", "54:2A:1B": "Sonos",
# Sony
"00:1D:BA": "Sony", "AC:9B:0A": "Sony",
# LG
"00:1E:75": "LG", "C4:36:55": "LG",
# OnePlus / Oppo
"94:65:2D": "OnePlus", "A4:3B:FA": "Oppo",
# Ring / Blink
"CC:9E:A2": "Ring/Amazon",
# Tuya IoT
"D8:1F:12": "Tuya",
# Shelly
"E8:68:E7": "Shelly", "EC:FA:BC": "Shelly/Espressif",
# Nordic Semiconductor
"E7:3E:B6": "Nordic Semi",
# Texas Instruments
"00:12:4B": "TI", "D4:F5:13": "TI",
}
class HpGeoLookup:
"""
IP geolocation with SQLite cache + MAC vendor lookup.
Background thread resolves public IPs via ip-api.com.
"""
def __init__(self, db_path=None):
if db_path is None:
db_path = os.path.join(os.path.dirname(__file__), "honeypot_geo.db")
self._db_path = db_path
self._lock = threading.Lock()
self._queue = queue.Queue(maxsize=500)
self._cache = {} # in-memory hot cache: ip -> dict
self._rate_count = 0
self._rate_reset = time.time() + 60
self._init_db()
self._load_cache()
# Background worker thread
self._worker = threading.Thread(target=self._resolver_worker, daemon=True)
self._worker.start()
def _init_db(self):
conn = sqlite3.connect(self._db_path)
conn.execute("""CREATE TABLE IF NOT EXISTS geo_cache (
ip TEXT PRIMARY KEY,
country TEXT, country_code TEXT, city TEXT, isp TEXT,
lat REAL, lon REAL, is_private INTEGER DEFAULT 0,
cached_at REAL
)""")
conn.commit()
conn.close()
def _load_cache(self):
"""Load recent cache entries into memory."""
try:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
cutoff = time.time() - 86400 * 7 # 7 days
rows = conn.execute(
"SELECT * FROM geo_cache WHERE cached_at > ?", (cutoff,)
).fetchall()
for r in rows:
self._cache[r["ip"]] = dict(r)
conn.close()
except Exception:
pass
def lookup_ip(self, ip):
"""
Look up IP geolocation. Returns cached result or enqueues
for background resolution. Non-blocking.
"""
if not ip or ip == "0.0.0.0":
return {"country": "", "country_code": "", "is_private": True}
# Check memory cache (with 7-day TTL)
with self._lock:
cached = self._cache.get(ip)
if cached:
if time.time() - cached.get("cached_at", 0) < 86400 * 7:
return cached
del self._cache[ip]
# Private IP — instant result
try:
if ipaddress.ip_address(ip).is_private:
result = {
"ip": ip, "country": "LAN", "country_code": "",
"city": "", "isp": "Private Network",
"lat": 0, "lon": 0, "is_private": 1,
"cached_at": time.time(),
}
with self._lock:
self._cache[ip] = result
self._persist(result)
return result
except ValueError:
return {"country": "", "country_code": "", "is_private": False}
# Enqueue for background resolution
try:
self._queue.put_nowait(ip)
except queue.Full:
pass
return {"country": "...", "country_code": "", "is_private": False,
"pending": True}
def lookup_mac_vendor(self, mac):
"""Instant MAC vendor lookup from OUI map."""
if not mac or mac == "00:00:00:00:00:00":
return ""
prefix = mac[:8].upper()
return OUI_MAP.get(prefix, "")
def enrich_attacker(self, attacker):
"""Add geo and vendor info to an attacker dict."""
geo = self.lookup_ip(attacker.get("ip", ""))
attacker["country"] = geo.get("country", "")
attacker["country_code"] = geo.get("country_code", "")
attacker["city"] = geo.get("city", "")
attacker["isp"] = geo.get("isp", "")
attacker["is_private"] = geo.get("is_private", False)
attacker["vendor"] = self.lookup_mac_vendor(attacker.get("mac", ""))
return attacker
def _persist(self, result):
"""Write geo result to SQLite cache."""
try:
conn = sqlite3.connect(self._db_path)
conn.execute(
"""INSERT OR REPLACE INTO geo_cache
(ip, country, country_code, city, isp, lat, lon,
is_private, cached_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
(result["ip"], result.get("country", ""),
result.get("country_code", ""), result.get("city", ""),
result.get("isp", ""), result.get("lat", 0),
result.get("lon", 0), result.get("is_private", 0),
result.get("cached_at", time.time()))
)
conn.commit()
conn.close()
except Exception:
pass
def _resolver_worker(self):
"""Background thread: resolve IPs from queue via ip-api.com."""
while True:
try:
ip = self._queue.get(timeout=5)
except queue.Empty:
continue
# Already cached?
with self._lock:
if ip in self._cache:
continue
# Rate limit: 45 requests per minute
now = time.time()
if now > self._rate_reset:
self._rate_count = 0
self._rate_reset = now + 60
if self._rate_count >= 45:
# Wait until reset
time.sleep(max(0, self._rate_reset - now))
self._rate_count = 0
self._rate_reset = time.time() + 60
try:
url = f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,city,isp,lat,lon"
req = urllib.request.Request(url, headers={"User-Agent": "Espilon-HP/1.0"})
resp = urllib.request.urlopen(req, timeout=5)
data = json.loads(resp.read().decode())
self._rate_count += 1
if data.get("status") == "success":
result = {
"ip": ip,
"country": data.get("country", ""),
"country_code": data.get("countryCode", ""),
"city": data.get("city", ""),
"isp": data.get("isp", ""),
"lat": data.get("lat", 0),
"lon": data.get("lon", 0),
"is_private": 0,
"cached_at": time.time(),
}
with self._lock:
# Cap cache at 50,000 entries
if len(self._cache) >= 50000:
oldest = min(self._cache,
key=lambda k: self._cache[k].get("cached_at", 0))
del self._cache[oldest]
self._cache[ip] = result
self._persist(result)
except Exception:
pass

View File

@ -0,0 +1,844 @@
"""
Honeypot Dashboard Blueprint Flask routes for C3PO integration.
Usage in C3PO server.py:
from hp_dashboard import create_hp_blueprint, HpStore, HpCommander, HpAlertEngine
hp_store = HpStore()
hp_alerts = HpAlertEngine()
hp_commander = HpCommander(
get_transport=lambda: transport,
get_registry=lambda: self.device_registry,
)
app.register_blueprint(create_hp_blueprint({
**base_config,
"hp_store": hp_store,
"hp_commander": hp_commander,
"hp_alerts": hp_alerts,
}))
"""
import os
import re
import time
import json
import hmac
import hashlib
import secrets
import tempfile
from collections import deque
from flask import Blueprint, jsonify, request, render_template, url_for, Response, send_from_directory
from .hp_commander import (HP_SERVICES, HP_MONITORS, SYSTEM_COMMANDS,
ALLOWED_COMMANDS, CONFIG_BANNER_SERVICES,
CONFIG_THRESHOLDS)
from .hp_store import KILL_CHAIN_PHASES
def _clamp(val, lo, hi):
"""Clamp a value between lo and hi."""
return max(lo, min(hi, val))
def _sanitize_str(val, max_len=256, default=""):
"""Strip and length-bound a string input. Returns default if not a string."""
if not isinstance(val, str):
return default
return val.strip()[:max_len]
def create_hp_blueprint(server_config):
"""
Create the honeypot dashboard blueprint.
server_config keys:
- hp_store: HpStore instance
- hp_commander: HpCommander instance
- hp_alerts: HpAlertEngine instance (optional)
- require_login (optional): auth decorator for HTML pages
"""
bp = Blueprint("honeypot", __name__,
static_folder="static",
static_url_path="/hp-static")
store = server_config["hp_store"]
commander = server_config["hp_commander"]
alerts = server_config.get("hp_alerts")
geo = server_config.get("hp_geo") # Optional HpGeoLookup
require_login = server_config.get("require_login", lambda f: f)
# Detect C3PO mode (base_config includes c2_root when running inside C3PO)
c3po_nav = "c2_root" in server_config
# Wire alert engine to store for kill chain analysis
if alerts and hasattr(alerts, "set_store"):
alerts.set_store(store)
# Endpoints exempt from auth (called by devices, not browsers)
_PUBLIC_ENDPOINTS = frozenset({
"api_hp_ota_firmware_download", "api_hp_health", "api_hp_csrf_token",
})
# ------------------------------------------------------------------
# CSRF protection — token-based for all state-changing requests
# ------------------------------------------------------------------
_csrf_secret = secrets.token_bytes(32)
def _csrf_generate():
"""Generate a signed CSRF token."""
nonce = secrets.token_hex(16)
sig = hmac.new(_csrf_secret, nonce.encode(), hashlib.sha256).hexdigest()[:32]
return f"{nonce}.{sig}"
def _csrf_validate(token):
"""Validate a CSRF token signature."""
if not token or "." not in token:
return False
nonce, sig = token.rsplit(".", 1)
expected = hmac.new(_csrf_secret, nonce.encode(), hashlib.sha256).hexdigest()[:32]
return hmac.compare_digest(sig, expected)
@bp.before_request
def _csrf_check():
"""Enforce CSRF token on state-changing methods."""
if request.method in ("GET", "HEAD", "OPTIONS"):
return None
# Public endpoints skip CSRF (device-to-server)
if request.endpoint in _PUBLIC_ENDPOINTS:
return None
# File uploads use multipart — accept token from form field or header
token = (request.headers.get("X-CSRF-Token")
or (request.form.get("_csrf_token") if request.form else None))
if not _csrf_validate(token):
return jsonify({"error": "CSRF token missing or invalid"}), 403
@bp.route("/api/honeypot/csrf-token")
def api_hp_csrf_token():
"""Get a CSRF token for subsequent POST requests."""
return jsonify({"token": _csrf_generate()})
# ============================================================
# READ-ONLY API (preserved from v1)
# ============================================================
@bp.route("/api/honeypot/events")
def api_hp_events():
"""Get recent honeypot events."""
limit = _clamp(request.args.get("limit", 100, type=int), 1, 1000)
event_type = request.args.get("type", None)
severity = request.args.get("severity", None)
src_ip = request.args.get("ip", None)
service = request.args.get("service", None)
offset = max(0, request.args.get("offset", 0, type=int))
events = store.get_recent_events(
limit=limit, event_type=event_type, severity=severity,
src_ip=src_ip, service=service, offset=offset,
)
return jsonify({"events": events, "count": len(events)})
@bp.route("/api/honeypot/stats")
def api_hp_stats():
"""Get aggregated honeypot statistics."""
stats = store.get_stats()
if alerts:
stats["alert_count"] = alerts.get_unacknowledged_count()
return jsonify(stats)
@bp.route("/api/honeypot/attackers")
def api_hp_attackers():
"""Get top attackers."""
limit = _clamp(request.args.get("limit", 50, type=int), 1, 1000)
attackers = store.get_attackers(limit=limit)
return jsonify({"attackers": attackers, "count": len(attackers)})
# ============================================================
# NEW: Event Detail + Attacker Profile
# ============================================================
@bp.route("/api/honeypot/events/<int:event_id>")
def api_hp_event_detail(event_id):
"""Get single event with related events."""
evt = store.get_event_by_id(event_id)
if not evt:
return jsonify({"error": "Not found"}), 404
return jsonify(evt)
@bp.route("/api/honeypot/attacker/<ip>")
def api_hp_attacker_profile(ip):
"""Full attacker profile."""
profile = store.get_attacker_profile(ip)
return jsonify(profile)
# ============================================================
# NEW: Sessions
# ============================================================
@bp.route("/api/honeypot/sessions")
def api_hp_sessions():
"""Get grouped sessions."""
limit = _clamp(request.args.get("limit", 50, type=int), 1, 1000)
src_ip = request.args.get("ip", None)
sessions = store.get_sessions(limit=limit, src_ip=src_ip)
return jsonify({"sessions": sessions, "count": len(sessions)})
@bp.route("/api/honeypot/sessions/<session_id>")
def api_hp_session_events(session_id):
"""Get all events for a session."""
events = store.get_session_events(session_id)
return jsonify({"events": events, "count": len(events)})
# ============================================================
# NEW: Search + Timeline + Export
# ============================================================
@bp.route("/api/honeypot/search")
def api_hp_search():
"""Multi-criteria search."""
q_raw = request.args.get("q")
q_clean = _sanitize_str(q_raw, max_len=200) if q_raw else None
events = store.search(
q=q_clean,
event_type=_sanitize_str(request.args.get("type"), max_len=64),
severity=_sanitize_str(request.args.get("severity"), max_len=16),
src_ip=_sanitize_str(request.args.get("ip"), max_len=45),
service=_sanitize_str(request.args.get("service"), max_len=32),
from_ts=request.args.get("from", type=float),
to_ts=request.args.get("to", type=float),
limit=_clamp(request.args.get("limit", 100, type=int), 1, 1000),
offset=_clamp(request.args.get("offset", 0, type=int), 0, 100000),
)
return jsonify({"events": events, "count": len(events)})
@bp.route("/api/honeypot/timeline")
def api_hp_timeline():
"""Event counts per time bucket by severity."""
hours = _clamp(request.args.get("hours", 24, type=int), 1, 168)
bucket = _clamp(request.args.get("bucket", 5, type=int), 1, 60)
data = store.get_timeline(hours=hours, bucket_minutes=bucket)
return jsonify({"timeline": data})
@bp.route("/api/honeypot/export")
def api_hp_export():
"""Export events as CSV or JSON."""
fmt = request.args.get("format", "json")
content = store.export_events(
fmt=fmt,
q=request.args.get("q"),
event_type=request.args.get("type"),
severity=request.args.get("severity"),
src_ip=request.args.get("ip"),
service=request.args.get("service"),
from_ts=request.args.get("from", type=float),
to_ts=request.args.get("to", type=float),
)
if fmt == "csv":
return Response(
content,
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=honeypot_events.csv"},
)
return Response(content, mimetype="application/json")
# ============================================================
# NEW: SSE Event Stream
# ============================================================
@bp.route("/api/honeypot/stream")
def api_hp_stream():
"""Server-Sent Events stream for real-time updates."""
_sev_map = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
min_sev_name = request.args.get("min_severity", "MEDIUM").upper()
min_sev = _sev_map.get(min_sev_name, 1)
def generate():
q = deque(maxlen=100)
store.register_sse_queue(q)
try:
# Send initial keepalive
yield "data: {\"type\":\"connected\"}\n\n"
while True:
if q:
evt = q.popleft()
evt_sev = _sev_map.get((evt.get("severity") or "LOW").upper(), 0)
if evt_sev < min_sev:
continue
yield f"data: {json.dumps(evt, default=str)}\n\n"
else:
# Keepalive every 15s
yield ": keepalive\n\n"
# Use a short sleep to avoid busy-wait
import time as _time
_time.sleep(0.5)
except GeneratorExit:
pass
finally:
store.unregister_sse_queue(q)
return Response(
generate(),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
# ============================================================
# NEW: Alert Rules & Active Alerts
# ============================================================
@bp.route("/api/honeypot/alerts/rules")
def api_hp_alert_rules():
"""List alert rules."""
if not alerts:
return jsonify({"rules": []})
return jsonify({"rules": alerts.get_rules()})
@bp.route("/api/honeypot/alerts/rules", methods=["POST"])
def api_hp_add_alert_rule():
"""Add a custom alert rule."""
if not alerts:
return jsonify({"error": "Alert engine not configured"}), 500
data = request.get_json(silent=True) or {}
# Sanitize string fields in rule data
for field in ("name", "description", "event_type", "severity",
"src_ip", "service"):
if field in data and isinstance(data[field], str):
data[field] = _sanitize_str(data[field], max_len=256)
rule = alerts.add_rule(data)
return jsonify({"ok": True, "rule": rule})
@bp.route("/api/honeypot/alerts/rules/<rule_id>", methods=["DELETE"])
def api_hp_delete_alert_rule(rule_id):
"""Delete an alert rule."""
if not alerts:
return jsonify({"error": "Alert engine not configured"}), 500
ok = alerts.delete_rule(rule_id)
if ok:
return jsonify({"ok": True})
return jsonify({"error": "Rule not found"}), 404
@bp.route("/api/honeypot/alerts/active")
def api_hp_active_alerts():
"""Get active alerts."""
if not alerts:
return jsonify({"alerts": []})
return jsonify({"alerts": alerts.get_active_alerts()})
@bp.route("/api/honeypot/alerts/ack/<int:alert_id>", methods=["POST"])
def api_hp_ack_alert(alert_id):
"""Acknowledge an alert."""
if not alerts:
return jsonify({"error": "Alert engine not configured"}), 500
ok = alerts.acknowledge_alert(alert_id)
if ok:
return jsonify({"ok": True})
return jsonify({"error": "Alert not found"}), 404
# ============================================================
# NEW: Credentials, MITRE, Webhooks
# ============================================================
@bp.route("/api/honeypot/credentials")
def api_hp_credentials():
if not store:
return jsonify({"error": "No store"}), 500
return jsonify(store.get_credential_intel())
@bp.route("/api/honeypot/mitre")
def api_hp_mitre():
if not store:
return jsonify({"error": "No store"}), 500
return jsonify(store.get_mitre_coverage())
@bp.route("/api/honeypot/webhooks")
def api_hp_webhooks_get():
if not alerts:
return jsonify({"webhooks": []})
return jsonify({"webhooks": alerts.get_webhooks()})
@bp.route("/api/honeypot/webhooks", methods=["POST"])
def api_hp_webhooks_add():
if not alerts:
return jsonify({"error": "No alert engine"}), 500
data = request.get_json(silent=True) or {}
url = _sanitize_str(data.get("url"), max_len=512)
if not url:
return jsonify({"error": "URL required"}), 400
name = _sanitize_str(data.get("name"), max_len=128)
wh = alerts.add_webhook(url, name)
if "error" in wh:
return jsonify(wh), 400
return jsonify({"ok": True, "webhook": wh})
@bp.route("/api/honeypot/webhooks/<int:wh_id>", methods=["DELETE"])
def api_hp_webhooks_delete(wh_id):
if not alerts:
return jsonify({"error": "No alert engine"}), 500
ok = alerts.remove_webhook(wh_id)
return jsonify({"ok": ok})
# ============================================================
# Device & Service State APIs (preserved from v1)
# ============================================================
@bp.route("/api/honeypot/devices")
def api_hp_devices():
"""List connected honeypot devices."""
devices = commander.get_honeypot_devices()
return jsonify({"devices": devices, "count": len(devices)})
@bp.route("/api/honeypot/services")
def api_hp_services():
"""Get cached service states for all devices."""
device_id = request.args.get("device_id", None)
states = commander.get_service_states(device_id)
return jsonify({
"services": states,
"definitions": {
"services": {k: {"port": v["port"]} for k, v in HP_SERVICES.items()},
"monitors": list(HP_MONITORS.keys()),
},
})
@bp.route("/api/honeypot/history")
def api_hp_history():
"""Get command history."""
limit = _clamp(request.args.get("limit", 50, type=int), 1, 500)
history = commander.get_command_history(limit=limit)
return jsonify({"history": history, "count": len(history)})
# ============================================================
# Command Dispatch APIs (POST) — preserved from v1
# ============================================================
@bp.route("/api/honeypot/command", methods=["POST"])
def api_hp_send_command():
"""Send a single command."""
data = request.get_json(silent=True) or {}
device_id = _sanitize_str(data.get("device_id"), max_len=64)
command_name = _sanitize_str(data.get("command"), max_len=64)
argv = data.get("argv", [])
if isinstance(argv, list):
argv = [_sanitize_str(a, max_len=128) for a in argv]
if not device_id or not command_name:
return jsonify({"error": "device_id and command required"}), 400
if command_name not in ALLOWED_COMMANDS:
return jsonify({"error": f"Unknown command: {command_name}"}), 400
rid = commander.send_command(device_id, command_name, argv)
if rid:
return jsonify({"ok": True, "request_id": rid})
return jsonify({"error": "Failed to send (device offline?)"}), 500
@bp.route("/api/honeypot/start_all", methods=["POST"])
def api_hp_start_all():
"""Start all services + monitors via single bulk command."""
data = request.get_json(silent=True) or {}
device_id = _sanitize_str(data.get("device_id"), max_len=64) or None
request_ids = []
if device_id:
rid = commander.send_command(device_id, "hp_start_all")
if rid:
request_ids.append(rid)
else:
rids = commander.send_to_all_devices("hp_start_all")
request_ids.extend(rids)
return jsonify({"ok": True, "request_ids": request_ids, "count": len(request_ids)})
@bp.route("/api/honeypot/stop_all", methods=["POST"])
def api_hp_stop_all():
"""Stop all services + monitors via single bulk command."""
data = request.get_json(silent=True) or {}
device_id = _sanitize_str(data.get("device_id"), max_len=64) or None
request_ids = []
if device_id:
rid = commander.send_command(device_id, "hp_stop_all")
if rid:
request_ids.append(rid)
else:
rids = commander.send_to_all_devices("hp_stop_all")
request_ids.extend(rids)
return jsonify({"ok": True, "request_ids": request_ids, "count": len(request_ids)})
@bp.route("/api/honeypot/refresh_status", methods=["POST"])
def api_hp_refresh_status():
"""Refresh cached service state."""
data = request.get_json(silent=True) or {}
device_id = _sanitize_str(data.get("device_id"), max_len=64) or None
request_ids = []
for svc_name in HP_SERVICES:
if device_id:
rid = commander.send_service_command(device_id, svc_name, "status")
if rid:
request_ids.append(rid)
else:
rids = commander.send_service_to_all(svc_name, "status")
request_ids.extend(rids)
for mon_name in HP_MONITORS:
if device_id:
rid = commander.send_monitor_command(device_id, mon_name, "status")
if rid:
request_ids.append(rid)
else:
rids = commander.send_monitor_to_all(mon_name, "status")
request_ids.extend(rids)
return jsonify({"ok": True, "request_ids": request_ids, "count": len(request_ids)})
# ============================================================
# Kill Chain & Honeytoken APIs
# ============================================================
@bp.route("/api/honeypot/killchain")
def api_hp_killchain_top():
"""Get top attackers by kill chain score."""
limit = _clamp(request.args.get("limit", 20, type=int), 1, 200)
results = store.get_kill_chain_top(limit=limit)
return jsonify({"attackers": results, "phases": KILL_CHAIN_PHASES})
@bp.route("/api/honeypot/killchain/<ip>")
def api_hp_killchain_detail(ip):
"""Get kill chain analysis for a specific IP."""
analysis = store.get_kill_chain_analysis(ip)
return jsonify({**analysis, "phase_defs": KILL_CHAIN_PHASES})
@bp.route("/api/honeypot/honeytokens")
def api_hp_honeytokens():
"""Get honeytoken event statistics."""
return jsonify(store.get_honeytoken_stats())
# ============================================================
# Runtime Config APIs
# ============================================================
@bp.route("/api/honeypot/config/<device_id>")
def api_hp_config_list(device_id):
"""List all runtime config for a device."""
cfg_type = request.args.get("type", "")
rid = commander.config_list(device_id, cfg_type)
if rid:
return jsonify({"ok": True, "request_id": rid})
return jsonify({"error": "Failed to send"}), 500
@bp.route("/api/honeypot/config/<device_id>", methods=["POST"])
def api_hp_config_set(device_id):
"""Set a config value."""
data = request.get_json(silent=True) or {}
cfg_type = _sanitize_str(data.get("type"), max_len=32)
key = _sanitize_str(data.get("key"), max_len=64)
value = _sanitize_str(data.get("value"), max_len=256)
if not cfg_type or not key:
return jsonify({"error": "type and key required"}), 400
# Whitelist cfg_type and key to prevent arbitrary config injection
if cfg_type not in ("banner", "threshold"):
return jsonify({"error": f"Invalid config type: {cfg_type}"}), 400
if cfg_type == "banner" and key not in CONFIG_BANNER_SERVICES:
return jsonify({"error": f"Invalid banner service: {key}"}), 400
if cfg_type == "threshold":
if key not in CONFIG_THRESHOLDS:
return jsonify({"error": f"Invalid threshold key: {key}"}), 400
# Validate threshold value is a positive integer
try:
int_val = int(value)
if int_val <= 0:
raise ValueError
except (ValueError, TypeError):
return jsonify({"error": "Threshold value must be a positive integer"}), 400
rid = commander.config_set(device_id, cfg_type, key, str(value))
if rid:
return jsonify({"ok": True, "request_id": rid})
return jsonify({"error": "Failed to send"}), 500
@bp.route("/api/honeypot/config/<device_id>/reset", methods=["POST"])
def api_hp_config_reset(device_id):
"""Reset all runtime config to defaults."""
rid = commander.config_reset(device_id)
if rid:
return jsonify({"ok": True, "request_id": rid})
return jsonify({"error": "Failed to send"}), 500
@bp.route("/api/honeypot/config/meta")
def api_hp_config_meta():
"""Get config metadata (available banners, thresholds, defaults)."""
return jsonify({
"banner_services": CONFIG_BANNER_SERVICES,
"thresholds": CONFIG_THRESHOLDS,
})
# ============================================================
# OTA Firmware Management
# ============================================================
# Firmware storage directory (sibling to hp_dashboard/)
_fw_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"firmwares")
_SAFE_NAME = re.compile(r'^[\w\-\.]+\.bin$')
_MAX_FW_SIZE = 2 * 1024 * 1024 # 2 MB
@bp.route("/api/honeypot/ota/upload", methods=["POST"])
def api_hp_ota_upload():
"""Upload a firmware .bin file."""
if "file" not in request.files:
return jsonify({"error": "No file part"}), 400
f = request.files["file"]
if not f.filename or not f.filename.endswith(".bin"):
return jsonify({"error": "Only .bin files accepted"}), 400
safe_name = f.filename.replace(" ", "_")
if not _SAFE_NAME.match(safe_name):
return jsonify({"error": "Invalid filename"}), 400
os.makedirs(_fw_dir, exist_ok=True)
# Read into memory to check size before writing
data = f.read()
if len(data) > _MAX_FW_SIZE:
return jsonify({"error": f"File too large ({len(data)} bytes, max {_MAX_FW_SIZE})"}), 400
if len(data) == 0:
return jsonify({"error": "Empty file"}), 400
# Atomic write: write to temp file first, then rename into place.
# This prevents serving a partially-written firmware if a reader
# hits the file mid-upload (or if the process crashes).
path = os.path.join(_fw_dir, safe_name)
fd, tmp_path = tempfile.mkstemp(dir=_fw_dir, suffix=".tmp")
try:
with os.fdopen(fd, "wb") as out:
out.write(data)
os.rename(tmp_path, path)
except BaseException:
# Clean up temp file on any failure
try:
os.unlink(tmp_path)
except OSError:
pass
raise
# Compute and store SHA256 hash for firmware integrity verification
sha256_hex = hashlib.sha256(data).hexdigest()
sha_path = path + ".sha256"
with open(sha_path, "w") as sf:
sf.write(sha256_hex)
return jsonify({"ok": True, "filename": safe_name, "size": len(data),
"sha256": sha256_hex})
@bp.route("/api/honeypot/ota/firmwares")
def api_hp_ota_firmwares():
"""List available firmware files."""
if not os.path.isdir(_fw_dir):
return jsonify({"firmwares": []})
firmwares = []
for name in sorted(os.listdir(_fw_dir)):
if name.endswith(".bin"):
path = os.path.join(_fw_dir, name)
stat = os.stat(path)
fw_entry = {
"name": name,
"size": stat.st_size,
"uploaded_at": stat.st_mtime,
}
sha_path = path + ".sha256"
if os.path.isfile(sha_path):
try:
with open(sha_path) as sf:
fw_entry["sha256"] = sf.read().strip()
except OSError:
pass
firmwares.append(fw_entry)
return jsonify({"firmwares": firmwares})
@bp.route("/api/honeypot/ota/firmware/<filename>")
@bp.route("/fw/<filename>")
def api_hp_ota_firmware_download(filename):
"""Serve a firmware binary (called by ESP32 during OTA)."""
if not _SAFE_NAME.match(filename):
return jsonify({"error": "Invalid filename"}), 400
if not os.path.isdir(_fw_dir):
return jsonify({"error": "No firmwares directory"}), 404
path = os.path.join(_fw_dir, filename)
if not os.path.isfile(path):
return jsonify({"error": "Firmware not found"}), 404
return send_from_directory(_fw_dir, filename,
mimetype="application/octet-stream")
@bp.route("/api/honeypot/ota/firmware/<filename>", methods=["DELETE"])
def api_hp_ota_firmware_delete(filename):
"""Delete a firmware file."""
if not _SAFE_NAME.match(filename):
return jsonify({"error": "Invalid filename"}), 400
path = os.path.join(_fw_dir, filename)
if not os.path.isfile(path):
return jsonify({"error": "Firmware not found"}), 404
os.remove(path)
sha_path = path + ".sha256"
if os.path.isfile(sha_path):
os.remove(sha_path)
return jsonify({"ok": True})
@bp.route("/api/honeypot/ota/flash", methods=["POST"])
def api_hp_ota_flash():
"""Trigger OTA update on a device."""
data = request.get_json(silent=True) or {}
device_id = _sanitize_str(data.get("device_id"), max_len=64)
filename = _sanitize_str(data.get("filename"), max_len=128)
if not device_id or not filename:
return jsonify({"error": "device_id and filename required"}), 400
if not _SAFE_NAME.match(filename):
return jsonify({"error": "Invalid filename"}), 400
path = os.path.join(_fw_dir, filename)
if not os.path.isfile(path):
return jsonify({"error": "Firmware not found"}), 404
# Build a short download URL (argv max 64 bytes in nanopb).
# Use the real server IP so ESP32 can reach it (not "localhost").
import socket as _sock
host = request.host
hostname = host.split(":")[0]
port = host.split(":")[1] if ":" in host else "5000"
if hostname in ("localhost", "127.0.0.1"):
try:
s = _sock.socket(_sock.AF_INET, _sock.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
hostname = s.getsockname()[0]
s.close()
except Exception:
pass
# HP_OTA_HTTPS: Set to "false" to use http:// instead of https://
# Defaults to "true" for secure firmware downloads
use_https = os.environ.get("HP_OTA_HTTPS", "true").lower() == "true"
scheme = "https" if use_https else "http"
fw_url = f"{scheme}://{hostname}:{port}/fw/{filename}"
# Enforce nanopb argv limit (64 bytes max per argument)
if len(fw_url.encode("utf-8")) > 64:
return jsonify({"error": f"Firmware URL too long ({len(fw_url)} chars, max 64)"}), 400
# Read or compute SHA256 hash for firmware integrity verification
sha_path = path + ".sha256"
if os.path.isfile(sha_path):
with open(sha_path) as sf:
sha256_hex = sf.read().strip()
else:
with open(path, "rb") as bf:
sha256_hex = hashlib.sha256(bf.read()).hexdigest()
rid = commander.send_command(device_id, "ota_update", [fw_url, sha256_hex])
if rid:
return jsonify({"ok": True, "request_id": rid, "url": fw_url})
return jsonify({"error": "Failed to send (device offline?)"}), 500
@bp.route("/api/honeypot/ota/rollback", methods=["POST"])
def api_hp_ota_rollback():
"""Trigger firmware rollback on a device."""
data = request.get_json(silent=True) or {}
device_id = _sanitize_str(data.get("device_id"), max_len=64)
if not device_id:
return jsonify({"error": "device_id required"}), 400
rid = commander.send_command(device_id, "ota_rollback")
if rid:
return jsonify({"ok": True, "request_id": rid})
return jsonify({"error": "Failed to send (device offline?)"}), 500
# ============================================================
# Geo-IP API
# ============================================================
@bp.route("/api/honeypot/geo/<ip>")
def api_hp_geo(ip):
"""Get geo-IP and vendor info for an IP address."""
if not geo:
return jsonify({"error": "Geo lookup not configured"}), 501
result = geo.lookup_ip(ip)
# Also look up vendor if we have a MAC for this IP
with store.lock:
atk = store.attackers.get(ip, {})
result["vendor"] = geo.lookup_mac_vendor(atk.get("mac", ""))
return jsonify(result)
# ============================================================
# Health / Monitoring
# ============================================================
_bp_start_time = time.time()
@bp.route("/api/honeypot/health")
def api_hp_health():
"""Health check endpoint for monitoring (Prometheus, Uptime Kuma, etc.)."""
stats = store.get_stats()
now = time.time()
# Events in the last hour
one_hour_ago = now - 3600
try:
conn = store._get_conn()
row = conn.execute(
"SELECT COUNT(*) as cnt FROM events WHERE timestamp >= ?",
(one_hour_ago,)
).fetchone()
events_last_hour = row["cnt"] if row else 0
except Exception:
events_last_hour = 0
# DB file size
db_size_mb = 0
try:
db_size_mb = round(os.path.getsize(store.db_path) / (1024 * 1024), 2)
except OSError:
pass
# SSE client count
with store.lock:
sse_clients = len(store._sse_queues)
# Device count
devices_connected = 0
try:
registry = commander._get_registry()
if registry:
devices_connected = len(registry.all())
except Exception:
pass
return jsonify({
"status": "ok",
"uptime_s": round(now - _bp_start_time),
"devices_connected": devices_connected,
"events_total": stats.get("total_events", 0),
"events_last_hour": events_last_hour,
"alerts_unacked": alerts.get_unacknowledged_count() if alerts else 0,
"db_size_mb": db_size_mb,
"sse_clients": sse_clients,
})
# ============================================================
# Dashboard HTML
# ============================================================
@bp.route("/honeypot")
def hp_dashboard():
"""Serve the honeypot management dashboard."""
static_url = url_for("honeypot.static", filename="")
return render_template("honeypot.html", static_url=static_url,
active_page="honeypot")
# ============================================================
# Apply require_login to all API routes (except public ones)
# ============================================================
for endpoint, view_func in list(bp.view_functions.items()):
if endpoint not in _PUBLIC_ENDPOINTS:
bp.view_functions[endpoint] = require_login(view_func)
return bp

View File

@ -0,0 +1,944 @@
"""
Honeypot Event Store SQLite-backed storage with structured field parsing.
Stores all honeypot events in a SQLite database for complex queries,
while maintaining in-memory counters for fast dashboard polling.
"""
import re
import time
import hashlib
import sqlite3
import threading
import json
from collections import defaultdict, OrderedDict
from typing import Optional
# ============================================================
# Field extraction regexes — parse structured data from detail
# ============================================================
RE_USER = re.compile(r"user='([^']*)'")
RE_PASS = re.compile(r"pass='([^']*)'")
RE_CMD = re.compile(r"cmd='([^']*)'")
RE_URL = re.compile(r"url='([^']*)'")
RE_FILE = re.compile(r"file='([^']*)'")
RE_CLIENT = re.compile(r"client='([^']*)'")
RE_COMMUNITY = re.compile(r"community='([^']*)'")
RE_OS_TAG = re.compile(r"\[(\w+)\]\s*$")
RE_MALWARE_TAG = re.compile(r"\[(Mirai|Botnet-generic|RCE-pipe|Obfuscation|Reverse-shell|"
r"RCE-python|RCE-perl|Destructive|Mirai-variant|HONEYTOKEN)\]")
PORT_TO_SERVICE = {
22: "ssh", 23: "telnet", 80: "http", 1883: "mqtt", 21: "ftp",
53: "dns", 161: "snmp", 69: "tftp", 5683: "coap", 6379: "redis",
554: "rtsp", 3306: "mysql", 502: "modbus", 1900: "upnp",
5060: "sip", 2323: "telnet", 23231: "telnet",
}
VALID_SEVERITIES = {"LOW", "MEDIUM", "HIGH", "CRITICAL"}
# MITRE ATT&CK technique mapping
MITRE_MAP = {
"WIFI_DEAUTH": [("T1498.001", "Direct Network Flood", "Impact")],
"WIFI_PROBE": [("T1595.001", "Scanning IP Blocks", "Reconnaissance")],
"WIFI_EVIL_TWIN": [("T1557.002", "ARP Cache Poisoning", "Credential Access")],
"WIFI_BEACON_FLOOD": [("T1498.001", "Direct Network Flood", "Impact")],
"WIFI_EAPOL": [("T1557", "Adversary-in-the-Middle", "Credential Access")],
"ARP_SPOOF": [("T1557.002", "ARP Cache Poisoning", "Credential Access")],
"ARP_FLOOD": [("T1498.001", "Direct Network Flood", "Impact")],
"PORT_SCAN": [("T1046", "Network Service Discovery", "Discovery")],
"ICMP_SWEEP": [("T1018", "Remote System Discovery", "Discovery")],
"SYN_FLOOD": [("T1498.001", "Direct Network Flood", "Impact")],
"UDP_FLOOD": [("T1498.001", "Direct Network Flood", "Impact")],
"SVC_CONNECT": [("T1021", "Remote Services", "Lateral Movement")],
"SVC_AUTH_ATTEMPT": [("T1110", "Brute Force", "Credential Access")],
"SVC_COMMAND": [("T1059", "Command Interpreter", "Execution")],
"SVC_HTTP_REQUEST": [("T1190", "Exploit Public-Facing App", "Initial Access")],
"SVC_MQTT_MSG": [("T1071.001", "Web Protocols", "Command and Control")],
}
MITRE_DETAIL_MAP = {
"HONEYTOKEN": [("T1083", "File and Directory Discovery", "Discovery"),
("T1005", "Data from Local System", "Collection")],
"DOWNLOAD": [("T1105", "Ingress Tool Transfer", "Command and Control")],
"DNS_TUNNEL": [("T1071.004", "DNS", "Command and Control"),
("T1048.003", "Exfiltration Over Non-C2", "Exfiltration")],
"Mirai": [("T1583.005", "Botnet", "Resource Development")],
"Reverse-shell": [("T1059.004", "Unix Shell", "Execution")],
"RCE-python": [("T1059.006", "Python", "Execution")],
"CONFIG SET": [("T1053", "Scheduled Task/Job", "Persistence")],
"SLAVEOF": [("T1020", "Automated Exfiltration", "Exfiltration")],
"Write": [("T1565.001", "Stored Data Manipulation", "Impact")],
}
VALID_EVENT_TYPES = set(MITRE_MAP.keys()) | {
"HONEYTOKEN", "SVC_FTP_CMD", "SVC_SNMP_QUERY", "SVC_COAP_REQUEST",
"SVC_MODBUS_QUERY", "SVC_UPNP_REQUEST", "SVC_SIP_REGISTER",
"SVC_REDIS_CMD", "SVC_RTSP_REQUEST", "SVC_DNS_QUERY",
}
# ============================================================
# Kill Chain phase definitions and classifier
# ============================================================
KILL_CHAIN_PHASES = [
{"id": "recon", "order": 1, "score": 10, "label": "Reconnaissance"},
{"id": "weaponize", "order": 2, "score": 15, "label": "Weaponization"},
{"id": "delivery", "order": 3, "score": 20, "label": "Delivery"},
{"id": "exploitation", "order": 4, "score": 30, "label": "Exploitation"},
{"id": "installation", "order": 5, "score": 40, "label": "Installation"},
{"id": "c2", "order": 6, "score": 50, "label": "Command & Control"},
{"id": "actions", "order": 7, "score": 60, "label": "Actions on Objectives"},
]
PHASE_SCORES = {p["id"]: p["score"] for p in KILL_CHAIN_PHASES}
PHASE_ORDER = {p["id"]: p["order"] for p in KILL_CHAIN_PHASES}
def _classify_kill_chain_phase(event_type, detail):
"""Classify an event into the highest matching kill chain phase."""
d = (detail or "").upper()
# Phase 7: Actions on Objectives (exfiltration, sabotage)
if "HONEYTOKEN EXFIL" in d or "FTP STOR" in d or "CONFIG SET" in d:
return "actions"
# Phase 6: C2 (command & control channels)
if event_type == "SVC_MQTT_MSG" or "SLAVEOF" in d or "EVAL" in d:
return "c2"
# Phase 5: Installation (malware drop, honeytoken access)
if "HONEYTOKEN" in d or "DOWNLOAD" in d:
return "installation"
# Phase 4: Exploitation (command execution in shell)
if event_type == "SVC_COMMAND":
return "exploitation"
# Phase 3: Delivery (service connection, auth attempts)
if event_type in ("SVC_CONNECT", "SVC_AUTH_ATTEMPT", "SVC_HTTP_REQUEST"):
return "delivery"
# Phase 2: Weaponization (network manipulation)
if event_type in ("ARP_SPOOF", "ARP_FLOOD", "WIFI_EVIL_TWIN", "WIFI_EAPOL"):
return "weaponize"
if "DNS_TUNNEL" in d:
return "weaponize"
# Phase 1: Reconnaissance (scanning, probing)
if event_type in ("WIFI_PROBE", "PORT_SCAN", "ICMP_SWEEP", "SYN_FLOOD", "UDP_FLOOD"):
return "recon"
return None
def _extract_mitre(event_type, detail):
"""Resolve MITRE ATT&CK techniques for an event."""
techniques = list(MITRE_MAP.get(event_type, []))
for keyword, techs in MITRE_DETAIL_MAP.items():
if keyword in detail:
techniques.extend(techs)
seen = set()
result = []
for t in techniques:
if t[0] not in seen:
seen.add(t[0])
result.append({"id": t[0], "name": t[1], "tactic": t[2]})
return result
def _session_id(src_ip: str, dst_port: int, timestamp: float) -> str:
"""Compute session ID: md5(ip + port + 5min_bucket)[:12]."""
bucket = int(timestamp / 300)
key = f"{src_ip}:{dst_port}:{bucket}"
return hashlib.md5(key.encode()).hexdigest()[:12]
def _extract_fields(detail: str) -> dict:
"""Extract structured fields from event detail string."""
fields = {}
m = RE_USER.search(detail)
if m:
fields["username"] = m.group(1)
m = RE_PASS.search(detail)
if m:
fields["password"] = m.group(1)
m = RE_CMD.search(detail)
if m:
fields["command"] = m.group(1)
m = RE_URL.search(detail)
if m:
fields["url"] = m.group(1)
m = RE_FILE.search(detail)
if m:
fields["path"] = m.group(1)
m = RE_CLIENT.search(detail)
if m:
fields["client_id"] = m.group(1)
m = RE_COMMUNITY.search(detail)
if m:
fields["username"] = m.group(1) # community as username
m = RE_MALWARE_TAG.search(detail)
if m:
fields["malware_tag"] = m.group(1)
m = RE_OS_TAG.search(detail)
if m and m.group(1) in ("Linux", "Windows", "macOS", "Cisco"):
fields["os_tag"] = m.group(1)
return fields
class HpStore:
"""
Thread-safe SQLite-backed event store.
Maintains in-memory counters for fast dashboard polling.
"""
def __init__(self, db_path: str = "honeypot_events.db", geo_lookup=None):
self.db_path = db_path
self.lock = threading.Lock()
self._local = threading.local()
self._geo = geo_lookup # Optional HpGeoLookup instance
# In-memory counters for fast polling
self.total_count = 0
self.count_by_type: dict = defaultdict(int)
self.count_by_severity: dict = defaultdict(int)
self.count_by_device: dict = defaultdict(int)
self.attackers: OrderedDict = OrderedDict()
self._last_id = 0
# SSE subscribers
self._sse_queues: list = []
# Alert engine (set via set_alert_engine after bootstrap)
self._alert_engine = None
self._init_db()
def set_alert_engine(self, engine):
"""Wire the alert engine so events are evaluated for alerts."""
self._alert_engine = engine
def _get_conn(self) -> sqlite3.Connection:
"""Thread-local SQLite connection."""
if not hasattr(self._local, "conn") or self._local.conn is None:
self._local.conn = sqlite3.connect(self.db_path)
self._local.conn.row_factory = sqlite3.Row
self._local.conn.execute("PRAGMA journal_mode=WAL")
self._local.conn.execute("PRAGMA synchronous=NORMAL")
return self._local.conn
def _init_db(self):
"""Create tables and indexes, with migrations for existing DBs."""
conn = self._get_conn()
# Create table (without mitre_techniques for compat with existing DBs)
conn.execute("""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
device_id TEXT NOT NULL,
event_type TEXT NOT NULL,
severity TEXT NOT NULL,
src_mac TEXT,
src_ip TEXT,
src_port INTEGER,
dst_port INTEGER,
detail TEXT,
received_at REAL,
service TEXT,
username TEXT,
password TEXT,
command TEXT,
url TEXT,
path TEXT,
client_id TEXT,
session_id TEXT,
os_tag TEXT,
malware_tag TEXT
)
""")
# Migration: add mitre_techniques column if missing
cols = {r[1] for r in conn.execute("PRAGMA table_info(events)").fetchall()}
if "mitre_techniques" not in cols:
conn.execute("ALTER TABLE events ADD COLUMN mitre_techniques TEXT")
# Create indexes (safe now that all columns exist)
conn.executescript("""
CREATE INDEX IF NOT EXISTS idx_ts ON events(timestamp);
CREATE INDEX IF NOT EXISTS idx_type ON events(event_type);
CREATE INDEX IF NOT EXISTS idx_sev ON events(severity);
CREATE INDEX IF NOT EXISTS idx_ip ON events(src_ip);
CREATE INDEX IF NOT EXISTS idx_session ON events(session_id);
CREATE INDEX IF NOT EXISTS idx_service ON events(service);
CREATE INDEX IF NOT EXISTS idx_mitre ON events(mitre_techniques);
""")
conn.commit()
# Load counters from existing data
row = conn.execute("SELECT COUNT(*) as cnt, MAX(id) as max_id FROM events").fetchone()
self.total_count = row["cnt"] or 0
self._last_id = row["max_id"] or 0
for row in conn.execute("SELECT event_type, COUNT(*) as cnt FROM events GROUP BY event_type"):
self.count_by_type[row["event_type"]] = row["cnt"]
for row in conn.execute("SELECT severity, COUNT(*) as cnt FROM events GROUP BY severity"):
self.count_by_severity[row["severity"]] = row["cnt"]
for row in conn.execute("SELECT device_id, COUNT(*) as cnt FROM events GROUP BY device_id"):
self.count_by_device[row["device_id"]] = row["cnt"]
def parse_and_store(self, device_id: str, payload: str) -> Optional[dict]:
"""
Parse EVT wire format and store in SQLite.
Format: EVT|<type>|<severity>|<mac>|<ip>:<port>><dport>|<detail>
Returns event dict or None.
"""
try:
parts = payload.split("|", 5)
if len(parts) < 6 or parts[0] not in ("EVT", "HP"):
return None
_, event_type, severity, src_mac, addr_part, detail = parts
# Validate severity to prevent pipe injection
if severity not in VALID_SEVERITIES:
severity = "LOW"
src_ip = "0.0.0.0"
src_port = 0
dst_port = 0
if ">" in addr_part:
left, dst_port_s = addr_part.split(">", 1)
dst_port = int(dst_port_s) if dst_port_s.isdigit() else 0
if ":" in left:
src_ip, src_port_s = left.rsplit(":", 1)
src_port = int(src_port_s) if src_port_s.isdigit() else 0
now = time.time()
fields = _extract_fields(detail)
service = PORT_TO_SERVICE.get(dst_port, "")
session_id = _session_id(src_ip, dst_port, now)
mitre = _extract_mitre(event_type, detail)
mitre_json = json.dumps(mitre) if mitre else None
conn = self._get_conn()
cur = conn.execute(
"""INSERT INTO events (timestamp, device_id, event_type, severity,
src_mac, src_ip, src_port, dst_port, detail, received_at,
service, username, password, command, url, path,
client_id, session_id, os_tag, malware_tag, mitre_techniques)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(now, device_id, event_type, severity,
src_mac, src_ip, src_port, dst_port, detail, now,
service, fields.get("username"), fields.get("password"),
fields.get("command"), fields.get("url"), fields.get("path"),
fields.get("client_id"), session_id,
fields.get("os_tag"), fields.get("malware_tag"), mitre_json)
)
conn.commit()
event_id = cur.lastrowid
with self.lock:
self.total_count += 1
self._last_id = event_id
self.count_by_type[event_type] += 1
self.count_by_severity[severity] += 1
self.count_by_device[device_id] += 1
if src_ip != "0.0.0.0":
if src_ip not in self.attackers:
# Evict oldest (LRU) if at capacity — O(1)
if len(self.attackers) >= 10000:
self.attackers.popitem(last=False)
self.attackers[src_ip] = {
"ip": src_ip, "mac": src_mac, "types": set(),
"count": 0, "first_seen": now, "last_seen": now,
}
# Move to end on each access so LRU is always at front
self.attackers.move_to_end(src_ip)
atk = self.attackers[src_ip]
atk["types"].add(event_type)
atk["count"] += 1
atk["last_seen"] = now
if src_mac != "00:00:00:00:00:00":
atk["mac"] = src_mac
evt_dict = {
"id": event_id, "timestamp": now, "device_id": device_id,
"event_type": event_type, "severity": severity,
"src_mac": src_mac, "src_ip": src_ip, "src_port": src_port,
"dst_port": dst_port, "detail": detail, "received_at": now,
"service": service, "session_id": session_id,
**fields,
}
# Trigger background geo resolution (non-blocking)
if self._geo and src_ip != "0.0.0.0":
self._geo.lookup_ip(src_ip)
# Notify SSE subscribers (snapshot under lock)
with self.lock:
sse_snapshot = list(self._sse_queues)
for q in sse_snapshot:
try:
q.append(evt_dict)
except Exception:
pass
# Evaluate alert rules
if self._alert_engine:
try:
self._alert_engine.evaluate(evt_dict)
except Exception as e:
print(f"[HP_STORE] Alert evaluation error: {e}")
return evt_dict
except Exception as e:
print(f"[HP_STORE] Failed to parse event: {e} — payload={payload!r}")
return None
# ============================================================
# Query APIs
# ============================================================
def get_recent_events(self, limit: int = 100, event_type: str = None,
severity: str = None, src_ip: str = None,
service: str = None, offset: int = 0) -> list:
"""Get recent events with optional filters."""
conn = self._get_conn()
conditions = []
params = []
if event_type:
conditions.append("event_type = ?")
params.append(event_type)
if severity:
conditions.append("severity = ?")
params.append(severity)
if src_ip:
conditions.append("src_ip = ?")
params.append(src_ip)
if service:
conditions.append("service = ?")
params.append(service)
where = " WHERE " + " AND ".join(conditions) if conditions else ""
sql = f"SELECT * FROM events{where} ORDER BY id DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows]
def get_event_by_id(self, event_id: int) -> Optional[dict]:
"""Get single event with related events (same IP +-5min)."""
conn = self._get_conn()
row = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
if not row:
return None
evt = dict(row)
# Related events: same IP, +-5 minutes
related = conn.execute(
"""SELECT * FROM events WHERE src_ip = ? AND id != ?
AND timestamp BETWEEN ? AND ? ORDER BY timestamp LIMIT 20""",
(evt["src_ip"], event_id,
evt["timestamp"] - 300, evt["timestamp"] + 300)
).fetchall()
evt["related"] = [dict(r) for r in related]
return evt
def get_attacker_profile(self, ip: str) -> dict:
"""Full attacker profile: events, credentials, commands, sessions."""
conn = self._get_conn()
events = conn.execute(
"SELECT * FROM events WHERE src_ip = ? ORDER BY timestamp DESC LIMIT 200",
(ip,)
).fetchall()
creds = conn.execute(
"""SELECT username, password, service, timestamp FROM events
WHERE src_ip = ? AND username IS NOT NULL ORDER BY timestamp""",
(ip,)
).fetchall()
commands = conn.execute(
"""SELECT command, detail, service, timestamp FROM events
WHERE src_ip = ? AND command IS NOT NULL ORDER BY timestamp""",
(ip,)
).fetchall()
sessions = conn.execute(
"""SELECT session_id, service, MIN(timestamp) as start,
MAX(timestamp) as end, COUNT(*) as event_count,
MAX(CASE severity WHEN 'CRITICAL' THEN 4 WHEN 'HIGH' THEN 3
WHEN 'MEDIUM' THEN 2 ELSE 1 END) as max_sev
FROM events WHERE src_ip = ? GROUP BY session_id
ORDER BY start DESC""",
(ip,)
).fetchall()
profile = {
"ip": ip,
"total_events": len(events),
"events": [dict(r) for r in events[:50]],
"credentials": [dict(r) for r in creds],
"commands": [dict(r) for r in commands],
"sessions": [dict(r) for r in sessions],
}
if self._geo:
geo = self._geo.lookup_ip(ip)
profile["country"] = geo.get("country", "")
profile["country_code"] = geo.get("country_code", "")
profile["city"] = geo.get("city", "")
profile["isp"] = geo.get("isp", "")
profile["is_private"] = geo.get("is_private", False)
# Vendor from first known MAC for this IP
with self.lock:
atk = self.attackers.get(ip, {})
profile["vendor"] = self._geo.lookup_mac_vendor(atk.get("mac", ""))
return profile
def get_sessions(self, limit: int = 50, src_ip: str = None) -> list:
"""Get grouped sessions."""
conn = self._get_conn()
conditions = []
params = []
if src_ip:
conditions.append("src_ip = ?")
params.append(src_ip)
where = " WHERE " + " AND ".join(conditions) if conditions else ""
sql = f"""SELECT session_id, src_ip, service, dst_port,
MIN(timestamp) as start_time, MAX(timestamp) as end_time,
COUNT(*) as event_count,
MAX(CASE severity WHEN 'CRITICAL' THEN 4 WHEN 'HIGH' THEN 3
WHEN 'MEDIUM' THEN 2 ELSE 1 END) as max_severity,
SUM(CASE WHEN username IS NOT NULL THEN 1 ELSE 0 END) as auth_count,
SUM(CASE WHEN command IS NOT NULL THEN 1 ELSE 0 END) as cmd_count,
SUM(CASE WHEN malware_tag IS NOT NULL THEN 1 ELSE 0 END) as malware_count
FROM events{where}
GROUP BY session_id
ORDER BY start_time DESC LIMIT ?"""
params.append(limit)
rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows]
def get_session_events(self, session_id: str) -> list:
"""Get all events for a session."""
conn = self._get_conn()
rows = conn.execute(
"SELECT * FROM events WHERE session_id = ? ORDER BY timestamp",
(session_id,)
).fetchall()
return [dict(r) for r in rows]
@staticmethod
def _escape_like(s):
"""Escape LIKE wildcards to prevent DoS via crafted search queries."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def search(self, q: str = None, event_type: str = None,
severity: str = None, src_ip: str = None,
service: str = None, from_ts: float = None,
to_ts: float = None, limit: int = 100,
offset: int = 0) -> list:
"""Multi-criteria search."""
conn = self._get_conn()
conditions = []
params = []
if q:
conditions.append("(detail LIKE ? ESCAPE '\\' "
"OR username LIKE ? ESCAPE '\\' "
"OR command LIKE ? ESCAPE '\\')")
escaped = self._escape_like(q)
like = f"%{escaped}%"
params.extend([like, like, like])
if event_type:
conditions.append("event_type = ?")
params.append(event_type)
if severity:
conditions.append("severity = ?")
params.append(severity)
if src_ip:
conditions.append("src_ip = ?")
params.append(src_ip)
if service:
conditions.append("service = ?")
params.append(service)
if from_ts:
conditions.append("timestamp >= ?")
params.append(from_ts)
if to_ts:
conditions.append("timestamp <= ?")
params.append(to_ts)
where = " WHERE " + " AND ".join(conditions) if conditions else ""
sql = f"SELECT * FROM events{where} ORDER BY timestamp DESC LIMIT ? OFFSET ?"
params.append(limit)
params.append(offset)
rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows]
def get_timeline(self, hours: int = 24, bucket_minutes: int = 5) -> list:
"""Get event counts per time bucket, grouped by severity."""
conn = self._get_conn()
since = time.time() - (hours * 3600)
bucket_s = bucket_minutes * 60
rows = conn.execute(
"""SELECT CAST(timestamp / ? AS INTEGER) * ? as bucket,
severity, COUNT(*) as cnt
FROM events WHERE timestamp >= ?
GROUP BY bucket, severity ORDER BY bucket""",
(bucket_s, bucket_s, since)
).fetchall()
# Group by bucket
buckets = {}
for r in rows:
b = r["bucket"]
if b not in buckets:
buckets[b] = {"time": b, "LOW": 0, "MEDIUM": 0, "HIGH": 0, "CRITICAL": 0, "total": 0}
buckets[b][r["severity"]] = r["cnt"]
buckets[b]["total"] += r["cnt"]
return sorted(buckets.values(), key=lambda x: x["time"])
def get_stats(self) -> dict:
"""Get aggregated statistics (from memory for speed)."""
with self.lock:
return {
"total_events": self.total_count,
"by_type": dict(self.count_by_type),
"by_severity": dict(self.count_by_severity),
"by_device": dict(self.count_by_device),
"last_id": self._last_id,
}
def get_attackers(self, limit: int = 50) -> list:
"""Get top attackers sorted by event count, enriched with geo/vendor."""
with self.lock:
attackers = []
for ip, data in self.attackers.items():
attackers.append({
"ip": data["ip"], "mac": data["mac"],
"types": list(data["types"]),
"count": data["count"],
"first_seen": data["first_seen"],
"last_seen": data["last_seen"],
})
attackers.sort(key=lambda x: x["count"], reverse=True)
result = attackers[:limit]
if self._geo:
for atk in result:
self._geo.enrich_attacker(atk)
return result
def get_events_after(self, last_id: int, limit: int = 50) -> list:
"""Get events after a given ID (for SSE polling)."""
conn = self._get_conn()
rows = conn.execute(
"SELECT * FROM events WHERE id > ? ORDER BY id LIMIT ?",
(last_id, limit)
).fetchall()
return [dict(r) for r in rows]
def get_last_id(self) -> int:
"""Get last event ID."""
with self.lock:
return self._last_id
@staticmethod
def _csv_safe(val):
"""Escape a CSV cell to prevent formula injection in spreadsheet apps."""
s = str(val).replace(",", ";")
if s and s[0] in ("=", "+", "-", "@", "\t", "\r"):
s = "'" + s
return s
def export_events(self, fmt: str = "json", **filters) -> str:
"""Export events as JSON or CSV."""
events = self.search(**filters, limit=10000)
if fmt == "csv":
if not events:
return ""
cols = list(events[0].keys())
lines = [",".join(cols)]
for e in events:
lines.append(",".join(self._csv_safe(e.get(c, "")) for c in cols))
return "\n".join(lines)
return json.dumps(events, default=str)
def get_credential_intel(self):
"""Get credential intelligence summary."""
conn = self._get_conn()
top_usernames = conn.execute(
"SELECT username, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT service) as services "
"FROM events WHERE username IS NOT NULL "
"GROUP BY username ORDER BY cnt DESC LIMIT 20"
).fetchall()
top_passwords = conn.execute(
"SELECT password, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT service) as services "
"FROM events WHERE password IS NOT NULL "
"GROUP BY password ORDER BY cnt DESC LIMIT 20"
).fetchall()
top_combos = conn.execute(
"SELECT username, password, COUNT(*) as cnt, service "
"FROM events WHERE username IS NOT NULL AND password IS NOT NULL "
"GROUP BY username, password ORDER BY cnt DESC LIMIT 20"
).fetchall()
by_service = conn.execute(
"SELECT service, COUNT(DISTINCT username) as unique_users, "
"COUNT(*) as total_attempts "
"FROM events WHERE username IS NOT NULL AND service IS NOT NULL "
"GROUP BY service ORDER BY total_attempts DESC"
).fetchall()
return {
"top_usernames": [dict(r) for r in top_usernames],
"top_passwords": [dict(r) for r in top_passwords],
"top_combos": [dict(r) for r in top_combos],
"by_service": [dict(r) for r in by_service],
}
def get_mitre_coverage(self):
"""Get MITRE ATT&CK coverage summary."""
conn = self._get_conn()
rows = conn.execute(
"SELECT mitre_techniques FROM events WHERE mitre_techniques IS NOT NULL"
).fetchall()
techniques = {}
tactics = {}
for r in rows:
try:
for t in json.loads(r["mitre_techniques"]):
tid = t["id"]
tactic = t["tactic"]
techniques[tid] = techniques.get(tid, 0) + 1
tactics[tactic] = tactics.get(tactic, 0) + 1
except (json.JSONDecodeError, KeyError):
pass
return {"techniques": techniques, "tactics": tactics}
# ============================================================
# Kill Chain Analysis
# ============================================================
def get_kill_chain_analysis(self, ip):
"""Analyze kill chain progression for a specific IP."""
conn = self._get_conn()
rows = conn.execute(
"SELECT event_type, detail, timestamp, mitre_techniques "
"FROM events WHERE src_ip = ? ORDER BY timestamp",
(ip,)
).fetchall()
if not rows:
return {"ip": ip, "phases": {}, "score": 0, "max_phase": None,
"progression_pct": 0, "duration_seconds": 0,
"is_full_chain": False, "total_events": 0}
phases = {}
for row in rows:
phase = _classify_kill_chain_phase(row["event_type"], row["detail"])
if not phase:
continue
if phase not in phases:
phases[phase] = {
"count": 0, "first_seen": row["timestamp"],
"last_seen": row["timestamp"], "techniques": set()
}
p = phases[phase]
p["count"] += 1
p["last_seen"] = row["timestamp"]
if row["mitre_techniques"]:
try:
for t in json.loads(row["mitre_techniques"]):
p["techniques"].add(t["id"])
except (json.JSONDecodeError, KeyError):
pass
for p in phases.values():
p["techniques"] = list(p["techniques"])
score = sum(PHASE_SCORES.get(pid, 0) for pid in phases)
max_phase = max(phases.keys(), key=lambda x: PHASE_ORDER.get(x, 0)) if phases else None
max_order = PHASE_ORDER.get(max_phase, 0) if max_phase else 0
return {
"ip": ip,
"phases": phases,
"score": score,
"max_phase": max_phase,
"max_phase_order": max_order,
"progression_pct": round(max_order / 7 * 100),
"duration_seconds": round(rows[-1]["timestamp"] - rows[0]["timestamp"]),
"is_full_chain": max_order >= 5,
"total_events": len(rows),
}
def get_kill_chain_top(self, limit=20):
"""Get top attackers by kill chain score (single query, in-memory analysis)."""
conn = self._get_conn()
rows = conn.execute(
"SELECT src_ip, event_type, detail, timestamp, mitre_techniques "
"FROM events WHERE src_ip != '0.0.0.0' ORDER BY src_ip, timestamp"
).fetchall()
ip_events = defaultdict(list)
for r in rows:
ip_events[r["src_ip"]].append(r)
results = []
for ip, events in ip_events.items():
phases = {}
for row in events:
phase = _classify_kill_chain_phase(row["event_type"], row["detail"])
if not phase:
continue
if phase not in phases:
phases[phase] = {"count": 0, "first_seen": row["timestamp"],
"techniques": set()}
phases[phase]["count"] += 1
if row["mitre_techniques"]:
try:
for t in json.loads(row["mitre_techniques"]):
phases[phase]["techniques"].add(t["id"])
except (json.JSONDecodeError, KeyError):
pass
if not phases:
continue
for p in phases.values():
p["techniques"] = list(p["techniques"])
score = sum(PHASE_SCORES.get(pid, 0) for pid in phases)
max_phase = max(phases.keys(), key=lambda x: PHASE_ORDER.get(x, 0))
max_order = PHASE_ORDER.get(max_phase, 0)
results.append({
"ip": ip,
"phases": phases,
"score": score,
"max_phase": max_phase,
"max_phase_order": max_order,
"progression_pct": round(max_order / 7 * 100),
"duration_seconds": round(events[-1]["timestamp"] - events[0]["timestamp"]),
"is_full_chain": max_order >= 5,
"total_events": len(events),
})
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]
# ============================================================
# Honeytoken Stats
# ============================================================
def get_honeytoken_stats(self):
"""Get honeytoken event statistics."""
conn = self._get_conn()
ht_events = conn.execute(
"SELECT * FROM events WHERE detail LIKE '%HONEYTOKEN%' "
"ORDER BY timestamp DESC"
).fetchall()
by_service = defaultdict(int)
by_type = defaultdict(int)
by_ip = defaultdict(lambda: {"count": 0, "services": set()})
for evt in ht_events:
e = dict(evt)
svc = e.get("service") or "unknown"
by_service[svc] += 1
detail = e.get("detail", "")
if "credential" in detail.lower():
by_type["credential"] += 1
elif "exfil" in detail.lower():
by_type["exfil_attempt"] += 1
else:
by_type["file_access"] += 1
ip = e.get("src_ip", "")
if ip:
by_ip[ip]["count"] += 1
by_ip[ip]["services"].add(svc)
top_attackers = sorted(by_ip.items(), key=lambda x: x[1]["count"], reverse=True)[:10]
return {
"total": len(ht_events),
"by_service": dict(by_service),
"by_type": dict(by_type),
"top_attackers": [
{"ip": ip, "count": d["count"], "services": list(d["services"])}
for ip, d in top_attackers
],
"recent": [dict(e) for e in ht_events[:10]],
}
# ============================================================
# Data Retention / Cleanup
# ============================================================
def cleanup_old_events(self, days=30):
"""Delete events older than `days` days. Returns count of deleted rows."""
cutoff = time.time() - (days * 86400)
conn = self._get_conn()
cur = conn.execute("DELETE FROM events WHERE timestamp < ?", (cutoff,))
deleted = cur.rowcount
conn.commit()
if deleted > 0:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
# Rebuild in-memory counters from DB
self._reload_counters()
print(f"[HP_STORE] Cleanup: deleted {deleted} events older than {days} days")
return deleted
def vacuum_db(self):
"""Reclaim disk space after cleanup."""
conn = self._get_conn()
conn.execute("VACUUM")
print("[HP_STORE] Database vacuumed")
def _reload_counters(self):
"""Reload in-memory counters from the database."""
conn = self._get_conn()
with self.lock:
row = conn.execute("SELECT COUNT(*) as cnt, MAX(id) as max_id FROM events").fetchone()
self.total_count = row["cnt"] or 0
self._last_id = row["max_id"] or 0
self.count_by_type.clear()
self.count_by_severity.clear()
self.count_by_device.clear()
for row in conn.execute("SELECT event_type, COUNT(*) as cnt FROM events GROUP BY event_type"):
self.count_by_type[row["event_type"]] = row["cnt"]
for row in conn.execute("SELECT severity, COUNT(*) as cnt FROM events GROUP BY severity"):
self.count_by_severity[row["severity"]] = row["cnt"]
for row in conn.execute("SELECT device_id, COUNT(*) as cnt FROM events GROUP BY device_id"):
self.count_by_device[row["device_id"]] = row["cnt"]
def start_cleanup_timer(self, days=30, interval_hours=24):
"""Start a background thread that cleans old events periodically."""
def _cleanup_loop():
while True:
time.sleep(interval_hours * 3600)
try:
self.cleanup_old_events(days=days)
except Exception as e:
print(f"[HP_STORE] Cleanup error: {e}")
t = threading.Thread(target=_cleanup_loop, daemon=True)
t.start()
print(f"[HP_STORE] Cleanup timer started: every {interval_hours}h, retention={days} days")
def register_sse_queue(self, q):
"""Register a deque for SSE event push."""
with self.lock:
self._sse_queues.append(q)
def unregister_sse_queue(self, q):
"""Unregister an SSE queue."""
with self.lock:
try:
self._sse_queues.remove(q)
except ValueError:
pass

View File

@ -0,0 +1,790 @@
/* ================================================================
ESPILON HONEYPOT DASHBOARD Stylesheet
Adapted for new C3PO design system (SQL viewer theme)
Uses CSS variables from main.css
================================================================ */
/* Variable compatibility layer map old names used by HP JS modules
to the new design system variables defined in main.css */
.hp-page {
--bg-primary: var(--bg-base);
--bg-secondary: var(--bg-surface);
--bg-tertiary: var(--bg-elevated);
--border-color: var(--border);
--border-medium: var(--border);
--border-subtle: var(--border-subtle);
--accent-primary: var(--accent);
--accent-primary-hover:var(--accent-hover);
--accent-secondary: var(--accent);
--btn-primary: var(--accent);
--btn-primary-hover: var(--accent-hover);
--status-online: var(--ok);
--status-success: var(--ok);
--status-error: var(--err);
--sev-med: var(--sev-medium);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.5);
--bg-hover: rgba(255,255,255,0.03);
--glass: rgba(255,255,255,0.05);
}
/* Page layout — fill the content area from base.html */
.hp-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Header ────────────────────────────────────────────── */
.hp-header {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--sp-3);
gap: var(--sp-3);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.hp-header-kpis { display: flex; align-items: center; gap: var(--sp-2); }
.hp-header-kpi {
display: flex;
align-items: center;
gap: var(--sp-1);
background: var(--bg-elevated);
border-radius: var(--radius);
padding: 2px 8px;
border: 1px solid var(--border-subtle);
}
.hp-header-kpi-val { font-size: var(--fs-sm); font-weight: 700; font-family: var(--font-mono); }
.hp-header-kpi-label { font-size: var(--fs-xs); color: var(--text-muted); text-transform: uppercase; }
.hp-header-controls { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
/* ── Sub-Nav Bar ───────────────────────────────────────── */
.hp-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 28px;
padding: 0 var(--sp-3);
background: var(--bg-base);
border-bottom: 1px solid var(--border-subtle);
position: relative;
flex-shrink: 0;
}
.hp-nav-tabs { display: flex; align-items: center; gap: 0; overflow-x: auto; }
.hp-nav-actions { display: flex; align-items: center; gap: var(--sp-2); margin-left: var(--sp-3); flex-shrink: 0; }
.nav-btn {
display: flex;
align-items: center;
gap: var(--sp-1);
padding: 4px 10px;
font-size: var(--fs-xs);
font-weight: 500;
color: var(--text-secondary);
border: none;
border-bottom: 2px solid transparent;
background: transparent;
cursor: pointer;
white-space: nowrap;
transition: color 150ms;
}
.nav-btn:hover { color: var(--text-primary); }
.nav-btn.active { color: var(--accent-hover); border-bottom-color: var(--accent); }
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
font-size: 10px;
font-weight: 600;
background: var(--err);
color: #fff;
border-radius: 8px;
padding: 0 4px;
}
.hp-nav-hamburger { display: none; }
/* ── Layout ────────────────────────────────────────────── */
.hp-layout {
display: grid;
grid-template-columns: 1fr;
flex: 1;
min-height: 0;
overflow: hidden;
}
.hp-main { display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.hp-main-content { flex: 1; overflow-y: auto; padding: var(--sp-3); }
/* ── Search & Filters ──────────────────────────────────── */
.hp-search-bar {
display: none;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
flex-shrink: 0;
}
.hp-search-icon { color: var(--text-muted); flex-shrink: 0; }
.hp-search-wrap { position: relative; display: flex; align-items: center; flex: 1; }
.hp-search-input {
width: 100%;
background: transparent;
border: none;
font-size: var(--fs-base);
color: var(--text-primary);
outline: none;
font-family: var(--font-mono);
}
.hp-search-input::placeholder { color: var(--text-muted); }
.hp-search-clear {
position: absolute;
right: 4px;
color: var(--text-muted);
font-size: 14px;
background: transparent;
border: none;
cursor: pointer;
display: none;
}
.hp-search-clear:hover { color: var(--text-primary); }
.filter-panel {
display: none;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
flex-shrink: 0;
}
.filter-panel.active { display: flex; }
/* ── HP Sidebar ───────────────────────────────────────── */
.hp-sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 280px;
z-index: 600;
transform: translateX(100%);
transition: transform 200ms;
background: var(--bg-surface);
border-left: 1px solid var(--border);
overflow-y: auto;
}
.hp-sidebar.sidebar-open { transform: translateX(0); }
.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 590; }
.sidebar-overlay.active { display: block; }
.sb-section { border-bottom: 1px solid var(--border-subtle); }
.sb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px var(--sp-3);
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
user-select: none;
}
.sb-body { padding: 0 var(--sp-3) var(--sp-2); }
.sb-chevron { transition: transform 200ms; }
.sb-section.collapsed .sb-body { display: none; }
.sb-section.collapsed .sb-chevron { transform: rotate(-90deg); }
.sb-body-scroll { max-height: 200px; overflow-y: auto; }
.sb-body-scroll-sm { max-height: 140px; overflow-y: auto; }
/* ── HP Forms ─────────────────────────────────────────── */
.hp-select {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 3px 8px;
font-size: var(--fs-xs);
color: var(--text-primary);
cursor: pointer;
outline: none;
}
.hp-select:focus { border-color: var(--accent-border); }
.hp-input {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 3px 8px;
font-size: var(--fs-xs);
color: var(--text-primary);
font-family: var(--font-mono);
outline: none;
}
.hp-input::placeholder { color: var(--text-muted); }
.hp-input:focus { border-color: var(--accent-border); }
/* ── HP Buttons ───────────────────────────────────────── */
.hp-page .icon-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
color: var(--text-secondary);
cursor: pointer;
border: none;
background: transparent;
transition: color 100ms, background 100ms;
}
.hp-page .icon-btn:hover { color: var(--text-primary); background: var(--bg-elevated); }
.btn-start { background: rgba(78,201,176,0.2); color: var(--ok); border: 1px solid rgba(78,201,176,0.3); }
.btn-start:hover { background: rgba(78,201,176,0.3); }
.btn-stop { background: rgba(244,71,71,0.2); color: var(--err); border: 1px solid rgba(244,71,71,0.3); }
.btn-stop:hover { background: rgba(244,71,71,0.3); }
.btn-ghost { background: var(--bg-elevated); color: var(--text-primary); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--border-subtle); }
.btn-ack { font-size: 10px; padding: 2px 6px; background: var(--accent-bg); color: var(--accent-hover); border-radius: var(--radius-sm); cursor: pointer; border: none; }
.btn-ack:hover { background: rgba(86,156,214,0.25); }
.btn-refresh { background: var(--bg-elevated); color: var(--text-primary); border: 1px solid var(--border); }
.btn-refresh:hover { background: var(--border-subtle); }
.btn-save { background: var(--accent-bg); color: var(--accent-hover); border: 1px solid var(--accent-border); }
.btn-save:hover { background: rgba(86,156,214,0.25); }
.btn-reset { background: var(--err-bg); color: var(--err); border: 1px solid rgba(244,71,71,0.3); }
.btn-reset:hover { background: rgba(244,71,71,0.25); }
/* ── Connection ────────────────────────────────────────── */
.conn-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted); display: inline-block; transition: background 150ms; }
.conn-dot.connected { background: var(--ok); }
.conn-indicator { display: flex; align-items: center; gap: 4px; }
.conn-label { font-size: var(--fs-xs); color: var(--text-muted); }
/* ── Alert Banner ──────────────────────────────────────── */
.alert-banner {
display: none;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) var(--sp-3);
font-size: var(--fs-xs);
font-weight: 500;
color: #fecdd3;
cursor: pointer;
background: var(--err-bg);
border-bottom: 1px solid rgba(244,71,71,0.2);
flex-shrink: 0;
animation: alertPulse 2s ease-in-out infinite;
}
.alert-banner.active { display: flex; }
.alert-banner-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.alert-banner-count { color: var(--err); font-family: var(--font-mono); }
.alert-banner-dismiss { margin-left: auto; color: rgba(244,71,71,0.6); cursor: pointer; font-size: 14px; background: transparent; border: none; line-height: 1; }
.alert-banner-dismiss:hover { color: var(--err); }
/* ── Event Rows ────────────────────────────────────────── */
.session-list { display: flex; flex-direction: column; }
.ev-row { padding: 6px var(--sp-3); border-bottom: 1px solid var(--border-subtle); cursor: pointer; transition: background 100ms; }
.ev-row:hover { background: var(--bg-hover); }
.ev-row.flash { animation: flashIn 1s ease-out; }
.ev-row-line1 { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-base); }
.ev-row-line2 { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-sm); color: var(--text-muted); margin-top: 2px; }
.ev-time { color: var(--text-muted); font-family: var(--font-mono); font-size: var(--fs-xs); flex-shrink: 0; }
.ev-layer { font-family: var(--font-mono); font-size: var(--fs-xs); font-weight: 600; flex-shrink: 0; }
.ev-service { color: var(--text-primary); flex-shrink: 0; }
.ev-type { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ev-severity { font-size: var(--fs-xs); font-weight: 600; padding: 1px 4px; border-radius: var(--radius-sm); flex-shrink: 0; }
.ev-ip { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--ok); flex-shrink: 0; cursor: pointer; }
.ev-ip:hover { text-decoration: underline; }
.ev-detail { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ev-mitre { display: flex; gap: 4px; flex-shrink: 0; }
/* ── Severity Colors ──────────────────────────────────── */
.sev-critical { color: var(--sev-crit); font-weight: 600; }
.sev-high { color: var(--sev-high); font-weight: 600; }
.sev-medium { color: var(--sev-medium); font-weight: 500; }
.sev-low { color: var(--sev-low); }
/* ── Tables ────────────────────────────────────────────── */
.ov-table { width: 100%; font-size: var(--fs-sm); border-collapse: collapse; }
.ov-table th { text-align: left; font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border); }
.ov-table td { padding: 4px 0; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); }
.ov-table tr.clickable { cursor: pointer; }
.ov-table tr.clickable:hover { background: var(--bg-hover); }
/* ── Overview ──────────────────────────────────────────── */
.overview-grid { display: flex; flex-direction: column; gap: var(--sp-3); }
.ov-kpi-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--sp-2); }
.ov-kpi-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--sp-3);
text-align: center;
}
.ov-kpi-val { font-size: 18px; font-weight: 700; }
.ov-kpi-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 2px; }
.ov-section {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
padding: var(--sp-3);
}
.ov-section-title { font-size: var(--fs-xs); font-weight: 600; color: var(--text-secondary); margin-bottom: var(--sp-2); text-transform: uppercase; }
.ov-chart-row { display: grid; grid-template-columns: 2fr 1fr; gap: var(--sp-3); }
.ov-2col { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--sp-3); }
.ov-devices-grid { display: grid; grid-template-columns: 1fr; gap: var(--sp-2); }
/* ── Device Cards ──────────────────────────────────────── */
.device-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: border-color 200ms;
}
.device-card:hover { border-color: var(--accent-border); }
.dev-card-header { display: flex; gap: var(--sp-2); padding: var(--sp-2); }
.dev-card-thumb {
position: relative;
width: 32px;
height: 32px;
border-radius: var(--radius);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.dev-card-thumb img { width: 100%; height: 100%; object-fit: cover; }
.dev-card-dot { position: absolute; bottom: -2px; right: -2px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--bg-surface); }
.dev-card-info { flex: 1; min-width: 0; }
.dev-card-name { font-size: var(--fs-base); font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dev-card-ip { font-size: var(--fs-xs); font-family: var(--font-mono); color: var(--text-secondary); }
.dev-card-status { display: flex; align-items: center; gap: 4px; font-size: var(--fs-xs); color: var(--text-secondary); margin-top: 2px; }
.dev-card-status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dev-card-lastseen { font-size: 10px; color: var(--text-muted); }
.dev-card-stats { display: flex; border-top: 1px solid var(--border-subtle); }
.dev-card-stats > * + * { border-left: 1px solid var(--border-subtle); }
.dev-card-stat { flex: 1; padding: 4px 0; text-align: center; }
.dev-card-stat-val { font-size: var(--fs-base); font-weight: 600; color: var(--text-primary); }
.dev-card-stat-label { font-size: 10px; color: var(--text-muted); }
.dev-card-severity-bar { display: flex; height: 3px; border-radius: 2px; overflow: hidden; }
/* ── Service Grid ──────────────────────────────────────── */
.svc-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-1); }
.svc-card {
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-elevated);
border-radius: var(--radius);
padding: 3px 6px;
font-size: var(--fs-xs);
}
.svc-indicator { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted); flex-shrink: 0; }
.svc-indicator.on { background: var(--ok); }
.svc-indicator.off { background: var(--text-muted); }
.svc-name { color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.svc-port { font-size: 10px; color: var(--text-muted); flex-shrink: 0; }
.svc-toggle {
margin-left: auto;
padding: 1px 6px;
font-size: 10px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.svc-toggle:hover { background: var(--bg-hover); color: var(--text-primary); }
.svc-toggle:disabled { opacity: 0.5; cursor: default; }
.svc-toggle-start { border-color: var(--ok); color: var(--ok); }
.svc-toggle-stop { border-color: var(--sev-high); color: var(--sev-high); }
.ev-load-more { text-align: center; padding: var(--sp-3) 0; }
/* ── Kill Chain ────────────────────────────────────────── */
.kc-row { display: flex; align-items: center; gap: var(--sp-2); padding: 4px var(--sp-3); border-bottom: 1px solid var(--border-subtle); }
.kc-row-header { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.kc-row-ip { width: 8rem; font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--text-primary); flex-shrink: 0; display: flex; align-items: center; gap: 4px; }
.kc-row-score { width: 3rem; font-size: var(--fs-sm); font-weight: 700; text-align: right; flex-shrink: 0; }
.kc-phases { flex: 1; display: flex; gap: 2px; }
.kc-phase-label { flex: 1; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 9px; }
.kc-bar { flex: 1; height: 16px; border-radius: var(--radius-sm); }
.kc-bar.empty { background: var(--bg-elevated); }
.kc-row-events { width: 3.5rem; font-size: var(--fs-sm); color: var(--text-secondary); text-align: right; flex-shrink: 0; }
.kc-row-dur { width: 4rem; font-size: var(--fs-sm); color: var(--text-muted); text-align: right; flex-shrink: 0; }
.kc-full-chain-badge { color: var(--sev-medium); margin-right: 4px; }
.kc-mitre-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.kc-detail-content > * + * { margin-top: var(--sp-3); }
.kc-detail-header { display: flex; align-items: center; justify-content: space-between; }
.kc-detail-ip { font-family: var(--font-mono); font-size: var(--fs-lg); font-weight: 600; color: var(--text-primary); }
.kc-detail-score { font-size: 16px; font-weight: 700; }
.kc-detail-meta { display: flex; flex-wrap: wrap; gap: var(--sp-3); font-size: var(--fs-sm); color: var(--text-secondary); }
.kc-detail-bar { display: flex; gap: 2px; height: 8px; }
.kc-detail-phase { padding-left: var(--sp-2); padding-top: var(--sp-2); padding-bottom: var(--sp-2); border-left: 2px solid; }
.kc-detail-phase.inactive { opacity: 0.4; }
.kc-detail-phase-header { display: flex; align-items: center; justify-content: space-between; }
.kc-detail-phase-name { font-size: var(--fs-sm); font-weight: 600; color: var(--text-primary); }
.kc-detail-phase-info { font-size: var(--fs-xs); color: var(--text-muted); }
/* ── Toasts ────────────────────────────────────────────── */
.toast-container { position: fixed; top: var(--sp-3); right: var(--sp-3); z-index: 9999; display: flex; flex-direction: column; gap: var(--sp-2); pointer-events: none; }
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
background: var(--bg-surface);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
font-size: var(--fs-sm);
min-width: 240px;
max-width: 340px;
transform: translateX(120%);
opacity: 0;
transition: all 200ms;
position: relative;
overflow: hidden;
}
.toast.show { transform: translateX(0); opacity: 1; }
.toast-icon { font-size: 14px; line-height: 1; margin-top: 1px; flex-shrink: 0; }
.toast-body { flex: 1; min-width: 0; }
.toast-title { font-weight: 600; color: var(--text-primary); font-size: var(--fs-xs); }
.toast-text { color: var(--text-secondary); font-size: var(--fs-xs); margin-top: 1px; }
.toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; animation: toastProgress 4s linear forwards; }
/* ── Detail Panel ──────────────────────────────────────── */
.detail-panel {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: 400px;
max-width: 100%;
z-index: 500;
background: var(--bg-surface);
border-left: 1px solid var(--border);
transform: translateX(100%);
transition: transform 200ms ease-out;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
}
.detail-panel.open { transform: translateX(0); }
.detail-hdr { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-bottom: 1px solid var(--border); }
.detail-hdr h3 { font-size: var(--fs-base); font-weight: 600; color: var(--text-primary); }
.detail-close {
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius); color: var(--text-secondary);
cursor: pointer; font-size: 14px; border: none; background: transparent;
}
.detail-close:hover { color: var(--text-primary); background: var(--bg-elevated); }
.detail-body { flex: 1; overflow-y: auto; padding: var(--sp-3); }
.detail-body > * + * { margin-top: var(--sp-3); }
.detail-group > * + * { margin-top: var(--sp-2); }
.detail-group-title { font-size: var(--fs-xs); font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
.detail-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--sp-2); }
.detail-field > * + * { margin-top: 2px; }
.detail-field-col { grid-column: span 1; }
.detail-field-full { grid-column: span 2; }
.detail-label { display: block; font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.025em; }
.detail-value { display: block; font-size: var(--fs-sm); color: var(--text-primary); font-family: var(--font-mono); }
.detail-value.clickable { color: var(--accent-hover); cursor: pointer; }
.detail-value.clickable:hover { text-decoration: underline; }
.detail-copy-row { margin-top: var(--sp-3); text-align: right; }
/* ── Attacker Modal ────────────────────────────────────── */
.hp-page .modal-overlay {
position: fixed;
inset: 0;
z-index: 700;
background: rgba(0,0,0,0.6);
display: none;
align-items: center;
justify-content: center;
padding: var(--sp-3);
}
.hp-page .modal-overlay.open { display: flex; }
.modal-content {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 100%;
max-width: 42rem;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
animation: scaleIn 0.15s ease-out;
}
.modal-hdr { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border); }
.modal-hdr h2 { font-size: var(--fs-lg); font-weight: 600; color: var(--text-primary); font-family: var(--font-mono); }
.modal-close {
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius); color: var(--text-secondary);
cursor: pointer; font-size: 16px; border: none; background: transparent;
}
.modal-close:hover { color: var(--text-primary); background: var(--bg-elevated); }
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-4); }
.modal-body > * + * { margin-top: var(--sp-3); }
.modal-header-badges { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2); }
.modal-header-ip { font-family: var(--font-mono); font-size: var(--fs-lg); color: var(--text-primary); font-weight: 600; }
.modal-header-vendor { font-size: var(--fs-xs); background: var(--bg-elevated); color: var(--text-secondary); padding: 1px 6px; border-radius: var(--radius); }
.modal-header-country { font-size: var(--fs-xs); color: var(--text-secondary); }
.modal-stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2); margin-bottom: var(--sp-3); }
.modal-stat-card { background: var(--bg-elevated); border-radius: var(--radius); padding: var(--sp-2); text-align: center; border: 1px solid var(--border-subtle); }
.modal-stat-val { font-size: 16px; font-weight: 700; color: var(--accent-hover); }
.modal-stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 2px; }
.modal-info-block { background: var(--bg-elevated); border-radius: var(--radius); padding: var(--sp-2); margin-bottom: var(--sp-2); }
.modal-info-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.modal-info-row { font-size: var(--fs-sm); color: var(--text-primary); padding: 1px 0; }
.modal-tabs-bar { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: var(--sp-2); }
.modal-tab-btn {
padding: 4px 8px;
font-size: var(--fs-xs);
color: var(--text-secondary);
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
background: transparent;
transition: color 100ms;
}
.modal-tab-btn:hover { color: var(--text-primary); }
.modal-tab-btn.active { color: var(--accent-hover); border-bottom-color: var(--accent); }
.modal-session-card { padding: var(--sp-2); background: var(--bg-elevated); border-radius: var(--radius); margin-bottom: var(--sp-1); cursor: pointer; transition: background 100ms; }
.modal-session-card:hover { background: var(--border-subtle); }
.modal-session-card-top { display: flex; align-items: center; justify-content: space-between; }
.modal-session-card-time { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 2px; }
.modal-ev-sev { font-size: var(--fs-xs); margin-right: var(--sp-2); flex-shrink: 0; }
.modal-ev-type { font-size: var(--fs-sm); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modal-ev-time { font-size: var(--fs-sm); color: var(--text-muted); margin-left: auto; flex-shrink: 0; }
/* ── Replay Modal ──────────────────────────────────────── */
.replay-overlay { position: fixed; inset: 0; z-index: 800; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; padding: var(--sp-3); }
.replay-overlay.open { display: flex; }
.replay-content {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 100%;
max-width: 48rem;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
animation: scaleIn 0.15s ease-out;
}
.replay-hdr { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); }
.replay-hdr-info { display: flex; align-items: center; gap: var(--sp-2); }
.replay-hdr-info h3 { font-size: var(--fs-base); font-weight: 600; color: var(--text-primary); }
.replay-hdr-info span { font-size: var(--fs-sm); color: var(--text-muted); font-family: var(--font-mono); }
.replay-close {
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius); color: var(--text-secondary);
cursor: pointer; font-size: 14px; border: none; background: transparent;
}
.replay-close:hover { color: var(--text-primary); background: var(--bg-elevated); }
.replay-controls {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
background: var(--bg-base);
}
.replay-btn {
padding: 2px 8px; font-size: var(--fs-xs); font-weight: 500;
border-radius: var(--radius); background: var(--bg-elevated);
color: var(--text-primary); cursor: pointer;
border: 1px solid var(--border); transition: background 100ms;
}
.replay-btn:hover { background: var(--border-subtle); }
.replay-speed {
background: var(--bg-input); color: var(--text-primary);
font-size: var(--fs-xs); border: 1px solid var(--border);
border-radius: var(--radius); padding: 2px 6px; cursor: pointer;
}
.replay-progress { flex: 1; height: 4px; background: var(--bg-elevated); border-radius: 2px; cursor: pointer; position: relative; overflow: hidden; }
.replay-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 150ms; }
.replay-counter { font-size: var(--fs-xs); color: var(--text-muted); font-family: var(--font-mono); flex-shrink: 0; }
.replay-terminal {
height: 16rem; overflow-y: auto; padding: var(--sp-3);
font-family: var(--font-mono); font-size: var(--fs-sm);
background: var(--bg-base); margin: var(--sp-3);
border-radius: var(--radius); border: 1px solid var(--border-subtle);
}
.replay-line { padding: 1px 0; color: var(--text-secondary); }
.replay-line-connect { color: var(--ok); }
.replay-line-auth-ok { color: var(--ok); }
.replay-line-auth-fail { color: var(--err); }
.replay-line-cmd { color: var(--accent-hover); }
.replay-line-http { color: var(--sev-medium); }
/* ── Charts ────────────────────────────────────────────── */
.chart-container { display: flex; align-items: center; justify-content: center; }
.chart-donut-wrapper { display: flex; align-items: center; gap: var(--sp-3); }
.chart-donut-ring { width: 80px; height: 80px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.chart-donut-center { width: 52px; height: 52px; border-radius: 50%; background: var(--bg-base); display: flex; flex-direction: column; align-items: center; justify-content: center; }
.chart-donut-total { font-size: var(--fs-lg); font-weight: 700; color: var(--text-primary); }
.chart-donut-label { font-size: 9px; color: var(--text-muted); text-transform: uppercase; }
.chart-legend { display: flex; flex-direction: column; gap: 4px; }
.chart-legend-item { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-sm); }
.chart-legend-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.chart-legend-label { color: var(--text-secondary); width: 4rem; }
.chart-timeline { display: flex; align-items: flex-end; gap: 2px; width: 100%; }
.chart-bar-col { display: flex; flex-direction: column; justify-content: flex-end; position: relative; }
.chart-bar-crit { background: var(--sev-crit); border-top-left-radius: 1px; border-top-right-radius: 1px; }
.chart-bar-high { background: var(--sev-high); }
.chart-bar-med { background: var(--sev-medium); }
.chart-bar-low { background: var(--sev-low); border-bottom-left-radius: 1px; border-bottom-right-radius: 1px; }
.chart-time-label { position: absolute; bottom: -14px; left: 50%; transform: translateX(-50%); font-size: 9px; color: var(--text-muted); white-space: nowrap; }
/* ── Sidebar Stats ─────────────────────────────────────── */
.sev-stat-row { display: flex; align-items: center; justify-content: space-between; padding: 2px 0; font-size: var(--fs-sm); }
.sev-stat-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin-right: var(--sp-2); }
.sev-stat-count { font-family: var(--font-mono); font-weight: 600; color: var(--text-primary); }
.layer-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-1); }
.layer-box { background: var(--bg-elevated); border-radius: var(--radius); padding: var(--sp-2); text-align: center; border-top: 2px solid; }
.layer-box-val { font-size: var(--fs-lg); font-weight: 700; color: var(--text-primary); }
.layer-box-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
.atk-row { padding: 4px 6px; cursor: pointer; border-radius: var(--radius); margin: 0 -6px; transition: background 100ms; }
.atk-row:hover { background: var(--bg-hover); }
.atk-row-top { display: flex; align-items: center; justify-content: space-between; }
.atk-row-ip { font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--text-primary); }
.atk-row-count { font-size: var(--fs-sm); font-weight: 600; color: var(--accent-hover); }
.atk-row-vendor { font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.atk-row-bar { height: 3px; background: var(--bg-elevated); border-radius: 2px; margin-top: 4px; overflow: hidden; }
.atk-row-bar-fill { height: 100%; background: var(--accent-bg); border-radius: 2px; }
.alert-item { background: var(--bg-elevated); border-radius: var(--radius); padding: 6px; margin-bottom: var(--sp-1); border-left: 2px solid; }
.alert-item.acked { opacity: 0.5; }
.alert-item-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-2); }
.alert-item-msg { font-size: var(--fs-sm); color: var(--text-primary); line-height: 1.5; }
.alert-item-time { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
.hist-item { display: flex; align-items: center; gap: var(--sp-2); padding: 2px 0; font-size: var(--fs-sm); }
.hist-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.hist-cmd { font-family: var(--font-mono); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hist-time { color: var(--text-muted); margin-left: auto; flex-shrink: 0; font-size: 10px; }
/* ── MITRE Tags ────────────────────────────────────────── */
.mitre-tag {
display: inline-block;
padding: 1px 4px;
font-size: 10px;
font-family: var(--font-mono);
font-weight: 500;
background: var(--accent-bg);
color: var(--accent-hover);
border: 1px solid var(--accent-border);
border-radius: var(--radius-sm);
}
.mitre-tag-malware { background: var(--err-bg); color: var(--err); border-color: rgba(244,71,71,0.3); }
/* ── Empty States & Skeletons ──────────────────────────── */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 3rem 0; color: var(--text-muted); }
.empty-state-icon { font-size: 28px; margin-bottom: var(--sp-2); opacity: 0.4; }
.empty-state-title { font-size: var(--fs-base); font-weight: 500; color: var(--text-secondary); }
.empty-state-sub { font-size: var(--fs-sm); color: var(--text-muted); margin-top: 2px; }
.skeleton + .skeleton { margin-top: var(--sp-2); }
.skeleton-row {
height: 32px; border-radius: var(--radius);
background: linear-gradient(90deg, var(--bg-elevated) 0%, var(--border-subtle) 50%, var(--bg-elevated) 100%);
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
}
.loading-spinner { width: 24px; height: 24px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; margin: 2rem auto; animation: spin 0.8s linear infinite; }
/* ── HP Status Footer ─────────────────────────────────── */
.hp-statusbar {
height: 22px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--sp-3);
background: var(--bg-base);
border-top: 1px solid var(--border-subtle);
font-size: var(--fs-xs);
flex-shrink: 0;
}
.hp-statusbar-left, .hp-statusbar-right { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); }
.hp-statusbar-sep { color: var(--border); }
/* ── Utility Classes ──────────────────────────────────── */
.text-accent { color: var(--accent-hover); }
.text-crit { color: var(--sev-crit); }
.text-error { color: var(--err); }
.font-mono { font-family: var(--font-mono); }
.fw-600 { font-weight: 600; }
.fw-700 { font-weight: 700; }
.clickable { cursor: pointer; }
/* ── MITRE ATT&CK Matrix ─────────────────────────────── */
.mitre-summary { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; }
.mitre-stat { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3) var(--sp-4); text-align: center; flex: 1; min-width: 80px; }
.mitre-stat-val { display: block; font-size: 18px; font-weight: 700; }
.mitre-stat-label { display: block; font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
.mitre-matrix { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--sp-2); }
.mitre-col { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.mitre-col.active { border-color: var(--accent); }
.mitre-tactic-header { padding: 6px 8px; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.mitre-tactic-name { font-size: var(--fs-xs); font-weight: 600; color: var(--text-primary); }
.mitre-tactic-count { font-size: 10px; font-weight: 700; color: var(--accent); background: var(--accent-bg); padding: 1px 5px; border-radius: var(--radius); }
.mitre-technique { padding: 4px 8px; border-bottom: 1px solid var(--border-subtle); cursor: default; transition: background 150ms; }
.mitre-technique:last-child { border-bottom: none; }
.mitre-technique:hover { filter: brightness(1.15); }
.mitre-technique.empty { color: var(--text-muted); font-size: var(--fs-xs); text-align: center; padding: var(--sp-3); font-style: italic; }
.mitre-tech-id { font-size: 10px; color: var(--accent); font-family: var(--font-mono); }
.mitre-tech-name { font-size: var(--fs-xs); color: var(--text-primary); margin-top: 1px; }
.mitre-tech-count { font-size: 10px; color: var(--text-muted); margin-top: 1px; }
/* ── Responsive ────────────────────────────────────────── */
@media (min-width: 640px) {
.ov-devices-grid { grid-template-columns: repeat(2, 1fr); }
.svc-grid { grid-template-columns: repeat(6, 1fr); }
}
@media (min-width: 768px) {
.hp-nav-tabs { display: flex; }
.hp-nav-hamburger { display: none; }
}
@media (min-width: 1024px) {
.hp-layout { grid-template-columns: 1fr 280px; }
.hp-sidebar {
position: relative;
width: auto;
z-index: auto;
transform: none;
transition: none;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
}
.ov-devices-grid { grid-template-columns: repeat(3, 1fr); }
}
.hp-nav-tabs.nav-open {
display: flex; flex-direction: column;
position: absolute; top: 28px; left: 0; right: 0;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-lg);
z-index: 170;
padding: var(--sp-1);
}
/* ── Animations ────────────────────────────────────────── */
@keyframes flashIn { 0% { background: var(--accent-bg); } 100% { background: transparent; } }
@keyframes critFlash { 0%, 100% { background: transparent; } 50% { background: var(--err-bg); } }
@keyframes alertPulse { 0%, 100% { background: var(--err-bg); } 50% { background: rgba(244,71,71,0.18); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes toastProgress { from { width: 100%; } to { width: 0%; } }

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,87 @@
/* ESPILON Honeypot Dashboard — API Client */
import { S } from './state.js';
// showToast will be set by ui.js to break circular dependency
let _showToast = (title, msg, detail, type) => console.warn('[HP]', title, msg);
export function setToastHandler(fn) {
_showToast = fn;
}
// Default render callback — set by app.js so fetchAll() always re-renders
let _defaultRender = null;
export function setDefaultRender(fn) {
_defaultRender = fn;
}
export async function api(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return await r.json();
} catch (e) {
console.warn('[HP API]', url, e.message);
_showToast('API Error', url + ': ' + e.message, '', 'error');
return null;
}
}
let _csrfToken = null;
async function _getCsrf() {
if (_csrfToken) return _csrfToken;
try {
const r = await fetch('/api/honeypot/csrf-token');
if (r.ok) { const j = await r.json(); _csrfToken = j.token; }
} catch (_) { /* ignore */ }
return _csrfToken || '';
}
export async function postApi(url, body) {
try {
const csrf = await _getCsrf();
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
body: JSON.stringify(body || {})
});
if (r.status === 403) { _csrfToken = null; } // token expired, refresh next time
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return await r.json();
} catch (e) {
console.warn('[HP API]', url, e.message);
_showToast('API Error', url + ': ' + e.message, '', 'error');
return null;
}
}
export async function fetchAll(renderAll) {
const devParam = S.selectedDevice ? '?device_id=' + S.selectedDevice : '';
const [stats, events, attackers, timeline, devices, services, history, alerts] = await Promise.all([
api('/api/honeypot/stats'),
api('/api/honeypot/events?limit=200'),
api('/api/honeypot/attackers?limit=50'),
api('/api/honeypot/timeline?hours=24&bucket=5'),
api('/api/honeypot/devices'),
api('/api/honeypot/services' + devParam),
api('/api/honeypot/history?limit=50'),
api('/api/honeypot/alerts/active')
]);
if (stats) S.stats = stats;
if (events) S.events = events.events || [];
if (attackers) S.attackers = attackers.attackers || [];
if (timeline) S.timeline = timeline.timeline || [];
if (devices) S.devices = devices.devices || [];
if (services) { S.services = services.services || {}; S.definitions = services.definitions || {}; }
if (history) S.history = history.history || [];
if (alerts) S.alerts = alerts.alerts || [];
S.lastId = S.stats.last_id || 0;
// Reset cached tabs
S.sessions = null;
S.credentials = null;
S.killchain = null;
const cb = renderAll || _defaultRender;
if (cb) cb();
}

View File

@ -0,0 +1,215 @@
/* ESPILON Honeypot Dashboard — Application Entry Point */
import { S } from './state.js';
import { $id } from './utils.js';
import { setToastHandler, setDefaultRender, fetchAll } from './api.js';
import { connectSSE, setSSECallbacks } from './sse.js';
import { showToast, closeDetail, closeModal, toggleSidebar, toggleNavMenu, toggleSbSection,
scrollToAlerts, restoreSidebarState, registerActions, setupEventDelegation, setupResponsive } from './ui.js';
import { setTabRenderers, switchTab, setupKeyboardShortcuts } from './router.js';
import { renderOverview, updateHeaderKpis, updateStatusBar, updateAlertBanner, renderDeviceSelect,
toggleService, startAll, stopAll, refreshStatus, ackAlert } from './overview.js';
import { renderEvents, prependEventRow, showDetail, showAttackerModal,
switchModalTab, onSearchKey, applyFilters, toggleFilters, exportData, loadMoreEvents } from './events.js';
import { renderSessions, openReplay, toggleReplayPlayback, replayStep, replayReset,
seekReplay, updateReplaySpeed, closeReplay } from './sessions.js';
import { renderCredentials } from './credentials.js';
import { renderKillChain, showKillChainDetail } from './killchain.js';
import { renderMitre } from './mitre.js';
import { renderSidebar } from './sidebar.js';
// ── Wire Dependencies ───────────────────────────────────────
// Break circular: api.js needs showToast from ui.js
setToastHandler(showToast);
// Default render callback for fetchAll() (so calls without explicit callback still re-render)
setDefaultRender(renderAll);
// Wire tab renderers into the router
setTabRenderers({
overview: renderAll,
timeline: renderEvents,
sessions: renderSessions,
credentials: renderCredentials,
killchain: renderKillChain,
mitre: renderMitre,
});
// Wire SSE callbacks
setSSECallbacks({
onNewEvent(evt) {
// Prepend row if on events tab
prependEventRow(evt);
// Update sidebar
renderSidebar();
// Update header KPIs
updateHeaderKpis();
updateAlertBanner();
},
onStatsUpdate() {
// Refresh overview KPIs without full re-render
updateHeaderKpis();
}
});
// ── Tab content renderers (main-list area) ──────────────────
const _contentRenderers = {
overview: renderOverview,
timeline: renderEvents,
sessions: renderSessions,
credentials: renderCredentials,
killchain: renderKillChain,
mitre: renderMitre,
};
// ── Render All (after fetchAll) ─────────────────────────────
function renderAll() {
renderDeviceSelect();
// Render the currently active tab, not always overview
const renderer = _contentRenderers[S.tab] || renderOverview;
renderer();
renderSidebar();
updateHeaderKpis();
updateAlertBanner();
updateStatusBar();
}
// ── Event Delegation: register all actions ──────────────────
registerActions({
'tab': (el) => switchTab(el.dataset.tab),
'detail': (el) => showDetail(parseInt(el.dataset.id)),
'attacker': (el) => showAttackerModal(el.dataset.ip),
'replay': (el) => openReplay(el.dataset.session, el.dataset.ip, el.dataset.service),
'killchain-detail': (el) => showKillChainDetail(el.dataset.ip),
'toggle-sidebar': () => toggleSidebar(),
'toggle-nav': () => toggleNavMenu(),
'toggle-section': (el) => toggleSbSection(el),
'close-detail': () => closeDetail(),
'close-modal': () => closeModal(),
'close-replay': () => closeReplay(),
'scroll-alerts': () => scrollToAlerts(),
'toggle-sound': () => {
S.soundEnabled = !S.soundEnabled;
const btn = $id('sound-btn');
if (btn) btn.style.opacity = S.soundEnabled ? '1' : '0.4';
showToast('Sound', S.soundEnabled ? 'Alert sounds enabled' : 'Alert sounds muted', '', 'info');
},
'toggle-notif': () => {
if (!S.notifEnabled && typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
Notification.requestPermission().then(function(perm) {
if (perm === 'granted') {
S.notifEnabled = true;
var btn = $id('notif-btn');
if (btn) btn.style.opacity = '1';
showToast('Notifications', 'Browser notifications enabled', '', 'success');
} else {
showToast('Notifications', 'Permission denied by browser', '', 'warning');
}
});
} else {
S.notifEnabled = !S.notifEnabled;
var btn = $id('notif-btn');
if (btn) btn.style.opacity = S.notifEnabled ? '1' : '0.4';
showToast('Notifications', S.notifEnabled ? 'Browser notifications enabled' : 'Browser notifications disabled', '', 'info');
}
},
'toggle-service': (el) => toggleService(el.dataset.name, el.dataset.running === '1'),
'start-all': () => startAll(),
'stop-all': () => stopAll(),
'refresh-status': () => refreshStatus(),
'ack-alert': (el) => ackAlert(parseInt(el.dataset.id)),
'toggle-filters': () => toggleFilters(),
'load-more-events': () => loadMoreEvents(),
'export-csv': () => exportData('csv'),
'export-json': () => exportData('json'),
'dismiss-banner': (el, e) => {
e.stopPropagation();
$id('alert-banner')?.classList.remove('active');
},
'replay-play': () => toggleReplayPlayback(),
'replay-step': () => replayStep(),
'replay-reset': () => replayReset(),
'replay-seek': (el, e) => seekReplay(e),
'modal-tab': (el) => switchModalTab(el.dataset.tab),
'copy-json': () => {
if (window._detailEvtJson) {
navigator.clipboard.writeText(window._detailEvtJson);
showToast('Copied', 'Event JSON copied', '', 'success');
}
},
});
// ── Init ────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function() {
restoreSidebarState();
setupEventDelegation();
setupResponsive();
// Form element listeners (change/keyup — not click-based)
const devSel = $id('device-select');
if (devSel) devSel.addEventListener('change', () => {
S.selectedDevice = devSel.value;
fetchAll(renderAll);
});
const searchIn = $id('search-input');
if (searchIn) searchIn.addEventListener('keyup', () => onSearchKey());
const searchClear = $id('search-clear');
if (searchClear) searchClear.addEventListener('click', () => {
if (searchIn) { searchIn.value = ''; onSearchKey(); searchIn.focus(); }
});
['f-type', 'f-sev', 'f-service'].forEach(id => {
const el = $id(id);
if (el) el.addEventListener('change', () => applyFilters());
});
const fIp = $id('f-ip');
if (fIp) fIp.addEventListener('change', () => applyFilters());
const replaySpd = $id('replay-speed');
if (replaySpd) replaySpd.addEventListener('change', () => updateReplaySpeed());
const sevFilter = $id('sse-sev-filter');
if (sevFilter) sevFilter.addEventListener('change', () => {
S.minSeverity = sevFilter.value;
connectSSE();
});
setupKeyboardShortcuts({
onEscape() { closeDetail(); closeModal(); closeReplay(); },
onRefresh() { refreshStatus(); },
onToggleSound() {
S.soundEnabled = !S.soundEnabled;
const btn = $id('sound-btn');
if (btn) btn.style.opacity = S.soundEnabled ? '1' : '0.4';
showToast('Sound', S.soundEnabled ? 'Alert sounds enabled' : 'Alert sounds muted', '', 'info');
}
});
fetchAll(renderAll);
connectSSE();
/* Conditional auto-refresh: only re-render if data actually changed */
let _lastDataHash = '';
function renderIfChanged() {
const hash = JSON.stringify([
S.stats.total_events,
S.events.length,
S.alerts.length,
S.devices.length,
Object.keys(S.services).length
]);
if (hash !== _lastDataHash) {
_lastDataHash = hash;
renderAll();
}
}
S._refreshTimer = setInterval(() => fetchAll(renderIfChanged), 30000);
setInterval(updateStatusBar, 1000);
});

View File

@ -0,0 +1,17 @@
/* ESPILON Honeypot Dashboard — Audio Alerts */
export function playAlertSound(severity) {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = severity === 'CRITICAL' ? 880 : 660;
gain.gain.value = 0.15;
osc.start();
osc.stop(ctx.currentTime + 0.15);
} catch (e) {
// Audio context may be blocked by browser policy
}
}

View File

@ -0,0 +1,80 @@
/* ESPILON Honeypot Dashboard — Charts */
import { escHtml, formatTime } from './utils.js';
// ── Severity Donut ──────────────────────────────────────────
export function renderSevDonut(container, stats) {
if (!container) return;
const sevs = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
const rawColors = {CRITICAL: '#f43f5e', HIGH: '#fb923c', MEDIUM: '#fbbf24', LOW: '#4ade80'};
const total = sevs.reduce((s, k) => s + (stats[k] || 0), 0);
if (!total) {
container.innerHTML = '<div class="chart-container text-muted fs-sm" style="height:100px">No events</div>';
return;
}
let gradParts = [], angle = 0;
sevs.forEach(s => {
const v = stats[s] || 0;
if (!v) return;
const seg = (v / total) * 360;
gradParts.push(rawColors[s] + ' ' + angle + 'deg ' + (angle + seg) + 'deg');
angle += seg;
});
let html = '<div class="chart-donut-wrapper">';
html += '<div class="chart-donut-ring" style="background:conic-gradient(' + gradParts.join(',') + ')">'
+ '<div class="chart-donut-center">'
+ '<div class="chart-donut-total">' + total + '</div>'
+ '<div class="chart-donut-label">events</div></div></div>';
html += '<div class="chart-legend">';
sevs.forEach(s => {
const v = stats[s] || 0, pct = (v / total * 100).toFixed(1);
html += '<div class="chart-legend-item">'
+ '<span class="chart-legend-dot" style="background:' + rawColors[s] + '"></span>'
+ '<span class="chart-legend-label">' + s + '</span>'
+ '<span class="fw-600">' + v + '</span>'
+ '<span class="text-muted fs-xs">(' + pct + '%)</span></div>';
});
html += '</div></div>';
container.innerHTML = html;
}
// ── Timeline Bar Chart ──────────────────────────────────────
export function renderTimeline(container, data, height) {
if (!container) return;
height = height || 100;
if (!data || !data.length) {
container.innerHTML = '<div class="chart-container text-muted fs-sm" style="height:' + height + 'px">No activity</div>';
return;
}
const maxTotal = Math.max(...data.map(d => d.total || 0), 1);
const barW = Math.max(Math.floor((container.offsetWidth || 600) / data.length) - 2, 3);
const labelEvery = Math.max(1, Math.floor(data.length / 8));
const chartH = height - 18;
let html = '<div class="chart-timeline" style="height:' + height + 'px">';
data.forEach((d, i) => {
const t = d.total || 0, barH = t > 0 ? Math.max((t / maxTotal) * chartH, 2) : 0;
const crit = d.CRITICAL || 0, high = d.HIGH || 0, med = d.MEDIUM || 0, low = d.LOW || 0;
let timeLabel = '';
if (d.time && i % labelEvery === 0) {
const dt = new Date(typeof d.time === 'number' ? (d.time < 1e12 ? d.time * 1000 : d.time) : d.time);
timeLabel = String(dt.getHours()).padStart(2, '0') + ':' + String(dt.getMinutes()).padStart(2, '0');
}
const tooltip = (timeLabel || formatTime(d.time)) + ' \u2014 Total:' + t
+ ' (C:' + crit + ' H:' + high + ' M:' + med + ' L:' + low + ')';
html += '<div class="chart-bar-col" style="width:' + barW + 'px;height:' + chartH + 'px" title="' + escHtml(tooltip) + '">';
if (t > 0) {
if (crit) html += '<div class="chart-bar-crit" style="height:' + (crit / t) * barH + 'px"></div>';
if (high) html += '<div class="chart-bar-high" style="height:' + (high / t) * barH + 'px"></div>';
if (med) html += '<div class="chart-bar-med" style="height:' + (med / t) * barH + 'px"></div>';
if (low) html += '<div class="chart-bar-low" style="height:' + (low / t) * barH + 'px"></div>';
}
if (timeLabel) {
html += '<div class="chart-time-label">' + timeLabel + '</div>';
}
html += '</div>';
});
html += '</div>';
container.innerHTML = html;
}

View File

@ -0,0 +1,77 @@
/* ESPILON Honeypot Dashboard — Credentials Tab */
import { S } from './state.js';
import { $id, escHtml, maskPassword, svcIcon, emptyState, skeletonRows } from './utils.js';
import { api } from './api.js';
export async function renderCredentials() {
const ml = $id('main-list');
if (!ml) return;
ml.innerHTML = skeletonRows(5);
if (!S.credentials) {
S.credentials = await api('/api/honeypot/credentials');
}
const c = S.credentials;
if (!c) {
ml.innerHTML = emptyState('\uD83D\uDD12', 'No credential data', 'Captured credentials will be shown here');
return;
}
let html = '<div class="ov-2col">';
// Top Usernames
html += '<div class="sb-section"><div class="sb-header">Top Usernames</div><div class="sb-body">';
html += '<table class="ov-table"><tr><th>#</th><th>Username</th><th>Count</th><th>Services</th></tr>';
const users = c.top_usernames || [];
for (let i = 0; i < users.length; i++) {
const u = users[i];
html += '<tr><td>' + (i + 1) + '</td>'
+ '<td class="font-mono">' + escHtml(u.username) + '</td>'
+ '<td>' + u.cnt + '</td>'
+ '<td>' + escHtml(Array.isArray(u.services) ? u.services.join(', ') : (u.services || '')) + '</td></tr>';
}
html += '</table></div></div>';
// Top Passwords
html += '<div class="sb-section"><div class="sb-header">Top Passwords</div><div class="sb-body">';
html += '<table class="ov-table"><tr><th>#</th><th>Password</th><th>Count</th><th>Services</th></tr>';
const passwords = c.top_passwords || [];
for (let i = 0; i < passwords.length; i++) {
const p = passwords[i];
const masked = maskPassword(p.password);
html += '<tr><td>' + (i + 1) + '</td>'
+ '<td class="font-mono">' + escHtml(masked) + '</td>'
+ '<td>' + p.cnt + '</td>'
+ '<td>' + escHtml(Array.isArray(p.services) ? p.services.join(', ') : (p.services || '')) + '</td></tr>';
}
html += '</table></div></div>';
// Top Combos
html += '<div class="sb-section"><div class="sb-header">Top Combos</div><div class="sb-body">';
html += '<table class="ov-table"><tr><th>#</th><th>Username:Password</th><th>Count</th><th>Service</th></tr>';
const combos = c.top_combos || [];
for (let i = 0; i < combos.length; i++) {
const cb = combos[i];
html += '<tr><td>' + (i + 1) + '</td>'
+ '<td class="font-mono">' + escHtml(cb.username) + ':' + escHtml(maskPassword(cb.password)) + '</td>'
+ '<td>' + cb.cnt + '</td>'
+ '<td>' + escHtml(cb.service || '') + '</td></tr>';
}
html += '</table></div></div>';
// By Service
html += '<div class="sb-section"><div class="sb-header">By Service</div><div class="sb-body">';
html += '<table class="ov-table"><tr><th>Service</th><th>Unique Users</th><th>Total Attempts</th></tr>';
const bySvc = c.by_service || [];
for (let i = 0; i < bySvc.length; i++) {
const sv = bySvc[i];
html += '<tr><td>' + svcIcon(sv.service) + ' ' + escHtml(sv.service) + '</td>'
+ '<td>' + sv.unique_users + '</td>'
+ '<td>' + sv.total_attempts + '</td></tr>';
}
html += '</table></div></div>';
html += '</div>';
ml.innerHTML = html;
}

View File

@ -0,0 +1,340 @@
/* ESPILON Honeypot Dashboard — Events Tab + Attacker Modal */
import { S } from './state.js';
import { $id, escHtml, formatTime, sevClass, layerForType, layerColor, countryFlag, debounce, emptyState, skeletonRows } from './utils.js';
import { api } from './api.js';
import { showToast, closeDetail, closeModal } from './ui.js';
// ── Events Tab ──────────────────────────────────────────────
const EVENTS_PAGE_SIZE = 50;
let _eventsHasMore = true;
export function renderEvents() {
const sb = $id('search-bar');
if (sb) sb.style.display = '';
const ml = $id('main-list');
if (!ml) return;
if (!S.events || !S.events.length) {
ml.innerHTML = emptyState('\uD83D\uDCCA', 'No events recorded', 'Events will appear here as they are detected');
return;
}
let html = '';
for (let i = 0; i < S.events.length; i++) {
html += buildEventRow(S.events[i]);
}
if (_eventsHasMore) {
html += '<div class="ev-load-more"><button class="btn btn-sm" data-action="load-more-events">Load More</button></div>';
}
ml.innerHTML = html;
}
export function buildEventRow(evt) {
const layer = layerForType(evt.event_type);
let detail = evt.detail || '';
if (detail.length > 80) detail = detail.substring(0, 80) + '\u2026';
let mitre = '';
if (evt.mitre_techniques) {
mitre = buildMitreBadges(evt.mitre_techniques);
}
const safeId = parseInt(evt.id) || 0;
return '<div class="ev-row" data-action="detail" data-id="' + safeId + '">'
+ '<div class="ev-row-line1">'
+ '<span class="ev-time">' + formatTime(evt.timestamp) + '</span>'
+ '<span class="ev-layer" style="color:' + layerColor(layer) + '">[' + layer + ']</span>'
+ '<span class="ev-service">' + escHtml(evt.service || '') + '</span>'
+ '<span class="ev-type">' + escHtml(evt.event_type) + '</span>'
+ '<span class="ev-severity ' + sevClass(evt.severity) + '">' + escHtml(evt.severity) + '</span>'
+ '</div>'
+ '<div class="ev-row-line2">'
+ '<span class="ev-ip" data-action="attacker" data-ip="' + escHtml(evt.src_ip) + '">' + escHtml(evt.src_ip) + '</span>'
+ '<span class="ev-detail">' + escHtml(detail) + '</span>'
+ '<span class="ev-mitre">' + mitre + '</span>'
+ '</div>'
+ '</div>';
}
export function buildMitreBadges(mitreJson) {
let techniques;
if (!mitreJson) return '';
try {
if (typeof mitreJson === 'string') {
techniques = JSON.parse(mitreJson);
} else {
techniques = mitreJson;
}
} catch (e) {
return '';
}
if (!techniques || !techniques.length) return '';
let html = '';
for (let i = 0; i < techniques.length; i++) {
const t = techniques[i];
const tid = typeof t === 'string' ? t : (t.technique_id || t.id || t);
const name = typeof t === 'object' ? (t.name || t.technique_name || '') : '';
const tactic = typeof t === 'object' ? (t.tactic || '') : '';
let title = tid;
if (name) title += ' - ' + name;
if (tactic) title += ' (' + tactic + ')';
html += '<span class="mitre-tag" title="' + escHtml(title) + '">' + escHtml(String(tid)) + '</span>';
}
return html;
}
export function prependEventRow(evt) {
if (S.tab !== 'timeline') return;
const ml = $id('main-list');
if (!ml) return;
const empty = ml.querySelector('.empty-state');
if (empty) ml.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.innerHTML = buildEventRow(evt);
const el = wrapper.firstChild;
if (el) {
el.classList.add('flash');
ml.insertBefore(el, ml.firstChild);
setTimeout(function() { el.classList.remove('flash'); }, 2000);
}
}
// ── Search & Filters ────────────────────────────────────────
const _debouncedFilter = debounce(() => applyFilters(), 300);
export function onSearchKey(e) {
if (!e) { applyFilters(); return; }
if (e.key === 'Enter') applyFilters();
else if (e.key === 'Escape') { e.target.value = ''; applyFilters(); }
else _debouncedFilter();
}
function _buildFilterParams() {
const params = new URLSearchParams();
const q = ($id('search-input')?.value || '').trim();
const type = $id('f-type')?.value;
const sev = $id('f-sev')?.value;
const service = $id('f-service')?.value;
const ip = ($id('f-ip')?.value || '').trim();
if (q) params.set('q', q);
if (type) params.set('type', type);
if (sev) params.set('severity', sev);
if (service) params.set('service', service);
if (ip) params.set('ip', ip);
return params;
}
export async function applyFilters() {
const params = _buildFilterParams();
params.set('limit', String(EVENTS_PAGE_SIZE));
const data = await api('/api/honeypot/search?' + params.toString());
if (data) {
S.events = data.events || [];
_eventsHasMore = S.events.length >= EVENTS_PAGE_SIZE;
renderEvents();
}
}
export async function loadMoreEvents() {
const params = _buildFilterParams();
params.set('limit', String(EVENTS_PAGE_SIZE));
params.set('offset', String(S.events.length));
const data = await api('/api/honeypot/search?' + params.toString());
if (data && data.events && data.events.length) {
S.events.push(...data.events);
_eventsHasMore = data.events.length >= EVENTS_PAGE_SIZE;
renderEvents();
} else {
_eventsHasMore = false;
/* Remove the Load More button */
const btn = document.querySelector('[data-action="load-more-events"]');
if (btn && btn.parentElement) btn.parentElement.remove();
}
}
export function toggleFilters() {
$id('filter-panel')?.classList.toggle('active');
}
// ── Event Detail Panel ──────────────────────────────────────
export async function showDetail(eventId) {
const panel = $id('detail-panel'), body = $id('detail-body');
if (!panel || !body) return;
panel.classList.add('open');
body.innerHTML = skeletonRows(6);
const evt = await api('/api/honeypot/events/' + eventId);
if (!evt) { body.innerHTML = '<p class="text-error">Failed to load event</p>'; return; }
let html = '';
// Connection
html += '<div class="detail-group"><h4 class="detail-group-title">Connection</h4><div class="detail-fields">';
if (evt.src_ip) html += '<div class="detail-field detail-field-col"><span class="detail-label">Source IP</span><span class="detail-value clickable" data-action="attacker" data-ip="' + escHtml(evt.src_ip) + '">' + escHtml(evt.src_ip) + '</span></div>';
if (evt.src_port) html += '<div class="detail-field detail-field-col"><span class="detail-label">Src Port</span><span class="detail-value">' + escHtml(String(evt.src_port)) + '</span></div>';
if (evt.dst_port) html += '<div class="detail-field detail-field-col"><span class="detail-label">Dst Port</span><span class="detail-value">' + escHtml(String(evt.dst_port)) + '</span></div>';
if (evt.service) html += '<div class="detail-field detail-field-col"><span class="detail-label">Service</span><span class="detail-value">' + escHtml(evt.service) + '</span></div>';
if (evt.session_id) html += '<div class="detail-field detail-field-col"><span class="detail-label">Session</span><span class="detail-value">' + escHtml(evt.session_id) + '</span></div>';
html += '</div></div>';
// Event
html += '<div class="detail-group"><h4 class="detail-group-title">Event</h4><div class="detail-fields">';
html += '<div class="detail-field detail-field-col"><span class="detail-label">Type</span><span class="detail-value">' + escHtml(evt.event_type) + '</span></div>';
html += '<div class="detail-field detail-field-col"><span class="detail-label">Severity</span><span class="detail-value ' + sevClass(evt.severity) + '">' + escHtml(evt.severity) + '</span></div>';
html += '<div class="detail-field detail-field-col"><span class="detail-label">Time</span><span class="detail-value">' + formatTime(evt.timestamp) + '</span></div>';
if (evt.device_id) html += '<div class="detail-field detail-field-col"><span class="detail-label">Device</span><span class="detail-value">' + escHtml(evt.device_id) + '</span></div>';
if (evt.detail) html += '<div class="detail-field detail-field-full"><span class="detail-label">Detail</span><span class="detail-value">' + escHtml(evt.detail) + '</span></div>';
html += '</div></div>';
// Auth
if (evt.username) {
html += '<div class="detail-group"><h4 class="detail-group-title">Authentication</h4><div class="detail-fields">';
html += '<div class="detail-field detail-field-col"><span class="detail-label">Username</span><span class="detail-value">' + escHtml(evt.username) + '</span></div>';
if (evt.password) html += '<div class="detail-field detail-field-col"><span class="detail-label">Password</span><span class="detail-value">' + escHtml(evt.password) + '</span></div>';
html += '</div></div>';
}
// Payload
if (evt.command || evt.url || evt.path) {
html += '<div class="detail-group"><h4 class="detail-group-title">Payload</h4><div class="detail-fields">';
if (evt.command) html += '<div class="detail-field detail-field-full"><span class="detail-label">Command</span><span class="detail-value text-accent">' + escHtml(evt.command) + '</span></div>';
if (evt.url) html += '<div class="detail-field detail-field-full"><span class="detail-label">URL</span><span class="detail-value">' + escHtml(evt.url) + '</span></div>';
if (evt.path) html += '<div class="detail-field detail-field-col"><span class="detail-label">Path</span><span class="detail-value">' + escHtml(evt.path) + '</span></div>';
html += '</div></div>';
}
// Tags
if (evt.malware_tag || evt.os_tag) {
html += '<div class="detail-group"><h4 class="detail-group-title">Tags</h4><div class="detail-fields">';
if (evt.malware_tag) html += '<span class="mitre-tag mitre-tag-malware">' + escHtml(evt.malware_tag) + '</span>';
if (evt.os_tag) html += '<span class="mitre-tag">' + escHtml(evt.os_tag) + '</span>';
html += '</div></div>';
}
// MITRE
if (evt.mitre_techniques) {
html += '<div class="detail-group"><h4 class="detail-group-title">MITRE ATT&amp;CK</h4><div class="detail-fields">';
html += buildMitreBadges(evt.mitre_techniques);
html += '</div></div>';
}
// Related
if (evt.related && evt.related.length) {
html += '<div class="detail-group"><h4 class="detail-group-title">Related Events</h4>';
evt.related.slice(0, 10).forEach(r => {
html += '<div class="ev-row" data-action="detail" data-id="' + (parseInt(r.id) || 0) + '">#' + (parseInt(r.id) || 0) + ' ' + escHtml(r.event_type) + ' \u2014 ' + escHtml(r.severity) + ' \u2014 ' + formatTime(r.timestamp) + '</div>';
});
html += '</div>';
}
// Copy JSON
window._detailEvtJson = JSON.stringify(evt, null, 2);
html += '<div class="detail-copy-row"><button class="btn btn-sm btn-ghost" data-action="copy-json">Copy JSON</button></div>';
body.innerHTML = html;
}
// ── Export ───────────────────────────────────────────────────
export function exportData(format) {
const params = new URLSearchParams();
params.set('format', format);
const q = ($id('search-input')?.value || '').trim();
if (q) params.set('q', q);
if ($id('f-type')?.value) params.set('type', $id('f-type').value);
if ($id('f-sev')?.value) params.set('severity', $id('f-sev').value);
if ($id('f-service')?.value) params.set('service', $id('f-service').value);
const ip = ($id('f-ip')?.value || '').trim();
if (ip) params.set('ip', ip);
window.open('/api/honeypot/export?' + params.toString());
}
// ── Attacker Modal ──────────────────────────────────────────
export async function showAttackerModal(ip) {
const modal = $id('attacker-modal'), body = $id('modal-body');
if (!modal || !body) return;
modal.classList.add('open');
$id('modal-title').textContent = ip;
body.innerHTML = skeletonRows(5);
const data = await api('/api/honeypot/attacker/' + ip);
if (!data) { body.innerHTML = '<p>Not found</p>'; return; }
const credCount = data.credentials?.length || 0;
const cmdCount = data.commands?.length || 0;
const sessCount = data.sessions?.length || 0;
let html = '';
// Header badges
html += '<div class="modal-header-badges">';
html += '<span class="modal-header-ip">' + escHtml(data.ip) + '</span>';
if (data.vendor) html += '<span class="modal-header-vendor">' + escHtml(data.vendor) + '</span>';
if (data.country_code) html += '<span class="modal-header-country">' + countryFlag(data.country_code) + ' ' + escHtml(data.country || data.country_code) + '</span>';
html += '</div>';
// Stats grid
html += '<div class="modal-stat-grid">';
[{v: data.total_events || 0, l: 'Events'}, {v: credCount, l: 'Credentials'}, {v: cmdCount, l: 'Commands'}, {v: sessCount, l: 'Sessions'}].forEach(s => {
html += '<div class="modal-stat-card"><div class="modal-stat-val">' + s.v + '</div><div class="modal-stat-label">' + s.l + '</div></div>';
});
html += '</div>';
// Geo
if (data.is_private) {
html += '<div class="modal-info-block"><div class="modal-info-label">Network</div><div class="modal-info-row">Private Network (LAN)</div>';
if (data.vendor) html += '<div class="modal-info-row">Vendor: ' + escHtml(data.vendor) + '</div>';
html += '</div>';
} else if (data.country || data.city || data.isp) {
html += '<div class="modal-info-block"><div class="modal-info-label">Geo / Threat Intel</div>';
if (data.country) html += '<div class="modal-info-row">' + countryFlag(data.country_code) + ' ' + escHtml(data.country) + '</div>';
if (data.city) html += '<div class="modal-info-row">City: ' + escHtml(data.city) + '</div>';
if (data.isp) html += '<div class="modal-info-row">ISP: ' + escHtml(data.isp) + '</div>';
if (data.vendor) html += '<div class="modal-info-row">Vendor: ' + escHtml(data.vendor) + '</div>';
html += '</div>';
}
// Tabs
const defaultTab = credCount > 0 ? 'creds' : 'events';
html += '<div class="modal-tabs-bar">';
['creds', 'cmds', 'sess', 'events'].forEach(t => {
const labels = {creds: 'Credentials (' + credCount + ')', cmds: 'Commands (' + cmdCount + ')', sess: 'Sessions (' + sessCount + ')', events: 'Events'};
html += '<button class="modal-tab-btn' + (t === defaultTab ? ' active' : '') + '" data-tab="' + t + '" data-action="modal-tab">' + labels[t] + '</button>';
});
html += '</div>';
// Creds panel
html += '<div class="modal-tab-panel" data-tab="creds" style="display:' + (defaultTab === 'creds' ? '' : 'none') + '">';
if (credCount) {
html += '<table class="ov-table"><thead><tr><th>User</th><th>Pass</th><th>Svc</th><th>Time</th></tr></thead><tbody>';
data.credentials.forEach(c => {
html += '<tr><td class="font-mono">' + escHtml(c.username || '') + '</td><td class="font-mono">' + escHtml(c.password || '') + '</td><td>' + escHtml(c.service || '') + '</td><td>' + formatTime(c.timestamp) + '</td></tr>';
});
html += '</tbody></table>';
} else html += emptyState('\uD83D\uDD12', 'No credentials', '');
html += '</div>';
// Cmds panel
html += '<div class="modal-tab-panel" data-tab="cmds" style="display:' + (defaultTab === 'cmds' ? '' : 'none') + '">';
if (cmdCount) {
html += '<table class="ov-table"><thead><tr><th>Command</th><th>Svc</th><th>Time</th></tr></thead><tbody>';
data.commands.forEach(c => {
html += '<tr><td class="font-mono text-accent">' + escHtml(c.command || '') + '</td><td>' + escHtml(c.service || '') + '</td><td>' + formatTime(c.timestamp) + '</td></tr>';
});
html += '</tbody></table>';
} else html += emptyState('\uD83D\uDCBB', 'No commands', '');
html += '</div>';
// Sessions panel
html += '<div class="modal-tab-panel" data-tab="sess" style="display:' + (defaultTab === 'sess' ? '' : 'none') + '">';
if (sessCount) {
data.sessions.forEach(s => {
html += '<div class="modal-session-card" data-action="replay" data-session="' + escHtml(s.session_id) + '" data-ip="' + escHtml(data.ip) + '" data-service="' + escHtml(s.service || '') + '">'
+ '<div class="modal-session-card-top"><span class="fw-600">' + escHtml(s.service || '') + ' session</span><span class="' + sevClass(s.max_sev) + '">' + (s.event_count || 0) + ' events</span></div>'
+ '<div class="modal-session-card-time">' + formatTime(s.start) + ' \u2014 ' + formatTime(s.end) + '</div></div>';
});
} else html += emptyState('\uD83D\uDD17', 'No sessions', '');
html += '</div>';
// Events panel
html += '<div class="modal-tab-panel" data-tab="events" style="display:' + (defaultTab === 'events' ? '' : 'none') + '">';
if (data.events?.length) {
data.events.slice(0, 20).forEach(ev => {
html += '<div class="ev-row" data-action="detail" data-id="' + (parseInt(ev.id) || 0) + '">'
+ '<span class="' + sevClass(ev.severity) + ' modal-ev-sev">' + escHtml(ev.severity || '') + '</span>'
+ '<span class="modal-ev-type">' + escHtml(ev.event_type || '') + ' ' + escHtml((ev.detail || '').substring(0, 50)) + '</span>'
+ '<span class="modal-ev-time">' + formatTime(ev.timestamp) + '</span></div>';
});
} else html += emptyState('\uD83D\uDCCB', 'No events', '');
html += '</div>';
body.innerHTML = html;
}
export function switchModalTab(tabName) {
document.querySelectorAll('.modal-tab-btn').forEach(b => {
const active = b.dataset.tab === tabName;
b.classList.toggle('active', active);
});
document.querySelectorAll('.modal-tab-panel').forEach(p => {
p.style.display = p.dataset.tab === tabName ? '' : 'none';
});
}

View File

@ -0,0 +1,160 @@
/* ESPILON Honeypot Dashboard — Kill Chain Tab */
import { S, KC_PHASES } from './state.js';
import { $id, escHtml, formatTime, formatDuration, emptyState, skeletonRows } from './utils.js';
import { api } from './api.js';
import { showToast } from './ui.js';
export function scoreColor(score) {
if (score >= 150) return 'var(--sev-crit)';
if (score >= 80) return 'var(--sev-high)';
if (score >= 40) return 'var(--sev-med)';
return 'var(--sev-low)';
}
export function phaseColor(order, total) {
const lightness = 65 - (order / total) * 35;
const saturation = 50 + (order / total) * 30;
return 'hsl(0,' + saturation + '%,' + lightness + '%)';
}
export async function renderKillChain() {
const ml = $id('main-list');
if (!ml) return;
ml.innerHTML = skeletonRows(5);
S.killchain = await api('/api/honeypot/killchain?limit=20');
if (!S.killchain || !S.killchain.attackers || !S.killchain.attackers.length) {
ml.innerHTML = emptyState('\uD83D\uDEE1\uFE0F', 'No kill chain data', 'Attack progression will be tracked here');
return;
}
const phases = S.killchain.phases || KC_PHASES;
const attackers = S.killchain.attackers;
let html = '<div class="kc-table-wrapper">';
// Header row with phase names
html += '<div class="kc-row kc-row-header">';
html += '<span class="kc-row-ip">Attacker</span>';
html += '<span class="kc-row-score">Score</span>';
html += '<div class="kc-phases">';
for (let p = 0; p < phases.length; p++) {
html += '<span class="kc-phase-label" title="' + escHtml(phases[p].label) + '">' + escHtml(phases[p].label) + '</span>';
}
html += '</div>';
html += '<span class="kc-row-events">Events</span>';
html += '<span class="kc-row-dur">Duration</span>';
html += '</div>';
// Attacker rows
for (let i = 0; i < attackers.length; i++) {
const a = attackers[i];
html += '<div class="kc-row ev-row" data-action="killchain-detail" data-ip="' + escHtml(a.ip) + '">';
// IP + full chain badge
html += '<span class="kc-row-ip">';
if (a.is_full_chain) html += '<span title="Full kill chain" class="kc-full-chain-badge">\u26A0</span>';
html += escHtml(a.ip) + '</span>';
// Score
html += '<span class="kc-row-score" style="color:' + scoreColor(a.score) + '">' + (a.score || 0) + '</span>';
// Phase progression bar
html += '<div class="kc-phases">';
for (let p = 0; p < phases.length; p++) {
const phaseId = phases[p].id;
const hasPhase = a.phases && a.phases[phaseId] && a.phases[phaseId].count > 0;
const segColor = hasPhase ? phaseColor(phases[p].order, phases.length) : 'var(--bg-secondary)';
const segTitle = phases[p].label + (hasPhase ? ' (' + a.phases[phaseId].count + ' events)' : ' (none)');
html += '<div class="kc-bar' + (hasPhase ? '' : ' empty') + '"' + (hasPhase ? ' style="background:' + segColor + '"' : '') + ' title="' + escHtml(segTitle) + '"></div>';
}
html += '</div>';
// Event count
html += '<span class="kc-row-events">' + (a.total_events || 0) + '</span>';
// Duration
const dur = a.duration_seconds ? formatDuration(a.duration_seconds) : '-';
html += '<span class="kc-row-dur">' + dur + '</span>';
html += '</div>';
}
html += '</div>';
ml.innerHTML = html;
}
export async function showKillChainDetail(ip) {
const data = await api('/api/honeypot/killchain/' + encodeURIComponent(ip));
if (!data) {
showToast('Error', 'Failed to load kill chain for ' + ip, '', 'error');
return;
}
const phases = data.phase_defs || KC_PHASES;
let html = '<div class="kc-detail-content">';
// Header
html += '<div class="kc-detail-header">';
html += '<div><span class="kc-detail-ip">' + escHtml(data.ip) + '</span></div>';
html += '<div class="kc-detail-score" style="color:' + scoreColor(data.score) + '">Score: ' + (data.score || 0) + '</div>';
html += '</div>';
// Duration + progression
html += '<div class="kc-detail-meta">';
html += '<span>Max phase: ' + escHtml(data.max_phase || '-') + '</span>';
html += '<span>Progression: ' + (data.progression_pct || 0) + '%</span>';
if (data.duration_seconds) html += '<span>Duration: ' + formatDuration(data.duration_seconds) + '</span>';
if (data.is_full_chain) html += '<span class="text-crit fw-700">\u26A0 Full Kill Chain</span>';
html += '</div>';
// Visual progression bar
html += '<div class="kc-detail-bar">';
for (let p = 0; p < phases.length; p++) {
const phaseId = phases[p].id;
const hasPhase = data.phases && data.phases[phaseId] && data.phases[phaseId].count > 0;
const segColor = hasPhase ? phaseColor(phases[p].order, phases.length) : 'var(--bg-secondary)';
const segBorder = hasPhase ? 'none' : '1px solid var(--border-color)';
html += '<div style="flex:1;background:' + segColor + ';border:' + segBorder + ';border-radius:3px"></div>';
}
html += '</div>';
// Phase details
for (let p = 0; p < phases.length; p++) {
const phaseId = phases[p].id;
const phaseData = (data.phases && data.phases[phaseId]) ? data.phases[phaseId] : null;
const active = phaseData && phaseData.count > 0;
const color = active ? phaseColor(phases[p].order, phases.length) : 'var(--border-color)';
html += '<div class="kc-detail-phase ' + (active ? 'active' : 'inactive') + '" style="border-left-color:' + color + '">';
html += '<div class="kc-detail-phase-header">';
html += '<span class="kc-detail-phase-name">' + escHtml(phases[p].label) + '</span>';
if (active) {
html += '<span class="kc-detail-phase-info">' + phaseData.count + ' events &middot; first seen ' + formatTime(phaseData.first_seen) + '</span>';
}
html += '</div>';
if (active && phaseData.techniques && phaseData.techniques.length) {
html += '<div class="kc-mitre-tags">';
for (let t = 0; t < phaseData.techniques.length; t++) {
const tech = phaseData.techniques[t];
const tid = typeof tech === 'string' ? tech : (tech.technique_id || tech.id || tech);
html += '<span class="mitre-tag">' + escHtml(String(tid)) + '</span>';
}
html += '</div>';
}
html += '</div>';
}
html += '</div>';
// Show in detail panel
const panel = $id('detail-panel');
if (panel) {
const panelBody = panel.querySelector('.detail-body') || panel;
panelBody.innerHTML = html;
panel.classList.add('open');
}
}

View File

@ -0,0 +1,122 @@
/* ESPILON Honeypot Dashboard — MITRE ATT&CK Matrix */
import { $id, escHtml, emptyState, skeletonRows } from './utils.js';
import { api } from './api.js';
// MITRE ATT&CK tactics in kill chain order
var TACTICS = [
'Reconnaissance', 'Resource Development', 'Initial Access',
'Execution', 'Persistence', 'Credential Access',
'Discovery', 'Lateral Movement', 'Collection',
'Command and Control', 'Exfiltration', 'Impact'
];
// Technique metadata: id -> { name, tactic }
var TECHNIQUES = {
'T1498.001': { name: 'Direct Network Flood', tactic: 'Impact' },
'T1595.001': { name: 'Scanning IP Blocks', tactic: 'Reconnaissance' },
'T1557.002': { name: 'ARP Cache Poisoning', tactic: 'Credential Access' },
'T1557': { name: 'Adversary-in-the-Middle', tactic: 'Credential Access' },
'T1046': { name: 'Network Service Discovery', tactic: 'Discovery' },
'T1018': { name: 'Remote System Discovery', tactic: 'Discovery' },
'T1021': { name: 'Remote Services', tactic: 'Lateral Movement' },
'T1110': { name: 'Brute Force', tactic: 'Credential Access' },
'T1059': { name: 'Command Interpreter', tactic: 'Execution' },
'T1059.004': { name: 'Unix Shell', tactic: 'Execution' },
'T1059.006': { name: 'Python', tactic: 'Execution' },
'T1190': { name: 'Exploit Public-Facing App', tactic: 'Initial Access' },
'T1071.001': { name: 'Web Protocols', tactic: 'Command and Control' },
'T1071.004': { name: 'DNS', tactic: 'Command and Control' },
'T1083': { name: 'File and Directory Discovery', tactic: 'Discovery' },
'T1005': { name: 'Data from Local System', tactic: 'Collection' },
'T1105': { name: 'Ingress Tool Transfer', tactic: 'Command and Control' },
'T1048.003': { name: 'Exfil Over Non-C2', tactic: 'Exfiltration' },
'T1583.005': { name: 'Botnet', tactic: 'Resource Development' },
'T1053': { name: 'Scheduled Task/Job', tactic: 'Persistence' },
'T1020': { name: 'Automated Exfiltration', tactic: 'Exfiltration' },
'T1565.001': { name: 'Stored Data Manipulation', tactic: 'Impact' }
};
// ── Render MITRE Tab ────────────────────────────────────────
export async function renderMitre() {
var ml = $id('main-list');
if (!ml) return;
ml.innerHTML = skeletonRows(6);
var data = await api('/api/honeypot/mitre');
if (!data || !data.techniques || Object.keys(data.techniques).length === 0) {
ml.innerHTML = emptyState('&#128737;', 'No MITRE ATT&CK coverage yet', 'Events will be mapped to techniques as they arrive');
return;
}
var techniques = data.techniques;
var tactics = data.tactics;
var maxCount = 0;
for (var k in techniques) {
if (techniques[k] > maxCount) maxCount = techniques[k];
}
// Group techniques by tactic
var byTactic = {};
TACTICS.forEach(function(t) { byTactic[t] = []; });
for (var tid in techniques) {
var meta = TECHNIQUES[tid];
if (meta) {
byTactic[meta.tactic].push({ id: tid, name: meta.name, count: techniques[tid] });
}
}
// Summary stats
var totalTechniques = Object.keys(techniques).length;
var totalTactics = Object.keys(tactics).length;
var totalHits = 0;
for (var t in techniques) totalHits += techniques[t];
var html = '';
// Summary cards
html += '<div class="mitre-summary">';
html += '<div class="mitre-stat"><span class="mitre-stat-val text-accent">' + totalTechniques + '</span><span class="mitre-stat-label">Techniques</span></div>';
html += '<div class="mitre-stat"><span class="mitre-stat-val" style="color:#fbbf24">' + totalTactics + '</span><span class="mitre-stat-label">Tactics</span></div>';
html += '<div class="mitre-stat"><span class="mitre-stat-val text-crit">' + totalHits + '</span><span class="mitre-stat-label">Total Hits</span></div>';
html += '<div class="mitre-stat"><span class="mitre-stat-val" style="color:#4ade80">' + Math.round(totalTactics / TACTICS.length * 100) + '%</span><span class="mitre-stat-label">Coverage</span></div>';
html += '</div>';
// Matrix grid
html += '<div class="mitre-matrix">';
TACTICS.forEach(function(tactic) {
var techs = byTactic[tactic];
var tacticCount = tactics[tactic] || 0;
var hasHits = tacticCount > 0;
html += '<div class="mitre-col' + (hasHits ? ' active' : '') + '">';
html += '<div class="mitre-tactic-header">';
html += '<div class="mitre-tactic-name">' + escHtml(tactic) + '</div>';
if (hasHits) {
html += '<div class="mitre-tactic-count">' + tacticCount + '</div>';
}
html += '</div>';
if (techs.length > 0) {
techs.sort(function(a, b) { return b.count - a.count; });
techs.forEach(function(t) {
var intensity = Math.min(t.count / Math.max(maxCount, 1), 1);
var hue = 270 - intensity * 270; // purple(270) -> red(0) as intensity increases
var bg = 'hsla(' + hue + ', 70%, 50%, ' + (0.15 + intensity * 0.35) + ')';
html += '<div class="mitre-technique" style="background:' + bg + '" title="' + escHtml(t.id + ': ' + t.name + ' (' + t.count + ' hits)') + '">';
html += '<div class="mitre-tech-id">' + escHtml(t.id) + '</div>';
html += '<div class="mitre-tech-name">' + escHtml(t.name) + '</div>';
html += '<div class="mitre-tech-count">' + t.count + '</div>';
html += '</div>';
});
} else {
html += '<div class="mitre-technique empty">No coverage</div>';
}
html += '</div>';
});
html += '</div>';
ml.innerHTML = html;
}

View File

@ -0,0 +1,307 @@
/* ESPILON Honeypot Dashboard — Overview Tab */
import { S, SERVICES, MONITORS } from './state.js';
import { $id, escHtml, formatTime, countryFlag, sevClass, layerForType, layerColor, animateCounter, emptyState } from './utils.js';
import { renderTimeline, renderSevDonut } from './charts.js';
import { postApi, fetchAll } from './api.js';
import { showToast } from './ui.js';
const DEVICE_IMG = '/hp-static/hp/img/floating.png';
// ── Device Cards ────────────────────────────────────────────
function renderDeviceCards() {
if (!S.devices.length) {
return emptyState('\uD83D\uDCE1', 'No honeypot devices connected', 'Connect an ESP32 honeypot to start monitoring');
}
let html = '';
S.devices.forEach(d => {
// BUG FIX: case-insensitive status comparison
const st = (d.status || '').toLowerCase();
const isOnline = st === 'online' || st === 'connected';
const dotColor = isOnline ? 'var(--status-success)' : 'var(--sev-high)';
const statusTxt = isOnline ? 'Online' : 'Offline';
const lastSeen = d.last_seen ? formatTime(d.last_seen) : '--';
let runCount = 0, totalSvc = 0;
const devSvc = S.services[d.id] || {};
for (const sn in devSvc) { totalSvc++; if (devSvc[sn] && devSvc[sn].running) runCount++; }
let evtCount = 0;
S.events.forEach(ev => { if (ev.device_id === d.id) evtCount++; });
const sevCounts = {CRITICAL:0, HIGH:0, MEDIUM:0, LOW:0};
S.events.forEach(ev => { if (ev.device_id === d.id && sevCounts[ev.severity] !== undefined) sevCounts[ev.severity]++; });
html += '<div class="device-card">';
html += '<div class="dev-card-header">';
html += '<div class="dev-card-thumb">';
html += '<img src="' + DEVICE_IMG + '" alt="Honeypot">';
html += '<div class="dev-card-dot" style="background:' + dotColor + ';box-shadow:0 0 6px ' + dotColor + '"></div>';
html += '</div>';
html += '<div class="dev-card-info">';
html += '<div class="dev-card-name">' + escHtml(d.id) + '</div>';
html += '<div class="dev-card-ip">' + escHtml(d.ip || 'unknown') + '</div>';
html += '<div class="dev-card-status"><span class="dev-card-status-dot" style="background:' + dotColor + '"></span>' + statusTxt + '</div>';
html += '<div class="dev-card-lastseen">Last seen: ' + lastSeen + '</div>';
html += '</div></div>';
// Stats bar
html += '<div class="dev-card-stats">';
html += '<div class="dev-card-stat"><div class="dev-card-stat-val">' + runCount + '/' + totalSvc + '</div><div class="dev-card-stat-label">Services</div></div>';
html += '<div class="dev-card-stat"><div class="dev-card-stat-val text-accent">' + evtCount + '</div><div class="dev-card-stat-label">Events</div></div>';
html += '<div class="dev-card-stat"><div class="dev-card-stat-val text-crit">' + sevCounts.CRITICAL + '</div><div class="dev-card-stat-label">Critical</div></div>';
html += '</div>';
// Severity mini-bar
const sevTotal = Object.values(sevCounts).reduce((a, b) => a + b, 0) || 1;
html += '<div class="dev-card-severity-bar">';
if (sevCounts.CRITICAL) html += '<div style="flex:' + (sevCounts.CRITICAL / sevTotal) + ';background:var(--sev-crit)"></div>';
if (sevCounts.HIGH) html += '<div style="flex:' + (sevCounts.HIGH / sevTotal) + ';background:var(--sev-high)"></div>';
if (sevCounts.MEDIUM) html += '<div style="flex:' + (sevCounts.MEDIUM / sevTotal) + ';background:var(--sev-med)"></div>';
if (sevCounts.LOW) html += '<div style="flex:' + (sevCounts.LOW / sevTotal) + ';background:var(--sev-low)"></div>';
html += '</div>';
html += '</div>';
});
return html;
}
// ── Top Attackers Table ─────────────────────────────────────
function renderTopAttackersTable() {
const top = S.attackers.slice(0, 8);
if (!top.length) return emptyState('\uD83D\uDC64', 'No attackers', 'Attacker data will appear here');
let html = '<table class="ov-table"><tr><th>#</th><th>IP</th><th>Vendor</th><th>Count</th></tr>';
top.forEach((a, i) => {
const flag = countryFlag(a.country_code);
html += '<tr class="clickable" data-action="attacker" data-ip="' + escHtml(a.ip) + '">'
+ '<td>' + (i + 1) + '</td>'
+ '<td class="font-mono">' + (flag ? flag + ' ' : '') + escHtml(a.ip) + '</td>'
+ '<td>' + escHtml(a.vendor || '-') + '</td>'
+ '<td class="text-right text-accent fw-600">' + (a.count || 0) + '</td></tr>';
});
return html + '</table>';
}
// ── Service Grid ────────────────────────────────────────────
function renderServiceGrid() {
let svcStatus = {};
for (const devId in S.services) {
const ds = S.services[devId];
for (const sn in ds) if (!svcStatus[sn] || ds[sn].running) svcStatus[sn] = ds[sn];
}
let html = '<div class="svc-grid">';
Object.keys(SERVICES).forEach(name => {
const running = svcStatus[name]?.running;
html += '<div class="svc-card">'
+ '<span class="svc-indicator ' + (running ? 'on' : 'off') + '"></span>'
+ '<span class="svc-name">' + escHtml(name) + '</span>'
+ '<span class="svc-port">' + escHtml(String(SERVICES[name].port)) + '</span>'
+ '<button class="svc-toggle ' + (running ? 'svc-toggle-stop' : 'svc-toggle-start') + '" '
+ 'data-action="toggle-service" data-name="' + escHtml(name) + '" '
+ 'data-running="' + (running ? '1' : '0') + '">'
+ (running ? 'Stop' : 'Start') + '</button></div>';
});
MONITORS.forEach(name => {
const running = svcStatus[name]?.running;
html += '<div class="svc-card">'
+ '<span class="svc-indicator ' + (running ? 'on' : 'off') + '"></span>'
+ '<span class="svc-name">' + escHtml(name) + '</span>'
+ '<span class="svc-port">mon</span>'
+ '<button class="svc-toggle ' + (running ? 'svc-toggle-stop' : 'svc-toggle-start') + '" '
+ 'data-action="toggle-service" data-name="' + escHtml(name) + '" '
+ 'data-running="' + (running ? '1' : '0') + '">'
+ (running ? 'Stop' : 'Start') + '</button></div>';
});
return html + '</div>';
}
// ── Recent Events ───────────────────────────────────────────
function renderRecentEvents() {
const recent = S.events.slice(0, 10);
if (!recent.length) return emptyState('\uD83D\uDCCA', 'No events', 'Events will appear here as they are detected');
return '<div>' + recent.map(ev => {
const layer = layerForType(ev.event_type);
const detail = ev.detail ? (ev.detail.length > 60 ? ev.detail.slice(0, 60) + '...' : ev.detail) : '';
return '<div class="ev-row" data-action="detail" data-id="' + (parseInt(ev.id) || 0) + '">'
+ '<div class="ev-row-line1">'
+ '<span class="ev-time">' + formatTime(ev.timestamp) + '</span>'
+ '<span class="ev-layer" style="color:' + layerColor(layer) + '">' + layer + '</span>'
+ '<span class="ev-service">' + escHtml(ev.service || '-') + '</span>'
+ '<span class="ev-severity ' + sevClass(ev.severity) + '">' + escHtml(ev.severity) + '</span>'
+ '<span class="ev-ip">' + escHtml(ev.src_ip || '') + '</span>'
+ '<span class="ev-type">' + escHtml(detail) + '</span>'
+ '</div></div>';
}).join('') + '</div>';
}
// ── Overview Render ─────────────────────────────────────────
export function renderOverview() {
const ml = $id('main-list');
if (!ml) return;
const bs = S.stats.by_severity || {};
const totalEvts = S.stats.total_events || 0;
const critCount = bs.CRITICAL || 0;
let activeCount = 0;
for (const devId in S.services) for (const sn in S.services[devId]) if (S.services[devId][sn]?.running) activeCount++;
const kpis = [
{val: totalEvts, label: 'Total Events', color: 'var(--accent-primary)'},
{val: critCount, label: 'Critical', color: 'var(--sev-crit)'},
{val: S.attackers.length, label: 'Attackers', color: 'var(--status-warning)'},
{val: activeCount, label: 'Services', color: 'var(--status-success)'},
{val: S.alerts.filter(a => !a.acknowledged).length, label: 'Alerts', color: 'var(--accent-secondary)'}
];
let html = '<div class="overview-grid">';
html += '<div class="ov-kpi-row">';
kpis.forEach(k => {
html += '<div class="ov-kpi-card">'
+ '<div class="ov-kpi-val" style="color:' + k.color + '">' + k.val + '</div>'
+ '<div class="ov-kpi-label">' + k.label + '</div></div>';
});
html += '</div>';
// Device cards row
if (S.devices.length) {
html += '<div class="ov-section">';
html += '<div class="ov-section-title">\uD83D\uDCE1 Honeypot Devices</div>';
html += '<div class="ov-devices-grid">';
html += renderDeviceCards();
html += '</div></div>';
}
// Charts row
html += '<div class="ov-chart-row">';
html += '<div class="ov-section">';
html += '<div class="ov-section-title">Activity Timeline</div>';
html += '<div id="overview-timeline"></div></div>';
html += '<div class="ov-section">';
html += '<div class="ov-section-title">Severity</div>';
html += '<div id="overview-donut"></div></div></div>';
// Data row
html += '<div class="ov-2col">';
html += '<div class="ov-section">';
html += '<div class="ov-section-title">Top Attackers</div>';
html += renderTopAttackersTable() + '</div>';
html += '<div class="ov-section">';
html += '<div class="ov-section-title" style="display:flex;align-items:center;justify-content:space-between">Services';
html += '<span style="display:flex;gap:0.5rem">';
html += '<button data-action="start-all" class="btn btn-sm btn-start">Start All</button>';
html += '<button data-action="stop-all" class="btn btn-sm btn-stop">Stop All</button>';
html += '<button data-action="refresh-status" class="btn btn-sm btn-ghost">Refresh</button>';
html += '</span></div>';
html += renderServiceGrid() + '</div></div>';
// Recent events
html += '<div class="ov-section">';
html += '<div class="ov-section-title">Recent Events</div>';
html += renderRecentEvents() + '</div></div>';
ml.innerHTML = html;
renderTimeline($id('overview-timeline'), S.timeline, 100);
renderSevDonut($id('overview-donut'), bs);
}
// ── Header KPI Updates ──────────────────────────────────────
export function updateHeaderKpis() {
const bs = S.stats.by_severity || {};
animateCounter($id('kpi-events'), S.stats.total_events || 0);
animateCounter($id('kpi-critical'), bs.CRITICAL || 0);
animateCounter($id('kpi-attackers'), S.attackers.length);
const unacked = S.alerts.filter(a => !a.acknowledged).length;
animateCounter($id('kpi-alerts'), unacked);
}
// ── Device Select ───────────────────────────────────────────
export function renderDeviceSelect() {
const sel = $id('device-select');
if (!sel) return;
const cur = sel.value;
let html = '<option value="">All Devices</option>';
S.devices.forEach(d => {
html += '<option value="' + escHtml(d.id) + '"' + (d.id === cur ? ' selected' : '') + '>' + escHtml(d.id) + ' (' + escHtml(d.ip || '?') + ')</option>';
});
sel.innerHTML = html;
}
// ── Status Bar ──────────────────────────────────────────────
export function updateStatusBar() {
const dot = $id('status-conn-dot'), text = $id('status-conn-text');
if (dot) dot.classList.toggle('connected', S.sseConnected);
if (text) text.textContent = S.sseConnected ? 'Connected' : 'Disconnected';
const ref = $id('status-refresh');
if (ref) ref.textContent = new Date().toLocaleTimeString('en-GB');
const dev = $id('status-device');
if (dev) dev.textContent = S.selectedDevice || 'All Devices';
// Rate calc
const now = Date.now(), cutoff = now - 60000;
S._eventTimes = S._eventTimes.filter(t => t > cutoff);
S.eventRate = S._eventTimes.length;
const rEl = $id('status-rate');
if (rEl) rEl.textContent = S.eventRate + ' evt/min';
const dbEl = $id('status-db-count');
if (dbEl) dbEl.textContent = (S.stats.total_events || 0).toLocaleString() + ' stored';
}
// ── Alert Banner ────────────────────────────────────────────
export function updateAlertBanner() {
const banner = $id('alert-banner');
if (!banner) return;
const unacked = S.alerts.filter(a => !a.acknowledged);
if (!unacked.length) { banner.classList.remove('active'); return; }
banner.classList.add('active');
const txt = $id('alert-banner-text'), cnt = $id('alert-banner-count');
if (txt) txt.textContent = unacked[0].message || unacked[0].rule_name || 'Alert';
if (cnt) cnt.textContent = unacked.length > 1 ? '+' + (unacked.length - 1) + ' more' : '';
}
// ── Service Control Functions (migrated from config.js) ─────
export async function toggleService(name, currentlyRunning) {
if (!S.selectedDevice) {
showToast('Error', 'No device selected', 'Select a device first.', 'error');
return;
}
/* Loading state feedback */
const btn = document.querySelector('[data-action="toggle-service"][data-name="' + name + '"]');
if (btn) { btn.disabled = true; btn.textContent = '...'; }
const action = currentlyRunning ? 'stop' : 'start';
const cmd = 'hp_' + name + '_' + action;
const res = await postApi('/api/honeypot/command', {
device_id: S.selectedDevice,
command: cmd,
argv: []
});
if (res && res.ok) {
showToast('Command Sent', cmd, 'Request ID: ' + (res.request_id || '?'), 'success');
} else {
showToast('Error', 'Failed to send ' + cmd, '', 'error');
}
setTimeout(() => { if (btn) btn.disabled = false; fetchAll(); }, 2000);
}
export async function startAll() {
await postApi('/api/honeypot/start_all', { device_id: S.selectedDevice });
showToast('Services', 'Start all command sent', '', 'info');
setTimeout(fetchAll, 2000);
}
export async function stopAll() {
await postApi('/api/honeypot/stop_all', { device_id: S.selectedDevice });
showToast('Services', 'Stop all command sent', '', 'info');
setTimeout(fetchAll, 2000);
}
export async function refreshStatus() {
await postApi('/api/honeypot/refresh_status', { device_id: S.selectedDevice });
showToast('Status', 'Refresh requested', '', 'info');
setTimeout(fetchAll, 1000);
}
export async function ackAlert(id) {
const res = await postApi('/api/honeypot/alerts/ack/' + id, {});
if (res && res.ok) {
S.alerts = S.alerts.filter(a => a.id !== id);
showToast('Alert', 'Alert acknowledged', '', 'success');
} else {
showToast('Error', 'Failed to acknowledge alert', '', 'error');
}
}

View File

@ -0,0 +1,74 @@
/* ESPILON Honeypot Dashboard — Router & Navigation */
import { S } from './state.js';
import { $id } from './utils.js';
// Tab render functions — set by app.js via setTabRenderers().
const _tabRenderers = {};
export function setTabRenderers(renderers) {
Object.assign(_tabRenderers, renderers);
}
export function switchTab(tab) {
S.tab = tab;
// Highlight active nav button
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
const btn = $id('tab-' + tab);
if (btn) btn.classList.add('active');
// Toggle search bar visibility (only on events tab)
const sb = $id('search-bar');
if (sb) sb.style.display = tab === 'timeline' ? '' : 'none';
// Collapse filter panel when leaving events tab
const fp = $id('filter-panel');
if (fp) fp.classList.remove('active');
// Clear events badge when switching to events
if (tab === 'timeline') {
const badge = $id('nav-badge-events');
if (badge) { badge.textContent = '0'; badge.style.display = 'none'; }
}
// Close mobile nav
$id('nav-tabs')?.classList.remove('nav-open');
// Render the tab
const renderer = _tabRenderers[tab];
if (renderer) renderer();
}
// ── Keyboard Shortcuts ──────────────────────────────────────
// extraHandlers: { onEscape, onRefresh, onToggleSound }
export function setupKeyboardShortcuts(extraHandlers) {
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') {
if (e.key === 'Escape') e.target.blur();
return;
}
switch (e.key) {
case '/':
e.preventDefault();
if (S.tab === 'timeline') $id('search-input')?.focus();
break;
case 'Escape':
if (extraHandlers?.onEscape) extraHandlers.onEscape();
break;
case '1': switchTab('overview'); break;
case '2': switchTab('timeline'); break;
case '3': switchTab('sessions'); break;
case '4': switchTab('credentials'); break;
case '5': switchTab('killchain'); break;
case '6': switchTab('mitre'); break;
case 'r':
if (extraHandlers?.onRefresh) extraHandlers.onRefresh();
break;
case 's':
if (extraHandlers?.onToggleSound) extraHandlers.onToggleSound();
break;
}
});
}

View File

@ -0,0 +1,152 @@
/* ESPILON Honeypot Dashboard — Sessions Tab + Replay */
import { S } from './state.js';
import { $id, escHtml, formatTime, formatDuration, sevClass, svcIcon, emptyState, skeletonRows } from './utils.js';
import { api } from './api.js';
// ── Sessions Tab ────────────────────────────────────────────
export async function renderSessions() {
const ml = $id('main-list');
if (!ml) return;
ml.innerHTML = skeletonRows(5);
const url = '/api/honeypot/sessions?limit=50';
const data = await api(url);
S.sessions = (data && data.sessions) ? data.sessions : [];
if (!S.sessions.length) {
ml.innerHTML = emptyState('\uD83D\uDC64', 'No sessions recorded', 'Session data appears when attackers interact with services');
return;
}
let html = '<div class="session-list">';
for (let i = 0; i < S.sessions.length; i++) {
const s = S.sessions[i];
const sevCls = sevClass(s.max_severity || 'LOW');
const startT = formatTime(s.start_time);
const endT = s.end_time ? formatTime(s.end_time) : 'active';
const dur = s.end_time && s.start_time
? formatDuration(Math.round((new Date(s.end_time) - new Date(s.start_time)) / 1000))
: 'ongoing';
const port = s.dst_port ? ':' + s.dst_port : '';
html += '<div class="ev-row session-row" data-action="replay" data-session="' + escHtml(s.session_id) + '" data-ip="' + escHtml(s.src_ip) + '" data-service="' + escHtml(s.service || '') + '">'
+ '<div class="ev-row-line1">'
+ '<span class="ev-service">' + svcIcon(s.service || '') + ' ' + escHtml(s.service || '?') + escHtml(port) + '</span>'
+ '<span class="ev-ip" data-action="attacker" data-ip="' + escHtml(s.src_ip) + '">' + escHtml(s.src_ip) + '</span>'
+ '<span class="ev-time">' + startT + ' \u2192 ' + endT + '</span>'
+ '<span class="ev-severity ' + sevCls + '">' + escHtml(s.max_severity || 'LOW') + '</span>'
+ '</div>'
+ '<div class="ev-row-line2">'
+ '<span>\uD83D\uDCC4 ' + (s.event_count || 0) + ' events</span>'
+ '<span>' + dur + '</span>'
+ (s.auth_count ? '<span>\uD83D\uDD11 ' + s.auth_count + '</span>' : '')
+ (s.cmd_count ? '<span>\u2328 ' + s.cmd_count + '</span>' : '')
+ (s.malware_count ? '<span class="text-crit">malware: ' + s.malware_count + '</span>' : '')
+ '</div>'
+ '</div>';
}
html += '</div>';
ml.innerHTML = html;
}
// ── Session Replay ──────────────────────────────────────────
export async function openReplay(sessionId, ip, service) {
const modal = $id('replay-modal');
modal.classList.add('open');
$id('replay-title').textContent = 'Session ' + sessionId;
$id('replay-meta').textContent = ip + ' — ' + service;
$id('replay-terminal').innerHTML = '';
$id('replay-progress').style.width = '0%';
$id('replay-counter').textContent = '0/0';
$id('replay-play-btn').textContent = 'Play';
S._replayEvents = [];
S._replayIdx = 0;
S._replayPlaying = false;
if (S._replayInterval) { clearInterval(S._replayInterval); S._replayInterval = null; }
const data = await api('/api/honeypot/sessions/' + sessionId);
if (data && data.events) S._replayEvents = data.events;
$id('replay-counter').textContent = '0/' + S._replayEvents.length;
}
export function toggleReplayPlayback() {
S._replayPlaying = !S._replayPlaying;
$id('replay-play-btn').textContent = S._replayPlaying ? 'Pause' : 'Play';
if (S._replayPlaying) {
const speed = parseInt($id('replay-speed').value) || 500;
S._replayInterval = setInterval(replayStep, speed);
} else if (S._replayInterval) {
clearInterval(S._replayInterval);
S._replayInterval = null;
}
}
export function replayStep() {
if (S._replayIdx >= S._replayEvents.length) {
S._replayPlaying = false;
$id('replay-play-btn').textContent = 'Play';
if (S._replayInterval) { clearInterval(S._replayInterval); S._replayInterval = null; }
return;
}
const evt = S._replayEvents[S._replayIdx];
const term = $id('replay-terminal');
term.innerHTML += renderReplayLine(evt);
term.scrollTop = term.scrollHeight;
S._replayIdx++;
const total = S._replayEvents.length;
$id('replay-progress').style.width = (S._replayIdx / total * 100) + '%';
$id('replay-counter').textContent = S._replayIdx + '/' + total;
}
function renderReplayLine(evt) {
const type = evt.event_type || '', detail = evt.detail || '';
if (type === 'SVC_CONNECT')
return '<div class="replay-line replay-line-connect">Connected from ' + escHtml(evt.src_ip || '') + '</div>';
if (type === 'SVC_AUTH_ATTEMPT') {
const ok = detail.toLowerCase().includes('success');
return '<div class="replay-line ' + (ok ? 'replay-line-auth-ok' : 'replay-line-auth-fail') + '">AUTH user=\'' + escHtml(evt.username || '') + '\' pass=\'' + escHtml(evt.password || '') + '\' [' + (ok ? 'OK' : 'FAIL') + ']</div>';
}
if (type === 'SVC_COMMAND')
return '<div class="replay-line replay-line-cmd">$ ' + escHtml(evt.command || detail) + '</div>';
if (type === 'SVC_HTTP_REQUEST')
return '<div class="replay-line replay-line-http">' + escHtml(detail) + '</div>';
return '<div class="replay-line">' + escHtml(detail) + '</div>';
}
export function replayReset() {
S._replayIdx = 0;
S._replayPlaying = false;
if (S._replayInterval) { clearInterval(S._replayInterval); S._replayInterval = null; }
$id('replay-play-btn').textContent = 'Play';
$id('replay-terminal').innerHTML = '';
$id('replay-progress').style.width = '0%';
$id('replay-counter').textContent = '0/' + S._replayEvents.length;
}
export function seekReplay(e) {
const bar = e.currentTarget, rect = bar.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const total = S._replayEvents.length, target = Math.round(pct * total);
const term = $id('replay-terminal');
term.innerHTML = '';
for (let i = 0; i < target && i < total; i++) term.innerHTML += renderReplayLine(S._replayEvents[i]);
term.scrollTop = term.scrollHeight;
S._replayIdx = target;
$id('replay-progress').style.width = (target / total * 100) + '%';
$id('replay-counter').textContent = target + '/' + total;
}
export function updateReplaySpeed() {
if (S._replayPlaying && S._replayInterval) {
clearInterval(S._replayInterval);
S._replayInterval = setInterval(replayStep, parseInt($id('replay-speed').value) || 500);
}
}
export function closeReplay() {
S._replayPlaying = false;
if (S._replayInterval) { clearInterval(S._replayInterval); S._replayInterval = null; }
$id('replay-modal')?.classList.remove('open');
}

View File

@ -0,0 +1,211 @@
/* ESPILON Honeypot Dashboard — Sidebar Renders */
import { S } from './state.js';
import { $id, escHtml, formatTime, countryFlag, sevColor, layerForType, layerColor, emptyState } from './utils.js';
import { renderSevDonut } from './charts.js';
// ── Severity Stats ──────────────────────────────────────────
export function renderSevStats() {
const el = $id('sev-stats');
if (!el) return;
const bySev = (S.stats && S.stats.by_severity) ? S.stats.by_severity : {};
const sevs = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
const counts = [];
let total = 0;
for (let i = 0; i < sevs.length; i++) {
const c = bySev[sevs[i]] || 0;
counts.push(c);
total += c;
}
let html = '';
// Donut placeholder
html += '<div id="sev-donut-sb" class="text-center mb-sm"></div>';
// Count list
html += '<div>';
for (let i = 0; i < sevs.length; i++) {
html += '<div class="sev-stat-row">';
html += '<span><span class="sev-stat-dot" style="background:' + sevColor(sevs[i]) + '"></span>' + sevs[i] + '</span>';
html += '<span class="sev-stat-count">' + counts[i] + '</span>';
html += '</div>';
}
html += '</div>';
el.innerHTML = html;
// Render donut into the container
const donutEl = $id('sev-donut-sb');
if (donutEl) {
const statsObj = {};
for (let i = 0; i < sevs.length; i++) statsObj[sevs[i]] = counts[i];
renderSevDonut(donutEl, statsObj);
}
}
// ── Layer Stats ─────────────────────────────────────────────
export function renderLayerStats() {
const el = $id('layer-stats');
if (!el) return;
const layerCounts = { L2: 0, L3: 0, L4: 0, L7: 0 };
if (S.events) {
for (let i = 0; i < S.events.length; i++) {
const l = layerForType(S.events[i].event_type);
if (layerCounts[l] !== undefined) layerCounts[l]++;
}
}
const layers = ['L2', 'L3', 'L4', 'L7'];
let html = '<div class="layer-grid">';
for (let i = 0; i < layers.length; i++) {
const l = layers[i];
html += '<div class="layer-box" style="border-top-color:' + layerColor(l) + '">';
html += '<div class="layer-box-val">' + layerCounts[l] + '</div>';
html += '<div class="layer-box-label">' + l + '</div>';
html += '</div>';
}
html += '</div>';
el.innerHTML = html;
}
// ── Attackers ───────────────────────────────────────────────
export function renderAttackers() {
const el = $id('attackers-list');
if (!el) return;
const list = S.attackers || [];
const top = list.slice(0, 10);
if (!top.length) {
el.innerHTML = emptyState('\u{1F464}', 'No attackers yet', '');
return;
}
const maxCount = top[0].total_events || top[0].count || 1;
let html = '';
for (let i = 0; i < top.length; i++) {
const a = top[i];
const cnt = a.total_events || a.count || 0;
const pct = Math.round((cnt / maxCount) * 100);
const flag = a.country_code ? countryFlag(a.country_code) + ' ' : '';
const vendor = a.vendor || '';
html += '<div class="atk-row" data-action="attacker" data-ip="' + escHtml(a.ip) + '">';
html += '<div class="atk-row-top">';
html += '<span class="atk-row-ip">' + flag + escHtml(a.ip) + '</span>';
html += '<span class="atk-row-count">' + cnt + '</span>';
html += '</div>';
if (vendor) {
html += '<div class="atk-row-vendor">' + escHtml(vendor) + '</div>';
}
html += '<div class="atk-row-bar">';
html += '<div class="atk-row-bar-fill" style="width:' + pct + '%"></div>';
html += '</div>';
html += '</div>';
}
el.innerHTML = html;
}
// ── Alerts ──────────────────────────────────────────────────
export function renderAlerts() {
const el = $id('alerts-list');
const badge = $id('alert-badge');
const banner = $id('alert-banner');
const bannerCount = $id('alert-banner-count');
const alerts = S.alerts || [];
const unacked = alerts.filter(function(a) { return !a.acknowledged; });
// Badge
if (badge) {
badge.textContent = unacked.length;
badge.style.display = unacked.length > 0 ? '' : 'none';
}
// Banner
if (banner) {
banner.style.display = unacked.length > 0 ? '' : 'none';
}
if (bannerCount) {
bannerCount.textContent = unacked.length;
}
// List
if (!el) return;
if (!alerts.length) {
el.innerHTML = emptyState('\u{1F514}', 'No alerts', '');
return;
}
let html = '';
for (let i = 0; i < alerts.length; i++) {
const a = alerts[i];
const borderCol = sevColor(a.severity || 'MEDIUM');
const acked = a.acknowledged;
html += '<div class="alert-item' + (acked ? ' acked' : '') + '" style="border-left-color:' + borderCol + '">';
html += '<div class="alert-item-top">';
html += '<div class="alert-item-msg">' + escHtml(a.message || a.detail || 'Alert') + '</div>';
if (!acked) {
html += '<button class="btn-ack" data-action="ack-alert" data-id="' + a.id + '">ACK</button>';
}
html += '</div>';
if (a.timestamp) {
html += '<div class="alert-item-time">' + formatTime(a.timestamp) + '</div>';
}
html += '</div>';
}
el.innerHTML = html;
}
// ── Command History ─────────────────────────────────────────
export function renderHistory() {
const el = $id('cmd-history');
if (!el) return;
const list = S.history || [];
const items = list.slice(0, 20);
if (!items.length) {
el.innerHTML = emptyState('\u{2328}\u{FE0F}', 'No commands yet', '');
return;
}
let html = '';
for (let i = 0; i < items.length; i++) {
const h = items[i];
let statusDot;
if (h.status === 'completed') {
statusDot = 'var(--sev-low)';
} else if (h.status === 'pending' || h.status === 'sent') {
statusDot = 'var(--sev-med)';
} else {
statusDot = 'var(--sev-high)';
}
let cmdName = h.command_name || h.command || '?';
if (cmdName.length > 24) cmdName = cmdName.substring(0, 24) + '\u2026';
const timeStr = h.sent_at ? formatTime(h.sent_at) : '';
html += '<div class="hist-item">';
html += '<span class="hist-dot" style="background:' + statusDot + '"></span>';
html += '<span class="hist-cmd" title="' + escHtml(h.command_name || h.command || '') + '">' + escHtml(cmdName) + '</span>';
html += '<span class="hist-time">' + timeStr + '</span>';
html += '</div>';
}
el.innerHTML = html;
}
// ── Full Sidebar Render ─────────────────────────────────────
export function renderSidebar() {
renderSevStats();
renderLayerStats();
renderAttackers();
renderAlerts();
renderHistory();
}

View File

@ -0,0 +1,106 @@
/* ESPILON Honeypot Dashboard — Server-Sent Events */
import { S } from './state.js';
import { $id } from './utils.js';
import { playAlertSound } from './audio.js';
import { showToast } from './ui.js';
// Callbacks set by app.js to avoid circular imports.
// onNewEvent(evt) — called for every new event (render row, update sidebar, etc.)
// onStatsUpdate() — called after in-memory stats are patched
let _onNewEvent = null;
let _onStatsUpdate = null;
export function setSSECallbacks({ onNewEvent, onStatsUpdate }) {
_onNewEvent = onNewEvent;
_onStatsUpdate = onStatsUpdate;
}
export function connectSSE() {
if (S._eventSource) {
try { S._eventSource.close(); } catch (x) { /* ignore */ }
}
const sev = S.minSeverity || 'MEDIUM';
const es = new EventSource('/api/honeypot/stream?min_severity=' + sev);
S._eventSource = es;
es.onopen = function() {
S.sseConnected = true;
S._sseRetry = 0;
const d1 = $id('conn-dot'), d2 = $id('status-conn-dot');
if (d1) d1.classList.add('connected');
if (d2) d2.classList.add('connected');
const l1 = $id('conn-label'), l2 = $id('status-conn-text');
if (l1) l1.textContent = 'Live';
if (l2) l2.textContent = 'Connected';
};
es.onmessage = function(e) {
let evt;
try { evt = JSON.parse(e.data); } catch (x) { return; }
if (evt.type === 'connected' || evt.type === 'keepalive') return;
// Buffer into events array
S.events.unshift(evt);
if (S.events.length > 500) S.events.length = 500;
if (evt.id && evt.id > S.lastId) S.lastId = evt.id;
// Track event times for rate calculation
S._eventTimes.push(Date.now());
if (S._eventTimes.length > 1000) S._eventTimes = S._eventTimes.slice(-500);
// Update in-memory stats
if (S.stats.by_severity) {
S.stats.by_severity[evt.severity] = (S.stats.by_severity[evt.severity] || 0) + 1;
}
if (S.stats.by_type) {
S.stats.by_type[evt.event_type] = (S.stats.by_type[evt.event_type] || 0) + 1;
}
S.stats.total_events = (S.stats.total_events || 0) + 1;
// Notify app-level callbacks
if (_onNewEvent) _onNewEvent(evt);
if (_onStatsUpdate) _onStatsUpdate();
// Update events badge
const badge = $id('nav-badge-events');
if (badge && S.tab !== 'timeline') {
const n = parseInt(badge.textContent || '0') + 1;
badge.textContent = n;
badge.style.display = 'inline-flex';
}
// Alert sounds
if (S.soundEnabled && (evt.severity === 'CRITICAL' || evt.severity === 'HIGH')) {
playAlertSound(evt.severity);
}
// Browser notifications
if (S.notifEnabled && (evt.severity === 'CRITICAL' || evt.severity === 'HIGH')) {
try {
if (Notification.permission === 'granted') {
new Notification('ESPILON Alert — ' + evt.severity, {
body: (evt.detail || evt.event_type || 'New event').substring(0, 120),
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="%231a1a28"/><circle cx="16" cy="16" r="9" fill="none" stroke="%23a855f7" stroke-width="1.5"/></svg>',
tag: 'espilon-' + (evt.id || Date.now()),
silent: true
});
}
} catch (x) { /* Notification API not available */ }
}
};
es.onerror = function() {
S.sseConnected = false;
es.close();
const d1 = $id('conn-dot'), d2 = $id('status-conn-dot');
if (d1) d1.classList.remove('connected');
if (d2) d2.classList.remove('connected');
const l1 = $id('conn-label'), l2 = $id('status-conn-text');
if (l1) l1.textContent = 'SSE';
if (l2) l2.textContent = 'Disconnected';
S._sseRetry++;
const delay = Math.min(1000 * Math.pow(2, S._sseRetry), 30000);
setTimeout(connectSSE, delay);
};
}

View File

@ -0,0 +1,51 @@
/* ESPILON Honeypot Dashboard — State */
export const SERVICES = {
ssh:{port:22}, telnet:{port:23}, http:{port:80}, mqtt:{port:1883}, ftp:{port:21},
dns:{port:53}, snmp:{port:161}, tftp:{port:69}, coap:{port:5683}, redis:{port:6379},
rtsp:{port:554}, mysql:{port:3306}, modbus:{port:502}, upnp:{port:1900}, sip:{port:5060},
telnet_alt:{port:2323}
};
export const MONITORS = ['wifi', 'net'];
export const KC_PHASES = [
{id:'recon', order:1, score:10, label:'Reconnaissance'},
{id:'weaponize', order:2, score:15, label:'Weaponization'},
{id:'delivery', order:3, score:20, label:'Delivery'},
{id:'exploitation', order:4, score:30, label:'Exploitation'},
{id:'installation', order:5, score:40, label:'Installation'},
{id:'c2', order:6, score:50, label:'Command & Control'},
{id:'actions', order:7, score:60, label:'Actions on Objectives'}
];
export const S = {
tab: 'overview',
events: [],
stats: {},
attackers: [],
timeline: [],
devices: [],
services: {},
definitions: {},
history: [],
alerts: [],
sessions: null,
credentials: null,
killchain: null,
lastId: 0,
selectedDevice: '',
sseConnected: false,
soundEnabled: false,
notifEnabled: false,
minSeverity: 'MEDIUM',
eventRate: 0,
_eventTimes: [],
_refreshTimer: null,
_sseRetry: 0,
_eventSource: null,
_replayEvents: [],
_replayIdx: 0,
_replayPlaying: false,
_replayInterval: null
};

View File

@ -0,0 +1,131 @@
/* ESPILON Honeypot Dashboard — UI Components */
import { escHtml, $id } from './utils.js';
// ── Toasts ──────────────────────────────────────────────────
export function showToast(title, msg, detail, type) {
type = type || 'info';
const c = $id('toast-container');
if (!c) return;
const icons = {
success: '&#10003;',
error: '&#10007;',
warning: '&#9888;',
info: '&#8505;'
};
const colors = {
success: 'var(--status-success)',
error: 'var(--status-error)',
warning: 'var(--status-warning)',
info: 'var(--accent-secondary)'
};
const d = document.createElement('div');
d.className = 'toast';
d.dataset.type = type;
d.innerHTML = '<div class="toast-icon" style="color:' + colors[type] + '">' + (icons[type] || '') + '</div>'
+ '<div class="toast-body"><div class="toast-title">' + escHtml(title) + '</div>'
+ '<div class="toast-text">' + escHtml(msg) + '</div></div>'
+ '<div class="toast-progress" style="background:' + colors[type] + '"></div>';
c.appendChild(d);
requestAnimationFrame(() => d.classList.add('show'));
setTimeout(() => { d.classList.remove('show'); setTimeout(() => d.remove(), 300); }, 4000);
}
// ── Modals & Panels ─────────────────────────────────────────
export function closeDetail() {
$id('detail-panel')?.classList.remove('open');
}
export function closeModal() {
$id('attacker-modal')?.classList.remove('open');
}
// ── Sidebar & Nav Toggles ───────────────────────────────────
export function toggleSidebar() {
$id('sidebar')?.classList.toggle('sidebar-open');
$id('sidebar-overlay')?.classList.toggle('active');
}
export function toggleNavMenu() {
$id('nav-tabs')?.classList.toggle('nav-open');
}
export function toggleSbSection(headerEl) {
const section = headerEl.closest('.sb-section');
if (!section) return;
section.classList.toggle('collapsed');
document.querySelectorAll('.sb-section').forEach((sec, i) => {
if (sec === section) localStorage.setItem('sb_' + i, sec.classList.contains('collapsed'));
});
}
export function scrollToAlerts() {
const el = $id('alerts-panel');
if (el) el.scrollIntoView({behavior: 'smooth'});
}
export function restoreSidebarState() {
document.querySelectorAll('.sb-section').forEach((sec, i) => {
if (localStorage.getItem('sb_' + i) === 'true') sec.classList.add('collapsed');
});
}
// ── Event Delegation ────────────────────────────────────────
// Central click handler that replaces inline onclick attributes.
// HTML elements use data-action="..." attributes; the action map
// is populated by app.js after all modules are loaded.
const _actionHandlers = {};
export function registerAction(name, handler) {
_actionHandlers[name] = handler;
}
export function registerActions(map) {
Object.assign(_actionHandlers, map);
}
export function setupEventDelegation() {
document.addEventListener('click', (e) => {
const el = e.target.closest('[data-action]');
if (!el) return;
const action = el.dataset.action;
const handler = _actionHandlers[action];
if (handler) {
e.preventDefault();
handler(el, e);
}
});
// Modal backdrop clicks
$id('attacker-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
$id('replay-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
// closeReplay is registered via registerAction from sessions.js;
// for the backdrop we just hide the modal directly.
$id('replay-modal')?.classList.remove('open');
}
});
}
// ── Responsive Sidebar ──────────────────────────────────────
export function setupResponsive() {
function checkResponsive() {
const w = window.innerWidth;
const sidebarBtn = $id('sidebar-toggle-btn');
if (sidebarBtn) sidebarBtn.style.display = w < 1024 ? '' : 'none';
// On resize to desktop, remove mobile sidebar state
if (w >= 1024) {
$id('sidebar')?.classList.remove('sidebar-open');
$id('sidebar-overlay')?.classList.remove('active');
}
}
window.addEventListener('resize', checkResponsive);
checkResponsive();
}

View File

@ -0,0 +1,103 @@
/* ESPILON Honeypot Dashboard — Utilities */
export const $id = id => document.getElementById(id);
export function debounce(fn, ms) {
let tid;
return function(...args) { clearTimeout(tid); tid = setTimeout(() => fn.apply(this, args), ms); };
}
export function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
export function formatTime(ts) {
if (!ts) return '--:--:--';
const d = new Date(typeof ts === 'number' ? (ts < 1e12 ? ts * 1000 : ts) : ts);
return d.toLocaleTimeString('en-GB');
}
export function formatDuration(s) {
if (!s || s <= 0) return '0s';
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600),
m = Math.floor(s % 3600 / 60), sec = Math.floor(s % 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + sec + 's';
return sec + 's';
}
export function countryFlag(code) {
if (!code || code.length !== 2) return '';
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
}
export function animateCounter(el, target) {
if (!el) return;
target = parseInt(target) || 0;
const start = parseInt(el.textContent) || 0;
if (start === target) return;
const duration = 400, startTime = performance.now();
function step(now) {
const p = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - p, 3);
el.textContent = Math.round(start + (target - start) * ease);
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
export function sevColor(s) {
return {CRITICAL:'var(--sev-crit)', HIGH:'var(--sev-high)', MEDIUM:'var(--sev-med)', LOW:'var(--sev-low)'}[s] || 'var(--text-secondary)';
}
export function sevClass(s) {
return {CRITICAL:'sev-critical', HIGH:'sev-high', MEDIUM:'sev-medium', LOW:'sev-low'}[s] || '';
}
export function layerForType(t) {
if (!t) return 'L3';
if (t.startsWith('WIFI_') || t.startsWith('ARP_')) return 'L2';
if (t === 'SVC_CONNECT') return 'L4';
if (t.startsWith('SVC_')) return 'L7';
return 'L3';
}
export function layerColor(l) {
return {L2:'var(--layer-l2)', L3:'var(--layer-l3)', L4:'var(--layer-l4)', L7:'var(--layer-l7)'}[l] || 'var(--text-secondary)';
}
export function svcIcon(name) {
const icons = {
ssh:'🔒', telnet:'💻', http:'🌐', mqtt:'📡',
ftp:'📁', dns:'🏷️', snmp:'📊', tftp:'📂',
coap:'⚙️', redis:'💾', rtsp:'🎥', mysql:'🗃️',
modbus:'⚙️', upnp:'🔌', sip:'📞', telnet_alt:'💻',
wifi:'📶', net:'🌐'
};
return icons[(name || '').toLowerCase()] || '⚙️';
}
export function maskPassword(p) {
if (!p) return '';
if (p.length <= 3) return p;
return p.charAt(0) + '*'.repeat(Math.min(p.length - 2, 8)) + p.charAt(p.length - 1);
}
export function emptyState(icon, title, subtitle) {
return '<div class="empty-state">'
+ '<div class="empty-state-icon">' + icon + '</div>'
+ '<div class="empty-state-title">' + escHtml(title) + '</div>'
+ (subtitle ? '<div class="empty-state-sub">' + escHtml(subtitle) + '</div>' : '')
+ '</div>';
}
export function skeletonRows(n) {
let h = '';
for (let i = 0; i < n; i++) {
h += '<div class="skeleton skeleton-row"></div>';
}
return h;
}

View File

@ -1,17 +0,0 @@
from utils.constant import _color
class LogManager:
def handle(self, log):
level = "INFO"
color = "GREEN"
if log.log_error_code != 0:
level = f"ERROR:{log.log_error_code}"
color = "RED"
print(
f"{_color(color)}"
f"[ESP:{log.id}][{log.tag}][{level}] {log.log_message}"
f"{_color('RESET')}"
)

View File

@ -6,6 +6,7 @@ protobuf>=4.21.0
# Web Server
flask>=2.0.0
flask-limiter>=3.0.0
# Configuration
python-dotenv>=1.0.0

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f172a"/>
<text x="16" y="23" text-anchor="middle" font-family="monospace" font-weight="700" font-size="20" fill="#38bdf8">&#949;</text>
</svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@ -0,0 +1,67 @@
/* ESPILON C2 — Command send + poll mixin for Alpine.js */
function commander() {
return {
cmdPending: {},
cmdHistory: [],
async sendCommand(deviceIds, command, argv = []) {
const payload = { device_ids: deviceIds, command, argv };
const res = await fetch('/api/commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.error) throw new Error(data.error);
for (const r of (data.results || [])) {
if (r.status === 'ok' && r.request_id) {
this.cmdPending[r.request_id] = {
device_id: r.device_id, command, status: 'pending', output: []
};
this._pollResult(r.request_id);
}
this.cmdHistory.unshift({
...r, command, argv,
time: new Date().toISOString()
});
}
if (this.cmdHistory.length > 200) this.cmdHistory.length = 200;
return data;
},
_pollResult(requestId) {
let attempts = 0;
const poll = async () => {
attempts++;
try {
const res = await fetch('/api/commands/' + encodeURIComponent(requestId));
const data = await res.json();
const entry = this.cmdPending[requestId];
if (entry) {
entry.output = data.output || [];
if (data.status === 'completed' || data.status === 'error' || attempts >= 60) {
entry.status = data.status || 'completed';
clearInterval(iv);
}
}
} catch (e) {
if (attempts >= 60) clearInterval(iv);
}
};
const iv = setInterval(poll, 500);
setTimeout(poll, 200);
},
getPendingOutput(requestId) {
const entry = this.cmdPending[requestId];
return entry ? entry.output : [];
},
isPending(requestId) {
const entry = this.cmdPending[requestId];
return entry && entry.status === 'pending';
}
};
}

View File

@ -0,0 +1,76 @@
/* ESPILON C2 — Reusable sortable/filterable table mixin for Alpine.js */
function dataTable(config = {}) {
return {
rows: [],
sortCol: config.defaultSort || null,
sortDir: config.defaultDir || 'asc',
filter: '',
loading: true,
page: 0,
pageSize: config.pageSize || 100,
get filteredRows() {
let r = this.rows;
if (this.filter) {
const q = this.filter.toLowerCase();
r = r.filter(row => {
for (const v of Object.values(row)) {
if (v != null && String(v).toLowerCase().includes(q)) return true;
}
return false;
});
}
if (this.sortCol) {
r = [...r].sort((a, b) => {
let va = a[this.sortCol], vb = b[this.sortCol];
if (va == null) va = '';
if (vb == null) vb = '';
if (typeof va === 'number' && typeof vb === 'number') {
return this.sortDir === 'asc' ? va - vb : vb - va;
}
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
const cmp = va < vb ? -1 : va > vb ? 1 : 0;
return this.sortDir === 'asc' ? cmp : -cmp;
});
}
return r;
},
get pagedRows() {
const start = this.page * this.pageSize;
return this.filteredRows.slice(start, start + this.pageSize);
},
get totalPages() {
return Math.ceil(this.filteredRows.length / this.pageSize);
},
toggleSort(col) {
if (this.sortCol === col) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortCol = col;
this.sortDir = 'asc';
}
},
sortClass(col) {
if (this.sortCol !== col) return '';
return this.sortDir === 'asc' ? 'sort-asc' : 'sort-desc';
},
async refresh(url, extract) {
this.loading = true;
try {
const res = await fetch(url);
const data = await res.json();
this.rows = extract ? extract(data) : (config.extract ? config.extract(data) : data);
} catch (e) {
console.error('dataTable refresh error:', e);
}
this.loading = false;
}
};
}

View File

@ -575,7 +575,8 @@ function uploadPlanImage(input) {
reader.onload = function(e) {
planImage = new Image();
planImage.onload = function() {
document.getElementById('calibrate-btn').disabled = false;
var btn = document.getElementById('calibrate-btn');
if (btn) btn.disabled = false;
drawPlan();
};
planImage.src = e.target.result;
@ -590,7 +591,8 @@ function calibratePlan() {
function clearPlan() {
planImage = null;
document.getElementById('calibrate-btn').disabled = true;
var btn = document.getElementById('calibrate-btn');
if (btn) btn.disabled = true;
drawPlan();
}

View File

@ -0,0 +1,73 @@
/* ESPILON C3PO Split Pane Resizer
Handles drag-to-resize for .resizer elements within .split-h and .split-v containers.
.resizer = vertical bar (col-resize) between siblings in a .split-h
.resizer-h = horizontal bar (row-resize) between siblings in a .split-v
*/
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.resizer').forEach(initResizer);
});
function initResizer(handle) {
var isHorizontal = handle.classList.contains('resizer-h');
var prev = handle.previousElementSibling;
var next = handle.nextElementSibling;
if (!prev || !next) return;
handle.addEventListener('mousedown', function(e) {
e.preventDefault();
e.stopPropagation();
startDrag(e, handle, prev, next, isHorizontal);
});
}
function startDrag(e, handle, prev, next, isHorizontal) {
var container = handle.parentElement;
var startPos = isHorizontal ? e.clientY : e.clientX;
var prevSize = isHorizontal ? prev.getBoundingClientRect().height : prev.getBoundingClientRect().width;
var nextSize = isHorizontal ? next.getBoundingClientRect().height : next.getBoundingClientRect().width;
var totalSize = prevSize + nextSize;
handle.classList.add('active');
document.body.style.cursor = isHorizontal ? 'row-resize' : 'col-resize';
document.body.style.userSelect = 'none';
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;cursor:' + (isHorizontal ? 'row-resize' : 'col-resize');
document.body.appendChild(overlay);
function onMove(ev) {
var currentPos = isHorizontal ? ev.clientY : ev.clientX;
var delta = currentPos - startPos;
var newPrev = Math.max(40, Math.min(totalSize - 40, prevSize + delta));
var newNext = totalSize - newPrev;
if (isHorizontal) {
prev.style.height = newPrev + 'px';
prev.style.flex = 'none';
next.style.height = newNext + 'px';
next.style.flex = 'none';
} else {
prev.style.width = newPrev + 'px';
prev.style.flex = 'none';
next.style.width = newNext + 'px';
next.style.flex = 'none';
}
}
function onUp() {
handle.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
overlay.remove();
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
})();

View File

@ -0,0 +1,57 @@
/* ESPILON C2 — Alpine.js global store */
document.addEventListener('alpine:init', () => {
Alpine.store('app', {
devices: [],
stats: { connected_devices: 0, active_cameras: 0, multilateration_scanners: 0 },
serverOnline: true,
sidebarCollapsed: localStorage.getItem('espilon_sidebar') === '1',
async fetchDevices() {
try {
const res = await fetch('/api/devices');
if (!res.ok) throw new Error(res.status);
const data = await res.json();
this.devices = data.devices || [];
this.serverOnline = true;
} catch (e) {
this.serverOnline = false;
}
},
async fetchStats() {
try {
const res = await fetch('/api/stats');
if (!res.ok) throw new Error(res.status);
this.stats = await res.json();
this.serverOnline = true;
} catch (e) {
this.serverOnline = false;
}
},
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed;
localStorage.setItem('espilon_sidebar', this.sidebarCollapsed ? '1' : '0');
},
connectedDevices() {
return this.devices.filter(d => d.status === 'Connected');
},
offlineDevices() {
return this.devices.filter(d => d.status !== 'Connected');
},
deviceIds() {
return this.connectedDevices().map(d => d.id);
},
init() {
this.fetchDevices();
this.fetchStats();
setInterval(() => this.fetchDevices(), 5000);
setInterval(() => this.fetchStats(), 10000);
}
});
});

View File

@ -0,0 +1,49 @@
/* ESPILON C2 — Shared utilities */
function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function formatDuration(seconds) {
if (seconds == null || isNaN(seconds)) return '-';
seconds = Math.round(seconds);
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h < 24) return h + 'h ' + m + 'm';
const d = Math.floor(h / 24);
return d + 'd ' + (h % 24) + 'h';
}
function formatBytes(bytes) {
if (bytes == null || isNaN(bytes)) return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function formatTimestamp(ts) {
if (!ts) return '-';
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts);
const pad = n => String(n).padStart(2, '0');
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}
function debounce(fn, ms) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
function toast(msg, type) {
const el = document.createElement('div');
el.className = 'toast' + (type ? ' toast-' + type : '');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}

View File

@ -1,6 +1,8 @@
"""Configuration loader for camera server module - reads from .env file."""
import os
import sys
import warnings
from pathlib import Path
from dotenv import load_dotenv
@ -8,13 +10,20 @@ from dotenv import load_dotenv
C2_ROOT = Path(__file__).parent.parent
ENV_FILE = C2_ROOT / ".env"
_ENV_LOADED = False
if ENV_FILE.exists():
load_dotenv(ENV_FILE)
_ENV_LOADED = True
else:
# Try .env.example as fallback for development
example_env = C2_ROOT / ".env.example"
if example_env.exists():
load_dotenv(example_env)
warnings.warn(
"No .env file found — loaded .env.example defaults. "
"Copy .env.example to .env and set secure values before production use.",
stacklevel=2,
)
def _get_bool(key: str, default: bool = False) -> bool:
@ -53,13 +62,64 @@ DEFAULT_USERNAME = os.getenv("WEB_USERNAME", "admin")
DEFAULT_PASSWORD = os.getenv("WEB_PASSWORD", "admin")
# Storage paths
IMAGE_DIR = os.getenv("IMAGE_DIR", "static/streams")
IMAGE_DIR = os.getenv("IMAGE_DIR", str(C2_ROOT / "static" / "streams"))
# Video recording
VIDEO_ENABLED = _get_bool("VIDEO_ENABLED", True)
VIDEO_PATH = os.getenv("VIDEO_PATH", "static/streams/record.avi")
VIDEO_PATH = os.getenv("VIDEO_PATH", str(C2_ROOT / "static" / "streams" / "record.avi"))
VIDEO_FPS = _get_int("VIDEO_FPS", 10)
VIDEO_CODEC = os.getenv("VIDEO_CODEC", "MJPG")
# Multilateration
MULTILAT_AUTH_TOKEN = os.getenv("MULTILAT_AUTH_TOKEN", "multilat_secret_token")
# Rate Limiting
RATE_LIMIT_DEFAULT = os.getenv("RATE_LIMIT_DEFAULT", "200 per minute")
RATE_LIMIT_LOGIN = os.getenv("RATE_LIMIT_LOGIN", "5 per minute")
# CORS
CORS_ALLOWED_ORIGINS = [
o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()
]
# Tunnel / SOCKS5 Proxy
TUNNEL_SOCKS_HOST = os.getenv("TUNNEL_SOCKS_HOST", "127.0.0.1")
TUNNEL_SOCKS_PORT = _get_int("TUNNEL_SOCKS_PORT", 1080)
TUNNEL_LISTEN_PORT = _get_int("TUNNEL_LISTEN_PORT", 2627)
# Insecure default values that must be changed for production
_INSECURE_DEFAULTS = {
"FLASK_SECRET_KEY": "change_this_for_prod",
"CAMERA_SECRET_TOKEN": "Sup3rS3cretT0k3n",
"MULTILAT_AUTH_TOKEN": "multilat_secret_token",
}
def validate_config():
"""Check that no insecure default values are still in use.
Prints warnings for each insecure default found.
Returns True if configuration is safe, False otherwise.
"""
insecure = []
if FLASK_SECRET_KEY == _INSECURE_DEFAULTS["FLASK_SECRET_KEY"]:
insecure.append("FLASK_SECRET_KEY")
if SECRET_TOKEN == _INSECURE_DEFAULTS["CAMERA_SECRET_TOKEN"].encode():
insecure.append("CAMERA_SECRET_TOKEN")
if MULTILAT_AUTH_TOKEN == _INSECURE_DEFAULTS["MULTILAT_AUTH_TOKEN"]:
insecure.append("MULTILAT_AUTH_TOKEN")
if DEFAULT_USERNAME == "admin" and DEFAULT_PASSWORD == "admin":
insecure.append("WEB_USERNAME/WEB_PASSWORD (admin/admin)")
if insecure:
print("\n" + "=" * 60)
print(" WARNING: Insecure default values detected!")
print("=" * 60)
for name in insecure:
print(f" - {name}")
print()
print(" Set these in your .env file before production use.")
print(" See .env.example for reference.")
print("=" * 60 + "\n")
return False
return True

View File

@ -4,52 +4,106 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ESPILON{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/store.js') }}"></script>
<script src="{{ url_for('static', filename='js/data-table.js') }}"></script>
<script src="{{ url_for('static', filename='js/commander.js') }}"></script>
<script src="{{ url_for('static', filename='js/resizer.js') }}"></script>
{% block head %}{% endblock %}
</head>
<body>
<header>
<div class="logo">ESPILON</div>
<nav class="main-nav">
<a href="/dashboard" class="nav-link {% if active_page == 'dashboard' %}active{% endif %}">
Dashboard
</a>
<a href="/cameras" class="nav-link {% if active_page == 'cameras' %}active{% endif %}">
Cameras
</a>
<a href="/mlat" class="nav-link {% if active_page == 'mlat' %}active{% endif %}">
MLAT
</a>
<a href="/honeypot" class="nav-link {% if active_page == 'honeypot' %}active{% endif %}">
Honeypot
</a>
</nav>
<div class="header-right">
<div class="status">
<div class="status-dot"></div>
<span id="device-count">-</span> device(s)
<body x-data x-init="$store.app.init()">
<!-- Tab Bar -->
<div class="tabs">
<div class="tab-brand">&epsilon;</div>
<a href="/dashboard" class="tab {% if active_page == 'dashboard' %}active{% endif %}">Devices</a>
<a href="/terminal" class="tab {% if active_page == 'terminal' %}active{% endif %}">Terminal</a>
<a href="/cameras" class="tab {% if active_page == 'cameras' %}active{% endif %}">Cameras</a>
<a href="/mlat" class="tab {% if active_page == 'mlat' %}active{% endif %}">MLAT</a>
<a href="/canbus" class="tab {% if active_page == 'canbus' %}active{% endif %}">CAN Bus</a>
<a href="/network" class="tab {% if active_page == 'network' %}active{% endif %}">Network</a>
<a href="/tunnel" class="tab {% if active_page == 'tunnel' %}active{% endif %}">Tunnel</a>
<a href="/fakeap" class="tab {% if active_page == 'fakeap' %}active{% endif %}">FakeAP</a>
<a href="/redteam" class="tab {% if active_page == 'redteam' %}active{% endif %}">Red Team</a>
<a href="/honeypot" class="tab {% if active_page == 'honeypot' %}active{% endif %}">Honeypot</a>
<a href="/ota" class="tab {% if active_page == 'ota' %}active{% endif %}">OTA</a>
<a href="/system" class="tab {% if active_page == 'system' %}active{% endif %}">System</a>
<div class="tab-spacer"></div>
<a href="/logout" class="tab tab-right">Logout</a>
</div>
<!-- App Layout: Sidebar + Main -->
<div class="app-layout" :class="{ 'sidebar-collapsed': $store.app.sidebarCollapsed }">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title" x-show="!$store.app.sidebarCollapsed">Devices</span>
<button class="btn-icon" @click="$store.app.toggleSidebar()" x-text="$store.app.sidebarCollapsed ? '\u25B6' : '\u25C0'"></button>
</div>
<a href="/logout" class="logout">Logout</a>
<div class="sidebar-body" x-show="!$store.app.sidebarCollapsed">
<div class="tree">
<template x-if="$store.app.connectedDevices().length > 0">
<div>
<div class="tree-group-label">Connected</div>
<template x-for="dev in $store.app.connectedDevices()" :key="dev.id">
<a :href="'/device/' + dev.id" class="tree-item">
<span class="tree-icon"><span class="statusbar-dot ok"></span></span>
<span class="tree-label" x-text="dev.id"></span>
</a>
</template>
</div>
</template>
<template x-if="$store.app.offlineDevices().length > 0">
<div>
<div class="tree-group-label">Offline</div>
<template x-for="dev in $store.app.offlineDevices()" :key="dev.id">
<a :href="'/device/' + dev.id" class="tree-item">
<span class="tree-icon"><span class="statusbar-dot err"></span></span>
<span class="tree-label" x-text="dev.id"></span>
</a>
</template>
</div>
</template>
<template x-if="$store.app.devices.length === 0">
<div class="text-muted text-xs" style="padding: 8px;">No devices</div>
</template>
</div>
{% block sidebar %}{% endblock %}
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
{% block content %}{% endblock %}
</main>
</div>
<!-- Status Bar -->
<div class="statusbar">
<div class="statusbar-left">
<span class="statusbar-item">
<span class="statusbar-dot" :class="$store.app.serverOnline ? 'ok' : 'err'"></span>
<span x-text="$store.app.serverOnline ? 'Connected' : 'Disconnected'"></span>
</span>
<span class="statusbar-sep">|</span>
<span class="statusbar-item" x-text="$store.app.stats.connected_devices + ' device(s)'"></span>
<span class="statusbar-sep">|</span>
<span class="statusbar-item" x-text="($store.app.stats.active_cameras || 0) + ' cam(s)'"></span>
<span class="statusbar-sep">|</span>
<span class="statusbar-item" x-text="($store.app.stats.multilateration_scanners || 0) + ' scanner(s)'"></span>
</div>
</header>
<div class="statusbar-right">
<span class="statusbar-item">ESPILON C2</span>
</div>
</div>
<main>
{% block content %}{% endblock %}
</main>
<script>
// Update device count in header
async function updateStats() {
try {
const res = await fetch('/api/stats');
const data = await res.json();
document.getElementById('device-count').textContent = data.connected_devices || 0;
} catch (e) {}
}
updateStats();
setInterval(updateStats, 10000);
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -1,270 +1,136 @@
{% extends "base.html" %}
{% block title %}Cameras - ESPILON{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">Cameras <span>Live Feed</span></div>
<div class="status">
<div class="status-dot"></div>
<span id="camera-count">{{ image_files|length }}</span> camera(s)
</div>
</div>
<div class="page" x-data="camerasApp()" x-init="init()">
<div class="split-h" style="flex:1;">
{% if image_files %}
<div class="grid grid-cameras" id="grid">
{% for img in image_files %}
<div class="card" data-camera-id="{{ img.replace('.jpg', '') }}">
<div class="card-header">
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
<div class="card-actions">
<button class="btn-record" data-camera="{{ img.replace('.jpg', '') }}" title="Start Recording">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8"/>
</svg>
</button>
<span class="badge badge-live">LIVE</span>
<!-- Left: Camera list -->
<div class="panel" style="width:320px;min-width:220px;">
<div class="panel-header">
<span>Cameras (<span x-text="cameras.length"></span>)</span>
</div>
<div class="panel-body">
<table class="dt">
<thead>
<tr>
<th>ID</th>
<th class="col-shrink">Rec</th>
</tr>
</thead>
<tbody>
<template x-for="cam in cameras" :key="cam.id">
<tr class="clickable" :class="selectedCam === cam.id ? 'selected' : ''" @click="selectedCam = cam.id">
<td x-text="cam.id.replace(/_/g, ':')"></td>
<td class="col-center">
<span x-show="cam.recording" class="rec-dot active"></span>
</td>
</tr>
</template>
</tbody>
</table>
<template x-if="cameras.length === 0">
<div class="dt-empty">No cameras</div>
</template>
</div>
</div>
<div class="card-body card-body-image">
<img src="/streams/{{ img }}?t=0"
data-src="/streams/{{ img }}"
data-default="/static/images/no-signal.png"
onerror="this.src=this.dataset.default">
</div>
<div class="record-indicator" style="display: none;">
<span class="record-dot"></span>
<span class="record-time">00:00</span>
<div class="resizer"></div>
<!-- Right: Preview + controls -->
<div class="panel flex-1">
<div class="panel-header">
<span x-text="selectedCam ? selectedCam.replace(/_/g, ':') : 'Select a camera'"></span>
<div class="panel-header-actions" x-show="selectedCam">
<button class="btn btn-sm" :class="isRecording(selectedCam) ? 'btn-danger' : ''"
@click="toggleRecording(selectedCam)"
x-text="isRecording(selectedCam) ? 'Stop Rec' : 'Record'"></button>
<span x-show="isRecording(selectedCam)" class="text-xs text-mono" style="color:var(--err);"
x-text="recTimer(selectedCam)"></span>
</div>
</div>
<div class="panel-body">
<template x-if="selectedCam">
<div class="cam-preview">
<img :src="'/streams/' + selectedCam + '.jpg?t=' + refreshTs"
:alt="selectedCam"
onerror="this.style.display='none'">
</div>
</template>
<template x-if="!selectedCam">
<div class="cam-preview">
<span class="no-signal">Select a camera from the list</span>
</div>
</template>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-cameras">
<div class="no-signal-container">
<img src="/static/images/no-signal.png" alt="No Signal" class="no-signal-img">
<h2>No active cameras</h2>
<p>Waiting for ESP32-CAM devices to send frames on UDP port 5000</p>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<style>
.card-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-record {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: var(--bg-elevated);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-record:hover {
background: var(--status-error-bg);
color: var(--status-error);
}
.btn-record.recording {
background: var(--status-error);
color: white;
animation: pulse-record 1.5s infinite;
}
@keyframes pulse-record {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.record-indicator {
padding: 8px 16px;
background: var(--bg-elevated);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--status-error);
}
.record-dot {
width: 8px;
height: 8px;
background: var(--status-error);
border-radius: 50%;
animation: pulse-record 1s infinite;
}
.record-time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.empty-cameras {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.no-signal-container {
text-align: center;
}
.no-signal-img {
max-width: 300px;
margin-bottom: 24px;
opacity: 0.8;
border-radius: 12px;
}
.no-signal-container h2 {
font-size: 20px;
color: var(--text-primary);
margin-bottom: 8px;
}
.no-signal-container p {
color: var(--text-muted);
font-size: 14px;
}
.card-body-image img {
min-height: 180px;
object-fit: contain;
background: var(--bg-tertiary);
}
</style>
<script>
// Recording state
const recordingState = {};
function camerasApp() {
return {
cameras: [],
selectedCam: null,
refreshTs: Date.now(),
recState: {},
// Refresh camera images
function refresh() {
const t = Date.now();
document.querySelectorAll('.card-body-image img').forEach(img => {
// Only update if not showing default image
if (!img.src.includes('no-signal')) {
img.src = img.dataset.src + '?t=' + t;
}
});
}
init() {
this.fetchCameras();
setInterval(() => this.fetchCameras(), 5000);
setInterval(() => { this.refreshTs = Date.now(); }, 150);
setInterval(() => this.$nextTick(() => {}), 1000);
},
// Check for new/removed cameras
async function checkCameras() {
try {
const res = await fetch('/api/cameras');
const data = await res.json();
const current = document.querySelectorAll('.card').length;
document.getElementById('camera-count').textContent = data.count || 0;
async fetchCameras() {
try {
const res = await fetch('/api/cameras');
const data = await res.json();
this.cameras = data.cameras || [];
for (const cam of this.cameras) {
if (cam.recording && !this.recState[cam.id]) {
this.recState[cam.id] = Date.now();
} else if (!cam.recording && this.recState[cam.id]) {
delete this.recState[cam.id];
}
}
if (!this.selectedCam && this.cameras.length > 0) {
this.selectedCam = this.cameras[0].id;
}
} catch (e) {}
},
// Update recording states
if (data.cameras) {
data.cameras.forEach(cam => {
updateRecordingUI(cam.id, cam.recording);
});
}
isRecording(camId) {
return !!this.recState[camId];
},
if (data.count !== current) location.reload();
} catch (e) {}
}
recTimer(camId) {
if (!this.recState[camId]) return '';
const elapsed = Math.floor((Date.now() - this.recState[camId]) / 1000);
const m = String(Math.floor(elapsed / 60)).padStart(2, '0');
const s = String(elapsed % 60).padStart(2, '0');
return m + ':' + s;
},
// Update recording UI
function updateRecordingUI(cameraId, isRecording) {
const card = document.querySelector(`[data-camera-id="${cameraId}"]`);
if (!card) return;
const btn = card.querySelector('.btn-record');
const indicator = card.querySelector('.record-indicator');
if (isRecording) {
btn.classList.add('recording');
btn.title = 'Stop Recording';
indicator.style.display = 'flex';
// Start timer if not already
if (!recordingState[cameraId]) {
recordingState[cameraId] = { startTime: Date.now() };
}
} else {
btn.classList.remove('recording');
btn.title = 'Start Recording';
indicator.style.display = 'none';
delete recordingState[cameraId];
async toggleRecording(camId) {
if (!camId) return;
const isRec = this.isRecording(camId);
const endpoint = isRec ? 'stop' : 'start';
try {
const res = await fetch('/api/recording/' + endpoint + '/' + camId, { method: 'POST' });
const data = await res.json();
if (!data.error) {
if (isRec) {
delete this.recState[camId];
} else {
this.recState[camId] = Date.now();
}
}
} catch (e) {}
}
}
// Update recording timers
function updateTimers() {
for (const [cameraId, state] of Object.entries(recordingState)) {
const card = document.querySelector(`[data-camera-id="${cameraId}"]`);
if (!card) continue;
const timeEl = card.querySelector('.record-time');
if (timeEl) {
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
const secs = (elapsed % 60).toString().padStart(2, '0');
timeEl.textContent = `${mins}:${secs}`;
}
}
}
// Toggle recording
async function toggleRecording(cameraId) {
const btn = document.querySelector(`[data-camera="${cameraId}"]`);
const isRecording = btn.classList.contains('recording');
try {
const endpoint = isRecording ? 'stop' : 'start';
const res = await fetch(`/api/recording/${endpoint}/${cameraId}`, {
method: 'POST'
});
const data = await res.json();
if (data.error) {
console.error('Recording error:', data.error);
return;
}
updateRecordingUI(cameraId, !isRecording);
if (!isRecording) {
recordingState[cameraId] = { startTime: Date.now() };
}
} catch (e) {
console.error('Recording toggle failed:', e);
}
}
// Event listeners
document.querySelectorAll('.btn-record').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const cameraId = btn.dataset.camera;
toggleRecording(cameraId);
});
});
// Intervals
setInterval(refresh, 100);
setInterval(checkCameras, 5000);
setInterval(updateTimers, 1000);
// Initial check
checkCameras();
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,225 @@
{% extends "base.html" %}
{% block title %}CAN Bus - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="canbusApp()" x-init="init()">
<div class="split-v" style="flex:1;">
<!-- Top: Frame table -->
<div class="panel flex-1">
<div class="toolbar">
<span class="toolbar-label">Frames</span>
<select class="select" x-model="selectedDevice" @change="refreshFrames()">
<option value="">all devices</option>
<template x-for="d in $store.app.connectedDevices()" :key="d.id">
<option :value="d.id" x-text="d.id"></option>
</template>
</select>
<input type="text" class="input" x-model="filterCanId" placeholder="CAN ID (hex)" style="width:120px;" @input="refreshFrames()">
<div class="toolbar-sep"></div>
<label class="checkbox-label"><input type="checkbox" x-model="autoRefresh"> Auto</label>
<button class="btn btn-sm" @click="refreshFrames()">Refresh</button>
<button class="btn btn-sm" @click="exportCsv()">Export CSV</button>
<div class="toolbar-sep"></div>
<span class="text-xs text-muted" x-text="rows.length + ' frames'"></span>
</div>
<div class="panel-body">
<table class="dt">
<thead>
<tr>
<th @click="toggleSort('received_at')" :class="sortClass('received_at')">Time</th>
<th @click="toggleSort('device_id')" :class="sortClass('device_id')">Device</th>
<th @click="toggleSort('can_id')" :class="sortClass('can_id')">CAN ID</th>
<th @click="toggleSort('dlc')" :class="sortClass('dlc')" class="col-center">DLC</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<template x-for="f in pagedRows" :key="f.received_at + f.can_id + Math.random()">
<tr>
<td x-text="formatTimestamp(f.received_at)"></td>
<td x-text="f.device_id"></td>
<td x-text="'0x' + f.can_id"></td>
<td class="col-center" x-text="f.dlc"></td>
<td class="col-wrap" x-text="f.data_hex"></td>
</tr>
</template>
</tbody>
</table>
<template x-if="rows.length === 0 && !loading">
<div class="dt-empty">No CAN frames captured</div>
</template>
</div>
<div class="panel-footer" x-show="totalPages > 1">
Page <span x-text="page + 1"></span>/<span x-text="totalPages"></span>
<button class="btn btn-sm" @click="page = Math.max(0, page-1)" :disabled="page===0">&laquo;</button>
<button class="btn btn-sm" @click="page = Math.min(totalPages-1, page+1)" :disabled="page>=totalPages-1">&raquo;</button>
</div>
</div>
<div class="resizer resizer-h"></div>
<!-- Bottom: Controls -->
<div class="panel" style="flex:0 0 auto;max-height:40%;">
<div class="subtabs">
<div class="subtab" :class="bottomTab==='controls'?'active':''" @click="bottomTab='controls'">Controls</div>
<div class="subtab" :class="bottomTab==='uds'?'active':''" @click="bottomTab='uds'">UDS / OBD</div>
<div class="subtab" :class="bottomTab==='stats'?'active':''" @click="bottomTab='stats'">Stats</div>
</div>
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
<!-- Controls Tab -->
<div x-show="bottomTab==='controls'">
<div class="form-row">
<span class="form-label">Device</span>
<select class="select" x-model="cmdDevice">
<template x-for="d in $store.app.connectedDevices()" :key="d.id">
<option :value="d.id" x-text="d.id"></option>
</template>
</select>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:8px;">
<button class="btn btn-sm btn-success" @click="cmd('can_start')">Start</button>
<button class="btn btn-sm btn-danger" @click="cmd('can_stop')">Stop</button>
<button class="btn btn-sm" @click="cmd('can_status')">Status</button>
<button class="btn btn-sm" @click="cmd('can_sniff', [sniffDuration])">Sniff</button>
<input type="number" class="input" x-model="sniffDuration" style="width:60px;" placeholder="10s">
<button class="btn btn-sm" @click="cmd('can_record', [recordDuration])">Record</button>
<input type="number" class="input" x-model="recordDuration" style="width:60px;" placeholder="10s">
<button class="btn btn-sm" @click="cmd('can_dump')">Dump</button>
<button class="btn btn-sm" @click="cmd('can_replay', [replaySpeed])">Replay</button>
<input type="number" class="input" x-model="replaySpeed" style="width:60px;" placeholder="100%">
</div>
<div class="form-row" style="margin-top:8px;">
<span class="form-label">Send</span>
<input type="text" class="input" x-model="sendId" placeholder="ID (hex)" style="width:90px;">
<input type="text" class="input flex-1" x-model="sendData" placeholder="Data (hex)">
<button class="btn btn-sm btn-primary" @click="cmd('can_send', [sendId, sendData])">TX</button>
</div>
<div class="form-row" style="margin-top:4px;">
<span class="form-label">Filter</span>
<input type="text" class="input" x-model="filterId" placeholder="CAN ID (hex)" style="width:90px;">
<button class="btn btn-sm" @click="cmd('can_filter_add', [filterId])">Add</button>
<button class="btn btn-sm" @click="cmd('can_filter_del', [filterId])">Del</button>
<button class="btn btn-sm" @click="cmd('can_filter_list')">List</button>
<button class="btn btn-sm btn-danger" @click="cmd('can_filter_clear')">Clear</button>
</div>
<!-- Command output -->
<template x-if="cmdOutput.length > 0">
<pre class="build-log" style="margin-top:8px;max-height:120px;" x-text="cmdOutput.join('\n')"></pre>
</template>
</div>
<!-- UDS/OBD Tab -->
<div x-show="bottomTab==='uds'">
<div class="flex gap-2" style="flex-wrap:wrap;">
<button class="btn btn-sm" @click="cmd('can_scan_ecu')">Scan ECUs</button>
<button class="btn btn-sm" @click="cmd('can_obd_vin')">Read VIN</button>
<button class="btn btn-sm" @click="cmd('can_obd_dtc')">Read DTCs</button>
<button class="btn btn-sm" @click="cmd('can_obd_supported')">Supported PIDs</button>
</div>
<div class="form-row" style="margin-top:8px;">
<span class="form-label">UDS Raw</span>
<input type="text" class="input" x-model="udsTxId" placeholder="TX ID" style="width:80px;">
<input type="text" class="input" x-model="udsSvc" placeholder="SVC (hex)" style="width:80px;">
<input type="text" class="input flex-1" x-model="udsData" placeholder="Data (hex)">
<button class="btn btn-sm btn-primary" @click="cmd('can_uds', [udsTxId, udsSvc, udsData].filter(Boolean))">Send</button>
</div>
<div class="form-row" style="margin-top:4px;">
<span class="form-label">OBD PID</span>
<input type="text" class="input" x-model="obdPid" placeholder="PID (hex)" style="width:80px;">
<button class="btn btn-sm" @click="cmd('can_obd', [obdPid])">Query</button>
<button class="btn btn-sm" @click="cmd('can_obd_monitor', [obdPid, obdInterval])">Monitor</button>
<input type="number" class="input" x-model="obdInterval" style="width:70px;" placeholder="1000ms">
<button class="btn btn-sm btn-danger" @click="cmd('can_obd_monitor_stop')">Stop</button>
</div>
<template x-if="cmdOutput.length > 0">
<pre class="build-log" style="margin-top:8px;max-height:120px;" x-text="cmdOutput.join('\n')"></pre>
</template>
</div>
<!-- Stats Tab -->
<div x-show="bottomTab==='stats'">
<template x-if="Object.keys(stats).length > 0">
<div class="kv">
<template x-for="(val, key) in stats" :key="key">
<div class="kv-row"><span class="kv-key" x-text="key"></span><span class="kv-val" x-text="val"></span></div>
</template>
</div>
</template>
<template x-if="Object.keys(stats).length === 0">
<div class="text-muted text-xs">No stats yet</div>
</template>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function canbusApp() {
return {
...dataTable({ defaultSort: 'received_at', defaultDir: 'desc', pageSize: 200, extract: d => d.frames || [] }),
...commander(),
selectedDevice: '',
filterCanId: '',
autoRefresh: true,
stats: {},
bottomTab: 'controls',
cmdDevice: '',
cmdOutput: [],
sniffDuration: '10', recordDuration: '10', replaySpeed: '100',
sendId: '', sendData: '', filterId: '',
udsTxId: '', udsSvc: '', udsData: '', obdPid: '', obdInterval: '1000',
init() {
this.refreshFrames();
this.refreshStats();
setInterval(() => { if (this.autoRefresh) this.refreshFrames(); }, 1000);
setInterval(() => this.refreshStats(), 5000);
},
async refreshFrames() {
let url = '/api/can/frames?limit=500';
if (this.selectedDevice) url += '&device_id=' + this.selectedDevice;
if (this.filterCanId) url += '&can_id=' + this.filterCanId;
await this.refresh(url);
},
async refreshStats() {
try { const r = await fetch('/api/can/stats'); this.stats = await r.json(); } catch(e) {}
},
exportCsv() { window.open('/api/can/frames/export', '_blank'); },
async cmd(command, argv) {
const dev = this.cmdDevice || (this.$store.app.connectedDevices()[0] || {}).id;
if (!dev) { toast('No device selected', 'error'); return; }
argv = (argv || []).map(String).filter(Boolean);
this.cmdOutput = ['Sending ' + command + '...'];
try {
const data = await this.sendCommand([dev], command, argv);
const r = (data.results || [])[0];
if (r && r.status === 'ok' && r.request_id) {
// Poll for result
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
const res = await fetch('/api/commands/' + encodeURIComponent(r.request_id));
const d = await res.json();
if (d.output) this.cmdOutput = d.output;
if (d.status === 'completed' || d.status === 'error' || attempts >= 30) clearInterval(iv);
}, 500);
} else if (r) {
this.cmdOutput = [r.message || 'Error'];
}
} catch (e) {
this.cmdOutput = ['Error: ' + e.message];
}
}
};
}
</script>
{% endblock %}

View File

@ -1,83 +1,56 @@
{% extends "base.html" %}
{% block title %}Dashboard - ESPILON{% endblock %}
{% block title %}Devices - ESPILON{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">Dashboard <span>Connected Devices</span></div>
<div class="header-stats">
<div class="stat">
<span class="stat-value" id="device-count">0</span>
<span class="stat-label">Devices</span>
<div class="page" x-data="deviceList()" x-init="init()">
<div class="panel" style="flex:1;">
<div class="panel-header">
<span>Devices <span x-text="'(' + rows.length + ')'"></span></span>
<div class="panel-header-actions">
<input type="text" class="input" placeholder="Filter..." x-model="filter" style="width:180px;">
</div>
</div>
<div class="stat">
<span class="stat-value" id="active-count">0</span>
<span class="stat-label">Active</span>
<div class="panel-body">
<table class="dt">
<thead>
<tr>
<th @click="toggleSort('id')" :class="sortClass('id')">ID</th>
<th @click="toggleSort('status')" :class="sortClass('status')" class="col-shrink">Status</th>
<th @click="toggleSort('ip')" :class="sortClass('ip')">Address</th>
<th @click="toggleSort('chip')" :class="sortClass('chip')" class="col-shrink">Chip</th>
<th>Modules</th>
<th @click="toggleSort('connected_for_seconds')" :class="sortClass('connected_for_seconds')" class="col-right">Uptime</th>
<th @click="toggleSort('last_seen_ago_seconds')" :class="sortClass('last_seen_ago_seconds')" class="col-right">Last Seen</th>
</tr>
</thead>
<tbody>
<template x-for="d in pagedRows" :key="d.id">
<tr class="clickable" @click="window.location='/device/'+d.id">
<td x-text="d.id"></td>
<td>
<span class="badge" :class="d.status==='Connected' ? 'badge-ok' : 'badge-warn'" x-text="d.status"></span>
</td>
<td x-text="(d.ip||'-')+':'+(d.port||'-')"></td>
<td x-text="d.chip || '-'"></td>
<td>
<template x-for="m in (d.modules||'').split(',').filter(Boolean)" :key="m">
<span class="badge" x-text="m" style="margin-right:2px;"></span>
</template>
</td>
<td class="col-right" x-text="formatDuration(d.connected_for_seconds)"></td>
<td class="col-right" x-text="formatDuration(d.last_seen_ago_seconds)+' ago'"></td>
</tr>
</template>
</tbody>
</table>
<template x-if="!loading && rows.length === 0">
<div class="dt-empty">No devices connected</div>
</template>
</div>
</div>
</div>
<div id="devices-grid" class="grid">
<!-- Devices loaded via JavaScript -->
</div>
<div id="empty-state" class="empty-lain" style="display: none;">
<div class="lain-container">
<pre class="lain-ascii">
⠠⡐⢠⠂⠥⠒⡌⠰⡈⢆⡑⢢⠘⡐⢢⠑⢢⠁⠦⢡⢂⠣⢌⠒⡄⢃⠆⡱⢌⠒⠌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⡀⢀⠀⠠⠀⠠⠀⠀⠀⠀⠀⠀⠀⠣⢘⡐⢢⢡⠒⡌⠒⠤⢃⠜⡰⢈⠔⢢⠑⢢⠑⡌⠒⡌⠰⢌⠒⡰⢈⠒⢌⠢⡑⢢⠁⠎⠤⡑⢂⠆⡑⠢⢌
⠠⠑⣂⢉⠒⡥⠘⡡⢑⠢⡘⠤⡉⠔⡡⠊⡅⠚⡌⠢⠜⡰⢈⡒⠌⡆⡍⠐⠀⠀⠀⠀⠀⠂⠄⡐⠀⠀⠀⠐⠀⠀⠂⠈⠐⠀⠄⠂⠀⠂⠁⢀⠀⠠⢀⠀⠀⠀⡀⠀⠈⠢⢡⢊⠔⣉⠦⡁⢎⠰⡉⠆⡑⢊⠔⢃⠌⡱⢈⠣⡘⢄⠃⡡⠋⡄⢓⡈⢆⡉⠎⡰⢉⠆⡘⠡⢃⠌
⠠⠓⡄⢊⠔⢢⠑⡐⠣⡑⢌⠢⠱⡘⢄⠓⡌⠱⢠⡉⠆⡅⢣⠘⠈⠀⠀⠀⠀⠀⠀⠀⠄⠠⠀⠠⠀⠁⠌⠀⠀⠈⠀⠈⠀⠐⠀⡀⠂⠀⠐⠀⠂⠁⡀⠠⠁⠀⠀⠀⠀⠀⠀⠈⠘⡄⢢⠑⡌⢢⠑⡌⠱⡈⠜⡐⣊⠔⡡⢒⠡⢊⠔⡡⠓⡈⠦⠘⠤⡘⢢⠑⡌⢢⠑⡃⢎⡘
⠐⡅⢊⠤⡉⢆⠱⣈⠱⡈⢆⠡⡃⠜⡠⢃⠌⣑⠢⢌⡱⠈⠁⠀⠀⠀⠠⠈⠀⠀⡐⠈⢀⠠⠀⢀⠐⠀⠈⠀⠐⠀⢁⠀⠂⡀⠀⢀⠐⠠⠁⠈⠀⠀⠀⠀⠀⠡⠐⠀⠂⠀⠀⠀⠀⠀⠁⠊⠴⡁⢎⠰⢡⠘⢢⠑⡄⢊⠔⡡⢊⠔⡨⢐⠡⠜⡰⠉⢆⡑⠢⡑⣈⠆⡱⢈⠆⡘
⠐⡌⢂⠒⣡⠊⡔⢠⠃⡜⢠⠃⡜⢠⠱⣈⠒⡌⢒⠢⠁⠀⠀⠀⠀⠄⠡⢀⠀⠀⠀⠂⠄⠀⠄⠀⢀⠀⠂⠈⠀⠡⠀⠐⠠⠀⠈⠀⠄⠀⠂⠀⠠⠀⠀⠐⠈⠐⠀⠡⢀⠈⠀⠄⠀⠀⠀⠀⠐⡁⢎⡘⠤⡉⢆⠡⡘⠤⢃⠔⡡⢎⠰⢉⠢⠱⣀⠋⠤⢌⠱⡐⠄⢎⠰⡁⢎⠰
⠐⢌⠢⡑⢄⠣⢌⠢⡑⢌⠢⡑⢌⠢⡑⢄⠣⡘⠂⠀⠀⠀⠀⠁⠀⠀⢀⠀⡈⠄⠐⠠⠀⢀⠀⠄⠂⡀⠀⠄⠈⡀⠀⠂⠀⠐⠀⢁⠀⠁⠠⠈⠀⠀⡁⠀⠁⠀⠀⠀⠄⠀⠂⡀⠂⠌⡀⠁⠀⠈⠢⡘⠤⡑⢌⠢⠑⡌⢢⠘⡐⢢⠑⡌⢢⠑⠤⣉⠒⡌⢢⠡⡉⢆⠱⡐⢌⠱
⡈⢆⠱⡈⢆⠱⡈⢔⡈⢆⠱⣈⢂⠆⡱⢈⢆⠁⠀⠀⠀⠐⠈⠀⠌⠐⡀⠀⠐⢀⠀⠂⠁⠄⠈⠀⡐⠀⠂⠈⠄⠐⠠⠀⠁⠄⡈⠠⠀⠂⢀⠠⠁⠄⠀⢈⠀⠀⡀⠠⢀⠀⠄⢀⠈⠄⠀⡀⠂⠀⠀⠁⠆⢍⠢⣉⠒⡌⢄⠣⡘⢄⠣⡐⢡⠊⡔⢠⠃⠜⣀⠣⡘⢄⠣⡘⢠⢃
⠐⡌⠰⡁⢎⠰⡁⢆⡘⢄⠣⡐⢌⠢⡑⢌⠂⠀⠀⠀⠀⠁⢀⠈⠀⢀⠀⠌⠐⠀⠈⠐⠀⠂⠌⠀⡀⠀⠀⠠⠈⠀⠄⠈⠀⠂⠀⠐⠀⠈⡀⠠⠀⠈⢀⠀⠂⠀⡀⠀⢀⠀⠈⠀⠀⡀⠀⠄⠀⡁⠂⠀⠘⡄⠣⢄⠣⡘⢄⠊⡔⠌⢢⠉⢆⠱⣈⠤⣉⠒⡄⢣⠘⡄⢣⠘⡄⣊
⠂⡌⠱⡈⠆⠥⡘⠤⡈⢆⠱⡈⢆⠱⡈⠎⠀⠀⠀⠀⠈⠄⠀⠀⠂⡀⠀⠠⠀⠂⠐⠈⠀⡁⠀⠀⠀⠀⠄⠁⠀⠀⠀⠀⠀⢀⠀⠄⡀⠠⠀⠀⠠⠁⠀⠄⠀⠄⠠⠐⠀⠀⠀⠄⠀⠄⡁⠠⠐⠀⠂⠀⠀⠨⡑⢌⢂⠱⣈⠒⡌⡘⠤⣉⢂⠒⡄⡒⢄⠣⡘⠄⢣⠘⡄⠣⠔⢢
⠐⡨⠑⡌⣘⠢⡑⢢⠑⣈⠆⡱⢈⠦⡁⠀⠀⠄⠠⠐⠀⠀⠂⠀⡐⠀⠈⠀⠀⡁⠂⠐⠀⠀⠀⠀⢂⠀⠀⠠⠁⠀⠀⠀⠈⠀⠀⠐⠀⠀⠠⠀⠐⠀⠈⠀⠀⠀⠄⠐⠀⠌⠠⠀⠄⠀⡀⠀⠂⠐⡀⠁⠀⠀⠑⡌⢢⠑⡄⢣⠘⡄⢣⠐⡌⢒⡰⢁⠎⣐⠡⢊⠅⡒⢌⠱⡈⢆
⠁⢆⠱⡐⢢⠑⡌⢢⠑⡂⠜⣀⠣⠂⠀⠀⠀⠀⠀⠀⠈⠀⢀⠀⠄⠀⠂⠁⠀⠄⠠⠀⠀⠀⠌⠀⠀⢠⡀⠀⠀⠀⠄⠀⠀⠠⠀⠂⡀⠄⠀⠀⠄⠈⠀⠀⠄⠀⠀⠀⠂⠠⠀⠀⡐⠠⠀⠁⠐⠀⠀⠐⠀⡀⠀⠘⡄⢣⠘⡄⢣⠘⡄⢣⠐⡡⢂⠥⢊⢄⠣⢌⢂⠱⡈⢆⠱⣈
⢉⠢⢡⠘⣄⠊⡔⢡⠊⡜⢠⣁⠃⠀⠀⠀⠂⠁⡀⠀⠐⠀⡀⠠⠀⠂⠐⠠⠈⠀⠀⠀⢀⠁⠀⠀⠀⢰⣧⡟⠀⠀⢀⠀⠠⠀⠁⠀⠀⠀⠂⠁⠈⠀⠀⠄⠀⠀⠀⠀⠀⠠⢀⠁⠀⠀⠂⠈⠀⠠⠁⠀⠀⠀⠀⠀⠘⡄⢣⠘⡄⢣⠘⡄⢃⠆⡡⠘⣄⠊⡔⡈⢆⠡⢒⡈⢒⠤
⢂⡑⢢⠑⡄⡊⠔⡡⢊⠔⡡⢂⠄⠀⠀⠡⠀⠐⠀⠀⠁⠐⢀⠁⠄⠀⢂⠀⠄⡀⠁⠈⠀⠀⠀⠀⠀⣸⣿⣿⡄⠈⠀⢈⠀⠀⠀⡀⠀⠀⢀⠈⠀⠀⠀⠀⡀⠄⠀⠀⠀⠐⡀⠈⠀⠄⠁⡐⠈⠀⠄⠠⠀⠀⠀⠀⠀⡜⢠⢃⠜⡠⠑⡌⢢⠘⡄⠣⢄⠣⡐⢡⠊⡔⢡⠘⡌⠒
⠂⡌⢢⠉⡔⢡⠊⡔⢡⠊⡔⡁⠀⠀⡀⠀⠂⠀⢀⠂⠌⠀⠀⡀⠈⠐⠀⠄⠀⠀⠀⠀⠀⠂⠀⠀⠀⣾⣿⣿⡆⠀⠀⠀⡀⠀⠐⠀⢠⠀⠂⢀⠀⠀⠀⠀⠄⠐⠀⡁⢀⠀⠀⠁⠀⠀⠂⢀⠐⠈⡀⠐⠀⠈⠀⠀⠀⡜⢠⠊⡔⢡⠃⡜⠠⢃⠌⡑⢢⠡⡘⢄⠣⠌⢢⠡⠌⢣
⠐⡌⢆⠱⣈⠢⡑⢌⠢⡑⡰⠁⠀⠁⠀⠐⢀⠀⠂⠀⠄⠐⠀⠀⠀⠂⢀⠀⠀⠁⡀⢀⠀⡀⠀⠀⠀⣿⣿⣿⣧⠀⠀⠀⠀⠀⠁⠀⠠⡇⠀⠀⠀⠀⣇⠀⠂⠀⠀⠀⠈⡄⠀⢀⠂⠀⠐⠀⠠⠀⡀⠀⠌⠀⠄⠀⠀⢈⠆⡱⢈⠆⡱⢈⠱⡈⠜⡠⠃⢆⠱⡈⢆⡉⢆⠱⡘⠤
⠒⡨⢐⠢⡄⠣⢌⠢⡑⢢⠑⠀⠀⠀⠀⠐⠀⢈⠀⡀⠀⠁⠈⠠⢈⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡄⠀⠀⠐⠀⠀⠀⠀⣿⠀⠠⠀⠀⣯⠀⠀⠀⠀⠀⠀⡇⠀⠀⠄⠈⢀⠐⠀⠀⠄⠀⠀⠀⠀⠈⠀⠀⡎⠰⡁⢎⠰⣁⠲⡁⢎⠰⡁⢎⠰⣁⠢⡘⢄⠣⡘⡰
⢂⠱⣈⠒⡌⠱⡈⢆⡑⠢⠍⠀⠀⠀⠀⠈⠐⠀⠂⠠⠀⠠⠐⠀⠀⠈⠀⠄⠀⠀⠀⠀⠀⠀⢰⠀⠀⣿⣿⣿⣿⣇⠀⢤⠀⠀⠀⠀⠀⢸⣟⡀⠀⠀⣿⣆⠀⠈⠀⠀⠀⢟⡀⠀⠠⠀⠀⡀⠀⠂⠀⠂⠀⠀⢂⠀⠀⠀⡜⢡⠘⠤⡁⢆⠡⡘⢄⠣⡘⢄⠣⢄⠱⡈⢆⠱⢠⠑
⠄⡃⢄⠣⢌⠱⡈⠆⡌⢡⠃⠀⠀⠀⠀⠀⠈⠀⠌⠀⠈⠀⡐⠀⠀⠀⠀⠀⡀⠀⠀⡀⠀⠄⢸⠀⠀⣿⣿⣿⣿⣿⢂⢸⡀⠀⠀⠀⠀⠘⣿⣜⡄⠀⣿⣯⡄⣀⠀⠀⠀⠺⠅⠀⠐⠀⠀⠀⠁⠀⠠⠀⠁⠄⠀⠀⠀⠀⡜⢠⠋⡔⢡⠊⡔⢡⠊⡔⠡⢊⠔⢊⠰⡁⢎⠰⠁⢎
⢄⠱⣈⠒⡌⢢⠑⡘⡄⣃⠆⠀⠀⠀⠀⠀⠀⠀⠠⠀⠄⠀⠀⢀⠀⠄⠀⠀⡁⠀⢀⠀⣤⠀⠘⡇⠀⢹⣿⣿⣿⣿⣯⣸⡴⠀⠀⠀⠀⢀⣻⣿⣬⣂⡋⢁⣤⢤⢶⣶⣤⣰⣶⠀⠀⠄⢀⠐⠀⠄⠁⡀⠠⠀⠀⠌⠀⠐⡘⡄⢣⠘⡄⢣⠘⡄⢃⠌⡱⢈⠜⡠⢃⠜⡠⢃⠍⢢
⣀⠒⡄⢣⠘⣄⢃⡒⡌⣐⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠌⠀⠈⡀⠀⠀⠀⢰⡆⠁⠀⠘⠒⠁⣀⣉⠀⢀⣀⣉⣩⣿⡟⢿⣿⣽⣯⣿⣼⣿⣿⣿⠿⢀⡿⡹⠊⠋⠉⠁⠀⠈⠛⠄⢀⠀⠂⢀⠀⠂⠀⠀⠐⠀⠀⡀⠂⠠⡑⢌⠢⡑⢌⠢⡑⢌⠢⡘⢄⠃⣆⠱⡈⠆⡱⢈⡌⡡
⢀⠣⠌⡄⠓⡄⣂⠒⡰⢈⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠂⢨⠄⠀⣔⣾⣿⡿⠿⠼⠆⠸⠿⣞⣱⡞⣿⣠⣹⣿⣿⣿⣿⣿⣿⡟⠰⢫⠗⡐⠀⠀⠀⠀⢄⠀⣶⣤⡀⠀⠀⠂⠀⠀⠀⠀⠐⠀⠀⠀⠀⠀⡱⢈⡔⠡⢊⠤⡑⢌⠢⡑⠌⡒⢠⢃⡘⠤⡑⢌⠰⢡
⢀⠣⡘⠠⢍⠰⣀⢃⠒⡩⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⢀⢸⠀⠀⢸⡃⠘⢊⠉⠀⠀⠀⠀⠀⢀⡀⠀⢉⡙⠻⣿⣿⣿⣿⣿⣿⣿⣯⣀⣷⣏⡌⠀⠠⠀⠀⠀⢈⠀⣸⣿⣿⠄⠀⠀⠀⠀⡀⠄⠀⠀⠀⠀⠀⠀⣑⠢⣐⠡⢊⠔⢌⠢⡑⢄⠣⡘⢄⠢⡘⠤⡑⢌⡑⢢
⠠⡑⢌⠱⣈⠒⡄⢣⠘⡔⢡⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠸⠆⠀⢸⠷⠊⢁⠀⠀⠄⠀⠀⠉⡀⢹⣷⡄⠻⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣿⡀⠁⠀⠄⢁⣴⣿⡿⢻⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠀⠀⢢⠑⡄⠣⢌⡘⢄⠣⡘⢄⠃⡜⠠⢃⠜⡠⢑⠢⡘⠤
⢄⠱⡈⢆⢡⠊⡔⠡⢃⠜⠤⡀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠀⠀⠘⣇⠀⢸⠀⠘⣿⣇⠈⠆⠀⠀⢐⠀⣼⣿⣷⣄⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠶⣾⡿⠿⠟⣡⣾⡀⠀⠀⠀⠠⠀⢀⠀⠀⠀⠀⢀⠠⢅⠪⡐⢅⠢⡘⢄⠣⡘⢄⠣⢌⠱⡈⢆⠱⡈⢆⠱⢌
⠄⡃⠜⡠⢂⠣⢌⠱⡈⠜⡰⢁⠆⠀⠀⠀⠀⠀⠈⡄⢳⡄⠀⠀⠀⠿⡄⢾⣿⣦⣘⠿⣷⣤⣁⣈⣴⣾⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣷⣾⣿⣿⠀⠀⠀⠠⢀⠀⠀⠀⠀⠀⠀⠤⢃⡌⢢⠑⡌⢢⠑⡌⢢⠑⡌⠒⡌⢢⠑⡌⢂⠅⡊⢔⠨
⠤⠑⢌⡐⠣⡘⠄⢣⠘⡌⠔⡩⠘⡄⠀⠀⠀⠀⠀⢃⢻⣆⠈⠀⠀⣹⣡⢸⣿⣿⣿⣷⣬⣉⣙⣋⣩⣥⣴⣾⣿⣿⣿⣿⣿⣿⣿⡟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠈⠀⠀⠀⢀⡘⢢⢡⠘⡄⢣⠐⢢⠑⡈⢆⠒⢌⡑⢌⠢⡑⡈⠆⡌⠱⣈⠒
⠠⢉⠆⡌⠱⡠⢉⠆⡱⢈⠆⡱⢉⠔⡀⠀⠀⠀⠀⠈⢆⣻⡇⣆⠈⠷⣜⣆⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢳⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⢀⠀⠀⠀⡄⣊⠔⣂⠣⠘⠤⡉⢆⢡⠱⡈⠜⡠⠒⡌⠒⠤⡑⢌⡐⠣⢄⠩
⣀⠣⡘⢠⠃⡔⣉⠢⡑⢌⡘⢄⠣⡘⡁⠀⠀⠀⠀⠀⠈⠻⣷⡘⠆⠈⢳⠺⡄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣣⢗⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠔⠀⠀⠀⠀⠀⠰⢐⠡⢊⢄⠣⡉⢆⠱⡈⢆⠢⡑⠬⡐⡡⠌⡑⢢⠁⠆⡌⠱⣈⠱
⡀⢆⡑⢢⠑⡰⢄⠱⡈⢆⡘⢄⠣⢔⡁⠀⠀⡄⠀⠀⠀⠀⠘⢻⣷⣄⠈⢫⡽⡄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣤⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⠀⠀⠀⠀⠀⠀⠀⠀⡱⢈⡒⠩⢄⠱⡈⢆⠡⡘⠤⡑⠌⢢⠑⡰⢡⠑⢢⠉⡜⢠⠃⡄⢣
⠐⡂⠜⡠⢃⠒⡌⡰⢁⠆⡸⢀⠇⢢⠄⠀⠰⡀⠀⠀⠀⠀⠀⠀⠉⠛⠳⣄⠹⣹⢆⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠁⠀⠀⡔⠡⢌⠱⡈⢆⠱⡈⢆⠑⡢⢡⢉⠆⡱⢀⠣⡘⢄⠣⢌⠢⡑⢌⠢
⠡⡘⠤⠑⡌⠒⠤⡑⠌⣂⠱⡈⢎⢢⠁⢀⡱⠰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠑⢯⠶⡘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣏⣡⣴⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠈⠀⠀⠀⠀⠀⠀⠀⠀⡰⢉⠆⡱⢈⠆⠱⡐⢌⠢⡑⠢⠌⡆⠱⡈⢆⠱⣈⠒⡄⢣⠘⡠⢃
⠐⡌⢢⢉⡔⡉⢆⠱⡈⢄⢃⠜⡠⢆⠁⢠⢂⡱⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣵⣈⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⡑⢢⠘⡄⢣⢈⠱⡈⢆⠱⣈⠱⡘⢄⠣⡑⢌⠒⡠⠑⡌⢢⠑⡄⢣
⠐⡌⢂⠦⡐⢡⠊⡔⢡⠊⡔⢨⡐⢌⠒⠤⢒⡰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⢼⣢⡙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠈⠀⠀⠀⡀⢄⠀⢑⡂⢣⠘⠤⡈⢆⠱⡈⠔⡠⢃⠜⡠⢃⠜⡠⢊⠅⠣⢌⠡⢊⠔⡡
⠈⡔⢡⢂⡑⠆⡱⢈⠆⡱⢈⠆⡘⡠⢉⠜⡐⢢⠁⠀⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠧⢌⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠉⠀⠀⠀⠀⠀⠀⠀⢀⠀⠤⡑⢊⠔⢢⡘⢄⠣⢌⠱⣀⠣⡘⠰⣁⠣⣈⠱⠈⢆⠱⡈⢌⠱⡈⢆⠣⡘⠔
⠐⡌⢂⠆⡱⢈⠔⡡⢊⠔⡡⢊⠔⡑⢌⠢⠱⣈⠒⡰⣀⠒⠤⣀⠀⡀⠀⠀⠀⠀⣈⠀⠀⠀⠀⠀⠀⠀⢤⡈⠐⠪⣙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⣠⠂⠀⠀⠀⠀⠀⡄⠀⠀⠀⠀⢆⡱⢨⠘⡄⠲⣈⠒⡌⠒⡄⠣⢌⠱⠠⡑⡄⢣⠉⢆⠢⡑⢌⢂⠱⡈⢆⠱⣈
⠐⡌⢢⠘⡄⢣⢘⠰⡈⢆⠑⠢⢌⡑⢌⠒⡡⢂⡱⠐⢤⢉⠒⡌⢢⢡⠩⢌⠓⡌⢄⠣⢢⡐⠤⠠⠀⠀⢸⣚⡳⢧⡤⣌⡈⠛⠛⠿⢻⢟⠿⠿⠟⢋⣡⢴⡛⢶⠀⠀⠐⠂⠥⡉⠄⠀⠀⠀⠘⢠⠢⡑⡌⠰⢃⠄⠣⢌⠱⣈⠒⡌⢒⡡⡘⠤⡁⠎⡄⢃⠜⡠⢊⠔⡡⢊⠔⢢
⢂⠌⡄⢣⠘⡄⢎⠰⡁⠎⡌⡑⠢⠌⡄⠣⠔⡃⢔⠩⡐⢊⠔⡌⣡⠢⡑⢌⠒⡌⢌⡒⠁⠈⠀⠀⠀⠀⠸⣴⢫⡗⡾⣡⢏⡷⢲⠖⡦⣴⠲⣖⣺⠹⣖⡣⣟⠾⠀⠀⠀⠀⢂⠵⡁⠀⠀⠀⡘⢄⠣⡐⢌⠱⡈⢌⠣⢌⠒⡄⢣⠘⡄⢢⠑⠤⡑⢌⠰⡁⢆⠱⣈⠢⡑⢌⠚⠤
⠂⡜⢠⠃⡜⠰⢈⠆⡱⢈⠔⡨⠑⠬⡐⠱⡈⡔⣈⠒⡡⢊⠔⡨⢐⠢⡑⢌⠒⡌⠢⠜⡀⠀⠀⠀⠀⠀⠀⠞⣧⢻⠵⣋⢾⡱⣏⢿⡱⣎⡳⣝⢮⡻⠵⠋⠈⠀⠀⠀⠀⠀⢉⡒⡀⠀⠀⠀⠱⡈⢆⠱⡈⢆⡑⠢⡑⠢⡑⠌⢢⠑⡌⢢⠑⢢⠑⡌⡑⢌⢂⠒⡄⢃⠜⡠⣉⠒
⠐⡄⢣⠘⡄⠓⡌⢢⠑⡌⢢⠡⡉⢆⠡⢃⠴⠐⡄⢣⠐⢣⠘⡄⢃⠆⡱⢈⡒⠌⣅⠃⠀⠀⠀⠀⠀⠀⠀⠀⠈⠋⠿⣱⢧⡝⣮⢧⡻⠜⠓⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠒⡄⠀⠀⢠⠓⡘⡄⢣⠘⠤⣈⠱⡈⣑⠨⡘⢄⠣⠘⠤⣉⠢⡑⠤⡑⢌⠢⡑⢌⡂⢎⡐⠤⣉
⠐⡌⢢⠑⡌⠱⡈⠤⠃⡜⣀⠣⣘⠠⢃⠌⡂⢇⠸⢠⠉⢆⠱⡈⢆⠱⣀⠣⡘⠬⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠂⠉⠔⣈⠆⣉⠒⡄⠣⠔⡠⢃⠜⡠⢃⠍⡔⠄⢣⠘⠤⡑⢌⠢⡁⢆⡘⠤⡘⢰⠠
⠐⡌⢂⠱⣈⠱⣈⠒⡡⢒⠠⢃⠄⠣⢌⠢⣉⠢⣁⠣⡘⢄⠣⡘⢄⠣⡄⠓⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠊⠔⠣⢌⡑⡊⠔⣡⠊⡔⢡⠊⠤⡙⠠⢍⠒⢌⠢⠑⡌⢢⠘⠤⡑⢢⠑
⠐⢌⠡⠒⡄⠣⢄⠣⡐⢡⠊⡔⢊⠱⣈⠒⣄⠃⢆⠱⣈⠦⠱⠘⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠘⠸⢠⠑⡌⢢⠉⣆⠩⡑⠬⡘⢄⠣⡑⢄⠣⡘⠤⡑⢢⢉
⠈⢆⠡⢃⠌⡑⢢⠑⡌⠡⢎⠰⡁⠎⡄⡓⠤⠙⠈⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠐⠁⠚⠤⡑⡌⠱⡈⢆⠱⡈⢆⠱⡈⢆⠱⡈⢆
⢁⠊⡔⡁⢎⠰⡁⢎⠰⡉⢆⠣⠘⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⢌⢢⡁⠇⣌⠂⡅⢊⠤⡑⢌
⠌⡒⠤⡑⢌⠢⡑⢌⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⡌⢄⠣⠜⡠⢆⠱⣈
⠒⢌⠰⢡⠊⡔⠡⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢆⡑⢊⠔⢢⠑⠤
⡈⢆⡘⢂⠱⠨⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢌⡡⢊⠆⣉⠒
⠐⢢⠘⠤⡉⡕⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠢⢅⡊⠤⣉
⢈⠢⢉⠆⡱⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠒⡌⠱⡠
</pre>
<div class="lain-message">
<h2>No devices in the Wired</h2>
<p class="typing">Waiting for ESP32 agents to connect...</p>
<p class="quote">"Present day... Present time... HAHAHA!"</p>
<div class="panel-footer" x-show="totalPages > 1">
Page <span x-text="page + 1"></span> / <span x-text="totalPages"></span>
<button class="btn btn-sm" @click="page = Math.max(0, page-1)" :disabled="page === 0">&laquo;</button>
<button class="btn btn-sm" @click="page = Math.min(totalPages-1, page+1)" :disabled="page >= totalPages-1">&raquo;</button>
</div>
</div>
</div>
@ -85,84 +58,14 @@
{% block scripts %}
<script>
function formatDuration(seconds) {
if (seconds < 60) return Math.round(seconds) + 's';
if (seconds < 3600) return Math.round(seconds / 60) + 'm';
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
return hours + 'h ' + mins + 'm';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function createDeviceCard(device) {
const statusClass = device.status === 'Connected' ? 'badge-connected' : 'badge-inactive';
const safeId = escapeHtml(String(device.id));
const safeStatus = escapeHtml(String(device.status));
const safeIp = escapeHtml(String(device.ip));
const safePort = escapeHtml(String(device.port));
return `
<div class="card" data-device-id="${safeId}">
<div class="card-header">
<span class="name">${safeId}</span>
<span class="badge ${statusClass}">${safeStatus}</span>
</div>
<div class="card-body">
<div class="device-info">
<div class="device-row">
<span class="label">IP Address</span>
<span class="value">${safeIp}:${safePort}</span>
</div>
<div class="device-row">
<span class="label">Connected</span>
<span class="value">${formatDuration(device.connected_for_seconds)}</span>
</div>
<div class="device-row">
<span class="label">Last Seen</span>
<span class="value">${formatDuration(device.last_seen_ago_seconds)} ago</span>
</div>
</div>
</div>
</div>
`;
}
async function loadDevices() {
try {
const res = await fetch('/api/devices');
const data = await res.json();
const grid = document.getElementById('devices-grid');
const empty = document.getElementById('empty-state');
const deviceCount = document.getElementById('device-count');
const activeCount = document.getElementById('active-count');
if (data.devices && data.devices.length > 0) {
grid.innerHTML = data.devices.map(createDeviceCard).join('');
grid.style.display = 'grid';
empty.style.display = 'none';
// Update stats
deviceCount.textContent = data.devices.length;
const active = data.devices.filter(d => d.status === 'Connected').length;
activeCount.textContent = active;
} else {
grid.style.display = 'none';
empty.style.display = 'flex';
deviceCount.textContent = '0';
activeCount.textContent = '0';
}
} catch (e) {
console.error('Failed to load devices:', e);
function deviceList() {
return {
...dataTable({ defaultSort: 'status', defaultDir: 'asc', extract: d => d.devices || [] }),
init() {
this.refresh('/api/devices');
setInterval(() => this.refresh('/api/devices'), 5000);
}
}
loadDevices();
setInterval(loadDevices, 5000);
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}{{ device_id }} - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="deviceDetail()" x-init="init()">
<!-- Top: Device Info -->
<div class="panel">
<div class="panel-header">
<span>
<a href="/dashboard" style="color:var(--text-muted);">&larr;</a>
{{ device_id }}
<span class="badge" :class="dev.status==='Connected' ? 'badge-ok' : 'badge-warn'" x-text="dev.status || '--'"></span>
</span>
</div>
<div class="panel-body">
<div class="kv">
<div class="kv-row"><span class="kv-key">ID</span><span class="kv-val">{{ device_id }}</span></div>
<div class="kv-row"><span class="kv-key">IP</span><span class="kv-val" x-text="dev.ip || '--'"></span></div>
<div class="kv-row"><span class="kv-key">Port</span><span class="kv-val" x-text="dev.port || '--'"></span></div>
<div class="kv-row"><span class="kv-key">Chip</span><span class="kv-val" x-text="dev.chip || '--'"></span></div>
<div class="kv-row"><span class="kv-key">Modules</span><span class="kv-val" x-text="dev.modules || '--'"></span></div>
<div class="kv-row"><span class="kv-key">Uptime</span><span class="kv-val" x-text="formatDuration(dev.connected_for_seconds)"></span></div>
<div class="kv-row"><span class="kv-key">Last Seen</span><span class="kv-val" x-text="formatDuration(dev.last_seen_ago_seconds) + ' ago'"></span></div>
</div>
</div>
</div>
<!-- Resizer -->
<div class="resizer resizer-h"></div>
<!-- Bottom: Command -->
<div class="panel" style="flex:1;">
<div class="panel-header">
<span>Command</span>
</div>
<div class="panel-body" style="display:flex;flex-direction:column;">
<div class="term-output" id="cmd-output" style="flex:1;">
<template x-for="(entry, i) in log" :key="i">
<div class="term-line">
<span class="term-cmd" x-text="'> ' + entry.cmd"></span>
<template x-if="entry.status === 'pending'">
<span class="term-pending"> pending...</span>
</template>
<template x-if="entry.output.length > 0">
<div>
<template x-for="(line, j) in entry.output" :key="j">
<div x-text="line"></div>
</template>
</div>
</template>
<template x-if="entry.status === 'completed' && entry.output.length === 0">
<span class="term-success"> OK</span>
</template>
<template x-if="entry.status === 'error'">
<span class="term-error"> error</span>
</template>
</div>
</template>
</div>
</div>
<div class="term-input-row">
<span class="term-prompt">{{ device_id }}&gt;</span>
<input type="text" class="term-input" x-model="cmdText" @keydown.enter="send()"
placeholder="system_info" autocomplete="off" spellcheck="false">
<button class="btn btn-primary btn-sm" @click="send()" style="margin-left:8px;">Send</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function deviceDetail() {
const DEVICE_ID = '{{ device_id }}';
return {
...commander(),
dev: {},
cmdText: '',
log: [],
async init() {
this.loadDevice();
setInterval(() => this.loadDevice(), 5000);
},
async loadDevice() {
try {
const res = await fetch('/api/devices');
const data = await res.json();
const d = (data.devices || []).find(d => d.id === DEVICE_ID);
if (d) this.dev = d;
} catch (e) {}
this.updatePending();
},
async send() {
const line = this.cmdText.trim();
if (!line) return;
const parts = line.split(/\s+/);
const cmd = parts[0];
const argv = parts.slice(1);
this.cmdText = '';
const entry = { cmd: line, status: 'pending', output: [], requestId: null };
this.log.push(entry);
try {
const data = await this.sendCommand([DEVICE_ID], cmd, argv);
const r = (data.results || [])[0];
if (r && r.status === 'ok') {
entry.requestId = r.request_id;
} else {
entry.status = 'error';
entry.output = [r ? r.message : 'unknown error'];
}
} catch (e) {
entry.status = 'error';
entry.output = [e.message];
}
},
updatePending() {
for (const entry of this.log) {
if (entry.status !== 'pending' || !entry.requestId) continue;
const p = this.cmdPending[entry.requestId];
if (p) {
entry.output = p.output;
if (p.status !== 'pending') entry.status = p.status;
}
}
}
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}FakeAP - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="fakeapApp()" x-init="init()">
<div class="split-h" style="flex:1;">
<!-- Left: Controls -->
<div class="panel" style="width:340px;min-width:260px;">
<div class="panel-header"><span>FakeAP Controls</span></div>
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
<div class="form-group">
<label>Device</label>
<select class="select w-full" x-model="device">
<option value="">select device...</option>
<template x-for="d in $store.app.connectedDevices()" :key="d.id">
<option :value="d.id" x-text="d.id"></option>
</template>
</select>
</div>
<!-- AP Start -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="form-row">
<span class="form-label">SSID</span>
<input type="text" class="input flex-1" x-model="ssid" placeholder="FreeWiFi">
</div>
<div class="form-row">
<span class="form-label">Auth</span>
<select class="select" x-model="authMode">
<option value="open">Open</option>
<option value="wpa2">WPA2</option>
</select>
<input type="text" class="input flex-1" x-model="password" placeholder="password" x-show="authMode==='wpa2'">
</div>
<div class="flex gap-2" style="margin-top:8px;">
<button class="btn btn-sm btn-success" @click="run('fakeap_start', [ssid, authMode, password].filter(Boolean))">Start AP</button>
<button class="btn btn-sm btn-danger" @click="run('fakeap_stop')">Stop AP</button>
<button class="btn btn-sm" @click="run('fakeap_status')">Status</button>
</div>
</div>
<!-- Portal -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="toolbar-label" style="margin-bottom:6px;">Captive Portal</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-success" @click="run('fakeap_portal_start')">Start Portal</button>
<button class="btn btn-sm btn-danger" @click="run('fakeap_portal_stop')">Stop Portal</button>
</div>
</div>
<!-- Sniffer -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="toolbar-label" style="margin-bottom:6px;">Packet Sniffer</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-success" @click="run('fakeap_sniffer_on')">Sniffer On</button>
<button class="btn btn-sm btn-danger" @click="run('fakeap_sniffer_off')">Sniffer Off</button>
</div>
</div>
<!-- Clients -->
<div>
<button class="btn btn-sm" @click="run('fakeap_clients')">List Clients</button>
</div>
</div>
</div>
<div class="resizer"></div>
<!-- Right: Output -->
<div class="panel flex-1">
<div class="panel-header">
<span>Output</span>
<button class="btn btn-sm" @click="outputLines = []">Clear</button>
</div>
<div class="term-output" style="flex:1;">
<template x-for="(l, i) in outputLines" :key="i">
<div class="term-line" :class="l.cls || ''" x-html="l.html"></div>
</template>
<template x-if="outputLines.length === 0">
<div class="term-line term-system">FakeAP command output will appear here.</div>
</template>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function fakeapApp() {
return {
...commander(),
device: '', outputLines: [],
ssid: 'FreeWiFi', authMode: 'open', password: '',
init() {},
async run(command, argv) {
if (!this.device) { toast('Select a device', 'error'); return; }
argv = (argv || []).filter(Boolean);
this.outputLines.push({ html: '<span class="term-cmd">' + escapeHtml(command + ' ' + argv.join(' ')) + '</span>' });
try {
const data = await this.sendCommand([this.device], command, argv);
const r = (data.results || [])[0];
if (r && r.status === 'ok' && r.request_id) {
const pendingIdx = this.outputLines.length;
this.outputLines.push({ html: '<span class="term-pending">pending...</span>' });
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
try {
const res = await fetch('/api/commands/' + encodeURIComponent(r.request_id));
const d = await res.json();
if (d.status === 'completed' || d.status === 'error' || attempts >= 60) {
clearInterval(iv);
this.outputLines.splice(pendingIdx, 1);
if (d.output && d.output.length) d.output.forEach(l => this.outputLines.push({ html: escapeHtml(l) }));
else this.outputLines.push({ html: '<span class="term-success">OK</span>' });
}
} catch (e) {}
}, 500);
} else if (r) {
this.outputLines.push({ html: '<span class="term-error">' + escapeHtml(r.message || 'Error') + '</span>' });
}
} catch (e) {
this.outputLines.push({ html: '<span class="term-error">' + escapeHtml(e.message) + '</span>' });
}
}
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,251 @@
{% extends "base.html" %}
{% block title %}Honeypot - ESPILON{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('honeypot.static', filename='hp/css/honeypot.css') }}">
{% endblock %}
{% block content %}
<div class="page hp-page">
<!-- Toast Container (HP's own toast system) -->
<div class="toast-container" id="toast-container"></div>
<!-- Sidebar Overlay (mobile) -->
<div class="sidebar-overlay" id="sidebar-overlay" data-action="toggle-sidebar"></div>
<!-- Alert Banner -->
<div class="alert-banner" id="alert-banner" data-action="scroll-alerts" role="alert">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" transform="scale(0.7) translate(1,1)"/><line x1="8" y1="7" x2="8" y2="10"/><circle cx="8" cy="13" r="0.5"/></svg>
<span class="alert-banner-text" id="alert-banner-text">ALERTS ACTIVE</span>
<span class="alert-banner-count" id="alert-banner-count">0</span>
<button class="alert-banner-dismiss" data-action="dismiss-banner">&times;</button>
</div>
<!-- HP Header (KPIs + Controls) -->
<header class="hp-header">
<div class="hp-header-kpis">
<div class="hp-header-kpi">
<span class="hp-header-kpi-val text-accent" id="kpi-events">0</span>
<span class="hp-header-kpi-label">Events</span>
</div>
<div class="hp-header-kpi">
<span class="hp-header-kpi-val text-crit" id="kpi-critical">0</span>
<span class="hp-header-kpi-label">Critical</span>
</div>
<div class="hp-header-kpi">
<span class="hp-header-kpi-val" style="color:var(--warn)" id="kpi-attackers">0</span>
<span class="hp-header-kpi-label">Attackers</span>
</div>
<div class="hp-header-kpi">
<span class="hp-header-kpi-val" style="color:var(--warn)" id="kpi-alerts">0</span>
<span class="hp-header-kpi-label">Alerts</span>
</div>
</div>
<div class="hp-header-controls">
<select class="hp-select" id="device-select">
<option value="">All Devices</option>
</select>
<button class="icon-btn" id="sound-btn" data-action="toggle-sound" title="Toggle alert sounds">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 5L6 9H2v6h4l5 4V5z" transform="scale(0.8)"/></svg>
</button>
<button class="icon-btn" id="notif-btn" data-action="toggle-notif" title="Toggle browser notifications" style="opacity:0.4">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9" transform="scale(0.58) translate(0,2)"/><path d="M13.73 21a2 2 0 01-3.46 0" transform="scale(0.58) translate(0,2)"/></svg>
</button>
<select class="hp-select" id="sse-sev-filter" title="SSE minimum severity" style="width:auto;min-width:70px;font-size:11px;padding:2px 6px">
<option value="LOW">All</option>
<option value="MEDIUM" selected>Med+</option>
<option value="HIGH">High+</option>
<option value="CRITICAL">Crit</option>
</select>
<div class="conn-indicator">
<span class="conn-dot" id="conn-dot"></span>
<span class="conn-label" id="conn-label">SSE</span>
</div>
</div>
</header>
<!-- Sub-Nav Bar (6 tabs) -->
<nav class="hp-nav">
<div class="hp-nav-tabs" id="nav-tabs">
<button class="nav-btn active" data-action="tab" data-tab="overview" id="tab-overview">Overview</button>
<button class="nav-btn" data-action="tab" data-tab="timeline" id="tab-timeline">Events <span class="nav-badge" id="nav-badge-events" style="display:none">0</span></button>
<button class="nav-btn" data-action="tab" data-tab="sessions" id="tab-sessions">Sessions</button>
<button class="nav-btn" data-action="tab" data-tab="credentials" id="tab-credentials">Credentials</button>
<button class="nav-btn" data-action="tab" data-tab="killchain" id="tab-killchain">Kill Chain</button>
<button class="nav-btn" data-action="tab" data-tab="mitre" id="tab-mitre">MITRE</button>
</div>
<button class="icon-btn hp-nav-hamburger" id="nav-hamburger" data-action="toggle-nav" aria-label="Toggle navigation">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="5" x2="13" y2="5"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="11" x2="13" y2="11"/></svg>
</button>
<div class="hp-nav-actions">
<button class="btn btn-success btn-sm" data-action="start-all">Start All</button>
<button class="btn btn-danger btn-sm" data-action="stop-all">Stop All</button>
<button class="btn btn-sm" data-action="refresh-status" title="Refresh">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
<button class="icon-btn" data-action="toggle-sidebar" title="Toggle sidebar" id="sidebar-toggle-btn" style="display:none">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="10" height="12" rx="1"/><line x1="7" y1="3" x2="7" y2="15"/></svg>
</button>
</div>
</nav>
<!-- Main Content -->
<div class="hp-layout">
<div class="hp-main">
<!-- Search Bar (visible on Events tab) -->
<div class="hp-search-bar" id="search-bar">
<svg class="hp-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<div class="hp-search-wrap">
<input class="hp-search-input" id="search-input" type="text" placeholder="Search events... (/ to focus)">
<button class="hp-search-clear" id="search-clear" aria-label="Clear search">&times;</button>
</div>
<button class="btn btn-sm" id="filter-toggle" data-action="toggle-filters">Filters</button>
</div>
<!-- Filter Panel -->
<div class="filter-panel" id="filter-panel">
<select class="hp-select" id="f-type">
<option value="">All Types</option>
<option>WIFI_DEAUTH</option><option>WIFI_PROBE</option><option>WIFI_EVIL_TWIN</option>
<option>WIFI_BEACON_FLOOD</option><option>WIFI_EAPOL</option><option>ARP_SPOOF</option>
<option>PORT_SCAN</option><option>ICMP_SWEEP</option><option>SYN_FLOOD</option>
<option>UDP_FLOOD</option><option>SVC_CONNECT</option><option>SVC_AUTH_ATTEMPT</option>
<option>SVC_COMMAND</option><option>SVC_HTTP_REQUEST</option><option>SVC_MQTT_MSG</option>
</select>
<select class="hp-select" id="f-sev">
<option value="">All Severity</option>
<option>LOW</option><option>MEDIUM</option><option>HIGH</option><option>CRITICAL</option>
</select>
<select class="hp-select" id="f-service">
<option value="">All Services</option>
<option>telnet</option><option>ssh</option><option>ftp</option><option>http</option>
<option>mqtt</option><option>dns</option><option>snmp</option><option>tftp</option>
<option>coap</option><option>redis</option><option>rtsp</option><option>mysql</option>
<option>modbus</option><option>upnp</option><option>sip</option>
</select>
<input class="hp-input" id="f-ip" type="text" placeholder="Source IP" style="width:9rem">
</div>
<!-- Main List -->
<div class="hp-main-content" id="main-list"></div>
</div>
<!-- Right Sidebar -->
<div class="hp-sidebar" id="sidebar">
<div class="sb-section">
<div class="sb-header" data-action="toggle-section">
<span>Severity</span>
<svg class="sb-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="sb-body" id="sev-stats"></div>
</div>
<div class="sb-section">
<div class="sb-header" data-action="toggle-section">
<span>Layers</span>
<svg class="sb-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="sb-body"><div class="layer-grid" id="layer-stats"></div></div>
</div>
<div class="sb-section">
<div class="sb-header" data-action="toggle-section">
<span>Top Attackers</span>
<svg class="sb-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="sb-body sb-body-scroll" id="attackers-list"></div>
</div>
<div class="sb-section" id="alerts-panel">
<div class="sb-header" data-action="toggle-section">
<span>Alerts <span id="alert-badge" class="nav-badge" style="display:none">0</span></span>
<svg class="sb-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="sb-body sb-body-scroll-sm" id="alerts-list"></div>
</div>
<div class="sb-section">
<div class="sb-header" data-action="toggle-section">
<span>Commands</span>
<svg class="sb-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="sb-body sb-body-scroll-sm" id="cmd-history"></div>
</div>
<div class="sb-section">
<div class="sb-header">
<span>Export</span>
<div style="display:flex;gap:4px">
<button class="btn btn-sm" data-action="export-csv">CSV</button>
<button class="btn btn-sm" data-action="export-json">JSON</button>
</div>
</div>
</div>
</div>
</div>
<!-- HP Status Footer (inline, not fixed) -->
<div class="hp-statusbar">
<div class="hp-statusbar-left">
<span class="conn-dot" id="status-conn-dot"></span>
<span id="status-conn-text">Disconnected</span>
<span class="hp-statusbar-sep">|</span>
<span id="status-refresh">--</span>
<span class="hp-statusbar-sep">|</span>
<span id="status-device">No device</span>
</div>
<div class="hp-statusbar-right">
<span id="status-rate">0 evt/min</span>
<span class="hp-statusbar-sep">|</span>
<span id="status-db-count">0 stored</span>
</div>
</div>
<!-- Detail Panel (slide-out) -->
<div class="detail-panel" id="detail-panel">
<div class="detail-hdr">
<h3>Event Detail</h3>
<button class="detail-close" data-action="close-detail" aria-label="Close detail panel">&times;</button>
</div>
<div class="detail-body" id="detail-body"></div>
</div>
<!-- Attacker Modal -->
<div class="modal-overlay" id="attacker-modal" role="dialog" aria-modal="true">
<div class="modal-content">
<div class="modal-hdr">
<h2 id="modal-title">Attacker Profile</h2>
<button class="modal-close" data-action="close-modal" aria-label="Close modal">&times;</button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<!-- Session Replay Modal -->
<div class="replay-overlay" id="replay-modal" role="dialog" aria-modal="true">
<div class="replay-content">
<div class="replay-hdr">
<div class="replay-hdr-info">
<h3 id="replay-title">Session Replay</h3>
<span id="replay-meta"></span>
</div>
<button class="replay-close" data-action="close-replay" aria-label="Close replay">&times;</button>
</div>
<div class="replay-controls">
<button class="replay-btn" id="replay-play-btn" data-action="replay-play">Play</button>
<button class="replay-btn" data-action="replay-step">Step</button>
<button class="replay-btn" data-action="replay-reset">Reset</button>
<select class="replay-speed" id="replay-speed">
<option value="500">0.5x</option>
<option value="250" selected>1x</option>
<option value="100">2x</option>
<option value="50">5x</option>
</select>
<div class="replay-progress" data-action="replay-seek">
<div class="replay-progress-fill" id="replay-progress"></div>
</div>
<span class="replay-counter" id="replay-counter">0/0</span>
</div>
<div class="replay-terminal" id="replay-terminal"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="module" src="{{ url_for('honeypot.static', filename='hp/js/app.js') }}"></script>
{% endblock %}

View File

@ -4,28 +4,30 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - ESPILON</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body class="login-container">
<div class="login-box">
<div class="logo">ESPILON</div>
<div class="login-brand">&epsilon; ESPILON</div>
{% if error %}
<div class="error">{{ error }}</div>
<div class="login-error">{{ error }}</div>
{% endif %}
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
<input type="text" id="username" name="username" required autofocus placeholder="admin">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<input type="password" id="password" name="password" required placeholder="password">
</div>
<button type="submit" class="btn-login">Sign in</button>
</form>
</div>

View File

@ -1,5 +1,4 @@
{% extends "base.html" %}
{% block title %}MLAT - ESPILON{% endblock %}
{% block head %}
@ -8,161 +7,128 @@
{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">MLAT <span>Multilateration Positioning</span></div>
<div class="view-toggle">
<button class="view-btn active" data-view="map" onclick="switchView('map')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/>
</svg>
Map
</button>
<button class="view-btn" data-view="plan" onclick="switchView('plan')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
Plan
</button>
</div>
</div>
<div class="page">
<div class="mlat-layout">
<!-- Main: Map/Plan views -->
<div class="mlat-main">
<!-- View toggle toolbar -->
<div class="toolbar">
<button class="btn btn-sm" :class="currentView==='map'?'btn-primary':''" data-view="map" onclick="switchView('map')">Map</button>
<button class="btn btn-sm" :class="currentView==='plan'?'btn-primary':''" data-view="plan" onclick="switchView('plan')">Plan</button>
<div class="toolbar-sep"></div>
<span class="toolbar-label" id="coord-mode-label">GPS</span>
</div>
<div class="mlat-container">
<!-- Map/Plan View -->
<div class="mlat-view-wrapper">
<!-- Leaflet Map View -->
<div id="map-view" class="mlat-view active">
<div id="leaflet-map"></div>
</div>
<!-- Leaflet Map -->
<div id="map-view" class="mlat-view active">
<div id="leaflet-map"></div>
</div>
<!-- Plan View (Canvas + Image) -->
<div id="plan-view" class="mlat-view">
<div class="plan-controls">
<input type="file" id="plan-upload" accept="image/*" style="display:none" onchange="uploadPlanImage(this)">
<button class="btn btn-sm" onclick="document.getElementById('plan-upload').click()">
Upload Plan
</button>
<button class="btn btn-sm" onclick="clearPlan()">
Clear
</button>
<div class="control-divider"></div>
<button class="btn btn-sm toggle-btn active" id="grid-toggle" onclick="toggleGrid()">
Grid
</button>
<button class="btn btn-sm toggle-btn active" id="labels-toggle" onclick="toggleLabels()">
Labels
</button>
<div class="control-divider"></div>
<span class="control-label">Zoom:</span>
<button class="btn btn-sm" onclick="zoomPlan(-1)" title="Zoom Out">-</button>
<span class="zoom-level" id="zoom-level">100%</span>
<button class="btn btn-sm" onclick="zoomPlan(1)" title="Zoom In">+</button>
<button class="btn btn-sm" onclick="resetZoom()" title="Reset View">Reset</button>
<div class="control-divider"></div>
<span class="control-label">Size:</span>
<button class="btn btn-sm" onclick="adjustPlanSize(-10)" title="Shrink Plan">-10m</button>
<span class="size-display" id="size-display">50x30m</span>
<button class="btn btn-sm" onclick="adjustPlanSize(10)" title="Enlarge Plan">+10m</button>
</div>
<div class="plan-canvas-wrapper">
<canvas id="plan-canvas"></canvas>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="mlat-sidebar">
<!-- Target Position -->
<div class="mlat-panel">
<h3>Target Position</h3>
<div class="mlat-stat" id="target-coord1-row">
<span class="label" id="target-coord1-label">Latitude</span>
<span class="value" id="target-coord1">-</span>
</div>
<div class="mlat-stat" id="target-coord2-row">
<span class="label" id="target-coord2-label">Longitude</span>
<span class="value" id="target-coord2">-</span>
</div>
<div class="mlat-stat">
<span class="label">Confidence</span>
<span class="value" id="target-confidence">-</span>
</div>
<div class="mlat-stat">
<span class="label">Last Update</span>
<span class="value" id="target-age">-</span>
</div>
<div class="mlat-stat">
<span class="label">Mode</span>
<span class="value" id="coord-mode">GPS</span>
<!-- Plan Canvas -->
<div id="plan-view" class="mlat-view">
<div class="plan-controls">
<input type="file" id="plan-upload" accept="image/*" style="display:none" onchange="uploadPlanImage(this)">
<button class="btn btn-sm" onclick="document.getElementById('plan-upload').click()">Upload</button>
<button class="btn btn-sm" id="grid-toggle" onclick="toggleGrid()">Grid</button>
<button class="btn btn-sm" id="labels-toggle" onclick="toggleLabels()">Labels</button>
<div class="toolbar-sep"></div>
<button class="btn btn-sm" onclick="zoomPlan(-1)">-</button>
<span class="zoom-level" id="zoom-level">100%</span>
<button class="btn btn-sm" onclick="zoomPlan(1)">+</button>
<button class="btn btn-sm" onclick="resetZoom()">Reset</button>
</div>
<div class="plan-canvas-wrapper">
<canvas id="plan-canvas"></canvas>
</div>
</div>
</div>
<!-- Active Scanners -->
<div class="mlat-panel">
<h3>Scanners (<span id="scanner-count">0</span>)</h3>
<div class="scanner-list" id="scanner-list">
<div class="empty">No scanners active</div>
<!-- Sidebar -->
<div class="mlat-sidebar-panel" style="display:flex;flex-direction:column;gap:0;">
<!-- Target -->
<div class="panel" style="border:none;border-bottom:1px solid var(--border);">
<div class="panel-header"><span>Target</span></div>
<div class="panel-body">
<div class="kv">
<div class="kv-row"><span class="kv-key" id="target-coord1-label">Latitude</span><span class="kv-val" id="target-coord1">-</span></div>
<div class="kv-row"><span class="kv-key" id="target-coord2-label">Longitude</span><span class="kv-val" id="target-coord2">-</span></div>
<div class="kv-row"><span class="kv-key">Confidence</span><span class="kv-val" id="target-confidence">-</span></div>
<div class="kv-row"><span class="kv-key">Last Update</span><span class="kv-val" id="target-age">-</span></div>
<div class="kv-row"><span class="kv-key">Mode</span><span class="kv-val" id="coord-mode">GPS</span></div>
</div>
</div>
</div>
</div>
<!-- Map Settings (GPS mode) -->
<div class="mlat-panel" id="map-settings">
<h3>Map Settings (GPS)</h3>
<div class="config-row">
<label>Center Lat</label>
<input type="number" id="map-center-lat" value="48.8566" step="0.0001">
<!-- Scanners -->
<div class="panel" style="border:none;border-bottom:1px solid var(--border);flex:1;overflow:hidden;">
<div class="panel-header"><span>Scanners (<span id="scanner-count">0</span>)</span></div>
<div class="panel-body" id="scanner-list" style="overflow-y:auto;padding:4px;">
<div class="text-muted text-xs" style="padding:8px;">No scanners</div>
</div>
</div>
<div class="config-row">
<label>Center Lon</label>
<input type="number" id="map-center-lon" value="2.3522" step="0.0001">
</div>
<div class="config-row">
<label>Zoom</label>
<input type="number" id="map-zoom" value="18" min="1" max="20">
</div>
<button class="btn btn-primary btn-sm" onclick="centerMap()">Center Map</button>
<button class="btn btn-sm" onclick="fitMapToBounds()">Fit to Scanners</button>
</div>
<!-- Plan Settings (Local mode) -->
<div class="mlat-panel" id="plan-settings" style="display:none">
<h3>Plan Settings (Local)</h3>
<div class="config-row">
<label>Width (m)</label>
<input type="number" id="plan-width" value="50" min="1" step="1">
<!-- Map/Plan Settings -->
<div class="panel" id="map-settings" style="border:none;border-bottom:1px solid var(--border);">
<div class="panel-header"><span>Map Settings</span></div>
<div class="panel-body panel-body-pad">
<div class="form-row">
<span class="form-label">Lat</span>
<input type="number" class="input flex-1" id="map-center-lat" value="48.8566" step="0.0001">
</div>
<div class="form-row">
<span class="form-label">Lon</span>
<input type="number" class="input flex-1" id="map-center-lon" value="2.3522" step="0.0001">
</div>
<div class="form-row">
<span class="form-label">Zoom</span>
<input type="number" class="input" id="map-zoom" value="18" min="1" max="20" style="width:60px;">
<button class="btn btn-sm" onclick="centerMap()">Center</button>
<button class="btn btn-sm" onclick="fitMapToBounds()">Fit</button>
</div>
</div>
</div>
<div class="config-row">
<label>Height (m)</label>
<input type="number" id="plan-height" value="30" min="1" step="1">
<div class="panel" id="plan-settings" style="border:none;border-bottom:1px solid var(--border);display:none;">
<div class="panel-header"><span>Plan Settings</span></div>
<div class="panel-body panel-body-pad">
<div class="form-row">
<span class="form-label">W (m)</span>
<input type="number" class="input flex-1" id="plan-width" value="50" min="1">
</div>
<div class="form-row">
<span class="form-label">H (m)</span>
<input type="number" class="input flex-1" id="plan-height" value="30" min="1">
</div>
<div class="form-row">
<span class="form-label">Origin</span>
<input type="number" class="input" id="plan-origin-x" value="0" step="0.1" style="width:60px;" placeholder="X">
<input type="number" class="input" id="plan-origin-y" value="0" step="0.1" style="width:60px;" placeholder="Y">
<button class="btn btn-sm" onclick="applyPlanSettings()">Apply</button>
</div>
</div>
</div>
<div class="config-row">
<label>Origin X (m)</label>
<input type="number" id="plan-origin-x" value="0" step="0.1">
</div>
<div class="config-row">
<label>Origin Y (m)</label>
<input type="number" id="plan-origin-y" value="0" step="0.1">
</div>
<button class="btn btn-primary btn-sm" onclick="applyPlanSettings()">Apply</button>
</div>
<!-- MLAT Configuration -->
<div class="mlat-panel">
<h3>MLAT Config</h3>
<div class="config-row">
<label>RSSI @ 1m</label>
<input type="number" id="config-rssi" value="-40" step="1">
</div>
<div class="config-row">
<label>Path Loss (n)</label>
<input type="number" id="config-n" value="2.5" step="0.1">
</div>
<div class="config-row">
<label>Smoothing</label>
<input type="number" id="config-smooth" value="5" min="1" max="20">
</div>
<div class="btn-group">
<button class="btn btn-primary btn-sm" onclick="saveConfig()">Save</button>
<button class="btn btn-secondary btn-sm" onclick="clearData()">Clear All</button>
<!-- MLAT Config -->
<div class="panel" style="border:none;">
<div class="panel-header"><span>Config</span></div>
<div class="panel-body panel-body-pad">
<div class="form-row">
<span class="form-label">RSSI@1m</span>
<input type="number" class="input" id="config-rssi" value="-40" step="1" style="width:70px;">
</div>
<div class="form-row">
<span class="form-label">Loss (n)</span>
<input type="number" class="input" id="config-n" value="2.5" step="0.1" style="width:70px;">
</div>
<div class="form-row">
<span class="form-label">Smooth</span>
<input type="number" class="input" id="config-smooth" value="5" min="1" max="20" style="width:70px;">
</div>
<div class="form-row">
<span class="form-label"></span>
<button class="btn btn-sm btn-primary" onclick="saveConfig()">Save</button>
<button class="btn btn-sm btn-danger" onclick="clearData()">Clear</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}Network - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="networkApp()" x-init="init()">
<div class="split-h" style="flex:1;">
<!-- Left: Command forms -->
<div class="panel" style="width:360px;min-width:280px;">
<div class="panel-header">
<span>Network Commands</span>
</div>
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
<div class="form-group">
<label>Target Device</label>
<select class="select w-full" x-model="device">
<option value="">select device...</option>
<template x-for="d in $store.app.connectedDevices()" :key="d.id">
<option :value="d.id" x-text="d.id"></option>
</template>
</select>
</div>
<!-- Ping -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="form-row">
<span class="form-label">Ping</span>
<input type="text" class="input flex-1" x-model="pingHost" placeholder="8.8.8.8">
<button class="btn btn-sm btn-primary" @click="run('ping', [pingHost])">Go</button>
</div>
</div>
<!-- ARP Scan -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="form-row">
<span class="form-label">ARP Scan</span>
<button class="btn btn-sm btn-primary" @click="run('arp_scan')">Scan</button>
</div>
</div>
<!-- DoS -->
<div>
<div class="form-row">
<span class="form-label">DoS TCP</span>
<input type="text" class="input" x-model="dosIp" placeholder="target IP" style="width:100px;">
<input type="text" class="input" x-model="dosPort" placeholder="port" style="width:60px;">
<input type="text" class="input" x-model="dosCount" placeholder="count" style="width:60px;">
<button class="btn btn-sm btn-danger" @click="run('dos_tcp', [dosIp, dosPort, dosCount])">Go</button>
</div>
</div>
</div>
</div>
<div class="resizer"></div>
<!-- Right: Output -->
<div class="panel flex-1">
<div class="panel-header">
<span>Output</span>
<button class="btn btn-sm" @click="outputLines = []">Clear</button>
</div>
<div class="term-output" style="flex:1;">
<template x-for="(l, i) in outputLines" :key="i">
<div class="term-line" :class="l.cls || ''" x-html="l.html"></div>
</template>
<template x-if="outputLines.length === 0">
<div class="term-line term-system">Run a command to see output here.</div>
</template>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function networkApp() {
return {
...commander(),
device: '',
outputLines: [],
pingHost: '',
dosIp: '', dosPort: '', dosCount: '100',
init() {},
async run(command, argv) {
if (!this.device) { toast('Select a device', 'error'); return; }
argv = (argv || []).filter(Boolean);
this.outputLines.push({ html: '<span class="term-cmd">' + escapeHtml(this.device) + '&gt; ' + escapeHtml(command + ' ' + argv.join(' ')) + '</span>' });
try {
const data = await this.sendCommand([this.device], command, argv);
const r = (data.results || [])[0];
if (r && r.status === 'ok' && r.request_id) {
this.outputLines.push({ html: '<span class="term-pending">pending...</span>', cls: '' });
const lineIdx = this.outputLines.length - 1;
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
try {
const res = await fetch('/api/commands/' + encodeURIComponent(r.request_id));
const d = await res.json();
if (d.status === 'completed' || d.status === 'error' || attempts >= 60) {
clearInterval(iv);
this.outputLines.splice(lineIdx, 1);
if (d.output && d.output.length > 0) {
d.output.forEach(l => this.outputLines.push({ html: escapeHtml(l) }));
} else {
this.outputLines.push({ html: '<span class="term-success">OK</span>' });
}
}
} catch (e) {}
}, 500);
} else if (r) {
this.outputLines.push({ html: '<span class="term-error">' + escapeHtml(r.message || 'Error') + '</span>' });
}
} catch (e) {
this.outputLines.push({ html: '<span class="term-error">' + escapeHtml(e.message) + '</span>' });
}
}
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,270 @@
{% extends "base.html" %}
{% block title %}OTA - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="otaApp()" x-init="init()">
<div class="split-v" style="flex:1;">
<!-- Top: Build + Source -->
<div class="split-h" style="flex:0 0 auto;max-height:50%;">
<!-- Build Firmware -->
<div class="panel flex-1">
<div class="panel-header">
<span>Build Firmware</span>
<span class="badge" :class="buildStatusClass" x-text="buildStatus"></span>
</div>
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
<div class="form-row">
<span class="form-label">Device ID</span>
<input type="text" class="input flex-1" x-model="buildDeviceId" placeholder="e.g. ce4f626b"
list="dev-list">
<datalist id="dev-list">
<template x-for="d in $store.app.devices" :key="d.id">
<option :value="d.id"></option>
</template>
</datalist>
</div>
<div class="form-row">
<span class="form-label">Hostname</span>
<input type="text" class="input flex-1" x-model="buildHostname" placeholder="optional">
</div>
<div class="form-row">
<span class="form-label">Modules</span>
<div class="module-checkboxes">
<label class="checkbox-label"><input type="checkbox" x-model="modules.network"> Network</label>
<label class="checkbox-label"><input type="checkbox" x-model="modules.fakeap"> FakeAP</label>
<label class="checkbox-label"><input type="checkbox" x-model="modules.honeypot"> Honeypot</label>
<label class="checkbox-label"><input type="checkbox" x-model="modules.recon"> Recon</label>
<label class="checkbox-label"><input type="checkbox" x-model="modules.redteam"> Red Team</label>
<label class="checkbox-label"><input type="checkbox" x-model="modules.canbus"> CAN Bus</label>
<label class="checkbox-label"><input type="checkbox" x-model="modules.ota" checked> OTA</label>
</div>
</div>
<div x-show="modules.recon" class="form-row" style="padding-left:80px;">
<label class="checkbox-label"><input type="checkbox" x-model="modules.recon_camera"> Camera</label>
<label class="checkbox-label"><input type="checkbox" x-model="modules.recon_ble_trilat"> BLE Trilat</label>
</div>
<div class="form-row">
<span class="form-label"></span>
<label class="checkbox-label"><input type="checkbox" x-model="otaAllowHttp"> Allow OTA over HTTP</label>
</div>
<div class="form-row">
<span class="form-label"></span>
<button class="btn btn-primary" @click="startBuild()" :disabled="buildStatus === 'building'">Build</button>
</div>
<template x-if="buildLog.length > 0">
<div>
<div class="text-xs text-muted" style="margin-top:8px;" x-text="buildHint"></div>
<pre class="build-log" x-text="buildLog.join('\n')"></pre>
</div>
</template>
</div>
</div>
<div class="resizer"></div>
<!-- Firmware Source -->
<div class="panel" style="width:300px;min-width:200px;">
<div class="panel-header">
<span>Firmware (<span x-text="firmware.length"></span>)</span>
</div>
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
<div class="form-group">
<label>Deploy URL</label>
<input type="text" class="input w-full" x-model="fwUrl" placeholder="https://...firmware.bin">
</div>
<div class="form-group">
<label>Upload .bin</label>
<input type="file" accept=".bin" @change="uploadFile($event)" style="font-size:11px;">
</div>
<template x-for="fw in firmware" :key="fw.filename">
<div style="display:flex;align-items:center;gap:4px;padding:3px 0;border-bottom:1px solid var(--border-subtle);">
<span class="text-mono text-xs flex-1 truncate" x-text="fw.filename"></span>
<span class="text-xs text-muted" x-text="formatBytes(fw.size)"></span>
<button class="btn btn-sm" @click="useFirmware(fw.filename)">Use</button>
<button class="btn btn-sm btn-danger" @click="deleteFirmware(fw.filename)">Del</button>
</div>
</template>
<template x-if="firmware.length === 0">
<div class="text-muted text-xs">No firmware uploaded</div>
</template>
</div>
</div>
</div>
<div class="resizer resizer-h"></div>
<!-- Bottom: Devices -->
<div class="panel flex-1">
<div class="panel-header">
<span>Devices</span>
<button class="btn btn-sm btn-primary" @click="deployAll()">Deploy All</button>
</div>
<div class="panel-body">
<table class="dt">
<thead>
<tr>
<th>ID</th>
<th class="col-shrink">Status</th>
<th>Chip</th>
<th class="col-shrink">OTA</th>
<th class="col-shrink">Action</th>
</tr>
</thead>
<tbody>
<template x-for="d in $store.app.devices" :key="d.id">
<tr>
<td x-text="d.id"></td>
<td><span class="badge" :class="d.status==='Connected'?'badge-ok':'badge-warn'" x-text="d.status"></span></td>
<td x-text="d.chip || '-'"></td>
<td>
<span class="badge" :class="hasOta(d)?'badge-ok':'badge-err'"
x-text="hasOta(d)?'Yes':'No'"></span>
</td>
<td>
<button class="btn btn-sm btn-primary" @click="deployDevice(d.id)"
:disabled="d.status!=='Connected' || !hasOta(d) || !fwUrl">Deploy</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function otaApp() {
return {
fwUrl: '',
firmware: [],
buildDeviceId: '',
buildHostname: '',
buildStatus: 'idle',
buildHint: '',
buildLog: [],
buildLogOffset: 0,
buildPoll: null,
otaAllowHttp: true,
modules: { network: true, fakeap: false, honeypot: false, recon: false, recon_camera: false, recon_ble_trilat: false, redteam: false, canbus: false, ota: true },
get buildStatusClass() {
if (this.buildStatus === 'building') return 'badge-warn';
if (this.buildStatus === 'success') return 'badge-ok';
if (this.buildStatus === 'failed') return 'badge-err';
return '';
},
init() {
this.loadFirmware();
this.loadBuildDefaults();
this.checkBuild();
setInterval(() => this.loadFirmware(), 15000);
},
hasOta(d) { return d.modules && d.modules.split(',').includes('ota'); },
async loadFirmware() {
try { const r = await fetch('/api/ota/firmware'); const d = await r.json(); this.firmware = d.firmware || []; } catch (e) {}
},
async loadBuildDefaults() {
try {
const r = await fetch('/api/ota/build/defaults'); const d = await r.json();
if (d.modules) Object.assign(this.modules, d.modules);
if (d.ota) this.otaAllowHttp = !!d.ota.allow_http;
} catch (e) {}
},
useFirmware(filename) {
const origin = this.getOrigin();
if (origin) this.fwUrl = origin + '/api/ota/fw/' + filename;
},
getOrigin() {
const h = window.location.hostname;
if (h === '0.0.0.0' || h === '127.0.0.1' || h === 'localhost') {
const ip = localStorage.getItem('ota_server_ip') || prompt('Enter server LAN IP:');
if (ip) { localStorage.setItem('ota_server_ip', ip); return window.location.protocol + '//' + ip + ':' + window.location.port; }
return null;
}
return window.location.origin;
},
async deployDevice(deviceId) {
if (!this.fwUrl) { toast('Enter a firmware URL', 'error'); return; }
try {
const r = await fetch('/api/ota/deploy', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({url:this.fwUrl, device_ids:[deviceId]}) });
const d = await r.json();
if (d.results && d.results[0] && d.results[0].status === 'error') toast('Deploy failed: ' + d.results[0].message, 'error');
else toast('Deploy sent to ' + deviceId, 'ok');
} catch (e) { toast('Deploy failed', 'error'); }
},
async deployAll() {
if (!this.fwUrl) { toast('Enter a firmware URL', 'error'); return; }
const ids = this.$store.app.connectedDevices().filter(d => this.hasOta(d)).map(d => d.id);
if (!ids.length) { toast('No OTA-capable devices', 'error'); return; }
try {
await fetch('/api/ota/deploy', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({url:this.fwUrl, device_ids:ids}) });
toast('Deploy sent to ' + ids.length + ' device(s)', 'ok');
} catch (e) { toast('Deploy failed', 'error'); }
},
async uploadFile(e) {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData(); fd.append('file', file);
try {
const r = await fetch('/api/ota/upload', { method: 'POST', body: fd });
const d = await r.json();
if (d.error) toast('Upload failed: ' + d.error, 'error');
else { toast('Uploaded: ' + d.filename, 'ok'); this.useFirmware(d.filename); this.loadFirmware(); }
} catch (e) { toast('Upload failed', 'error'); }
e.target.value = '';
},
async deleteFirmware(filename) {
try { await fetch('/api/ota/firmware/' + filename, { method: 'DELETE' }); this.loadFirmware(); toast('Deleted', 'ok'); } catch (e) {}
},
async startBuild() {
if (!this.buildDeviceId) { toast('Enter device ID', 'error'); return; }
const body = { device_id: this.buildDeviceId, hostname: this.buildHostname, modules: this.modules, ota: { enabled: true, allow_http: this.otaAllowHttp } };
try {
const r = await fetch('/api/ota/build/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const d = await r.json();
if (d.error) { toast('Build error: ' + d.error, 'error'); return; }
this.buildStatus = 'building'; this.buildLog = []; this.buildLogOffset = 0;
this.buildPoll = setInterval(() => this.pollBuild(), 2000);
} catch (e) { toast('Build failed', 'error'); }
},
async checkBuild() {
try {
const r = await fetch('/api/ota/build/status'); const d = await r.json();
this.buildStatus = d.status;
if (d.status === 'building') { this.buildPoll = setInterval(() => this.pollBuild(), 2000); }
if (d.status === 'success' && d.output_filename) this.useFirmware(d.output_filename);
} catch (e) {}
},
async pollBuild() {
try {
const [sr, lr] = await Promise.all([fetch('/api/ota/build/status'), fetch('/api/ota/build/log?offset=' + this.buildLogOffset)]);
const s = await sr.json(); const l = await lr.json();
this.buildStatus = s.status; this.buildHint = s.progress_hint || '';
if (l.lines && l.lines.length > 0) { this.buildLog.push(...l.lines); this.buildLogOffset = l.total; }
if (s.status !== 'building') {
clearInterval(this.buildPoll); this.buildPoll = null;
if (s.status === 'success' && s.output_filename) { this.useFirmware(s.output_filename); this.loadFirmware(); }
}
} catch (e) {}
}
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}Red Team - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="redteamApp()" x-init="init()">
<div class="split-h" style="flex:1;">
<!-- Left: Controls -->
<div class="panel" style="width:360px;min-width:280px;">
<div class="panel-header"><span>Red Team Controls</span></div>
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
<div class="form-group">
<label>Device</label>
<select class="select w-full" x-model="device">
<option value="">select device...</option>
<template x-for="d in $store.app.connectedDevices()" :key="d.id">
<option :value="d.id" x-text="d.id"></option>
</template>
</select>
</div>
<!-- Hunt -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="toolbar-label" style="margin-bottom:6px;">Autonomous Hunt</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-success" @click="run('rt_hunt')">Start Hunt</button>
<button class="btn btn-sm btn-danger" @click="run('rt_stop')">Stop</button>
<button class="btn btn-sm" @click="run('rt_status')">Status</button>
</div>
</div>
<!-- WiFi Scan -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="toolbar-label" style="margin-bottom:6px;">WiFi Scan</div>
<button class="btn btn-sm" @click="run('rt_scan')">Scan Networks</button>
</div>
<!-- Known Networks -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="toolbar-label" style="margin-bottom:6px;">Known Networks</div>
<div class="form-row">
<input type="text" class="input flex-1" x-model="netSsid" placeholder="SSID">
<input type="text" class="input" x-model="netPass" placeholder="password" style="width:100px;">
</div>
<div class="flex gap-2" style="margin-top:6px;">
<button class="btn btn-sm btn-success" @click="run('rt_net_add', [netSsid, netPass].filter(Boolean))">Add</button>
<button class="btn btn-sm btn-danger" @click="run('rt_net_add', [netSsid, ''])">Remove</button>
<button class="btn btn-sm" @click="run('rt_net_list')">List</button>
</div>
</div>
<!-- Mesh -->
<div>
<div class="toolbar-label" style="margin-bottom:6px;">ESP-NOW Mesh</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-success" @click="run('rt_mesh', ['start'])">Start Mesh</button>
<button class="btn btn-sm btn-danger" @click="run('rt_mesh', ['stop'])">Stop Mesh</button>
</div>
</div>
</div>
</div>
<div class="resizer"></div>
<!-- Right: Output -->
<div class="panel flex-1">
<div class="panel-header">
<span>Output</span>
<button class="btn btn-sm" @click="outputLines = []">Clear</button>
</div>
<div class="term-output" style="flex:1;">
<template x-for="(l, i) in outputLines" :key="i">
<div class="term-line" :class="l.cls || ''" x-html="l.html"></div>
</template>
<template x-if="outputLines.length === 0">
<div class="term-line term-system">Red Team command output will appear here.</div>
</template>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function redteamApp() {
return {
...commander(),
device: '', outputLines: [],
netSsid: '', netPass: '',
init() {},
async run(command, argv) {
if (!this.device) { toast('Select a device', 'error'); return; }
argv = (argv || []).filter(Boolean);
this.outputLines.push({ html: '<span class="term-cmd">' + escapeHtml(command + ' ' + argv.join(' ')) + '</span>' });
try {
const data = await this.sendCommand([this.device], command, argv);
const r = (data.results || [])[0];
if (r && r.status === 'ok' && r.request_id) {
const pendingIdx = this.outputLines.length;
this.outputLines.push({ html: '<span class="term-pending">pending...</span>' });
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
try {
const res = await fetch('/api/commands/' + encodeURIComponent(r.request_id));
const d = await res.json();
if (d.status === 'completed' || d.status === 'error' || attempts >= 60) {
clearInterval(iv);
this.outputLines.splice(pendingIdx, 1);
if (d.output && d.output.length) d.output.forEach(l => this.outputLines.push({ html: escapeHtml(l) }));
else this.outputLines.push({ html: '<span class="term-success">OK</span>' });
}
} catch (e) {}
}, 500);
} else if (r) {
this.outputLines.push({ html: '<span class="term-error">' + escapeHtml(r.message || 'Error') + '</span>' });
}
} catch (e) {
this.outputLines.push({ html: '<span class="term-error">' + escapeHtml(e.message) + '</span>' });
}
}
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}System - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="systemApp()" x-init="init()">
<div class="split-h" style="flex:1;">
<!-- Left: Device selector + info -->
<div class="panel" style="width:400px;min-width:300px;">
<div class="panel-header"><span>System Info</span></div>
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
<div class="form-group">
<label>Device</label>
<select class="select w-full" x-model="device" @change="refresh()">
<option value="">select device...</option>
<template x-for="d in $store.app.connectedDevices()" :key="d.id">
<option :value="d.id" x-text="d.id"></option>
</template>
</select>
</div>
<div class="flex gap-2" style="margin-bottom:12px;">
<button class="btn btn-sm" @click="run('system_info')">Info</button>
<button class="btn btn-sm" @click="run('system_mem')">Memory</button>
<button class="btn btn-sm" @click="run('system_uptime')">Uptime</button>
<button class="btn btn-sm btn-danger" @click="confirmReboot()">Reboot</button>
</div>
<!-- Device KV from API -->
<template x-if="dev.id">
<div>
<div class="toolbar-label" style="margin-bottom:6px;">Connection Info</div>
<div class="kv">
<div class="kv-row"><span class="kv-key">ID</span><span class="kv-val" x-text="dev.id"></span></div>
<div class="kv-row"><span class="kv-key">Status</span><span class="kv-val" x-text="dev.status"></span></div>
<div class="kv-row"><span class="kv-key">IP</span><span class="kv-val" x-text="dev.ip + ':' + dev.port"></span></div>
<div class="kv-row"><span class="kv-key">Chip</span><span class="kv-val" x-text="dev.chip || '-'"></span></div>
<div class="kv-row"><span class="kv-key">Modules</span><span class="kv-val" x-text="dev.modules || '-'"></span></div>
<div class="kv-row"><span class="kv-key">Uptime</span><span class="kv-val" x-text="formatDuration(dev.connected_for_seconds)"></span></div>
<div class="kv-row"><span class="kv-key">Last Seen</span><span class="kv-val" x-text="formatDuration(dev.last_seen_ago_seconds) + ' ago'"></span></div>
</div>
</div>
</template>
</div>
</div>
<div class="resizer"></div>
<!-- Right: Command output -->
<div class="panel flex-1">
<div class="panel-header">
<span>Output</span>
<button class="btn btn-sm" @click="outputLines = []">Clear</button>
</div>
<div class="term-output" style="flex:1;">
<template x-for="(l, i) in outputLines" :key="i">
<div class="term-line" :class="l.cls || ''" x-html="l.html"></div>
</template>
<template x-if="outputLines.length === 0">
<div class="term-line term-system">Select a device and run a system command.</div>
</template>
</div>
</div>
</div>
<!-- Reboot Modal -->
<template x-if="showRebootModal">
<div class="modal-overlay" @click.self="showRebootModal = false">
<div class="modal-box">
<h3>Confirm Reboot</h3>
<p class="text-secondary text-sm" style="margin:8px 0;">Reboot device <strong x-text="device"></strong>?</p>
<div class="modal-actions">
<button class="btn" @click="showRebootModal = false">Cancel</button>
<button class="btn btn-danger" @click="doReboot()">Reboot</button>
</div>
</div>
</div>
</template>
</div>
{% endblock %}
{% block scripts %}
<script>
function systemApp() {
return {
...commander(),
device: '', dev: {}, outputLines: [],
showRebootModal: false,
init() {},
async refresh() {
if (!this.device) { this.dev = {}; return; }
try {
const res = await fetch('/api/devices');
const data = await res.json();
this.dev = (data.devices || []).find(d => d.id === this.device) || {};
} catch (e) {}
},
confirmReboot() {
if (!this.device) { toast('Select a device', 'error'); return; }
this.showRebootModal = true;
},
async doReboot() {
this.showRebootModal = false;
await this.run('system_reboot');
},
async run(command, argv) {
if (!this.device) { toast('Select a device', 'error'); return; }
argv = (argv || []).filter(Boolean);
this.outputLines.push({ html: '<span class="term-cmd">' + escapeHtml(command) + '</span>' });
try {
const data = await this.sendCommand([this.device], command, argv);
const r = (data.results || [])[0];
if (r && r.status === 'ok' && r.request_id) {
const pendingIdx = this.outputLines.length;
this.outputLines.push({ html: '<span class="term-pending">pending...</span>' });
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
try {
const res = await fetch('/api/commands/' + encodeURIComponent(r.request_id));
const d = await res.json();
if (d.status === 'completed' || d.status === 'error' || attempts >= 60) {
clearInterval(iv);
this.outputLines.splice(pendingIdx, 1);
if (d.output && d.output.length) d.output.forEach(l => this.outputLines.push({ html: escapeHtml(l) }));
else this.outputLines.push({ html: '<span class="term-success">OK</span>' });
}
} catch (e) {}
}, 500);
} else if (r) {
this.outputLines.push({ html: '<span class="term-error">' + escapeHtml(r.message || 'Error') + '</span>' });
}
} catch (e) {
this.outputLines.push({ html: '<span class="term-error">' + escapeHtml(e.message) + '</span>' });
}
}
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,297 @@
{% extends "base.html" %}
{% block title %}Terminal - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="terminalApp()" x-init="init()">
<div class="split-h" style="flex:1;">
<!-- Command Terminal -->
<div class="panel flex-1">
<div class="toolbar">
<span class="toolbar-label">Command</span>
<select class="select" x-model="selectedDevice" @change="updatePrompt()">
<option value="all">all devices</option>
<template x-for="d in $store.app.devices" :key="d.id">
<option :value="d.id" x-text="d.id + (d.status==='Connected' ? '' : ' (offline)')"></option>
</template>
</select>
<div class="toolbar-sep"></div>
<button class="btn btn-sm" @click="showMonitor = !showMonitor; if(showMonitor) refreshPorts();"
:class="showMonitor ? 'btn-primary' : ''">Monitor</button>
<button class="btn btn-sm" @click="lines = []">Clear</button>
</div>
<div class="term-output" id="term-out" x-ref="termOut">
<div class="term-line term-system">ESPILON C2 Terminal — Type a command and press Enter.</div>
<template x-for="(l, i) in lines" :key="i">
<div class="term-line" :class="l.cls || ''" x-html="l.html"></div>
</template>
</div>
<div class="term-input-row">
<span class="term-prompt" x-html="promptHtml"></span>
<input type="text" class="term-input" x-model="inputText" @keydown="handleKey($event)"
autocomplete="off" spellcheck="false" placeholder="system_info" x-ref="termInput">
</div>
</div>
<!-- Serial Monitor (toggleable) -->
<template x-if="showMonitor">
<div class="flex" style="flex:0 0 auto;">
<div class="resizer" @mousedown="startResize($event)"></div>
<div class="panel" :style="'width:' + monitorWidth + 'px'">
<div class="toolbar">
<span class="toolbar-label">Serial</span>
<select class="select" x-model="selectedPort">
<option value="">select port...</option>
<template x-for="p in ports" :key="p.port">
<option :value="p.port" x-text="p.port + (p.device_id ? ' (' + p.device_id + ')' : '') + (p.monitoring ? ' *' : '')"></option>
</template>
</select>
<button class="btn btn-sm" :class="monitorConnected ? 'btn-danger' : 'btn-success'"
@click="monitorConnected ? disconnectMonitor() : connectMonitor()"
x-text="monitorConnected ? 'Disconnect' : 'Connect'"></button>
<button class="btn btn-sm" @click="monitorLines = []">Clear</button>
<button class="btn btn-sm" @click="showMonitor = false; disconnectMonitor();">X</button>
</div>
<div class="term-output">
<template x-for="(l, i) in monitorLines" :key="i">
<div class="term-line" :class="l.cls || ''" x-text="l.text"></div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function terminalApp() {
const LOCAL_COMMANDS = ['help', 'clear', 'devices'];
const MODULE_COMMANDS = {
system: ['system_info', 'system_mem', 'system_uptime', 'system_reboot'],
network: ['ping', 'arp_scan', 'proxy_start', 'proxy_stop', 'dos_tcp'],
fakeap: ['fakeap_start', 'fakeap_stop', 'fakeap_status', 'fakeap_clients',
'fakeap_portal_start', 'fakeap_portal_stop',
'fakeap_sniffer_on', 'fakeap_sniffer_off'],
recon: ['cam_start', 'cam_stop', 'mlat', 'trilat'],
honeypot: ['hp_svc', 'hp_wifi', 'hp_net', 'hp_status', 'hp_config_set', 'hp_config_get', 'hp_config_list', 'hp_config_reset'],
ota: ['ota_update', 'ota_status'],
canbus: ['can_start', 'can_stop', 'can_send', 'can_sniff', 'can_record', 'can_replay',
'can_dump', 'can_status', 'can_filter_add', 'can_filter_del', 'can_filter_list', 'can_filter_clear',
'can_scan_ecu', 'can_uds', 'can_uds_session', 'can_uds_read', 'can_uds_dump', 'can_uds_auth',
'can_obd', 'can_obd_vin', 'can_obd_dtc', 'can_obd_supported', 'can_obd_monitor', 'can_obd_monitor_stop'],
redteam: ['rt_hunt', 'rt_stop', 'rt_status', 'rt_scan', 'rt_net_add', 'rt_net_list', 'rt_mesh'],
};
return {
selectedDevice: 'all',
inputText: '',
lines: [],
promptHtml: '<span style="color:var(--accent)">all</span>&gt;',
// Command history
history: JSON.parse(localStorage.getItem('term_history') || '[]'),
historyIdx: -1,
// Polling
pendingPolls: {},
// Monitor
showMonitor: false,
monitorWidth: 450,
selectedPort: '',
ports: [],
monitorLines: [],
monitorConnected: false,
monitorES: null,
init() {
this.$nextTick(() => this.$refs.termInput && this.$refs.termInput.focus());
},
updatePrompt() {
this.promptHtml = '<span style="color:var(--accent)">' + escapeHtml(this.selectedDevice) + '</span>&gt;';
},
appendLine(html, cls) {
this.lines.push({ html, cls });
if (this.lines.length > 2000) this.lines.splice(0, this.lines.length - 1500);
this.$nextTick(() => {
const el = this.$refs.termOut;
if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
el.scrollTop = el.scrollHeight;
}
});
},
handleKey(e) {
if (e.key === 'Enter') {
const line = this.inputText.trim();
if (!line) return;
this.history.unshift(line);
if (this.history.length > 100) this.history.length = 100;
try { localStorage.setItem('term_history', JSON.stringify(this.history)); } catch(ex) {}
this.historyIdx = -1;
this.inputText = '';
this.execute(line);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (this.historyIdx < this.history.length - 1) { this.historyIdx++; this.inputText = this.history[this.historyIdx]; }
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (this.historyIdx > 0) { this.historyIdx--; this.inputText = this.history[this.historyIdx]; }
else { this.historyIdx = -1; this.inputText = ''; }
} else if (e.key === 'Tab') {
e.preventDefault();
if (!this.inputText) return;
const cmds = this.getAutocomplete();
const matches = cmds.filter(c => c.startsWith(this.inputText));
if (matches.length === 1) this.inputText = matches[0] + ' ';
else if (matches.length > 1) this.appendLine(' ' + matches.join(' '), 'term-system');
} else if (e.key === 'l' && e.ctrlKey) {
e.preventDefault();
this.lines = [];
}
},
getAutocomplete() {
let cmds = [...LOCAL_COMMANDS, ...MODULE_COMMANDS.system];
if (this.selectedDevice === 'all') {
Object.values(MODULE_COMMANDS).forEach(c => cmds.push(...c));
} else {
const dev = this.$store.app.devices.find(d => d.id === this.selectedDevice);
if (dev && dev.modules) {
dev.modules.split(',').forEach(m => {
if (MODULE_COMMANDS[m]) cmds.push(...MODULE_COMMANDS[m]);
});
}
}
return [...new Set(cmds)];
},
async execute(line) {
const parts = line.split(/\s+/);
const cmd = parts[0];
const argv = parts.slice(1);
if (cmd === 'clear') { this.lines = []; return; }
if (cmd === 'help') {
this.appendLine('Available commands:', 'term-system');
for (const [mod, cmds] of Object.entries(MODULE_COMMANDS)) {
this.appendLine(' <span class="term-cmd">' + mod + ':</span> ' + cmds.join(', '));
}
this.appendLine(' <span class="term-cmd">local:</span> ' + LOCAL_COMMANDS.join(', '));
return;
}
if (cmd === 'devices') {
await this.$store.app.fetchDevices();
const devs = this.$store.app.devices;
if (!devs.length) { this.appendLine('No devices.', 'term-error'); return; }
devs.forEach(d => {
const st = d.status === 'Connected'
? '<span class="term-success">online</span>'
: '<span class="term-error">offline</span>';
this.appendLine('<span class="term-device">' + escapeHtml(d.id) + '</span> ' + st + ' ' + escapeHtml(d.ip||'') + ' [' + escapeHtml(d.modules||'') + ']');
});
return;
}
const target = this.selectedDevice;
const deviceIds = target === 'all' ? 'all' : [target];
this.appendLine('<span class="term-cmd">' + escapeHtml(target) + '&gt; ' + escapeHtml(line) + '</span>');
try {
const res = await fetch('/api/commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_ids: deviceIds, command: cmd, argv })
});
const data = await res.json();
if (data.error) { this.appendLine('Error: ' + escapeHtml(data.error), 'term-error'); return; }
for (const r of (data.results || [])) {
if (r.status === 'ok') {
const idx = this.lines.length;
this.appendLine('<span class="term-device">[' + escapeHtml(r.device_id) + ']</span> <span class="term-pending">pending...</span>');
this.startPolling(r.request_id, r.device_id, idx);
} else {
this.appendLine('<span class="term-device">[' + escapeHtml(r.device_id) + ']</span> <span class="term-error">' + escapeHtml(r.message || 'error') + '</span>');
}
}
} catch (e) {
this.appendLine('Network error: ' + escapeHtml(e.message), 'term-error');
}
},
startPolling(requestId, deviceId, lineIdx) {
let attempts = 0;
const poll = async () => {
attempts++;
try {
const res = await fetch('/api/commands/' + encodeURIComponent(requestId));
const data = await res.json();
if (data.status === 'completed' || data.status === 'error' || attempts >= 60) {
clearInterval(this.pendingPolls[requestId]);
delete this.pendingPolls[requestId];
let html;
if (data.output && data.output.length > 0) {
html = '<span class="term-device">[' + escapeHtml(deviceId) + ']</span> ' + escapeHtml(data.output.join('\n'));
} else if (attempts >= 60) {
html = '<span class="term-device">[' + escapeHtml(deviceId) + ']</span> <span class="term-error">Timeout</span>';
} else {
html = '<span class="term-device">[' + escapeHtml(deviceId) + ']</span> <span class="term-success">OK</span>';
}
if (this.lines[lineIdx]) this.lines[lineIdx].html = html;
}
} catch (e) {}
};
this.pendingPolls[requestId] = setInterval(poll, 500);
setTimeout(poll, 300);
},
// Monitor
async refreshPorts() {
try {
const res = await fetch('/api/monitor/ports');
const data = await res.json();
this.ports = data.ports || [];
} catch (e) {}
},
connectMonitor() {
if (!this.selectedPort) return;
this.disconnectMonitor();
const path = '/api/monitor/stream' + this.selectedPort;
this.monitorES = new EventSource(path);
this.monitorConnected = true;
this.monitorES.onmessage = (e) => {
let cls = '';
if (/\bE\s*\(/.test(e.data) || /error/i.test(e.data)) cls = 'monitor-error';
else if (/\bW\s*\(/.test(e.data) || /warn/i.test(e.data)) cls = 'monitor-warn';
else if (/\bI\s*\(/.test(e.data)) cls = 'monitor-info';
this.monitorLines.push({ text: e.data, cls });
if (this.monitorLines.length > 2000) this.monitorLines.splice(0, 500);
};
this.monitorES.onerror = () => {
this.monitorLines.push({ text: '[connection lost]', cls: 'monitor-error' });
this.disconnectMonitor();
};
},
disconnectMonitor() {
if (this.monitorES) { this.monitorES.close(); this.monitorES = null; }
this.monitorConnected = false;
},
startResize(e) {
const startX = e.clientX;
const startW = this.monitorWidth;
const onMove = (ev) => { this.monitorWidth = Math.max(200, startW - (ev.clientX - startX)); };
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
};
}
</script>
{% endblock %}

View File

@ -0,0 +1,262 @@
{% extends "base.html" %}
{% block title %}Tunnel - ESPILON{% endblock %}
{% block content %}
<div class="page" x-data="tunnelApp()" x-init="init()">
<div class="split-h" style="flex:1;">
<!-- Left: Controls -->
<div class="panel" style="width:360px;min-width:280px;">
<div class="panel-header">
<span>SOCKS5 Tunnel</span>
</div>
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
<!-- Start tunnel on a device -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="form-group">
<label>Target Device</label>
<select class="select w-full" x-model="device">
<option value="">select device...</option>
<template x-for="d in $store.app.connectedDevices()" :key="d.id">
<option :value="d.id" x-text="d.id"></option>
</template>
</select>
</div>
<div class="form-group">
<label>C3PO Tunnel Server (ESP32 connects back here)</label>
<div class="form-row" style="gap:6px;">
<input type="text" class="input flex-1" x-model="tunnelIp" placeholder="C3PO IP">
<input type="text" class="input" x-model="tunnelPort" placeholder="2627" style="width:70px;">
</div>
</div>
<div class="form-row" style="gap:6px;">
<button class="btn btn-sm btn-success flex-1" @click="startTunnel()" :disabled="!device || !tunnelIp">Start Tunnel</button>
<button class="btn btn-sm btn-danger flex-1" @click="stopTunnel()" :disabled="!device">Stop Tunnel</button>
</div>
</div>
<!-- Active device for SOCKS5 -->
<div style="border-bottom:1px solid var(--border-subtle);padding-bottom:12px;margin-bottom:12px;">
<div class="form-group">
<label>Active SOCKS5 Device</label>
<div class="form-row" style="gap:6px;">
<select class="select flex-1" x-model="activeDevice">
<option value="">none</option>
<template x-for="d in tunnels" :key="d.device_id">
<option :value="d.device_id" x-text="d.device_id"></option>
</template>
</select>
<button class="btn btn-sm btn-primary" @click="setActive()" :disabled="!activeDevice">Set</button>
</div>
</div>
</div>
<!-- SOCKS5 info -->
<div class="form-group">
<label>SOCKS5 Proxy</label>
<div class="text-mono text-xs" style="padding:8px;background:var(--bg-inset);border-radius:4px;">
<div><span class="text-muted">Address:</span> <span x-text="socksAddr"></span></div>
<div><span class="text-muted">Status:</span>
<span :class="socksRunning ? 'text-success' : 'text-muted'" x-text="socksRunning ? 'listening' : 'stopped'"></span>
</div>
<div style="margin-top:6px;" class="text-muted">
proxychains curl http://target/
</div>
</div>
</div>
</div>
</div>
<div class="resizer"></div>
<!-- Right: Status + Channels -->
<div class="panel flex-1">
<div class="panel-header">
<span>Tunnel Status</span>
<button class="btn btn-sm" @click="refresh()">Refresh</button>
</div>
<div class="panel-body" style="flex:1;overflow-y:auto;">
<!-- Connected tunnels -->
<template x-if="tunnels.length > 0">
<div>
<template x-for="tun in tunnels" :key="tun.device_id">
<div style="padding:12px;border-bottom:1px solid var(--border-subtle);">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span class="statusbar-dot ok"></span>
<strong x-text="tun.device_id"></strong>
<span class="text-muted text-xs" x-show="tun.device_id === currentActive">(active)</span>
</div>
<div class="text-xs text-mono" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:4px 16px;">
<div><span class="text-muted">Channels:</span> <span x-text="tun.active_channels + '/' + tun.max_channels"></span></div>
<div><span class="text-muted">TX:</span> <span x-text="formatBytes(tun.bytes_tx)"></span></div>
<div><span class="text-muted">RX:</span> <span x-text="formatBytes(tun.bytes_rx)"></span></div>
<div><span class="text-muted">Encrypted:</span> <span x-text="tun.encrypted ? 'yes' : 'no'"></span></div>
</div>
<!-- Channel list -->
<template x-if="tun.channels && tun.channels.length > 0">
<div style="margin-top:8px;">
<table class="data-table" style="font-size:11px;">
<thead>
<tr>
<th>CH</th>
<th>Target</th>
<th>State</th>
<th>TX</th>
<th>RX</th>
</tr>
</thead>
<tbody>
<template x-for="ch in tun.channels" :key="ch.id">
<tr>
<td x-text="ch.id"></td>
<td x-text="ch.target || '-'"></td>
<td x-text="ch.state"></td>
<td x-text="formatBytes(ch.bytes_tx)"></td>
<td x-text="formatBytes(ch.bytes_rx)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</template>
</div>
</template>
<template x-if="tunnels.length === 0">
<div class="text-muted text-xs" style="padding:16px;text-align:center;">
No active tunnels. Start a tunnel on a connected device.
</div>
</template>
<!-- Output log -->
<template x-if="outputLines.length > 0">
<div style="border-top:1px solid var(--border-subtle);padding:8px;">
<div class="text-xs text-muted" style="margin-bottom:4px;">Log</div>
<div class="term-output" style="max-height:200px;">
<template x-for="(l, i) in outputLines" :key="i">
<div class="term-line" :class="l.cls || ''" x-html="l.html"></div>
</template>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function tunnelApp() {
return {
...commander(),
device: '',
tunnelIp: '',
tunnelPort: '2627',
activeDevice: '',
currentActive: '',
socksAddr: '',
socksRunning: false,
tunnels: [],
outputLines: [],
_interval: null,
init() {
this.refresh();
this._interval = setInterval(() => this.refresh(), 3000);
},
destroy() {
if (this._interval) clearInterval(this._interval);
},
formatBytes(n) {
if (!n || n === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return n.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
},
async refresh() {
try {
const res = await fetch('/api/tunnel/status');
if (!res.ok) return;
const data = await res.json();
this.socksRunning = data.socks_running || false;
this.socksAddr = data.socks_addr || '-';
this.currentActive = data.active_device || '';
this.tunnels = data.tunnels || [];
if (!this.activeDevice && this.currentActive) {
this.activeDevice = this.currentActive;
}
} catch (e) {}
},
async startTunnel() {
if (!this.device) { toast('Select a device', 'error'); return; }
if (!this.tunnelIp) { toast('Enter C3PO tunnel IP', 'error'); return; }
const port = this.tunnelPort || '2627';
this.log('Starting tunnel on ' + this.device + ' -> ' + this.tunnelIp + ':' + port);
try {
const data = await this.sendCommand([this.device], 'tun_start', [this.tunnelIp, port]);
const r = (data.results || [])[0];
if (r && r.status === 'ok') {
this.log('Tunnel start command sent (async)');
} else {
this.log('Error: ' + (r && r.message || 'unknown'), 'term-error');
}
} catch (e) {
this.log('Error: ' + e.message, 'term-error');
}
},
async stopTunnel() {
if (!this.device) { toast('Select a device', 'error'); return; }
this.log('Stopping tunnel on ' + this.device + '...');
try {
const data = await this.sendCommand([this.device], 'tun_stop', []);
const r = (data.results || [])[0];
if (r && r.status === 'ok') {
this.log('Tunnel stopped');
} else {
this.log('Error: ' + (r && r.message || 'unknown'), 'term-error');
}
} catch (e) {
this.log('Error: ' + e.message, 'term-error');
}
setTimeout(() => this.refresh(), 500);
},
async setActive() {
if (!this.activeDevice) return;
try {
const res = await fetch('/api/tunnel/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: this.activeDevice })
});
const data = await res.json();
if (data.status === 'ok') {
this.log('Active device set to ' + this.activeDevice);
this.currentActive = this.activeDevice;
} else {
this.log('Error: ' + (data.error || 'unknown'), 'term-error');
}
} catch (e) {
this.log('Error: ' + e.message, 'term-error');
}
},
log(msg, cls) {
this.outputLines.push({ html: escapeHtml(msg), cls: cls || '' });
}
};
}
</script>
{% endblock %}

View File

@ -48,10 +48,11 @@ class C3POApp(App):
Binding("tab", "tab_complete", show=False, priority=True),
]
def __init__(self, registry=None, cli=None, **kwargs):
def __init__(self, registry=None, session=None, commander=None, **kwargs):
super().__init__(**kwargs)
self.registry = registry
self.cli = cli
self.session = session
self.commander = commander
self._device_panes: dict[str, DeviceLogPane] = {}
self._device_containers: dict[str, DeviceContainer] = {}
self._device_modules: dict[str, str] = {}
@ -82,7 +83,7 @@ class C3POApp(App):
self.set_interval(0.1, self.process_bridge_queue)
cmd_input = self.query_one("#command-input", CommandInput)
if self.cli:
if self.session:
cmd_input.set_completer(self._make_completer())
cmd_input.focus()
@ -101,7 +102,7 @@ class C3POApp(App):
]
def completer(text: str, state: int) -> str | None:
if not self.cli:
if not self.session:
return None
cmd_input = self.query_one("#command-input", CommandInput)
@ -111,16 +112,16 @@ class C3POApp(App):
options = []
if len(parts) <= 1 and not buffer.endswith(" "):
options = ["send", "list", "modules", "group", "help", "clear", "exit",
options = ["send", "list", "modules", "group", "help",
"active_commands", "web", "camera"]
elif parts[0] == "send":
if len(parts) == 2 and not buffer.endswith(" "):
options = ["all", "group"] + self.cli.registry.ids()
options = ["all", "group"] + self.session.registry.ids()
elif len(parts) == 2 and buffer.endswith(" "):
options = ["all", "group"] + self.cli.registry.ids()
options = ["all", "group"] + self.session.registry.ids()
elif len(parts) == 3 and parts[1] == "group" and not buffer.endswith(" "):
options = list(self.cli.groups.all_groups().keys())
options = list(self.session.groups.all_groups().keys())
elif len(parts) == 3 and parts[1] == "group" and buffer.endswith(" "):
options = ESP_COMMANDS
elif len(parts) == 3 and parts[1] != "group":
@ -142,9 +143,9 @@ class C3POApp(App):
elif len(parts) == 2 and buffer.endswith(" "):
options = ["add", "remove", "list", "show"]
elif parts[1] in ("remove", "show") and len(parts) >= 3:
options = list(self.cli.groups.all_groups().keys())
options = list(self.session.groups.all_groups().keys())
elif parts[1] == "add" and len(parts) >= 3:
options = self.cli.registry.ids()
options = self.session.registry.ids()
matches = [o for o in options if o.startswith(text)]
return matches[state] if state < len(matches) else None
@ -270,9 +271,9 @@ class C3POApp(App):
global_log = self.query_one("#global-log", GlobalLogPane)
global_log.add_system(self._timestamp(), f"Executing: {command}")
if self.cli:
if self.commander:
try:
self.cli.execute_command(command)
self.commander.execute(command)
except Exception as e:
global_log.add_error(self._timestamp(), f"Command error: {e}")

View File

@ -1,11 +1,10 @@
import readline
import os
import time
from typing import Optional
from utils.display import Display
from cli.help import HelpManager
from core.transport import Transport
from tui.help import HelpManager
from core.session import Session
from proto.c2_pb2 import Command
from streams.udp_receiver import UDPReceiver
from streams.config import (
@ -13,158 +12,60 @@ from streams.config import (
WEB_HOST, WEB_PORT, DEFAULT_USERNAME, DEFAULT_PASSWORD, FLASK_SECRET_KEY
)
from web.server import UnifiedWebServer
from web.mlat import MlatEngine
DEV_MODE = True
class CLI:
def __init__(self, registry, commands, groups, transport: Transport):
self.registry = registry
self.commands = commands
self.groups = groups
self.transport = transport
self.help_manager = HelpManager(commands, DEV_MODE)
self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}}
class Commander:
"""Routes text commands to handlers. No UI, no readline."""
# Separate server instances
self.web_server: Optional[UnifiedWebServer] = None
self.udp_receiver: Optional[UDPReceiver] = None
self.mlat_engine = MlatEngine()
def __init__(self, session: Session):
self.session = session
self.help_manager = HelpManager(session.commands, dev_mode=True)
# Honeypot dashboard components (created on web start)
self.hp_store = None
self.hp_commander = None
self.hp_alerts = None
self.hp_geo = None
readline.parse_and_bind("tab: complete")
readline.set_completer(self._complete)
# ================= TAB COMPLETION =================
def _complete(self, text, state):
buffer = readline.get_line_buffer()
parts = buffer.split()
options = []
if len(parts) == 1:
options = ["send", "list", "modules", "group", "help", "clear", "exit", "active_commands", "web", "camera"]
elif parts[0] == "send":
if len(parts) == 2: # Completing target (device ID, 'all', 'group')
options = ["all", "group"] + self.registry.ids()
elif len(parts) == 3 and parts[1] == "group": # Completing group name after 'send group'
options = list(self.groups.all_groups().keys())
elif (len(parts) == 3 and parts[1] != "group") or (len(parts) == 4 and parts[1] == "group"): # Completing command name
options = self.commands.list()
elif parts[0] == "web":
if len(parts) == 2:
options = ["start", "stop", "status"]
elif parts[0] == "camera":
if len(parts) == 2:
options = ["start", "stop", "status"]
elif parts[0] == "group":
if len(parts) == 2: # Completing group action
options = ["add", "remove", "list", "show"]
elif parts[1] == "add" and len(parts) >= 3: # Completing device IDs for 'group add'
# Suggest available device IDs that are not already in the group being added to
group_name = parts[2] if len(parts) > 2 else ""
current_group_members = self.groups.get(group_name) if group_name else []
all_device_ids = set(self.registry.ids())
options = sorted(list(all_device_ids - set(current_group_members)))
elif parts[1] in ("remove", "show") and len(parts) == 3: # Completing group names for 'group remove/show'
options = list(self.groups.all_groups().keys())
elif parts[1] == "remove" and len(parts) >= 4: # Completing device IDs for 'group remove'
group_name = parts[2]
options = self.groups.get(group_name)
matches = [o for o in options if o.startswith(text)]
return matches[state] if state < len(matches) else None
# ================= MAIN LOOP =================
def loop(self):
while True:
cmd = input(Display.cli_prompt()).strip()
if not cmd:
continue
if cmd == "exit":
return
self.execute_command(cmd)
def execute_command(self, cmd: str):
"""Execute a command string. Used by both CLI loop and TUI."""
def execute(self, cmd: str):
"""Execute a command string. Called by TUI on submit."""
if not cmd:
return
parts = cmd.split()
action = parts[0]
if action == "help":
self.help_manager.show(parts[1:])
return
dispatch = {
"help": lambda: self.help_manager.show(parts[1:]),
"exit": lambda: Display.system_message("Use Ctrl+Q to quit"),
"clear": lambda: Display.system_message("Use Ctrl+L to clear logs"),
"list": lambda: self._handle_list(),
"modules": lambda: self.help_manager.show_modules(),
"group": lambda: self._handle_group(parts[1:]),
"send": lambda: self._handle_send(parts),
"active_commands": lambda: self._handle_active_commands(),
"web": lambda: self._handle_web(parts[1:]),
"camera": lambda: self._handle_camera(parts[1:]),
"can": lambda: self._handle_can(parts[1:]),
}
if action == "exit":
return
if action == "clear":
os.system("cls" if os.name == "nt" else "clear")
return
if action == "list":
self._handle_list()
return
if action == "modules":
self.help_manager.show_modules()
return
if action == "group":
self._handle_group(parts[1:])
return
if action == "send":
self._handle_send(parts)
return
if action == "active_commands":
self._handle_active_commands()
return
if action == "web":
self._handle_web(parts[1:])
return
if action == "camera":
self._handle_camera(parts[1:])
return
Display.error("Unknown command")
handler = dispatch.get(action)
if handler:
handler()
else:
Display.error(f"Unknown command: {action}")
# ================= HANDLERS =================
def _handle_list(self):
now = time.time()
active_devices = self.registry.all()
active_devices = self.session.registry.all()
if not active_devices:
Display.system_message("No devices currently connected.")
return
Display.system_message("Connected Devices:")
Display.print_table_header(["ID", "IP Address", "Status", "Connected For", "Last Seen"])
Display.system_message(f" {'ID':<18}{'IP Address':<18}{'Status':<18}{'Connected For':<18}{'Last Seen':<18}")
Display.system_message(" " + "-" * 90)
for d in active_devices:
connected_for = Display.format_duration(now - d.connected_at)
last_seen_duration = Display.format_duration(now - d.last_seen)
Display.print_table_row([d.id, d.address[0], d.status, connected_for, last_seen_duration])
Display.system_message(f" {d.id:<18}{d.address[0]:<18}{d.status:<18}{connected_for:<18}{last_seen_duration}")
def _handle_send(self, parts):
if len(parts) < 3:
@ -177,9 +78,8 @@ class CLI:
devices_to_target = []
target_description = ""
# Resolve devices based on target_specifier
if target_specifier == "all":
devices_to_target = self.registry.all()
devices_to_target = self.session.registry.all()
target_description = "all connected devices"
elif target_specifier == "group":
if len(command_parts) < 2:
@ -187,27 +87,27 @@ class CLI:
return
group_name = command_parts[0]
command_parts = command_parts[1:]
group_members_ids = self.groups.get(group_name)
group_members_ids = self.session.groups.get(group_name)
if not group_members_ids:
Display.error(f"Group '{group_name}' not found or is empty.")
return
active_group_devices = []
for esp_id in group_members_ids:
dev = self.registry.get(esp_id)
dev = self.session.registry.get(esp_id)
if dev:
active_group_devices.append(dev)
else:
Display.device_event(esp_id, f"Device in group '{group_name}' is not currently connected.")
if not active_group_devices:
Display.error(f"No active devices found in group '{group_name}'.")
return
devices_to_target = active_group_devices
target_description = f"group '{group_name}' ({', '.join([d.id for d in devices_to_target])})"
else:
dev = self.registry.get(target_specifier)
dev = self.session.registry.get(target_specifier)
if dev:
devices_to_target.append(dev)
target_description = f"device '{target_specifier}'"
@ -219,7 +119,6 @@ class CLI:
Display.error("No target devices resolved for sending command.")
return
# Build Command
cmd_name = command_parts[0]
argv = command_parts[1:]
@ -231,13 +130,13 @@ class CLI:
cmd.device_id = d.id
cmd.command_name = cmd_name
cmd.argv.extend(argv)
request_id = f"{request_id_base}-{i}"
cmd.request_id = request_id
Display.command_sent(d.id, cmd_name, request_id)
self.transport.send_command(d.sock, cmd, d.id)
self.active_commands[request_id] = {
self.session.transport.send_command(d.sock, cmd, d.id)
self.session.active_commands[request_id] = {
"device_id": d.id,
"command_name": cmd_name,
"start_time": time.time(),
@ -245,22 +144,6 @@ class CLI:
"output": []
}
def handle_command_response(self, request_id: str, device_id: str, payload: str, eof: bool):
if request_id in self.active_commands:
command_info = self.active_commands[request_id]
command_info["output"].append(payload)
if eof:
command_info["status"] = "completed"
Display.command_response(request_id, device_id, f"Command completed in {Display.format_duration(time.time() - command_info['start_time'])}")
# Optionally print full output here if not already streamed
# Display.command_response(request_id, device_id, "\n".join(command_info["output"]))
del self.active_commands[request_id]
else:
# For streaming output, Display.command_response already prints each line
pass
else:
Display.device_event(device_id, f"Received response for unknown command {request_id}: {payload}")
def _handle_group(self, parts):
if not parts:
Display.error("Usage: group <add|remove|list|show>")
@ -272,8 +155,8 @@ class CLI:
group = parts[1]
added_devices = []
for esp_id in parts[2:]:
if self.registry.get(esp_id): # Only add if device exists
self.groups.add_device(group, esp_id)
if self.session.registry.get(esp_id):
self.session.groups.add_device(group, esp_id)
added_devices.append(esp_id)
else:
Display.device_event(esp_id, "Device not found, skipping group add.")
@ -282,13 +165,12 @@ class CLI:
else:
Display.system_message(f"No valid devices to add to group '{group}'.")
elif cmd == "remove" and len(parts) >= 3:
group = parts[1]
removed_devices = []
for esp_id in parts[2:]:
if esp_id in self.groups.get(group):
self.groups.remove_device(group, esp_id)
if esp_id in self.session.groups.get(group):
self.session.groups.remove_device(group, esp_id)
removed_devices.append(esp_id)
else:
Display.device_event(esp_id, f"Device not in group '{group}', skipping remove.")
@ -298,7 +180,7 @@ class CLI:
Display.system_message(f"No specified devices found in group '{group}' to remove.")
elif cmd == "list":
all_groups = self.groups.all_groups()
all_groups = self.session.groups.all_groups()
if not all_groups:
Display.system_message("No groups defined.")
return
@ -308,7 +190,7 @@ class CLI:
elif cmd == "show" and len(parts) == 2:
group_name = parts[1]
members = self.groups.get(group_name)
members = self.session.groups.get(group_name)
if members:
Display.system_message(f"Members of group '{group_name}': {', '.join(members)}")
else:
@ -318,26 +200,24 @@ class CLI:
Display.error("Invalid group command usage. See 'help group' for details.")
def _handle_active_commands(self):
if not self.active_commands:
if not self.session.active_commands:
Display.system_message("No commands are currently active.")
return
Display.system_message("Active Commands:")
Display.print_table_header(["Request ID", "Device ID", "Command", "Status", "Elapsed Time"])
Display.system_message(f" {'Request ID':<18}{'Device ID':<18}{'Command':<18}{'Status':<18}{'Elapsed Time':<18}")
Display.system_message(" " + "-" * 90)
now = time.time()
for req_id, cmd_info in self.active_commands.items():
for req_id, cmd_info in self.session.active_commands.items():
elapsed_time = Display.format_duration(now - cmd_info["start_time"])
Display.print_table_row([
req_id,
cmd_info["device_id"],
cmd_info["command_name"],
cmd_info["status"],
elapsed_time
])
Display.system_message(
f" {req_id:<18}{cmd_info['device_id']:<18}"
f"{cmd_info['command_name']:<18}{cmd_info['status']:<18}{elapsed_time}"
)
def _handle_web(self, parts):
"""Handle web server commands (frontend + multilateration API)."""
"""Handle web server commands."""
if not parts:
Display.error("Usage: web <start|stop|status>")
return
@ -345,73 +225,80 @@ class CLI:
cmd = parts[0]
if cmd == "start":
if self.web_server and self.web_server.is_running:
if self.session.web_server and self.session.web_server.is_running:
Display.system_message("Web server is already running.")
return
# Initialize honeypot dashboard components
try:
from hp_dashboard import HpStore, HpCommander, HpAlertEngine, HpGeoLookup
if not self.hp_store:
self.hp_geo = HpGeoLookup()
self.hp_store = HpStore(geo_lookup=self.hp_geo)
if not self.hp_alerts:
self.hp_alerts = HpAlertEngine()
self.hp_alerts.set_store(self.hp_store)
if not self.hp_commander:
self.hp_commander = HpCommander(
get_transport=lambda: self.transport,
get_registry=lambda: self.registry,
_c3po_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_data_dir = os.path.join(_c3po_root, "data")
os.makedirs(_data_dir, exist_ok=True)
if not self.session.hp_store:
self.session.hp_geo = HpGeoLookup(
db_path=os.path.join(_data_dir, "honeypot_geo.db"))
self.session.hp_store = HpStore(
db_path=os.path.join(_data_dir, "honeypot_events.db"),
geo_lookup=self.session.hp_geo)
if not self.session.hp_alerts:
self.session.hp_alerts = HpAlertEngine(
db_path=os.path.join(_data_dir, "honeypot_alerts.db"))
self.session.hp_alerts.set_store(self.session.hp_store)
if not self.session.hp_commander:
self.session.hp_commander = HpCommander(
get_transport=lambda: self.session.transport,
get_registry=lambda: self.session.registry,
)
# Wire into transport for event/response routing
self.transport.hp_store = self.hp_store
self.transport.hp_commander = self.hp_commander
self.session.transport.hp_store = self.session.hp_store
self.session.transport.hp_commander = self.session.hp_commander
Display.system_message("Honeypot dashboard enabled (alerts + geo active)")
except ImportError:
Display.system_message("Honeypot dashboard not available (hp_dashboard not found)")
self.web_server = UnifiedWebServer(
self.session.web_server = UnifiedWebServer(
host=WEB_HOST,
port=WEB_PORT,
image_dir=IMAGE_DIR,
username=DEFAULT_USERNAME,
password=DEFAULT_PASSWORD,
secret_key=FLASK_SECRET_KEY,
device_registry=self.registry,
mlat_engine=self.mlat_engine,
device_registry=self.session.registry,
transport=self.session.transport,
session=self.session,
mlat_engine=self.session.mlat_engine,
multilat_token=MULTILAT_AUTH_TOKEN,
camera_receiver=self.udp_receiver,
hp_store=self.hp_store,
hp_commander=self.hp_commander,
hp_alerts=self.hp_alerts,
hp_geo=self.hp_geo,
camera_receiver=self.session.udp_receiver,
hp_store=self.session.hp_store,
hp_commander=self.session.hp_commander,
hp_alerts=self.session.hp_alerts,
hp_geo=self.session.hp_geo,
)
if self.web_server.start():
Display.system_message(f"Web server started at {self.web_server.get_url()}")
if self.session.web_server.start():
Display.system_message(f"Web server started at {self.session.web_server.get_url()}")
else:
Display.error("Web server failed to start")
elif cmd == "stop":
if not self.web_server or not self.web_server.is_running:
if not self.session.web_server or not self.session.web_server.is_running:
Display.system_message("Web server is not running.")
return
self.web_server.stop()
self.session.web_server.stop()
Display.system_message("Web server stopped.")
self.web_server = None
self.session.web_server = None
elif cmd == "status":
Display.system_message("Web Server Status:")
if self.web_server and self.web_server.is_running:
if self.session.web_server and self.session.web_server.is_running:
Display.system_message(f" Status: Running")
Display.system_message(f" URL: {self.web_server.get_url()}")
Display.system_message(f" URL: {self.session.web_server.get_url()}")
else:
Display.system_message(f" Status: Stopped")
# MLAT stats
Display.system_message("MLAT Engine:")
state = self.mlat_engine.get_state()
state = self.session.mlat_engine.get_state()
Display.system_message(f" Mode: {state.get('coord_mode', 'gps').upper()}")
Display.system_message(f" Scanners: {state['scanners_count']}")
if state['target']:
@ -435,42 +322,40 @@ class CLI:
cmd = parts[0]
if cmd == "start":
if self.udp_receiver and self.udp_receiver.is_running:
if self.session.udp_receiver and self.session.udp_receiver.is_running:
Display.system_message("Camera UDP receiver is already running.")
return
self.udp_receiver = UDPReceiver(
self.session.udp_receiver = UDPReceiver(
host=UDP_HOST,
port=UDP_PORT,
image_dir=IMAGE_DIR,
device_registry=self.registry
device_registry=self.session.registry
)
if self.udp_receiver.start():
if self.session.udp_receiver.start():
Display.system_message(f"Camera UDP receiver started on {UDP_HOST}:{UDP_PORT}")
# Update web server if running
if self.web_server and self.web_server.is_running:
self.web_server.set_camera_receiver(self.udp_receiver)
if self.session.web_server and self.session.web_server.is_running:
self.session.web_server.set_camera_receiver(self.session.udp_receiver)
Display.system_message("Web server updated with camera receiver")
else:
Display.error("Camera UDP receiver failed to start")
elif cmd == "stop":
if not self.udp_receiver or not self.udp_receiver.is_running:
if not self.session.udp_receiver or not self.session.udp_receiver.is_running:
Display.system_message("Camera UDP receiver is not running.")
return
self.udp_receiver.stop()
self.session.udp_receiver.stop()
Display.system_message("Camera UDP receiver stopped.")
self.udp_receiver = None
# Update web server
if self.web_server and self.web_server.is_running:
self.web_server.set_camera_receiver(None)
self.session.udp_receiver = None
if self.session.web_server and self.session.web_server.is_running:
self.session.web_server.set_camera_receiver(None)
elif cmd == "status":
Display.system_message("Camera UDP Receiver Status:")
if self.udp_receiver and self.udp_receiver.is_running:
stats = self.udp_receiver.get_stats()
if self.session.udp_receiver and self.session.udp_receiver.is_running:
stats = self.session.udp_receiver.get_stats()
Display.system_message(f" Status: Running on {UDP_HOST}:{UDP_PORT}")
Display.system_message(f" Packets received: {stats['packets_received']}")
Display.system_message(f" Frames decoded: {stats['frames_received']}")
@ -482,3 +367,45 @@ class CLI:
else:
Display.error("Invalid camera command. Use: start, stop, status")
def _handle_can(self, parts):
"""Handle CAN bus commands — show local CAN store stats or frames."""
if not parts:
Display.error("Usage: can <stats|frames|clear> [device_id]")
return
cmd = parts[0]
if cmd == "stats":
device_id = parts[1] if len(parts) > 1 else None
stats = self.session.can_store.get_stats(device_id=device_id)
Display.system_message("CAN Bus Statistics:")
Display.system_message(f" Total received: {stats['total_received']}")
Display.system_message(f" Stored: {stats['total_stored']}")
Display.system_message(f" Unique CAN IDs: {stats['unique_can_ids']}")
if stats['can_ids']:
Display.system_message(f" IDs: {', '.join(stats['can_ids'][:20])}")
elif cmd == "frames":
device_id = parts[1] if len(parts) > 1 else None
limit = int(parts[2]) if len(parts) > 2 else 20
frames = self.session.can_store.get_frames(device_id=device_id, limit=limit)
if not frames:
Display.system_message("No CAN frames stored.")
return
Display.system_message(f"CAN Frames (last {len(frames)}):")
Display.system_message(f" {'Device':<12}{'CAN ID':<10}{'DLC':<6}{'Data':<20}{'Timestamp'}")
Display.system_message(" " + "-" * 70)
for f in frames:
Display.system_message(
f" {f['device_id']:<12}{f['can_id']:<10}{f['dlc']:<6}"
f"{f['data']:<20}{f['timestamp_ms']}"
)
elif cmd == "clear":
self.session.can_store.frames.clear()
self.session.can_store.total_count = 0
Display.system_message("CAN frame store cleared.")
else:
Display.error("Invalid CAN command. Use: stats, frames, clear")

View File

View File

@ -1,149 +0,0 @@
from utils.manager import list_groups
from utils.constant import _color
# from utils.genhash import generate_random_endpoint, generate_token
from utils.utils import _print_status, _list_clients, _send_command
#from test.test import system_check
# from udp_server import start_cam_server, stop_cam_server
import os
import readline
from utils.sheldon import call
def _setup_cli(c2):
readline.parse_and_bind("tab: complete")
readline.set_completer_delims(' \t\n;')
readline.set_completer(c2._complete)
readline.set_auto_history(True)
def _show_menu():
menu = f"""
{_color('CYAN')}=============== Menu Server ==============={_color('RESET')}
menu / help -> List of commands
########## Manage Esp32 ##########
add_group <group> <id_esp32> -> Add a client to a group
list_groups -> List all groups
remove_group <group> -> Remove a group
remove_esp_from <group> <esp> -> Remove a client from a group
reboot <id_esp32> -> Reboot a specific client
reboot <group> -> Reboot all clients in a group
reboot all -> Reboot all clients
########## System C2 Commands ##########
list -> List all connected clients
clear -> Clear the terminal screen
exit -> Exit the server
start/stop srv_video -> Register a camera service
########## Firmware Services client ##########
## Send Commands to firmware ##
send <id_esp32> <message> -> Send a message to a client
or
send <id_esp32> <start/stop> <service> <args> -> Start/Stop a service on a specific client
## Start/Stop Services on clients ##
start proxy <IP> <PORT> -> Start a reverse proxy on ur ip port for a specific client
stop proxy -> Stop the revproxy on a specific client
start stream <IP> <PORT> -> Start camera stream on a specific client
stop stream -> Stop camera stream on a specific client
start ap <WIFI_SSID> <PASSWORD> -> Start an access point on a specific client
(WIFI_SSID and PASSWORD are optional, default_SSID="ESP_32_WIFI_SSID")
stop ap -> Stop the access point on a specific client
list_clients -> List all connected clients on the access point
start sniffer -> Start packet sniffing on clients connected to the access point
stop sniffer -> Stop packet sniffing on clients connected to the access point
start captive_portal <WIFI_SSID> -> Start a server on a specific client
(WIFI_SSID is optional, default_SSID="ESP_32_WIFI_SSID")
stop captive_portal -> Stop the server on a specific client
"""
print(menu)
def _show_banner():
banner = rf"""
{_color('CYAN')}Authors : Eunous/grogore, itsoktocryyy, offpath, Wepfen, p2lu
___________
\_ _____/ ____________ |__| | ____ ____
| __)_ / ___/\____ \| | | / _ \ / \
| \\___ \ | |_> > | |_( <_> ) | \
/_______ /____ >| __/|__|____/\____/|___| /
\/ \/ |__| \/
=============== v 0.1 ==============={_color('RESET')}
"""
print(banner)
def cli_interface(self):
_show_banner()
_show_menu()
# def _cmd_start(parts):
# if len(parts) > 1 and parts[1] == "srv_video":
# start_cam_server()
# def _cmd_stop(parts):
# if len(parts) > 1 and parts[1] == "srv_video":
# stop_cam_server()
commands = {
"menu": lambda parts: _show_menu(),
"help": lambda parts: _show_menu(),
"send": lambda parts: _send_command(self, " ".join(parts)),
"list": lambda parts: _list_clients(self),
"clear": lambda parts: os.system('cls' if os.name == 'nt' else 'clear'),
"exit": lambda parts: self._shutdown(),
"reboot": lambda parts: self._handle_reboot(parts),
"add_group": lambda parts: self._handle_add_group(parts),
"list_groups": lambda parts: list_groups(self),
"remove_group": lambda parts: self._handle_remove_group(parts),
"remove_esp_from": lambda parts: self._handle_remove_esp_from(parts),
# "start": _cmd_start,
# "stop": _cmd_stop,
# "system_check": lambda parts: system_check(self),
}
while True:
choix = input(f"\n{_color('BLUE')}striker:> {_color('RESET')}").strip()
if not choix:
continue
parts = choix.split()
cmd = parts[0]
try:
if cmd in commands:
commands[cmd](parts)
else:
call(choix)
except Exception as e:
_print_status(f"Erreur: {str(e)}", "RED", "")

View File

@ -1,5 +1,4 @@
import time
from utils.constant import _color
# TUI bridge import (lazy to avoid circular imports)
_tui_bridge = None
@ -21,100 +20,74 @@ class Display:
@classmethod
def enable_tui_mode(cls):
"""Enable TUI mode - routes output to TUI bridge instead of print."""
cls._tui_mode = True
@classmethod
def disable_tui_mode(cls):
"""Disable TUI mode - back to print output."""
cls._tui_mode = False
@staticmethod
def _timestamp() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
@staticmethod
def system_message(message: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.SYSTEM_MESSAGE,
payload=message
))
return
print(f"{Display._timestamp()} {_color('CYAN')}[SYSTEM]{_color('RESET')} {message}")
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.SYSTEM_MESSAGE,
payload=message
))
@staticmethod
def device_event(device_id: str, event: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
# Detect special events
if "Connected from" in event:
msg_type = MessageType.DEVICE_CONNECTED
elif "Reconnected from" in event:
msg_type = MessageType.DEVICE_RECONNECTED
elif event == "Disconnected":
msg_type = MessageType.DEVICE_DISCONNECTED
else:
msg_type = MessageType.DEVICE_EVENT
bridge.post_message(TUIMessage(
msg_type=msg_type,
device_id=device_id,
payload=event
))
return
print(f"{Display._timestamp()} {_color('YELLOW')}[DEVICE:{device_id}]{_color('RESET')} {event}")
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
if "Connected from" in event:
msg_type = MessageType.DEVICE_CONNECTED
elif "Reconnected from" in event:
msg_type = MessageType.DEVICE_RECONNECTED
elif event == "Disconnected":
msg_type = MessageType.DEVICE_DISCONNECTED
else:
msg_type = MessageType.DEVICE_EVENT
bridge.post_message(TUIMessage(
msg_type=msg_type,
device_id=device_id,
payload=event
))
@staticmethod
def command_sent(device_id: str, command_name: str, request_id: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.COMMAND_SENT,
device_id=device_id,
payload=command_name,
request_id=request_id
))
return
print(f"{Display._timestamp()} {_color('BLUE')}[CMD_SENT:{request_id}]{_color('RESET')} To {device_id}: {command_name}")
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.COMMAND_SENT,
device_id=device_id,
payload=command_name,
request_id=request_id
))
@staticmethod
def command_response(request_id: str, device_id: str, response: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.COMMAND_RESPONSE,
device_id=device_id,
payload=response,
request_id=request_id
))
return
print(f"{Display._timestamp()} {_color('GREEN')}[CMD_RESP:{request_id}]{_color('RESET')} From {device_id}: {response}")
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.COMMAND_RESPONSE,
device_id=device_id,
payload=response,
request_id=request_id
))
@staticmethod
def error(message: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.ERROR,
payload=message
))
return
print(f"{Display._timestamp()} {_color('RED')}[ERROR]{_color('RESET')} {message}")
@staticmethod
def cli_prompt():
return f"\n{_color('BLUE')}c2:> {_color('RESET')}"
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.ERROR,
payload=message
))
@staticmethod
def format_duration(seconds: float) -> str:
@ -134,18 +107,3 @@ class Display:
@staticmethod
def format_timestamp(timestamp: float) -> str:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp))
@staticmethod
def print_table_header(headers: list):
header_str = ""
for header in headers:
header_str += f"{header:<18}"
print(header_str)
print("-" * (len(headers) * 18))
@staticmethod
def print_table_row(columns: list):
row_str = ""
for col in columns:
row_str += f"{str(col):<18}"
print(row_str)

View File

@ -0,0 +1,213 @@
"""Background firmware build manager for the OTA web interface."""
import json
import os
import shlex
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import List, Optional
# Import deploy.py as a library (one level up from C3PO/)
import sys as _sys
_TOOLS_DIR = str(Path(__file__).resolve().parent.parent.parent)
if _TOOLS_DIR not in _sys.path:
_sys.path.insert(0, _TOOLS_DIR)
from deploy import (
DeviceConfig,
generate_sdkconfig,
find_app_binary,
config_from_dict,
PROJECT_DIR,
)
class BuildStatus(str, Enum):
IDLE = "idle"
BUILDING = "building"
SUCCESS = "success"
FAILED = "failed"
@dataclass
class BuildState:
status: BuildStatus = BuildStatus.IDLE
device_id: str = ""
started_at: float = 0.0
finished_at: float = 0.0
output_filename: str = ""
log_lines: List[str] = field(default_factory=list)
error: str = ""
progress_hint: str = ""
class BuildManager:
"""Manages one firmware build at a time in a background thread."""
def __init__(self, firmware_dir: str, deploy_json_path: str):
self.firmware_dir = firmware_dir
self.deploy_json_path = deploy_json_path
self._lock = threading.Lock()
self._state = BuildState()
@property
def state(self) -> dict:
s = self._state
elapsed = 0.0
if s.status == BuildStatus.BUILDING and s.started_at:
elapsed = time.time() - s.started_at
elif s.finished_at and s.started_at:
elapsed = s.finished_at - s.started_at
return {
"status": s.status.value,
"device_id": s.device_id,
"started_at": s.started_at,
"finished_at": s.finished_at,
"elapsed": round(elapsed, 1),
"output_filename": s.output_filename,
"error": s.error,
"progress_hint": s.progress_hint,
"log_line_count": len(s.log_lines),
}
def get_log(self, offset: int = 0) -> List[str]:
return self._state.log_lines[offset:]
def get_defaults(self) -> dict:
try:
with open(self.deploy_json_path) as f:
data = json.load(f)
return data.get("defaults", {})
except (FileNotFoundError, json.JSONDecodeError):
return {}
def start_build(self, config_dict: dict) -> tuple:
"""Start a background build. Returns (success, message)."""
with self._lock:
if self._state.status == BuildStatus.BUILDING:
return False, "A build is already in progress"
self._state = BuildState(
status=BuildStatus.BUILDING,
device_id=config_dict.get("device_id", "unknown"),
started_at=time.time(),
)
t = threading.Thread(target=self._build_worker, args=(config_dict,), daemon=True)
t.start()
return True, "Build started"
def _build_worker(self, config_dict: dict):
s = self._state
try:
# 1. Find ESP-IDF
s.progress_hint = "Checking ESP-IDF..."
s.log_lines.append("[build] Checking ESP-IDF environment")
idf_path = self._find_idf()
s.log_lines.append(f"[build] ESP-IDF: {idf_path}")
# 2. Build DeviceConfig from dict + deploy.json defaults
s.progress_hint = "Preparing configuration..."
defaults = self.get_defaults()
cfg = config_from_dict(config_dict, defaults)
s.log_lines.append(f"[build] Device: {cfg.device_id}")
mods = []
if cfg.mod_network: mods.append("network")
if cfg.mod_fakeap: mods.append("fakeap")
if cfg.mod_honeypot: mods.append("honeypot")
if cfg.mod_recon: mods.append("recon")
if cfg.recon_camera: mods.append("camera")
s.log_lines.append(f"[build] Modules: {', '.join(mods) or 'none'}")
# 3. Write sdkconfig.defaults
s.progress_hint = "Writing sdkconfig..."
sdkconfig_path = PROJECT_DIR / "sdkconfig.defaults"
if sdkconfig_path.exists():
shutil.copy2(sdkconfig_path, sdkconfig_path.with_suffix(".defaults.bak"))
content = generate_sdkconfig(cfg)
with open(sdkconfig_path, "w") as f:
f.write(content)
s.log_lines.append("[build] sdkconfig.defaults written")
# 4. Clean build directory
s.progress_hint = "Cleaning build directory..."
sdkconfig = PROJECT_DIR / "sdkconfig"
build_dir = PROJECT_DIR / "build"
if sdkconfig.exists():
sdkconfig.unlink()
if build_dir.exists():
shutil.rmtree(build_dir)
s.log_lines.append("[build] Build directory cleaned")
# 5. Run idf.py build with streaming output
s.progress_hint = "Building firmware (this takes 3-8 minutes)..."
s.log_lines.append("[build] Starting idf.py build...")
idf_export = os.path.join(idf_path, "export.sh")
if not os.path.isfile(idf_export):
raise RuntimeError(f"export.sh not found in {idf_path}")
cmd = (
f". {shlex.quote(idf_export)} > /dev/null 2>&1 && "
f"idf.py -C {shlex.quote(str(PROJECT_DIR))} "
f"-D SDKCONFIG_DEFAULTS=sdkconfig.defaults "
f"build 2>&1"
)
process = subprocess.Popen(
["bash", "-c", cmd],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
for line in iter(process.stdout.readline, ""):
line = line.rstrip()
if not line:
continue
s.log_lines.append(line)
# Extract progress hints
lower = line.lower()
if "compiling" in lower or "building" in lower or "linking" in lower:
s.progress_hint = line[:100]
elif "generating" in lower:
s.progress_hint = line[:100]
process.wait()
if process.returncode != 0:
raise RuntimeError(f"idf.py build failed (exit code {process.returncode})")
# 6. Copy binary to firmware directory
s.progress_hint = "Copying binary..."
app_bin = find_app_binary(build_dir)
bin_size = os.path.getsize(app_bin)
filename = f"{cfg.device_id}-{int(time.time())}.bin"
os.makedirs(self.firmware_dir, exist_ok=True)
dest = os.path.join(self.firmware_dir, filename)
shutil.copy2(app_bin, dest)
s.log_lines.append(f"[build] Binary: {filename} ({bin_size:,} bytes)")
s.output_filename = filename
s.progress_hint = "Build complete"
s.status = BuildStatus.SUCCESS
s.finished_at = time.time()
except Exception as e:
s.error = str(e)
s.status = BuildStatus.FAILED
s.finished_at = time.time()
s.progress_hint = "Build failed"
s.log_lines.append(f"[error] {e}")
@staticmethod
def _find_idf() -> str:
"""Find and validate the ESP-IDF installation path."""
idf_path = os.environ.get("IDF_PATH", "")
if idf_path and os.path.isdir(idf_path):
if os.path.isfile(os.path.join(idf_path, "export.sh")):
return os.path.realpath(idf_path)
for candidate in [os.path.expanduser("~/esp-idf"), "/opt/esp-idf"]:
if os.path.isdir(candidate) and os.path.isfile(os.path.join(candidate, "export.sh")):
return os.path.realpath(candidate)
raise RuntimeError("ESP-IDF not found. Set IDF_PATH or install ESP-IDF.")

View File

@ -5,6 +5,12 @@ from .api_devices import create_devices_blueprint
from .api_cameras import create_cameras_blueprint
from .api_mlat import create_mlat_blueprint
from .api_stats import create_stats_blueprint
from .api_ota import create_ota_blueprint
from .api_build import create_build_blueprint
from .api_commands import create_commands_blueprint
from .api_monitor import create_monitor_blueprint
from .api_can import create_can_blueprint
from .api_tunnel import create_tunnel_blueprint
__all__ = [
"create_pages_blueprint",
@ -12,4 +18,10 @@ __all__ = [
"create_cameras_blueprint",
"create_mlat_blueprint",
"create_stats_blueprint",
"create_ota_blueprint",
"create_build_blueprint",
"create_commands_blueprint",
"create_monitor_blueprint",
"create_can_blueprint",
"create_tunnel_blueprint",
]

View File

@ -0,0 +1,108 @@
"""OTA firmware build API routes."""
import re
from flask import Blueprint, jsonify, request
def create_build_blueprint(server_config):
"""
Create the build API blueprint.
Args:
server_config: Dict with keys:
- build_manager: BuildManager instance
- require_api_auth: Auth decorator
"""
bp = Blueprint("api_build", __name__, url_prefix="/api/ota/build")
build_manager = server_config["build_manager"]
require_api_auth = server_config["require_api_auth"]
limiter = server_config["limiter"]
@bp.route("/defaults", methods=["GET"])
@require_api_auth
def get_defaults():
"""Return deploy.json defaults for form pre-population."""
return jsonify(build_manager.get_defaults())
@bp.route("/start", methods=["POST"])
@require_api_auth
@limiter.limit("3 per minute")
def start_build():
"""Trigger a firmware build for a device."""
data = request.get_json()
if not data:
return jsonify({"error": "JSON body required"}), 400
device_id = data.get("device_id", "")
if not isinstance(device_id, str):
return jsonify({"error": "device_id must be a string"}), 400
device_id = device_id.strip()
if not device_id:
return jsonify({"error": "device_id is required"}), 400
if not re.match(r'^[a-zA-Z0-9_-]{1,32}$', device_id):
return jsonify({"error": "Invalid device_id (alphanumeric/hyphens/underscores, max 32 chars)"}), 400
hostname = data.get("hostname", "")
if not isinstance(hostname, str):
return jsonify({"error": "hostname must be a string"}), 400
hostname = hostname.strip()
if hostname and not re.match(r'^[a-zA-Z0-9_-]{1,63}$', hostname):
return jsonify({"error": "Invalid hostname"}), 400
modules = data.get("modules", {})
if not isinstance(modules, dict):
return jsonify({"error": "modules must be an object"}), 400
VALID_MODULES = {"network", "fakeap", "honeypot", "recon", "recon_camera",
"recon_ble_trilat", "redteam", "ota", "canbus"}
for key, val in modules.items():
if key not in VALID_MODULES:
return jsonify({"error": f"Unknown module: {key}"}), 400
if not isinstance(val, bool):
return jsonify({"error": f"Module '{key}' must be a boolean"}), 400
server_cfg = data.get("server", {})
if not isinstance(server_cfg, dict):
return jsonify({"error": "server must be an object"}), 400
network_cfg = data.get("network", {})
if not isinstance(network_cfg, dict):
return jsonify({"error": "network must be an object"}), 400
ota_cfg = data.get("ota", {"enabled": True, "allow_http": False})
if not isinstance(ota_cfg, dict):
return jsonify({"error": "ota must be an object"}), 400
config = {
"device_id": device_id,
"hostname": hostname,
"modules": modules,
"server": server_cfg,
"network": network_cfg,
"ota": ota_cfg,
}
ok, msg = build_manager.start_build(config)
if not ok:
return jsonify({"error": msg}), 409
return jsonify({"status": "started", "device_id": device_id})
@bp.route("/status", methods=["GET"])
@require_api_auth
def build_status():
"""Return current build state."""
return jsonify(build_manager.state)
@bp.route("/log", methods=["GET"])
@require_api_auth
def build_log():
"""Return build log lines (supports offset for incremental fetch)."""
offset = request.args.get("offset", 0, type=int)
lines = build_manager.get_log(offset)
return jsonify({
"lines": lines,
"offset": offset,
"total": offset + len(lines),
})
return bp

View File

@ -0,0 +1,81 @@
"""CAN bus frame API routes."""
from flask import Blueprint, jsonify, request, Response
def create_can_blueprint(server_config):
"""
Create the CAN bus API blueprint.
Args:
server_config: Dict with keys:
- get_can_store: Callable returning CanStore instance
- require_api_auth: Auth decorator
"""
bp = Blueprint("api_can", __name__, url_prefix="/api/can")
get_can_store = server_config["get_can_store"]
require_api_auth = server_config["require_api_auth"]
@bp.route("/frames", methods=["GET"])
@require_api_auth
def get_frames():
"""List CAN frames with optional filters."""
store = get_can_store()
if not store:
return jsonify({"error": "CAN store not available"}), 503
device_id = request.args.get("device_id")
can_id_str = request.args.get("can_id")
limit = request.args.get("limit", 100, type=int)
offset = request.args.get("offset", 0, type=int)
# Clamp
limit = max(1, min(limit, 1000))
offset = max(0, offset)
can_id = None
if can_id_str:
try:
can_id = int(can_id_str, 16) if can_id_str.startswith("0x") else int(can_id_str)
except ValueError:
return jsonify({"error": "Invalid can_id format"}), 400
frames = store.get_frames(device_id=device_id, can_id=can_id,
limit=limit, offset=offset)
return jsonify({
"frames": frames,
"count": len(frames),
"offset": offset,
"limit": limit,
})
@bp.route("/stats", methods=["GET"])
@require_api_auth
def get_stats():
"""Get CAN frame statistics."""
store = get_can_store()
if not store:
return jsonify({"error": "CAN store not available"}), 503
device_id = request.args.get("device_id")
return jsonify(store.get_stats(device_id=device_id))
@bp.route("/frames/export", methods=["GET"])
@require_api_auth
def export_csv():
"""Export CAN frames as CSV."""
store = get_can_store()
if not store:
return jsonify({"error": "CAN store not available"}), 503
device_id = request.args.get("device_id")
csv_data = store.export_csv(device_id=device_id)
return Response(
csv_data,
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=can_frames.csv"},
)
return bp

View File

@ -0,0 +1,229 @@
"""Generic command API routes — send any command to ESP32 devices."""
import re
import time
import uuid
import threading
from flask import Blueprint, jsonify, request
from proto.c2_pb2 import Command
# In-memory store for command results (request_id → record)
_command_store = {}
_store_lock = threading.Lock()
# Auto-purge completed commands older than 5 minutes
_PURGE_AGE = 300
def _purge_old():
now = time.time()
with _store_lock:
expired = [k for k, v in _command_store.items()
if now - v["created_at"] > _PURGE_AGE]
for k in expired:
del _command_store[k]
def create_commands_blueprint(server_config):
"""
Create the commands API blueprint.
Args:
server_config: Dict with keys:
- get_device_registry: Callable returning device registry
- get_transport: Callable returning transport instance
- get_session: Callable returning session instance
- require_api_auth: Auth decorator
- limiter: Flask-Limiter instance
"""
bp = Blueprint("api_commands", __name__, url_prefix="/api")
get_registry = server_config["get_device_registry"]
get_transport = server_config["get_transport"]
get_session = server_config["get_session"]
require_api_auth = server_config["require_api_auth"]
limiter = server_config["limiter"]
@bp.route("/commands", methods=["POST"])
@require_api_auth
@limiter.limit("60 per minute")
def send_command():
"""
Send a command to one or more devices.
JSON body:
{
"device_ids": ["abc123"] or "all",
"command": "ping",
"argv": ["8.8.8.8"] // optional
}
Returns:
{
"results": [
{"device_id": "abc123", "status": "ok", "request_id": "..."},
...
]
}
"""
data = request.get_json()
if not data:
return jsonify({"error": "JSON body required"}), 400
command_name = data.get("command", "")
if not isinstance(command_name, str):
return jsonify({"error": "command must be a string"}), 400
command_name = command_name.strip()
if not command_name:
return jsonify({"error": "command is required"}), 400
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]{0,63}$', command_name):
return jsonify({"error": "Invalid command name"}), 400
device_ids = data.get("device_ids", [])
if device_ids != "all":
if not isinstance(device_ids, list):
return jsonify({"error": "device_ids must be a list or \"all\""}), 400
if len(device_ids) > 100:
return jsonify({"error": "Too many device_ids (max 100)"}), 400
for did in device_ids:
if not isinstance(did, str) or not did.strip():
return jsonify({"error": "Each device_id must be a non-empty string"}), 400
argv = data.get("argv", [])
if not isinstance(argv, list):
return jsonify({"error": "argv must be a list"}), 400
if len(argv) > 10:
return jsonify({"error": "Too many arguments (max 10)"}), 400
for i, a in enumerate(argv):
if not isinstance(a, (str, int, float)):
return jsonify({"error": f"argv[{i}] must be a string or number"}), 400
if isinstance(a, str) and len(a) > 256:
return jsonify({"error": f"argv[{i}] too long (max 256 chars)"}), 400
registry = get_registry()
transport = get_transport()
session = get_session()
if not registry or not transport:
return jsonify({"error": "C2 not ready"}), 503
# "all" → broadcast to every connected device
if device_ids == "all":
device_ids = [d.id for d in registry.all() if d.status == "Connected"]
if not device_ids:
return jsonify({"error": "device_ids is required (list or \"all\")"}), 400
_purge_old()
results = []
for did in device_ids:
device = registry.get(did)
if not device:
results.append({"device_id": did, "status": "error",
"message": "Device not found"})
continue
if device.status != "Connected":
results.append({"device_id": did, "status": "error",
"message": "Device not connected"})
continue
try:
req_id = f"web-{did}-{uuid.uuid4().hex[:8]}"
cmd = Command()
cmd.device_id = did
cmd.command_name = command_name
cmd.request_id = req_id
for a in argv:
cmd.argv.append(str(a))
# Shared output list so transport writes are visible to API
shared_output = []
now = time.time()
# Track in session so Transport routes responses back
if session:
session.active_commands[req_id] = {
"device_id": did,
"command": command_name,
"start_time": now,
"status": "pending",
"output": shared_output,
}
# Also track in our web store for polling (same output list)
with _store_lock:
_command_store[req_id] = {
"device_id": did,
"command": command_name,
"argv": argv,
"status": "pending",
"output": shared_output,
"created_at": now,
}
transport.send_command(device.sock, cmd, did)
results.append({"device_id": did, "status": "ok",
"request_id": req_id})
except Exception as e:
results.append({"device_id": did, "status": "error",
"message": str(e)})
return jsonify({"results": results})
@bp.route("/commands/<request_id>", methods=["GET"])
@require_api_auth
def get_command_status(request_id):
"""
Poll the result of a previously sent command.
Returns:
{
"request_id": "...",
"device_id": "...",
"command": "ping",
"status": "pending" | "completed",
"output": ["line1", "line2"]
}
"""
session = get_session()
# Check our web store (shares output list with session.active_commands)
with _store_lock:
if request_id in _command_store:
rec = _command_store[request_id]
# Sync status: if removed from session, it completed
if rec["status"] == "pending" and session:
if request_id in session.active_commands:
rec["status"] = session.active_commands[request_id]["status"]
else:
rec["status"] = "completed"
return jsonify({
"request_id": request_id,
**rec,
})
return jsonify({"error": "Command not found"}), 404
@bp.route("/commands", methods=["GET"])
@require_api_auth
def list_commands():
"""List recent commands and their statuses."""
session = get_session()
commands = []
with _store_lock:
for req_id, rec in sorted(_command_store.items(),
key=lambda x: x[1]["created_at"],
reverse=True):
# Sync completion status
if session and req_id not in session.active_commands:
if rec["status"] == "pending":
rec["status"] = "completed"
commands.append({"request_id": req_id, **rec})
return jsonify({"commands": commands[:50]})
return bp

View File

@ -1,7 +1,8 @@
"""Device API routes."""
import json
import time
from flask import Blueprint, jsonify
from flask import Blueprint, jsonify, Response
def create_devices_blueprint(server_config):
@ -18,31 +19,41 @@ def create_devices_blueprint(server_config):
get_registry = server_config["get_device_registry"]
require_api_auth = server_config["require_api_auth"]
@bp.route("/devices")
@require_api_auth
def list_devices():
def _serialize_devices():
registry = get_registry()
if registry is None:
return jsonify({"error": "Device registry not available", "devices": []})
return {"devices": [], "count": 0}
now = time.time()
devices = []
for d in registry.all():
devices.append({
"id": d.id,
"ip": d.address[0] if d.address else "unknown",
"port": d.address[1] if d.address else 0,
"status": d.status,
"chip": d.chip,
"modules": d.modules,
"connected_at": d.connected_at,
"last_seen": d.last_seen,
"connected_for_seconds": round(now - d.connected_at, 1),
"last_seen_ago_seconds": round(now - d.last_seen, 1)
})
return {"devices": devices, "count": len(devices)}
return jsonify({
"devices": devices,
"count": len(devices)
})
@bp.route("/devices")
@require_api_auth
def list_devices():
return jsonify(_serialize_devices())
@bp.route("/devices/stream")
@require_api_auth
def stream_devices():
def generate():
while True:
data = _serialize_devices()
yield f"data: {json.dumps(data)}\n\n"
time.sleep(3)
return Response(generate(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
return bp

View File

@ -0,0 +1,216 @@
"""Serial monitor API — stream ESP32 serial logs via SSE."""
import os
import threading
import time
import queue
from flask import Blueprint, Response, jsonify, request
# TTY port → device_id mapping (set at startup from deploy.json or manually)
_port_map = {}
_port_map_lock = threading.Lock()
# Active serial readers: port → { "thread": ..., "subscribers": [...], "stop": Event }
_readers = {}
_readers_lock = threading.Lock()
BAUD_RATE = 115200
def _serial_reader_thread(port, stop_event, subscribers, subscribers_lock):
"""Background thread that reads from a serial port and fans out to subscribers."""
import serial
try:
ser = serial.Serial(port, BAUD_RATE, timeout=0.5)
except Exception as e:
msg = f"[monitor] Failed to open {port}: {e}\n"
with subscribers_lock:
for q in subscribers:
q.put(msg)
return
buf = b""
while not stop_event.is_set():
try:
chunk = ser.read(256)
if not chunk:
continue
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
text = line.decode("utf-8", errors="replace").rstrip("\r")
with subscribers_lock:
dead = []
for i, q in enumerate(subscribers):
try:
q.put_nowait(text)
except queue.Full:
dead.append(i)
for i in reversed(dead):
subscribers.pop(i)
except Exception:
if stop_event.is_set():
break
time.sleep(0.1)
try:
ser.close()
except Exception:
pass
def _ensure_reader(port):
"""Start a serial reader thread for a port if not already running."""
with _readers_lock:
if port in _readers and _readers[port]["thread"].is_alive():
return _readers[port]
stop_event = threading.Event()
subscribers = []
subs_lock = threading.Lock()
t = threading.Thread(
target=_serial_reader_thread,
args=(port, stop_event, subscribers, subs_lock),
daemon=True,
)
t.start()
_readers[port] = {
"thread": t,
"stop": stop_event,
"subscribers": subscribers,
"subscribers_lock": subs_lock,
}
return _readers[port]
def _stop_reader(port):
"""Stop a serial reader thread."""
with _readers_lock:
if port in _readers:
_readers[port]["stop"].set()
del _readers[port]
def load_port_map_from_deploy(c2_root):
"""Try to load port→device_id mapping from deploy.json."""
import json
deploy_path = os.path.join(os.path.dirname(c2_root), "deploy.json")
try:
with open(deploy_path) as f:
cfg = json.load(f)
for dev in cfg.get("devices", []):
port = dev.get("port", "")
did = dev.get("device_id", "")
if port and did:
_port_map[port] = did
except (FileNotFoundError, json.JSONDecodeError, KeyError):
pass
def create_monitor_blueprint(server_config):
"""
Create the serial monitor API blueprint.
Args:
server_config: Dict with keys:
- get_device_registry: Callable returning device registry
- require_api_auth: Auth decorator
- require_login: Login decorator (for SSE)
"""
bp = Blueprint("api_monitor", __name__, url_prefix="/api")
get_registry = server_config["get_device_registry"]
require_api_auth = server_config["require_api_auth"]
require_login = server_config["require_login"]
# Auto-load port mapping from deploy.json
c2_root = server_config.get("c2_root", "")
load_port_map_from_deploy(c2_root)
@bp.route("/monitor/ports", methods=["GET"])
@require_api_auth
def list_ports():
"""List available serial ports and their device mappings."""
ports = []
for dev in sorted(os.listdir("/dev")):
if dev.startswith("ttyUSB") or dev.startswith("ttyACM"):
port = f"/dev/{dev}"
with _port_map_lock:
device_id = _port_map.get(port, "")
with _readers_lock:
active = port in _readers and _readers[port]["thread"].is_alive()
ports.append({
"port": port,
"device_id": device_id,
"monitoring": active,
})
return jsonify({"ports": ports})
@bp.route("/monitor/ports/map", methods=["POST"])
@require_api_auth
def set_port_map():
"""Set port → device_id mapping.
JSON body: {"mapping": {"/dev/ttyUSB0": "esp-fakeap", ...}}
"""
data = request.get_json()
if not data or "mapping" not in data:
return jsonify({"error": "mapping required"}), 400
with _port_map_lock:
_port_map.update(data["mapping"])
return jsonify({"ok": True, "mapping": _port_map})
@bp.route("/monitor/stream/<path:port>")
@require_login
def stream_serial(port):
"""SSE endpoint: stream serial logs from a port.
Example: GET /api/monitor/stream/dev/ttyUSB0
"""
real_port = "/" + port # reconstruct /dev/ttyUSBx
if not os.path.exists(real_port):
return jsonify({"error": f"Port {real_port} not found"}), 404
reader = _ensure_reader(real_port)
q = queue.Queue(maxsize=500)
with reader["subscribers_lock"]:
reader["subscribers"].append(q)
def generate():
yield f"data: [monitor] Connected to {real_port} @ {BAUD_RATE} baud\n\n"
try:
while True:
try:
line = q.get(timeout=30)
yield f"data: {line}\n\n"
except queue.Empty:
yield ": keepalive\n\n"
except GeneratorExit:
# Client disconnected, remove subscriber
with reader["subscribers_lock"]:
try:
reader["subscribers"].remove(q)
except ValueError:
pass
return Response(
generate(),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@bp.route("/monitor/stop/<path:port>", methods=["POST"])
@require_api_auth
def stop_monitor(port):
"""Stop monitoring a serial port."""
real_port = "/" + port
_stop_reader(real_port)
return jsonify({"ok": True, "port": real_port})
return bp

View File

@ -0,0 +1,176 @@
"""OTA firmware update API routes."""
import hmac
import os
import time
import uuid
from flask import Blueprint, jsonify, request, send_from_directory
from werkzeug.utils import secure_filename
from proto.c2_pb2 import Command
def create_ota_blueprint(server_config):
"""
Create the OTA API blueprint.
Args:
server_config: Dict with keys:
- get_device_registry: Callable returning device registry
- get_transport: Callable returning transport instance
- require_login: Auth decorator
- require_api_auth: Auth decorator
- c2_root: C2 root directory path
"""
bp = Blueprint("api_ota", __name__, url_prefix="/api/ota")
get_registry = server_config["get_device_registry"]
get_transport = server_config["get_transport"]
require_api_auth = server_config["require_api_auth"]
limiter = server_config["limiter"]
c2_root = server_config["c2_root"]
firmware_dir = os.path.join(c2_root, "firmware")
os.makedirs(firmware_dir, exist_ok=True)
@bp.route("/deploy", methods=["POST"])
@require_api_auth
@limiter.limit("10 per minute")
def deploy():
"""Send ota_update command to one or more devices."""
data = request.get_json()
if not data:
return jsonify({"error": "JSON body required"}), 400
url = data.get("url", "").strip()
device_ids = data.get("device_ids", [])
if not url:
return jsonify({"error": "url is required"}), 400
if not device_ids:
return jsonify({"error": "device_ids is required"}), 400
registry = get_registry()
transport = get_transport()
if not registry or not transport:
return jsonify({"error": "Registry or transport not available"}), 503
results = []
for did in device_ids:
device = registry.get(did)
if not device:
results.append({"device_id": did, "status": "error", "message": "Device not found"})
continue
if device.status != "Connected":
results.append({"device_id": did, "status": "error", "message": "Device not connected"})
continue
try:
req_id = f"ota-{did}-{uuid.uuid4().hex[:8]}"
cmd = Command()
cmd.device_id = did
cmd.command_name = "ota_update"
cmd.request_id = req_id
cmd.argv.append(url)
transport.send_command(device.sock, cmd, did)
results.append({"device_id": did, "status": "ok", "request_id": req_id})
except Exception as e:
results.append({"device_id": did, "status": "error", "message": str(e)})
return jsonify({"results": results})
@bp.route("/upload", methods=["POST"])
@require_api_auth
@limiter.limit("5 per minute")
def upload():
"""Upload a firmware binary to C3PO for serving."""
if "file" not in request.files:
return jsonify({"error": "No file uploaded"}), 400
f = request.files["file"]
if not f.filename:
return jsonify({"error": "No filename"}), 400
custom_name = request.form.get("name", "").strip()
if custom_name:
if not custom_name.endswith(".bin"):
custom_name += ".bin"
filename = secure_filename(custom_name)
else:
filename = secure_filename(f.filename)
if not filename.endswith(".bin"):
return jsonify({"error": "Only .bin files allowed"}), 400
filepath = os.path.join(firmware_dir, filename)
f.save(filepath)
size = os.path.getsize(filepath)
return jsonify({
"filename": filename,
"size": size,
"url": f"/api/ota/firmware/{filename}",
})
@bp.route("/firmware/<filename>")
@require_api_auth
def serve_firmware(filename):
"""Serve a firmware binary file (requires API auth)."""
filename = secure_filename(filename)
return send_from_directory(firmware_dir, filename)
@bp.route("/fw/<filename>")
def serve_firmware_short(filename):
"""Short URL for firmware download (ESP32 OTA + authenticated users).
Checks either Bearer token or session login."""
from flask import session as flask_session
from streams.config import MULTILAT_AUTH_TOKEN
auth = request.headers.get("Authorization", "")
token_param = request.args.get("token", "")
logged_in = flask_session.get("logged_in", False)
valid_bearer = auth == f"Bearer {MULTILAT_AUTH_TOKEN}"
valid_token = token_param and hmac.compare_digest(token_param, MULTILAT_AUTH_TOKEN)
if not (logged_in or valid_bearer or valid_token):
return jsonify({"error": "Unauthorized"}), 401
filename = secure_filename(filename)
return send_from_directory(firmware_dir, filename)
@bp.route("/dl/<filename>")
def serve_firmware_public(filename):
"""Public firmware download for ESP32 OTA (no auth required).
The device cannot set HTTP headers, and query-param auth may fail
with the IDF HTTP client. Bind to LAN only."""
filename = secure_filename(filename)
filepath = os.path.join(firmware_dir, filename)
if not os.path.exists(filepath):
return jsonify({"error": "File not found"}), 404
return send_from_directory(firmware_dir, filename)
@bp.route("/firmware", methods=["GET"])
@require_api_auth
def list_firmware():
"""List available firmware files."""
files = []
if os.path.isdir(firmware_dir):
for f in sorted(os.listdir(firmware_dir)):
if f.endswith(".bin"):
fpath = os.path.join(firmware_dir, f)
files.append({
"filename": f,
"size": os.path.getsize(fpath),
"modified": os.path.getmtime(fpath),
})
return jsonify({"firmware": files})
@bp.route("/firmware/<filename>", methods=["DELETE"])
@require_api_auth
def delete_firmware(filename):
"""Delete a firmware file."""
filename = secure_filename(filename)
filepath = os.path.join(firmware_dir, filename)
if os.path.exists(filepath):
os.remove(filepath)
return jsonify({"status": "deleted"})
return jsonify({"error": "File not found"}), 404
return bp

View File

@ -0,0 +1,32 @@
"""Flask API routes for tunnel/SOCKS5 proxy management."""
from flask import Blueprint, jsonify, request
def create_tunnel_blueprint(session):
bp = Blueprint("api_tunnel", __name__)
@bp.route("/api/tunnel/status")
def tunnel_status():
tunnel = getattr(session, "tunnel_server", None)
if tunnel is None:
return jsonify({"error": "tunnel server not initialized"}), 503
return jsonify(tunnel.get_status())
@bp.route("/api/tunnel/active", methods=["POST"])
def tunnel_set_active():
"""Switch SOCKS5 traffic to a different device."""
tunnel = getattr(session, "tunnel_server", None)
if tunnel is None:
return jsonify({"error": "tunnel server not initialized"}), 503
data = request.get_json(silent=True) or {}
device_id = data.get("device_id")
if not device_id:
return jsonify({"error": "device_id required"}), 400
if tunnel.set_active_device(device_id):
return jsonify({"status": "ok", "active_device": device_id})
return jsonify({"error": f"device '{device_id}' not connected"}), 404
return bp

View File

@ -1,5 +1,6 @@
"""Page routes (login, dashboard, cameras, mlat)."""
import hmac
import os
import secrets
from flask import Blueprint, render_template, redirect, url_for, request, session
@ -24,8 +25,11 @@ def create_pages_blueprint(server_config):
image_dir = server_config["image_dir"]
c2_root = server_config["c2_root"]
require_login = server_config["require_login"]
limiter = server_config["limiter"]
rate_limit_login = server_config["rate_limit_login"]
@bp.route("/login", methods=["GET", "POST"])
@limiter.limit(rate_limit_login, methods=["POST"])
def login():
error = None
if request.method == "POST":
@ -36,7 +40,8 @@ def create_pages_blueprint(server_config):
else:
form_user = request.form.get("username")
form_pass = request.form.get("password")
if form_user == username and form_pass == password:
if hmac.compare_digest(form_user, username) and hmac.compare_digest(form_pass, password):
session.clear() # Prevent session fixation
session["logged_in"] = True
return redirect(url_for("pages.dashboard"))
else:
@ -79,6 +84,51 @@ def create_pages_blueprint(server_config):
def mlat():
return render_template("mlat.html", active_page="mlat")
@bp.route("/ota")
@require_login
def ota():
return render_template("ota.html", active_page="ota")
@bp.route("/device/<device_id>")
@require_login
def device_detail(device_id):
return render_template("device.html", active_page="dashboard", device_id=device_id)
@bp.route("/terminal")
@require_login
def terminal():
return render_template("terminal.html", active_page="terminal")
@bp.route("/canbus")
@require_login
def canbus():
return render_template("canbus.html", active_page="canbus")
@bp.route("/redteam")
@require_login
def redteam():
return render_template("redteam.html", active_page="redteam")
@bp.route("/network")
@require_login
def network():
return render_template("network.html", active_page="network")
@bp.route("/fakeap")
@require_login
def fakeap():
return render_template("fakeap.html", active_page="fakeap")
@bp.route("/system")
@require_login
def system():
return render_template("system.html", active_page="system")
@bp.route("/tunnel")
@require_login
def tunnel():
return render_template("tunnel.html", active_page="tunnel")
@bp.route("/streams/<filename>")
@require_login
def stream_image(filename):

View File

@ -1,12 +1,13 @@
"""Unified Flask web server for ESPILON C2 dashboard."""
import os
import sys
import logging
import threading
from typing import Optional
from flask import Flask
from flask import Flask, jsonify, request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.serving import make_server
from .mlat import MlatEngine
@ -17,14 +18,14 @@ from .routes import (
create_cameras_blueprint,
create_mlat_blueprint,
create_stats_blueprint,
create_ota_blueprint,
create_build_blueprint,
create_commands_blueprint,
create_monitor_blueprint,
create_can_blueprint,
create_tunnel_blueprint,
)
# Make hp_dashboard importable (lives in espilon-honey-pot/tools/)
_HP_TOOLS_DIR = os.environ.get("HP_DASHBOARD_PATH", os.path.normpath(os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..", "espilon-honey-pot", "tools"
)))
if os.path.isdir(_HP_TOOLS_DIR) and _HP_TOOLS_DIR not in sys.path:
sys.path.insert(0, _HP_TOOLS_DIR)
from .build_manager import BuildManager
# Disable Flask/Werkzeug request logging
logging.getLogger('werkzeug').setLevel(logging.ERROR)
@ -49,6 +50,8 @@ class UnifiedWebServer:
secret_key: str = "change_this_for_prod",
multilat_token: str = "multilat_secret_token",
device_registry=None,
transport=None,
session=None,
mlat_engine: Optional[MlatEngine] = None,
camera_receiver=None,
hp_store=None,
@ -82,6 +85,8 @@ class UnifiedWebServer:
self.secret_key = secret_key
self.multilat_token = multilat_token
self.device_registry = device_registry
self.transport = transport
self._session = session
self.mlat = mlat_engine or MlatEngine()
self.camera_receiver = camera_receiver
self.hp_store = hp_store
@ -118,6 +123,41 @@ class UnifiedWebServer:
static_folder=static_dir)
app.secret_key = self.secret_key
# CORS: allow cross-origin requests from whitelisted origins only
from streams.config import CORS_ALLOWED_ORIGINS
@app.after_request
def add_cors_headers(response):
origin = request.headers.get("Origin", "")
if origin and CORS_ALLOWED_ORIGINS and origin in CORS_ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
@app.route("/api/<path:path>", methods=["OPTIONS"])
@app.route("/api/", defaults={"path": ""}, methods=["OPTIONS"])
def handle_preflight(path):
return "", 204
# Rate limiter (in-memory, per-IP)
from streams.config import RATE_LIMIT_DEFAULT, RATE_LIMIT_LOGIN
limiter = Limiter(
app=app,
key_func=get_remote_address,
default_limits=[RATE_LIMIT_DEFAULT],
storage_uri="memory://",
)
# Return JSON for API errors
@app.errorhandler(429)
def handle_429(e):
return jsonify({"error": "Rate limit exceeded", "detail": str(e.description)}), 429
@app.errorhandler(500)
def handle_500(e):
return jsonify({"error": "Internal server error", "detail": str(e)}), 500
# Create auth decorators
require_login, require_api_auth = create_auth_decorators(
lambda: self.multilat_token
@ -129,6 +169,8 @@ class UnifiedWebServer:
"image_dir": self.image_dir,
"require_login": require_login,
"require_api_auth": require_api_auth,
"limiter": limiter,
"rate_limit_login": RATE_LIMIT_LOGIN,
}
# Register blueprints
@ -159,6 +201,42 @@ class UnifiedWebServer:
"get_mlat_engine": lambda: self.mlat,
}))
app.register_blueprint(create_ota_blueprint({
**base_config,
"get_device_registry": lambda: self.device_registry,
"get_transport": lambda: self.transport,
}))
app.register_blueprint(create_commands_blueprint({
**base_config,
"get_device_registry": lambda: self.device_registry,
"get_transport": lambda: self.transport,
"get_session": lambda: self._session,
}))
app.register_blueprint(create_monitor_blueprint({
**base_config,
"get_device_registry": lambda: self.device_registry,
}))
# Build firmware from web (uses deploy.py as library)
deploy_json = os.path.join(os.path.dirname(self.c2_root), "deploy.json")
firmware_dir = os.path.join(self.c2_root, "firmware")
build_mgr = BuildManager(firmware_dir, deploy_json)
app.register_blueprint(create_build_blueprint({
**base_config,
"build_manager": build_mgr,
}))
# CAN bus frame API (always available — store is created in session)
app.register_blueprint(create_can_blueprint({
**base_config,
"get_can_store": lambda: self._session.can_store if self._session else None,
}))
# Tunnel / SOCKS5 proxy API
app.register_blueprint(create_tunnel_blueprint(self._session))
# Honeypot dashboard (optional — only if hp_store is provided)
if self.hp_store and self.hp_commander:
try: