From e7a6ba0b861c27ba39e6024b4e7722343b309fe2 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sat, 24 Oct 2026 20:15:16 +0000 Subject: [PATCH] Upload via GUI (94 Dateien) --- Plugins/plugins/spamfilter-1.0.0.zip | Bin 0 -> 10973 bytes Plugins/plugins/spamfilter/plugin.php | 15 + Plugins/plugins/ueberwachung-1.1.1.zip | Bin 0 -> 23915 bytes Plugins/plugins/ueberwachung/cron.php | 24 + Plugins/plugins/ueberwachung/lib.php | 432 ++++++++++++++++++ Plugins/plugins/ueberwachung/meldung.php | 168 +++++++ .../migrations/001_ueberwachung.mysql.sql | 30 ++ .../migrations/001_ueberwachung.sqlite.sql | 30 ++ .../migrations/002_erster_fehler.mysql.sql | 1 + .../migrations/002_erster_fehler.sqlite.sql | 1 + .../migrations/003_tls_pruefung.mysql.sql | 1 + .../migrations/003_tls_pruefung.sqlite.sql | 1 + .../migrations/004_antwort.mysql.sql | 1 + .../migrations/004_antwort.sqlite.sql | 2 + .../ueberwachung/pages/ueberwachung.php | 182 ++++++++ Plugins/plugins/ueberwachung/plugin.json | 9 + Plugins/plugins/ueberwachung/plugin.php | 15 + Plugins/plugins/vertretung-1.0.0.zip | Bin 0 -> 7874 bytes Plugins/plugins/vertretung/lib.php | 219 +++++++++ .../migrations/001_abwesenheit.mysql.sql | 22 + .../migrations/001_abwesenheit.sqlite.sql | 22 + .../plugins/vertretung/pages/vertretung.php | 86 ++++ Plugins/plugins/vertretung/plugin.json | 9 + Plugins/plugins/vertretung/plugin.php | 16 + Plugins/plugins/virenscan-1.2.0.zip | Bin 0 -> 18579 bytes Plugins/plugins/virenscan/analyse.php | 125 +++++ Plugins/plugins/virenscan/lib.php | 425 +++++++++++++++++ .../migrations/001_dateien.mysql.sql | 14 + .../migrations/001_dateien.sqlite.sql | 14 + .../migrations/002_abfrage.mysql.sql | 1 + .../migrations/002_abfrage.sqlite.sql | 2 + Plugins/plugins/virenscan/pages/virenscan.php | 210 +++++++++ Plugins/plugins/virenscan/plugin.json | 9 + Plugins/plugins/virenscan/plugin.php | 16 + 34 files changed, 2102 insertions(+) create mode 100644 Plugins/plugins/spamfilter-1.0.0.zip create mode 100644 Plugins/plugins/spamfilter/plugin.php create mode 100644 Plugins/plugins/ueberwachung-1.1.1.zip create mode 100644 Plugins/plugins/ueberwachung/cron.php create mode 100644 Plugins/plugins/ueberwachung/lib.php create mode 100644 Plugins/plugins/ueberwachung/meldung.php create mode 100644 Plugins/plugins/ueberwachung/migrations/001_ueberwachung.mysql.sql create mode 100644 Plugins/plugins/ueberwachung/migrations/001_ueberwachung.sqlite.sql create mode 100644 Plugins/plugins/ueberwachung/migrations/002_erster_fehler.mysql.sql create mode 100644 Plugins/plugins/ueberwachung/migrations/002_erster_fehler.sqlite.sql create mode 100644 Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.mysql.sql create mode 100644 Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.sqlite.sql create mode 100644 Plugins/plugins/ueberwachung/migrations/004_antwort.mysql.sql create mode 100644 Plugins/plugins/ueberwachung/migrations/004_antwort.sqlite.sql create mode 100644 Plugins/plugins/ueberwachung/pages/ueberwachung.php create mode 100644 Plugins/plugins/ueberwachung/plugin.json create mode 100644 Plugins/plugins/ueberwachung/plugin.php create mode 100644 Plugins/plugins/vertretung-1.0.0.zip create mode 100644 Plugins/plugins/vertretung/lib.php create mode 100644 Plugins/plugins/vertretung/migrations/001_abwesenheit.mysql.sql create mode 100644 Plugins/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql create mode 100644 Plugins/plugins/vertretung/pages/vertretung.php create mode 100644 Plugins/plugins/vertretung/plugin.json create mode 100644 Plugins/plugins/vertretung/plugin.php create mode 100644 Plugins/plugins/virenscan-1.2.0.zip create mode 100644 Plugins/plugins/virenscan/analyse.php create mode 100644 Plugins/plugins/virenscan/lib.php create mode 100644 Plugins/plugins/virenscan/migrations/001_dateien.mysql.sql create mode 100644 Plugins/plugins/virenscan/migrations/001_dateien.sqlite.sql create mode 100644 Plugins/plugins/virenscan/migrations/002_abfrage.mysql.sql create mode 100644 Plugins/plugins/virenscan/migrations/002_abfrage.sqlite.sql create mode 100644 Plugins/plugins/virenscan/pages/virenscan.php create mode 100644 Plugins/plugins/virenscan/plugin.json create mode 100644 Plugins/plugins/virenscan/plugin.php diff --git a/Plugins/plugins/spamfilter-1.0.0.zip b/Plugins/plugins/spamfilter-1.0.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..57b68334dc4ee65c89060e0633603ef3e6f89a58 GIT binary patch literal 10973 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 literal 0 HcmV?d00001 diff --git a/Plugins/plugins/spamfilter/plugin.php b/Plugins/plugins/spamfilter/plugin.php new file mode 100644 index 00000000..e370b6fd --- /dev/null +++ b/Plugins/plugins/spamfilter/plugin.php @@ -0,0 +1,15 @@ +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 literal 0 HcmV?d00001 diff --git a/Plugins/plugins/ueberwachung/cron.php b/Plugins/plugins/ueberwachung/cron.php new file mode 100644 index 00000000..0cbd6258 --- /dev/null +++ b/Plugins/plugins/ueberwachung/cron.php @@ -0,0 +1,24 @@ +query('SELECT first_fail_at, verify_tls, last_status, last_title FROM plugin_ueberwachung_monitors LIMIT 1'); + } catch (Throwable $e) { + try { + plugin_run_migrations(get_db(), UEBERWACHUNG); + } catch (Throwable $e2) { + error_log('ueberwachung schema: ' . $e2->getMessage()); + } + } +} + +function ueberwachung_config(): array +{ + $mail = trim((string) plugin_setting(UEBERWACHUNG, 'alert_email', '')); + return [ + 'threshold' => max(1, min(10, (int) plugin_setting(UEBERWACHUNG, 'threshold', '2'))), + 'department_id' => (int) plugin_setting(UEBERWACHUNG, 'department_id', '0'), + 'priority_id' => (int) plugin_setting(UEBERWACHUNG, 'priority_id', '0'), + 'create_tickets' => plugin_setting(UEBERWACHUNG, 'create_tickets', '1') === '1', + 'auto_resolve' => plugin_setting(UEBERWACHUNG, 'auto_resolve', '1') === '1', + 'mail' => plugin_setting(UEBERWACHUNG, 'mail', '1') === '1', + 'alert_email' => $mail !== '' ? $mail : (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? '')), + ]; +} + +function ueberwachung_types(): array +{ + return ['http' => 'Webseite (HTTP/HTTPS)', 'port' => 'Port (TCP)', 'tls' => 'Zertifikat eines Dienstes (TLS-Port)']; +} + +/** Prüft die Eingaben eines Überwachungsziels. @return array{ok:bool,error?:string,data?:array} */ +function ueberwachung_validate(array $in): array +{ + $name = trim((string) ($in['name'] ?? '')); + $type = (string) ($in['type'] ?? 'http'); + $target = trim((string) ($in['target'] ?? '')); + if ($name === '' || mb_strlen($name) > 120) return ['ok' => false, 'error' => 'Bitte einen Namen angeben (höchstens 120 Zeichen).']; + if (!isset(ueberwachung_types()[$type])) return ['ok' => false, 'error' => 'Unbekannte Art der Prüfung.']; + if ($type === 'http') { + $p = parse_url($target); + if (!$p || !in_array($p['scheme'] ?? '', ['http', 'https'], true) || empty($p['host']) || preg_match('/[\s\x00-\x1F]/', $target)) return ['ok' => false, 'error' => 'Bitte eine Adresse mit http:// oder https:// angeben.']; + } else { + if (!preg_match('/^([A-Za-z0-9.-]{1,253}|\[[0-9a-fA-F:]+\]):(\d{1,5})$/', $target, $m) || (int) $m[2] < 1 || (int) $m[2] > 65535) return ['ok' => false, 'error' => 'Bitte Host und Port im Format host:port angeben (zum Beispiel mail.example.de:993).']; + } + return ['ok' => true, 'data' => [ + 'name' => $name, 'type' => $type, 'target' => mb_substr($target, 0, 500), + 'expect_text' => $type === 'http' ? mb_substr(trim((string) ($in['expect_text'] ?? '')), 0, 200) : '', + 'interval_min' => max(1, min(1440, (int) ($in['interval_min'] ?? 5))), + 'timeout_sec' => max(2, min(30, (int) ($in['timeout_sec'] ?? 8))), + 'cert_warn_days' => max(1, min(90, (int) ($in['cert_warn_days'] ?? 14))), + 'verify_tls' => array_key_exists('verify_tls', $in) ? (!empty($in['verify_tls']) ? 1 : 0) : 1, + 'is_active' => array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : 1, + ]]; +} + +function ueberwachung_save(PDO $pdo, array $in, int $id = 0): array +{ + $v = ueberwachung_validate($in); + if (!$v['ok']) return $v; + $d = $v['data']; + if ($id === 0) { + $pdo->prepare('INSERT INTO plugin_ueberwachung_monitors (name, type, target, expect_text, interval_min, timeout_sec, cert_warn_days, verify_tls, is_active, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)') + ->execute([$d['name'], $d['type'], $d['target'], $d['expect_text'], $d['interval_min'], $d['timeout_sec'], $d['cert_warn_days'], $d['verify_tls'], $d['is_active'], db_now()]); + return ['ok' => true, 'id' => (int) $pdo->lastInsertId()]; + } + $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET name=?, type=?, target=?, expect_text=?, interval_min=?, timeout_sec=?, cert_warn_days=?, verify_tls=?, is_active=? WHERE id=?') + ->execute([$d['name'], $d['type'], $d['target'], $d['expect_text'], $d['interval_min'], $d['timeout_sec'], $d['cert_warn_days'], $d['verify_tls'], $d['is_active'], $id]); + return ['ok' => true, 'id' => $id]; +} + +function ueberwachung_delete(PDO $pdo, int $id): void +{ + $pdo->prepare('DELETE FROM plugin_ueberwachung_events WHERE monitor_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM plugin_ueberwachung_monitors WHERE id = ?')->execute([$id]); +} + +// ── Prüfung ──────────────────────────────────────────────────────────────── + +/** Eine Prüfung ausführen. @return array{ok:bool,error:?string,ms:int,cert_expires:?int} (für Tests über $GLOBALS['ueberwachung_probe'] austauschbar) */ +function ueberwachung_probe(array $m): array +{ + if (isset($GLOBALS['ueberwachung_probe']) && is_callable($GLOBALS['ueberwachung_probe'])) return ($GLOBALS['ueberwachung_probe'])($m); + $timeout = max(2, (int) $m['timeout_sec']); + $start = microtime(true); + $ms = fn() => (int) round((microtime(true) - $start) * 1000); + $verifyTls = !isset($m['verify_tls']) || (int) $m['verify_tls'] === 1; + $ssl = ['capture_peer_cert' => true, 'verify_peer' => $verifyTls, 'verify_peer_name' => $verifyTls]; + $certInfo = []; + $certTs = function ($ctx) use (&$certInfo): ?int { + $params = stream_context_get_params($ctx); + $cert = $params['options']['ssl']['peer_certificate'] ?? null; + if (!$cert) return null; + $info = @openssl_x509_parse($cert); + if (!$info) return null; + $certInfo = ['issuer' => (string) ($info['issuer']['O'] ?? $info['issuer']['CN'] ?? ''), 'cert_subject' => (string) ($info['subject']['CN'] ?? '')]; + return (int) $info['validTo_time_t']; + }; + if ($m['type'] === 'http' && function_exists('curl_init') && empty($GLOBALS['ueberwachung_no_curl'])) return ueberwachung_probe_curl($m, $timeout); + if ($m['type'] === 'http') { + $ctx = stream_context_create(['http' => ['method' => 'GET', 'timeout' => $timeout, 'ignore_errors' => true, 'follow_location' => 1, 'max_redirects' => 5, 'header' => "User-Agent: DeskHub-Ueberwachung\r\nAccept: */*\r\n"], 'ssl' => $ssl]); + $old = ini_set('default_socket_timeout', (string) $timeout); + $warnings = []; + set_error_handler(function (int $no, string $str) use (&$warnings): bool { $warnings[] = $str; return true; }); + try { + $fh = fopen((string) $m['target'], 'r', false, $ctx); + } finally { + restore_error_handler(); + } + if ($old !== false) ini_set('default_socket_timeout', (string) $old); + if (!$fh) { + return ['ok' => false, 'error' => ueberwachung_describe_error(implode(' | ', $warnings)), 'ms' => $ms(), 'cert_expires' => null]; + } + stream_set_timeout($fh, $timeout); + $body = (string) stream_get_contents($fh, 65536); + $meta = stream_get_meta_data($fh); + fclose($fh); + $status = 0; + foreach ((array) ($meta['wrapper_data'] ?? []) as $h) if (is_string($h) && preg_match('#^HTTP/\S+\s+(\d+)#', $h, $mm)) $status = (int) $mm[1]; + $cert = $certTs($ctx); + $extra = $certInfo + ['status' => $status, 'title' => ueberwachung_page_title($body)]; + if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP-Status ' . ($status ?: 'unbekannt'), 'ms' => $ms(), 'cert_expires' => $cert] + $extra; + if ($m['expect_text'] !== '' && !str_contains($body, (string) $m['expect_text'])) return ['ok' => false, 'error' => 'Der erwartete Text fehlt auf der Seite.', 'ms' => $ms(), 'cert_expires' => $cert] + $extra; + if ($m['expect_text'] === '' && ($why = ueberwachung_default_page($extra['title'], $body))) return ['ok' => false, 'error' => $why, 'ms' => $ms(), 'cert_expires' => $cert] + $extra; + return ['ok' => true, 'error' => null, 'ms' => $ms(), 'cert_expires' => $cert] + $extra; + } + [$host, $port] = [preg_replace('/:\d+$/', '', (string) $m['target']), (int) substr((string) strrchr((string) $m['target'], ':'), 1)]; + $isTls = $m['type'] === 'tls'; + $ctx = stream_context_create(['ssl' => $ssl + ['peer_name' => trim($host, '[]')]]); + $fh = @stream_socket_client(($isTls ? 'ssl://' : 'tcp://') . $host . ':' . $port, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx); + if (!$fh) return ['ok' => false, 'error' => ueberwachung_describe_error((string) $errstr ?: 'connection refused'), 'ms' => $ms(), 'cert_expires' => null]; + $cert = $isTls ? $certTs($ctx) : null; + fclose($fh); + return ['ok' => true, 'error' => null, 'ms' => $ms(), 'cert_expires' => $cert] + $certInfo; +} + +/** Kennt dieses PHP keine vertrauenswürdigen Zertifizierungsstellen? Dann scheitert jede HTTPS-Prüfung mit „Zertifikat ungültig“, auch bei gesunden Seiten. */ +function ueberwachung_ca_bundle_missing(): bool +{ + // Mit open_basedir (bei Plesk üblich) lassen sich die Dateien nicht prüfen: dann nichts behaupten + if ((string) ini_get('open_basedir') !== '') return false; + foreach ([(string) ini_get('curl.cainfo'), (string) ini_get('openssl.cafile'), '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem'] as $file) { + if ($file !== '' && is_file($file)) return false; + } + $loc = function_exists('openssl_get_cert_locations') ? openssl_get_cert_locations() : []; + if (!empty($loc['default_cert_file']) && is_file($loc['default_cert_file'])) return false; + $dir = (string) ($loc['default_cert_dir'] ?? ''); + return !($dir !== '' && is_dir($dir) && (glob($dir . '/*') ?: [])); +} + +/** Prüfung einer Webseite mit cURL: genaue Fehlertexte, HTTP-Status und Zertifikatsdaten. */ +function ueberwachung_probe_curl(array $m, int $timeout): array +{ + $verify = !isset($m['verify_tls']) || (int) $m['verify_tls'] === 1; + $body = ''; + $ch = curl_init((string) $m['target']); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => min($timeout, 10), + CURLOPT_USERAGENT => 'DeskHub-Ueberwachung', CURLOPT_HTTPHEADER => ['Accept: */*'], + CURLOPT_SSL_VERIFYPEER => $verify, CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, CURLOPT_CERTINFO => true, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + // nur die ersten 64 KB lesen (reicht für den erwarteten Text) + CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int { + $body .= $data; + return strlen($body) > 65536 ? 0 : strlen($data); + }, + ]); + $start = microtime(true); + $ok = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $ms = (int) round((microtime(true) - $start) * 1000); + $cert = null; + $extra = []; + $info = curl_getinfo($ch, CURLINFO_CERTINFO); + if (is_array($info) && !empty($info[0]['Expire date'])) { + $ts = strtotime((string) $info[0]['Expire date']); + if ($ts) $cert = $ts; + $issuer = (string) ($info[0]['Issuer'] ?? ''); + $subject = (string) ($info[0]['Subject'] ?? ''); + $extra = ['issuer' => preg_match('/O\s*=\s*([^,\/]+)/', $issuer, $x) ? trim($x[1]) : (preg_match('/CN\s*=\s*([^,\/]+)/', $issuer, $x) ? trim($x[1]) : ''), 'cert_subject' => preg_match('/CN\s*=\s*([^,\/]+)/', $subject, $x) ? trim($x[1]) : '']; + } + curl_close($ch); + $extra += ['status' => $status]; + // Zertifikatsfehler: ohne Prüfung nachsehen, ob die Seite antwortet und was für ein Zertifikat sie hat + if ($ok === false && $verify && preg_match('/certificate|ssl|tls/i', $error)) { + $r2 = ueberwachung_probe_curl(array_merge($m, ['verify_tls' => 0]), $timeout); + $msg = ueberwachung_describe_error($error); + if (!empty($r2['cert_expires']) && $r2['cert_expires'] < time()) $msg = 'Zertifikat/TLS-Fehler (Zertifikat abgelaufen): Die gesicherte Verbindung kam nicht zustande'; + if (ueberwachung_ca_bundle_missing()) $msg .= ' – möglicherweise fehlt auf diesem Server die Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle). Das Ziel kann in Ordnung sein: Zertifikatsprüfung für dieses Ziel abschalten oder curl.cainfo/openssl.cafile in der PHP-Konfiguration setzen'; + elseif (!empty($r2['status'])) $msg .= ' (die Seite antwortet ohne Prüfung mit HTTP ' . $r2['status'] . ')'; + return ['ok' => false, 'error' => $msg, 'ms' => $ms, 'cert_expires' => $r2['cert_expires'] ?? null] + array_intersect_key($r2, ['issuer' => 1, 'cert_subject' => 1, 'status' => 1]); + } + // Abbruch durch unsere 64-KB-Grenze (Fehler 23) ist kein Fehler + if ($ok === false && !($errno === 23 && strlen($body) > 65536 && $status > 0)) { + return ['ok' => false, 'error' => ueberwachung_describe_error($error !== '' ? $error : 'curl error ' . $errno), 'ms' => $ms, 'cert_expires' => $cert] + $extra; + } + $extra['title'] = ueberwachung_page_title($body); + if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP-Status ' . ($status ?: 'unbekannt'), 'ms' => $ms, 'cert_expires' => $cert] + $extra; + if ($m['expect_text'] !== '' && !str_contains($body, (string) $m['expect_text'])) return ['ok' => false, 'error' => 'Der erwartete Text fehlt auf der Seite.', 'ms' => $ms, 'cert_expires' => $cert] + $extra; + if ($m['expect_text'] === '' && ($why = ueberwachung_default_page($extra['title'], $body))) return ['ok' => false, 'error' => $why, 'ms' => $ms, 'cert_expires' => $cert] + $extra; + return ['ok' => true, 'error' => null, 'ms' => $ms, 'cert_expires' => $cert] + $extra; +} + +function ueberwachung_event(PDO $pdo, int $monitorId, string $kind, string $message): void +{ + $pdo->prepare('INSERT INTO plugin_ueberwachung_events (monitor_id, kind, message, created_at) VALUES (?,?,?,?)')->execute([$monitorId, $kind, mb_substr($message, 0, 400), db_now()]); +} + +/** Mail an den Administrator (für Tests über $GLOBALS['ueberwachung_mail'] austauschbar). */ +function ueberwachung_alert(string $subject, string $html): void +{ + $cfg = ueberwachung_config(); + if (!$cfg['mail']) return; + foreach (preg_split('/[,;\s]+/', $cfg['alert_email'], -1, PREG_SPLIT_NO_EMPTY) ?: [] as $to) { + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) continue; + try { + if (isset($GLOBALS['ueberwachung_mail']) && is_callable($GLOBALS['ueberwachung_mail'])) ($GLOBALS['ueberwachung_mail'])($to, $subject, $html); + else send_mail($to, $subject, $html); + } catch (Throwable $e) { + error_log('ueberwachung mail: ' . $e->getMessage()); + } + } +} + +function ueberwachung_duration(int $seconds): string +{ + if ($seconds < 90) return $seconds . ' Sekunden'; + if ($seconds < 5400) return round($seconds / 60) . ' Minuten'; + if ($seconds < 172800) return round($seconds / 3600, 1) . ' Stunden'; + return round($seconds / 86400, 1) . ' Tage'; +} + +/** + * Legt ein Ticket ohne Ticket-Regeln, Absender-Sperre und Antwort-Mail an. Das ist der Rückfall, wenn das normale Anlegen scheitert: + * Eine Störungsmeldung darf nicht daran hängen, dass eine Regel („Spam ablehnen“) oder die Sperrliste die eigene Adresse trifft. + */ +function ueberwachung_create_direct(PDO $pdo, string $email, string $subject, string $message, ?int $deptId, ?int $prioId): int +{ + $deptId = active_department_id($pdo, $deptId ?: (int) get_setting('default_department_id', '1')); + if (!$deptId) throw new RuntimeException('Es ist keine Abteilung eingerichtet.'); + if (!$prioId) $prioId = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn(); + $p = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); + $p->execute([$prioId]); + $priority = $p->fetch(); + if (!$priority) throw new RuntimeException('Die Priorität gibt es nicht.'); + $sla = ['first_response_due_at' => null, 'resolution_due_at' => null]; + try { $sla = calc_sla_dates($priority, null, $deptId, null); } catch (Throwable $e) { /* ohne Fristen */ } + $number = generate_ticket_number($pdo); + $now = db_now(); + $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)') + ->execute([$number, mb_substr($subject, 0, 250), 'Überwachung', $email, $deptId, $prioId, 'open', 'api', '0.0.0.0', $sla['first_response_due_at'] ?? null, $sla['resolution_due_at'] ?? null, $now, $now]); + $id = (int) $pdo->lastInsertId(); + $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', ?, 0, ?)")->execute([$id, $message, $now]); + log_activity($pdo, $id, null, 'Ticket von der Überwachung angelegt'); + apply_tag_to_ticket($pdo, $id, 'Überwachung'); + $d = $pdo->prepare('SELECT name FROM departments WHERE id = ?'); + $d->execute([$deptId]); + try { + notify_staff_new_ticket(['ticket_number' => $number, 'subject' => $subject, 'requester_name' => 'Überwachung', 'requester_email' => $email, 'department_id' => $deptId, 'department_name' => $d->fetchColumn() ?: null]); + } catch (Throwable $e) { /* Benachrichtigung ist nachrangig */ } + try { webhook_dispatch($pdo, 'ticket.created', $id); } catch (Throwable $e) { /* dito */ } + return $id; +} + +/** Legt ein Ticket an (erst auf dem normalen Weg, sonst direkt). Merkt sich den letzten Fehler für die Seite. @return int|null Ticket-ID */ +function ueberwachung_open_ticket(PDO $pdo, string $subject, string $message): ?int +{ + $cfg = ueberwachung_config(); + if (!$cfg['create_tickets']) return null; + $first = preg_split('/[,;\s]+/', $cfg['alert_email'], -1, PREG_SPLIT_NO_EMPTY)[0] ?? ''; + $email = filter_var($first, FILTER_VALIDATE_EMAIL) ? $first : 'ueberwachung@system.invalid'; + $why = ''; + try { + $r = public_ticket_create($pdo, [ + 'name' => 'Überwachung', 'email' => $email, 'subject' => mb_substr($subject, 0, 250), 'message' => $message, + 'departmentId' => $cfg['department_id'] ?: null, 'priorityId' => $cfg['priority_id'] ?: null, + ], '0.0.0.0', ['activity' => 'Ticket von der Überwachung angelegt', 'tag' => 'Überwachung']); + plugin_set_setting(UEBERWACHUNG, 'ticket_error', ''); + return (int) $r['id']; + } catch (Throwable $e) { + $why = $e->getMessage(); + error_log('ueberwachung ticket: ' . $why); + } + // Ist das Ticket trotz Fehler (zum Beispiel bei der Benachrichtigung) schon angelegt worden, nicht doppelt anlegen + $dup = $pdo->prepare("SELECT id FROM tickets WHERE subject = ? AND requester_name = 'Überwachung' AND created_at >= ? ORDER BY id DESC LIMIT 1"); + $dup->execute([mb_substr($subject, 0, 250), date('Y-m-d H:i:s', time() - 120)]); + if ($id = $dup->fetchColumn()) { plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': ' . $why . ' (Ticket wurde trotzdem angelegt)'); return (int) $id; } + try { + $id = ueberwachung_create_direct($pdo, $email, $subject, $message, $cfg['department_id'] ?: null, $cfg['priority_id'] ?: null); + plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': Das Ticket musste am normalen Weg vorbei angelegt werden, weil dort abgelehnt wurde: ' . $why); + return $id; + } catch (Throwable $e) { + error_log('ueberwachung ticket (direkt): ' . $e->getMessage()); + plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': Ticket konnte nicht angelegt werden: ' . $why . ' / ' . $e->getMessage()); + return null; + } +} +/** Setzt das Ticket auf „Gelöst“ und vermerkt die Entwarnung (nur wenn es noch offen ist und die Einstellung es erlaubt). */ +function ueberwachung_resolve_ticket(PDO $pdo, ?int $ticketId, string $note): void +{ + if (!$ticketId) return; + $s = $pdo->prepare('SELECT status FROM tickets WHERE id = ?'); + $s->execute([$ticketId]); + $status = $s->fetchColumn(); + if ($status === false) return; + $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticketId, $note, db_now()]); + log_activity($pdo, $ticketId, null, 'Überwachung: ' . $note); + if (!ueberwachung_config()['auto_resolve'] || in_array($status, ['resolved', 'closed'], true)) return; + $now = db_now(); + $pdo->prepare("UPDATE tickets SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]); + webhook_dispatch($pdo, 'ticket.status_changed', $ticketId, ['previousStatus' => (string) $status]); +} + +/** Wertet ein Prüfergebnis aus: Zustand, Ticket, Mail, Entwarnung. */ +function ueberwachung_apply(PDO $pdo, array $m, array $res): void +{ + $cfg = ueberwachung_config(); + $now = db_now(); + $id = (int) $m['id']; + $certStr = $res['cert_expires'] ? date('Y-m-d H:i:s', (int) $res['cert_expires']) : ($m['cert_expires'] ?? null); + $upd = $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ?, last_ms = ?, cert_expires = ?, last_status = ?, last_title = ? WHERE id = ?'); + $upd->execute([$now, $res['ms'], $certStr, !empty($res['status']) ? (int) $res['status'] : null, isset($res['title']) && $res['title'] !== '' ? mb_substr((string) $res['title'], 0, 150) : null, $id]); + + if ($res['ok']) { + $wasDown = $m['state'] === 'down'; + $pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'up', fails = 0, last_ok_at = ?, last_error = NULL, down_since = NULL, first_fail_at = NULL, ticket_id = ? WHERE id = ?")->execute([$now, $wasDown ? null : $m['ticket_id'], $id]); + if ($wasDown) { + $dur = $m['down_since'] ? ueberwachung_duration(max(0, time() - (int) strtotime((string) $m['down_since']))) : 'unbekannt'; + ueberwachung_event($pdo, $id, 'up', 'Wieder erreichbar nach ' . $dur . '.'); + ueberwachung_resolve_ticket($pdo, $m['ticket_id'] ? (int) $m['ticket_id'] : null, ueberwachung_up_note($m, $dur)); + ueberwachung_alert(ueberwachung_subject('Entwarnung', $m) . ' ist wieder erreichbar', '

' . e($m['name']) . ' ist wieder erreichbar.

' . ueberwachung_table([['Dienst', (string) $m['name']], ['Adresse', (string) $m['target']], ['Ausfall', ueberwachung_dt((string) ($m['first_fail_at'] ?? $m['down_since'] ?? '')) . ' bis ' . date('d.m.Y H:i:s')], ['Dauer', $dur], ['Antwortzeit jetzt', (int) $res['ms'] . ' ms']])); + } + } else { + $fails = (int) $m['fails'] + 1; + $firstFail = ($fails === 1 || empty($m['first_fail_at'])) ? $now : (string) $m['first_fail_at']; + $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET fails = ?, last_error = ?, first_fail_at = ? WHERE id = ?')->execute([$fails, mb_substr((string) $res['error'], 0, 300), $firstFail, $id]); + if ($fails >= $cfg['threshold'] && $m['state'] !== 'down') { + $body = ueberwachung_down_body($m, $res, $fails, $firstFail); + $ticket = ueberwachung_open_ticket($pdo, ueberwachung_subject('Ausfall', $m), $body); + $pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'down', down_since = ?, ticket_id = ? WHERE id = ?")->execute([$firstFail, $ticket, $id]); + ueberwachung_event($pdo, $id, 'down', 'Ausfall: ' . $res['error'] . ($ticket ? '' : ($cfg['create_tickets'] ? ' (Ticket konnte nicht angelegt werden, siehe Hinweis oben)' : ' (Tickets sind ausgeschaltet)'))); + ueberwachung_alert(ueberwachung_subject('Ausfall', $m), $body); + } elseif ($fails < $cfg['threshold'] && $m['state'] === 'unknown') { + $pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'up' WHERE id = ? AND state = 'unknown'")->execute([$id]); + } + } + + // Zertifikat + if ($res['cert_expires']) { + $days = (int) floor(((int) $res['cert_expires'] - time()) / 86400); + $warn = (int) $m['cert_warn_days']; + if ($days <= $warn && !$m['cert_ticket_id']) { + $text = $days < 0 ? 'Das Zertifikat von ' . $m['name'] . ' ist seit ' . abs($days) . ' Tagen abgelaufen.' : 'Das Zertifikat von ' . $m['name'] . ' läuft in ' . $days . ' Tagen ab (' . date('d.m.Y', (int) $res['cert_expires']) . ').'; + $certBody = ueberwachung_cert_body($m, $res, $days); + $ticket = ueberwachung_open_ticket($pdo, ueberwachung_subject('Zertifikat', $m), $certBody); + $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET cert_ticket_id = ? WHERE id = ?')->execute([$ticket ?: -1, $id]); + ueberwachung_event($pdo, $id, 'cert', $text); + ueberwachung_alert(ueberwachung_subject($days < 0 ? 'Zertifikat abgelaufen' : 'Zertifikat läuft bald ab', $m), $certBody); + } elseif ($days > $warn && $m['cert_ticket_id']) { + $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET cert_ticket_id = NULL WHERE id = ?')->execute([$id]); + ueberwachung_event($pdo, $id, 'cert_ok', 'Zertifikat erneuert, gültig bis ' . date('d.m.Y', (int) $res['cert_expires']) . '.'); + ueberwachung_resolve_ticket($pdo, (int) $m['cert_ticket_id'] > 0 ? (int) $m['cert_ticket_id'] : null, 'Zertifikat erneuert (' . $m['name'] . '), gültig bis ' . date('d.m.Y', (int) $res['cert_expires']) . '.'); + ueberwachung_alert('Zertifikat erneuert: ' . $m['name'], '

Das Zertifikat von ' . e($m['name']) . ' wurde erneuert und ist gültig bis ' . e(date('d.m.Y', (int) $res['cert_expires'])) . '.

'); + } + } +} + +/** Prüft ein Ziel jetzt. */ +function ueberwachung_check(PDO $pdo, int $id): bool +{ + $s = $pdo->prepare('SELECT * FROM plugin_ueberwachung_monitors WHERE id = ?'); + $s->execute([$id]); + $m = $s->fetch(); + if (!$m) return false; + try { + $res = ueberwachung_probe($m); + } catch (Throwable $e) { + $res = ['ok' => false, 'error' => 'Prüfung fehlgeschlagen: ' . mb_substr($e->getMessage(), 0, 150), 'ms' => 0, 'cert_expires' => null]; + } + ueberwachung_apply($pdo, $m, $res); + return true; +} + +/** Wartezeit bis zur nächsten Prüfung in Sekunden: der eingestellte Abstand, nach einem ersten Fehlschlag (Ausfall noch nicht gemeldet) nur eine Minute. */ +function ueberwachung_next_wait(array $m): int +{ + if ((int) ($m['fails'] ?? 0) > 0 && ($m['state'] ?? '') !== 'down') return 45; + return max(45, (int) $m['interval_min'] * 60 - 15); +} + +/** Hintergrundlauf: fällige Ziele prüfen (höchstens 6 pro Lauf, damit der Lauf kurz bleibt). */ +function ueberwachung_tick(PDO $pdo): int +{ + plugin_set_setting(UEBERWACHUNG, 'last_tick', (string) time()); + $due = []; + foreach ($pdo->query('SELECT id, interval_min, last_check_at, fails, state FROM plugin_ueberwachung_monitors WHERE is_active = 1 ORDER BY last_check_at')->fetchAll() as $m) { + if ($m['last_check_at'] === null || time() - (int) strtotime((string) $m['last_check_at']) >= ueberwachung_next_wait($m)) $due[] = (int) $m['id']; + if (count($due) >= 6) break; + } + foreach ($due as $id) ueberwachung_check($pdo, $id); + if ($due && time() - (int) plugin_setting(UEBERWACHUNG, 'last_prune', '0') > 86400) { + plugin_set_setting(UEBERWACHUNG, 'last_prune', (string) time()); + $pdo->prepare('DELETE FROM plugin_ueberwachung_events WHERE created_at < ?')->execute([date('Y-m-d H:i:s', time() - 90 * 86400)]); + } + return count($due); +} + +plugin_add_hook('plugin.uninstall.' . UEBERWACHUNG, function () { + try { + get_db()->exec('DROP TABLE IF EXISTS plugin_ueberwachung_events'); + get_db()->exec('DROP TABLE IF EXISTS plugin_ueberwachung_monitors'); + } catch (Throwable $e) { + } +}); diff --git a/Plugins/plugins/ueberwachung/meldung.php b/Plugins/plugins/ueberwachung/meldung.php new file mode 100644 index 00000000..ed140d8b --- /dev/null +++ b/Plugins/plugins/ueberwachung/meldung.php @@ -0,0 +1,168 @@ + $rows */ +function ueberwachung_table(array $rows): string +{ + $html = ''; + foreach ($rows as [$k, $v]) $html .= ''; + return $html . '
' . e($k) . '' . e($v) . '
'; +} + +function ueberwachung_dt(?string $dt): string +{ + return $dt ? date('d.m.Y H:i:s', (int) strtotime($dt)) : '–'; +} + +/** Zeilen zu einem Zertifikat. @return list */ +function ueberwachung_cert_rows(array $m, array $res, ?int $days = null): array +{ + if (empty($res['cert_expires'])) return []; + $days ??= (int) floor(((int) $res['cert_expires'] - time()) / 86400); + $rows = [['Zertifikat gültig bis', date('d.m.Y H:i', (int) $res['cert_expires']) . ($days < 0 ? ' (seit ' . abs($days) . ' Tagen abgelaufen)' : ' (noch ' . $days . ' Tage)')]]; + if (!empty($res['issuer'])) $rows[] = ['Aussteller', (string) $res['issuer']]; + if (!empty($res['cert_subject'])) $rows[] = ['Ausgestellt für', (string) $res['cert_subject']]; + return $rows; +} + +/** Hinweise zur Fehlersuche je nach Art und Fehler. @return list */ +function ueberwachung_hints(array $m, string $error): array +{ + $e = strtolower($error); + if (str_starts_with($error, 'Zertifikat')) return ['Zertifikat prüfen: abgelaufen, falscher Name oder fehlende Zwischenzertifikate?', 'Zertifikat erneuern (zum Beispiel Let’s Encrypt in Plesk) und die Seite im Browser öffnen.']; + if (preg_match('/http-status 5\d\d/', $e)) return ['Der Server antwortet, aber die Anwendung meldet einen Fehler: Fehlerprotokoll des Webservers und der Anwendung ansehen.', 'Datenbank, Speicherplatz und PHP-Prozess prüfen.', 'Kürzlich eingespielte Änderungen oder Updates zurücknehmen.']; + if (preg_match('/http-status 4\d\d/', $e)) return ['Die Adresse antwortet mit einem Fehler (zum Beispiel 404 oder 403): Adresse, Weiterleitung und Zugriffsschutz prüfen.']; + if (str_contains($e, 'standard- oder fehlerseite')) return ['Der Server liefert nur seine Standard- oder Fehlerseite aus: Ist der Eintrag im Reverse-Proxy (zum Beispiel Nginx Proxy Manager) aktiviert, und läuft der Dienst dahinter?', 'Adresse und Port des Ziels im Proxy prüfen und den Dienst dahinter ansehen.']; + if (str_contains($e, 'text fehlt')) return ['Die Seite antwortet, enthält aber nicht den erwarteten Text: Wartungsseite, Fehlerseite oder veränderter Inhalt?']; + if (str_contains($e, 'dns')) return ['DNS-Eintrag der Adresse prüfen (Domain abgelaufen? Eintrag geändert?).']; + $hints = ['Läuft der Server und der Dienst? Neustart des Webservers bzw. Dienstes versuchen.', 'Firewall und Port-Freigaben prüfen.']; + if ($m['type'] === 'http') $hints[] = 'Die Seite einmal im Browser und von einem anderen Netz aus öffnen, um ein lokales Problem auszuschließen.'; + return $hints; +} + +function ueberwachung_hint_list(array $hints): string +{ + return '

Mögliche Ursachen und nächste Schritte

    ' . implode('', array_map(fn($h) => '
  • ' . e($h) . '
  • ', $hints)) . '
'; +} + +/** Text des Ausfall-Tickets. */ +function ueberwachung_down_body(array $m, array $res, int $fails, ?string $firstFail): string +{ + $rows = [ + ['Dienst', (string) $m['name']], + ['Art der Prüfung', ueberwachung_type_label((string) $m['type'])], + ['Adresse', (string) $m['target']], + ['Zustand', 'Ausfall'], + ['Fehler', (string) $res['error']], + ]; + if (!empty($res['status'])) $rows[] = ['HTTP-Status', (string) $res['status']]; + $rows[] = ['Seit', ueberwachung_dt($firstFail) . ($firstFail ? ' (vor ' . ueberwachung_duration(max(0, time() - (int) strtotime($firstFail))) . ')' : '')]; + $rows[] = ['Fehlgeschlagene Prüfungen in Folge', $fails . ' (Prüfung alle ' . (int) $m['interval_min'] . ' Minuten, Ausfall ab ' . ueberwachung_config()['threshold'] . ' in Folge)']; + $rows[] = ['Letzter erfolgreicher Abruf', $m['last_ok_at'] ? ueberwachung_dt((string) $m['last_ok_at']) : 'noch nie']; + $rows[] = ['Dauer der letzten Prüfung', (int) $res['ms'] . ' ms (Zeitlimit ' . (int) $m['timeout_sec'] . ' Sekunden)']; + if (!empty($m['expect_text'])) $rows[] = ['Erwarteter Text', (string) $m['expect_text']]; + foreach (ueberwachung_cert_rows($m, $res) as $r) $rows[] = $r; + $rows[] = ['Geprüft von', (string) (gethostname() ?: 'DeskHub-Server') . ', ' . date('d.m.Y H:i:s')]; + return '

' . e($m['name']) . ' ist nicht erreichbar bzw. fehlerhaft.

' . ueberwachung_table($rows) + . ueberwachung_hint_list(ueberwachung_hints($m, (string) $res['error'])) + . '

Diese Meldung stammt von der automatischen Überwachung. Sobald der Dienst wieder erreichbar ist, wird das Ticket automatisch gelöst.

'; +} + +/** Text des Zertifikat-Tickets. */ +function ueberwachung_cert_body(array $m, array $res, int $days): string +{ + $headline = $days < 0 ? 'Das Zertifikat ist seit ' . abs($days) . ' Tagen abgelaufen.' : 'Das Zertifikat läuft in ' . $days . ' Tagen ab.'; + $rows = array_merge([ + ['Dienst', (string) $m['name']], + ['Art der Prüfung', ueberwachung_type_label((string) $m['type'])], + ['Adresse', (string) $m['target']], + ], ueberwachung_cert_rows($m, $res, $days), [ + ['Warnung ab', (int) $m['cert_warn_days'] . ' Tagen vor Ablauf'], + ['Geprüft von', (string) (gethostname() ?: 'DeskHub-Server') . ', ' . date('d.m.Y H:i:s')], + ]); + return '

' . e($headline) . '

' . ueberwachung_table($rows) + . ueberwachung_hint_list(['Zertifikat erneuern (zum Beispiel Let’s Encrypt in Plesk oder beim Anbieter).', 'Nach der Erneuerung die Seite im Browser öffnen und die Kette prüfen.', 'Bei automatischer Erneuerung: prüfen, warum sie nicht gelaufen ist.']) + . '

Nach der Erneuerung wird dieses Ticket automatisch gelöst.

'; +} + +/** Interne Notiz bei Entwarnung (Ausfall). */ +function ueberwachung_up_note(array $m, string $dur): string +{ + $since = $m['first_fail_at'] ?? $m['down_since'] ?? null; + return 'Wieder erreichbar (' . $m['name'] . '). Der Ausfall dauerte ' . $dur . '.' + . ($since ? ' Von ' . ueberwachung_dt((string) $since) . ' bis ' . date('d.m.Y H:i:s') . '.' : ''); +} + +/** Titel der Seite (für Anzeige und die Erkennung von Standard- und Fehlerseiten). */ +function ueberwachung_page_title(string $body): string +{ + if (!preg_match('/]*>(.*?)<\/title>/is', $body, $m)) return ''; + return mb_substr(trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 0, 150); +} + +/** + * Antwortet ein Server mit 200, liefert aber nur seine Standard- oder Fehlerseite (zum Beispiel ein abgeschalteter Eintrag im Proxy), ist das + * Ziel trotzdem ausgefallen. Erkannt wird das am Seitentitel. Mit „erwartetem Text“ am Ziel entfällt diese Prüfung. @return string|null Fehlertext + */ +function ueberwachung_default_page(string $title, string $body = ''): ?string +{ + // Titel bekannter Standard- und Fehlerseiten (Nginx Proxy Manager: „Default Site“) + if ($title !== '' && preg_match('/^default site$|congratulations|welcome to nginx|nginx.*default|default (web )?(server )?page|test page for|apache2? .*default page|^it works!?$|^index of \/|iis windows server|^welcome to openresty|bad gateway|service (temporarily )?unavailable|gateway time-?out|^50[234]\b|^404\b|not found$/i', $title)) { + return 'Der Server antwortet mit einer Standard- oder Fehlerseite statt mit der Anwendung (Seitentitel: „' . mb_substr($title, 0, 80) . '“)'; + } + // Typische Sätze der Standardseiten, auch wenn der Titel nichtssagend ist + if ($body !== '' && preg_match('/successfully started the nginx proxy manager|trying to access a host that isn.?t set up yet|if you see this page, the nginx web server is successfully installed|apache2 (ubuntu|debian) default page|this is the default welcome page|web server\'s default page|domain (is )?(not configured|parked)/i', $body, $m)) { + return 'Der Server antwortet mit einer Standardseite statt mit der Anwendung (Text auf der Seite: „' . mb_substr($m[0], 0, 80) . '“)'; + } + return null; +} +/** + * Soll die Warnung „PHP kennt keine vertrauenswürdigen Zertifizierungsstellen“ erscheinen? Nur wenn das System es annimmt UND es auch so aussieht: + * Mindestens ein HTTPS-Ziel mit Zertifikatsprüfung scheitert an der Zertifikatsprüfung und keines wurde erfolgreich geprüft. Wird irgendeine + * HTTPS-Seite mit Prüfung erreicht, funktioniert die Prüfung, egal was die Dateisuche meint. + * @param list> $monitors + */ +function ueberwachung_ca_warning(array $monitors, bool $systemSaysMissing): bool +{ + if (!$systemSaysMissing) return false; + $bad = 0; + $good = 0; + foreach ($monitors as $m) { + if (($m['type'] ?? '') !== 'http' || !str_starts_with((string) $m['target'], 'https') || (int) ($m['verify_tls'] ?? 1) !== 1 || empty($m['last_check_at'])) continue; + if (str_starts_with((string) ($m['last_error'] ?? ''), 'Zertifikat/TLS-Fehler')) $bad++; else $good++; + } + return $bad > 0 && $good === 0; +} \ No newline at end of file diff --git a/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.mysql.sql b/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.mysql.sql new file mode 100644 index 00000000..a0bbf171 --- /dev/null +++ b/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.mysql.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS plugin_ueberwachung_monitors ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + type VARCHAR(8) NOT NULL DEFAULT 'http', + target VARCHAR(500) NOT NULL, + expect_text VARCHAR(200) NOT NULL DEFAULT '', + interval_min INT NOT NULL DEFAULT 5, + timeout_sec INT NOT NULL DEFAULT 8, + cert_warn_days INT NOT NULL DEFAULT 14, + is_active TINYINT(1) NOT NULL DEFAULT 1, + state VARCHAR(8) NOT NULL DEFAULT 'unknown', + fails INT NOT NULL DEFAULT 0, + last_check_at DATETIME NULL, + last_ok_at DATETIME NULL, + last_error VARCHAR(300) NULL, + last_ms INT NULL, + cert_expires DATETIME NULL, + down_since DATETIME NULL, + ticket_id INT NULL, + cert_ticket_id INT NULL, + created_at DATETIME NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS plugin_ueberwachung_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + monitor_id INT NOT NULL, + kind VARCHAR(12) NOT NULL, + message VARCHAR(400) NOT NULL, + created_at DATETIME NOT NULL, + KEY idx_plugin_ueberwachung_events (monitor_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.sqlite.sql b/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.sqlite.sql new file mode 100644 index 00000000..7f5b662e --- /dev/null +++ b/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.sqlite.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS plugin_ueberwachung_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'http', + target TEXT NOT NULL, + expect_text TEXT NOT NULL DEFAULT '', + interval_min INTEGER NOT NULL DEFAULT 5, + timeout_sec INTEGER NOT NULL DEFAULT 8, + cert_warn_days INTEGER NOT NULL DEFAULT 14, + is_active INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL DEFAULT 'unknown', + fails INTEGER NOT NULL DEFAULT 0, + last_check_at TEXT NULL, + last_ok_at TEXT NULL, + last_error TEXT NULL, + last_ms INTEGER NULL, + cert_expires TEXT NULL, + down_since TEXT NULL, + ticket_id INTEGER NULL, + cert_ticket_id INTEGER NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS plugin_ueberwachung_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_id INTEGER NOT NULL, + kind TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_ueberwachung_events ON plugin_ueberwachung_events (monitor_id, id) diff --git a/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.mysql.sql b/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.mysql.sql new file mode 100644 index 00000000..e76a4560 --- /dev/null +++ b/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.mysql.sql @@ -0,0 +1 @@ +ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN first_fail_at DATETIME NULL \ No newline at end of file diff --git a/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.sqlite.sql b/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.sqlite.sql new file mode 100644 index 00000000..49c6d6dd --- /dev/null +++ b/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.sqlite.sql @@ -0,0 +1 @@ +ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN first_fail_at TEXT NULL \ No newline at end of file diff --git a/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.mysql.sql b/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.mysql.sql new file mode 100644 index 00000000..7b74da29 --- /dev/null +++ b/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.mysql.sql @@ -0,0 +1 @@ +ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN verify_tls TINYINT(1) NOT NULL DEFAULT 1 \ No newline at end of file diff --git a/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.sqlite.sql b/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.sqlite.sql new file mode 100644 index 00000000..eab03931 --- /dev/null +++ b/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.sqlite.sql @@ -0,0 +1 @@ +ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN verify_tls INTEGER NOT NULL DEFAULT 1 \ No newline at end of file diff --git a/Plugins/plugins/ueberwachung/migrations/004_antwort.mysql.sql b/Plugins/plugins/ueberwachung/migrations/004_antwort.mysql.sql new file mode 100644 index 00000000..8e5ac369 --- /dev/null +++ b/Plugins/plugins/ueberwachung/migrations/004_antwort.mysql.sql @@ -0,0 +1 @@ +ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN last_status INT NULL, ADD COLUMN last_title VARCHAR(150) NULL \ No newline at end of file diff --git a/Plugins/plugins/ueberwachung/migrations/004_antwort.sqlite.sql b/Plugins/plugins/ueberwachung/migrations/004_antwort.sqlite.sql new file mode 100644 index 00000000..9727cb6c --- /dev/null +++ b/Plugins/plugins/ueberwachung/migrations/004_antwort.sqlite.sql @@ -0,0 +1,2 @@ +ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN last_status INTEGER NULL; +ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN last_title TEXT NULL \ No newline at end of file diff --git a/Plugins/plugins/ueberwachung/pages/ueberwachung.php b/Plugins/plugins/ueberwachung/pages/ueberwachung.php new file mode 100644 index 00000000..56d9e687 --- /dev/null +++ b/Plugins/plugins/ueberwachung/pages/ueberwachung.php @@ -0,0 +1,182 @@ +Überwachung
Diese Seite ist nur für Administratoren zugänglich.
'; return; } +$message = null; +$error = null; +$edit = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + if ($action === 'save') { + $id = (int) ($_POST['id'] ?? 0); + $r = ueberwachung_save($pdo, $_POST + ['is_active' => !empty($_POST['is_active']) ? 1 : 0, 'verify_tls' => !empty($_POST['verify_tls']) ? 1 : 0], $id); + if ($r['ok']) { $message = $id ? 'Gespeichert.' : 'Das Ziel wird jetzt überwacht.'; if (!$id) ueberwachung_check($pdo, $r['id']); } + else { $error = $r['error']; $edit = $_POST; } + } elseif ($action === 'self') { + $r = ueberwachung_save($pdo, ['name' => 'Diese DeskHub-Seite', 'type' => 'http', 'target' => rtrim(base_url(), '/') . '/', 'interval_min' => 5]); + if ($r['ok']) { ueberwachung_check($pdo, $r['id']); $message = 'Diese Seite wird jetzt überwacht.'; } else $error = $r['error']; + } elseif ($action === 'test_ticket') { + $tid = ueberwachung_open_ticket($pdo, 'Test: Überwachung', 'Dies ist ein Testticket der Überwachung. Es kann gelöscht oder geschlossen werden.'); + if ($tid) { $tn = $pdo->query('SELECT ticket_number FROM tickets WHERE id = ' . (int) $tid)->fetchColumn(); $message = 'Testticket #' . $tn . ' wurde angelegt.'; } + else $error = 'Das Testticket konnte nicht angelegt werden: ' . (plugin_setting(UEBERWACHUNG, 'ticket_error', '') ?: 'Tickets sind ausgeschaltet.'); + } elseif ($action === 'check') { + $message = ueberwachung_check($pdo, (int) ($_POST['id'] ?? 0)) ? 'Geprüft.' : null; + if ($message === null) $error = 'Ziel nicht gefunden.'; + } elseif ($action === 'toggle') { + $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET is_active = 1 - is_active WHERE id = ?')->execute([(int) ($_POST['id'] ?? 0)]); + $message = 'Gespeichert.'; + } elseif ($action === 'delete') { + ueberwachung_delete($pdo, (int) ($_POST['id'] ?? 0)); + $message = 'Das Ziel wurde gelöscht (bereits angelegte Tickets bleiben).'; + } elseif ($action === 'settings') { + plugin_set_setting(UEBERWACHUNG, 'threshold', (string) max(1, min(10, (int) ($_POST['threshold'] ?? 2)))); + plugin_set_setting(UEBERWACHUNG, 'department_id', (string) (int) ($_POST['department_id'] ?? 0)); + plugin_set_setting(UEBERWACHUNG, 'priority_id', (string) (int) ($_POST['priority_id'] ?? 0)); + foreach (['create_tickets', 'auto_resolve', 'mail'] as $k) plugin_set_setting(UEBERWACHUNG, $k, !empty($_POST[$k]) ? '1' : '0'); + $mailTo = trim((string) ($_POST['alert_email'] ?? '')); + $ok = true; + foreach (preg_split('/[,;\s]+/', $mailTo, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $a) if (!filter_var($a, FILTER_VALIDATE_EMAIL)) { $ok = false; $error = 'Ungültige Adresse: ' . $a; } + if ($ok) { plugin_set_setting(UEBERWACHUNG, 'alert_email', $mailTo); $message = 'Einstellungen gespeichert.'; } + } +} +if (isset($_GET['edit'])) { + $s = $pdo->prepare('SELECT * FROM plugin_ueberwachung_monitors WHERE id = ?'); + $s->execute([(int) $_GET['edit']]); + $edit = $s->fetch() ?: null; +} + +$cfg = ueberwachung_config(); +$monitors = $pdo->query('SELECT * FROM plugin_ueberwachung_monitors ORDER BY name')->fetchAll(); +$events = $pdo->query('SELECT e.*, m.name FROM plugin_ueberwachung_events e LEFT JOIN plugin_ueberwachung_monitors m ON m.id = e.monitor_id ORDER BY e.id DESC LIMIT 40')->fetchAll(); +$depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll(); +$prios = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll(); +$types = ueberwachung_types(); +$down = count(array_filter($monitors, fn($m) => $m['state'] === 'down')); +$badge = ['up' => ['In Ordnung', '#157a45', '#e6f7ee'], 'down' => ['Ausfall', '#b42318', '#fde8e8'], 'unknown' => ['Noch nicht geprüft', '#5b6b77', '#eef1f4']]; +$f = fn(string $k, $d = '') => $edit[$k] ?? $d; +?> +

Website- und Server-Überwachung

+
+
+ + +
Achtung: Die Zertifikatsprüfung scheitert bei allen HTTPS-Zielen, und auf diesem Server findet PHP keine Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle). Vermutlich meldet die Prüfung deshalb bei jeder HTTPS-Adresse „Zertifikat ungültig“, auch wenn die Seite in Ordnung ist. Stellen Sie in der PHP-Konfiguration curl.cainfo und openssl.cafile auf eine CA-Datei (zum Beispiel /etc/ssl/certs/ca-certificates.crt) oder schalten Sie bei den Zielen „Zertifikat prüfen“ aus.
+ + + +
+ Hinweis zum Anlegen von Tickets: +
+
+ +
+

