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
This commit is contained in:
sascha 2026-04-05 09:15:49 +02:00
parent 3eb59acdc5
commit c7158acc96
2 changed files with 53 additions and 7 deletions

27
app.py
View file

@ -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 flask import Flask, request, jsonify, render_template, Response
from pymongo import MongoClient, DESCENDING from pymongo import MongoClient, DESCENDING
from datetime import datetime, timedelta 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__) app = Flask(__name__)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -17,7 +18,25 @@ KUMA_URL = os.environ.get("KUMA_URL", "")
KUMA_TOKEN = os.environ.get("KUMA_TOKEN", "") KUMA_TOKEN = os.environ.get("KUMA_TOKEN", "")
STALE_HOURS = int(os.environ.get("STALE_HOURS", "26")) 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 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.hosts.create_index("name", unique=True)
db.history.create_index([("host", 1), ("timestamp", -1)]) db.history.create_index([("host", 1), ("timestamp", -1)])
db.history.create_index("timestamp", expireAfterSeconds=90 * 86400) # 90 Tage TTL 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) ────────────────────── # ── API: Push (called by borgmatic after_backup hook) ──────────────────────
@app.route("/api/push", methods=["POST", "GET"]) @app.route("/api/push", methods=["POST", "GET"])
@require_api_key
def push(): def push():
host = request.args.get("host") or request.json.get("host", "") if request.is_json else request.args.get("host") host = request.args.get("host") or request.json.get("host", "") if request.is_json else request.args.get("host")
if not host: if not host:
@ -114,6 +134,7 @@ def list_hosts():
@app.route("/api/hosts", methods=["POST"]) @app.route("/api/hosts", methods=["POST"])
@require_api_key
def add_host(): def add_host():
data = request.json data = request.json
name = data.get("name", "").strip() name = data.get("name", "").strip()
@ -129,6 +150,7 @@ def add_host():
@app.route("/api/hosts/<name>", methods=["PUT"]) @app.route("/api/hosts/<name>", methods=["PUT"])
@require_api_key
def update_host(name): def update_host(name):
data = request.json data = request.json
update = {} update = {}
@ -142,6 +164,7 @@ def update_host(name):
@app.route("/api/hosts/<name>", methods=["DELETE"]) @app.route("/api/hosts/<name>", methods=["DELETE"])
@require_api_key
def delete_host(name): def delete_host(name):
db.hosts.delete_one({"name": name}) db.hosts.delete_one({"name": name})
db.history.delete_many({"host": name}) db.history.delete_many({"host": name})
@ -330,7 +353,7 @@ def _check_stale_hosts():
@app.route("/") @app.route("/")
def index(): def index():
return render_template("index.html") return render_template("index.html", api_key_required=bool(API_KEY))
# ── Helpers ──────────────────────────────────────────────────────────────── # ── Helpers ────────────────────────────────────────────────────────────────

View file

@ -2,6 +2,7 @@
const API = ''; const API = '';
let refreshTimer; let refreshTimer;
let apiKey = localStorage.getItem('bm_api_key') || '';
// ── Init ────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -9,6 +10,28 @@ document.addEventListener('DOMContentLoaded', () => {
refreshTimer = setInterval(loadAll, 30000); 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() { async function loadAll() {
await Promise.all([loadSummary(), loadHosts()]); await Promise.all([loadSummary(), loadHosts()]);
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE'); document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE');
@ -254,16 +277,16 @@ async function saveHost(e) {
const enabled = document.getElementById('formEnabled').checked; const enabled = document.getElementById('formEnabled').checked;
if (mode === 'add') { if (mode === 'add') {
await fetch(`${API}/api/hosts`, { await apiFetch(`${API}/api/hosts`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: authHeaders(),
body: JSON.stringify({ name, kuma_push_url: kuma }) body: JSON.stringify({ name, kuma_push_url: kuma })
}); });
toast(`${name} hinzugefügt`, 'success'); toast(`${name} hinzugefügt`, 'success');
} else { } else {
await fetch(`${API}/api/hosts/${name}`, { await apiFetch(`${API}/api/hosts/${name}`, {
method: 'PUT', method: 'PUT',
headers: {'Content-Type': 'application/json'}, headers: authHeaders(),
body: JSON.stringify({ kuma_push_url: kuma, enabled }) body: JSON.stringify({ kuma_push_url: kuma, enabled })
}); });
toast(`${name} aktualisiert`, 'success'); toast(`${name} aktualisiert`, 'success');
@ -275,7 +298,7 @@ async function saveHost(e) {
async function confirmDelete(name) { async function confirmDelete(name) {
if (!confirm(`Host "${name}" und alle History wirklich löschen?`)) return; 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'); toast(`${name} gelöscht`, 'success');
closeDrawer(); closeDrawer();
loadAll(); loadAll();