134 lines
5.4 KiB
Python
134 lines
5.4 KiB
Python
from fastapi import FastAPI, Depends, Request
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
from slowapi.errors import RateLimitExceeded
|
|
from routers import docker_router, gitea, files, terminal, system, auth
|
|
import auth as auth_module
|
|
|
|
import asyncio
|
|
import httpx
|
|
import logging
|
|
import time
|
|
import jwt
|
|
from datetime import datetime, timezone, timedelta
|
|
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT
|
|
|
|
_tz_cst = timezone(timedelta(hours=8))
|
|
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
app = FastAPI(title="NAS Dashboard")
|
|
app.state.limiter = limiter
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=CORS_ORIGINS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
@app.exception_handler(RateLimitExceeded)
|
|
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
|
return JSONResponse(status_code=429, content={"detail": "Too many requests, try again later"})
|
|
|
|
@app.middleware("http")
|
|
async def security_headers(request: Request, call_next):
|
|
response = await call_next(request)
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss://nas.jimmygan.com wss://nas.jimmygan.com:8443; frame-ancestors 'none'"
|
|
return response
|
|
|
|
# Audit logger
|
|
import os
|
|
_audit_log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
|
|
os.makedirs(os.path.dirname(_audit_log_path), exist_ok=True)
|
|
_audit_logger = logging.getLogger("audit")
|
|
_audit_logger.setLevel(logging.INFO)
|
|
_audit_handler = logging.FileHandler(_audit_log_path)
|
|
_audit_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
_audit_logger.addHandler(_audit_handler)
|
|
_audit_logger.propagate = False
|
|
|
|
@app.middleware("http")
|
|
async def audit_log(request: Request, call_next):
|
|
start = time.time()
|
|
response = await call_next(request)
|
|
duration = int((time.time() - start) * 1000)
|
|
user = "-"
|
|
try:
|
|
auth_header = request.headers.get("authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
payload = jwt.decode(auth_header[7:], SECRET_KEY, algorithms=[ALGORITHM])
|
|
user = payload.get("sub", "-")
|
|
except Exception:
|
|
pass
|
|
_audit_logger.info(
|
|
"%s %s %s %s %s %d %dms",
|
|
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), request.client.host, user,
|
|
request.method, request.url.path, response.status_code, duration,
|
|
)
|
|
return response
|
|
|
|
async def monitor_containers():
|
|
import docker
|
|
from config import DOCKER_HOST
|
|
client = docker.DockerClient(base_url=DOCKER_HOST)
|
|
|
|
# Store initial states
|
|
previous_states = {}
|
|
|
|
while True:
|
|
try:
|
|
for c in client.containers.list(all=True):
|
|
current_status = c.status
|
|
if c.id in previous_states:
|
|
prev_status = previous_states[c.id]
|
|
# Alert if it transitioned from running to exited or unhealthy
|
|
if prev_status == "running" and current_status in ("exited", "dead"):
|
|
msg = f"⚠️ *Container Alert* ⚠️\nContainer `{c.name}` just stopped unexpectedly (Status: {current_status})."
|
|
# Use httpx directly as system.send_notification expects a FastAPI request object usually
|
|
if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
|
|
proxy_url = os.environ.get("TELEGRAM_PROXY")
|
|
async with httpx.AsyncClient(proxy=proxy_url) as hc:
|
|
await hc.post(
|
|
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
|
data={"chat_id": TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "Markdown"},
|
|
)
|
|
previous_states[c.id] = current_status
|
|
except Exception as e:
|
|
logging.getLogger(__name__).error("Error in container monitor: %s", e)
|
|
|
|
await asyncio.sleep(60)
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
asyncio.create_task(monitor_containers())
|
|
|
|
@app.get("/api/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
@app.get("/api/client-ip")
|
|
async def client_ip(request: Request):
|
|
return {"ip": request.client.host}
|
|
|
|
# Public auth endpoints
|
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
|
|
|
# Protected endpoints
|
|
app.include_router(docker_router.router, prefix="/api/docker", dependencies=[Depends(auth_module.get_current_user)])
|
|
app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(auth_module.get_current_user)])
|
|
app.include_router(files.router, prefix="/api/files", dependencies=[Depends(auth_module.get_current_user)])
|
|
app.include_router(system.router, prefix="/api/system", dependencies=[Depends(auth_module.get_current_user)])
|
|
|
|
# WebSocket endpoint (auth handled inside handler via Query param)
|
|
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
|
|
|
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|