Zustand

+ +

Noch nichts eingetragen. Unten ein Ziel hinzufügen oder gleich diese Seite überwachen:

+
+ +

' . $down . ' Ausfall' . ($down > 1 ? 'e' : '') . ' · ' : 'Alles in Ordnung · ' ?> Ziele, geprüft vom Hintergrunddienst von DeskHub (im jeweils eingestellten Abstand).

+ + + + + + + + + + + + + + +
NameZielZustandZertifikatZuletzt geprüft
Antwort:
+
+ 0): ?>
Fehlschlag von (Ausfall und Ticket ab Fehlschlag ):
+
nächste Prüfung ca.
abgelaufen' : ($days <= (int) $m['cert_warn_days'] ? '' . $days . ' Tage' : $days . ' Tage')) ?> +
+ + Ändern +
+
+ +
+ + 900); +?> +
+

Hintergrunddienst

+

+ Noch kein Lauf. Die Prüfungen starten, sobald DeskHub das nächste Mal aufgerufen wird. + Letzter Lauf vor . Es hat länger niemand DeskHub aufgerufen, oder der automatische Hintergrund-Betrieb ist ausgeschaltet (Einstellungen → Cron-Jobs). + ✓ Läuft. Letzter Lauf: . +

+ +

+ Echter Server-Cronjob: + bisher kein Lauf (Einstellungen → Cron-Jobs → „Neu installieren / aktualisieren“, mit den aktuellen DeskHub-Dateien; bei älteren Dateien siehe unten) + 900): ?>letzter Lauf vor (). Er sollte jede Minute laufen: Crontab und cron/run_plugins.log prüfen. + ✓ läuft (letzter Lauf ) +

+

Die Prüfungen laufen mit den Hintergrundaufgaben von DeskHub. Mit dem aktuellen DeskHub-Update gehören sie zum echten Server-Cronjob: Einstellungen → Cron-Jobs → „Neu installieren / aktualisieren“ trägt die Zeile ein (jede Minute), dann wird auch nachts ohne Besucher in dem Abstand geprüft, den Sie je Ziel einstellen (mindestens 1 Minute). Bei älteren Dateien oder ohne Server-Cronjob läuft die Prüfung nur, solange jemand die Seite oder das Backend aufruft. Dann hilft ein eigener Cronjob für dieses Plugin (zum Beispiel Plesk → Geplante Aufgaben, alle 5 Minuten):

+
php 
+

Wichtig: Fällt der Server aus, auf dem DeskHub selbst läuft, kann DeskHub das nicht melden. Die eigene Erreichbarkeit überwacht am besten ein Dienst auf einem anderen Server.

+
+
+ + + +

+
+
+
+
+ + + +

Tipp: Mit einem Text, der nur auf der echten Seite steht (zum Beispiel der Name Ihrer Anwendung), erkennt die Überwachung auch Fälle, in denen ein Proxy oder Webserver eine andere Seite mit „200 OK“ ausliefert. Ohne Text werden bekannte Standard- und Fehlerseiten am Titel erkannt.

+ +
+
+
+
+
+

Der eingestellte Abstand gilt, sobald der Hintergrunddienst von DeskHub jede Minute läuft (aktuelle Dateien: ein Cronjob mit „Neu installieren / aktualisieren“, oder bei Seitenaufrufen). Bei älteren Dateien läuft er nur alle 5 Minuten, kürzere Werte greifen dort erst mit dem eigenen Cronjob des Plugins (Karte „Hintergrunddienst“, Zeitplan * * * * *).

+ + +
Abbrechen
+
+ +
+ + +

Einstellungen

+ + + +
+
+
+
+
+ + +
+
+ +
+

Ereignisse

+

Noch keine Ereignisse.

