From e05faaacbe3ff97353943dd442da8abd776d36e6 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 5 Jan 2026 03:32:22 +0000 Subject: [PATCH] Add web-based Uptime Kuma login with TOTP support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add login modal for username/password/TOTP authentication - Persist token to file for session persistence - Make UPTIME_KUMA_API_KEY optional (can login via web UI) - Add /api/kuma/auth, /api/kuma/login, /api/kuma/logout endpoints - Show login prompt when not authenticated 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/app.py | 46 ++++++++ backend/config.py | 3 +- backend/services/kuma_client.py | 98 +++++++++++++++- frontend/src/App.jsx | 46 +++++++- frontend/src/api/client.js | 6 + frontend/src/components/KumaLoginModal.jsx | 126 +++++++++++++++++++++ 6 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/KumaLoginModal.jsx diff --git a/backend/app.py b/backend/app.py index aebb7d4..e087d66 100644 --- a/backend/app.py +++ b/backend/app.py @@ -406,6 +406,52 @@ def create_suggested_monitors(): return jsonify({"created": created}) +# Uptime Kuma authentication endpoints +@app.route("/api/kuma/auth", methods=["GET"]) +def kuma_auth_status(): + """Check if authenticated to Uptime Kuma.""" + try: + kuma = get_kuma_client() + authenticated = kuma.is_authenticated() + config = get_config() + return jsonify({ + "authenticated": authenticated, + "url": config.uptime_kuma_url, + }) + except Exception as e: + return jsonify({"authenticated": False, "error": str(e)}) + + +@app.route("/api/kuma/login", methods=["POST"]) +def kuma_login(): + """Login to Uptime Kuma with username/password/TOTP.""" + data = request.json + username = data.get("username") + password = data.get("password") + totp = data.get("totp") # Optional + + if not username or not password: + return jsonify({"error": "username and password are required"}), 400 + + try: + kuma = get_kuma_client() + result = kuma.login(username, password, totp) + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 401 + + +@app.route("/api/kuma/logout", methods=["POST"]) +def kuma_logout(): + """Logout from Uptime Kuma.""" + try: + kuma = get_kuma_client() + kuma.logout() + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + # Test Uptime Kuma connection @app.route("/api/kuma/test", methods=["GET"]) def test_kuma_connection(): diff --git a/backend/config.py b/backend/config.py index fbb4f55..a2e9719 100644 --- a/backend/config.py +++ b/backend/config.py @@ -100,8 +100,7 @@ class Config: errors.append("SSH_PRIVATE_KEY is required") if not self.uptime_kuma_url: errors.append("UPTIME_KUMA_URL is required") - if not self.uptime_kuma_api_key: - errors.append("UPTIME_KUMA_API_KEY is required") + # UPTIME_KUMA_API_KEY is optional - can login via web UI if not self.claude_api_key: errors.append("CLAUDE_API_KEY is required") return errors diff --git a/backend/services/kuma_client.py b/backend/services/kuma_client.py index 15ed636..813dbfa 100644 --- a/backend/services/kuma_client.py +++ b/backend/services/kuma_client.py @@ -1,3 +1,5 @@ +import os +import json from typing import Optional from dataclasses import dataclass from uptime_kuma_api import UptimeKumaApi, MonitorType @@ -15,6 +17,9 @@ TYPE_MAP = { "push": MonitorType.PUSH, } +# Token storage path +TOKEN_FILE = os.environ.get("KUMA_TOKEN_FILE", "/tmp/kuma_token.json") + @dataclass class Monitor: @@ -40,21 +45,68 @@ class Monitor: self.accepted_statuscodes = ["200-299"] +def load_saved_token() -> Optional[str]: + """Load saved token from file.""" + try: + if os.path.exists(TOKEN_FILE): + with open(TOKEN_FILE, "r") as f: + data = json.load(f) + return data.get("token") + except Exception: + pass + return None + + +def save_token(token: str) -> None: + """Save token to file.""" + try: + with open(TOKEN_FILE, "w") as f: + json.dump({"token": token}, f) + except Exception as e: + print(f"Warning: Could not save token: {e}") + + +def clear_saved_token() -> None: + """Clear saved token.""" + try: + if os.path.exists(TOKEN_FILE): + os.remove(TOKEN_FILE) + except Exception: + pass + + class UptimeKumaClient: """Client for Uptime Kuma using Socket.io API.""" def __init__(self): config = get_config() self.base_url = config.uptime_kuma_url.rstrip("/") - self.api_key = config.uptime_kuma_api_key + self.api_key = config.uptime_kuma_api_key # Can be empty if using saved token self._api = None + self._token = None + + def _get_token(self) -> Optional[str]: + """Get token from config or saved file.""" + if self._token: + return self._token + if self.api_key: + return self.api_key + return load_saved_token() def _get_api(self) -> UptimeKumaApi: """Get connected API instance.""" if self._api is None: + token = self._get_token() + if not token: + raise Exception("Not authenticated. Please login to Uptime Kuma first.") + self._api = UptimeKumaApi(self.base_url) - # Login with API key as token - self._api.login_by_token(self.api_key) + try: + self._api.login_by_token(token) + except Exception as e: + self._api = None + clear_saved_token() + raise Exception(f"Authentication failed. Please login again: {e}") return self._api def _disconnect(self): @@ -66,6 +118,46 @@ class UptimeKumaClient: pass self._api = None + def login(self, username: str, password: str, totp: Optional[str] = None) -> dict: + """Login with username/password and optional TOTP.""" + self._disconnect() + try: + api = UptimeKumaApi(self.base_url) + if totp: + result = api.login(username, password, totp) + else: + result = api.login(username, password) + + token = result.get("token") + if token: + save_token(token) + self._token = token + + api.disconnect() + return {"success": True, "message": "Login successful"} + except Exception as e: + raise Exception(f"Login failed: {str(e)}") + + def is_authenticated(self) -> bool: + """Check if we have valid authentication.""" + token = self._get_token() + if not token: + return False + try: + api = UptimeKumaApi(self.base_url) + api.login_by_token(token) + api.disconnect() + return True + except Exception: + clear_saved_token() + return False + + def logout(self) -> None: + """Clear saved authentication.""" + self._disconnect() + self._token = None + clear_saved_token() + def get_monitors(self) -> list[dict]: """Get all monitors.""" try: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6bf6c33..a9a7291 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { io } from 'socket.io-client'; import Dashboard from './components/Dashboard'; import DevModeToggle from './components/DevModeToggle'; import ApprovalModal from './components/ApprovalModal'; +import KumaLoginModal from './components/KumaLoginModal'; import { api } from './api/client'; const socket = io(window.location.origin, { @@ -16,12 +17,24 @@ export default function App() { const [scanProgress, setScanProgress] = useState({}); const [scanResults, setScanResults] = useState({}); const [analysisResults, setAnalysisResults] = useState({}); + const [kumaAuth, setKumaAuth] = useState({ authenticated: false, url: '' }); + const [showKumaLogin, setShowKumaLogin] = useState(false); + + // Load initial settings and check Kuma auth + const checkKumaAuth = useCallback(async () => { + try { + const auth = await api.getKumaAuthStatus(); + setKumaAuth(auth); + } catch (e) { + setKumaAuth({ authenticated: false, url: '' }); + } + }, []); - // Load initial settings useEffect(() => { api.getSettings().then(setSettings).catch(console.error); api.getPendingApprovals().then(data => setPendingApprovals(data.approvals)).catch(console.error); - }, []); + checkKumaAuth(); + }, [checkKumaAuth]); // Socket connection useEffect(() => { @@ -138,17 +151,34 @@ export default function App() { {/* Main Content */}
{/* Config Status */} - {(!settings.has_ssh_key || !settings.has_claude_key || !settings.has_kuma_key) && ( + {(!settings.has_ssh_key || !settings.has_claude_key) && (

Configuration Required

    {!settings.has_ssh_key &&
  • • SSH_PRIVATE_KEY environment variable is not set
  • } {!settings.has_claude_key &&
  • • CLAUDE_API_KEY environment variable is not set
  • } - {!settings.has_kuma_key &&
  • • UPTIME_KUMA_API_KEY environment variable is not set
  • }
)} + {/* Uptime Kuma Auth Status */} + {!kumaAuth.authenticated && ( +
+
+

Uptime Kuma Login Required

+

+ Connect to Uptime Kuma to create monitors +

+
+ +
+ )} + )} + + {/* Kuma Login Modal */} + setShowKumaLogin(false)} + onSuccess={checkKumaAuth} + kumaUrl={kumaAuth.url || settings?.uptime_kuma_url} + /> ); } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 6d5eab3..a5e06e9 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -61,4 +61,10 @@ export const api = { // Uptime Kuma testKumaConnection: () => fetchApi('/kuma/test'), + getKumaAuthStatus: () => fetchApi('/kuma/auth'), + kumaLogin: (username, password, totp) => fetchApi('/kuma/login', { + method: 'POST', + body: JSON.stringify({ username, password, totp }), + }), + kumaLogout: () => fetchApi('/kuma/logout', { method: 'POST' }), }; diff --git a/frontend/src/components/KumaLoginModal.jsx b/frontend/src/components/KumaLoginModal.jsx new file mode 100644 index 0000000..5be69d1 --- /dev/null +++ b/frontend/src/components/KumaLoginModal.jsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { api } from '../api/client'; + +export default function KumaLoginModal({ isOpen, onClose, onSuccess, kumaUrl }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [totp, setTotp] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [needsTotp, setNeedsTotp] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + await api.kumaLogin(username, password, totp || undefined); + onSuccess?.(); + onClose(); + } catch (err) { + const errorMsg = err.message || 'Login failed'; + if (errorMsg.includes('2FA') || errorMsg.includes('token') || errorMsg.includes('TOTP')) { + setNeedsTotp(true); + setError('Please enter your 2FA code'); + } else { + setError(errorMsg); + } + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

Login to Uptime Kuma

+ +
+ +

+ Connect to {kumaUrl} +

+ +
+
+ + setUsername(e.target.value)} + className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded focus:ring-2 focus:ring-kuma-green focus:border-transparent outline-none" + required + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded focus:ring-2 focus:ring-kuma-green focus:border-transparent outline-none" + required + /> +
+ + {needsTotp && ( +
+ + setTotp(e.target.value)} + placeholder="6-digit code" + className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded focus:ring-2 focus:ring-kuma-green focus:border-transparent outline-none" + maxLength={6} + autoFocus + /> +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); +}