HexaWetter v1.2.0: Security, Admin, Radar, UI
All checks were successful
CI / api-check (push) Successful in 20s
CI / frontend-check (push) Successful in 6s

This commit is contained in:
TheOnlyMace
2026-06-18 23:59:08 +02:00
parent 3ebdc96381
commit 5a08d4c4ac
72 changed files with 695 additions and 409 deletions

View File

@@ -66,6 +66,14 @@ def decode_access_token(token: str) -> dict[str, Any]:
return jwt.decode(token, ADMIN_JWT_SECRET, algorithms=["HS256"])
def _unauthorized(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
async def require_admin(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
@@ -73,13 +81,13 @@ async def require_admin(
if not admin_configured():
raise HTTPException(status_code=503, detail="Admin access is not configured")
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
raise _unauthorized("Missing bearer token")
try:
payload = decode_access_token(credentials.credentials)
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
raise _unauthorized("Invalid or expired token") from exc
if payload.get("sub") != ADMIN_USERNAME or payload.get("scope") != "admin":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token scope")
raise _unauthorized("Invalid token scope")
return payload

View File

@@ -2,7 +2,7 @@ import os
from pathlib import Path
APP_NAME = "HexaWetter"
APP_VERSION = "1.1.0"
APP_VERSION = "1.2.0"
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar"
@@ -42,6 +42,15 @@ CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60"))
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120"))
API_KEY = os.getenv("API_KEY", "")
REQUIRE_API_KEY = os.getenv("REQUIRE_API_KEY", "true").lower() in ("1", "true", "yes", "on")
_allowed_origins = os.getenv("ALLOWED_ORIGINS", "").strip()
ALLOWED_ORIGINS = tuple(o.strip() for o in _allowed_origins.split(",") if o.strip())
_allowed_hosts = os.getenv("ALLOWED_HOSTS", "").strip()
ALLOWED_HOSTS = tuple(h.strip() for h in _allowed_hosts.split(",") if h.strip())
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "")
ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "")

View File

@@ -200,7 +200,7 @@ async def list_rendered_radar(product: str, limit: int = 24) -> list[dict[str, A
result.append(
{
"filename": row["filename"],
"image_url": f"/api/radar/rendered/{product}/{row['filename']}.png",
"image_url": f"/api/admin/radar/rendered/{product}/{row['filename']}.png",
"bounds": bounds,
"modified_at": row["modified_at"].isoformat() if row["modified_at"] else None,
"rendered_at": row["updated_at"].isoformat() if row["updated_at"] else None,

View File

@@ -1,52 +1,45 @@
from __future__ import annotations
import logging
import os
import re
import time
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.auth import admin_configured
from app.cache import close_redis, init_redis, redis_available
from app.cache import close_redis, init_redis
from app.config import (
APP_NAME,
APP_VERSION,
BRIGHTSKY_BASE_URL,
DEFAULT_LAT,
DEFAULT_LON,
DEFAULT_PLACE,
DWD_RADAR_BASE_URL,
DWD_WMS_URL,
OPEN_METEO_BASE_URL,
RADAR_PRODUCT,
RADAR_RETENTION_HOURS,
RATE_LIMIT_PER_MINUTE,
RENDERED_RADAR_DIR,
RAW_RADAR_DIR,
RENDERED_RADAR_DIR,
)
from app.database import (
close_db,
init_db,
list_rendered_radar,
pool_available,
upsert_radar_meta,
)
from app.http_client import close_http_client, get_client, init_http_client
from app.database import close_db, init_db
from app.http_client import close_http_client, init_http_client
from app.services.dashboard import build_dashboard
from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index
from app.services.dwd_radar import fetch_radar_index
from app.services.geocoding import reverse_geocode, search_places
from app.services.health import check_sources
from app.routes.admin import router as admin_router
from app.security import (
cors_allowed_origins,
is_api_key_exempt,
trusted_hosts,
validate_security_config,
verify_request_api_key,
)
from app.services.warnings import fetch_warnings_for_location
from app.services.weather import get_forecast, get_observations
from app.services.wms import fetch_wms_time_steps
@@ -74,6 +67,7 @@ class FavoriteLocation(BaseModel):
@asynccontextmanager
async def lifespan(_: FastAPI):
validate_security_config()
await init_http_client()
await init_db()
await init_redis()
@@ -87,14 +81,32 @@ async def lifespan(_: FastAPI):
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
app.include_router(admin_router)
_hosts = list(trusted_hosts())
if _hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=_hosts)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_origins=cors_allowed_origins(),
allow_methods=["GET", "POST", "PUT", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
)
@app.middleware("http")
async def api_key_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/api/") and not is_api_key_exempt(path):
if not verify_request_api_key(request):
return JSONResponse(
status_code=401,
content={"detail": "Invalid or missing API key"},
headers={"WWW-Authenticate": "ApiKey"},
)
return await call_next(request)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
@@ -102,6 +114,9 @@ async def security_headers_middleware(request: Request, call_next):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(self)"
if path := request.url.path:
if path.startswith("/api/admin") or path.endswith("admin.html"):
response.headers["X-Robots-Tag"] = "noindex, nofollow"
return response
@@ -131,20 +146,6 @@ async def health() -> dict[str, Any]:
"service": APP_NAME,
"version": APP_VERSION,
"time_utc": utc_now().isoformat(),
"admin_enabled": admin_configured(),
"postgres": pool_available(),
"redis": redis_available(),
}
@app.get("/api/health/sources")
async def health_sources() -> dict[str, Any]:
sources = await check_sources()
degraded = [s for s in sources if s.get("status") not in ("ok", "disabled")]
return {
"status": "degraded" if degraded else "ok",
"time_utc": utc_now().isoformat(),
"sources": sources,
}
@@ -162,18 +163,6 @@ async def dashboard(
raise HTTPException(status_code=502, detail=f"Dashboard error: {exc}") from exc
def cleanup_old_radar_files() -> None:
cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS)
if not RAW_RADAR_DIR.exists():
return
for path in RAW_RADAR_DIR.rglob("*"):
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
continue
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
if modified < cutoff:
path.unlink(missing_ok=True)
@app.get("/api/location/default")
async def default_location() -> Location:
return Location()
@@ -247,88 +236,6 @@ async def radar_latest(
raise HTTPException(status_code=502, detail=f"DWD radar index unavailable: {exc}") from exc
@app.post("/api/radar/sync")
async def radar_sync(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(6, ge=1, le=50),
) -> dict[str, Any]:
latest = await radar_latest(product=product, limit=limit)
target_dir = RAW_RADAR_DIR / "composite" / product
target_dir.mkdir(parents=True, exist_ok=True)
downloaded = []
skipped = []
client = get_client()
for item in latest["files"]:
out = target_dir / item["name"]
if out.exists() and out.stat().st_size > 0:
skipped.append(item["name"])
continue
response = await client.get(item["url"], timeout=90.0)
response.raise_for_status()
out.write_bytes(response.content)
downloaded.append(item["name"])
modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc)
await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending")
cleanup_old_radar_files()
return {"product": product, "target_dir": str(target_dir), "downloaded": downloaded, "skipped": skipped}
@app.get("/api/radar/files")
async def radar_files(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
) -> dict[str, Any]:
target_dir = RAW_RADAR_DIR / "composite" / product
target_dir.mkdir(parents=True, exist_ok=True)
files = []
for path in sorted(target_dir.iterdir()):
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
continue
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
files.append(
{
"name": path.name,
"size_bytes": path.stat().st_size,
"modified_utc": modified.isoformat(),
"download_url": f"/api/radar/files/{product}/{path.name}",
}
)
return {"product": product, "path": str(target_dir), "count": len(files), "files": files}
@app.get("/api/radar/files/{product}/{filename}")
async def radar_file_download(product: str, filename: str):
if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Invalid product")
if not RADAR_FILE_RE.fullmatch(filename):
raise HTTPException(status_code=400, detail="Invalid filename")
path = RAW_RADAR_DIR / "composite" / product / filename
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream"
return FileResponse(path, filename=filename, media_type=media_type)
@app.get("/api/radar/rendered")
async def radar_rendered_list(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(24, ge=1, le=100),
) -> dict[str, Any]:
frames = await list_rendered_radar(product, limit=limit)
return {"product": product, "count": len(frames), "frames": frames}
@app.get("/api/radar/rendered/{product}/{filename}.png")
async def radar_rendered_image(product: str, filename: str):
if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Invalid filename")
path = RENDERED_RADAR_DIR / product / f"{filename}.png"
if not path.exists():
raise HTTPException(status_code=404, detail="Rendered image not found")
return FileResponse(path, media_type="image/png")
@app.get("/api/radar/wms")
async def radar_wms() -> dict[str, Any]:
return {
@@ -391,14 +298,3 @@ async def radar_wms_times(
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc
@app.get("/api/sources")
async def sources() -> dict[str, Any]:
return {
"note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.",
"open_meteo_base_url": OPEN_METEO_BASE_URL,
"brightsky_base_url": BRIGHTSKY_BASE_URL,
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
"dwd_wms_url": DWD_WMS_URL,
"dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.",
"osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.",
}

View File

@@ -1,11 +1,14 @@
from __future__ import annotations
import asyncio
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.auth import (
@@ -37,8 +40,10 @@ from app.config import (
RAW_RADAR_DIR,
RENDERED_RADAR_DIR,
)
from app.database import pool_available
from app.database import list_rendered_radar, pool_available
from app.services.dwd_radar import RADAR_FILE_RE
from app.services.health import check_sources
from app.services.radar_sync import sync_radar_files
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -87,7 +92,7 @@ def _public_config() -> dict[str, Any]:
@router.get("/status")
async def admin_status_public() -> dict[str, Any]:
return {"admin_enabled": admin_configured(), "version": APP_VERSION}
return {"admin_enabled": admin_configured()}
@router.post("/login")
@@ -97,6 +102,7 @@ async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]
check_login_rate_limit(request)
if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")):
record_failed_login(request)
await asyncio.sleep(1)
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(payload.username)
return {
@@ -125,6 +131,95 @@ async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[st
}
@router.get("/sources")
async def admin_sources(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
return {
"note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.",
"open_meteo_base_url": OPEN_METEO_BASE_URL,
"brightsky_base_url": BRIGHTSKY_BASE_URL,
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
"dwd_wms_url": DWD_WMS_URL,
"dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.",
"osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.",
}
@router.post("/radar/sync")
async def admin_radar_sync(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(6, ge=1, le=50),
_: dict[str, Any] = Depends(require_admin),
) -> dict[str, Any]:
try:
return await sync_radar_files(product=product, limit=limit)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Radar sync failed: {exc}") from exc
@router.get("/radar/files")
async def admin_radar_files(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
_: dict[str, Any] = Depends(require_admin),
) -> dict[str, Any]:
target_dir = RAW_RADAR_DIR / "composite" / product
target_dir.mkdir(parents=True, exist_ok=True)
files = []
for path in sorted(target_dir.iterdir()):
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
continue
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
files.append(
{
"name": path.name,
"size_bytes": path.stat().st_size,
"modified_utc": modified.isoformat(),
"download_url": f"/api/admin/radar/files/{product}/{path.name}",
}
)
return {"product": product, "path": str(target_dir), "count": len(files), "files": files}
@router.get("/radar/files/{product}/{filename}")
async def admin_radar_file_download(
product: str,
filename: str,
_: dict[str, Any] = Depends(require_admin),
):
if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Invalid product")
if not RADAR_FILE_RE.fullmatch(filename):
raise HTTPException(status_code=400, detail="Invalid filename")
path = RAW_RADAR_DIR / "composite" / product / filename
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream"
return FileResponse(path, filename=filename, media_type=media_type)
@router.get("/radar/rendered")
async def admin_radar_rendered_list(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(24, ge=1, le=100),
_: dict[str, Any] = Depends(require_admin),
) -> dict[str, Any]:
frames = await list_rendered_radar(product, limit=limit)
return {"product": product, "count": len(frames), "frames": frames}
@router.get("/radar/rendered/{product}/{filename}.png")
async def admin_radar_rendered_image(
product: str,
filename: str,
_: dict[str, Any] = Depends(require_admin),
):
if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Invalid filename")
path = RENDERED_RADAR_DIR / product / f"{filename}.png"
if not path.exists():
raise HTTPException(status_code=404, detail="Rendered image not found")
return FileResponse(path, media_type="image/png")
@router.post("/cache/clear")
async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
from app.cache import flush_cache

70
api/app/security.py Normal file
View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import logging
import secrets
from typing import Iterable
from fastapi import Request
from fastapi.responses import JSONResponse
from app.config import ALLOWED_HOSTS, API_KEY, REQUIRE_API_KEY
logger = logging.getLogger(__name__)
API_KEY_EXEMPT_PATHS: frozenset[str] = frozenset(
{
"/api/health",
"/api/admin/login",
"/api/admin/status",
}
)
def api_key_configured() -> bool:
return bool(API_KEY)
def api_key_required() -> bool:
return REQUIRE_API_KEY or api_key_configured()
def validate_security_config() -> None:
if REQUIRE_API_KEY and not API_KEY:
raise RuntimeError("REQUIRE_API_KEY is enabled but API_KEY is not set. Generate one with: python scripts/setup_secrets.py")
if API_KEY and len(API_KEY) < 24:
raise RuntimeError("API_KEY must be at least 24 characters")
if api_key_configured() and not REQUIRE_API_KEY:
logger.warning("API_KEY is set but REQUIRE_API_KEY=false — API key auth is still enforced when API_KEY is present")
if not api_key_configured() and not REQUIRE_API_KEY:
logger.warning("API is running without API_KEY — set API_KEY and REQUIRE_API_KEY=true for production")
def is_api_key_exempt(path: str) -> bool:
return path in API_KEY_EXEMPT_PATHS
def extract_api_key(request: Request) -> str:
return request.headers.get("X-API-Key", "").strip()
def verify_request_api_key(request: Request) -> bool:
if not api_key_required():
return True
provided = extract_api_key(request)
if not provided:
return False
return secrets.compare_digest(provided, API_KEY)
def cors_allowed_origins() -> list[str]:
from app.config import ALLOWED_ORIGINS
if ALLOWED_ORIGINS:
return list(ALLOWED_ORIGINS)
if api_key_required():
return []
return ["*"]
def trusted_hosts() -> Iterable[str]:
return ALLOWED_HOSTS

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from app.config import RADAR_PRODUCT, RADAR_RETENTION_HOURS, RAW_RADAR_DIR
from app.database import upsert_radar_meta
from app.http_client import get_client
from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index
_FILENAME_RE = re.compile(r"^[a-z0-9_-]+$", re.IGNORECASE)
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def cleanup_old_radar_files() -> None:
cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS)
if not RAW_RADAR_DIR.exists():
return
for path in RAW_RADAR_DIR.rglob("*"):
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
continue
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
if modified < cutoff:
path.unlink(missing_ok=True)
async def sync_radar_files(product: str = RADAR_PRODUCT, limit: int = 6) -> dict:
if not _FILENAME_RE.fullmatch(product):
raise ValueError("Invalid product")
latest = await fetch_radar_index(product=product, limit=limit)
target_dir = RAW_RADAR_DIR / "composite" / product
target_dir.mkdir(parents=True, exist_ok=True)
downloaded: list[str] = []
skipped: list[str] = []
client = get_client()
for item in latest["files"]:
out = target_dir / item["name"]
if out.exists() and out.stat().st_size > 0:
skipped.append(item["name"])
continue
response = await client.get(item["url"], timeout=90.0)
response.raise_for_status()
out.write_bytes(response.content)
downloaded.append(item["name"])
modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc)
await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending")
cleanup_old_radar_files()
return {
"product": product,
"target_dir": str(target_dir),
"downloaded": downloaded,
"skipped": skipped,
}

View File

@@ -86,11 +86,11 @@ async def fetch_all_warnings() -> list[dict[str, Any]]:
client = get_client()
response = await client.get(DWD_WARNINGS_URL, timeout=10.0)
response.raise_for_status()
text = response.text
if text.startswith("warnWetter.loadWarnings("):
text = text[text.index("(") + 1 : text.rindex(")")]
payload = json.loads(text)
response.raise_for_status()
text = response.text
if text.startswith("warnWetter.loadWarnings("):
text = text[text.index("(") + 1 : text.rindex(")")]
payload = json.loads(text)
warnings = _flatten_warnings(payload)
await cache_set(cache_key, warnings, CACHE_TTL_WARNINGS)

View File

@@ -4,6 +4,7 @@ import re
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree as ET
from app.cache import cache_get, cache_set
from app.http_client import get_client
from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL
@@ -59,10 +60,10 @@ async def fetch_wms_time_steps(layer: str, minutes: int = 120) -> dict:
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
client = get_client()
response = await client.get(DWD_WMS_URL, params=params, timeout=15.0)
response.raise_for_status()
dimension = _find_layer_dimension(response.text, layer)
if not dimension:
raise ValueError(f"No TIME dimension found for layer {layer}")
response.raise_for_status()
dimension = _find_layer_dimension(response.text, layer)
if not dimension:
raise ValueError(f"No TIME dimension found for layer {layer}")
if "/" in dimension:
start, end, step_minutes = _parse_time_dimension(dimension)