Initial commit: Rampart v0.2.0

Multi-layer DDoS protection for Minecraft servers.

- rampart-core: Edge node with XDP/eBPF + Rust L7 filtering
- rampart-manager: REST API with JWT auth, Redis sync
- rampart-cli: CLI tool for operators
- velocity-plugin: Domain check, HMAC verify, server registry, load balancer
- paper-plugin: Auto-registration, heartbeat, HMAC verify
- dashboard: React + Vite web UI for management
This commit is contained in:
loki5512344 2026-07-20 20:53:32 +02:00
commit cf9608ce5d
Signed by: boba
GPG key ID: 253067914055423B
159 changed files with 15341 additions and 0 deletions

378
dashboard/src/App.css Normal file
View file

@ -0,0 +1,378 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #1f2b47;
--bg-sidebar: #0f3460;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--accent: #e94560;
--accent-hover: #ff6b81;
--green: #2ecc71;
--red: #e74c3c;
--border: #2a3a5c;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
#root {
min-height: 100vh;
}
/* Login */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-primary);
}
.login-card {
background: var(--bg-card);
padding: 2.5rem;
border-radius: 12px;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.login-card h1 {
text-align: center;
margin-bottom: 0.5rem;
color: var(--accent);
font-size: 1.8rem;
}
.login-card p {
text-align: center;
color: var(--text-secondary);
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.login-card input {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.login-card input:focus {
border-color: var(--accent);
}
.login-card button {
width: 100%;
padding: 0.75rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.login-card button:hover {
background: var(--accent-hover);
}
.login-card button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
background: rgba(231, 76, 60, 0.15);
color: var(--red);
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
text-align: center;
}
/* Layout */
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: var(--bg-sidebar);
padding: 1.5rem;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar h2 {
color: var(--accent);
font-size: 1.3rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.sidebar nav button {
background: none;
border: none;
color: var(--text-secondary);
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.95rem;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.sidebar nav button:hover {
background: rgba(233, 69, 96, 0.1);
color: var(--text-primary);
}
.sidebar nav button.active {
background: rgba(233, 69, 96, 0.2);
color: var(--accent);
}
.sidebar .logout-btn {
margin-top: auto;
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.sidebar .logout-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.main-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.main-content h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: var(--text-primary);
}
/* Tables */
.table-container {
background: var(--bg-card);
border-radius: 10px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--bg-secondary);
}
th {
padding: 0.85rem 1rem;
text-align: left;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
tbody tr:hover {
background: rgba(255, 255, 255, 0.04);
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online {
background: var(--green);
box-shadow: 0 0 6px rgba(46, 204, 113, 0.5);
}
.status-dot.offline {
background: var(--red);
box-shadow: 0 0 6px rgba(231, 76, 60, 0.5);
}
/* Loading spinner */
.spinner {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
}
.spinner::after {
content: '';
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error message */
.error-msg {
background: rgba(231, 76, 60, 0.1);
color: var(--red);
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
/* Success message */
.success-msg {
background: rgba(46, 204, 113, 0.1);
color: var(--green);
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
/* Blacklist form */
.blacklist-form {
background: var(--bg-card);
padding: 1.5rem;
border-radius: 10px;
margin-bottom: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
}
.blacklist-form input,
.blacklist-form select {
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
min-width: 140px;
flex: 1;
}
.blacklist-form input:focus,
.blacklist-form select:focus {
border-color: var(--accent);
}
.blacklist-form button {
padding: 0.6rem 1.25rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.blacklist-form button:hover {
background: var(--accent-hover);
}
.blacklist-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.layout {
flex-direction: column;
}
.sidebar {
width: 100%;
padding: 1rem;
}
.sidebar nav {
flex-direction: row;
flex-wrap: wrap;
}
.sidebar .logout-btn {
margin-top: 0.5rem;
}
.main-content {
padding: 1rem;
}
.blacklist-form {
flex-direction: column;
}
.blacklist-form input,
.blacklist-form select,
.blacklist-form button {
width: 100%;
}
}

17
dashboard/src/App.tsx Normal file
View file

@ -0,0 +1,17 @@
import { useState } from 'react'
import Login from './components/Login'
import Layout from './components/Layout'
function App() {
const [token, setToken] = useState<string | null>(
() => sessionStorage.getItem('rampart_token')
)
if (!token) {
return <Login onLogin={(t) => setToken(t)} />
}
return <Layout />
}
export default App

122
dashboard/src/api.ts Normal file
View file

@ -0,0 +1,122 @@
const BASE = 'http://localhost:8080/api/v1'
function getToken(): string | null {
return sessionStorage.getItem('rampart_token')
}
function setToken(token: string): void {
sessionStorage.setItem('rampart_token', token)
}
function clearToken(): void {
sessionStorage.removeItem('rampart_token')
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${BASE}${path}`, { ...options, headers })
if (res.status === 401) {
clearToken()
window.location.reload()
throw new Error('Unauthorized')
}
if (!res.ok) {
const text = await res.text()
throw new Error(text || res.statusText)
}
return res.json()
}
export interface HealthResponse {
status: string
}
export interface LoginResponse {
token: string
}
export interface Server {
name: string
server_type: string
ip: string
port: number
status: string
online_players: number
max_players: number
tps: number
last_heartbeat: string
}
export interface BlacklistEntry {
target: string
type: string
reason: string
created: string
expires: string
}
export interface Node {
id: string
role: string
ip: string
status: string
last_heartbeat: string
}
export async function login(password: string): Promise<LoginResponse> {
const res = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || res.statusText)
}
const data: LoginResponse = await res.json()
setToken(data.token)
return data
}
export function logout(): void {
clearToken()
window.location.reload()
}
export async function fetchServers(): Promise<Server[]> {
return apiFetch<Server[]>('/servers')
}
export async function fetchBlacklist(): Promise<BlacklistEntry[]> {
return apiFetch<BlacklistEntry[]>('/blacklist')
}
export async function addBlacklist(
target: string,
type: string,
reason: string,
durationSecs: number
): Promise<void> {
await apiFetch('/blacklist', {
method: 'POST',
body: JSON.stringify({ target, type, reason, duration_secs: durationSecs }),
})
}
export async function fetchNodes(): Promise<Node[]> {
return apiFetch<Node[]>('/nodes')
}
export async function fetchHealth(): Promise<HealthResponse> {
return apiFetch<HealthResponse>('/health')
}

View file

@ -0,0 +1,137 @@
import { useState, useEffect, FormEvent } from 'react'
import { fetchBlacklist, addBlacklist, type BlacklistEntry } from '../api'
function Blacklist() {
const [entries, setEntries] = useState<BlacklistEntry[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [target, setTarget] = useState('')
const [reason, setReason] = useState('')
const [duration, setDuration] = useState('3600')
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState('')
const [addSuccess, setAddSuccess] = useState('')
useEffect(() => {
let cancelled = false
async function load() {
try {
const data = await fetchBlacklist()
if (!cancelled) {
setEntries(data)
setError('')
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load blacklist')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
const interval = setInterval(load, 30000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [])
async function handleAdd(e: FormEvent) {
e.preventDefault()
setAddError('')
setAddSuccess('')
setAdding(true)
try {
await addBlacklist(target, 'ip', reason, parseInt(duration, 10))
setAddSuccess(`Added ${target} to blacklist`)
setTarget('')
setReason('')
setDuration('3600')
const data = await fetchBlacklist()
setEntries(data)
} catch (err: unknown) {
setAddError(err instanceof Error ? err.message : 'Failed to add entry')
} finally {
setAdding(false)
}
}
if (loading) return <div className="spinner" />
return (
<>
<h1>Blacklist</h1>
<form className="blacklist-form" onSubmit={handleAdd}>
<input
type="text"
placeholder="IP Address"
value={target}
onChange={(e) => setTarget(e.target.value)}
required
/>
<input
type="text"
placeholder="Reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
required
/>
<input
type="number"
placeholder="Duration (seconds)"
value={duration}
onChange={(e) => setDuration(e.target.value)}
min={1}
required
/>
<button type="submit" disabled={adding}>
{adding ? 'Adding...' : 'Add to Blacklist'}
</button>
</form>
{addError && <div className="error-msg">{addError}</div>}
{addSuccess && <div className="success-msg">{addSuccess}</div>}
{error && <div className="error-msg">{error}</div>}
<div className="table-container">
<table>
<thead>
<tr>
<th>Target</th>
<th>Type</th>
<th>Reason</th>
<th>Created</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
No blacklist entries
</td>
</tr>
)}
{entries.map((e, i) => (
<tr key={i}>
<td>{e.target}</td>
<td>{e.type}</td>
<td>{e.reason}</td>
<td>{new Date(e.created).toLocaleString()}</td>
<td>{new Date(e.expires).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}
export default Blacklist

View file

@ -0,0 +1,53 @@
import { useState } from 'react'
import { logout } from '../api'
import Servers from './Servers'
import Blacklist from './Blacklist'
import Nodes from './Nodes'
type Page = 'servers' | 'blacklist' | 'nodes'
const navItems: { key: Page; label: string }[] = [
{ key: 'servers', label: 'Servers' },
{ key: 'blacklist', label: 'Blacklist' },
{ key: 'nodes', label: 'Nodes' },
]
function Layout() {
const [page, setPage] = useState<Page>('servers')
function renderPage() {
switch (page) {
case 'servers':
return <Servers />
case 'blacklist':
return <Blacklist />
case 'nodes':
return <Nodes />
}
}
return (
<div className="layout">
<aside className="sidebar">
<h2>Rampart Manager</h2>
<nav>
{navItems.map((item) => (
<button
key={item.key}
className={page === item.key ? 'active' : ''}
onClick={() => setPage(item.key)}
>
{item.label}
</button>
))}
</nav>
<button className="logout-btn" onClick={logout}>
Logout
</button>
</aside>
<main className="main-content">{renderPage()}</main>
</div>
)
}
export default Layout

View file

@ -0,0 +1,48 @@
import { useState, FormEvent } from 'react'
import { login } from '../api'
interface LoginProps {
onLogin: (token: string) => void
}
function Login({ onLogin }: LoginProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await login(password)
onLogin(res.token)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<form className="login-card" onSubmit={handleSubmit}>
<h1>Rampart</h1>
<p>Manager Dashboard</p>
{error && <div className="login-error">{error}</div>}
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
<button type="submit" disabled={loading || !password}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
)
}
export default Login

View file

@ -0,0 +1,82 @@
import { useState, useEffect } from 'react'
import { fetchNodes, type Node } from '../api'
function Nodes() {
const [nodes, setNodes] = useState<Node[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const data = await fetchNodes()
if (!cancelled) {
setNodes(data)
setError('')
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load nodes')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
const interval = setInterval(load, 15000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [])
if (loading) return <div className="spinner" />
return (
<>
<h1>Edge Nodes</h1>
{error && <div className="error-msg">{error}</div>}
<div className="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Role</th>
<th>IP</th>
<th>Status</th>
<th>Last Heartbeat</th>
</tr>
</thead>
<tbody>
{nodes.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
No nodes found
</td>
</tr>
)}
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.role}</td>
<td>{n.ip}</td>
<td>
<span className="status-badge">
<span className={`status-dot ${n.status === 'online' ? 'online' : 'offline'}`} />
{n.status}
</span>
</td>
<td>{new Date(n.last_heartbeat).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}
export default Nodes

View file

@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { fetchServers, type Server } from '../api'
function Servers() {
const [servers, setServers] = useState<Server[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const data = await fetchServers()
if (!cancelled) {
setServers(data)
setError('')
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load servers')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
const interval = setInterval(load, 10000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [])
if (loading) return <div className="spinner" />
return (
<>
<h1>Servers</h1>
{error && <div className="error-msg">{error}</div>}
<div className="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>IP:Port</th>
<th>Status</th>
<th>Online/Max</th>
<th>TPS</th>
<th>Last Heartbeat</th>
</tr>
</thead>
<tbody>
{servers.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
No servers found
</td>
</tr>
)}
{servers.map((s) => (
<tr key={s.name}>
<td>{s.name}</td>
<td>{s.server_type}</td>
<td>{s.ip}:{s.port}</td>
<td>
<span className="status-badge">
<span className={`status-dot ${s.status === 'online' ? 'online' : 'offline'}`} />
{s.status}
</span>
</td>
<td>{s.online_players}/{s.max_players}</td>
<td>{s.tps.toFixed(1)}</td>
<td>{new Date(s.last_heartbeat).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}
export default Servers

10
dashboard/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './App.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

1
dashboard/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />