From c7158acc9644e7020fefd96fc927453e38310590 Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 5 Apr 2026 09:15:49 +0200 Subject: [PATCH] feat: API key authentication for write endpoints - Set API_KEY env var to enable (empty = open access) - Protects: push, add/edit/delete hosts - Read-only endpoints always open (dashboard, metrics, history) - Web UI: prompts for key on 401, stores in localStorage - Borgmatic: pass via ?api_key= query param or X-API-Key header --- app.py | 27 +++++++++++++++++++++++++-- static/js/app.js | 33 ++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/app.py b/app.py index 90dd7ce..adecd28 100644 --- a/app.py +++ b/app.py @@ -6,7 +6,8 @@ MongoDB-backed backup monitoring with Web UI, Uptime Kuma, Prometheus & Webhook from flask import Flask, request, jsonify, render_template, Response from pymongo import MongoClient, DESCENDING from datetime import datetime, timedelta -import os, time, requests, logging, threading +from functools import wraps +import os, time, requests, logging, threading, secrets as _secrets app = Flask(__name__) logging.basicConfig(level=logging.INFO) @@ -17,7 +18,25 @@ KUMA_URL = os.environ.get("KUMA_URL", "") KUMA_TOKEN = os.environ.get("KUMA_TOKEN", "") STALE_HOURS = int(os.environ.get("STALE_HOURS", "26")) +# API Key Auth – set API_KEY to enable, leave empty to disable (open access) +API_KEY = os.environ.get("API_KEY", "") + db = MongoClient(MONGO_URI).backup_monitor + + +# ── Auth Decorator ───────────────────────────────────────────────────────── + +def require_api_key(f): + """Protect write endpoints. Checks X-API-Key header or ?api_key= query param.""" + @wraps(f) + def decorated(*args, **kwargs): + if not API_KEY: + return f(*args, **kwargs) + key = request.headers.get("X-API-Key") or request.args.get("api_key") + if not key or key != API_KEY: + return jsonify({"error": "Unauthorized – invalid or missing API key"}), 401 + return f(*args, **kwargs) + return decorated db.hosts.create_index("name", unique=True) db.history.create_index([("host", 1), ("timestamp", -1)]) db.history.create_index("timestamp", expireAfterSeconds=90 * 86400) # 90 Tage TTL @@ -26,6 +45,7 @@ db.history.create_index("timestamp", expireAfterSeconds=90 * 86400) # 90 Tage T # ── API: Push (called by borgmatic after_backup hook) ────────────────────── @app.route("/api/push", methods=["POST", "GET"]) +@require_api_key def push(): host = request.args.get("host") or request.json.get("host", "") if request.is_json else request.args.get("host") if not host: @@ -114,6 +134,7 @@ def list_hosts(): @app.route("/api/hosts", methods=["POST"]) +@require_api_key def add_host(): data = request.json name = data.get("name", "").strip() @@ -129,6 +150,7 @@ def add_host(): @app.route("/api/hosts/", methods=["PUT"]) +@require_api_key def update_host(name): data = request.json update = {} @@ -142,6 +164,7 @@ def update_host(name): @app.route("/api/hosts/", methods=["DELETE"]) +@require_api_key def delete_host(name): db.hosts.delete_one({"name": name}) db.history.delete_many({"host": name}) @@ -330,7 +353,7 @@ def _check_stale_hosts(): @app.route("/") def index(): - return render_template("index.html") + return render_template("index.html", api_key_required=bool(API_KEY)) # ── Helpers ──────────────────────────────────────────────────────────────── diff --git a/static/js/app.js b/static/js/app.js index c4e6cd0..fec0435 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2,6 +2,7 @@ const API = ''; let refreshTimer; +let apiKey = localStorage.getItem('bm_api_key') || ''; // ── Init ────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { @@ -9,6 +10,28 @@ document.addEventListener('DOMContentLoaded', () => { refreshTimer = setInterval(loadAll, 30000); }); +function authHeaders() { + const h = {'Content-Type': 'application/json'}; + if (apiKey) h['X-API-Key'] = apiKey; + return h; +} + +async function apiFetch(url, opts = {}) { + if (!opts.headers) opts.headers = {}; + if (apiKey) opts.headers['X-API-Key'] = apiKey; + const r = await fetch(url, opts); + if (r.status === 401) { + const key = prompt('🔑 API-Key eingeben:'); + if (key) { + apiKey = key; + localStorage.setItem('bm_api_key', key); + opts.headers['X-API-Key'] = key; + return fetch(url, opts); + } + } + return r; +} + async function loadAll() { await Promise.all([loadSummary(), loadHosts()]); document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE'); @@ -254,16 +277,16 @@ async function saveHost(e) { const enabled = document.getElementById('formEnabled').checked; if (mode === 'add') { - await fetch(`${API}/api/hosts`, { + await apiFetch(`${API}/api/hosts`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: authHeaders(), body: JSON.stringify({ name, kuma_push_url: kuma }) }); toast(`${name} hinzugefügt`, 'success'); } else { - await fetch(`${API}/api/hosts/${name}`, { + await apiFetch(`${API}/api/hosts/${name}`, { method: 'PUT', - headers: {'Content-Type': 'application/json'}, + headers: authHeaders(), body: JSON.stringify({ kuma_push_url: kuma, enabled }) }); toast(`${name} aktualisiert`, 'success'); @@ -275,7 +298,7 @@ async function saveHost(e) { async function confirmDelete(name) { if (!confirm(`Host "${name}" und alle History wirklich löschen?`)) return; - await fetch(`${API}/api/hosts/${name}`, { method: 'DELETE' }); + await apiFetch(`${API}/api/hosts/${name}`, { method: 'DELETE', headers: authHeaders() }); toast(`${name} gelöscht`, 'success'); closeDrawer(); loadAll();