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:
parent
3eb59acdc5
commit
c7158acc96
2 changed files with 53 additions and 7 deletions
27
app.py
27
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 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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue