feat: add dedicated Decoy Fleet inventory page and API
This commit is contained in:
@@ -166,8 +166,14 @@ class StatsResponse(BaseModel):
|
|||||||
total_logs: int
|
total_logs: int
|
||||||
unique_attackers: int
|
unique_attackers: int
|
||||||
active_deckies: int
|
active_deckies: int
|
||||||
|
deployed_deckies: int
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/stats", response_model=StatsResponse)
|
@app.get("/api/v1/stats", response_model=StatsResponse)
|
||||||
async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]:
|
async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return await repo.get_stats_summary()
|
return await repo.get_stats_summary()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/deckies")
|
||||||
|
async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
|
return await repo.get_deckies()
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class BaseRepository(ABC):
|
|||||||
"""Retrieve high-level dashboard metrics."""
|
"""Retrieve high-level dashboard metrics."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_deckies(self) -> list[dict[str, Any]]:
|
||||||
|
"""Retrieve the list of currently deployed deckies."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
||||||
"""Retrieve a user by their username."""
|
"""Retrieve a user by their username."""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import aiosqlite
|
import aiosqlite
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from decnet.web.repository import BaseRepository
|
from decnet.web.repository import BaseRepository
|
||||||
|
from decnet.config import load_state
|
||||||
|
|
||||||
|
|
||||||
class SQLiteRepository(BaseRepository):
|
class SQLiteRepository(BaseRepository):
|
||||||
@@ -128,16 +129,36 @@ class SQLiteRepository(BaseRepository):
|
|||||||
_row = await _cursor.fetchone()
|
_row = await _cursor.fetchone()
|
||||||
_unique_attackers: int = _row["unique_attackers"] if _row else 0
|
_unique_attackers: int = _row["unique_attackers"] if _row else 0
|
||||||
|
|
||||||
|
# Active deckies are those that HAVE interaction logs
|
||||||
async with _db.execute("SELECT COUNT(DISTINCT decky) as active_deckies FROM logs") as _cursor:
|
async with _db.execute("SELECT COUNT(DISTINCT decky) as active_deckies FROM logs") as _cursor:
|
||||||
_row = await _cursor.fetchone()
|
_row = await _cursor.fetchone()
|
||||||
_active_deckies: int = _row["active_deckies"] if _row else 0
|
_active_deckies: int = _row["active_deckies"] if _row else 0
|
||||||
|
|
||||||
|
# Deployed deckies are all those in the state file
|
||||||
|
_state = load_state()
|
||||||
|
_deployed_deckies: int = 0
|
||||||
|
if _state:
|
||||||
|
_deployed_deckies = len(_state[0].deckies)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_logs": _total_logs,
|
"total_logs": _total_logs,
|
||||||
"unique_attackers": _unique_attackers,
|
"unique_attackers": _unique_attackers,
|
||||||
"active_deckies": _active_deckies
|
"active_deckies": _active_deckies,
|
||||||
|
"deployed_deckies": _deployed_deckies
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def get_deckies(self) -> list[dict[str, Any]]:
|
||||||
|
_state = load_state()
|
||||||
|
if not _state:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# We can also enrich this with interaction counts/last seen from DB
|
||||||
|
_deckies: list[dict[str, Any]] = []
|
||||||
|
for _d in _state[0].deckies:
|
||||||
|
_deckies.append(_d.model_dump())
|
||||||
|
|
||||||
|
return _deckies
|
||||||
|
|
||||||
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
||||||
async with aiosqlite.connect(self.db_path) as _db:
|
async with aiosqlite.connect(self.db_path) as _db:
|
||||||
_db.row_factory = aiosqlite.Row
|
_db.row_factory = aiosqlite.Row
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d
|
|||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
|
import DeckyFleet from './components/DeckyFleet';
|
||||||
import LiveLogs from './components/LiveLogs';
|
import LiveLogs from './components/LiveLogs';
|
||||||
import Attackers from './components/Attackers';
|
import Attackers from './components/Attackers';
|
||||||
import Config from './components/Config';
|
import Config from './components/Config';
|
||||||
@@ -40,6 +41,7 @@ function App() {
|
|||||||
<Layout onLogout={handleLogout} onSearch={handleSearch}>
|
<Layout onLogout={handleLogout} onSearch={handleSearch}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard searchQuery={searchQuery} />} />
|
<Route path="/" element={<Dashboard searchQuery={searchQuery} />} />
|
||||||
|
<Route path="/fleet" element={<DeckyFleet />} />
|
||||||
<Route path="/live-logs" element={<LiveLogs />} />
|
<Route path="/live-logs" element={<LiveLogs />} />
|
||||||
<Route path="/attackers" element={<Attackers />} />
|
<Route path="/attackers" element={<Attackers />} />
|
||||||
<Route path="/config" element={<Config />} />
|
<Route path="/config" element={<Config />} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Stats {
|
|||||||
total_logs: number;
|
total_logs: number;
|
||||||
unique_attackers: number;
|
unique_attackers: number;
|
||||||
active_deckies: number;
|
active_deckies: number;
|
||||||
|
deployed_deckies: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
@@ -69,7 +70,7 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={<Shield size={32} />}
|
icon={<Shield size={32} />}
|
||||||
label="ACTIVE DECKIES"
|
label="ACTIVE DECKIES"
|
||||||
value={stats?.active_deckies || 0}
|
value={`${stats?.active_deckies || 0} / ${stats?.deployed_deckies || 0}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
|||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatCard: React.FC<StatCardProps> = ({ icon, label, value }) => (
|
const StatCard: React.FC<StatCardProps> = ({ icon, label, value }) => (
|
||||||
|
|||||||
134
decnet_web/src/components/DeckyFleet.tsx
Normal file
134
decnet_web/src/components/DeckyFleet.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import './Dashboard.css'; // Re-use common dashboard styles
|
||||||
|
import { Server, Cpu, Globe, Database } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Decky {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
services: string[];
|
||||||
|
distro: string;
|
||||||
|
hostname: string;
|
||||||
|
archetype: string | null;
|
||||||
|
service_config: Record<string, Record<string, any>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeckyFleet: React.FC = () => {
|
||||||
|
const [deckies, setDeckies] = useState<Decky[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchDeckies = async () => {
|
||||||
|
try {
|
||||||
|
const _res = await api.get('/deckies');
|
||||||
|
setDeckies(_res.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch decky fleet', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDeckies();
|
||||||
|
const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs
|
||||||
|
return () => clearInterval(_interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div className="loader">SCANNING NETWORK FOR DECOYS...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<div className="section-header" style={{ border: '1px solid var(--border-color)', backgroundColor: 'var(--secondary-color)', marginBottom: '24px' }}>
|
||||||
|
<Server size={20} />
|
||||||
|
<h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="deckies-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '24px' }}>
|
||||||
|
{deckies.length > 0 ? deckies.map(decky => (
|
||||||
|
<div key={decky.name} className="stat-card" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '16px', padding: '24px' }}>
|
||||||
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-color)', paddingBottom: '12px' }}>
|
||||||
|
<span className="matrix-text" style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{decky.name}</span>
|
||||||
|
<span className="dim" style={{ fontSize: '0.8rem', backgroundColor: 'rgba(0, 255, 65, 0.1)', padding: '2px 8px', borderRadius: '4px' }}>{decky.ip}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
|
||||||
|
<Cpu size={14} className="dim" />
|
||||||
|
<span className="dim">HOSTNAME:</span> {decky.hostname}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
|
||||||
|
<Globe size={14} className="dim" />
|
||||||
|
<span className="dim">DISTRO:</span> {decky.distro}
|
||||||
|
</div>
|
||||||
|
{decky.archetype && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
|
||||||
|
<Database size={14} className="dim" />
|
||||||
|
<span className="dim">ARCHETYPE:</span> <span style={{ color: 'var(--highlight-color)' }}>{decky.archetype}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<div className="dim" style={{ fontSize: '0.7rem', marginBottom: '8px', letterSpacing: '1px' }}>EXPOSED SERVICES:</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{decky.services.map(svc => {
|
||||||
|
const _config = decky.service_config[svc];
|
||||||
|
return (
|
||||||
|
<div key={svc} className="service-tag-container" style={{ position: 'relative' }}>
|
||||||
|
<span className="service-tag" style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
backgroundColor: 'var(--bg-color)',
|
||||||
|
border: '1px solid var(--accent-color)',
|
||||||
|
color: 'var(--accent-color)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
cursor: 'help'
|
||||||
|
}}>
|
||||||
|
{svc}
|
||||||
|
</span>
|
||||||
|
{_config && Object.keys(_config).length > 0 && (
|
||||||
|
<div className="service-config-tooltip" style={{
|
||||||
|
display: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: '0',
|
||||||
|
backgroundColor: 'rgba(10, 10, 10, 0.95)',
|
||||||
|
border: '1px solid var(--accent-color)',
|
||||||
|
padding: '12px',
|
||||||
|
zIndex: 100,
|
||||||
|
minWidth: '200px',
|
||||||
|
boxShadow: '0 0 15px rgba(0, 255, 65, 0.2)',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
{Object.entries(_config).map(([k, v]) => (
|
||||||
|
<div key={k} style={{ fontSize: '0.7rem', marginBottom: '4px' }}>
|
||||||
|
<span style={{ color: 'var(--highlight-color)', fontWeight: 'bold' }}>{k}:</span>
|
||||||
|
<span style={{ marginLeft: '6px', opacity: 0.9 }}>{String(v)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="stat-card" style={{ gridColumn: '1 / -1', justifyContent: 'center', padding: '60px' }}>
|
||||||
|
<span className="dim">NO DECOYS CURRENTLY DEPLOYED IN THIS SECTOR</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: `
|
||||||
|
.service-tag-container:hover .service-config-tooltip {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeckyFleet;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut } from 'lucide-react';
|
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server } from 'lucide-react';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
@@ -32,6 +32,7 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
|
|||||||
|
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" open={sidebarOpen} />
|
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" open={sidebarOpen} />
|
||||||
|
<NavItem to="/fleet" icon={<Server size={20} />} label="Decoy Fleet" open={sidebarOpen} />
|
||||||
<NavItem to="/live-logs" icon={<Terminal size={20} />} label="Live Logs" open={sidebarOpen} />
|
<NavItem to="/live-logs" icon={<Terminal size={20} />} label="Live Logs" open={sidebarOpen} />
|
||||||
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
|
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
|
||||||
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
|
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
|
||||||
|
|||||||
87
tests/test_fleet_api.py
Normal file
87
tests/test_fleet_api.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from decnet.web.api import app, repo
|
||||||
|
import decnet.config
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TEST_STATE_FILE = Path("test-decnet-state.json")
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def patch_state_file(monkeypatch):
|
||||||
|
# Patch the global STATE_FILE variable in the config module
|
||||||
|
monkeypatch.setattr(decnet.config, "STATE_FILE", TEST_STATE_FILE)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_state_file():
|
||||||
|
# Create a dummy state file for testing
|
||||||
|
_test_state = {
|
||||||
|
"config": {
|
||||||
|
"mode": "unihost",
|
||||||
|
"interface": "eth0",
|
||||||
|
"subnet": "192.168.1.0/24",
|
||||||
|
"gateway": "192.168.1.1",
|
||||||
|
"deckies": [
|
||||||
|
{
|
||||||
|
"name": "test-decky-1",
|
||||||
|
"ip": "192.168.1.10",
|
||||||
|
"services": ["ssh"],
|
||||||
|
"distro": "debian",
|
||||||
|
"base_image": "debian",
|
||||||
|
"hostname": "test-host-1",
|
||||||
|
"service_config": {"ssh": {"banner": "SSH-2.0-OpenSSH_8.9"}},
|
||||||
|
"archetype": "deaddeck",
|
||||||
|
"nmap_os": "linux",
|
||||||
|
"build_base": "debian:bookworm-slim"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "test-decky-2",
|
||||||
|
"ip": "192.168.1.11",
|
||||||
|
"services": ["http"],
|
||||||
|
"distro": "ubuntu",
|
||||||
|
"base_image": "ubuntu",
|
||||||
|
"hostname": "test-host-2",
|
||||||
|
"service_config": {},
|
||||||
|
"archetype": None,
|
||||||
|
"nmap_os": "linux",
|
||||||
|
"build_base": "debian:bookworm-slim"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"log_target": None,
|
||||||
|
"log_file": "test.log",
|
||||||
|
"ipvlan": False
|
||||||
|
},
|
||||||
|
"compose_path": "test-compose.yml"
|
||||||
|
}
|
||||||
|
TEST_STATE_FILE.write_text(json.dumps(_test_state))
|
||||||
|
|
||||||
|
yield _test_state
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
if TEST_STATE_FILE.exists():
|
||||||
|
TEST_STATE_FILE.unlink()
|
||||||
|
|
||||||
|
def test_get_deckies_endpoint(mock_state_file):
|
||||||
|
with TestClient(app) as _client:
|
||||||
|
# Login to get token
|
||||||
|
_login_resp = _client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
_token = _login_resp.json()["access_token"]
|
||||||
|
|
||||||
|
_response = _client.get("/api/v1/deckies", headers={"Authorization": f"Bearer {_token}"})
|
||||||
|
assert _response.status_code == 200
|
||||||
|
_data = _response.json()
|
||||||
|
assert len(_data) == 2
|
||||||
|
assert _data[0]["name"] == "test-decky-1"
|
||||||
|
assert _data[0]["service_config"]["ssh"]["banner"] == "SSH-2.0-OpenSSH_8.9"
|
||||||
|
|
||||||
|
def test_stats_includes_deployed_count(mock_state_file):
|
||||||
|
with TestClient(app) as _client:
|
||||||
|
_login_resp = _client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
_token = _login_resp.json()["access_token"]
|
||||||
|
|
||||||
|
_response = _client.get("/api/v1/stats", headers={"Authorization": f"Bearer {_token}"})
|
||||||
|
assert _response.status_code == 200
|
||||||
|
_data = _response.json()
|
||||||
|
assert "deployed_deckies" in _data
|
||||||
|
assert _data["deployed_deckies"] == 2
|
||||||
Reference in New Issue
Block a user