Compare commits

...

2 Commits

Author SHA1 Message Date
57d395d6d7 fix: auth redirect, SSE reconnect, stats polling removal, active decky count, schemathesis health check
Some checks failed
CI / Lint (ruff) (push) Successful in 18s
CI / SAST (bandit) (push) Successful in 19s
CI / Dependency audit (pip-audit) (push) Failing after 27s
CI / Test (Standard) (3.11) (push) Has been skipped
CI / Test (Standard) (3.12) (push) Has been skipped
CI / Test (Live) (3.11) (push) Has been skipped
CI / Test (Fuzz) (3.11) (push) Has been skipped
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
2026-04-13 18:33:32 -04:00
ac094965b5 fix: redirect to login on expired/missing JWT and 401 responses 2026-04-13 08:17:57 -04:00
6 changed files with 79 additions and 49 deletions

View File

@@ -226,11 +226,6 @@ class SQLiteRepository(BaseRepository):
select(func.count(func.distinct(Log.attacker_ip))) select(func.count(func.distinct(Log.attacker_ip)))
) )
).scalar() or 0 ).scalar() or 0
active_deckies = (
await session.execute(
select(func.count(func.distinct(Log.decky)))
)
).scalar() or 0
_state = await asyncio.to_thread(load_state) _state = await asyncio.to_thread(load_state)
deployed_deckies = len(_state[0].deckies) if _state else 0 deployed_deckies = len(_state[0].deckies) if _state else 0
@@ -238,7 +233,7 @@ class SQLiteRepository(BaseRepository):
return { return {
"total_logs": total_logs, "total_logs": total_logs,
"unique_attackers": unique_attackers, "unique_attackers": unique_attackers,
"active_deckies": active_deckies, "active_deckies": deployed_deckies,
"deployed_deckies": deployed_deckies, "deployed_deckies": deployed_deckies,
} }

View File

@@ -9,15 +9,30 @@ import Attackers from './components/Attackers';
import Config from './components/Config'; import Config from './components/Config';
import Bounty from './components/Bounty'; import Bounty from './components/Bounty';
function isTokenValid(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return typeof payload.exp === 'number' && payload.exp * 1000 > Date.now();
} catch {
return false;
}
}
function getValidToken(): string | null {
const stored = localStorage.getItem('token');
if (stored && isTokenValid(stored)) return stored;
if (stored) localStorage.removeItem('token');
return null;
}
function App() { function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('token')); const [token, setToken] = useState<string | null>(getValidToken);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
useEffect(() => { useEffect(() => {
const savedToken = localStorage.getItem('token'); const onAuthLogout = () => setToken(null);
if (savedToken) { window.addEventListener('auth:logout', onAuthLogout);
setToken(savedToken); return () => window.removeEventListener('auth:logout', onAuthLogout);
}
}, []); }, []);
const handleLogin = (newToken: string) => { const handleLogin = (newToken: string) => {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import './Dashboard.css'; import './Dashboard.css';
import { Shield, Users, Activity, Clock } from 'lucide-react'; import { Shield, Users, Activity, Clock } from 'lucide-react';
@@ -29,37 +29,52 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('token'); const connect = () => {
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1'; if (eventSourceRef.current) {
let url = `${baseUrl}/stream?token=${token}`; eventSourceRef.current.close();
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`;
}
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
setLogs(prev => [...payload.data, ...prev].slice(0, 100));
} else if (payload.type === 'stats') {
setStats(payload.data);
setLoading(false);
}
} catch (err) {
console.error('Failed to parse SSE payload', err);
} }
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
let url = `${baseUrl}/stream?token=${token}`;
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`;
}
const es = new EventSource(url);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
setLogs(prev => [...payload.data, ...prev].slice(0, 100));
} else if (payload.type === 'stats') {
setStats(payload.data);
setLoading(false);
window.dispatchEvent(new CustomEvent('decnet:stats', { detail: payload.data }));
}
} catch (err) {
console.error('Failed to parse SSE payload', err);
}
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
reconnectTimerRef.current = setTimeout(connect, 3000);
};
}; };
eventSource.onerror = (err) => { connect();
console.error('SSE connection error, attempting to reconnect...', err);
};
return () => { return () => {
eventSource.close(); if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
if (eventSourceRef.current) eventSourceRef.current.close();
}; };
}, [searchQuery]); }, [searchQuery]);

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive } from 'lucide-react'; import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive } from 'lucide-react';
import api from '../utils/api';
import './Layout.css'; import './Layout.css';
interface LayoutProps { interface LayoutProps {
@@ -21,17 +20,12 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
}; };
useEffect(() => { useEffect(() => {
const fetchStatus = async () => { const onStats = (e: Event) => {
try { const stats = (e as CustomEvent).detail;
const res = await api.get('/stats'); setSystemActive(stats.deployed_deckies > 0);
setSystemActive(res.data.deployed_deckies > 0);
} catch (err) {
console.error('Failed to fetch system status', err);
}
}; };
fetchStatus(); window.addEventListener('decnet:stats', onStats);
const interval = setInterval(fetchStatus, 10000); return () => window.removeEventListener('decnet:stats', onStats);
return () => clearInterval(interval);
}, []); }, []);
return ( return (

View File

@@ -12,4 +12,15 @@ api.interceptors.request.use((config) => {
return config; return config;
}); });
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.dispatchEvent(new Event('auth:logout'));
}
return Promise.reject(error);
}
);
export default api; export default api;

View File

@@ -12,7 +12,7 @@ Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.jso
""" """
import pytest import pytest
import schemathesis as st import schemathesis as st
from hypothesis import settings, Verbosity from hypothesis import settings, Verbosity, HealthCheck
from decnet.web.auth import create_access_token from decnet.web.auth import create_access_token
import subprocess import subprocess
@@ -102,6 +102,6 @@ schema = st.openapi.from_url(f"{LIVE_SERVER_URL}/openapi.json")
@pytest.mark.fuzz @pytest.mark.fuzz
@st.pytest.parametrize(api=schema) @st.pytest.parametrize(api=schema)
@settings(max_examples=3000, deadline=None, verbosity=Verbosity.debug) @settings(max_examples=3000, deadline=None, verbosity=Verbosity.debug, suppress_health_check=[HealthCheck.filter_too_much])
def test_schema_compliance(case): def test_schema_compliance(case):
case.call_and_validate() case.call_and_validate()