fix: real connection validation for Central API settings

- Add SmartHoneypotAPIClient::test_connection():
  1. GET /api/v1/health — verifies URL is reachable
  2. POST /api/v1/submit with empty blocks — verifies token:
     400 = auth passed (payload rejected as expected)
     403 = wrong or missing token
- Store connection_ok (null/true/false), last_verified, last_error in settings
- 'Active' dot now has 3 states: grey=untested, green=verified, red=failed
- Error message displayed inline when connection fails
- 'Test Connection' button appears as soon as URL is set
- Saving with changed URL or token resets connection status to untested
- Save action preserves verified status when URL/token unchanged
This commit is contained in:
2026-03-09 19:40:18 +01:00
parent f4f28db8b2
commit b6be526b46

View File

@@ -160,14 +160,63 @@ class SmartHoneypotAPIClient {
public static function defaults(): array { public static function defaults(): array {
return [ return [
'enabled' => false, 'enabled' => false,
'api_url' => '', 'api_url' => '',
'api_token' => '', 'api_token' => '',
'last_sync' => 0, 'last_sync' => 0,
'sent_total' => 0, 'sent_total' => 0,
'connection_ok' => null, // null=untested, true=ok, false=failed
'last_verified' => 0,
'last_error' => '',
]; ];
} }
/**
* Tests reachability (health endpoint) and auth (submit with bad payload).
* Returns ['ok' => bool, 'message' => string]
*/
public static function test_connection(): array {
$s = self::settings();
if (empty($s['api_url'])) {
return ['ok' => false, 'message' => 'No API URL configured.'];
}
$base = trailingslashit(esc_url_raw($s['api_url']));
$headers = ['Content-Type' => 'application/json'];
if (!empty($s['api_token'])) {
$headers['Authorization'] = 'Bearer ' . $s['api_token'];
}
// Step 1 — reachability
$health = wp_remote_get($base . 'api/v1/health', ['timeout' => 8]);
if (is_wp_error($health)) {
return ['ok' => false, 'message' => 'Cannot reach API: ' . $health->get_error_message()];
}
$code = wp_remote_retrieve_response_code($health);
if ($code !== 200) {
return ['ok' => false, 'message' => "API returned HTTP {$code}. Verify the URL is correct."];
}
// Step 2 — token validation: POST with an intentionally invalid payload.
// Auth passes → 400 (bad payload). Wrong/missing token → 403.
$auth = wp_remote_post($base . 'api/v1/submit', [
'timeout' => 8,
'headers' => $headers,
'body' => wp_json_encode(['site_hash' => 'connectivity_test', 'blocks' => []]),
]);
if (is_wp_error($auth)) {
return ['ok' => false, 'message' => 'Token check request failed: ' . $auth->get_error_message()];
}
$auth_code = wp_remote_retrieve_response_code($auth);
if ($auth_code === 403) {
return ['ok' => false, 'message' => 'API reachable but token rejected (HTTP 403). Check the token matches API_TOKEN in Docker.'];
}
// 400 = auth passed, payload correctly rejected — exactly what we expect
return ['ok' => true, 'message' => 'Connection verified. API is reachable and token is accepted.'];
}
public static function settings(): array { public static function settings(): array {
return wp_parse_args(get_option(self::OPT_SETTINGS, []), self::defaults()); return wp_parse_args(get_option(self::OPT_SETTINGS, []), self::defaults());
} }
@@ -311,19 +360,40 @@ class SmartHoneypotAdmin {
} }
if ($_POST['hp_action'] === 'save_api_settings') { if ($_POST['hp_action'] === 'save_api_settings') {
$current = SmartHoneypotAPIClient::settings(); $current = SmartHoneypotAPIClient::settings();
$new_url = esc_url_raw(trim($_POST['hp_api_url'] ?? ''));
$new_token = sanitize_text_field($_POST['hp_api_token'] ?? '');
$url_changed = $new_url !== $current['api_url'];
$tok_changed = $new_token !== $current['api_token'];
$new = [ $new = [
'enabled' => !empty($_POST['hp_api_enabled']), 'enabled' => !empty($_POST['hp_api_enabled']),
'api_url' => esc_url_raw(trim($_POST['hp_api_url'] ?? '')), 'api_url' => $new_url,
'api_token' => sanitize_text_field($_POST['hp_api_token'] ?? ''), 'api_token' => $new_token,
'last_sync' => $current['last_sync'], 'last_sync' => $current['last_sync'],
'sent_total' => $current['sent_total'], 'sent_total' => $current['sent_total'],
// Reset verification if URL or token changed
'connection_ok' => ($url_changed || $tok_changed) ? null : $current['connection_ok'],
'last_verified' => ($url_changed || $tok_changed) ? 0 : $current['last_verified'],
'last_error' => ($url_changed || $tok_changed) ? '' : $current['last_error'],
]; ];
update_option(SmartHoneypotAPIClient::OPT_SETTINGS, $new); update_option(SmartHoneypotAPIClient::OPT_SETTINGS, $new);
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'saved' => 1], admin_url('admin.php'))); wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'saved' => 1], admin_url('admin.php')));
exit; exit;
} }
if ($_POST['hp_action'] === 'test_connection') {
$result = SmartHoneypotAPIClient::test_connection();
$s = SmartHoneypotAPIClient::settings();
$s['connection_ok'] = $result['ok'];
$s['last_verified'] = time();
$s['last_error'] = $result['ok'] ? '' : $result['message'];
update_option(SmartHoneypotAPIClient::OPT_SETTINGS, $s);
set_transient('hp_conn_result', $result, 60);
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'tested' => 1], admin_url('admin.php')));
exit;
}
if ($_POST['hp_action'] === 'flush_queue') { if ($_POST['hp_action'] === 'flush_queue') {
SmartHoneypotAPIClient::flush(); SmartHoneypotAPIClient::flush();
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'flushed' => 1], admin_url('admin.php'))); wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'flushed' => 1], admin_url('admin.php')));
@@ -350,6 +420,13 @@ class SmartHoneypotAdmin {
<?php if (!empty($_GET['flushed'])): ?> <?php if (!empty($_GET['flushed'])): ?>
<div class="notice notice-success is-dismissible"><p>Queue flushed to central API.</p></div> <div class="notice notice-success is-dismissible"><p>Queue flushed to central API.</p></div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($_GET['tested'])):
$res = get_transient('hp_conn_result');
if ($res):
$cls = $res['ok'] ? 'notice-success' : 'notice-error'; ?>
<div class="notice <?= $cls ?> is-dismissible"><p><?= esc_html($res['message']) ?></p></div>
<?php endif;
endif; ?>
<nav class="nav-tab-wrapper hp-tabs"> <nav class="nav-tab-wrapper hp-tabs">
<a href="<?= esc_url(admin_url('admin.php?page=' . self::MENU_SLUG . '&tab=logs')) ?>" <a href="<?= esc_url(admin_url('admin.php?page=' . self::MENU_SLUG . '&tab=logs')) ?>"
@@ -489,8 +566,8 @@ class SmartHoneypotAdmin {
<div style="max-width:700px;margin-top:20px"> <div style="max-width:700px;margin-top:20px">
<h2>Central API Settings</h2> <h2>Central API Settings</h2>
<p style="color:#646970;margin-bottom:16px"> <p style="color:#646970;margin-bottom:16px">
Submit blocked attempts anonymously to a central dashboard for aggregate threat intelligence. Submit blocked attempts to a central dashboard for aggregate threat intelligence.
Only anonymised data is sent: masked IPs (first 2 octets only), form type, block reason, and UA family. No site URL, no full IPs. Data sent: full IP address, form type, block reason, UA family, and timestamp. No site URL or usernames are ever sent.
</p> </p>
<form method="post"> <form method="post">
@@ -533,30 +610,63 @@ class SmartHoneypotAdmin {
<hr> <hr>
<h3>Submission Status</h3> <h3>Connection Status</h3>
<?php
// Resolve dot state: null=untested, true=verified, false=failed
$conn = $s['connection_ok'];
if ($conn === true) {
$dot_class = 'dot-on';
$dot_label = 'Verified — connected and token accepted';
$dot_style = '';
} elseif ($conn === false) {
$dot_class = 'dot-fail';
$dot_label = 'Connection failed';
$dot_style = 'background:#b32d2e;';
} else {
$dot_class = 'dot-off';
$dot_label = 'Not tested yet';
$dot_style = '';
}
?>
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th>Status</th> <th>Connection</th>
<td> <td>
<span class="hp-api-status"> <span class="hp-api-status">
<span class="dot <?= $s['enabled'] && $s['api_url'] ? 'dot-on' : 'dot-off' ?>"></span> <span class="dot <?= $dot_class ?>" style="<?= $dot_style ?>"></span>
<?= $s['enabled'] && $s['api_url'] ? 'Active' : 'Inactive' ?> <?= esc_html($dot_label) ?>
</span> </span>
<?php if ($conn === false && $s['last_error']): ?>
<p class="description" style="color:#b32d2e;margin-top:4px"><?= esc_html($s['last_error']) ?></p>
<?php endif; ?>
</td> </td>
</tr> </tr>
<?php if ($s['last_verified']): ?>
<tr><th>Last Verified</th><td><?= esc_html(date('Y-m-d H:i:s', $s['last_verified'])) ?></td></tr>
<?php endif; ?>
<tr><th>Enabled</th><td><?= $s['enabled'] ? 'Yes' : 'No' ?></td></tr>
<tr><th>Last Sync</th><td><?= $s['last_sync'] ? esc_html(date('Y-m-d H:i:s', $s['last_sync'])) : 'Never' ?></td></tr> <tr><th>Last Sync</th><td><?= $s['last_sync'] ? esc_html(date('Y-m-d H:i:s', $s['last_sync'])) : 'Never' ?></td></tr>
<tr><th>Total Sent</th><td><?= number_format((int)$s['sent_total']) ?> blocks</td></tr> <tr><th>Total Sent</th><td><?= number_format((int) $s['sent_total']) ?> blocks</td></tr>
<tr><th>Queue Size</th><td><?= number_format($queue_size) ?> pending blocks</td></tr> <tr><th>Queue Size</th><td><?= number_format($queue_size) ?> pending</td></tr>
<tr><th>Next Auto-Flush</th><td><?= $next_run ? esc_html(date('Y-m-d H:i:s', $next_run)) . ' (every 5 min)' : 'Not scheduled' ?></td></tr> <tr><th>Next Auto-Flush</th><td><?= $next_run ? esc_html(date('Y-m-d H:i:s', $next_run)) . ' (every 5 min)' : 'Not scheduled' ?></td></tr>
</table> </table>
<?php if ($queue_size > 0 && $s['enabled'] && $s['api_url']): ?> <div style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;align-items:center">
<form method="post" style="margin-top:12px"> <?php if ($s['api_url']): ?>
<?php wp_nonce_field(self::NONCE_ACTION); ?> <form method="post">
<input type="hidden" name="hp_action" value="flush_queue"> <?php wp_nonce_field(self::NONCE_ACTION); ?>
<button type="submit" class="button button-secondary">Flush Queue Now (<?= $queue_size ?> pending)</button> <input type="hidden" name="hp_action" value="test_connection">
</form> <button type="submit" class="button button-primary">Test Connection</button>
<?php endif; ?> </form>
<?php endif; ?>
<?php if ($queue_size > 0 && $s['enabled'] && $s['api_url']): ?>
<form method="post">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="flush_queue">
<button type="submit" class="button button-secondary">Flush Queue Now (<?= $queue_size ?> pending)</button>
</form>
<?php endif; ?>
</div>
</div> </div>
<?php <?php
} }