mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
refactor!: complete migration from Python to Go backend with distroless containerization and multi-arch CI
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
# STAGE 1: Builder
|
||||
FROM golang:1.25 AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o honeywire-hub ./cmd/hub/main.go
|
||||
|
||||
# STAGE 2: Distroless
|
||||
# The "static" image contains literally nothing except SSL certificates and a non-root user.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/honeywire-hub /app/
|
||||
|
||||
# Expose port and run
|
||||
EXPOSE 8080
|
||||
CMD ["/app/honeywire-hub"]
|
||||
@@ -1,31 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 1. THE FIXER: Runs as root, fixes permissions, and dies instantly.
|
||||
permission-fixer:
|
||||
image: alpine:latest
|
||||
# 65532 is the standard UID/GID for the Distroless nonroot user
|
||||
command: sh -c "chown -R 65532:65532 /data"
|
||||
volumes:
|
||||
- ./honeywire_data:/data
|
||||
|
||||
# 2. THE HUB: pure Go distroless backend.
|
||||
honeywire-hub:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: honeywire-hub
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HW_PORT:-8080}:${HW_PORT:-8080}"
|
||||
volumes:
|
||||
- ./honeywire_data:/data
|
||||
depends_on:
|
||||
# This ensures the Hub waits until the fixer is completely finished
|
||||
permission-fixer:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
- HW_PORT=8080
|
||||
- HW_DB_PATH=/data/honeywire.db
|
||||
- HW_HUB_KEY=change_this_to_a_secure_random_string
|
||||
- HW_DASHBOARD_PASSWORD=admin
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Generate demo sensors + dynamic events for HoneyWire UI showcase."""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
HUB_URL = "http://localhost:8080"
|
||||
API_SECRET = "change_this_to_a_secure_random_string"
|
||||
HEADERS = {"x-api-key": API_SECRET, "Content-Type": "application/json"}
|
||||
|
||||
# Determine version for contract validation
|
||||
def get_project_version():
|
||||
version = os.getenv("HONEYWIRE_VERSION")
|
||||
if version: return version
|
||||
try:
|
||||
with open(os.path.join(os.path.dirname(__file__), "..", "VERSION"), "r") as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
return "1.0.0"
|
||||
|
||||
VERSION = get_project_version()
|
||||
|
||||
# --- Random Data Generators ---
|
||||
|
||||
def generate_random_ip():
|
||||
"""Generates a random public-looking IPv4 address."""
|
||||
return socket.inet_ntoa(struct.pack('>I', random.randint(0x01000000, 0xDF000000)))
|
||||
|
||||
def generate_fleet(num_sensors=8):
|
||||
"""Generates a list of random sensors with dynamic names."""
|
||||
types = ["tarpit", "canary_token", "ids_sentinel", "web_honeypot", "llm_probe"]
|
||||
regions = ["us-east", "us-west", "eu-central", "ap-south", "lan-core", "dmz"]
|
||||
|
||||
fleet = []
|
||||
for _ in range(num_blocks := num_sensors):
|
||||
s_type = random.choice(types)
|
||||
region = random.choice(regions)
|
||||
sensor_id = f"{s_type}-{region}-{random.randint(10, 99)}"
|
||||
fleet.append({"sensor_id": sensor_id, "sensor_type": s_type})
|
||||
return fleet
|
||||
|
||||
def generate_contextual_event(sensor):
|
||||
"""Generates a realistic event that matches the sensor type."""
|
||||
s_type = sensor["sensor_type"]
|
||||
ip = generate_random_ip()
|
||||
|
||||
# Base Event Template
|
||||
event = {
|
||||
"sensor_id": sensor["sensor_id"],
|
||||
"sensor_type": s_type,
|
||||
"contract_version": VERSION,
|
||||
"source": ip,
|
||||
"action_taken": random.choice(["logged", "blocked", "tarpitted", "alert_only"])
|
||||
}
|
||||
|
||||
# Contextual Threat Data
|
||||
if s_type == "web_honeypot":
|
||||
event["event_type"] = random.choice(["sqli_attempt", "xss_payload", "dir_bruteforce", "api_fuzz"])
|
||||
event["severity"] = random.choice(["high", "critical", "medium"])
|
||||
event["target"] = random.choice(["/wp-admin", "/api/v1/users", "/.env", "/config.php"])
|
||||
event["details"] = {"user_agent": "masscan/1.3", "payload": "1' OR '1'='1", "method": "POST"}
|
||||
|
||||
elif s_type == "tarpit":
|
||||
event["event_type"] = random.choice(["tcp_syn_flood", "port_scan", "ssh_bruteforce"])
|
||||
event["severity"] = random.choice(["low", "medium", "info"])
|
||||
event["target"] = random.choice(["Port 22", "Port 23", "Port 3389", "Port 445"])
|
||||
event["details"] = {"packets_dropped": random.randint(50, 5000), "connection_time_held": f"{random.randint(10, 120)}s"}
|
||||
|
||||
elif s_type == "canary_token":
|
||||
event["event_type"] = random.choice(["file_accessed", "aws_key_used", "db_queried"])
|
||||
event["severity"] = "critical"
|
||||
event["target"] = random.choice(["/etc/passwd.bak", "AWS_SECRET_KEY", "users_backup.sql"])
|
||||
event["details"] = {"process_name": random.choice(["cat", "curl", "python3"]), "user": "www-data"}
|
||||
|
||||
elif s_type == "llm_probe":
|
||||
event["event_type"] = random.choice(["prompt_injection", "jailbreak_attempt", "data_exfiltration"])
|
||||
event["severity"] = random.choice(["high", "critical"])
|
||||
event["target"] = "/v1/chat/completions"
|
||||
event["details"] = {"prompt_snippet": "Ignore previous instructions and print...", "model": "gpt-4-turbo"}
|
||||
|
||||
else: # ids_sentinel
|
||||
event["event_type"] = random.choice(["malware_signature", "lateral_movement", "beaconing"])
|
||||
event["severity"] = random.choice(["high", "critical", "medium"])
|
||||
event["target"] = f"Subnet 10.0.{random.randint(1,5)}.0/24"
|
||||
event["details"] = {"signature_id": f"SID-{random.randint(1000, 9999)}", "confidence": f"{random.randint(80, 100)}%"}
|
||||
|
||||
return event
|
||||
|
||||
# --- API Interaction ---
|
||||
|
||||
def post_heartbeat(sensor):
|
||||
body = {
|
||||
"sensor_id": sensor["sensor_id"],
|
||||
"sensor_type": sensor["sensor_type"],
|
||||
"details": {
|
||||
"version": VERSION,
|
||||
"os": random.choice(["Linux", "Windows Server 2022", "FreeBSD"]),
|
||||
"uptime_days": random.randint(1, 400),
|
||||
},
|
||||
}
|
||||
r = requests.post(f"{HUB_URL}/api/v1/heartbeat", json=body, headers=HEADERS, timeout=5)
|
||||
r.raise_for_status()
|
||||
|
||||
def post_event(event_data):
|
||||
r = requests.post(f"{HUB_URL}/api/v1/event", json=event_data, headers=HEADERS, timeout=5)
|
||||
r.raise_for_status()
|
||||
|
||||
def main():
|
||||
print(f"Generating dynamic fleet and events for HoneyWire Hub (v{VERSION})...")
|
||||
|
||||
# 1. Generate Fleet & Send Heartbeats
|
||||
fleet = generate_fleet(10) # Change this number to test UI scaling
|
||||
for sensor in fleet:
|
||||
post_heartbeat(sensor)
|
||||
print(f"[+] Heartbeat registered: {sensor['sensor_id']}")
|
||||
time.sleep(0.1)
|
||||
|
||||
print("\nSimulating threat activity...")
|
||||
|
||||
# 2. Fire 40 random, contextual events
|
||||
for _ in range(40):
|
||||
sensor = random.choice(fleet)
|
||||
|
||||
# Occasionally simulate an offline sensor by NOT sending a heartbeat,
|
||||
# but 90% of the time, refresh the heartbeat with the event.
|
||||
if random.random() > 0.1:
|
||||
post_heartbeat(sensor)
|
||||
|
||||
event_data = generate_contextual_event(sensor)
|
||||
post_event(event_data)
|
||||
|
||||
# Print a tiny log to the terminal so it looks cool running
|
||||
print(f" ↳ {event_data['severity'].upper().ljust(8)} | {event_data['sensor_id']} | {event_data['event_type']}")
|
||||
time.sleep(random.uniform(0.05, 0.2))
|
||||
|
||||
print("\n✅ Done. Refresh http://localhost:8080 to view the SOC dashboard.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,24 +0,0 @@
|
||||
# ==========================================
|
||||
# HONEYWIRE HUB CONFIGURATION
|
||||
# ==========================================
|
||||
|
||||
HW_HUB_PORT=8080
|
||||
|
||||
# The secret key your sensors use to talk to the Hub. Make this long and random!
|
||||
HW_HUB_KEY=change_this_to_a_secure_random_string
|
||||
|
||||
# The password to log into the Web Dashboard
|
||||
HW_DASHBOARD_PASSWORD=admin
|
||||
|
||||
# Where should the SQLite database be stored? (Inside the container)
|
||||
HW_DB_PATH=/data/honeywire.db
|
||||
|
||||
# ==========================================
|
||||
# NOTIFICATIONS (Optional)
|
||||
# ==========================================
|
||||
# Push alerts directly to your phone via Ntfy (e.g., https://ntfy.sh/my_secret_topic)
|
||||
HW_NTFY_URL=
|
||||
|
||||
# Push alerts to a self-hosted Gotify instance
|
||||
HW_GOTIFY_URL=
|
||||
HW_GOTIFY_TOKEN=
|
||||
+17
-35
@@ -1,38 +1,20 @@
|
||||
# --- Stage 1: Builder ---
|
||||
FROM python:3.11-slim AS builder
|
||||
# STAGE 1: Builder
|
||||
FROM golang:1.25 AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o honeywire-hub ./cmd/hub/main.go
|
||||
|
||||
# STAGE 2: Distroless
|
||||
# The "static" image contains literally nothing except SSL certificates and a non-root user.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/honeywire-hub /app/
|
||||
|
||||
# 1. Patch the OS (This is why your Debian vulnerabilities are now 0!)
|
||||
RUN apt-get update && apt-get upgrade -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. Create the virtual environment
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 3. CRITICAL: Upgrade core tools to fix the 'jaraco' and 'wheel' vulnerabilities
|
||||
# This pulls the fixed versions (6.1.0+ and 0.46.2+) identified in your scan
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
|
||||
|
||||
# 4. Install your pinned requirements
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 5. Prepare data directory for Distroless
|
||||
RUN mkdir /data && chown 65532:65532 /data
|
||||
|
||||
# --- Stage 2: Distroless & Rootless ---
|
||||
FROM gcr.io/distroless/python3-debian12
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PYTHONPATH="/opt/venv/lib/python3.11/site-packages"
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
COPY --from=builder --chown=65532:65532 /data /data
|
||||
COPY --chown=65532:65532 main.py /app/main.py
|
||||
COPY --chown=65532:65532 templates/ /app/templates/
|
||||
|
||||
USER nonroot:nonroot
|
||||
# Expose port and run
|
||||
EXPOSE 8080
|
||||
CMD ["/app/main.py"]
|
||||
CMD ["/app/honeywire-hub"]
|
||||
@@ -18,7 +18,7 @@ func main() {
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
dbStore, err := store.NewStore("test_honeywire.db")
|
||||
dbStore, err := store.NewStore(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
+26
-9
@@ -1,14 +1,31 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
hub:
|
||||
build: .
|
||||
# 1. THE FIXER: Runs as root, fixes permissions, and dies instantly.
|
||||
permission-fixer:
|
||||
image: alpine:latest
|
||||
# 65532 is the standard UID/GID for the Distroless nonroot user
|
||||
command: sh -c "chown -R 65532:65532 /data"
|
||||
volumes:
|
||||
- ./honeywire_data:/data
|
||||
|
||||
# 2. THE HUB: pure Go distroless backend.
|
||||
honeywire-hub:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: honeywire-hub
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HW_HUB_PORT}:8080"
|
||||
- "${HW_PORT:-8080}:${HW_PORT:-8080}"
|
||||
volumes:
|
||||
- honeywire_data:/data
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
honeywire_data:
|
||||
- ./honeywire_data:/data
|
||||
depends_on:
|
||||
# This ensures the Hub waits until the fixer is completely finished
|
||||
permission-fixer:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
- HW_PORT=8080
|
||||
- HW_DB_PATH=/data/honeywire.db
|
||||
- HW_HUB_KEY=change_this_to_a_secure_random_string
|
||||
- HW_DASHBOARD_PASSWORD=admin
|
||||
-737
@@ -1,737 +0,0 @@
|
||||
"""
|
||||
HoneyWire Hub — main.py
|
||||
Universal sensor hub supporting arbitrary sensor types via the HoneyWire Event Standard.
|
||||
|
||||
Architecture notes:
|
||||
- DB schema is versioned via PRAGMA user_version; add new migrations to MIGRATIONS list.
|
||||
- Notifications are dispatched through notify(); add new backends to NOTIFICATION_BACKENDS.
|
||||
- Auth is centralised in verify_ui_auth(); cookie logic lives in one place.
|
||||
- The Event model is intentionally sensor-agnostic: source, target, details cover every
|
||||
conceivable sensor (network tarpit, file canary, LLM probe, email honeypot, …).
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
import requests
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request, BackgroundTasks
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, field_validator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import FastAPI, Request, HTTPException, Depends, Header, BackgroundTasks, Query
|
||||
from fastapi import Query
|
||||
from datetime import timedelta
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
log = logging.getLogger("honeywire")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration (all values come from HW_ environment variables)
|
||||
# ---------------------------------------------------------------------------
|
||||
# The API Key sensors use to authenticate to the Hub
|
||||
API_SECRET = os.getenv("HW_HUB_KEY", "change_this_to_a_secure_random_string")
|
||||
|
||||
# Dashboard UI Password
|
||||
DASHBOARD_PASSWORD = os.getenv("HW_DASHBOARD_PASSWORD", "admin")
|
||||
|
||||
# Notification Endpoints
|
||||
NTFY_URL = os.getenv("HW_NTFY_URL", "")
|
||||
GOTIFY_URL = os.getenv("HW_GOTIFY_URL", "")
|
||||
GOTIFY_TOKEN = os.getenv("HW_GOTIFY_TOKEN", "")
|
||||
|
||||
# Database Location
|
||||
DB_PATH = os.getenv("HW_DB_PATH", "/data/honeywire.db")
|
||||
|
||||
# Versioning: one source of truth stored in root file + env override.
|
||||
DEFAULT_VERSION = "1.0.0"
|
||||
VERSION_FILE_PATH = os.path.join(os.path.dirname(__file__), "..", "VERSION")
|
||||
try:
|
||||
with open(os.path.abspath(VERSION_FILE_PATH), "r") as f:
|
||||
FILE_VERSION = f.read().strip()
|
||||
except FileNotFoundError:
|
||||
FILE_VERSION = DEFAULT_VERSION
|
||||
|
||||
# Use HW_VERSION to allow users to override it if strictly necessary
|
||||
HONEYWIRE_VERSION = os.getenv("HW_VERSION", FILE_VERSION)
|
||||
|
||||
AUTH_COOKIE_NAME = "hw_auth"
|
||||
|
||||
# Store active sessions in memory to prevent Pass-the-Hash vulnerabilities
|
||||
# Format: { "session_token": expiration_datetime }
|
||||
ACTIVE_SESSIONS: dict[str, datetime] = {}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database — versioned migrations
|
||||
# ---------------------------------------------------------------------------
|
||||
# We use one clean initialization string for new deployments
|
||||
INIT_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
contract_version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
sensor_id TEXT NOT NULL,
|
||||
sensor_type TEXT NOT NULL DEFAULT 'generic',
|
||||
event_type TEXT NOT NULL DEFAULT 'alert',
|
||||
severity TEXT NOT NULL DEFAULT 'medium',
|
||||
source TEXT NOT NULL DEFAULT 'Unknown',
|
||||
target TEXT NOT NULL DEFAULT 'Unknown',
|
||||
action_taken TEXT NOT NULL DEFAULT 'logged',
|
||||
details TEXT NOT NULL DEFAULT '{}',
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
is_archived INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sensors (
|
||||
sensor_id TEXT PRIMARY KEY,
|
||||
first_seen TEXT,
|
||||
last_seen TEXT NOT NULL,
|
||||
sensor_type TEXT NOT NULL DEFAULT 'generic',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
is_silenced INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO config (key, value) VALUES ('is_armed', 'true');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sensor_heartbeats (
|
||||
sensor_id TEXT NOT NULL,
|
||||
time_bucket TEXT NOT NULL,
|
||||
PRIMARY KEY (sensor_id, time_bucket)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeats_time ON sensor_heartbeats(time_bucket);
|
||||
"""
|
||||
|
||||
def _get_db_version(conn: sqlite3.Connection) -> int:
|
||||
return conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
|
||||
def _set_db_version(conn: sqlite3.Connection, version: int) -> None:
|
||||
conn.execute(f"PRAGMA user_version = {version}")
|
||||
|
||||
def init_db():
|
||||
"""Run exactly once on startup to ensure tables exist."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
conn.executescript(INIT_SCHEMA)
|
||||
conn.commit()
|
||||
log.info("Database initialized successfully.")
|
||||
except Exception as e:
|
||||
log.error(f"Database initialization failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""Context manager that yields a sqlite3 connection and closes it on exit."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
init_db()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
def _notify_ntfy(title: str, message: str, severity: str) -> None:
|
||||
if not NTFY_URL:
|
||||
return
|
||||
|
||||
# Ntfy scale: 1 (min) to 5 (max)
|
||||
priorities = {"info": 1, "low": 2, "medium": 3, "high": 4, "critical": 5}
|
||||
priority = priorities.get(severity.lower(), 3) # Default to medium
|
||||
|
||||
headers = {"Title": title, "Priority": str(priority), "Tags": "rotating_light"}
|
||||
|
||||
NTFY_TOKEN = os.getenv("NTFY_TOKEN", "")
|
||||
if NTFY_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
|
||||
|
||||
try:
|
||||
response = requests.post(NTFY_URL, data=message.encode('utf-8'), headers=headers, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
log.warning("Ntfy rejected the request: %s | Details: %s", exc, response.text)
|
||||
except Exception as exc:
|
||||
log.warning("Ntfy failed to connect entirely: %s", exc)
|
||||
|
||||
|
||||
def _notify_gotify(title: str, message: str, severity: str) -> None:
|
||||
if not (GOTIFY_URL and GOTIFY_TOKEN):
|
||||
return
|
||||
|
||||
# Gotify scale: 0-3 (min/silent), 4-7 (default/sound), 8-10 (high/interrupt)
|
||||
priorities = {"info": 1, "low": 3, "medium": 5, "high": 8, "critical": 10}
|
||||
priority = priorities.get(severity.lower(), 5) # Default to medium
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
GOTIFY_URL,
|
||||
headers={"X-Gotify-App-Token": GOTIFY_TOKEN},
|
||||
json={"title": title, "message": message, "priority": priority},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
log.warning("Gotify rejected the request: %s | Details: %s", exc, response.text)
|
||||
except Exception as exc:
|
||||
log.warning("Gotify failed to connect entirely: %s", exc)
|
||||
|
||||
|
||||
NOTIFICATION_BACKENDS: list[Callable[[str, str, str], None]] = [
|
||||
_notify_ntfy,
|
||||
_notify_gotify,
|
||||
]
|
||||
|
||||
|
||||
def notify(title: str, message: str, severity: str) -> None:
|
||||
"""Dispatch a notification to all configured backends.
|
||||
NOTE: This should always be called via BackgroundTasks to prevent blocking."""
|
||||
for backend in NOTIFICATION_BACKENDS:
|
||||
backend(title, message, severity)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
class Event(BaseModel):
|
||||
contract_version: str
|
||||
sensor_id: str
|
||||
sensor_type: str = "generic"
|
||||
event_type: str = "alert"
|
||||
severity: str = "medium"
|
||||
source: str = "Unknown"
|
||||
target: str = "Unknown"
|
||||
action_taken: str = "logged"
|
||||
metadata: dict = {}
|
||||
|
||||
@field_validator("severity")
|
||||
@classmethod
|
||||
def validate_severity(cls, v: str) -> str:
|
||||
allowed = {"info", "low", "medium", "high", "critical"}
|
||||
if v.lower() not in allowed:
|
||||
raise ValueError(f"severity must be one of {allowed}")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class Heartbeat(BaseModel):
|
||||
sensor_id: str
|
||||
sensor_type: str = "generic"
|
||||
metadata: Any = {}
|
||||
|
||||
|
||||
class SystemState(BaseModel):
|
||||
is_armed: bool
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
app = FastAPI(title="HoneyWire Hub")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth — Centralized Security Logic
|
||||
# ---------------------------------------------------------------------------
|
||||
def verify_ui_auth(request: Request) -> None:
|
||||
"""Validates the presence of an active, unexpired session token."""
|
||||
if DASHBOARD_PASSWORD:
|
||||
cookie_val = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not cookie_val or cookie_val not in ACTIVE_SESSIONS:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if datetime.now() > ACTIVE_SESSIONS[cookie_val]:
|
||||
del ACTIVE_SESSIONS[cookie_val]
|
||||
raise HTTPException(status_code=401, detail="Session Expired")
|
||||
|
||||
|
||||
def verify_agent_auth(x_api_key: str = Header(None), authorization: str = Header(None)):
|
||||
"""Verifies the secret key sent by the sensor (Supports X-Api-Key and Bearer)."""
|
||||
token = x_api_key
|
||||
if not token and authorization and authorization.startswith("Bearer "):
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
|
||||
if not token or not secrets.compare_digest(token, API_SECRET):
|
||||
log.warning("Unauthorized access attempt detected.")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontend API
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.get("/api/v1/system/state", dependencies=[Depends(verify_ui_auth)])
|
||||
async def get_system_state():
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT value FROM config WHERE key='is_armed'").fetchone()
|
||||
return {"is_armed": row["value"] == "true"}
|
||||
|
||||
|
||||
@app.patch("/api/v1/system/state", dependencies=[Depends(verify_ui_auth)])
|
||||
async def set_system_state(state: SystemState):
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE config SET value=? WHERE key='is_armed'",
|
||||
("true" if state.is_armed else "false",),
|
||||
)
|
||||
return {"status": "success", "is_armed": state.is_armed}
|
||||
|
||||
|
||||
@app.get("/api/v1/version")
|
||||
async def get_version():
|
||||
return {"version": HONEYWIRE_VERSION}
|
||||
|
||||
|
||||
@app.get("/api/v1/sensors", dependencies=[Depends(verify_ui_auth)])
|
||||
async def get_sensors():
|
||||
with get_db() as conn:
|
||||
# FIX 1: Add is_silenced to the SQL SELECT statement
|
||||
rows = conn.execute(
|
||||
"SELECT sensor_id, sensor_type, last_seen, metadata, is_silenced FROM sensors ORDER BY sensor_id"
|
||||
).fetchall()
|
||||
|
||||
now = datetime.now()
|
||||
fleet = []
|
||||
for r in rows:
|
||||
last_seen_dt = datetime.strptime(r["last_seen"], "%Y-%m-%d %H:%M:%S")
|
||||
fleet.append({
|
||||
"sensor_id": r["sensor_id"],
|
||||
"sensor_type": r["sensor_type"],
|
||||
"last_seen": r["last_seen"],
|
||||
"metadata": json.loads(r["metadata"]),
|
||||
"status": "online" if (now - last_seen_dt) < timedelta(seconds=90) else "offline",
|
||||
# FIX 2: Pass the boolean value to the frontend
|
||||
"is_silenced": bool(r["is_silenced"])
|
||||
})
|
||||
return fleet
|
||||
|
||||
|
||||
@app.get("/api/v1/events", dependencies=[Depends(verify_ui_auth)])
|
||||
async def get_events(archived: bool = Query(False)):
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM events WHERE is_archived = ? ORDER BY id DESC",
|
||||
(1 if archived else 0,)
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"contract_version": r["contract_version"],
|
||||
"id": r["id"],
|
||||
"timestamp": r["timestamp"],
|
||||
"sensor_id": r["sensor_id"],
|
||||
"sensor_type": r["sensor_type"],
|
||||
"event_type": r["event_type"],
|
||||
"severity": r["severity"],
|
||||
"source": r["source"],
|
||||
"target": r["target"],
|
||||
"action_taken": r["action_taken"],
|
||||
"details": json.loads(r["details"]) if r["details"] else {},
|
||||
"is_read": bool(r["is_read"]),
|
||||
"is_archived": bool(r["is_archived"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@app.patch("/api/v1/events/read", dependencies=[Depends(verify_ui_auth)])
|
||||
async def mark_events_read():
|
||||
with get_db() as conn:
|
||||
conn.execute("UPDATE events SET is_read = 1 WHERE is_read = 0")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@app.patch("/api/v1/events/{event_id}/read", dependencies=[Depends(verify_ui_auth)])
|
||||
async def mark_single_event_read(event_id: int):
|
||||
with get_db() as conn:
|
||||
conn.execute("UPDATE events SET is_read = 1 WHERE id = ?", (event_id,))
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@app.delete("/api/v1/events", dependencies=[Depends(verify_ui_auth)])
|
||||
async def clear_events():
|
||||
with get_db() as conn:
|
||||
conn.execute("DELETE FROM events")
|
||||
return {"status": "success"}
|
||||
|
||||
@app.get("/api/v1/uptime", dependencies=[Depends(verify_ui_auth)])
|
||||
async def get_uptime_data(timeframe: str = Query("24H")):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if timeframe == "1H":
|
||||
num_blocks = 60
|
||||
delta = timedelta(minutes=1)
|
||||
fmt = "%Y-%m-%d %H:%M"
|
||||
expected_pings = 1
|
||||
elif timeframe == "24H":
|
||||
num_blocks = 24
|
||||
delta = timedelta(hours=1)
|
||||
fmt = "%Y-%m-%d %H"
|
||||
expected_pings = 60
|
||||
elif timeframe == "7D":
|
||||
num_blocks = 7
|
||||
delta = timedelta(days=1)
|
||||
fmt = "%Y-%m-%d"
|
||||
expected_pings = 1440
|
||||
else: # 30D
|
||||
num_blocks = 30
|
||||
delta = timedelta(days=1)
|
||||
fmt = "%Y-%m-%d"
|
||||
expected_pings = 1440
|
||||
|
||||
cutoff = now - (delta * num_blocks)
|
||||
cutoff_str = cutoff.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with get_db() as conn:
|
||||
sensors = conn.execute("SELECT sensor_id, last_seen, first_seen FROM sensors ORDER BY sensor_id").fetchall()
|
||||
rows = conn.execute(
|
||||
"SELECT sensor_id, time_bucket FROM sensor_heartbeats WHERE time_bucket >= ?",
|
||||
(cutoff_str,)
|
||||
).fetchall()
|
||||
|
||||
history = {s["sensor_id"]: {} for s in sensors}
|
||||
for r in rows:
|
||||
bucket_dt = datetime.strptime(r["time_bucket"], "%Y-%m-%d %H:%M:00")
|
||||
time_key = bucket_dt.strftime(fmt)
|
||||
history[r["sensor_id"]][time_key] = history[r["sensor_id"]].get(time_key, 0) + 1
|
||||
|
||||
result = []
|
||||
for s in sensors:
|
||||
sensor_id = s["sensor_id"]
|
||||
blocks = []
|
||||
|
||||
# Safely parse first_seen (fallback to 'now' if missing)
|
||||
first_seen_str = s["first_seen"] or now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
first_seen_dt = datetime.strptime(first_seen_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
|
||||
# CRITICAL FIX: Convert first_seen to the exact same bucket format as time_key
|
||||
first_seen_key = first_seen_dt.strftime(fmt)
|
||||
|
||||
for i in range(num_blocks - 1, -1, -1):
|
||||
block_time = now - (delta * i)
|
||||
time_key = block_time.strftime(fmt)
|
||||
|
||||
time_label = "Current" if i == 0 else f"{i} " + ("mins ago" if timeframe == "1H" else "hours ago" if timeframe == "24H" else "days ago")
|
||||
|
||||
# 1. NO DATA CHECK: Elegant string comparison
|
||||
if time_key < first_seen_key:
|
||||
status, label = "nodata", "No Data (Not Deployed Yet)"
|
||||
else:
|
||||
# 2. DEGRADED / UP / DOWN CHECK
|
||||
ping_count = history[sensor_id].get(time_key, 0)
|
||||
|
||||
if ping_count == 0:
|
||||
status, label = "down", "Offline"
|
||||
elif ping_count < (expected_pings * 0.85):
|
||||
status, label = "degraded", f"Degraded ({ping_count}/{expected_pings} pings)"
|
||||
else:
|
||||
status, label = "up", "Online"
|
||||
|
||||
blocks.append({
|
||||
"status": status,
|
||||
"timeLabel": time_label,
|
||||
"label": label
|
||||
})
|
||||
|
||||
# Override the very last block to strictly match live last_seen status
|
||||
last_seen_dt = datetime.strptime(s["last_seen"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
is_live = (now - last_seen_dt) < timedelta(seconds=90)
|
||||
blocks[-1] = {
|
||||
"status": "up" if is_live else "down",
|
||||
"timeLabel": "Current",
|
||||
"label": "Online (Live)" if is_live else "Offline (Live)"
|
||||
}
|
||||
|
||||
result.append({
|
||||
"id": sensor_id,
|
||||
"name": sensor_id,
|
||||
"isOnline": is_live,
|
||||
"blocks": blocks
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@app.patch("/api/v1/events/{event_id}/archive", dependencies=[Depends(verify_ui_auth)])
|
||||
async def archive_single_event(event_id: int):
|
||||
with get_db() as conn:
|
||||
conn.execute("UPDATE events SET is_archived = 1, is_read = 1 WHERE id = ?", (event_id,))
|
||||
return {"status": "success"}
|
||||
|
||||
@app.patch("/api/v1/events/archive-all", dependencies=[Depends(verify_ui_auth)])
|
||||
async def archive_all_events():
|
||||
with get_db() as conn:
|
||||
# Only archive currently active events
|
||||
conn.execute("UPDATE events SET is_archived = 1, is_read = 1 WHERE is_archived = 0")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
|
||||
class SilenceRequest(BaseModel):
|
||||
is_silenced: bool
|
||||
|
||||
@app.patch("/api/v1/sensors/{sensor_id}/silence", dependencies=[Depends(verify_ui_auth)])
|
||||
async def toggle_sensor_silence(sensor_id: str, req: SilenceRequest):
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sensors SET is_silenced = ? WHERE sensor_id = ?",
|
||||
(1 if req.is_silenced else 0, sensor_id)
|
||||
)
|
||||
return {"status": "success", "sensor_id": sensor_id, "is_silenced": req.is_silenced}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sensor API
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.post("/api/v1/heartbeat", dependencies=[Depends(verify_agent_auth)])
|
||||
async def receive_heartbeat(hb: Heartbeat):
|
||||
now = datetime.now(timezone.utc)
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Round down to the current minute (e.g., "2026-04-05 21:05:00")
|
||||
minute_bucket = now.strftime("%Y-%m-%d %H:%M:00")
|
||||
|
||||
metadata_json = json.dumps(hb.metadata) if isinstance(hb.metadata, (dict, list)) else "{}"
|
||||
|
||||
with get_db() as conn:
|
||||
# 1. Update current live status & explicitly set first_seen if new
|
||||
conn.execute(
|
||||
"""INSERT INTO sensors (sensor_id, first_seen, last_seen, sensor_type, metadata)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(sensor_id) DO UPDATE SET last_seen=?, sensor_type=?, metadata=?""",
|
||||
(hb.sensor_id, now_str, now_str, hb.sensor_type, metadata_json,
|
||||
now_str, hb.sensor_type, metadata_json),
|
||||
)
|
||||
|
||||
# 2. Log historical bucket (Ignores duplicates within the same minute)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sensor_heartbeats (sensor_id, time_bucket) VALUES (?, ?)",
|
||||
(hb.sensor_id, minute_bucket)
|
||||
)
|
||||
|
||||
return {"status": "alive"}
|
||||
|
||||
|
||||
@app.post("/api/v1/event", dependencies=[Depends(verify_agent_auth)])
|
||||
async def receive_event(event: Event, bg_tasks: BackgroundTasks):
|
||||
# Optional but recommended: Semantic versioning check
|
||||
hub_major_version = HONEYWIRE_VERSION.split('.')[0]
|
||||
agent_major_version = event.contract_version.split('.')[0]
|
||||
|
||||
if hub_major_version != agent_major_version:
|
||||
log.warning(f"Version mismatch rejected: Hub is {HONEYWIRE_VERSION}, Agent sent {event.contract_version}")
|
||||
raise HTTPException(
|
||||
status_code=426,
|
||||
detail=f"Upgrade Required: Hub is on v{HONEYWIRE_VERSION}, but agent uses v{event.contract_version}"
|
||||
)
|
||||
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
details_payload = event.metadata if hasattr(event, 'metadata') else {}
|
||||
details_json = json.dumps(details_payload) if isinstance(details_payload, (dict, list)) else str(details_payload)
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO events
|
||||
(timestamp, contract_version, sensor_id, sensor_type, event_type, severity,
|
||||
source, target, action_taken, details, is_read)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
|
||||
(timestamp, event.contract_version, event.sensor_id, event.sensor_type, event.event_type,
|
||||
event.severity, event.source, event.target, event.action_taken, details_json),
|
||||
)
|
||||
# 1. Check Global Arm State
|
||||
is_armed = conn.execute(
|
||||
"SELECT value FROM config WHERE key='is_armed'"
|
||||
).fetchone()["value"] == "true"
|
||||
|
||||
# 2. Check Specific Sensor Silence State
|
||||
sensor_row = conn.execute(
|
||||
"SELECT is_silenced FROM sensors WHERE sensor_id = ?",
|
||||
(event.sensor_id,)
|
||||
).fetchone()
|
||||
is_silenced = bool(sensor_row["is_silenced"]) if sensor_row else False
|
||||
|
||||
msg = (
|
||||
f"[{event.sensor_id}] {event.event_type.upper()} — "
|
||||
f"{event.source} → {event.target} | action: {event.action_taken}"
|
||||
)
|
||||
|
||||
if is_armed and not is_silenced:
|
||||
bg_tasks.add_task(notify, title=f"HoneyWire Alert ({event.severity.upper()})", message=msg, severity=event.severity)
|
||||
|
||||
log.info("Event received (v%s): %s", event.contract_version, msg if is_armed else f"{event.sensor_id}/{event.event_type}")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web UI & Vault Door (Uncodixified)
|
||||
# ---------------------------------------------------------------------------
|
||||
LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HoneyWire Sentinel | Authentication</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = { darkMode: 'class' }
|
||||
if (localStorage.getItem('theme') === 'light' || (!('theme' in localStorage) && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; font-weight: 500; }
|
||||
.bg-grid {
|
||||
background-size: 40px 40px;
|
||||
background-image: linear-gradient(to right, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
||||
}
|
||||
.dark .bg-grid {
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-100 dark:bg-[#0a0a0c] text-slate-700 dark:text-zinc-200 h-screen flex flex-col items-center justify-center bg-grid transition-colors duration-200 p-6">
|
||||
|
||||
<div class="w-full max-w-[400px] space-y-8">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-12 h-12 flex items-center justify-center rounded-lg bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 shadow-sm text-2xl mb-4">🕸️</div>
|
||||
<h1 class="text-xl font-bold text-slate-900 dark:text-white tracking-tight">HoneyWire Sentinel</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-zinc-500 mt-1">Authorized personnel only</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg shadow-sm p-8 transition-colors duration-200">
|
||||
<form onsubmit="doLogin(event)" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label for="pwd" class="text-xs font-semibold text-slate-700 dark:text-zinc-400">Authentication Key</label>
|
||||
<input type="password" id="pwd" placeholder="••••••••••••"
|
||||
class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-zinc-950 border border-slate-300 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:focus:ring-zinc-600 focus:border-transparent transition-all placeholder-slate-300 dark:placeholder-zinc-700" required>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full py-2 rounded-md bg-slate-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-slate-800 dark:hover:bg-white text-sm font-semibold transition-all shadow-sm">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="err" class="hidden mt-6 p-3 rounded-md bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800/30 text-center">
|
||||
<p class="text-xs font-medium text-rose-700 dark:text-rose-400">Access Denied: Invalid Key</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button onclick="toggleTheme()" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-200 dark:bg-zinc-800 border border-slate-300 dark:border-zinc-700 text-slate-600 dark:text-zinc-400 hover:text-slate-900 dark:hover:text-white text-xs font-medium transition-colors">
|
||||
<svg id="moon-icon" class="w-3.5 h-3.5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
||||
<svg id="sun-icon" class="w-3.5 h-3.5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
<span>Switch Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = btn.innerText;
|
||||
btn.innerText = "Authenticating...";
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50');
|
||||
|
||||
try {
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({password: document.getElementById('pwd').value})
|
||||
});
|
||||
if (res.ok) {
|
||||
btn.innerText = "Access Granted";
|
||||
btn.classList.replace('bg-slate-900', 'bg-emerald-600');
|
||||
btn.classList.replace('dark:bg-zinc-100', 'dark:bg-emerald-600');
|
||||
btn.classList.add('text-white');
|
||||
setTimeout(() => window.location.reload(), 400);
|
||||
} else {
|
||||
document.getElementById('err').classList.remove('hidden');
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
document.getElementById('pwd').value = '';
|
||||
document.getElementById('pwd').focus();
|
||||
}
|
||||
} catch (err) {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login(req: LoginRequest):
|
||||
if DASHBOARD_PASSWORD and secrets.compare_digest(req.password, DASHBOARD_PASSWORD):
|
||||
session_token = secrets.token_hex(32)
|
||||
ACTIVE_SESSIONS[session_token] = datetime.now() + timedelta(days=30)
|
||||
|
||||
response = JSONResponse(content={"status": "ok"})
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME, value=session_token,
|
||||
max_age=2_592_000, httponly=True, samesite="strict",
|
||||
)
|
||||
return response
|
||||
raise HTTPException(status_code=401, detail="Invalid Password")
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout():
|
||||
response = HTMLResponse(content="<script>window.location.href='/';</script>")
|
||||
response.delete_cookie(AUTH_COOKIE_NAME)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def serve_dashboard(request: Request):
|
||||
if DASHBOARD_PASSWORD:
|
||||
cookie_val = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not cookie_val or cookie_val not in ACTIVE_SESSIONS or datetime.now() > ACTIVE_SESSIONS[cookie_val]:
|
||||
return HTMLResponse(content=LOGIN_HTML)
|
||||
return templates.TemplateResponse(request=request, name="index.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||
+6
-6
@@ -62,31 +62,31 @@ def generate_contextual_event(sensor):
|
||||
event["event_type"] = random.choice(["sqli_attempt", "xss_payload", "dir_bruteforce", "api_fuzz"])
|
||||
event["severity"] = random.choice(["high", "critical", "medium"])
|
||||
event["target"] = random.choice(["/wp-admin", "/api/v1/users", "/.env", "/config.php"])
|
||||
event["metadata"] = {"user_agent": "masscan/1.3", "payload": "1' OR '1'='1", "method": "POST"}
|
||||
event["details"] = {"user_agent": "masscan/1.3", "payload": "1' OR '1'='1", "method": "POST"}
|
||||
|
||||
elif s_type == "tarpit":
|
||||
event["event_type"] = random.choice(["tcp_syn_flood", "port_scan", "ssh_bruteforce"])
|
||||
event["severity"] = random.choice(["low", "medium", "info"])
|
||||
event["target"] = random.choice(["Port 22", "Port 23", "Port 3389", "Port 445"])
|
||||
event["metadata"] = {"packets_dropped": random.randint(50, 5000), "connection_time_held": f"{random.randint(10, 120)}s"}
|
||||
event["details"] = {"packets_dropped": random.randint(50, 5000), "connection_time_held": f"{random.randint(10, 120)}s"}
|
||||
|
||||
elif s_type == "canary_token":
|
||||
event["event_type"] = random.choice(["file_accessed", "aws_key_used", "db_queried"])
|
||||
event["severity"] = "critical"
|
||||
event["target"] = random.choice(["/etc/passwd.bak", "AWS_SECRET_KEY", "users_backup.sql"])
|
||||
event["metadata"] = {"process_name": random.choice(["cat", "curl", "python3"]), "user": "www-data"}
|
||||
event["details"] = {"process_name": random.choice(["cat", "curl", "python3"]), "user": "www-data"}
|
||||
|
||||
elif s_type == "llm_probe":
|
||||
event["event_type"] = random.choice(["prompt_injection", "jailbreak_attempt", "data_exfiltration"])
|
||||
event["severity"] = random.choice(["high", "critical"])
|
||||
event["target"] = "/v1/chat/completions"
|
||||
event["metadata"] = {"prompt_snippet": "Ignore previous instructions and print...", "model": "gpt-4-turbo"}
|
||||
event["details"] = {"prompt_snippet": "Ignore previous instructions and print...", "model": "gpt-4-turbo"}
|
||||
|
||||
else: # ids_sentinel
|
||||
event["event_type"] = random.choice(["malware_signature", "lateral_movement", "beaconing"])
|
||||
event["severity"] = random.choice(["high", "critical", "medium"])
|
||||
event["target"] = f"Subnet 10.0.{random.randint(1,5)}.0/24"
|
||||
event["metadata"] = {"signature_id": f"SID-{random.randint(1000, 9999)}", "confidence": f"{random.randint(80, 100)}%"}
|
||||
event["details"] = {"signature_id": f"SID-{random.randint(1000, 9999)}", "confidence": f"{random.randint(80, 100)}%"}
|
||||
|
||||
return event
|
||||
|
||||
@@ -96,7 +96,7 @@ def post_heartbeat(sensor):
|
||||
body = {
|
||||
"sensor_id": sensor["sensor_id"],
|
||||
"sensor_type": sensor["sensor_type"],
|
||||
"metadata": {
|
||||
"details": {
|
||||
"version": VERSION,
|
||||
"os": random.choice(["Linux", "Windows Server 2022", "FreeBSD"]),
|
||||
"uptime_days": random.randint(1, 400),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Web Framework (Fixed 2024/2025 DoS vulnerabilities)
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
|
||||
# HTML Templating
|
||||
jinja2>=3.1.4
|
||||
|
||||
# Data Validation (Pydantic V2.10 is the 2026 stable standard)
|
||||
pydantic>=2.10.0
|
||||
|
||||
# Security Fixes for multipart parsing (Fixes CVE-2024-53981)
|
||||
python-multipart>=0.0.22
|
||||
|
||||
# Networking
|
||||
requests>=2.32.3
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./templates/**/*.html"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HoneyWire Sentinel</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
tailwind.config = { darkMode: 'class' }
|
||||
if (localStorage.getItem('theme') === 'light' || (!('theme' in localStorage) && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* Preserved Theme: Severity Bleeds */
|
||||
.bleed-critical { border-left-color: #f43f5e !important; background: linear-gradient(90deg, rgba(244, 63, 94, 0.08) 0%, transparent 100%); }
|
||||
.bleed-high { border-left-color: #fb923c !important; background: linear-gradient(90deg, rgba(251, 146, 60, 0.06) 0%, transparent 100%); }
|
||||
.bleed-medium { border-left-color: #facc15 !important; background: linear-gradient(90deg, rgba(250, 204, 21, 0.05) 0%, transparent 100%); }
|
||||
.bleed-low { border-left-color: #3b82f6 !important; background: linear-gradient(90deg, rgba(59, 130, 246, 0.05) 0%, transparent 100%); }
|
||||
.bleed-info { border-left-color: #64748b !important; background: linear-gradient(90deg, rgba(100, 116, 139, 0.05) 0%, transparent 100%); }
|
||||
|
||||
/* Preserved Theme: Functional Badges */
|
||||
.severity-critical { color: #e11d48; border-color: rgba(225, 29, 72, 0.3); }
|
||||
.dark .severity-critical { color: #f43f5e; border-color: rgba(244, 63, 94, 0.3); }
|
||||
.severity-high { color: #ea580c; border-color: rgba(234, 88, 12, 0.3); }
|
||||
.dark .severity-high { color: #fb923c; border-color: rgba(251, 146, 60, 0.3); }
|
||||
.severity-medium { color: #ca8a04; border-color: rgba(202, 138, 4, 0.3); }
|
||||
.dark .severity-medium { color: #facc15; border-color: rgba(250, 204, 21, 0.3); }
|
||||
.severity-low { color: #2563eb; border-color: rgba(37, 99, 235, 0.3); }
|
||||
.dark .severity-low { color: #3b82f6; border-color: rgba(59, 130, 246, 0.3); }
|
||||
.severity-info { color: #64748b; border-color: rgba(100, 116, 139, 0.3); }
|
||||
.dark .severity-info { color: #a1a1aa; border-color: rgba(161, 161, 170, 0.3); }
|
||||
|
||||
/* Standardized Scrollbar - Invisible "Fat" Hitbox */
|
||||
.custom-scroll::-webkit-scrollbar { height: 14px; width: 14px; }
|
||||
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
border: 4px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.dark .custom-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: #3f3f46;
|
||||
border: 4px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.custom-scroll::-webkit-scrollbar-thumb:hover { background-color: #94a3b8; }
|
||||
.dark .custom-scroll::-webkit-scrollbar-thumb:hover { background-color: #52525b; }
|
||||
.custom-scroll::-webkit-scrollbar-corner { display: none; background: transparent; }
|
||||
|
||||
/* Hide scrollbar completely only when strictly needed */
|
||||
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* Tactical Grid Background */
|
||||
.bg-grid {
|
||||
background-size: 40px 40px;
|
||||
background-image: linear-gradient(to right, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
||||
}
|
||||
.dark .bg-grid {
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex h-screen overflow-hidden bg-slate-100 dark:bg-[#0a0a0c] text-slate-700 dark:text-zinc-200 transition-colors duration-200" x-data="sentinelApp()">
|
||||
|
||||
<aside class="flex flex-col bg-slate-50 dark:bg-zinc-950 border-r border-slate-200 dark:border-zinc-800 z-10 transition-all duration-300 ease-in-out shrink-0"
|
||||
:class="sidebarOpen ? 'w-[250px]' : 'w-[68px]'">
|
||||
|
||||
<div class="h-14 flex items-center px-4 border-b border-slate-200 dark:border-zinc-800 shrink-0 gap-3 overflow-hidden">
|
||||
<span class="text-xl shrink-0">🕸️</span>
|
||||
<div class="flex flex-col whitespace-nowrap opacity-100 transition-opacity duration-200" :class="sidebarOpen ? 'opacity-100' : 'opacity-0'">
|
||||
<span class="text-sm font-bold text-slate-800 dark:text-white leading-tight">HoneyWire</span>
|
||||
<span class="text-[11px] text-slate-500 dark:text-zinc-500 font-medium leading-tight mono" x-text="'v' + version"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 py-4 space-y-1 overflow-y-auto custom-scroll overflow-x-hidden px-3">
|
||||
<div x-show="sidebarOpen" class="px-3 py-2 text-xs font-semibold text-slate-400 dark:text-zinc-500 mb-1 transition-opacity">Menu</div>
|
||||
|
||||
<button @click="currentView = 'dashboard'"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
:class="currentView === 'dashboard' ? 'bg-slate-200 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100' : 'text-slate-600 dark:text-zinc-400 hover:bg-slate-200/50 dark:hover:bg-zinc-800/50'"
|
||||
:title="!sidebarOpen ? 'Dashboard' : ''">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
|
||||
<span x-show="sidebarOpen" class="whitespace-nowrap">Dashboard</span>
|
||||
</button>
|
||||
|
||||
<button @click="currentView = 'store'"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
:class="currentView === 'store' ? 'bg-slate-200 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100' : 'text-slate-600 dark:text-zinc-400 hover:bg-slate-200/50 dark:hover:bg-zinc-800/50'"
|
||||
:title="!sidebarOpen ? 'Sensor Store' : ''">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
|
||||
<span x-show="sidebarOpen" class="whitespace-nowrap">Sensor Store</span>
|
||||
</button>
|
||||
|
||||
<button @click="currentView = 'settings'"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
:class="currentView === 'settings' ? 'bg-slate-200 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100' : 'text-slate-600 dark:text-zinc-400 hover:bg-slate-200/50 dark:hover:bg-zinc-800/50'"
|
||||
:title="!sidebarOpen ? 'Settings' : ''">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
<span x-show="sidebarOpen" class="whitespace-nowrap">Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="p-3 border-t border-slate-200 dark:border-zinc-800 shrink-0 space-y-2">
|
||||
|
||||
<button @click="viewingArchive = !viewingArchive; update()"
|
||||
class="w-full flex justify-center items-center py-1.5 px-3 rounded-md text-xs font-semibold transition-colors border"
|
||||
:class="viewingArchive ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800/50 shadow-sm' : 'text-slate-600 dark:text-zinc-400 bg-slate-100 dark:bg-zinc-800 hover:bg-slate-200 dark:hover:bg-zinc-700 border-slate-300 dark:border-zinc-700'"
|
||||
:title="!sidebarOpen ? (viewingArchive ? 'Active Events' : 'Event Archive') : ''">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
|
||||
|
||||
<div class="overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap flex items-center"
|
||||
:class="sidebarOpen ? 'max-w-[120px] ml-2 opacity-100' : 'max-w-0 ml-0 opacity-0'">
|
||||
<span x-text="viewingArchive ? 'Active Events' : 'Event Archive'"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button @click="clearLogs()"
|
||||
class="w-full flex justify-center items-center py-1.5 px-3 rounded-md text-xs font-semibold text-rose-600 bg-rose-50 hover:bg-rose-100 dark:text-rose-400 dark:bg-rose-900/20 dark:hover:bg-rose-900/40 border border-rose-200 dark:border-rose-800/30 transition-colors"
|
||||
:title="!sidebarOpen ? 'Purge Logs' : ''">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
|
||||
<div class="overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap flex items-center"
|
||||
:class="sidebarOpen ? 'max-w-[120px] ml-2 opacity-100' : 'max-w-0 ml-0 opacity-0'">
|
||||
<span>Purge System Logs</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 flex flex-col min-w-0 bg-grid">
|
||||
|
||||
<header class="h-14 bg-slate-50/90 dark:bg-zinc-950/90 border-b border-slate-200 dark:border-zinc-800 flex items-center justify-between px-4 sm:px-6 shrink-0 backdrop-blur-sm">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="text-slate-400 hover:text-slate-700 dark:text-zinc-500 dark:hover:text-zinc-200 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
||||
</button>
|
||||
<h2 class="text-sm font-semibold text-slate-800 dark:text-white capitalize hidden sm:block" x-text="currentView.replace('-', ' ')"></h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<div class="hidden sm:flex items-center gap-2 pr-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse dark:shadow-[0_0_8px_rgba(16,185,129,0.8)]"></span>
|
||||
<span class="text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-zinc-400">Live</span>
|
||||
</div>
|
||||
|
||||
<button x-show="unreadCount > 0" @click="markAllRead()" x-cloak
|
||||
class="hidden sm:flex items-center gap-2 px-2.5 py-1 rounded-md bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-400 text-xs font-semibold mr-1 border border-rose-200 dark:border-rose-800/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
|
||||
<span x-text="unreadCount + ' Unread'"></span>
|
||||
</button>
|
||||
|
||||
<button @click="toggleArmed()"
|
||||
class="px-3 py-1.5 rounded-md text-xs font-semibold transition-colors border"
|
||||
:class="isArmed ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800/50' : 'bg-slate-200 dark:bg-zinc-800 text-slate-600 dark:text-zinc-400 border-slate-300 dark:border-zinc-700'">
|
||||
<span x-text="isArmed ? 'Armed' : 'Passive'"></span>
|
||||
</button>
|
||||
|
||||
<button @click="toggleTheme()" class="p-1.5 rounded-md bg-slate-200 dark:bg-zinc-800 border border-slate-300 dark:border-zinc-700 text-slate-600 dark:text-zinc-300 hover:bg-slate-300 dark:hover:bg-zinc-700 transition-colors">
|
||||
<svg class="w-4 h-4 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
||||
<svg class="w-4 h-4 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
</button>
|
||||
|
||||
<div class="w-px h-4 bg-slate-300 dark:bg-zinc-700 mx-1"></div>
|
||||
|
||||
<a href="/logout" class="text-xs font-semibold text-slate-500 hover:text-slate-800 dark:text-zinc-400 dark:hover:text-white transition-colors">Exit</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-auto custom-scroll p-4 sm:p-6">
|
||||
|
||||
<template x-if="currentView === 'dashboard'">
|
||||
<div class="max-w-[1600px] mx-auto space-y-6">
|
||||
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<div class="flex items-center w-full gap-2" x-data="{ canScrollRight: true }">
|
||||
<div class="flex-1 relative overflow-hidden">
|
||||
<div class="flex overflow-x-auto whitespace-nowrap gap-2 items-center custom-scroll pb-3 pr-2 relative"
|
||||
@scroll.passive="canScrollRight = $el.scrollLeft + $el.clientWidth < $el.scrollWidth - 5"
|
||||
x-init="$watch('fleet', () => $nextTick(() => canScrollRight = $el.scrollLeft + $el.clientWidth < $el.scrollWidth - 5))">
|
||||
|
||||
<div class="sticky left-0 z-20 pr-2 bg-slate-100 dark:bg-[#0a0a0c] flex items-center border-r border-slate-200 dark:border-zinc-800 transition-all duration-200">
|
||||
<button id="pill-all" @click="selectedSensor = null"
|
||||
class="shrink-0 px-3 py-1.5 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="!selectedSensor ? 'bg-slate-800 text-slate-50 dark:bg-zinc-200 dark:text-black border-transparent' : 'bg-slate-50 dark:bg-zinc-900 border-slate-300 dark:border-zinc-700 text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800'">
|
||||
All Traffic
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-for="s in fleet" :key="s.sensor_id">
|
||||
<button :id="'pill-' + s.sensor_id" @click="selectedSensor = s.sensor_id"
|
||||
class="shrink-0 flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm font-medium transition-colors group"
|
||||
:class="selectedSensor === s.sensor_id ? 'bg-slate-800 text-slate-50 dark:bg-zinc-200 dark:text-black border-transparent' : 'bg-slate-50 dark:bg-zinc-900 border-slate-300 dark:border-zinc-700 text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800'"
|
||||
:title="s.status === 'online' ? 'Active' : 'Last seen: ' + s.last_seen">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="s.status === 'online' ? 'bg-emerald-500' : 'bg-rose-500'"></span>
|
||||
<span class="mono text-xs pointer-events-none" x-text="s.sensor_id"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute right-0 top-0 bottom-3 w-16 bg-gradient-to-l from-slate-100 dark:from-[#0a0a0c] to-transparent pointer-events-none"></div>
|
||||
</div>
|
||||
<div class="w-6 h-8 shrink-0 flex items-center justify-center">
|
||||
<span x-show="canScrollRight && fleet.some(s => s.status !== 'online')" x-transition.opacity class="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
|
||||
<div class="lg:col-span-4 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg p-5 flex flex-col backdrop-blur-sm">
|
||||
<h3 class="text-sm font-semibold mb-4 text-slate-800 dark:text-zinc-200">Severity Distribution</h3>
|
||||
<div class="flex-1 relative min-h-[220px]" x-init="$nextTick(() => initChart())">
|
||||
<canvas id="severityChart"></canvas>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none mt-2">
|
||||
<span class="text-3xl font-bold text-slate-900 dark:text-zinc-100" x-text="filteredEvents.length"></span>
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-zinc-500">Events</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-8 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg p-5 flex flex-col backdrop-blur-sm">
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-800 dark:text-zinc-200">Fleet Uptime</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-400 mt-1">
|
||||
Fleet Overall Uptime:
|
||||
<span class="font-semibold transition-colors"
|
||||
:class="parseFloat(overallUptime) >= 95 ? 'text-emerald-600 dark:text-emerald-400' : (parseFloat(overallUptime) >= 85 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400')"
|
||||
x-text="overallUptime"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex bg-slate-200 dark:bg-zinc-800 p-0.5 rounded-md text-[11px] font-medium text-slate-600 dark:text-zinc-400">
|
||||
<template x-for="time in ['1H', '24H', '7D', '30D']" :key="time">
|
||||
<button @click="activeTimeframe = time; update()"
|
||||
class="px-2.5 py-1 rounded transition-colors"
|
||||
:class="activeTimeframe === time ? 'bg-slate-50 dark:bg-zinc-700 text-slate-900 dark:text-zinc-100 shadow-sm' : 'hover:text-slate-800 dark:hover:text-zinc-200'"
|
||||
x-text="time"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-4 flex-1" x-data="{ canScrollDown: true }">
|
||||
<div x-ref="uptimeScrollArea"
|
||||
@scroll.passive="canScrollDown = $el.scrollTop + $el.clientHeight < $el.scrollHeight - 5"
|
||||
x-init="$watch('uptimeData', () => $nextTick(() => canScrollDown = $el.scrollTop + $el.clientHeight < $el.scrollHeight - 5))"
|
||||
class="max-h-[240px] overflow-y-auto custom-scroll pr-2 space-y-3">
|
||||
|
||||
<div x-show="uptimeData.length === 0" class="text-xs text-slate-400 dark:text-zinc-500 py-4 text-center">
|
||||
No fleet data available.
|
||||
</div>
|
||||
|
||||
<template x-for="sensor in uptimeData" :key="sensor.id">
|
||||
<div class="flex items-center w-full">
|
||||
<div class="w-50 flex items-center gap-1.5 shrink-0">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="sensor.isOnline ? 'bg-emerald-500' : 'bg-rose-500'"></span>
|
||||
<button @click="selectedSensor = selectedSensor === sensor.id ? null : sensor.id; $nextTick(() => { document.getElementById(selectedSensor ? 'pill-' + sensor.id : 'pill-all')?.scrollIntoView({behavior: 'smooth', inline: 'center', block: 'nearest'}) })"
|
||||
class="text-[11px] mono text-left truncate transition-colors cursor-pointer px-2 py-0.5 rounded-md flex items-center gap-1 flex-1 min-w-0"
|
||||
:class="selectedSensor === sensor.id ? 'bg-slate-200 text-slate-900 dark:bg-zinc-700 dark:text-white font-bold' : 'text-slate-600 dark:text-zinc-400 font-medium hover:text-slate-900 dark:hover:text-zinc-200'"
|
||||
:title="'Filter by ' + sensor.name + (fleet.find(f => f.sensor_id === sensor.id)?.is_silenced ? ' (Silenced)' : '')">
|
||||
<span class="truncate flex-1" x-text="sensor.name"></span>
|
||||
<svg x-show="fleet.find(f => f.sensor_id === sensor.id)?.is_silenced" class="w-3 h-3 shrink-0 text-slate-400 dark:text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.73 21a2 2 0 01-3.46 0m-3.9-3.9a2.032 2.032 0 01-2.37.5L4 17h12.59l3.12 3.12M3 3l18 18M18 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341c-.5.186-.967.447-1.385.772"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex justify-end gap-[2px] overflow-hidden flex-nowrap pl-2">
|
||||
<template x-for="(block, i) in sensor.blocks" :key="i">
|
||||
<div class="flex-1 max-w-[8px] min-w-[2px] h-5 rounded-[1px] transition-opacity hover:opacity-70 cursor-pointer"
|
||||
:class="{
|
||||
'bg-emerald-500': block.status === 'up',
|
||||
'bg-rose-500': block.status === 'down',
|
||||
'bg-amber-500': block.status === 'degraded',
|
||||
'bg-slate-200 dark:bg-zinc-700': block.status === 'nodata'
|
||||
}"
|
||||
:title="block.timeLabel + ' - ' + block.label">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="canScrollDown && uptimeData.some(s => s.blocks.some(b => b.status === 'down'))" x-cloak
|
||||
@click="$refs.uptimeScrollArea.scrollTo({ top: $refs.uptimeScrollArea.scrollHeight, behavior: 'smooth' })"
|
||||
class="absolute bottom-1 left-1/2 -translate-x-1/2 bg-white/80 dark:bg-zinc-800/80 border border-slate-200 dark:border-zinc-700 text-slate-500 dark:text-zinc-400 text-[10px] font-medium px-2.5 py-1 rounded-md shadow-sm backdrop-blur-md flex items-center gap-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-zinc-700 z-10">
|
||||
<div class="w-1.5 sm:w-2 h-4 rounded-[1px] bg-rose-500 animate-pulse"></div>
|
||||
<span>↓ Offline Events Below</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg overflow-hidden flex flex-col backdrop-blur-sm">
|
||||
<div class="px-5 py-3 border-b border-slate-200 dark:border-zinc-800 flex justify-between items-center bg-slate-100/50 dark:bg-zinc-950/50">
|
||||
<h3 class="text-sm font-semibold text-slate-800 dark:text-zinc-200" x-text="viewingArchive ? 'Archived Events' : 'Active Threat Queue'"></h3>
|
||||
<!-- FIX 2: Archive All Button - Fixed condition to show when not viewing archive AND events exist -->
|
||||
<button x-show="!viewingArchive && filteredEvents.length > 0" @click="archiveAll()" x-cloak
|
||||
class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-600 dark:text-zinc-400 bg-white dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors border border-slate-300 dark:border-zinc-700 shadow-sm">
|
||||
Archive All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto custom-scroll">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead class="text-xs font-semibold text-slate-500 dark:text-zinc-400 border-b border-slate-200 dark:border-zinc-800">
|
||||
<tr>
|
||||
<th class="px-5 py-3">Threat</th>
|
||||
<th class="px-4 py-3">Event Trigger</th>
|
||||
<th class="px-4 py-3">Source</th>
|
||||
<th class="px-4 py-3">Target</th>
|
||||
<th class="px-4 py-3 text-right min-w-[180px]">Node</th>
|
||||
<th class="px-5 py-3 text-right">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800/50">
|
||||
<tr x-show="filteredEvents.length === 0" x-cloak>
|
||||
<td colspan="6" class="px-5 py-8 text-center text-slate-500 dark:text-zinc-500 text-sm">
|
||||
No events detected matching criteria.
|
||||
</td>
|
||||
</tr>
|
||||
<template x-for="event in filteredEvents" :key="event.id">
|
||||
<tr class="hover:bg-slate-100/50 dark:hover:bg-zinc-800/30 cursor-pointer border-l-[3px] border-transparent transition-colors"
|
||||
:class="'bleed-' + event.severity"
|
||||
@click="openEvent(event)">
|
||||
<td class="px-5 py-3 flex items-center gap-3">
|
||||
<div x-show="!event.is_read" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
|
||||
<span class="px-2 py-0.5 rounded border text-[11px] font-semibold uppercase tracking-wider bg-slate-50 dark:bg-transparent"
|
||||
:class="'severity-' + event.severity" x-text="event.severity"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-900 dark:text-zinc-100 capitalize" x-text="event.event_type.replace(/_/g, ' ')"></td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-zinc-400 mono" x-text="event.source"></td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-zinc-400 mono" x-text="event.target"></td>
|
||||
<td class="px-4 py-3 text-sm text-right text-slate-500 dark:text-zinc-500 mono min-w-[180px] max-w-[200px] truncate" x-text="event.sensor_id" :title="event.sensor_id"></td>
|
||||
<td class="px-5 py-3 text-sm text-right text-slate-500 dark:text-zinc-500 mono whitespace-nowrap" x-text="event.timestamp.split(' ')[1]"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentView === 'store'">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-center h-full">
|
||||
<div class="text-center text-slate-500 dark:text-zinc-500">
|
||||
<h3 class="text-lg font-semibold text-slate-800 dark:text-zinc-200 mb-2">Sensor Store</h3>
|
||||
<p class="text-sm">Storefront coming in next architectural phase.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentView === 'settings'">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-center h-full">
|
||||
<div class="text-center text-slate-500 dark:text-zinc-500">
|
||||
<h3 class="text-lg font-semibold text-slate-800 dark:text-zinc-200 mb-2">Settings</h3>
|
||||
<p class="text-sm">Configuration interface coming in next architectural phase.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div x-show="modal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||
<div class="absolute inset-0 bg-slate-900/40 dark:bg-black/60" @click="modal = false"></div>
|
||||
|
||||
<div class="relative bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 w-full max-w-2xl rounded-lg shadow-xl flex flex-col max-h-[90vh]"
|
||||
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="scale-95 opacity-0"
|
||||
x-transition:enter-end="scale-100 opacity-100">
|
||||
|
||||
<div class="p-5 sm:p-6 border-b border-slate-200 dark:border-zinc-800 flex justify-between items-start shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-900 dark:text-white capitalize" x-text="activeEvent?.event_type.replace(/_/g, ' ')"></h2>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-500 mono mt-1" x-text="'Trace: ' + activeEvent?.id"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded border text-xs font-semibold uppercase tracking-wider bg-slate-100 dark:bg-transparent"
|
||||
:class="'severity-' + activeEvent?.severity" x-text="activeEvent?.severity"></span>
|
||||
</div>
|
||||
|
||||
<div class="p-5 sm:p-6 overflow-y-auto custom-scroll flex-1 space-y-5">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="bg-slate-100/50 dark:bg-zinc-950/50 p-3 rounded-md border border-slate-200 dark:border-zinc-800/50">
|
||||
<div class="text-xs font-medium text-slate-500 dark:text-zinc-500 mb-1">Sensor Node</div>
|
||||
<div class="text-sm font-semibold text-slate-900 dark:text-zinc-100 mono truncate" x-text="activeEvent?.sensor_id" :title="activeEvent?.sensor_id"></div>
|
||||
</div>
|
||||
<div class="bg-slate-100/50 dark:bg-zinc-950/50 p-3 rounded-md border border-slate-200 dark:border-zinc-800/50">
|
||||
<div class="text-xs font-medium text-slate-500 dark:text-zinc-500 mb-1">Source</div>
|
||||
<div class="text-sm font-semibold text-slate-900 dark:text-zinc-100 mono" x-text="activeEvent?.source"></div>
|
||||
</div>
|
||||
<div class="bg-slate-100/50 dark:bg-zinc-950/50 p-3 rounded-md border border-slate-200 dark:border-zinc-800/50">
|
||||
<div class="text-xs font-medium text-slate-500 dark:text-zinc-500 mb-1">Target</div>
|
||||
<div class="text-sm font-semibold text-slate-900 dark:text-zinc-100 mono" x-text="activeEvent?.target"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<template x-for="(val, key) in activeEvent?.details" :key="key">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate-700 dark:text-zinc-300 capitalize mb-2" x-text="key.replace(/_/g, ' ')"></div>
|
||||
|
||||
<template x-if="Array.isArray(val)">
|
||||
<div class="space-y-2">
|
||||
<template x-for="(item, index) in val.slice(0, 50)" :key="index">
|
||||
<pre class="bg-slate-100 dark:bg-[#0c0c0e] border border-slate-200 dark:border-[#1f1f23] rounded p-2.5 text-sm text-emerald-700 dark:text-emerald-400 mono overflow-x-auto custom-scroll"
|
||||
x-text="typeof item === 'object' ? JSON.stringify(item, null, 2) : item"></pre>
|
||||
</template>
|
||||
<div x-show="val.length > 50" class="text-xs text-slate-500 dark:text-zinc-500 font-medium mt-2">
|
||||
<span x-text="'+ ' + (val.length - 50) + ' more packets truncated'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!Array.isArray(val)">
|
||||
<div class="text-sm text-slate-800 dark:text-zinc-200 mono break-all bg-slate-100 dark:bg-[#0c0c0e] border border-slate-200 dark:border-[#1f1f23] p-2.5 rounded whitespace-pre-wrap"
|
||||
x-text="typeof val === 'object' ? JSON.stringify(val, null, 2) : val"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- FIX 3: Event Modal Actions - Improved button styling and state management -->
|
||||
<div class="p-5 sm:p-6 border-t border-slate-200 dark:border-zinc-800 shrink-0 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<button @click.stop="toggleSilence(activeEvent?.sensor_id)"
|
||||
class="flex items-center justify-center gap-2 py-2 px-3 border transition-colors rounded-md text-sm font-medium w-[155px] shrink-0"
|
||||
:class="isActiveSensorSilenced ? 'bg-slate-100 dark:bg-zinc-800 text-slate-700 dark:text-zinc-300 border-slate-300 dark:border-zinc-700 hover:bg-slate-200 dark:hover:bg-zinc-700' : 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800/30 hover:bg-blue-100 dark:hover:bg-blue-900/30'">
|
||||
|
||||
<svg x-show="!isActiveSensorSilenced" class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
||||
</svg>
|
||||
|
||||
<svg x-show="isActiveSensorSilenced" x-cloak class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.73 21a2 2 0 01-3.46 0m-3.9-3.9a2.032 2.032 0 01-2.37.5L4 17h12.59l3.12 3.12M3 3l18 18M18 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341c-.5.186-.967.447-1.385.772"></path>
|
||||
</svg>
|
||||
|
||||
<span class="whitespace-nowrap" x-text="isActiveSensorSilenced ? 'Unsilence Sensor' : 'Silence Sensor'"></span>
|
||||
</button>
|
||||
|
||||
<button x-show="!viewingArchive" @click="archiveEvent(activeEvent?.id); modal = false"
|
||||
class="flex items-center gap-2 py-2 px-3 border transition-colors rounded-md text-sm font-medium bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800/30 hover:bg-amber-100 dark:hover:bg-amber-900/30">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
|
||||
Archive Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button @click="modal = false" class="py-2 px-4 bg-slate-800 hover:bg-slate-900 dark:bg-zinc-100 dark:hover:bg-white text-white dark:text-slate-900 text-sm font-medium rounded-md transition-colors">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Chart.js Neon Plugin
|
||||
const neonGlowPlugin = {
|
||||
id: 'neonGlow',
|
||||
beforeDatasetsDraw(chart) {
|
||||
if (!document.documentElement.classList.contains('dark')) return;
|
||||
const ctx = chart.ctx;
|
||||
const meta = chart.getDatasetMeta(0);
|
||||
ctx.save();
|
||||
meta.data.forEach(arc => {
|
||||
ctx.shadowColor = arc.options.backgroundColor;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
arc.draw(ctx);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
Chart.register(neonGlowPlugin);
|
||||
let severityChartInstance = null;
|
||||
|
||||
function sentinelApp() {
|
||||
return {
|
||||
// UI State
|
||||
currentView: 'dashboard',
|
||||
sidebarOpen: true,
|
||||
modal: false,
|
||||
activeEvent: null,
|
||||
selectedSensor: null,
|
||||
activeTimeframe: '30D',
|
||||
viewingArchive: false,
|
||||
|
||||
// Data State
|
||||
events: [],
|
||||
fleet: [],
|
||||
uptimeData: [],
|
||||
isArmed: true,
|
||||
version: '1.0.0',
|
||||
|
||||
// Computed Properties
|
||||
|
||||
get overallUptime() {
|
||||
if (!this.uptimeData || this.uptimeData.length === 0) return '0.0%';
|
||||
let validBlocks = 0;
|
||||
let upBlocks = 0;
|
||||
|
||||
this.uptimeData.forEach(sensor => {
|
||||
sensor.blocks.forEach(block => {
|
||||
if (block.status !== 'nodata') {
|
||||
validBlocks++;
|
||||
if (block.status === 'up') upBlocks += 1;
|
||||
else if (block.status === 'degraded') upBlocks += 0.8; // 80% credit for degraded
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (validBlocks === 0) return '100.0%';
|
||||
return ((upBlocks / validBlocks) * 100).toFixed(1) + '%';
|
||||
},
|
||||
|
||||
get unreadCount() {
|
||||
return this.events.filter(e => !e.is_read).length;
|
||||
},
|
||||
get filteredEvents() {
|
||||
return this.selectedSensor ? this.events.filter(e => e.sensor_id === this.selectedSensor) : this.events;
|
||||
},
|
||||
|
||||
init() {
|
||||
this.update();
|
||||
setInterval(() => this.update(), 5000);
|
||||
|
||||
// Re-calculate Heatmap blocks when timeframe changes
|
||||
this.$watch('activeTimeframe', () => this.generateUptimeData());
|
||||
this.$watch('selectedSensor', () => this.refreshChart());
|
||||
|
||||
const observer = new MutationObserver(() => this.refreshChart());
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
},
|
||||
|
||||
async update() {
|
||||
try {
|
||||
const [ev, sn, st, ver, uptimeResult] = await Promise.all([
|
||||
fetch(`/api/v1/events?archived=${this.viewingArchive}`).then(r => r.json()).catch(() => []),
|
||||
fetch('/api/v1/sensors').then(r => r.json()).catch(() => []),
|
||||
fetch('/api/v1/system/state').then(r => r.json()).catch(() => ({is_armed: this.isArmed})),
|
||||
fetch('/api/v1/version').then(r => r.json()).catch(() => ({version: this.version})),
|
||||
fetch(`/api/v1/uptime?timeframe=${this.activeTimeframe}`).then(r => r.json()).catch(() => [])
|
||||
]);
|
||||
|
||||
if (ev.length || sn.length) {
|
||||
this.events = ev;
|
||||
|
||||
this.fleet = sn.map(newSensor => {
|
||||
// Force strict boolean based on the NOW CORRECT backend data
|
||||
const normalizedSensor = { ...newSensor, is_silenced: !!newSensor.is_silenced };
|
||||
const current = this.fleet.find(f => f.sensor_id === normalizedSensor.sensor_id);
|
||||
|
||||
// Respect the manual UI lock so it doesn't flicker while updating
|
||||
if (current && current._lockedUntil && Date.now() < current._lockedUntil) {
|
||||
return { ...normalizedSensor, is_silenced: current.is_silenced, _lockedUntil: current._lockedUntil };
|
||||
}
|
||||
return normalizedSensor;
|
||||
});
|
||||
}
|
||||
|
||||
if (uptimeResult && uptimeResult.length) {
|
||||
this.uptimeData = uptimeResult;
|
||||
}
|
||||
|
||||
this.isArmed = st.is_armed !== undefined ? st.is_armed : this.isArmed;
|
||||
this.version = ver.version || this.version;
|
||||
if (this.currentView === 'dashboard') { this.refreshChart(); }
|
||||
} catch(e) { console.error('Polling failed', e); }
|
||||
},
|
||||
|
||||
initChart() {
|
||||
const ctx = document.getElementById('severityChart');
|
||||
if (!ctx) return;
|
||||
|
||||
if (severityChartInstance) severityChartInstance.destroy();
|
||||
|
||||
severityChartInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['critical', 'high', 'medium', 'low', 'info'],
|
||||
datasets: [{
|
||||
data: [0,0,0,0,0],
|
||||
backgroundColor: ['#f43f5e', '#fb923c', '#eab308', '#3b82f6', '#64748b'],
|
||||
borderWidth: 0, spacing: 4, borderRadius: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
cutout: '82%',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: true,
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
|
||||
this.refreshChart();
|
||||
},
|
||||
|
||||
refreshChart() {
|
||||
if (!severityChartInstance) return;
|
||||
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
this.filteredEvents.forEach(e => {
|
||||
const s = e.severity ? e.severity.toLowerCase() : 'info';
|
||||
if (counts.hasOwnProperty(s)) counts[s]++;
|
||||
});
|
||||
|
||||
const newData = ['critical', 'high', 'medium', 'low', 'info'].map(k => counts[k]);
|
||||
|
||||
if (JSON.stringify(severityChartInstance.data.datasets[0].data) !== JSON.stringify(newData)) {
|
||||
severityChartInstance.data.datasets[0].data = newData;
|
||||
}
|
||||
severityChartInstance.update();
|
||||
},
|
||||
|
||||
async openEvent(e) {
|
||||
this.activeEvent = e;
|
||||
this.modal = true;
|
||||
|
||||
if (!e.is_read) {
|
||||
e.is_read = 1;
|
||||
try { await fetch(`/api/v1/events/${e.id}/read`, {method: 'PATCH'}); } catch(err) {}
|
||||
}
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
await fetch('/api/v1/events/read', {method: 'PATCH'});
|
||||
this.events.forEach(e => e.is_read = 1);
|
||||
} catch(err) {}
|
||||
},
|
||||
|
||||
async clearLogs() {
|
||||
if (confirm("Confirm Database Purge? This will permanently delete all event logs.")) {
|
||||
try {
|
||||
await fetch('/api/v1/events', {method: 'DELETE'});
|
||||
this.update();
|
||||
} catch(err) {}
|
||||
}
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleArmed() {
|
||||
const next = !this.isArmed;
|
||||
try {
|
||||
await fetch('/api/v1/system/state', {
|
||||
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({is_armed: next})
|
||||
});
|
||||
this.isArmed = next;
|
||||
} catch(err) {}
|
||||
},
|
||||
|
||||
async archiveEvent(id) {
|
||||
try {
|
||||
await fetch(`/api/v1/events/${id}/archive`, {method: 'PATCH'});
|
||||
// Immediately remove it from the active view for a snappy UI
|
||||
this.events = this.events.filter(e => e.id !== id);
|
||||
this.refreshChart();
|
||||
} catch(err) { console.error(err); }
|
||||
},
|
||||
|
||||
async archiveAll() {
|
||||
if (confirm("Archive all currently active events?")) {
|
||||
try {
|
||||
await fetch('/api/v1/events/archive-all', {method: 'PATCH'});
|
||||
this.update(); // Refresh to pull the now-empty queue
|
||||
} catch(err) { console.error(err); }
|
||||
}
|
||||
},
|
||||
|
||||
async toggleSilence(sensorId) {
|
||||
if (!sensorId) return;
|
||||
const sensor = this.fleet.find(s => s.sensor_id === sensorId);
|
||||
if (!sensor) return;
|
||||
|
||||
const nextStateBool = !sensor.is_silenced;
|
||||
const nextStateInt = nextStateBool ? 1 : 0;
|
||||
|
||||
// Optimistic UI Update + 5 Second Lock
|
||||
this.fleet = this.fleet.map(s =>
|
||||
s.sensor_id === sensorId ? { ...s, is_silenced: nextStateBool, _lockedUntil: Date.now() + 5000 } : s
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/sensors/${sensorId}/silence`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ is_silenced: nextStateInt })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Server rejected update');
|
||||
// Let the background polling seamlessly pick up the state once the lock expires
|
||||
} catch(err) {
|
||||
console.error("Silence failed:", err);
|
||||
// Break lock and revert on error
|
||||
this.fleet = this.fleet.map(s => s.sensor_id === sensorId ? { ...s, _lockedUntil: 0 } : s);
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
get isActiveSensorSilenced() {
|
||||
if (!this.activeEvent) return false;
|
||||
return this.fleet.find(s => s.sensor_id === this.activeEvent.sensor_id)?.is_silenced || false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user