From 0b1d4ec3b8caba68c76d097eccf534a4a0d85b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 20:49:06 +0000 Subject: [PATCH] Webhook-Events & Health-Endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notifier: controllerUnreachable, loginNewIp, updateAvailable - Login von neuer IP -> Webhook (Abgleich gegen audit_log) - cron_sync meldet nicht erreichbare Controller; cron_cleanup prüft täglich optional auf Updates und meldet Verfügbarkeit - health.php: Status-Endpoint (DB; optional Controller per ?deep=1&token=) --- cron_cleanup.php | 19 ++++++++++++++ cron_sync.php | 4 +++ health.php | 59 +++++++++++++++++++++++++++++++++++++++++++ includes/Auth.php | 16 ++++++++++++ includes/Notifier.php | 18 +++++++++++++ 5 files changed, 116 insertions(+) create mode 100644 health.php diff --git a/cron_cleanup.php b/cron_cleanup.php index 7e958a4..69c098a 100644 --- a/cron_cleanup.php +++ b/cron_cleanup.php @@ -23,6 +23,7 @@ if (!$isCli) { require_once __DIR__ . '/config.php'; require_once __DIR__ . '/includes/Database.php'; +require_once __DIR__ . '/includes/Notifier.php'; function out($data, $isCli) { if ($isCli) { @@ -101,6 +102,24 @@ try { $deleted['reset_tokens'] = $stmt->rowCount(); } catch (Exception $e) { /* Tabelle evtl. nicht vorhanden */ } + // Optionaler täglicher Update-Check mit Webhook-Hinweis (Updater isoliert, + // daher nur falls vorhanden und nur einmal pro Tag). + $bootstrap = __DIR__ . '/updater/bootstrap.php'; + if (is_file($bootstrap)) { + $lastCheck = $db->getSetting('last_update_check', ''); + if (!$lastCheck || strtotime($lastCheck) < strtotime('-23 hours')) { + try { + require_once $bootstrap; + $mgr = \Updater\UpdaterFactory::create($db, null); + $res = $mgr->checkForUpdates(); + if (!empty($res['has_update'])) { + Notifier::updateAvailable($res['latest_sha'] ?? ''); + } + } catch (\Throwable $e) { /* Update-Check nie blockierend */ } + $db->setSetting('last_update_check', date('Y-m-d H:i:s')); + } + } + $db->query( "INSERT INTO settings (setting_key, setting_value) VALUES ('last_cleanup', NOW()) ON DUPLICATE KEY UPDATE setting_value = NOW()" diff --git a/cron_sync.php b/cron_sync.php index bd9ddc5..f018b53 100644 --- a/cron_sync.php +++ b/cron_sync.php @@ -90,6 +90,7 @@ set_exception_handler(function($e) use ($isCli) { require_once __DIR__ . '/config.php'; require_once __DIR__ . '/includes/Database.php'; require_once __DIR__ . '/includes/UniFiController.php'; +require_once __DIR__ . '/includes/Notifier.php'; $db = Database::getInstance(); @@ -194,6 +195,9 @@ try { $siteResult['error'] = $e->getMessage(); $totalStats['sites_failed']++; logMessage(" FEHLER - " . $e->getMessage(), $isCli); + if (class_exists('Notifier')) { + Notifier::controllerUnreachable($site['name'], $e->getMessage()); + } } $results[] = $siteResult; diff --git a/health.php b/health.php new file mode 100644 index 0000000..d419479 --- /dev/null +++ b/health.php @@ -0,0 +1,59 @@ + 'ok', 'time' => date('c'), 'checks' => []]; +$httpStatus = 200; + +// DB +try { + $db = Database::getInstance(); + $db->fetchOne("SELECT 1 AS ok"); + $result['checks']['database'] = 'ok'; +} catch (Throwable $e) { + $result['checks']['database'] = 'fail'; + $result['status'] = 'fail'; + $httpStatus = 503; + echo json_encode($result); + http_response_code($httpStatus); + exit; +} + +$result['checks']['active_sites'] = (int)($db->fetchOne("SELECT COUNT(*) c FROM sites WHERE is_active=1")['c'] ?? 0); + +// Tiefer Check (Controller) nur mit gültigem Cron-Token +if (isset($_GET['deep']) && $_GET['deep'] == '1') { + $token = $db->getSetting('cron_token', ''); + if ($token === '' || !hash_equals($token, (string)($_GET['token'] ?? ''))) { + http_response_code(403); + echo json_encode(['status' => 'forbidden', 'message' => 'deep check erfordert gültigen token']); + exit; + } + require_once __DIR__ . '/includes/UniFiController.php'; + $controllers = []; + foreach ($db->fetchAll("SELECT * FROM sites WHERE is_active=1") as $site) { + $r = UniFiController::testConnection( + $site['unifi_controller_url'], $site['unifi_username'], + Crypto::decrypt($site['unifi_password']), $site['site_id'] + ); + $ok = ($r === true); + $controllers[$site['name']] = $ok ? 'ok' : 'unreachable'; + if (!$ok) { $result['status'] = 'degraded'; $httpStatus = 503; } + } + $result['checks']['controllers'] = $controllers; +} + +http_response_code($httpStatus); +echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/includes/Auth.php b/includes/Auth.php index 05371dc..22764e1 100644 --- a/includes/Auth.php +++ b/includes/Auth.php @@ -1,5 +1,6 @@ notifyIfNewIp($user, $ip); $this->setUserSession($user); $this->updateLastLogin($user['id']); $this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich'); @@ -81,6 +83,19 @@ class Auth { return false; } + /** Webhook bei Login von einer für diesen Nutzer bisher unbekannten IP. */ + private function notifyIfNewIp($user, $ip) { + try { + $seen = $this->db->fetchOne( + "SELECT 1 FROM audit_log WHERE user_id = ? AND action = 'user_login' AND ip_address = ? LIMIT 1", + [$user['id'], $ip] + ); + if (!$seen) { + Notifier::loginNewIp($user['email'], $ip); + } + } catch (\Exception $e) { /* nie blockierend */ } + } + /** Liegt ein Login vor, der noch auf den 2FA-Code wartet? */ public function isTotpPending() { return isset($_SESSION['totp_pending_user_id']) @@ -112,6 +127,7 @@ class Auth { return false; } unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']); + $this->notifyIfNewIp($user, $this->clientIp()); $this->setUserSession($user); $this->updateLastLogin($user['id']); $this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich (2FA)'); diff --git a/includes/Notifier.php b/includes/Notifier.php index f05f6cf..eee82e5 100644 --- a/includes/Notifier.php +++ b/includes/Notifier.php @@ -41,6 +41,24 @@ class Notifier { curl_close($ch); } + /** Controller nicht erreichbar (z.B. beim Sync). */ + public static function controllerUnreachable($siteName, $detail = '') { + self::send("⚠️ UniFi-Controller für \"{$siteName}\" nicht erreichbar." . ($detail ? " ({$detail})" : ''), + ['type' => 'controller_unreachable', 'site' => $siteName, 'detail' => $detail]); + } + + /** Anmeldung von einer bisher unbekannten IP. */ + public static function loginNewIp($email, $ip) { + self::send("🔐 Neue Anmeldung für {$email} von IP {$ip}.", + ['type' => 'login_new_ip', 'email' => $email, 'ip' => $ip]); + } + + /** Update verfügbar. */ + public static function updateAvailable($sha) { + self::send("⬆️ Update verfügbar (" . substr((string)$sha, 0, 7) . "). Siehe Administration → System-Update.", + ['type' => 'update_available', 'sha' => $sha]); + } + /** Bequemer Helfer für erstellte Voucher. */ public static function voucherCreated($count, $siteName, $byUser = null) { $who = $byUser ? " von {$byUser}" : '';