+ + + + +
WannZielEreignis
'Ausfall', 'up' => 'Entwarnung', 'cert' => 'Zertifikat', 'cert_ok' => 'Zertifikat erneuert'][$ev['kind']] ?? $ev['kind']) ?>:
+ +
diff --git a/Plugins/plugins/ueberwachung/plugin.json b/Plugins/plugins/ueberwachung/plugin.json new file mode 100644 index 00000000..e5a13774 --- /dev/null +++ b/Plugins/plugins/ueberwachung/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "ueberwachung", + "name": "Website- und Server-Überwachung", + "version": "1.1.1", + "description": "Prüft regelmäßig Webseiten, Ports und Zertifikate. Fällt etwas aus oder läuft ein Zertifikat bald ab, legt das Plugin automatisch ein Ticket an, benachrichtigt den Administrator und gibt nach der Behebung Entwarnung (Ticket wird auf „Gelöst“ gesetzt). Nur für Administratoren.", + "author": "DeskHub", + "requires": "1.0.0", + "permissions": ["hooks.cron", "ui.menu", "db.migrations"] +} diff --git a/Plugins/plugins/ueberwachung/plugin.php b/Plugins/plugins/ueberwachung/plugin.php new file mode 100644 index 00000000..46750d0c --- /dev/null +++ b/Plugins/plugins/ueberwachung/plugin.php @@ -0,0 +1,15 @@ +bx<5z+wBLJ;K3nS&;WxIEMagN+-;CS26up@Pwq%Qg60RRC!000pH z0PwVQb$7LN_i%)9+1o&Y&eqPO28XW8eIyT6MS5kFD9&_>o*DZwjNlZ`C3SW%O_U0= zjy@iUV-VLRAvj!XaZ)MZC(@Gc$pzsJ`{M<(Phw8(OAb&_V)lm$H=a-PwYxXjwV!Lf zcs}bbF3>rh39(9P6cSz|cEpNWm5N@~(Do;{vQ7)b^c6LUrr5xza@|7B{3vp-jJEXE zT#1sO5=}~T>Jf{rTT19j8t*QK_2#dI&kcU)k5*5O1BSW;l_lZk2gYHi4N`CkKl?PW z%zzQoEuF(O0f~Vnw{ljovYWyd*jaAri#QNm84S<7G3}44IIVe+A;F(}{c z5&Z*fTQc}$Kx+x(ex|H9#K|4I(9T<4`tct9URySBVrjv9S>k~0M)BC)wv-f?RMIT} zY4K-Sml#fQCW%EoC3;)-Rpm-U?i_K}E-^55=O5jR*g3WCEoWxoowVOaG%W}OeS08& zKJI7sk9*%&Y{ong7EW)ak%}tMSAA<}gmW6(T&LSJiNCPoNm7zE*~Ra6dBCULb{X(M zO$kytM?||!y{bo7Jy@n~;1`*19mP|hPErQT4m7}$k-^eiLh}8@$+Da^v??}fFuEFI zzo$CHEr)5rV)6hLEfOxW!EKPVs09!n}UWI+!y+}(k1Y*uW9U< zdg|%u>J2iKnn{9Esf1lfX)ef6zcP|=n3h4tEVUQccYDB%WgJ>xu;snIuUQ>Ur>J)j zPa8l7EZ@>L*JqS-918XgRXI~bDC3m~w{H^hCvW_6A1Omns6unEdCe0h$?VhkeL+IP{WrBHs zU9_+U#mc?a8F+ri={-Y--{qviZ|K@LIToB*vN1#BlhCW_C=(A=5f*`XdEOqMmIu8@ zi|YK<{oPfvYoSJ8ad6R_R1Wo~VIY7MG2)FWXM<2!;29Eri)veGpeS@B z4ng;`BeOxA?dYsf1Z~*V z6L03DIbj!Xe{@g`H4VPXKsd;w06~Dg`Jyn6fMj4R@_kd18QF!!T)$9JP+h({pyk)- z!(`?Ih08DhpZEmk?xQ;;gvPNBien*L>OzXO?DJHTQfkzNiAUe=bjB+0i%%Y{;$piw6ZojE>FCjOo z>y!2(n4It|A4`ZT(BqK^esv$)7#ApB@?g9SAc067F%en6X2z!_l5g#ElcLl1v zKU)_olK-eh47+U{z~iv3K8R1!jbR66zgb{@OEqHVEfWIr1m|ieA(J~LsUe>6%co<3 z2}I|RuV#P9amR@y^%~b-lzYX+bhL0Vb zkUNJ1ZLXGFe0=4{H>&RcGE;^1Zh?Hhk)y+3h_Fk56R1N?O8!`+d z7);4>r(~Jb?Njk!!v1JVcaHy)SiS(={5ay4?lrrwOfL!Dx4-<r z$cauZSvg^^Qil&K(O2e?bZBk*Yzl~(lAGG_@_z9z?P`aY*#m7ApE?T@Bi9QRl=Ef> zTgi6n$peE$g@v=e760sPLCdj?I{@I8%_xsc2en|FjOP8c3HPhJe_DP4k~zLri+zf4 zIPvrlZWTam7&MKjJoW+47p0NND)2USyaxXc)>mPiB}qSpi?blX0DG}9v9s$&{hv^9Fa-iw+t@gh@c!V6c+sy_ZM z>Wd~~a(Nv>eJYsNi+nJ{>r`4sEyHpoaA&ABUJb~uE43V&VqdnCubuq}f05*YJT+?e ziT&uMeHCHxGfPfdGAAQxB+sK(G(BVKgxw0Vl~R!Uw+66ghm5HWEU=GGu<>)vh4$f; zRLaXKUMw46K9bJc<(l)$5JC}*>?!l$30Km77LC&`9T7ps^{6|+E{iP){LnJy8O@dd`rRoK-pQ#T2j6Q1|gMDMKihr z1}k%0R#N&Or@F>6%~@YVuSI#5Ng8=)N^hSm*_}z<#f4YD54>YZDB$siF0?`k-wW)% zFx%QNI~4B>92*yZ7(VS`5T&Xvs!dDSS`96k?ZO$Z#SD7k_yje{l+eeAdu%8<(@l{m zMYpSFaI6=Y&s~q@o%M{44_x&53=_&DlXwu6%%zS|2Wgf!A0@1JQg@c^!Cn!z=)K>u zy()lRdI|zPpP`ufhMZNN3#^=Ir<-dZIU{I(!i7KTMs8`EkkVhUwzAvMWQ^1@1}p5v z()US>-L)ql%KMteu#;HgW4c>A{jlZPY-O(xBzlv-2J~=H0X)bxUA&Nj=+9qsojCxb z{QI5a;z;whpeMwmtEaUzyzEsgq++p#Dk6513iw+_Q?=OFCuZ|UK2;x$NBu-rH`-+5 z!+@eno{;B^+o~)CkM5w&ZPeGkn=zyU(Pj!&E$-aqVBaY{nE)BQamEPcy`H3WTb~;0 zjj%57xcP`LR;^0kO$~Z!ZqMRtg=j{qRnF^3UoH>rV@iGJAuY)JwIOwuTUsUDUh&taw3ai<{CuN2?4cJ+Hml?&e5tb}x_QCmWmK4pKp5W5J3$#ohYlJ^B#eH>atc^*iP;O&@0`!n#&bkhad6#O+ zT%a`8nA`QDfxXB?3Y;kXMoodZ*dDi&kDXKPcvgu3NREmQq9k{a8e+6xkX)ERB5qCn z?376pN|b|wx6B|R^bvr|91BhN#$c_ahj)h2Zlx)g_)siRtjeIt^#9yZey(FAZ%8QH z-00QOwD2hnG-ul`sx$Zta&Jt%M-Kcze@wda!s^Sf60t~_+t;c@?89C6(We<+{%_>I zi!D_RZBin6%`KHh!gN_GdR)}$USIon)kRvKTJaW{5ts|hk=&O@s6~XMw(xU#<0i53 zu~Y`lN3LR{^KUGOZ}Amlm-K zWr`-PT<)}-JSfUX%Z4fzb#eyRcV?80NuI}D3q}2dn2HuKd={(b!A6H#etLZR12Z?HKa+ z?kh06z9NECc8P6OKXGPOYq*pZtRHRuYZD^NsZ_V30u3_;yJf&Rn?tObR0Nw-Yzs#r zggzv`U$sH-<%UVo=?;~h;8}3M4A^&V0|l234la)+{3fpUKUozbrw37@K&{|K0sw$0 z007rNv#Ns)%+<`@#>vr*i<_Ir#0=_X>1OF@ZE52UbntO=u?POXQZ$sELO=v<=j=Po zj7GM$Qy)&NyO$iJ7uKg{rV(#@;}4*w+&?I?C%0g!0i$Kl{ z{k#oYTa2zU?cjV_LWGfS4*aA=MHm*YmB0a-2TYNt4F_NOv!&VjxJ#5Bmi3eN7=C#I z$s{T1q*IUvSNMHy-{J>RkfuSSYRG!&>bc_B^`E8Fh8SsD0uk>4f?*Cf; z-@@CtTmGegcH*RC2sbg}*eBX;SIIjR!cz|B<9L#aT9pBPLq822>auqdm%!Q6+4NjK z9=~2b=2w#M#5!4LVgSACgyJz4*tH|J+80w~v+88ZKJkdspBrRWRBjBSu#%|t4YDh& zzsf74TkND-1jV1F)o|0ZcQ*x&&9c6cjG1yk0~ZvBDjS z@i@C?(;WQ(f6~@05g~r`-f!)XxbWZKHb62YdKm)%s3!#gX#Xj$vl-0NjqA_G%wL-_ zy?V=zRB}Y{U0p5o6&wor56QQRt)K>S=_n52d}?z}a_OXLDM9{ezWx142Q$-0dfNDR z_Xzk<=7U9tMOllVBe5mBtmk}?l8UN_wg+UaWXCkCa!RKIUlYh55d=Ho(=~9(9=2?_ z=N)<{Y4yqg=Qz-0EPW}|vrwPhQ9=|q(YTDWCX|kp2r===3Fx09y%P9ji{~nU4Dp5j zD#qgOUPp9Czb}=#*OEr2sWU}U>w1BR{vchM1tzXK2E3~sRIOs3K3{w@c>Sv=qIjCs z&qw5yTyLNg(!=LX#jwU&nZj-ruQiVPMaef|Mz-Vfm)(s|!c}#K^j?it4t9gk>I%ZvU-+cCyoAwxCW%DE<` zP4ZDC^_TlxGppa^3Em8I){>J~t7hpyKVSG9b2(6%Bh%G??-t;{&yV z44hwUdK8_sQp|g`yxc2dw$cIuRe}eqpF95Qj$HfBrQgzc%fy_T(@;h7dMc3lY4Tjn z#R|Y8dc2*_!!pwJg7Ot(B23V!%X^}c3!UQamE`9SWVMOA2d9KB+&{BFATA+rHMqbc zilx{>wUS%+7bKir!L^nUp`hm($6F8ix{7!;_fSq<&aKv!ms}M@^V;>~+7H{I5qN89l8l_~@jZxx%8KLCouW;@52FI8W?Z=-t{M zXYB73Hp(M_36>)VYLX+{+5+F2hRQzoA4=t^AbT$Fdn%7=A$_&KNbi)Do9F^|qqjcB zGB+P#tgKxfzKPZ@7|*DI5q4E#Dh8dZp| zyCy%p2f7rWZmS*>=qpiFPBKVsiHH#%Oe494{O} zY)&nD4r#R%;!xVsshN0>Miq`d6QDC|ea7qdJuX2~R5IVv!uyjPB$cP|Kd z6o9KnVyQ0Ztww%6<~ur)B^x7kDX>n`bA@rf`U>NUpms|OQ)b;Zm{A|uYy%H3LF*R-Dogyd>Ac&q|D4#Ek@RwwMi zrf3cIhY`J(Hv(amfejoq0C@1un%SH)zcI=d(Zpj}yr??(^}Bq@#d!;AvUmJLfvDRV zhexul_jDr~vN?EekNEDldTQ*K+7Rf`exZ8qX0z!T+CRSFnW`SdOh2DX z^tb7HfeZlrwg!Li&p6wAz-%0Wwr)<25cN+gJ={2}$L!@}+V17{UdT~sQ5&X0J@JB$ zcJ(fJ{Tqw~fqi{%6Y5yBR!PM>c3WYA4uDdjkL@kt2PiUV0xl-AXt2Xi>*KtOGax7i zmu@mg6me6v}U4|bQ@YX~2Kt|(T|K zn7DCWerWI7D&hV1*uheB5W_2_v)k!7^h+P#hrD4Z{_qr3tBeiV$2)Oivd&-7&Eg01 zxM(4t1_z;{(|j^L==AcjGepFdj;X zzpj8$kj`1QYs!H4wpLChPtNbZq1~F`TIPR53_q3Hn9SdD7yg!;bsu_h5el&l!`^Td64@B&%d@(h;w4JaMo=ZWe%_O~SCOZNu zNy+>Dn|70LvxleLR++dfZZIfr0*AjeUJQMrAJ+Q`jP{48WkA^K;GFUqxPs|MYbW5!$uja34@a zbxE%b|BJJh&lePJYBsLC*cnu=m!-(LvdneyD2?8Fkun?WTMtQ+*lAU;VAIhJR zE4u-H3ge&8lqP^c>{IU|)0vOuB~3b&oRc_!#t$B^S>_9IYnwYqt%0ki-=|XJoFv*h z18~UdKak;yQfL#~sm17|PK3eqm{?Wwf*uWqq*A@xzo7nT!$%x0ipDrZ06UY( zvE?Jq4DRWeR+hddaRez7NTL=VIxq4SB;nK?CFBq3!m*Z2W}7juP}IOk$l@sfdwcWu zEB(JmL-1e2A5Z=No1K53|8J4=|IG#f8iLDzH%K{|W!kK=lXCPVnEt|HP}m z)Bi0|{Gmq@{)7H+`Qq=ee_MwBz;Y@63H!4s{hjfbmj78>e;D{w|Hk-TX=-3J^uHcM P|9y`CF3VNwzwZ7I>)kuA literal 0 HcmV?d00001 diff --git a/Plugins/plugins/vertretung/lib.php b/Plugins/plugins/vertretung/lib.php new file mode 100644 index 00000000..75b6f124 --- /dev/null +++ b/Plugins/plugins/vertretung/lib.php @@ -0,0 +1,219 @@ + 'Geplant', 'active' => 'Läuft', 'done' => 'Beendet', 'cancelled' => 'Abgebrochen', 'missed' => 'Nicht angetreten']; +} + +function vertretung_staff(PDO $pdo, int $id): ?array +{ + $s = $pdo->prepare('SELECT id, name, email, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?'); + $s->execute([$id]); + return $s->fetch() ?: null; +} + +function vertretung_name(PDO $pdo, int $id): string +{ + return (string) (vertretung_staff($pdo, $id)['name'] ?? ('#' . $id)); +} + +function vertretung_can_manage(array $actor, int $staffId): bool +{ + return ($actor['role'] ?? '') === 'admin' || (int) ($actor['id'] ?? 0) === $staffId; +} + +function vertretung_mail(PDO $pdo, int $staffId, string $subject, string $html): void +{ + $st = vertretung_staff($pdo, $staffId); + if (!$st || !(int) $st['is_active'] || trim((string) $st['email']) === '') return; + try { + send_mail((string) $st['email'], $subject, $html); + } catch (Throwable $e) { + error_log('vertretung mail: ' . $e->getMessage()); + } +} + +/** + * Wer arbeitet an Stelle von $staffId? Folgt Vertretungsketten (höchstens drei Stufen, ohne Schleifen). null, wenn die Person + * anwesend ist oder keine nutzbare Vertretung hat. + */ +function vertretung_target(PDO $pdo, int $staffId): ?int +{ + $row = vertretung_staff($pdo, $staffId); + if (!$row || !(int) $row['is_on_vacation']) return null; + $seen = [$staffId]; + for ($i = 0; $i < 3; $i++) { + $sub = (int) ($row['vacation_substitute_id'] ?? 0); + if ($sub <= 0 || in_array($sub, $seen, true)) return null; + $row = vertretung_staff($pdo, $sub); + if (!$row || !(int) $row['is_active']) return null; + if (!(int) $row['is_on_vacation']) return $sub; + $seen[] = $sub; + } + return null; +} + +/** Gibt ein Ticket an $to weiter und merkt es für die Rückgabe. */ +function vertretung_move(PDO $pdo, int $ticketId, int $from, int $to, int $absenceId): void +{ + $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$to, $ticketId]); + log_activity($pdo, $ticketId, null, 'An die Vertretung ' . vertretung_name($pdo, $to) . ' weitergegeben (' . vertretung_name($pdo, $from) . ' ist abwesend)'); + $pdo->prepare('INSERT INTO plugin_vertretung_moves (absence_id, ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?,?)') + ->execute([$absenceId, $ticketId, $from, $to, db_now()]); +} + +/** Hook: Ticket gehört einer abwesenden Person → an die Vertretung. */ +function vertretung_redirect(PDO $pdo, int $ticketId): void +{ + $t = $pdo->prepare('SELECT assigned_staff_id, status, is_spam FROM tickets WHERE id = ?'); + $t->execute([$ticketId]); + $ticket = $t->fetch(); + if (!$ticket || !$ticket['assigned_staff_id'] || !empty($ticket['is_spam']) || in_array($ticket['status'], ['resolved', 'closed'], true)) return; + $from = (int) $ticket['assigned_staff_id']; + $to = vertretung_target($pdo, $from); + if ($to === null || $to === $from) return; + $a = $pdo->prepare("SELECT id FROM plugin_vertretung_absences WHERE staff_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1"); + $a->execute([$from]); + vertretung_move($pdo, $ticketId, $from, $to, (int) $a->fetchColumn()); +} + +/** Prüft Eingaben für eine neue Abwesenheit. Liefert eine Fehlermeldung oder null. */ +function vertretung_validate(PDO $pdo, int $staffId, int $subId, string $start, string $end): ?string +{ + $okDate = static fn(string $d) => preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $d, $m) === 1 && checkdate((int) $m[2], (int) $m[3], (int) $m[1]); + if (!$okDate($start) || !$okDate($end)) return 'Bitte Beginn und Ende als Datum angeben.'; + if ($end < $start) return 'Das Ende darf nicht vor dem Beginn liegen.'; + if ($end < vertretung_today()) return 'Der Zeitraum liegt schon in der Vergangenheit.'; + $st = vertretung_staff($pdo, $staffId); + if (!$st || !(int) $st['is_active']) return 'Dieser Mitarbeiter ist nicht aktiv.'; + $sub = vertretung_staff($pdo, $subId); + if (!$sub || !(int) $sub['is_active']) return 'Bitte eine aktive Vertretung wählen.'; + if ($subId === $staffId) return 'Die Vertretung muss eine andere Person sein.'; + $o = $pdo->prepare("SELECT COUNT(*) FROM plugin_vertretung_absences WHERE staff_id = ? AND status IN ('planned','active') AND start_date <= ? AND end_date >= ?"); + $o->execute([$staffId, $end, $start]); + if ((int) $o->fetchColumn() > 0) return 'In diesem Zeitraum ist für diese Person schon eine Abwesenheit eingetragen.'; + return null; +} + +/** @return array{ok:bool,error?:string,id?:int} */ +function vertretung_create(PDO $pdo, array $actor, int $staffId, int $subId, string $start, string $end, string $note): array +{ + if (!vertretung_can_manage($actor, $staffId)) return ['ok' => false, 'error' => 'Sie dürfen nur Ihre eigene Abwesenheit eintragen.']; + $err = vertretung_validate($pdo, $staffId, $subId, $start, $end); + if ($err !== null) return ['ok' => false, 'error' => $err]; + $pdo->prepare("INSERT INTO plugin_vertretung_absences (staff_id, substitute_id, start_date, end_date, note, status, created_by, created_at) VALUES (?,?,?,?,?,'planned',?,?)") + ->execute([$staffId, $subId, $start, $end, mb_substr(trim($note), 0, 250), (int) ($actor['id'] ?? 0) ?: null, db_now()]); + $id = (int) $pdo->lastInsertId(); + if ($start <= vertretung_today()) vertretung_tick($pdo); // beginnt sofort + return ['ok' => true, 'id' => $id]; +} + +function vertretung_cancel(PDO $pdo, array $actor, int $id): bool +{ + $s = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE id = ? AND status IN ('planned','active')"); + $s->execute([$id]); + $abs = $s->fetch(); + if (!$abs || !vertretung_can_manage($actor, (int) $abs['staff_id'])) return false; + if ($abs['status'] === 'active') vertretung_deactivate($pdo, $abs, 'cancelled'); + else $pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'cancelled' WHERE id = ?")->execute([$id]); + return true; +} + +/** Beginn: Urlaubsstatus setzen und offene Tickets an die Vertretung verteilen. Liefert die Zahl der weitergegebenen Tickets. */ +function vertretung_activate(PDO $pdo, array $abs): int +{ + $staffId = (int) $abs['staff_id']; + $pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'active' WHERE id = ?")->execute([$abs['id']]); + $pdo->prepare('UPDATE staff SET is_on_vacation = 1, vacation_substitute_id = ? WHERE id = ?')->execute([$abs['substitute_id'], $staffId]); + $to = vertretung_target($pdo, $staffId); + if ($to === null) return 0; + $t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id"); + $t->execute([$staffId]); + $tickets = $t->fetchAll(); + foreach ($tickets as $row) vertretung_move($pdo, (int) $row['id'], $staffId, $to, (int) $abs['id']); + $name = vertretung_name($pdo, $staffId); + $list = ''; + foreach (array_slice($tickets, 0, 15) as $row) $list .= '
  • #' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '
  • '; + vertretung_mail($pdo, $to, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', '

    Sie vertreten ' . e($name) . ' vom ' . e(vertretung_date((string) $abs['start_date'])) . ' bis ' . e(vertretung_date((string) $abs['end_date'])) . '.

    ' + . '

    ' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen, neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen.

    ' . ($list ? '
      ' . $list . '
    ' : '')); + return count($tickets); +} + +/** Ende oder Abbruch: Urlaubsstatus zurücknehmen und noch offene Tickets zurückgeben. Liefert die Zahl der zurückgegebenen Tickets. */ +function vertretung_deactivate(PDO $pdo, array $abs, string $newStatus): int +{ + $staffId = (int) $abs['staff_id']; + $pdo->prepare("UPDATE plugin_vertretung_absences SET status = ? WHERE id = ?")->execute([$newStatus, $abs['id']]); + // nur zurücknehmen, was dieses Plugin gesetzt hat (hat jemand die Vertretung im Profil geändert, bleibt es so) + $pdo->prepare('UPDATE staff SET is_on_vacation = 0, vacation_substitute_id = NULL WHERE id = ? AND is_on_vacation = 1 AND vacation_substitute_id = ?')->execute([$staffId, $abs['substitute_id']]); + $m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id, t.assigned_staff_id, t.status, t.ticket_number, t.subject FROM plugin_vertretung_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.absence_id = ? AND m.returned_at IS NULL ORDER BY m.id'); + $m->execute([$abs['id']]); + $back = []; + foreach ($m->fetchAll() as $row) { + $stillThere = (int) $row['assigned_staff_id'] === (int) $row['to_staff_id'] && !in_array($row['status'], ['resolved', 'closed'], true); + if ($stillThere) { + $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$staffId, $row['ticket_id']]); + log_activity($pdo, (int) $row['ticket_id'], null, 'Zurück an ' . vertretung_name($pdo, $staffId) . ' (Abwesenheit beendet)'); + $back[] = $row; + } + $pdo->prepare('UPDATE plugin_vertretung_moves SET returned_at = ? WHERE id = ?')->execute([db_now(), $row['id']]); + } + if ($back) { + $list = ''; + foreach (array_slice($back, 0, 15) as $row) $list .= '
  • #' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '
  • '; + vertretung_mail($pdo, $staffId, 'Willkommen zurück: ' . count($back) . ' Tickets', '

    Ihre Abwesenheit ist beendet. Diese Tickets sind wieder bei Ihnen:

      ' . $list . '
    '); + } + return count($back); +} + +/** Cron: abgelaufene Abwesenheiten beenden, fällige beginnen. */ +function vertretung_tick(PDO $pdo): void +{ + $today = vertretung_today(); + $q = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE status = 'active' AND end_date < ?"); + $q->execute([$today]); + foreach ($q->fetchAll() as $abs) vertretung_deactivate($pdo, $abs, 'done'); + $pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'missed' WHERE status = 'planned' AND end_date < ?")->execute([$today]); + $q = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE status = 'planned' AND start_date <= ? AND end_date >= ? ORDER BY start_date, id"); + $q->execute([$today, $today]); + foreach ($q->fetchAll() as $abs) vertretung_activate($pdo, $abs); + vertretung_rebalance($pdo); +} + +/** + * Ist die erste Vertretung zurück (oder hat sich die Kette geändert), wandern die für die Abwesende weitergegebenen Tickets, + * die noch unberührt bei der bisherigen Vertretung liegen, zur jetzt zuständigen Person. + */ +function vertretung_rebalance(PDO $pdo): void +{ + foreach ($pdo->query("SELECT id, staff_id FROM plugin_vertretung_absences WHERE status = 'active'")->fetchAll() as $abs) { + $target = vertretung_target($pdo, (int) $abs['staff_id']); + if ($target === null) continue; + $m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id FROM plugin_vertretung_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.absence_id = ? AND m.returned_at IS NULL AND m.to_staff_id <> ? AND t.assigned_staff_id = m.to_staff_id'); + $m->execute([$abs['id'], $target]); + foreach ($m->fetchAll() as $row) { + $t = $pdo->prepare('SELECT status FROM tickets WHERE id = ?'); + $t->execute([$row['ticket_id']]); + if (in_array($t->fetchColumn(), ['resolved', 'closed'], true)) continue; + $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$target, $row['ticket_id']]); + $pdo->prepare('UPDATE plugin_vertretung_moves SET to_staff_id = ? WHERE id = ?')->execute([$target, $row['id']]); + log_activity($pdo, (int) $row['ticket_id'], null, 'An die Vertretung ' . vertretung_name($pdo, $target) . ' weitergegeben (Vertretung ist zurück)'); + } + } +} + +function vertretung_date(string $ymd): string +{ + $t = strtotime($ymd . ' 12:00:00'); + return $t ? date('d.m.Y', $t) : $ymd; +} diff --git a/Plugins/plugins/vertretung/migrations/001_abwesenheit.mysql.sql b/Plugins/plugins/vertretung/migrations/001_abwesenheit.mysql.sql new file mode 100644 index 00000000..4a974c48 --- /dev/null +++ b/Plugins/plugins/vertretung/migrations/001_abwesenheit.mysql.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS plugin_vertretung_absences ( + id INT AUTO_INCREMENT PRIMARY KEY, + staff_id INT NOT NULL, + substitute_id INT NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + note VARCHAR(255) NOT NULL DEFAULT '', + status VARCHAR(12) NOT NULL DEFAULT 'planned', + created_by INT NULL, + created_at DATETIME NOT NULL, + KEY idx_plugin_vertretung_status (status, start_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS plugin_vertretung_moves ( + id INT AUTO_INCREMENT PRIMARY KEY, + absence_id INT NOT NULL, + ticket_id INT NOT NULL, + from_staff_id INT NOT NULL, + to_staff_id INT NOT NULL, + created_at DATETIME NOT NULL, + returned_at DATETIME NULL, + KEY idx_plugin_vertretung_moves_absence (absence_id, returned_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql b/Plugins/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql new file mode 100644 index 00000000..d6a172a6 --- /dev/null +++ b/Plugins/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS plugin_vertretung_absences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staff_id INTEGER NOT NULL, + substitute_id INTEGER NOT NULL, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'planned', + created_by INTEGER NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_status ON plugin_vertretung_absences (status, start_date); +CREATE TABLE IF NOT EXISTS plugin_vertretung_moves ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + absence_id INTEGER NOT NULL, + ticket_id INTEGER NOT NULL, + from_staff_id INTEGER NOT NULL, + to_staff_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + returned_at TEXT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_moves_absence ON plugin_vertretung_moves (absence_id, returned_at) diff --git a/Plugins/plugins/vertretung/pages/vertretung.php b/Plugins/plugins/vertretung/pages/vertretung.php new file mode 100644 index 00000000..829ac4fc --- /dev/null +++ b/Plugins/plugins/vertretung/pages/vertretung.php @@ -0,0 +1,86 @@ +query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); +$labels = vertretung_status_labels(); +$names = []; +foreach ($people as $p) $names[(int) $p['id']] = $p['name']; +$name = fn($id) => e($names[(int) $id] ?? vertretung_name($pdo, (int) $id)); +$current = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id AND m.returned_at IS NULL) AS holding FROM plugin_vertretung_absences a WHERE a.status IN ('planned','active') ORDER BY a.start_date, a.id")->fetchAll(); +$past = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved FROM plugin_vertretung_absences a WHERE a.status NOT IN ('planned','active') ORDER BY a.id DESC LIMIT 20")->fetchAll(); +?> +

    Abwesenheit und Vertretung

    +
    +
    + +
    + + +

    Abwesenheit eintragen

    +

    Zum Beginn gehen Ihre offenen Tickets an die Vertretung, neue Tickets ebenfalls (auch bei automatischer Zuweisung). Am Ende bekommen Sie die Tickets zurück, die dann noch bei der Vertretung liegen. Der Urlaubsstatus im Profil wird dafür automatisch gesetzt.

    +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    +

    Laufend und geplant

    +

    Keine Abwesenheiten eingetragen.

    + + + + + + + + + + + + + + +
    WerVertretungZeitraumStandTickets
    – + +
    + +
    + +
    + + +
    +

    Frühere Abwesenheiten

    + + + + + + + +
    WerVertretungZeitraumErgebnisWeitergegeben
    – Tickets
    +
    + diff --git a/Plugins/plugins/vertretung/plugin.json b/Plugins/plugins/vertretung/plugin.json new file mode 100644 index 00000000..abc968f6 --- /dev/null +++ b/Plugins/plugins/vertretung/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "vertretung", + "name": "Urlaub und Vertretung", + "version": "1.0.0", + "description": "Mitarbeiter tragen Abwesenheiten mit Vertretung ein. Zum Beginn werden ihre offenen Tickets an die Vertretung verteilt, neue Tickets gehen automatisch dorthin, und am Ende bekommt der Mitarbeiter seine noch offenen Tickets zurück. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf.", + "author": "DeskHub", + "requires": "1.0.0", + "permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"] +} diff --git a/Plugins/plugins/vertretung/plugin.php b/Plugins/plugins/vertretung/plugin.php new file mode 100644 index 00000000..0e5a4f87 --- /dev/null +++ b/Plugins/plugins/vertretung/plugin.php @@ -0,0 +1,16 @@ +L(yvFRU#)xmuSTQja zq(MMY0l)xAfU$~(E=dv0FFqImfD{h^_yqs}TrC|mJf38afczYscOe5sH&X!}7CR&y_T#VwbI&#wS;3R~QiIGi!=2CIrY~EqNmh61% zH^BrV-w%dXv)U&~G?8^^&^kXhbp`14PYS)Ox5>8C5i|v0hR1+ZX-cxmS`!n(B^{Aq zMocBzx5xtWMLCp$M{j#cnlJ#;QZwmoW(AVq2!1i3zNW$D*paY_s60fHQzX&ofg-Q6 z62lxGqoFKqsxmDKAGb=2$hY!|UCo)EH5@-Kns8J=6 z6A=Uw40Xtdr0B_;YjXNSzF%QihGXLz7?3Y~+_JK=F`J9I9xV=LoyX3)88H?bSVBpx zIRwgy9mN6BC?N_)#q0Z;|PB<_7x+-K$wJawIRkL^FqFUibaxj^ogRuB+yx+kWe|48)8-Am-$wYT$NC! zR0--3iUtI137c}aJBJjuI`_uuXpkUIgY0jw#F4#fs&4|CQw^%Tg;BFg2;C$h z&v{@EyYbpc%>UfK^IF~3?9^{WQ^0yAg(ulZokHwCrnP-RY?K8-&?AXx8G&8~0yg9? z9^|M>#%II|xUW5Fih}^fe8)0s@I$FgDC88}cf-f3(Y~&|`#n{7DQ276fmHgT-RfiS ze8}e7Lt?d^=gohL_q6i+KGsN2=6oU6v$#l*4C8vUJvicn*KgtCHR&2??z#i5WmgyP z!sW^fqPG^~qFNtGA_}|wgHv_?L1r{Lbkv*`cQ-&SYgB&o)_1h37s9z#d-qM-Q`TIl znc8n3@l|Mnq;=30iW`hq7i`=Mk~~%}Sfxug(s^tImWiL(d|#rD0}&ESqQbwBW>yne zzk_SCWh_oj4_W#t5n9lW-(!;uFH`$@|9hF3FvJixeh-OZTvWoskgbHRL5L8v*34{z z@J^7U!v2tDBklWe(dowH#CMU$`G(c!ZUDaPb^p6U7r+r?uMX4IMWyT&Ue^uy9a0FI zh;gZtt`)d7^wwAB=->zec9VuGE9^|0!5+I=w_yY55xj?<%UU+~3ir^zS?H&nK3Z5L zkwT3t`%tbfi>#EZN!UFPF@r~L8QwaUkN z+I|YA+Zql+i`m90`InG73e($+2ep-f8#qh|taCgViwSZktam9V@FpadTP_GxBt0@M zDy$bA=$DgG{(02O2JzmK>;Z12K?G}=RIcj8SKzTT3*De2X9qF9jk zmrMIQ9hIi&>|VKjj`VaHR7|_`cWCM?Aze$?$*v|aU0I5{bLhcL;yYZ+`Dax3(D>~4&ZZ zW|*;}I^j*MZO_D>vw1?bpm{bAN>PXdtkB`H=*FOP9rKGW%)#L=k82OT=_)qX8JUVN zih2tDQ5qoTRB|NUEdi13!sT-+WtRS;W{{Seg|nIfn&^y2UM~b^vko0ZkU0=qV`_3T zdO^%J&TQW16^`d=_9$-lGli5*h@UcH3SJb%!)qFc0^A;8*E9py0QfZ8hqY~Q)DMm!5$U|&W!aMT%3h644ng?;iXZCo8ESZ)%4 z$HC~*>dg1b10t`qSXkLIzFNs*#?kyo{G%t4^d!I{(Qn#(W4U9x+_^Uz8wH{Ee7J;U zkznWU$bK z%JbcAAl6kW{n=X6Y4rt-??OAlsB>;o9tQ%V`Gu3UeZt*-lpT79N|uo=J@KM?)2k8l zlrlM_*DiCJ5>=vE1e?$-P0^*&Q8BS4#Pnl5Pdq+|uT=31o#BZ#ouQ|;LYG_+MMM3C z38)m!qBO2U;i{^0`s**sL=Hda?Nq(rZG$W$HjEPlRS4~KJdlV zXbxtf89lsC7nO$Bx`%wBGZlsMR*Q0@K8d!cqnY$(@Y28K-U3r~$L1A>h%OiTYKahuv?xEXhIDiED~M@oxhEs8YX&v4U-9p(X4S9cJ z%uTrKHa{#~d+*3JcS3l*K)a5>Vv5J}RaCyw6^RTd4t>YF3?hG?O>HS`|Dxoa?62=D z#SP*&nA�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 literal 0 HcmV?d00001 diff --git a/Plugins/plugins/virenscan/analyse.php b/Plugins/plugins/virenscan/analyse.php new file mode 100644 index 00000000..d9ff70e8 --- /dev/null +++ b/Plugins/plugins/virenscan/analyse.php @@ -0,0 +1,125 @@ + */ +function virenscan_name_findings(string $name): array +{ + $out = []; + if (preg_match('/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u', $name)) $out[] = ['exec', 'Verschleierter Dateiname (Zeichen zur Umkehr der Schreibrichtung)']; + $ext = virenscan_file_ext($name); + if (in_array($ext, VIRENSCAN_BAD_EXT, true)) $out[] = ['exec', 'Gefährlicher Dateityp (.' . $ext . ')']; + elseif (preg_match('/\.(' . implode('|', array_map('preg_quote', VIRENSCAN_BAD_EXT)) . ')\s*\.[a-z0-9]{2,5}$/i', $name, $m)) $out[] = ['exec', 'Doppelte Endung (.' . strtolower($m[1]) . ' versteckt)']; + return $out; +} + +/** + * Untersucht eine Datei. @return list Funde als [Kategorie, Text]. Kategorien: exec (Programme, Skripte, Tricks), + * macro (Office-Makros), script (Skripte in HTML/SVG/RTF/PDF), archive (auffällige Archive), encrypted (passwortgeschützt), unscannable (nicht prüfbare Archive), eicar. + */ +function virenscan_analyze(string $path, string $name, int $depth = 0): array +{ + $f = []; + $size = (int) @filesize($path); + $head = (string) @file_get_contents($path, false, null, 0, 4096); + if ($head === '' && $size === 0) return $f; + $ext = virenscan_file_ext($name); + + if (str_contains((string) @file_get_contents($path, false, null, 0, 2 * 1024 * 1024), virenscan_eicar())) return [['eicar', 'Eicar-Test-Signature']]; + foreach (virenscan_name_findings($name) as $x) $f[] = $x; + if (($kind = virenscan_exec_magic($head)) !== null) $f[] = ['exec', $kind . ($ext !== '' && !in_array($ext, VIRENSCAN_BAD_EXT, true) ? ', getarnt als .' . $ext : '')]; + if (in_array($ext, VIRENSCAN_MACRO_EXT, true)) $f[] = ['macro', 'Office-Datei mit Makros (.' . $ext . ')']; + if (in_array($ext, VIRENSCAN_OTHER_ARCHIVES, true)) $f[] = ['unscannable', 'Archiv, das nicht geöffnet werden kann (.' . $ext . ')']; + + // ZIP (auch Office-Dateien): Inhalt prüfen + if (str_starts_with($head, "PK\x03\x04") && class_exists('ZipArchive')) { + $za = new ZipArchive(); + if ($za->open($path) === true) { + $n = $za->numFiles; + if ($n > 2000) $f[] = ['archive', 'Archiv mit sehr vielen Dateien (' . $n . ')']; + $total = 0; $encrypted = false; $macro = false; $bad = []; + for ($i = 0; $i < min($n, 2000); $i++) { + $st = $za->statIndex($i); + if (!$st) continue; + $total += (int) $st['size']; + $en = (string) $st['name']; + if (!empty($st['encryption_method'])) { $encrypted = true; continue; } + if (str_ends_with(strtolower($en), 'vbaproject.bin')) $macro = true; + foreach (virenscan_name_findings(basename($en)) as $x) { $bad[] = basename($en); break; } + if ($i < 300 && (int) $st['size'] > 0 && virenscan_exec_magic((string) $za->getFromIndex($i, 8)) !== null) $bad[] = basename($en); + } + if ($total > 1024 * 1024 * 1024 || ($size > 0 && $total / max(1, $size) > 2000 && $total > 100 * 1024 * 1024)) $f[] = ['archive', 'Archiv entpackt sich auf ein Vielfaches (Zip-Bombe)']; + if ($macro) $f[] = ['macro', 'Office-Datei mit Makros (VBA)']; + if ($bad) $f[] = ['exec', 'Archiv enthält ' . implode(', ', array_slice(array_values(array_unique($bad)), 0, 3))]; + if ($encrypted) $f[] = ['encrypted', 'Passwortgeschütztes Archiv (Inhalt nicht prüfbar)']; + $za->close(); + } elseif (!in_array($ext, ['docx', 'xlsx', 'pptx', 'odt', 'ods', 'jar', 'apk'], true)) { + $f[] = ['unscannable', 'Archiv lässt sich nicht öffnen']; + } + } + + // Altes Office-Format (OLE): Makros erkennen + if (str_starts_with($head, "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1")) { + $body = (string) @file_get_contents($path, false, null, 0, 4 * 1024 * 1024); + if (str_contains($body, '_VBA_PROJECT') || str_contains($body, "_\0V\0B\0A\0_\0P\0R\0O\0J\0E\0C\0T") || str_contains($body, "M\0a\0c\0r\0o\0s\0")) $f[] = ['macro', 'Office-Datei mit Makros (VBA)']; + } + + // PDF: startende Programme oder automatisches JavaScript + if (str_contains(substr($head, 0, 1024), '%PDF')) { + $body = (string) @file_get_contents($path, false, null, 0, 4 * 1024 * 1024); + if (preg_match('/\/Launch\b/', $body)) $f[] = ['script', 'PDF startet Programme (/Launch)']; + if (preg_match('/\/(JavaScript|JS)\b/', $body) && preg_match('/\/(OpenAction|AA)\b/', $body)) $f[] = ['script', 'PDF mit automatisch startendem JavaScript']; + if (preg_match('/\/EmbeddedFile\b/', $body) && preg_match('/\.(exe|scr|bat|cmd|vbs|js|jar|ps1|msi)\b/i', $body)) $f[] = ['exec', 'PDF mit eingebetteter ausführbarer Datei']; + } + + // Webseiten und Bilder mit Skriptcode + if (in_array($ext, ['html', 'htm', 'xhtml', 'shtml', 'svg', 'mht', 'mhtml', 'xml'], true)) { + $body = (string) @file_get_contents($path, false, null, 0, 1024 * 1024); + if (preg_match('/ Gründe, leer = sauber */ +function virenscan_block_reasons(array $findings, array $cfg): array +{ + $reasons = []; + foreach ($findings as [$cat, $text]) { + $on = match ($cat) { + 'exec', 'archive', 'script', 'eicar' => true, + 'macro' => $cfg['block_macros'], + 'encrypted' => $cfg['block_encrypted'], + 'unscannable' => $cfg['block_unscannable'], + default => true, + }; + if ($on) $reasons[] = $text; + } + return array_values(array_unique($reasons)); +} diff --git a/Plugins/plugins/virenscan/lib.php b/Plugins/plugins/virenscan/lib.php new file mode 100644 index 00000000..2accdebc --- /dev/null +++ b/Plugins/plugins/virenscan/lib.php @@ -0,0 +1,425 @@ +query('SELECT rep, sha256 FROM plugin_virenscan_files LIMIT 1'); + } catch (Throwable $e) { + try { + plugin_run_migrations(get_db(), VIRENSCAN); + } catch (Throwable $e2) { + error_log('virenscan schema: ' . $e2->getMessage()); + } + } +} + +function virenscan_config(): array +{ + $key = ''; + $enc = (string) plugin_setting(VIRENSCAN, 'vt_key', ''); + if ($enc !== '') { try { $key = decrypt_secret($enc); } catch (Throwable $e) { $key = ''; } } + return [ + 'analysis' => plugin_setting(VIRENSCAN, 'analysis', '1') === '1', + 'block_macros' => plugin_setting(VIRENSCAN, 'block_macros', '1') === '1', + 'block_encrypted' => plugin_setting(VIRENSCAN, 'block_encrypted', '1') === '1', + 'block_unscannable' => plugin_setting(VIRENSCAN, 'block_unscannable', '0') === '1', + 'vt_key' => $key, + 'vt_threshold' => max(1, min(30, (int) plugin_setting(VIRENSCAN, 'vt_threshold', '3'))), + ] + virenscan_config_base(); +} + +function virenscan_config_base(): array +{ + return [ + 'mode' => in_array(plugin_setting(VIRENSCAN, 'mode', 'auto'), ['auto', 'clamdscan', 'clamscan', 'builtin'], true) ? plugin_setting(VIRENSCAN, 'mode', 'auto') : 'auto', + 'binary' => trim((string) plugin_setting(VIRENSCAN, 'binary', '')), + 'timeout' => max(5, min(300, (int) plugin_setting(VIRENSCAN, 'timeout', '60'))), + 'max_mb' => max(1, min(500, (int) plugin_setting(VIRENSCAN, 'max_mb', '25'))), + 'block_on_error' => plugin_setting(VIRENSCAN, 'block_on_error', '0') === '1', + 'notify' => plugin_setting(VIRENSCAN, 'notify', '1') === '1', + ]; +} + +function virenscan_uploads_dir(): string +{ + return rtrim(str_replace('\\', '/', (string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads')), '/'); +} + +function virenscan_quarantine_dir(): string +{ + $dir = virenscan_uploads_dir() . '/quarantaene'; + if (!is_dir($dir)) { + @mkdir($dir, 0750, true); + @file_put_contents($dir . '/.htaccess', "Require all denied\nDeny from all\n"); + @file_put_contents($dir . '/index.html', ''); + } + return $dir; +} + +/** Die Standard-Testzeichenfolge (EICAR), in Teilen geschrieben, damit Virenscanner diese Quelldatei nicht selbst melden. */ +function virenscan_eicar(): string +{ + return 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$' . 'EICAR-STANDARD-ANTIVIRUS-TEST-FILE!' . '$H+H*'; +} + +/** + * Führt ein Programm mit Zeitlimit aus (ohne Shell). Die Ausgabe geht in Temp-Dateien statt in Pipes, damit das Zeitlimit auch unter Windows greift + * (dort lassen sich Pipes nicht nicht-blockierend lesen). @return array{0:int,1:string,2:string} Exit-Code, Ausgabe, Fehlerausgabe + */ +function virenscan_run(array $cmd, int $timeout): array +{ + $outFile = tempnam(sys_get_temp_dir(), 'vso'); + $errFile = tempnam(sys_get_temp_dir(), 'vse'); + $proc = @proc_open($cmd, [1 => ['file', $outFile, 'w'], 2 => ['file', $errFile, 'w']], $pipes); + if (!is_resource($proc)) { + @unlink($outFile); @unlink($errFile); + return [-1, '', 'Das Programm konnte nicht gestartet werden.']; + } + $end = microtime(true) + $timeout; + $exit = null; + $timedOut = false; + while (true) { + $status = proc_get_status($proc); + if (!$status['running']) { $exit = (int) $status['exitcode']; break; } + if (microtime(true) > $end) { proc_terminate($proc); $timedOut = true; break; } + usleep(40000); + } + proc_close($proc); + $out = (string) @file_get_contents($outFile, false, null, 0, 1048576); + $err = (string) @file_get_contents($errFile, false, null, 0, 1048576); + @unlink($outFile); @unlink($errFile); + if ($timedOut) return [-1, $out, "Zeitüberschreitung nach {$timeout} Sekunden."]; + return [$exit, $out, $err]; +} +/** Welches Prüfprogramm wird benutzt? @return array{mode:string,binary:string} mode: clamdscan, clamscan oder builtin */ +function virenscan_resolve(): array +{ + $cfg = virenscan_config(); + if ($cfg['mode'] === 'builtin') return ['mode' => 'builtin', 'binary' => '']; + if ($cfg['binary'] !== '') { + $m = $cfg['mode'] === 'auto' ? (str_contains(strtolower(basename($cfg['binary'])), 'clamdscan') ? 'clamdscan' : 'clamscan') : $cfg['mode']; + return ['mode' => $m, 'binary' => $cfg['binary']]; + } + $candidates = static fn(string $name) => ["/usr/bin/{$name}", "/usr/local/bin/{$name}", "/opt/clamav/bin/{$name}", "C:/Program Files/ClamAV/{$name}.exe"]; + foreach ($cfg['mode'] === 'auto' ? ['clamdscan', 'clamscan'] : [$cfg['mode']] as $m) { + foreach ($candidates($m) as $path) if (is_file($path) && is_executable($path)) return ['mode' => $m, 'binary' => $path]; + } + return ['mode' => 'builtin', 'binary' => '']; +} +/** Zustand des Prüfprogramms für die Einstellungsseite. @return array{mode:string,binary:string,version:?string,ok:bool,message:string} */ +function virenscan_status(): array +{ + $r = virenscan_resolve(); + if ($r['mode'] === 'builtin') { + return $r + ['version' => null, 'ok' => false, 'message' => 'ClamAV ist auf diesem Server nicht installiert. Es läuft die eingebaute Dateianalyse' . (virenscan_config()['vt_key'] !== '' ? ' mit Hash-Abfrage bei VirusTotal.' : '. Mit einem VirusTotal-Schlüssel (kostenlos) werden Dateien zusätzlich gegen bekannte Schadsoftware abgeglichen.')]; + } + [$code, $out, $err] = virenscan_run([$r['binary'], '--version'], 15); + if ($code !== 0) return $r + ['version' => null, 'ok' => false, 'message' => 'Das Prüfprogramm antwortet nicht: ' . trim($err !== '' ? $err : $out)]; + return $r + ['version' => trim(strtok($out, "\n")), 'ok' => true, 'message' => 'Bereit.']; +} + +/** + * Prüft eine Datei: zuerst ClamAV (falls auf dem Server vorhanden), dann die eingebaute Dateianalyse. + * @return array{status:string,signature:?string,message:?string} status: clean, infected oder error + */ +function virenscan_scan_file(string $path, string $name = ''): array +{ + if (!is_file($path)) return ['status' => 'error', 'signature' => null, 'message' => 'Datei nicht gefunden.']; + $cfg = virenscan_config(); + $r = virenscan_resolve(); + $clam = null; + if ($r['mode'] !== 'builtin') { + $cmd = [$r['binary'], '--no-summary']; + if ($r['mode'] === 'clamdscan') $cmd[] = '--fdpass'; + $cmd[] = $path; + [$code, $out, $err] = virenscan_run($cmd, $cfg['timeout']); + $clam = virenscan_interpret($code, $out, $err); + if ($clam['status'] === 'infected') return $clam; + } + // Eingebaute Analyse (läuft auch mit ClamAV zusätzlich: erkennt Dateitypen und Tricks, die ein Virenscanner durchlässt) + $findings = $cfg['analysis'] ? virenscan_analyze($path, $name !== '' ? $name : basename($path)) : []; + if (!$cfg['analysis']) { + $head = (string) @file_get_contents($path, false, null, 0, 2 * 1024 * 1024); + if (str_contains($head, virenscan_eicar())) $findings = [['eicar', 'Eicar-Test-Signature']]; + } + $reasons = virenscan_block_reasons($findings, $cfg); + if ($reasons) { + $sig = in_array('Eicar-Test-Signature', $reasons, true) ? 'Eicar-Test-Signature' : 'Dateianalyse: ' . implode('; ', $reasons); + return ['status' => 'infected', 'signature' => mb_substr($sig, 0, 250), 'message' => null]; + } + if ($clam !== null) return $clam; + return ['status' => 'clean', 'signature' => null, 'message' => $cfg['analysis'] ? 'Dateianalyse' : 'Nur Testprüfung (Dateianalyse ausgeschaltet)']; +} + +/** Wertet die Ausgabe von clamscan/clamdscan aus: Exit-Code 0 = sauber, 1 = Fund ("Datei: Name FOUND"), sonst Fehler. */ +function virenscan_interpret(int $code, string $out, string $err): array +{ + if ($code === 0) return ['status' => 'clean', 'signature' => null, 'message' => null]; + if ($code === 1) { + $sig = preg_match('/:\s*(.+?)\s+FOUND/i', $out, $m) ? $m[1] : 'unbekannt'; + return ['status' => 'infected', 'signature' => mb_substr($sig, 0, 250), 'message' => null]; + } + return ['status' => 'error', 'signature' => null, 'message' => mb_substr(trim($err !== '' ? $err : $out) ?: 'Prüfung fehlgeschlagen (Code ' . $code . ').', 0, 250)]; +} + +function virenscan_start_id(PDO $pdo): int +{ + $v = plugin_setting(VIRENSCAN, 'start_id', null); + if ($v === null) { + // Erster Lauf: Bestand nicht nachträglich prüfen, nur neue Anhänge (über die Seite lässt sich der Bestand nachholen) + $v = (string) (int) $pdo->query('SELECT COALESCE(MAX(id), 0) FROM ticket_attachments')->fetchColumn(); + plugin_set_setting(VIRENSCAN, 'start_id', $v); + } + return (int) $v; +} + +/** Noch ungeprüfte Anhänge (optional nur eines Tickets). */ +function virenscan_pending(PDO $pdo, int $limit = 25, ?int $ticketId = null): array +{ + $sql = "SELECT a.id, a.original_name, a.stored_name, a.filesize, t.ticket_id FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id + WHERE a.id > ? AND a.stored_name <> 'gesperrt' AND a.id NOT IN (SELECT attachment_id FROM plugin_virenscan_files)"; + $params = [virenscan_start_id($pdo)]; + if ($ticketId !== null) { $sql .= ' AND t.ticket_id = ?'; $params[] = $ticketId; } + $stmt = $pdo->prepare($sql . ' ORDER BY a.id LIMIT ' . max(1, $limit)); + $stmt->execute($params); + return $stmt->fetchAll(); +} + +function virenscan_record(PDO $pdo, array $att, string $status, ?string $signature, ?string $message, bool $quarantined = false): void +{ + $pdo->prepare('INSERT INTO plugin_virenscan_files (attachment_id, ticket_id, original_name, stored_name, status, signature, message, quarantined, scanned_at) VALUES (?,?,?,?,?,?,?,?,?)') + ->execute([(int) $att['id'], $att['ticket_id'] ?? null, mb_substr((string) $att['original_name'], 0, 255), mb_substr((string) $att['stored_name'], 0, 255), $status, $signature, $message !== null ? mb_substr($message, 0, 250) : null, $quarantined ? 1 : 0, db_now()]); +} + +/** Prüft einen Anhang und sperrt ihn, wenn er verseucht ist. @return string clean, infected, error, skipped oder blocked */ +function virenscan_scan_attachment(PDO $pdo, array $att): string +{ + $cfg = virenscan_config(); + $path = virenscan_uploads_dir() . '/' . basename((string) $att['stored_name']); + if (!is_file($path)) { virenscan_record($pdo, $att, 'error', null, 'Datei nicht gefunden.'); return 'error'; } + if (filesize($path) > $cfg['max_mb'] * 1024 * 1024) { virenscan_record($pdo, $att, 'skipped', null, 'Größer als ' . $cfg['max_mb'] . ' MB, nicht geprüft.'); return 'skipped'; } + $res = virenscan_scan_file($path, (string) $att['original_name']); + if ($res['status'] === 'infected') { + virenscan_record($pdo, $att, 'infected', $res['signature'], null); + virenscan_quarantine($pdo, (int) $att['id'], (string) $res['signature']); + return 'infected'; + } + if ($res['status'] === 'error') { + if ($cfg['block_on_error']) { + virenscan_record($pdo, $att, 'blocked', null, $res['message']); + virenscan_quarantine($pdo, (int) $att['id'], 'Prüfung fehlgeschlagen'); + return 'blocked'; + } + virenscan_record($pdo, $att, 'error', null, $res['message']); + return 'error'; + } + virenscan_record($pdo, $att, 'clean', null, $res['message']); + return 'clean'; +} + +/** Verschiebt die Datei in die Quarantäne, benennt den Anhang um (nicht mehr herunterladbar) und vermerkt es im Ticket. */ +function virenscan_quarantine(PDO $pdo, int $attachmentId, string $reason): bool +{ + $stmt = $pdo->prepare('SELECT a.*, t.ticket_id FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE a.id = ?'); + $stmt->execute([$attachmentId]); + $att = $stmt->fetch(); + if (!$att || $att['stored_name'] === 'gesperrt') return false; + $from = virenscan_uploads_dir() . '/' . basename((string) $att['stored_name']); + $to = virenscan_quarantine_dir() . '/' . basename((string) $att['stored_name']); + if (is_file($from) && !@rename($from, $to)) { + if (!@copy($from, $to) || !@unlink($from)) return false; + } + $pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[GESPERRT] ' . $att['original_name'], 0, 255), $attachmentId]); + $pdo->prepare('UPDATE plugin_virenscan_files SET quarantined = 1 WHERE attachment_id = ?')->execute([$attachmentId]); + $ticketId = (int) $att['ticket_id']; + $text = 'Der Anhang „' . $att['original_name'] . '“ wurde vom Virenscan gesperrt (' . $reason . '). Er liegt in der Quarantäne und lässt sich unter Virenscan prüfen oder freigeben.'; + $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticketId, $text, db_now()]); + log_activity($pdo, $ticketId, null, 'Anhang vom Virenscan gesperrt: ' . $att['original_name'] . ' (' . $reason . ')'); + if (function_exists('apply_tag_to_ticket')) apply_tag_to_ticket($pdo, $ticketId, 'Virus-Verdacht'); + if (virenscan_config()['notify']) virenscan_notify($pdo, $ticketId, (string) $att['original_name'], $reason); + return true; +} + +/** Mail an die Adresse des Administrators (Einstellung der Installation). */ +function virenscan_notify(PDO $pdo, int $ticketId, string $name, string $reason): void +{ + $to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? '')); + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) return; + $t = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id = ?'); + $t->execute([$ticketId]); + $number = (string) $t->fetchColumn(); + try { + send_mail($to, "Virenscan: Anhang in Ticket #{$number} gesperrt", '

    Der Anhang ' . e($name) . ' in Ticket #' . e($number) . ' wurde gesperrt (' . e($reason) . ').

    Er liegt in der Quarantäne. Prüfen, freigeben oder löschen kannst du ihn unter Virenscan im Mitarbeiterbereich.

    '); + } catch (Throwable $e) { + // Mailfehler dürfen das Sperren nie verhindern + } +} + +/** Gibt eine gesperrte Datei wieder frei (zum Beispiel bei Fehlalarm). */ +function virenscan_release(PDO $pdo, int $rowId): bool +{ + $stmt = $pdo->prepare('SELECT * FROM plugin_virenscan_files WHERE id = ? AND quarantined = 1'); + $stmt->execute([$rowId]); + $row = $stmt->fetch(); + if (!$row) return false; + $from = virenscan_quarantine_dir() . '/' . basename((string) $row['stored_name']); + $to = virenscan_uploads_dir() . '/' . basename((string) $row['stored_name']); + if (is_file($from) && !@rename($from, $to)) return false; + $pdo->prepare('UPDATE ticket_attachments SET stored_name = ?, original_name = ? WHERE id = ?')->execute([$row['stored_name'], $row['original_name'], (int) $row['attachment_id']]); + $pdo->prepare("UPDATE plugin_virenscan_files SET quarantined = 0, status = 'released', message = ? WHERE id = ?")->execute(['Freigegeben am ' . db_now(), $rowId]); + if ($row['ticket_id']) log_activity($pdo, (int) $row['ticket_id'], null, 'Gesperrter Anhang freigegeben: ' . $row['original_name']); + return true; +} + +/** Löscht eine gesperrte Datei endgültig (der Anhang bleibt als „gelöscht“ im Ticket vermerkt). */ +function virenscan_delete(PDO $pdo, int $rowId): bool +{ + $stmt = $pdo->prepare('SELECT * FROM plugin_virenscan_files WHERE id = ? AND quarantined = 1'); + $stmt->execute([$rowId]); + $row = $stmt->fetch(); + if (!$row) return false; + $file = virenscan_quarantine_dir() . '/' . basename((string) $row['stored_name']); + if (is_file($file)) @unlink($file); + $pdo->prepare("UPDATE ticket_attachments SET original_name = ? WHERE id = ?")->execute([mb_substr('[GELÖSCHT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]); + $pdo->prepare("UPDATE plugin_virenscan_files SET quarantined = 0, status = 'deleted', message = ? WHERE id = ?")->execute(['Gelöscht am ' . db_now(), $rowId]); + return true; +} + +/** Prüft die noch ungeprüften Anhänge eines Tickets (nach neuer Nachricht oder neuem Ticket). */ +function virenscan_scan_ticket(PDO $pdo, int $ticketId): int +{ + $n = 0; + foreach (virenscan_pending($pdo, 10, $ticketId) as $att) { virenscan_scan_attachment($pdo, $att); $n++; } + return $n; +} + +/** Hintergrundlauf (etwa alle 5 Minuten): ungeprüfte Anhänge nachholen, zum Beispiel aus E-Mails. */ +function virenscan_tick(PDO $pdo): int +{ + $n = 0; + foreach (virenscan_pending($pdo, 25) as $att) { virenscan_scan_attachment($pdo, $att); $n++; } + $n += virenscan_reputation_tick($pdo); + if ($n > 0) plugin_set_setting(VIRENSCAN, 'last_run', db_now()); + return $n; +} + +/** Zahlen für die Seite: Anzahl je Status. @return array */ +function virenscan_stats(PDO $pdo): array +{ + $out = ['clean' => 0, 'infected' => 0, 'error' => 0, 'skipped' => 0, 'blocked' => 0, 'released' => 0, 'deleted' => 0, 'quarantined' => 0]; + foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM plugin_virenscan_files GROUP BY status')->fetchAll() as $r) $out[$r['status']] = (int) $r['n']; + $out['quarantined'] = (int) $pdo->query('SELECT COUNT(*) FROM plugin_virenscan_files WHERE quarantined = 1')->fetchColumn(); + return $out; +} + +// ── Hash-Abfrage bei VirusTotal ──────────────────────────────────────────── +// Es wird nur der SHA-256-Wert der Datei gesendet, nie die Datei selbst. Kostenloser Schlüssel: 4 Abfragen pro Minute, 500 pro Tag. + +/** HTTP-Aufruf (für Tests austauschbar über $GLOBALS['virenscan_http']). @return array{status:int,body:string} */ +function virenscan_http(string $url, array $headers): array +{ + if (isset($GLOBALS['virenscan_http']) && is_callable($GLOBALS['virenscan_http'])) return ($GLOBALS['virenscan_http'])($url, $headers); + $ctx = stream_context_create(['http' => ['method' => 'GET', 'header' => implode("\r\n", $headers), 'timeout' => 8, 'ignore_errors' => true]]); + $res = @file_get_contents($url, false, $ctx); + $status = 0; + foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d+)#', $h, $m)) $status = (int) $m[1]; + return ['status' => $status, 'body' => $res === false ? '' : (string) $res]; +} + +/** @return array{result:string,detail:?string} result: bad, clean, unknown, limit, key oder error */ +function virenscan_vt_lookup(string $sha256, string $key, int $threshold): array +{ + $r = virenscan_http('https://www.virustotal.com/api/v3/files/' . $sha256, ['x-apikey: ' . $key, 'Accept: application/json']); + if ($r['status'] === 404) return ['result' => 'unknown', 'detail' => null]; + if ($r['status'] === 429) return ['result' => 'limit', 'detail' => null]; + if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.']; + if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').']; + $j = json_decode($r['body'], true); + $stats = $j['data']['attributes']['last_analysis_stats'] ?? null; + if (!is_array($stats)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.']; + $bad = (int) ($stats['malicious'] ?? 0); + if ($bad >= $threshold) { + $label = (string) ($j['data']['attributes']['popular_threat_classification']['suggested_threat_label'] ?? ''); + return ['result' => 'bad', 'detail' => 'VirusTotal: ' . $bad . ' Scanner melden Schadsoftware' . ($label !== '' ? ' (' . mb_substr($label, 0, 60) . ')' : '')]; + } + return ['result' => 'clean', 'detail' => null]; +} + +/** + * Hintergrundlauf der Hash-Abfrage: prüft saubere Dateien nachträglich gegen VirusTotal, höchstens 4 pro Lauf und 450 pro Tag. + * Findet VirusTotal Schadsoftware, wird die Datei gesperrt. @return int Zahl der abgefragten Dateien + */ +function virenscan_reputation_tick(PDO $pdo): int +{ + $cfg = virenscan_config(); + if ($cfg['vt_key'] === '') return 0; + $today = date('Y-m-d'); + if (plugin_setting(VIRENSCAN, 'vt_day', '') !== $today) { plugin_set_setting(VIRENSCAN, 'vt_day', $today); plugin_set_setting(VIRENSCAN, 'vt_count', '0'); } + $used = (int) plugin_setting(VIRENSCAN, 'vt_count', '0'); + $rows = $pdo->query("SELECT * FROM plugin_virenscan_files WHERE status = 'clean' AND rep IS NULL AND quarantined = 0 ORDER BY id LIMIT 4")->fetchAll(); + $done = 0; + foreach ($rows as $row) { + if ($used >= 450) break; + $path = virenscan_uploads_dir() . '/' . basename((string) $row['stored_name']); + if (!is_file($path) || filesize($path) > $cfg['max_mb'] * 1024 * 1024) { + $pdo->prepare("UPDATE plugin_virenscan_files SET rep = 'skipped' WHERE id = ?")->execute([$row['id']]); + continue; + } + $hash = hash_file('sha256', $path); + $res = virenscan_vt_lookup($hash, $cfg['vt_key'], $cfg['vt_threshold']); + if ($res['result'] === 'limit') break; + if ($res['result'] === 'key' || $res['result'] === 'error') { plugin_set_setting(VIRENSCAN, 'vt_error', db_now() . ': ' . $res['detail']); break; } + plugin_set_setting(VIRENSCAN, 'vt_error', ''); + $used++; $done++; + plugin_set_setting(VIRENSCAN, 'vt_count', (string) $used); + if ($res['result'] === 'bad') { + $pdo->prepare("UPDATE plugin_virenscan_files SET rep = 'bad', sha256 = ?, status = 'infected', signature = ? WHERE id = ?")->execute([$hash, mb_substr((string) $res['detail'], 0, 250), $row['id']]); + virenscan_quarantine($pdo, (int) $row['attachment_id'], (string) $res['detail']); + } else { + $pdo->prepare('UPDATE plugin_virenscan_files SET rep = ?, sha256 = ? WHERE id = ?')->execute([$res['result'], $hash, $row['id']]); + } + } + return $done; +} +/** Adresse eines Reiters der Plugin-Seite. */ +function virenscan_tab_url(string $tab, array $extra = []): string +{ + $q = ['p' => VIRENSCAN, 'page' => 'virenscan', 'tab' => $tab] + array_filter($extra, fn($v) => $v !== '' && $v !== null && $v !== 1); + return '/staff/plugin.php?' . http_build_query($q); +} + +/** Reiterleiste. @param array $tabs Schlüssel => [Beschriftung, Anzahl (0 = keine Anzeige)] */ +function virenscan_tabs(string $active, array $tabs): string +{ + $html = '
    '; + foreach ($tabs as $key => [$label, $count]) { + $on = $key === $active; + $html .= '' + . htmlspecialchars($label) + . ($count > 0 ? ' ' . (int) $count . '' : '') + . ''; + } + return $html . '
    '; +} + +/** Deinstallation mit „Daten löschen“: Quarantäne-Dateien bleiben liegen, nur die Tabelle verschwindet. */ +plugin_add_hook('plugin.uninstall.' . VIRENSCAN, function () { + try { + get_db()->exec('DROP TABLE IF EXISTS plugin_virenscan_files'); + } catch (Throwable $e) { + } +}); diff --git a/Plugins/plugins/virenscan/migrations/001_dateien.mysql.sql b/Plugins/plugins/virenscan/migrations/001_dateien.mysql.sql new file mode 100644 index 00000000..e1620937 --- /dev/null +++ b/Plugins/plugins/virenscan/migrations/001_dateien.mysql.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS plugin_virenscan_files ( + id INT AUTO_INCREMENT PRIMARY KEY, + attachment_id INT NOT NULL, + ticket_id INT NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL, + status VARCHAR(20) NOT NULL, + signature VARCHAR(255) NULL, + message VARCHAR(255) NULL, + quarantined TINYINT(1) NOT NULL DEFAULT 0, + scanned_at DATETIME NOT NULL, + UNIQUE KEY idx_plugin_virenscan_attachment (attachment_id), + KEY idx_plugin_virenscan_status (status, quarantined) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/virenscan/migrations/001_dateien.sqlite.sql b/Plugins/plugins/virenscan/migrations/001_dateien.sqlite.sql new file mode 100644 index 00000000..2959f886 --- /dev/null +++ b/Plugins/plugins/virenscan/migrations/001_dateien.sqlite.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS plugin_virenscan_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attachment_id INTEGER NOT NULL, + ticket_id INTEGER NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL, + status VARCHAR(20) NOT NULL, + signature VARCHAR(255) NULL, + message VARCHAR(255) NULL, + quarantined INTEGER NOT NULL DEFAULT 0, + scanned_at DATETIME NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_virenscan_attachment ON plugin_virenscan_files (attachment_id); +CREATE INDEX IF NOT EXISTS idx_plugin_virenscan_status ON plugin_virenscan_files (status, quarantined) diff --git a/Plugins/plugins/virenscan/migrations/002_abfrage.mysql.sql b/Plugins/plugins/virenscan/migrations/002_abfrage.mysql.sql new file mode 100644 index 00000000..2573e6ea --- /dev/null +++ b/Plugins/plugins/virenscan/migrations/002_abfrage.mysql.sql @@ -0,0 +1 @@ +ALTER TABLE plugin_virenscan_files ADD COLUMN rep VARCHAR(12) NULL, ADD COLUMN sha256 VARCHAR(64) NULL diff --git a/Plugins/plugins/virenscan/migrations/002_abfrage.sqlite.sql b/Plugins/plugins/virenscan/migrations/002_abfrage.sqlite.sql new file mode 100644 index 00000000..d3d36496 --- /dev/null +++ b/Plugins/plugins/virenscan/migrations/002_abfrage.sqlite.sql @@ -0,0 +1,2 @@ +ALTER TABLE plugin_virenscan_files ADD COLUMN rep TEXT NULL; +ALTER TABLE plugin_virenscan_files ADD COLUMN sha256 TEXT NULL diff --git a/Plugins/plugins/virenscan/pages/virenscan.php b/Plugins/plugins/virenscan/pages/virenscan.php new file mode 100644 index 00000000..dc547da8 --- /dev/null +++ b/Plugins/plugins/virenscan/pages/virenscan.php @@ -0,0 +1,210 @@ +Virenscan
    Der Virenscan ist nur für Administratoren zugänglich.
    '; return; } +$message = null; +$error = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + if (!$isAdmin) { + $error = 'Änderungen sind Administratoren vorbehalten.'; + } else { + $action = $_POST['action'] ?? ''; + if ($action === 'save') { + $mode = in_array($_POST['mode'] ?? '', ['auto', 'clamdscan', 'clamscan', 'builtin'], true) ? $_POST['mode'] : 'auto'; + $binary = trim((string) ($_POST['binary'] ?? '')); + if ($binary !== '' && (preg_match('/[\r\n\0]/', $binary) || !is_file($binary) || !is_executable($binary))) { + $error = 'Der angegebene Pfad ist keine ausführbare Datei.'; + } else { + plugin_set_setting(VIRENSCAN, 'mode', $mode); + plugin_set_setting(VIRENSCAN, 'binary', $binary); + plugin_set_setting(VIRENSCAN, 'timeout', (string) max(5, min(300, (int) ($_POST['timeout'] ?? 60)))); + plugin_set_setting(VIRENSCAN, 'max_mb', (string) max(1, min(500, (int) ($_POST['max_mb'] ?? 25)))); + plugin_set_setting(VIRENSCAN, 'block_on_error', !empty($_POST['block_on_error']) ? '1' : '0'); + plugin_set_setting(VIRENSCAN, 'notify', !empty($_POST['notify']) ? '1' : '0'); + foreach (['analysis', 'block_macros', 'block_encrypted', 'block_unscannable'] as $k) plugin_set_setting(VIRENSCAN, $k, !empty($_POST[$k]) ? '1' : '0'); + plugin_set_setting(VIRENSCAN, 'vt_threshold', (string) max(1, min(30, (int) ($_POST['vt_threshold'] ?? 3)))); + $newKey = trim((string) ($_POST['vt_key'] ?? '')); + if (!empty($_POST['vt_clear'])) plugin_set_setting(VIRENSCAN, 'vt_key', ''); + elseif ($newKey !== '') { + if (!preg_match('/^[a-f0-9]{64}$/i', $newKey)) $error = 'Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).'; + else { plugin_set_setting(VIRENSCAN, 'vt_key', encrypt_secret($newKey)); plugin_set_setting(VIRENSCAN, 'vt_error', ''); } + } + if (!$error) $message = 'Einstellungen gespeichert.'; + } + } elseif ($action === 'test') { + $tmp = tempnam(sys_get_temp_dir(), 'vsc'); + file_put_contents($tmp, virenscan_eicar()); + $res = virenscan_scan_file($tmp); + @unlink($tmp); + $message = $res['status'] === 'infected' + ? 'Test bestanden: Die Standard-Testdatei (EICAR) wurde erkannt' . ($res['signature'] ? ' (' . $res['signature'] . ')' : '') . '.' + : null; + if ($message === null) $error = 'Test nicht bestanden: Die Testdatei wurde nicht erkannt (' . ($res['message'] ?? $res['status']) . ').'; + } elseif ($action === 'backlog') { + plugin_set_setting(VIRENSCAN, 'start_id', '0'); + $message = 'Der gesamte Bestand wird jetzt nach und nach geprüft (jeweils bis zu 25 Dateien alle paar Minuten).'; + } elseif ($action === 'release') { + $message = virenscan_release($pdo, (int) ($_POST['id'] ?? 0)) ? 'Datei freigegeben. Sie ist im Ticket wieder herunterladbar.' : null; + if ($message === null) $error = 'Die Datei konnte nicht freigegeben werden.'; + } elseif ($action === 'delete') { + $message = virenscan_delete($pdo, (int) ($_POST['id'] ?? 0)) ? 'Datei endgültig gelöscht.' : null; + if ($message === null) $error = 'Die Datei konnte nicht gelöscht werden.'; + } + } +} + +$cfg = virenscan_config(); +$status = virenscan_status(); +$stats = virenscan_stats($pdo); +$labels = ['clean' => 'Sauber', 'infected' => 'Verseucht', 'error' => 'Fehler', 'skipped' => 'Übersprungen', 'blocked' => 'Gesperrt (Fehler)', 'released' => 'Freigegeben', 'deleted' => 'Gelöscht']; + +// Reiter: Übersicht, Quarantäne (mit Anzahl), Geprüft, Einstellungen +$tabs = ['uebersicht' => ['Übersicht', 0], 'quarantaene' => ['Quarantäne', (int) $stats['quarantined']], 'geprueft' => ['Geprüft', 0], 'einstellungen' => ['Einstellungen', 0]]; +$tab = isset($tabs[$_GET['tab'] ?? '']) ? (string) $_GET['tab'] : 'uebersicht'; + +// Geprüft: Filter und Seiten +$filter = isset($labels[$_GET['status'] ?? '']) ? (string) $_GET['status'] : ''; +$perPage = 50; +$pageNo = max(1, (int) ($_GET['seite'] ?? 1)); +if ($tab === 'geprueft') { + $cnt = $pdo->prepare('SELECT COUNT(*) FROM plugin_virenscan_files' . ($filter !== '' ? ' WHERE status = ?' : '')); + $cnt->execute($filter !== '' ? [$filter] : []); + $totalChecked = (int) $cnt->fetchColumn(); + $pages = max(1, (int) ceil($totalChecked / $perPage)); + $pageNo = min($pageNo, $pages); + $q = $pdo->prepare('SELECT f.*, t.ticket_number FROM plugin_virenscan_files f LEFT JOIN tickets t ON t.id = f.ticket_id' . ($filter !== '' ? ' WHERE f.status = ?' : '') . ' ORDER BY f.id DESC LIMIT ' . $perPage . ' OFFSET ' . (($pageNo - 1) * $perPage)); + $q->execute($filter !== '' ? [$filter] : []); + $checked = $q->fetchAll(); +} +if ($tab === 'quarantaene') { + $quarantine = $pdo->query('SELECT f.*, t.ticket_number FROM plugin_virenscan_files f LEFT JOIN tickets t ON t.id = f.ticket_id WHERE f.quarantined = 1 ORDER BY f.id DESC LIMIT 200')->fetchAll(); +} +?> +

    Virenscan

    +
    +
    + + + +
    +

    Zustand

    +

    + ✓ Dateianalyse aktivDateianalyse ausgeschaltet + · ClamAV bereit (, )ClamAV nicht vorhanden (optional) + · ✓ Hash-Abfrage aktiv (heute von 450 Abfragen)Hash-Abfrage aus (kein Schlüssel) +

    +

    +

    Hash-Abfrage:

    +

    Geprüft: sauber · verseucht · Fehler · übersprungen · in Quarantäne:

    +

    Letzter Hintergrundlauf:

    + 0): ?>

    Datei(en) in Quarantäne prüfen

    +
    + +
    +
    +
    + Ohne diesen Knopf werden nur Anhänge geprüft, die nach dem Einschalten des Plugins eingehen. +
    + + + +
    +

    Quarantäne

    +

    Keine gesperrten Dateien. Alles in Ordnung.

    + + + + + + + + + + + + + +
    DateiFundTicketWann
    # +
    +
    +
    + +
    + + + +
    +
    +

    Geprüfte Dateien ()

    +
    + + + +
    +
    +

    Keine Einträge.

    + + + + + + + + + + +
    DateiErgebnisHinweisTicketWann
    #
    + 1): ?> +
    + 1): ?>← Neuere + Seite von + Ältere → +
    + + +
    + + + +
    + + +

    Einstellungen

    +

    Dateianalyse (braucht nichts auf dem Server)

    +

    Sperrt Programme und Skripte (.exe, .js, .bat …), getarnte Dateien (zum Beispiel ein Programm als .pdf), doppelte Endungen, PDFs mit automatischem JavaScript, HTML/SVG mit Skripten und Archive mit solchen Dateien. Sie erkennt keine neuen Viren, die wie normale Dokumente aussehen; dafür dient die Hash-Abfrage unten oder ClamAV.

    + + + + + +

    Hash-Abfrage bei VirusTotal (optional, kostenlos)

    +

    Es wird nur der Fingerabdruck (SHA-256) einer Datei gesendet, nie die Datei selbst oder Ticketinhalte. So werden bekannte Schadprogramme auch erkannt, wenn sie wie ein normales Dokument aussehen. Läuft im Hintergrund (höchstens 4 Abfragen pro Lauf, 450 pro Tag), die Sperre kann also einige Minuten nach dem Eingang kommen. Den kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com unter „API key“; die kostenlose Nutzung ist laut VirusTotal nicht für den gewerblichen Einsatz gedacht.

    + + + +
    + +

    ClamAV (optional, nur wenn schon auf dem Server vorhanden)

    + + + + +
    +
    +
    +
    + + +
    +
    + diff --git a/Plugins/plugins/virenscan/plugin.json b/Plugins/plugins/virenscan/plugin.json new file mode 100644 index 00000000..bec5cf7d --- /dev/null +++ b/Plugins/plugins/virenscan/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "virenscan", + "name": "Anhänge-Virenscan", + "version": "1.2.0", + "description": "Prüft Anhänge von Tickets und sperrt gefährliche Dateien, bevor ein Mitarbeiter sie öffnet. Eingebaute Dateianalyse ohne Installation auf dem Server (Programme, Skripte, getarnte Dateien, Makros, PDF-Tricks, Archive), optionale Hash-Abfrage bei VirusTotal und ClamAV, falls vorhanden. Gesperrte Dateien liegen in Quarantäne und lassen sich freigeben oder löschen. Nur für Administratoren.", + "author": "DeskHub", + "requires": "1.0.0", + "permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"] +} diff --git a/Plugins/plugins/virenscan/plugin.php b/Plugins/plugins/virenscan/plugin.php new file mode 100644 index 00000000..b8ad1550 --- /dev/null +++ b/Plugins/plugins/virenscan/plugin.php @@ -0,0 +1,16 @@ +