.spw-widget {
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.spw-header {
background: #f5f5f5;
border-bottom: 1px solid #ddd;
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.spw-title {
font-weight: 600;
font-size: 13px;
color: #333;
margin: 0;
}
.spw-status {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
background: ' . esc_attr($status_color) . ';
color: #fff;
}
.spw-body {
padding: 12px;
}
.spw-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.spw-item {
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 4px;
padding: 8px;
}
.spw-item-label {
font-size: 11px;
color: #666;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 3px;
display: block;
}
.spw-item-value {
font-size: 20px;
font-weight: 600;
color: #000;
}
.spw-item-meta {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.spw-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.spw-metric {
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 4px;
padding: 6px;
text-align: center;
}
.spw-metric-label {
font-size: 10px;
color: #666;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 2px;
}
.spw-metric-value {
font-size: 16px;
font-weight: 600;
color: #000;
}
.spw-footer {
display: flex;
gap: 8px;
border-top: 1px solid #eee;
padding-top: 8px;
}
.spw-link {
flex: 1;
text-align: center;
padding: 6px 8px;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 11px;
color: #0073aa;
text-decoration: none;
font-weight: 600;
transition: background 0.2s;
}
.spw-link:hover {
background: #e5e5e5;
color: #005a87;
text-decoration: none;
}
@media (max-width: 900px) {
.spw-row { grid-template-columns: 1fr; }
.spw-metrics { grid-template-columns: repeat(2, 1fr); }
}
';
echo '
';
}
public static function register_menu() {
add_menu_page(
'StatusPulse',
'StatusPulse',
'manage_options',
self::MENU_SLUG,
array(__CLASS__, 'render_page'),
'dashicons-shield-alt',
81
);
add_submenu_page(
self::MENU_SLUG,
'Meldungen',
'Meldungen',
'manage_options',
self::MESSAGES_SLUG,
array(__CLASS__, 'render_messages_page')
);
add_submenu_page(
self::MENU_SLUG,
'Angriffsversuche',
'Angriffsversuche',
'manage_options',
self::LIST_SLUG,
array(__CLASS__, 'render_list_page')
);
}
public static function register_settings() {
register_setting(
'statusapi_backend_helper_group',
self::OPTION_KEY,
array(__CLASS__, 'sanitize_options')
);
}
public static function sanitize_options($input) {
$input = is_array($input) ? $input : array();
return array(
'statusapi_base_url' => self::sanitize_url(isset($input['statusapi_base_url']) ? $input['statusapi_base_url'] : ''),
'attack_api_key' => sanitize_text_field(isset($input['attack_api_key']) ? $input['attack_api_key'] : ''),
'attack_source' => sanitize_text_field(isset($input['attack_source']) ? $input['attack_source'] : 'WordPress'),
);
}
private static function sanitize_url($url) {
$url = trim((string) $url);
if ($url === '') {
return '';
}
$url = untrailingslashit(esc_url_raw($url));
// Falls aus anderen Plugins ein Endpoint eingetragen wurde,
// auf reine Basis-URL zurückführen.
$known_suffixes = array(
'/broadcast',
'/network/attack',
'/antibot/security-log',
'/health',
);
foreach ($known_suffixes as $suffix) {
if (substr($url, -strlen($suffix)) === $suffix) {
$url = substr($url, 0, -strlen($suffix));
break;
}
}
return untrailingslashit($url);
}
private static function get_pulsecast_api_url() {
$settings = get_option('pulsecast_settings', array());
if (!is_array($settings)) {
return '';
}
$protocol = isset($settings['api_protocol']) ? strtolower(trim((string) $settings['api_protocol'])) : 'http';
if ($protocol !== 'http' && $protocol !== 'https') {
$protocol = 'http';
}
$host = isset($settings['api_host']) ? trim((string) $settings['api_host']) : '';
if ($host === '') {
return '';
}
$port = isset($settings['api_port']) ? trim((string) $settings['api_port']) : '';
$path = isset($settings['api_path']) ? trim((string) $settings['api_path']) : '/broadcast';
if ($path === '') {
$path = '/broadcast';
}
if (substr($path, 0, 1) !== '/') {
$path = '/' . $path;
}
$url = $protocol . '://' . $host;
if ($port !== '' && !(($protocol === 'http' && $port === '80') || ($protocol === 'https' && $port === '443'))) {
$url .= ':' . $port;
}
// Wichtig: exakt wie PulseCast verwenden (inkl. konfiguriertem Pfad).
// So verhalten wir uns identisch zum funktionierenden Plugin.
$url .= $path;
return esc_url_raw($url);
}
private static function get_candidate_base_urls($base_url) {
$candidates = array();
$add = function($url) use (&$candidates) {
$u = trim((string) $url);
if ($u !== '' && !in_array($u, $candidates, true)) {
$candidates[] = $u;
}
};
$add_base_variants = function($url) use ($add) {
$u = trim((string) $url);
if ($u === '') {
return;
}
// Wenn nur Domain ohne Schema gespeichert wurde: wie in den anderen Plugins auf http normalisieren.
if (strpos($u, 'http://') !== 0 && strpos($u, 'https://') !== 0) {
$u = 'http://' . $u;
}
// Primär: exakt die konfigurierte URL testen.
$root = untrailingslashit($u);
$add($root);
// Direkt danach Port 9191 testen – falls NPM nicht korrekt routet, finden
// wir den StatusAPI-Server so viel schneller (vor /health etc.)
$parts = wp_parse_url($u);
if (is_array($parts) && !empty($parts['host']) && empty($parts['port'])) {
$host_9191 = 'http://' . $parts['host'] . ':9191';
$add($host_9191);
$add($host_9191 . '/');
$add($host_9191 . '/health');
}
$add($root . '/');
$add($root . '/health');
// Falls https gesetzt ist, auch http testen (häufiger NPM/SSL Sonderfall).
if (strpos($root, 'https://') === 0) {
$http_root = 'http://' . substr($root, 8);
$add($http_root);
$add($http_root . '/');
$add($http_root . '/health');
}
};
$primary = trim((string) $base_url);
if ($primary !== '') {
$add_base_variants($primary);
}
$pulsecast_url = self::get_pulsecast_api_url();
if ($pulsecast_url !== '') {
$add_base_variants($pulsecast_url);
}
return $candidates;
}
private static function get_options() {
$stored = get_option(self::OPTION_KEY, array());
$stored = is_array($stored) ? $stored : array();
return wp_parse_args($stored, array(
'statusapi_base_url' => '',
'attack_api_key' => '',
'attack_source' => 'WordPress',
));
}
private static function get_state() {
$stored = get_option(self::STATE_OPTION_KEY, array());
$stored = is_array($stored) ? $stored : array();
return wp_parse_args($stored, array(
'last_check_at' => 0,
'last_http_code' => 0,
'last_success_at' => 0,
'last_success_code' => 0,
'last_status' => 'unknown',
'last_error' => '',
'last_attack_mode' => null,
'last_memory_high' => false,
'last_player_high' => false,
'messages' => array(),
));
}
private static function save_state($state) {
update_option(self::STATE_OPTION_KEY, $state, false);
}
private static function push_message(&$state, $level, $title, $message) {
if (!isset($state['messages']) || !is_array($state['messages'])) {
$state['messages'] = array();
}
array_unshift($state['messages'], array(
'ts' => time(),
'level' => $level,
'title' => $title,
'message' => $message,
));
if (count($state['messages']) > 60) {
$state['messages'] = array_slice($state['messages'], 0, 60);
}
self::push_history_entry($level, $title, $message);
}
private static function get_history() {
$stored = get_option(self::HISTORY_OPTION_KEY, array());
return is_array($stored) ? $stored : array();
}
private static function save_history($history) {
update_option(self::HISTORY_OPTION_KEY, is_array($history) ? $history : array(), false);
}
private static function push_history_entry($level, $title, $message) {
$history = self::get_history();
array_unshift($history, array(
'ts' => time(),
'level' => (string) $level,
'title' => (string) $title,
'message' => (string) $message,
));
if (count($history) > 1000) {
$history = array_slice($history, 0, 1000);
}
self::save_history($history);
}
private static function build_messages_markup($state) {
$messages = isset($state['messages']) && is_array($state['messages']) ? $state['messages'] : array();
if (empty($messages)) {
return 'Noch keine MeldungenSobald Statuswechsel oder Attack-Events erkannt werden, erscheinen sie hier.
';
}
$html = '';
foreach (array_slice($messages, 0, 20) as $msg) {
$level = isset($msg['level']) ? (string) $msg['level'] : 'info';
$title = isset($msg['title']) ? (string) $msg['title'] : 'Meldung';
$text = isset($msg['message']) ? (string) $msg['message'] : '';
$ts = isset($msg['ts']) ? (int) $msg['ts'] : 0;
$html .= '';
$html .= '' . esc_html($title) . '' . esc_html(self::format_timestamp($ts)) . '
';
$html .= '' . esc_html($text) . '
';
$html .= '';
}
return $html;
}
public static function render_page() {
if (!current_user_can('manage_options')) {
return;
}
$options = self::get_options();
$statusapi_base_url = $options['statusapi_base_url'];
$attack_api_key = $options['attack_api_key'];
$attack_source = $options['attack_source'];
$live_status = self::probe_statusapi($statusapi_base_url);
$state = self::get_state();
$notice = self::build_notice();
$candidate_urls_for_ui = self::get_candidate_base_urls($statusapi_base_url);
$status_endpoint = !empty($candidate_urls_for_ui)
? $candidate_urls_for_ui[0]
: '';
$attack_endpoint = $status_endpoint . '/network/attack';
$status_json_snippet = "Status JSON: {$status_endpoint}";
$network_guard_snippet = "networkinfo.attack.api_key={$attack_api_key}\nnetworkinfo.attack.source={$attack_source}";
$curl_examples = self::build_curl_examples($status_endpoint, $attack_endpoint, $attack_api_key, $attack_source);
$refresh_nonce = wp_create_nonce('statusapi_backend_helper_refresh');
$debug_data = isset($live_status['debug']) && is_array($live_status['debug']) ? $live_status['debug'] : array();
$debug_raw_data = isset($debug_data['raw_data']) ? (string) $debug_data['raw_data'] : '';
$debug_error = isset($debug_data['error']) ? (string) $debug_data['error'] : '';
$debug_url_used = isset($debug_data['url_used']) ? (string) $debug_data['url_used'] : '';
$debug_attempts = isset($debug_data['attempts']) && is_array($debug_data['attempts']) ? $debug_data['attempts'] : array();
echo '';
self::render_styles();
self::render_scripts($refresh_nonce);
echo '
';
echo '
';
echo '
Proxy Control Surface';
echo '
StatusPulse
';
echo '
Reduzierte Admin-Seite für Proxy-URL, Attack-Key und direkte Tests gegen dein StatusAPI-Plugin.
';
echo '
';
echo '
';
echo 'Live Refresh';
echo 'alle 15s';
echo 'wartet auf erstes Update';
echo '
';
echo '
';
if ($notice !== null) {
printf(
'
',
esc_attr($notice['type']),
esc_html($notice['message'])
);
}
echo '
';
self::render_status_card_named(
'statusapi-card-proxy',
'Proxy Status',
$live_status['label'],
$live_status['meta'],
$live_status['status']
);
self::render_status_card_named(
'statusapi-card-http',
'Letzter HTTP-Code',
$live_status['http_code'] > 0 ? (string) $live_status['http_code'] : 'n/a',
$live_status['checked_at_human'],
$live_status['status']
);
self::render_status_card_named(
'statusapi-card-success',
'Letzte erfolgreiche Prüfung',
self::format_timestamp($state['last_success_at']),
$state['last_success_code'] > 0 ? 'HTTP ' . $state['last_success_code'] : 'Noch kein erfolgreicher Check gespeichert',
$state['last_success_at'] > 0 ? 'ok' : 'unknown'
);
self::render_status_card_named(
'statusapi-card-lastcheck',
'Zuletzt geprüft',
self::format_timestamp($state['last_check_at']),
$state['last_error'] !== '' ? $state['last_error'] : 'Keine Fehler gespeichert',
$state['last_status']
);
echo '
';
echo '
';
self::render_metric_card('statusapi-metric-online', 'Spieler online', $live_status['metrics']['online_players'], $live_status['metrics']['online_meta']);
self::render_metric_card('statusapi-metric-max', 'Max. Spieler', $live_status['metrics']['max_players'], $live_status['metrics']['max_meta']);
self::render_metric_card('statusapi-metric-uptime', 'Uptime', $live_status['metrics']['uptime'], $live_status['metrics']['uptime_meta']);
self::render_metric_card('statusapi-metric-memory', 'RAM Nutzung', $live_status['metrics']['memory'], $live_status['metrics']['memory_meta']);
echo '
';
echo '
';
echo '
';
echo '
🔍 DEBUG: API Response (klicken zum Ein-/Ausblenden)
';
echo '';
echo '
';
echo '
';
echo '
URL: ' . esc_html($debug_url_used !== '' ? $debug_url_used : $statusapi_base_url) . '
';
if (!empty($debug_attempts)) {
echo '
Versuche: ' . esc_html(implode(' | ', $debug_attempts)) . '
';
} else {
echo '
Versuche:
';
}
if ($debug_error !== '') {
echo '
⚠ Fehler: ' . esc_html($debug_error) . '
';
} else {
echo '
';
}
echo '
';
echo '
';
echo esc_html($debug_raw_data !== '' ? $debug_raw_data : '(keine Response)');
echo '';
echo '
';
echo '
';
echo '
';
echo '
';
echo '
';
echo '
Snippets
Direkt für deine Proxy-Konfiguration und externe Tests vorbereitet.
';
self::render_snippet_card('Für network-guard.properties', $network_guard_snippet, 4);
self::render_snippet_card('Wichtiger Endpunkt', $status_json_snippet . "\nAttack POST: {$attack_endpoint}", 4);
self::render_snippet_card('Test-Requests', $curl_examples, 8);
echo '
';
echo '
';
echo '
Aktionen
Teste direkt aus dem Backend, ob dein Proxy erreichbar ist und Attack-Meldungen akzeptiert.
';
echo '
';
echo '
';
echo '
';
echo '
';
echo '
';
}
public static function render_messages_page() {
if (!current_user_can('manage_options')) {
return;
}
$state = self::get_state();
$notice = self::build_notice();
$messages_markup = self::build_messages_markup($state);
$refresh_nonce = wp_create_nonce('statusapi_backend_helper_refresh');
echo '';
self::render_styles();
self::render_scripts($refresh_nonce);
echo '
StatusPulse Meldungen
';
echo '
Dedizierte Seite für alle laufenden Status-, Attack- und Warnmeldungen.
';
if ($notice !== null) {
printf(
'
',
esc_attr($notice['type']),
esc_html($notice['message'])
);
}
self::render_messages_center($messages_markup, self::MESSAGES_SLUG);
echo '
';
}
public static function render_list_page() {
if (!current_user_can('manage_options')) {
return;
}
$options = self::get_options();
$notice = self::build_notice();
$attacker_data = self::fetch_attacker_entries($options['statusapi_base_url']);
$rows = isset($attacker_data['entries']) && is_array($attacker_data['entries']) ? $attacker_data['entries'] : array();
$list_error = isset($attacker_data['error']) ? (string) $attacker_data['error'] : '';
echo '';
self::render_styles();
echo '
StatusPulse Angriffsversuche
';
echo '
Zeigt nur Spielername und UUID von erkannten Angriffsversuchen (inkl. Datum/Uhrzeit).
';
if ($notice !== null) {
printf(
'
',
esc_attr($notice['type']),
esc_html($notice['message'])
);
}
echo '
';
echo '
Angriffs-Ereignisse
Neueste Einträge oben.
';
if ($list_error !== '') {
echo '
' . esc_html($list_error) . '
';
}
if (empty($rows)) {
echo '
Keine Angriffs-Einträge mit Spielername/UUID vorhanden.
';
} else {
echo '
';
echo '| Datum / Uhrzeit | Spielername | IP | UUID |
';
echo '';
foreach ($rows as $row) {
$date = isset($row['datetime']) ? (string) $row['datetime'] : '';
$player = isset($row['player']) ? (string) $row['player'] : '-';
$ip = isset($row['ip']) ? (string) $row['ip'] : '-';
$uuid = isset($row['uuid']) ? (string) $row['uuid'] : '-';
echo '';
echo '| ' . esc_html($date) . ' | ';
echo '' . esc_html($player) . ' | ';
echo '' . esc_html($ip) . ' | ';
echo '' . esc_html($uuid) . ' | ';
echo '
';
}
echo '
';
}
echo '
';
echo '
';
}
private static function render_messages_center($messages_markup, $redirect_page) {
echo '';
echo '
Meldungs-Center
Hier siehst du laufend erkannte Status-, Attack- und Warnmeldungen aus deinem Proxy.
';
echo '
';
echo '';
echo '';
echo '';
echo '';
echo '';
echo '';
echo '
';
echo '
';
echo '
';
}
private static function render_input_field($label, $name, $value, $description) {
echo '';
}
private static function render_snippet_card($title, $content, $rows) {
echo '';
echo '
';
echo '
' . esc_html($title) . '
';
echo '';
echo '';
printf(
'
',
(int) $rows,
esc_attr('statusapi-copy-' . md5($title . $content)),
esc_textarea($content)
);
echo '
';
}
private static function render_status_card($title, $value, $meta, $status) {
self::render_status_card_named('', $title, $value, $meta, $status);
}
private static function render_status_card_named($id, $title, $value, $meta, $status) {
$status_class = 'statusapi-state-unknown';
if ($status === 'ok') {
$status_class = 'statusapi-state-ok';
} elseif ($status === 'error') {
$status_class = 'statusapi-state-error';
}
$id_attr = $id !== '' ? ' id="' . esc_attr($id) . '"' : '';
echo '';
echo '' . esc_html($title) . '';
echo '' . esc_html($value) . '';
echo '' . esc_html($meta) . '';
echo '
';
}
private static function render_metric_card($id, $title, $value, $meta) {
echo '';
echo '' . esc_html($title) . '';
echo '' . esc_html($value) . '';
echo '' . esc_html($meta) . '';
echo '
';
}
private static function build_curl_examples($status_endpoint, $attack_endpoint, $attack_api_key, $attack_source) {
$payload = wp_json_encode(array(
'event' => 'detected',
'source' => $attack_source,
'connectionsPerSecond' => 250,
'ipAddressesBlocked' => 12,
'connectionsBlocked' => 1800,
));
return "GET {$status_endpoint}\n\nPOST {$attack_endpoint}\nHeader: X-API-Key: {$attack_api_key}\nBody: {$payload}";
}
private static function is_supported_api_payload($decoded) {
if (!is_array($decoded)) {
return false;
}
$is_statusapi = isset($decoded['network']) && is_array($decoded['network']);
$has_players_array = isset($decoded['players']) && is_array($decoded['players']);
$has_online_flag = isset($decoded['online'])
&& (is_bool($decoded['online']) || $decoded['online'] === 0 || $decoded['online'] === 1 || $decoded['online'] === '0' || $decoded['online'] === '1' || $decoded['online'] === 'true' || $decoded['online'] === 'false');
$is_bungeecord = $has_online_flag && isset($decoded['players']) && is_array($decoded['players']);
// Wie in mc-player-history: players[] gilt bereits als verwertbares StatusAPI-JSON.
return $is_statusapi || $is_bungeecord || $has_players_array;
}
private static function should_try_stream_fallback($error_message) {
$msg = strtolower((string) $error_message);
return strpos($msg, 'curl error 35') !== false
|| strpos($msg, 'tlsv1 unrecognized name') !== false
|| strpos($msg, 'ssl routines') !== false;
}
private static function stream_http_request($url, $method, $headers, $body, $timeout) {
$parts = wp_parse_url($url);
if (!is_array($parts) || empty($parts['host'])) {
return new WP_Error('statuspulse_stream_bad_url', 'Ungueltige URL fuer Stream-Fallback.');
}
$scheme = isset($parts['scheme']) ? strtolower((string) $parts['scheme']) : 'http';
$host = (string) $parts['host'];
$port = isset($parts['port']) ? (int) $parts['port'] : ($scheme === 'https' ? 443 : 80);
$path = isset($parts['path']) ? (string) $parts['path'] : '/';
if ($path === '') {
$path = '/';
}
if (!empty($parts['query'])) {
$path .= '?' . $parts['query'];
}
$transport = $scheme === 'https' ? 'ssl://' : 'tcp://';
$context_options = array();
if ($scheme === 'https') {
$context_options['ssl'] = array(
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
// Workaround fuer Umgebungen mit problematischem SNI/TLS Handshake via cURL.
'SNI_enabled' => false,
);
}
$context = stream_context_create($context_options);
$errno = 0;
$errstr = '';
$fp = @stream_socket_client($transport . $host . ':' . $port, $errno, $errstr, (float) $timeout, STREAM_CLIENT_CONNECT, $context);
if (!$fp) {
return new WP_Error('statuspulse_stream_connect_failed', 'Stream-Verbindung fehlgeschlagen: ' . $errstr);
}
stream_set_timeout($fp, (int) max(1, (int) $timeout));
$request_headers = array(
'Host' => $host,
'Connection' => 'close',
'User-Agent' => 'StatusPulse/1.0',
'Accept' => 'application/json, */*',
);
foreach ((array) $headers as $hk => $hv) {
$request_headers[(string) $hk] = (string) $hv;
}
$payload = (string) $body;
if ($payload !== '') {
$request_headers['Content-Length'] = (string) strlen($payload);
}
$raw = strtoupper($method) . ' ' . $path . " HTTP/1.1\r\n";
foreach ($request_headers as $hk => $hv) {
$raw .= $hk . ': ' . $hv . "\r\n";
}
$raw .= "\r\n";
if ($payload !== '') {
$raw .= $payload;
}
fwrite($fp, $raw);
$response_raw = '';
while (!feof($fp)) {
$chunk = fread($fp, 8192);
if ($chunk === false) {
break;
}
$response_raw .= $chunk;
}
fclose($fp);
if ($response_raw === '') {
return new WP_Error('statuspulse_stream_empty', 'Leere Antwort vom Stream-Fallback.');
}
$parts_resp = explode("\r\n\r\n", $response_raw, 2);
$head = isset($parts_resp[0]) ? $parts_resp[0] : '';
$resp_body = isset($parts_resp[1]) ? $parts_resp[1] : '';
$status_code = 0;
$head_lines = explode("\r\n", $head);
if (!empty($head_lines)) {
if (preg_match('#HTTP/\d\.\d\s+(\d{3})#', (string) $head_lines[0], $m)) {
$status_code = (int) $m[1];
}
}
return array(
'code' => $status_code,
'body' => $resp_body,
'error' => '',
'via' => 'stream',
);
}
private static function perform_request_with_fallback($url, $method, $headers = array(), $body = '', $timeout = 5) {
$args = array(
'timeout' => $timeout,
'sslverify' => false,
'headers' => is_array($headers) ? $headers : array(),
);
if (strtoupper($method) !== 'GET') {
$args['body'] = (string) $body;
}
$response = wp_remote_request($url, array_merge($args, array('method' => strtoupper($method))));
if (!is_wp_error($response)) {
return array(
'code' => (int) wp_remote_retrieve_response_code($response),
'body' => (string) wp_remote_retrieve_body($response),
'error' => '',
'via' => 'wp',
);
}
$error_message = $response->get_error_message();
if (!self::should_try_stream_fallback($error_message)) {
return array('code' => 0, 'body' => '', 'error' => $error_message, 'via' => 'wp');
}
$fallback = self::stream_http_request($url, $method, $headers, $body, $timeout);
if (is_wp_error($fallback)) {
return array('code' => 0, 'body' => '', 'error' => $error_message . ' | Stream-Fallback: ' . $fallback->get_error_message(), 'via' => 'wp+stream');
}
return $fallback;
}
private static function probe_statusapi($base_url) {
$state = self::get_state();
$previous_status = isset($state['last_status']) ? (string) $state['last_status'] : 'unknown';
$previous_attack_mode = array_key_exists('last_attack_mode', $state) ? $state['last_attack_mode'] : null;
$previous_memory_high = !empty($state['last_memory_high']);
$previous_player_high = !empty($state['last_player_high']);
$candidate_urls = self::get_candidate_base_urls($base_url);
if (empty($candidate_urls)) {
return array(
'status' => 'unknown',
'label' => 'Nicht konfiguriert',
'meta' => 'Bitte zuerst eine StatusAPI Basis-URL eintragen (oder PulseCast konfigurieren).',
'http_code' => 0,
'checked_at_human' => self::format_timestamp($state['last_check_at']),
'metrics' => self::empty_metrics(),
'debug' => array(
'url_used' => '',
'attempts' => array(),
'error' => 'Keine Basis-URL gesetzt.',
'raw_data' => '',
),
);
}
$response_url = $candidate_urls[0];
$response = null;
$last_error = '';
$is_valid_response = false;
$is_reachable = false;
$reachable_code = 0;
$attempts = array();
$raw_fallback = '';
foreach ($candidate_urls as $try_url) {
$response = self::perform_request_with_fallback($try_url, 'GET', array(), '', 5);
if (!empty($response['error'])) {
$last_error = $response['error'];
$attempts[] = $try_url . ' -> ERROR: ' . $response['error'];
continue;
}
$code_tmp = (int) $response['code'];
$body_tmp = (string) $response['body'];
$decoded_tmp = json_decode($body_tmp, true);
if ($code_tmp > 0 && $code_tmp < 500 && !$is_reachable) {
$is_reachable = true;
$reachable_code = $code_tmp;
$response_url = $try_url;
}
if ($code_tmp >= 200 && $code_tmp < 300 && self::is_supported_api_payload($decoded_tmp)) {
$response_url = $try_url;
$is_valid_response = true;
$last_error = '';
$attempts[] = $try_url . ' -> HTTP ' . $code_tmp . ' (StatusAPI JSON)';
break;
}
if ($code_tmp >= 200 && $code_tmp < 300 && !self::is_supported_api_payload($decoded_tmp)) {
$attempts[] = $try_url . ' -> HTTP ' . $code_tmp . ' (kein StatusAPI JSON)';
if ($raw_fallback === '') {
$raw_fallback = $body_tmp;
}
if (is_string($body_tmp) && stripos($body_tmp, 'successfully started the Nginx Proxy Manager') !== false) {
// NPM-Domain ist nicht korrekt auf Port 9191 geroutet
$parsed_npm = wp_parse_url($try_url);
$npm_host = is_array($parsed_npm) && !empty($parsed_npm['host']) ? $parsed_npm['host'] : '';
$last_error = 'Nginx Proxy Manager Default-Seite erkannt – Proxy-Host für diese Domain fehlt.'
. ($npm_host !== '' ? ' Trage stattdessen direkt ein: http://' . $npm_host . ':9191' : '');
} else {
$last_error = 'Antwort ist kein StatusAPI-JSON.';
}
} else {
$attempts[] = $try_url . ' -> HTTP ' . $code_tmp;
$last_error = 'HTTP ' . $code_tmp;
}
}
$now = time();
if (!$is_valid_response) {
$state['last_check_at'] = $now;
$state['last_http_code'] = $is_reachable ? $reachable_code : 0;
$state['last_status'] = 'error';
$state['last_error'] = $last_error !== '' ? $last_error : 'Ungueltige Antwort vom Proxy';
if ($previous_status !== 'error') {
self::push_message($state, 'error', 'Proxy-Fehlkonfiguration', 'StatusAPI liefert keine gueltigen JSON-Daten. Pruefe Nginx Proxy Host fuer die Domain.');
}
self::save_state($state);
error_log('StatusPulse: Fehler fuer ' . implode(', ', $candidate_urls) . ': ' . $state['last_error']);
return array(
'status' => 'error',
'label' => 'Fehlkonfiguration',
'meta' => $state['last_error'],
'http_code' => 0,
'checked_at_human' => self::format_timestamp($now),
'metrics' => self::empty_metrics(),
'debug' => array(
'url_used' => $response_url,
'attempts' => $attempts,
'error' => $state['last_error'],
'raw_data' => $raw_fallback,
),
);
}
$code = (int) $response['code'];
$ok = $code >= 200 && $code < 300;
$body = (string) $response['body'];
$decoded = json_decode($body, true);
$state['last_check_at'] = $now;
$state['last_http_code'] = $code;
$state['last_status'] = $ok ? 'ok' : 'error';
$state['last_error'] = $ok ? '' : 'HTTP ' . $code;
if ($ok && $previous_status === 'error') {
self::push_message($state, 'success', 'Proxy wieder online', 'StatusAPI antwortet wieder erfolgreich.');
}
if (!$ok && $previous_status !== 'error') {
self::push_message($state, 'error', 'Proxy-Fehler', 'StatusAPI antwortet mit HTTP ' . $code . '.');
}
if ($ok && is_array($decoded)) {
$network = isset($decoded['network']) && is_array($decoded['network']) ? $decoded['network'] : array();
$antibot = isset($decoded['antibot']) && is_array($decoded['antibot']) ? $decoded['antibot'] : array();
if (array_key_exists('attack_mode', $antibot)) {
$attack_mode = (bool) $antibot['attack_mode'];
if ($previous_attack_mode !== null && $attack_mode !== (bool) $previous_attack_mode) {
if ($attack_mode) {
$cps = isset($antibot['last_cps']) ? $antibot['last_cps'] : 'n/a';
self::push_message($state, 'warning', 'Attack erkannt', 'AntiBot hat den Attack-Mode aktiviert (CPS: ' . $cps . ').');
} else {
self::push_message($state, 'success', 'Attack beendet', 'AntiBot hat den Attack-Mode beendet.');
}
}
$state['last_attack_mode'] = $attack_mode;
}
$memory_percent = null;
if (isset($network['memory']) && is_array($network['memory']) && isset($network['memory']['usage_percent'])) {
$memory_percent = (int) $network['memory']['usage_percent'];
}
$is_memory_high = $memory_percent !== null && $memory_percent >= 90;
if ($is_memory_high && !$previous_memory_high) {
self::push_message($state, 'warning', 'RAM-Warnung', 'RAM-Auslastung liegt bei ' . $memory_percent . '%.');
}
if (!$is_memory_high && $previous_memory_high) {
self::push_message($state, 'success', 'RAM normalisiert', 'RAM-Auslastung ist wieder unter 90%.');
}
$state['last_memory_high'] = $is_memory_high;
$occupancy = null;
if (isset($network['players']) && is_array($network['players']) && isset($network['players']['occupancy_percent'])) {
$occupancy = (int) $network['players']['occupancy_percent'];
}
$is_player_high = $occupancy !== null && $occupancy >= 95;
if ($is_player_high && !$previous_player_high) {
self::push_message($state, 'warning', 'Spieler-Auslastung hoch', 'Spieler-Auslastung liegt bei ' . $occupancy . '%.');
}
if (!$is_player_high && $previous_player_high) {
self::push_message($state, 'success', 'Spieler-Auslastung normalisiert', 'Spieler-Auslastung ist wieder unter 95%.');
}
$state['last_player_high'] = $is_player_high;
}
if ($ok) {
$state['last_success_at'] = $now;
$state['last_success_code'] = $code;
}
self::save_state($state);
return array(
'status' => $ok ? 'ok' : 'error',
'label' => $ok ? 'Online' : 'Fehler',
'meta' => $ok ? 'Antwort von ' . untrailingslashit($response_url) : 'HTTP ' . $code,
'http_code' => $code,
'checked_at_human' => self::format_timestamp($now),
'metrics' => $ok ? self::extract_metrics($decoded) : self::empty_metrics(),
'debug' => array(
'url_used' => $response_url,
'attempts' => $attempts,
'error' => $ok ? '' : 'HTTP ' . $code,
'raw_data' => $body,
),
);
}
private static function extract_metrics($decoded) {
if (!is_array($decoded)) {
return self::empty_metrics();
}
$players_list_count = self::count_players_fallback($decoded);
// StatusAPI-Format
$network = isset($decoded['network']) && is_array($decoded['network']) ? $decoded['network'] : array();
if (!empty($network)) {
$players = isset($network['players']) && is_array($network['players']) ? $network['players'] : array();
$memory = isset($network['memory']) && is_array($network['memory']) ? $network['memory'] : array();
// Wie in MC-Player-History: players[] als primäre Quelle bevorzugen.
$online_players = $players_list_count !== 'n/a'
? $players_list_count
: self::array_value($players, 'online', 'n/a');
$max_players_raw = self::array_value($players, 'max', self::array_value($decoded, 'max_players', 'n/a'));
$max_players = self::normalize_max_players($max_players_raw);
$occupancy = self::array_value($players, 'occupancy_percent', null);
$uptime_human = self::array_value($network, 'uptime_human', 'n/a');
$uptime_seconds = self::array_value($network, 'uptime_seconds', null);
$memory_percent = self::array_value($memory, 'usage_percent', null);
$memory_used = self::array_value($memory, 'used_mb', 'n/a');
$memory_max = self::array_value($memory, 'max_mb', 'n/a');
$memory_value = 'n/a';
if ($memory_percent !== null && $memory_percent !== 'n/a' && $memory_percent !== '') {
$memory_value = ((string) $memory_percent) . '%';
}
return array(
'online_players' => (string) $online_players,
'online_meta' => $occupancy !== null ? 'Auslastung ' . $occupancy . '%' : 'Aktuell verbundene Spieler',
'max_players' => (string) $max_players,
'max_meta' => 'Player-Limit laut Proxy',
'uptime' => (string) $uptime_human,
'uptime_meta' => $uptime_seconds !== null ? ((string) $uptime_seconds) . ' Sekunden' : 'Keine Uptime-Daten',
'memory' => $memory_value,
'memory_meta' => $memory_used . ' MB von ' . $memory_max . ' MB',
);
}
// BungeeCord-Format
$is_bungeecord = isset($decoded['players']) && is_array($decoded['players']) && isset($decoded['online']);
if ($is_bungeecord) {
return self::extract_metrics_bungeecord($decoded);
}
return self::empty_metrics();
}
private static function extract_metrics_bungeecord($decoded) {
if (!is_array($decoded)) {
return self::empty_metrics();
}
$players_array = isset($decoded['players']) && is_array($decoded['players']) ? $decoded['players'] : array();
$online_raw = self::array_value($decoded, 'online', null);
$is_online = $online_raw === true || $online_raw === 1 || $online_raw === '1' || $online_raw === 'true';
$online_count = $is_online ? count($players_array) : '0';
$max_players = self::array_value($decoded, 'max_players', 'n/a');
if ($max_players === 'n/a' && !empty($players_array) && is_array($players_array[0]) && isset($players_array[0]['max'])) {
$max_players = (int) $players_array[0]['max'];
}
$max_players = self::normalize_max_players($max_players);
return array(
'online_players' => (string) $online_count,
'online_meta' => 'Spieler online (BungeeCord)',
'max_players' => (string) $max_players,
'max_meta' => 'Max Spieler',
'uptime' => 'n/a',
'uptime_meta' => 'BungeeCord Format (nicht verfügbar)',
'memory' => 'n/a',
'memory_meta' => 'BungeeCord Format (nicht verfügbar)',
);
}
private static function empty_metrics() {
return array(
'online_players' => 'n/a',
'online_meta' => 'Keine Live-Daten',
'max_players' => 'n/a',
'max_meta' => 'Keine Live-Daten',
'uptime' => 'n/a',
'uptime_meta' => 'Keine Live-Daten',
'memory' => 'n/a',
'memory_meta' => 'Keine Live-Daten',
);
}
private static function array_value($array, $key, $fallback) {
if (!is_array($array) || !array_key_exists($key, $array)) {
return $fallback;
}
return $array[$key];
}
private static function count_players_fallback($decoded) {
if (isset($decoded['players']) && is_array($decoded['players'])) {
return count($decoded['players']);
}
return 'n/a';
}
private static function normalize_max_players($value) {
// -1 bedeutet: BungeeCord hat keinen Listener-Wert und kein globales Limit.
// Wird ab StatusAPI 4.1+ eigentlich nicht mehr auftreten da Java den Listener-Wert liefert.
if ($value === -1 || $value === '-1' || (is_numeric($value) && (int) $value === -1)) {
return '∞';
}
if (is_numeric($value) && (int) $value <= 0) {
return 'n/a';
}
return $value;
}
private static function format_timestamp($timestamp) {
$timestamp = (int) $timestamp;
if ($timestamp <= 0) {
return 'Noch kein Wert';
}
return wp_date('d.m.Y H:i:s', $timestamp);
}
private static function build_notice() {
if (!isset($_GET['statusapi_notice'])) {
return null;
}
$code = sanitize_text_field(wp_unslash($_GET['statusapi_notice']));
if ($code === 'connection_ok') {
return array('type' => 'success', 'message' => 'StatusAPI ist erreichbar.');
}
if ($code === 'attack_ok') {
return array('type' => 'success', 'message' => 'Test Attack-Meldung wurde erfolgreich an den Proxy gesendet.');
}
if ($code === 'missing_url') {
return array('type' => 'error', 'message' => 'Bitte zuerst die StatusAPI Basis-URL eintragen.');
}
if ($code === 'missing_attack_key') {
return array('type' => 'error', 'message' => 'Bitte zuerst einen Attack API-Key speichern.');
}
if ($code === 'connection_failed') {
$state = self::get_state();
$suffix = '';
if (!empty($state['last_error'])) {
$suffix = ' Letzter Fehler: ' . (string) $state['last_error'];
}
return array('type' => 'error', 'message' => 'Verbindungstest fehlgeschlagen. Bitte Basis-URL und Erreichbarkeit prüfen.' . $suffix);
}
if ($code === 'attack_failed') {
return array('type' => 'error', 'message' => 'Test Attack-Meldung konnte nicht gesendet werden. API-Key und Proxy-Endpunkt prüfen.');
}
if ($code === 'messages_cleared') {
return array('type' => 'success', 'message' => 'Meldungen wurden erfolgreich geleert.');
}
return null;
}
public static function handle_test_action() {
if (!current_user_can('manage_options')) {
wp_die('Nicht erlaubt.');
}
check_admin_referer('statusapi_backend_helper_test');
$options = self::get_options();
$base_url = $options['statusapi_base_url'];
if ($base_url === '') {
self::redirect_with_notice('missing_url');
}
if (isset($_POST['test_connection'])) {
$probe = self::probe_statusapi($base_url);
$ok = isset($probe['status']) && $probe['status'] === 'ok';
$state = self::get_state();
self::push_message(
$state,
$ok ? 'success' : 'error',
$ok ? 'Verbindungstest erfolgreich' : 'Verbindungstest fehlgeschlagen',
$ok
? 'Manueller Verbindungstest im WordPress-Backend war erfolgreich.'
: 'Manueller Verbindungstest im WordPress-Backend war nicht erfolgreich. ' . (isset($probe['meta']) ? (string) $probe['meta'] : '')
);
self::save_state($state);
self::redirect_with_notice($ok ? 'connection_ok' : 'connection_failed');
}
if (isset($_POST['test_attack'])) {
if ($options['attack_api_key'] === '') {
self::redirect_with_notice('missing_attack_key');
}
$payload = array(
'event' => 'detected',
'source' => $options['attack_source'] !== '' ? $options['attack_source'] : 'WordPress',
'connectionsPerSecond' => 250,
'ipAddressesBlocked' => 12,
'connectionsBlocked' => 1800,
);
$response = self::perform_request_with_fallback(
$base_url . '/network/attack',
'POST',
array(
'Content-Type' => 'application/json; charset=utf-8',
'X-API-Key' => $options['attack_api_key'],
),
wp_json_encode($payload),
8
);
if (!empty($response['error'])) {
$state = self::get_state();
$state['last_check_at'] = time();
$state['last_http_code'] = 0;
$state['last_status'] = 'error';
$state['last_error'] = $response['error'];
self::push_message($state, 'error', 'Test-Attack fehlgeschlagen', 'Konnte keine Test-Meldung senden: ' . $response['error']);
self::save_state($state);
self::redirect_with_notice('attack_failed');
}
$code = (int) $response['code'];
$state = self::get_state();
$state['last_check_at'] = time();
$state['last_http_code'] = $code;
$state['last_status'] = ($code >= 200 && $code < 300) ? 'ok' : 'error';
$state['last_error'] = ($code >= 200 && $code < 300) ? '' : 'HTTP ' . $code;
if ($code >= 200 && $code < 300) {
$state['last_success_at'] = $state['last_check_at'];
$state['last_success_code'] = $code;
self::push_message($state, 'success', 'Test-Attack gesendet', 'Die Test-Attack-Meldung wurde erfolgreich an den Proxy gesendet.');
} else {
self::push_message($state, 'error', 'Test-Attack fehlgeschlagen', 'Der Proxy antwortete mit HTTP ' . $code . '.');
}
self::save_state($state);
self::redirect_with_notice($code >= 200 && $code < 300 ? 'attack_ok' : 'attack_failed');
}
self::redirect_with_notice('connection_failed');
}
public static function handle_clear_messages() {
if (!current_user_can('manage_options')) {
wp_die('Nicht erlaubt.');
}
check_admin_referer('statusapi_backend_helper_clear_messages');
$state = self::get_state();
$redirect_page = isset($_POST['redirect_page']) ? sanitize_text_field(wp_unslash($_POST['redirect_page'])) : self::MENU_SLUG;
if ($redirect_page === self::LIST_SLUG) {
self::save_history(array());
} else {
$state['messages'] = array();
}
self::save_state($state);
if ($redirect_page !== self::MESSAGES_SLUG) {
if ($redirect_page !== self::LIST_SLUG) {
$redirect_page = self::MENU_SLUG;
}
}
self::redirect_with_notice('messages_cleared', $redirect_page);
}
public static function handle_ajax_refresh() {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'forbidden'), 403);
}
check_ajax_referer('statusapi_backend_helper_refresh');
$options = self::get_options();
$live_status = self::probe_statusapi($options['statusapi_base_url']);
$state = self::get_state();
$debug_data = isset($live_status['debug']) && is_array($live_status['debug']) ? $live_status['debug'] : array();
wp_send_json_success(array(
'refresh_state' => 'Aktualisiert ' . self::format_timestamp($state['last_check_at']),
'messages_html' => self::build_messages_markup($state),
'debug' => array(
'url_used' => isset($debug_data['url_used']) ? (string) $debug_data['url_used'] : '',
'attempts' => isset($debug_data['attempts']) && is_array($debug_data['attempts']) ? $debug_data['attempts'] : array(),
'error' => isset($debug_data['error']) ? (string) $debug_data['error'] : '',
'raw_data' => isset($debug_data['raw_data']) ? (string) $debug_data['raw_data'] : '',
),
'status_cards' => array(
'proxy' => array(
'value' => $live_status['label'],
'meta' => $live_status['meta'],
'status' => $live_status['status'],
),
'http' => array(
'value' => $live_status['http_code'] > 0 ? (string) $live_status['http_code'] : 'n/a',
'meta' => $live_status['checked_at_human'],
'status' => $live_status['status'],
),
'success' => array(
'value' => self::format_timestamp($state['last_success_at']),
'meta' => $state['last_success_code'] > 0 ? 'HTTP ' . $state['last_success_code'] : 'Noch kein erfolgreicher Check gespeichert',
'status' => $state['last_success_at'] > 0 ? 'ok' : 'unknown',
),
'lastcheck' => array(
'value' => self::format_timestamp($state['last_check_at']),
'meta' => $state['last_error'] !== '' ? $state['last_error'] : 'Keine Fehler gespeichert',
'status' => $state['last_status'],
),
),
'metric_cards' => array(
'online' => array(
'value' => $live_status['metrics']['online_players'],
'meta' => $live_status['metrics']['online_meta'],
),
'max' => array(
'value' => $live_status['metrics']['max_players'],
'meta' => $live_status['metrics']['max_meta'],
),
'uptime' => array(
'value' => $live_status['metrics']['uptime'],
'meta' => $live_status['metrics']['uptime_meta'],
),
'memory' => array(
'value' => $live_status['metrics']['memory'],
'meta' => $live_status['metrics']['memory_meta'],
),
),
));
}
private static function request_ok($url) {
$candidate_urls = self::get_candidate_base_urls($url);
if (empty($candidate_urls)) {
return false;
}
foreach ($candidate_urls as $try_url) {
$response = self::perform_request_with_fallback($try_url, 'GET', array(), '', 8);
if (!empty($response['error'])) {
error_log('StatusPulse: request_ok() fehlgeschlagen fuer ' . $try_url . ' - ' . $response['error']);
continue;
}
$code = (int) $response['code'];
$decoded = json_decode((string) $response['body'], true);
error_log('StatusPulse: request_ok() fuer ' . $try_url . ' -> HTTP ' . $code);
if ($code >= 200 && $code < 300 && self::is_supported_api_payload($decoded)) {
return true;
}
}
return false;
}
private static function fetch_attacker_entries($base_url) {
// Schritt 1: Arbeitsfähige Basis-URL ermitteln (erste die valide antwortet)
$candidate_urls = self::get_candidate_base_urls($base_url);
if (empty($candidate_urls)) {
return array(
'entries' => array(),
'error' => 'StatusAPI Basis-URL ist nicht konfiguriert.',
);
}
// Finde die funktionierende Basis-URL (die echtes StatusAPI-JSON liefert)
$working_base = '';
foreach ($candidate_urls as $try_url) {
// Entferne bekannte Pfad-Suffixe damit wir nur die Root-URL testen
$root_url = rtrim(preg_replace('#/(health|antibot/security-log|broadcast|network/attack)$#', '', $try_url), '/');
if ($root_url === '') {
continue;
}
$probe = self::perform_request_with_fallback($root_url, 'GET', array(), '', 8);
if (empty($probe['error'])) {
$probe_code = (int) $probe['code'];
$probe_decoded = json_decode((string) $probe['body'], true);
if ($probe_code >= 200 && $probe_code < 300 && self::is_supported_api_payload($probe_decoded)) {
$working_base = $root_url;
break;
}
}
}
if ($working_base === '') {
return array(
'entries' => array(),
'error' => 'StatusAPI konnte nicht erreicht werden. Bitte Basis-URL und Erreichbarkeit prüfen.',
);
}
// Schritt 2: Dedizierter Endpunkt /antibot/security-log abrufen
$security_log_url = rtrim($working_base, '/') . '/antibot/security-log';
$response = self::perform_request_with_fallback($security_log_url, 'GET', array(), '', 8);
if (!empty($response['error'])) {
return array(
'entries' => array(),
'error' => 'Konnte Security-Log nicht laden: ' . $response['error'],
);
}
$code = (int) $response['code'];
if ($code < 200 || $code >= 300) {
return array(
'entries' => array(),
'error' => 'StatusAPI /antibot/security-log lieferte HTTP ' . $code . '.',
);
}
$body = (string) $response['body'];
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
return array(
'entries' => array(),
'error' => 'Antwort vom Security-Log-Endpunkt ist kein gültiges JSON.',
);
}
$entries = array();
// StatusAPI liefert { "success": true, "events": [...] }
if (isset($decoded['events']) && is_array($decoded['events'])) {
foreach (array_slice($decoded['events'], 0, 100) as $entry) {
if (is_array($entry)) {
$entries[] = $entry;
}
}
}
return array(
'entries' => $entries,
'error' => count($entries) === 0 ? 'Keine Angriffsversuche im Log vorhanden.' : '',
);
}
private static function redirect_with_notice($code, $page = self::MENU_SLUG) {
$target_page = self::MENU_SLUG;
if ($page === self::MESSAGES_SLUG) {
$target_page = self::MESSAGES_SLUG;
} elseif ($page === self::LIST_SLUG) {
$target_page = self::LIST_SLUG;
}
$url = add_query_arg(
array(
'page' => $target_page,
'statusapi_notice' => $code,
),
admin_url('admin.php')
);
wp_safe_redirect($url);
exit;
}
private static function render_styles() {
echo '';
}
private static function render_scripts($refresh_nonce) {
echo '';
}
}
StatusAPI_Backend_Helper::boot();
}