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:
parent
c193e30671
commit
79c2a4d4bf
@ -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
26
tools/C3PO/Dockerfile
Normal 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"]
|
||||
@ -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()
|
||||
|
||||
|
||||
0
tools/C3PO/commands/__init__.py
Normal file
0
tools/C3PO/commands/__init__.py
Normal 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):
|
||||
|
||||
0
tools/C3PO/core/__init__.py
Normal file
0
tools/C3PO/core/__init__.py
Normal file
113
tools/C3PO/core/can_store.py
Normal file
113
tools/C3PO/core/can_store.py
Normal 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)
|
||||
@ -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."""
|
||||
|
||||
82
tools/C3PO/core/session.py
Normal file
82
tools/C3PO/core/session.py
Normal 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}")
|
||||
@ -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
684
tools/C3PO/core/tunnel.py
Normal 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
|
||||
15
tools/C3PO/docker-compose.yml
Normal file
15
tools/C3PO/docker-compose.yml
Normal 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
|
||||
8
tools/C3PO/hp_dashboard/__init__.py
Normal file
8
tools/C3PO/hp_dashboard/__init__.py
Normal 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"]
|
||||
555
tools/C3PO/hp_dashboard/hp_alerts.py
Normal file
555
tools/C3PO/hp_dashboard/hp_alerts.py
Normal 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
|
||||
299
tools/C3PO/hp_dashboard/hp_commander.py
Normal file
299
tools/C3PO/hp_dashboard/hp_commander.py
Normal 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]
|
||||
]
|
||||
312
tools/C3PO/hp_dashboard/hp_geo.py
Normal file
312
tools/C3PO/hp_dashboard/hp_geo.py
Normal 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
|
||||
844
tools/C3PO/hp_dashboard/hp_routes.py
Normal file
844
tools/C3PO/hp_dashboard/hp_routes.py
Normal 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
|
||||
944
tools/C3PO/hp_dashboard/hp_store.py
Normal file
944
tools/C3PO/hp_dashboard/hp_store.py
Normal 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
|
||||
790
tools/C3PO/hp_dashboard/static/hp/css/honeypot.css
Normal file
790
tools/C3PO/hp_dashboard/static/hp/css/honeypot.css
Normal 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%; } }
|
||||
BIN
tools/C3PO/hp_dashboard/static/hp/img/floating.png
Normal file
BIN
tools/C3PO/hp_dashboard/static/hp/img/floating.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
87
tools/C3PO/hp_dashboard/static/hp/js/api.js
Normal file
87
tools/C3PO/hp_dashboard/static/hp/js/api.js
Normal 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();
|
||||
}
|
||||
215
tools/C3PO/hp_dashboard/static/hp/js/app.js
Normal file
215
tools/C3PO/hp_dashboard/static/hp/js/app.js
Normal 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);
|
||||
});
|
||||
17
tools/C3PO/hp_dashboard/static/hp/js/audio.js
Normal file
17
tools/C3PO/hp_dashboard/static/hp/js/audio.js
Normal 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
|
||||
}
|
||||
}
|
||||
80
tools/C3PO/hp_dashboard/static/hp/js/charts.js
Normal file
80
tools/C3PO/hp_dashboard/static/hp/js/charts.js
Normal 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;
|
||||
}
|
||||
77
tools/C3PO/hp_dashboard/static/hp/js/credentials.js
Normal file
77
tools/C3PO/hp_dashboard/static/hp/js/credentials.js
Normal 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;
|
||||
}
|
||||
340
tools/C3PO/hp_dashboard/static/hp/js/events.js
Normal file
340
tools/C3PO/hp_dashboard/static/hp/js/events.js
Normal 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&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';
|
||||
});
|
||||
}
|
||||
160
tools/C3PO/hp_dashboard/static/hp/js/killchain.js
Normal file
160
tools/C3PO/hp_dashboard/static/hp/js/killchain.js
Normal 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 · 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');
|
||||
}
|
||||
}
|
||||
122
tools/C3PO/hp_dashboard/static/hp/js/mitre.js
Normal file
122
tools/C3PO/hp_dashboard/static/hp/js/mitre.js
Normal 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('🛡', '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;
|
||||
}
|
||||
307
tools/C3PO/hp_dashboard/static/hp/js/overview.js
Normal file
307
tools/C3PO/hp_dashboard/static/hp/js/overview.js
Normal 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');
|
||||
}
|
||||
}
|
||||
74
tools/C3PO/hp_dashboard/static/hp/js/router.js
Normal file
74
tools/C3PO/hp_dashboard/static/hp/js/router.js
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
152
tools/C3PO/hp_dashboard/static/hp/js/sessions.js
Normal file
152
tools/C3PO/hp_dashboard/static/hp/js/sessions.js
Normal 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');
|
||||
}
|
||||
211
tools/C3PO/hp_dashboard/static/hp/js/sidebar.js
Normal file
211
tools/C3PO/hp_dashboard/static/hp/js/sidebar.js
Normal 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();
|
||||
}
|
||||
106
tools/C3PO/hp_dashboard/static/hp/js/sse.js
Normal file
106
tools/C3PO/hp_dashboard/static/hp/js/sse.js
Normal 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);
|
||||
};
|
||||
}
|
||||
51
tools/C3PO/hp_dashboard/static/hp/js/state.js
Normal file
51
tools/C3PO/hp_dashboard/static/hp/js/state.js
Normal 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
|
||||
};
|
||||
131
tools/C3PO/hp_dashboard/static/hp/js/ui.js
Normal file
131
tools/C3PO/hp_dashboard/static/hp/js/ui.js
Normal 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: '✓',
|
||||
error: '✗',
|
||||
warning: '⚠',
|
||||
info: 'ℹ'
|
||||
};
|
||||
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();
|
||||
}
|
||||
103
tools/C3PO/hp_dashboard/static/hp/js/utils.js
Normal file
103
tools/C3PO/hp_dashboard/static/hp/js/utils.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@ -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')}"
|
||||
)
|
||||
@ -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
4
tools/C3PO/static/favicon.svg
Normal file
4
tools/C3PO/static/favicon.svg
Normal 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">ε</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
67
tools/C3PO/static/js/commander.js
Normal file
67
tools/C3PO/static/js/commander.js
Normal 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';
|
||||
}
|
||||
};
|
||||
}
|
||||
76
tools/C3PO/static/js/data-table.js
Normal file
76
tools/C3PO/static/js/data-table.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
73
tools/C3PO/static/js/resizer.js
Normal file
73
tools/C3PO/static/js/resizer.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
57
tools/C3PO/static/js/store.js
Normal file
57
tools/C3PO/static/js/store.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
49
tools/C3PO/static/js/utils.js
Normal file
49
tools/C3PO/static/js/utils.js
Normal 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);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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">ε</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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
225
tools/C3PO/templates/canbus.html
Normal file
225
tools/C3PO/templates/canbus.html
Normal 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">«</button>
|
||||
<button class="btn btn-sm" @click="page = Math.min(totalPages-1, page+1)" :disabled="page>=totalPages-1">»</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 %}
|
||||
@ -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">«</button>
|
||||
<button class="btn btn-sm" @click="page = Math.min(totalPages-1, page+1)" :disabled="page >= totalPages-1">»</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 %}
|
||||
|
||||
136
tools/C3PO/templates/device.html
Normal file
136
tools/C3PO/templates/device.html
Normal 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);">←</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 }}></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 %}
|
||||
133
tools/C3PO/templates/fakeap.html
Normal file
133
tools/C3PO/templates/fakeap.html
Normal 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 %}
|
||||
251
tools/C3PO/templates/honeypot.html
Normal file
251
tools/C3PO/templates/honeypot.html
Normal 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">×</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">×</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">×</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">×</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">×</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 %}
|
||||
@ -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">ε 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
125
tools/C3PO/templates/network.html
Normal file
125
tools/C3PO/templates/network.html
Normal 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) + '> ' + 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 %}
|
||||
270
tools/C3PO/templates/ota.html
Normal file
270
tools/C3PO/templates/ota.html
Normal 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 %}
|
||||
128
tools/C3PO/templates/redteam.html
Normal file
128
tools/C3PO/templates/redteam.html
Normal 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 %}
|
||||
145
tools/C3PO/templates/system.html
Normal file
145
tools/C3PO/templates/system.html
Normal 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 %}
|
||||
297
tools/C3PO/templates/terminal.html
Normal file
297
tools/C3PO/templates/terminal.html
Normal 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>>',
|
||||
|
||||
// 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>>';
|
||||
},
|
||||
|
||||
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) + '> ' + 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 %}
|
||||
262
tools/C3PO/templates/tunnel.html
Normal file
262
tools/C3PO/templates/tunnel.html
Normal 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 %}
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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")
|
||||
0
tools/C3PO/utils/__init__.py
Normal file
0
tools/C3PO/utils/__init__.py
Normal 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", "⚠")
|
||||
@ -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)
|
||||
|
||||
213
tools/C3PO/web/build_manager.py
Normal file
213
tools/C3PO/web/build_manager.py
Normal 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.")
|
||||
@ -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",
|
||||
]
|
||||
|
||||
108
tools/C3PO/web/routes/api_build.py
Normal file
108
tools/C3PO/web/routes/api_build.py
Normal 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
|
||||
81
tools/C3PO/web/routes/api_can.py
Normal file
81
tools/C3PO/web/routes/api_can.py
Normal 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
|
||||
229
tools/C3PO/web/routes/api_commands.py
Normal file
229
tools/C3PO/web/routes/api_commands.py
Normal 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
|
||||
@ -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
|
||||
|
||||
216
tools/C3PO/web/routes/api_monitor.py
Normal file
216
tools/C3PO/web/routes/api_monitor.py
Normal 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
|
||||
176
tools/C3PO/web/routes/api_ota.py
Normal file
176
tools/C3PO/web/routes/api_ota.py
Normal 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
|
||||
32
tools/C3PO/web/routes/api_tunnel.py
Normal file
32
tools/C3PO/web/routes/api_tunnel.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user