init
This commit is contained in:
10
scoreboard_injector/Dockerfile
Normal file
10
scoreboard_injector/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
316
scoreboard_injector/main.py
Normal file
316
scoreboard_injector/main.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Scoreboard Injector for ForcAD
|
||||
Monitors WebSocket for attacks and alerts on critical situations
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
import aiohttp
|
||||
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||
from pydantic import BaseModel
|
||||
import asyncpg
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Configuration
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://adctrl:adctrl@postgres:5432/adctrl")
|
||||
SECRET_TOKEN = os.getenv("SECRET_TOKEN", "change-me-in-production")
|
||||
SCOREBOARD_WS_URL = os.getenv("SCOREBOARD_WS_URL", "ws://scoreboard:8080/api/events")
|
||||
OUR_TEAM_ID = int(os.getenv("OUR_TEAM_ID", "1"))
|
||||
ALERT_THRESHOLD_POINTS = float(os.getenv("ALERT_THRESHOLD_POINTS", "100"))
|
||||
ALERT_THRESHOLD_TIME = int(os.getenv("ALERT_THRESHOLD_TIME", "300")) # seconds
|
||||
TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "http://tg-bot:8003/send")
|
||||
|
||||
# Database pool
|
||||
db_pool = None
|
||||
ws_task = None
|
||||
|
||||
class AttackStats(BaseModel):
|
||||
total_attacks: int
|
||||
attacks_by_us: int
|
||||
attacks_to_us: int
|
||||
recent_attacks: int
|
||||
critical_alerts: int
|
||||
|
||||
# Auth dependency
|
||||
async def verify_token(authorization: str = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
if token != SECRET_TOKEN:
|
||||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
return token
|
||||
|
||||
# Database functions
|
||||
async def get_db():
|
||||
return await db_pool.acquire()
|
||||
|
||||
async def release_db(conn):
|
||||
await db_pool.release(conn)
|
||||
|
||||
async def process_attack_event(event: Dict[str, Any]):
|
||||
"""Process attack event from scoreboard"""
|
||||
conn = await db_pool.acquire()
|
||||
try:
|
||||
# Extract attack information from event
|
||||
# Adjust fields based on actual ForcAD event structure
|
||||
attack_id = event.get('id') or f"{event.get('round')}_{event.get('attacker_id')}_{event.get('victim_id')}_{event.get('service')}"
|
||||
attacker_id = event.get('attacker_id') or event.get('team_id')
|
||||
victim_id = event.get('victim_id') or event.get('target_id')
|
||||
service_name = event.get('service') or event.get('service_name')
|
||||
flag = event.get('flag', '')
|
||||
timestamp = datetime.fromisoformat(event.get('time', datetime.utcnow().isoformat()))
|
||||
points = float(event.get('points', 0))
|
||||
|
||||
is_our_attack = attacker_id == OUR_TEAM_ID
|
||||
is_attack_to_us = victim_id == OUR_TEAM_ID
|
||||
|
||||
# Store attack in database
|
||||
await conn.execute("""
|
||||
INSERT INTO attacks (attack_id, attacker_team_id, victim_team_id, service_name, flag, timestamp, points, is_our_attack, is_attack_to_us)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (attack_id) DO NOTHING
|
||||
""", attack_id, attacker_id, victim_id, service_name, flag, timestamp, points, is_our_attack, is_attack_to_us)
|
||||
|
||||
# Check for alert conditions if attack is against us
|
||||
if is_attack_to_us:
|
||||
await check_and_create_alerts(conn, attacker_id, service_name)
|
||||
|
||||
finally:
|
||||
await db_pool.release(conn)
|
||||
|
||||
async def check_and_create_alerts(conn, attacker_id: int, service_name: str):
|
||||
"""Check if we should create an alert for attacks against us"""
|
||||
threshold_time = datetime.utcnow() - timedelta(seconds=ALERT_THRESHOLD_TIME)
|
||||
|
||||
# Check total points lost from this attacker in threshold time
|
||||
result = await conn.fetchrow("""
|
||||
SELECT COUNT(*) as attack_count, COALESCE(SUM(points), 0) as total_points
|
||||
FROM attacks
|
||||
WHERE is_attack_to_us = true
|
||||
AND attacker_team_id = $1
|
||||
AND service_name = $2
|
||||
AND timestamp > $3
|
||||
""", attacker_id, service_name, threshold_time)
|
||||
|
||||
if result and result['total_points'] >= ALERT_THRESHOLD_POINTS:
|
||||
# Create alert
|
||||
alert_message = f"CRITICAL: Team {attacker_id} has stolen {result['total_points']:.2f} points from service {service_name} in the last {ALERT_THRESHOLD_TIME}s ({result['attack_count']} attacks)"
|
||||
|
||||
# Check if we already alerted recently
|
||||
recent_alert = await conn.fetchrow("""
|
||||
SELECT id FROM attack_alerts
|
||||
WHERE alert_type = 'high_point_loss'
|
||||
AND message LIKE $1
|
||||
AND created_at > $2
|
||||
""", f"%Team {attacker_id}%{service_name}%", threshold_time)
|
||||
|
||||
if not recent_alert:
|
||||
alert_id = await conn.fetchval("""
|
||||
INSERT INTO attack_alerts (attack_id, alert_type, severity, message)
|
||||
VALUES (
|
||||
(SELECT id FROM attacks WHERE attacker_team_id = $1 AND service_name = $2 ORDER BY timestamp DESC LIMIT 1),
|
||||
'high_point_loss',
|
||||
'critical',
|
||||
$3
|
||||
)
|
||||
RETURNING id
|
||||
""", attacker_id, service_name, alert_message)
|
||||
|
||||
# Send to telegram
|
||||
await send_telegram_alert(alert_message)
|
||||
|
||||
# Mark as notified
|
||||
await conn.execute("UPDATE attack_alerts SET notified = true WHERE id = $1", alert_id)
|
||||
|
||||
async def send_telegram_alert(message: str):
|
||||
"""Send alert to telegram bot"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
TELEGRAM_API_URL,
|
||||
json={"message": message},
|
||||
headers={"Authorization": f"Bearer {SECRET_TOKEN}"}
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
print(f"Failed to send telegram alert: {await resp.text()}")
|
||||
except Exception as e:
|
||||
print(f"Error sending telegram alert: {e}")
|
||||
|
||||
async def websocket_listener():
|
||||
"""Listen to scoreboard WebSocket for events"""
|
||||
while True:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.ws_connect(SCOREBOARD_WS_URL) as ws:
|
||||
print(f"Connected to scoreboard WebSocket: {SCOREBOARD_WS_URL}")
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
event = json.loads(msg.data)
|
||||
# Process different event types
|
||||
if event.get('type') in ['attack', 'flag_stolen', 'service_status']:
|
||||
await process_attack_event(event)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Failed to decode WebSocket message: {msg.data}")
|
||||
except Exception as e:
|
||||
print(f"Error processing event: {e}")
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
print(f"WebSocket error: {ws.exception()}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"WebSocket connection error: {e}")
|
||||
print("Reconnecting in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# Lifespan context
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global db_pool, ws_task
|
||||
db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
|
||||
|
||||
# Start WebSocket listener
|
||||
ws_task = asyncio.create_task(websocket_listener())
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
if ws_task:
|
||||
ws_task.cancel()
|
||||
try:
|
||||
await ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await db_pool.close()
|
||||
|
||||
app = FastAPI(title="Scoreboard Injector", lifespan=lifespan)
|
||||
|
||||
# API Endpoints
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
@app.get("/stats", dependencies=[Depends(verify_token)])
|
||||
async def get_stats():
|
||||
"""Get attack statistics"""
|
||||
conn = await get_db()
|
||||
try:
|
||||
total = await conn.fetchval("SELECT COUNT(*) FROM attacks")
|
||||
attacks_by_us = await conn.fetchval("SELECT COUNT(*) FROM attacks WHERE is_our_attack = true")
|
||||
attacks_to_us = await conn.fetchval("SELECT COUNT(*) FROM attacks WHERE is_attack_to_us = true")
|
||||
|
||||
threshold_time = datetime.utcnow() - timedelta(minutes=5)
|
||||
recent = await conn.fetchval("SELECT COUNT(*) FROM attacks WHERE timestamp > $1", threshold_time)
|
||||
|
||||
critical_alerts = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM attack_alerts WHERE severity = 'critical' AND created_at > $1",
|
||||
threshold_time
|
||||
)
|
||||
|
||||
return {
|
||||
"total_attacks": total,
|
||||
"attacks_by_us": attacks_by_us,
|
||||
"attacks_to_us": attacks_to_us,
|
||||
"recent_attacks_5min": recent,
|
||||
"critical_alerts_5min": critical_alerts
|
||||
}
|
||||
finally:
|
||||
await release_db(conn)
|
||||
|
||||
@app.get("/attacks", dependencies=[Depends(verify_token)])
|
||||
async def get_attacks(limit: int = 100, our_attacks: Optional[bool] = None, attacks_to_us: Optional[bool] = None):
|
||||
"""Get recent attacks"""
|
||||
conn = await get_db()
|
||||
try:
|
||||
query = "SELECT * FROM attacks WHERE 1=1"
|
||||
params = []
|
||||
param_count = 0
|
||||
|
||||
if our_attacks is not None:
|
||||
param_count += 1
|
||||
query += f" AND is_our_attack = ${param_count}"
|
||||
params.append(our_attacks)
|
||||
|
||||
if attacks_to_us is not None:
|
||||
param_count += 1
|
||||
query += f" AND is_attack_to_us = ${param_count}"
|
||||
params.append(attacks_to_us)
|
||||
|
||||
param_count += 1
|
||||
query += f" ORDER BY timestamp DESC LIMIT ${param_count}"
|
||||
params.append(limit)
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
await release_db(conn)
|
||||
|
||||
@app.get("/alerts", dependencies=[Depends(verify_token)])
|
||||
async def get_alerts(limit: int = 50, unnotified: bool = False):
|
||||
"""Get alerts"""
|
||||
conn = await get_db()
|
||||
try:
|
||||
if unnotified:
|
||||
query = "SELECT * FROM attack_alerts WHERE notified = false ORDER BY created_at DESC LIMIT $1"
|
||||
else:
|
||||
query = "SELECT * FROM attack_alerts ORDER BY created_at DESC LIMIT $1"
|
||||
|
||||
rows = await conn.fetch(query, limit)
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
await release_db(conn)
|
||||
|
||||
@app.post("/alerts/{alert_id}/acknowledge", dependencies=[Depends(verify_token)])
|
||||
async def acknowledge_alert(alert_id: int):
|
||||
"""Mark alert as acknowledged"""
|
||||
conn = await get_db()
|
||||
try:
|
||||
await conn.execute("UPDATE attack_alerts SET notified = true WHERE id = $1", alert_id)
|
||||
return {"status": "acknowledged", "alert_id": alert_id}
|
||||
finally:
|
||||
await release_db(conn)
|
||||
|
||||
@app.get("/attacks/by-service", dependencies=[Depends(verify_token)])
|
||||
async def get_attacks_by_service():
|
||||
"""Get attack statistics grouped by service"""
|
||||
conn = await get_db()
|
||||
try:
|
||||
rows = await conn.fetch("""
|
||||
SELECT
|
||||
service_name,
|
||||
COUNT(*) as total_attacks,
|
||||
COUNT(*) FILTER (WHERE is_our_attack = true) as our_attacks,
|
||||
COUNT(*) FILTER (WHERE is_attack_to_us = true) as attacks_to_us,
|
||||
COALESCE(SUM(points) FILTER (WHERE is_our_attack = true), 0) as points_gained,
|
||||
COALESCE(SUM(points) FILTER (WHERE is_attack_to_us = true), 0) as points_lost
|
||||
FROM attacks
|
||||
GROUP BY service_name
|
||||
ORDER BY total_attacks DESC
|
||||
""")
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
await release_db(conn)
|
||||
|
||||
@app.post("/settings/team-id", dependencies=[Depends(verify_token)])
|
||||
async def set_team_id(team_id: int):
|
||||
"""Update our team ID"""
|
||||
global OUR_TEAM_ID
|
||||
OUR_TEAM_ID = team_id
|
||||
|
||||
conn = await get_db()
|
||||
try:
|
||||
await conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES ('our_team_id', $1) ON CONFLICT (key) DO UPDATE SET value = $1",
|
||||
str(team_id)
|
||||
)
|
||||
return {"team_id": team_id}
|
||||
finally:
|
||||
await release_db(conn)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8002)
|
||||
6
scoreboard_injector/requirements.txt
Normal file
6
scoreboard_injector/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
asyncpg==0.29.0
|
||||
pydantic==2.5.3
|
||||
aiohttp==3.9.1
|
||||
python-dotenv==1.0.0
|
||||
Reference in New Issue
Block a user