/api/security_events.php, autenticado com a // MESMA chave com que o hub sonda esta plataforma (X-Hub-Key). A resposta traz // os banimentos activos da frota, que entram no ip_bans local com // source='fleet' — um IP banido na dance passa a ser recusado na fotografia. // // Porquê uma caixa de saída e não um POST por evento: um ataque gera centenas // de recusas por minuto. Um pedido HTTP por recusa punha cada pedido do // atacante à espera da rede e transformava o ataque a um site num ataque ao // hub. Em lote, mil recusas são um pedido. // // Quando é que o lote sai: // • no fim de um pedido qualquer (platform_ip_guard() regista-o), no máximo a // cada HUB_SECURITY_FLUSH_EVERY s se há eventos e HUB_SECURITY_SYNC_EVERY s // se não há — sem eventos continua a sincronizar, é assim que os // banimentos dos OUTROS sites chegam aqui; // • pelo cron: bin/hub-security-sync.php, que esvazia a caixa toda. // // Nada disto bloqueia nem rebenta. Sem hub emparelhado não se regista nada // (senão a caixa crescia para sempre); com o hub em baixo os eventos esperam, // com tecto, e os banimentos locais funcionam exactamente como antes. // // Origem de cada decisão sobre banimentos da frota: // • um banimento chega ao hub só a partir do site que o decidiu — as cópias // (source='fleet') nunca são reenviadas, senão ecoavam em ciclo; // • levantar um banimento em QUALQUER site levanta-o na frota: as cópias // desaparecem de todos na sincronização seguinte. Sem isto, um unban local // de uma cópia era desfeito cinco minutos depois. O site de origem mantém // o seu próprio banimento até expirar ou ser levantado lá. require_once __DIR__ . '/functions_client_ip.php'; const HUB_SECURITY_FLUSH_EVERY = 30; const HUB_SECURITY_SYNC_EVERY = 300; const HUB_SECURITY_BATCH = 200; // Tecto da caixa com o hub em baixo. Os banimentos nunca são descartados; o // resto sai do mais antigo para o mais recente. const HUB_SECURITY_OUTBOX_MAX = 5000; function hub_security_enabled(): bool { static $on = null; if ($on !== null) return $on; try { if (!function_exists('hub_url')) require_once __DIR__ . '/functions_hub.php'; $on = hub_url() !== '' && hub_api_key() !== ''; } catch (Throwable $e) { $on = false; } return $on; } /** * Carimbos em ficheiro, para o fim de cada pedido decidir «há sincronização a * fazer?» com dois stat() e sem tocar na base de dados. */ function hub_security_stamp(string $name): string { return rtrim(sys_get_temp_dir(), '/\\') . '/hub-security-' . substr(md5(dirname(__DIR__, 2)), 0, 12) . '-' . $name; } function hub_security_ensure_table(PDO $pdo): void { static $done = false; if ($done) return; $done = true; // occurred_at em epoch e não DATETIME: o hub corre noutro contentor, e um // DATETIME sem fuso lido do outro lado seria uma hora errada em metade do ano. $pdo->exec( "CREATE TABLE IF NOT EXISTS `hub_security_outbox` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `kind` VARCHAR(40) NOT NULL, `ip` VARCHAR(45) NULL, `detail` VARCHAR(190) NULL, `data` TEXT NULL, `occurred_at` INT UNSIGNED NOT NULL, `attempts` INT NOT NULL DEFAULT 0, KEY `idx_hso_kind` (`kind`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); } /** * Destino alternativo dos eventos: fn(?PDO, kind, ?detail, ?ip, array data). * É o hub que se regista aqui — ele é o registo da frota, e mandar os seus * próprios eventos a si mesmo por HTTP exigia emparelhá-lo consigo próprio. */ function platform_security_event_sink(?callable $sink = null): ?callable { static $current = null; if ($sink !== null) $current = $sink; return $current; } /** * Regista um evento para o hub. Nunca lança e nunca espera pela rede. * * $kind curto e estável (ip_ban, ip_unban, offence, login_failed, * login_throttled, csrf_failed, mail_rate_limited): é por ele que o hub agrupa. */ function platform_security_event(?PDO $pdo, string $kind, ?string $detail = null, ?string $ip = null, array $data = []): void { try { $ip ??= platform_client_ip(); $ip = ($ip !== null && $ip !== '') ? $ip : null; // Os detalhes das infracções trazem às vezes o email de quem submeteu // (o antispam usa-o como assunto). Não tem de sair do site para o hub. if ($detail !== null) { $detail = preg_replace_callback('/[^\s@<>"\'(),;:]+@[^\s@<>"\'(),;:]+\.[a-z]{2,}/i', static fn(array $m): string => hub_security_mask_identifier($m[0]), $detail); } $sink = platform_security_event_sink(); if ($sink !== null) { $sink($pdo ?? (($GLOBALS['pdo'] ?? null) instanceof PDO ? $GLOBALS['pdo'] : null), $kind, $detail, $ip, $data); return; } if (!hub_security_enabled()) return; $pdo ??= hub_pdo(); if (!$pdo instanceof PDO) return; hub_security_ensure_table($pdo); $pdo->prepare( 'INSERT INTO hub_security_outbox (kind, ip, detail, data, occurred_at) VALUES (?, ?, ?, ?, ?)' )->execute([ mb_substr($kind, 0, 40), $ip, $detail === null ? null : mb_substr($detail, 0, 190), $data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, time(), ]); @touch(hub_security_stamp('dirty')); } catch (Throwable $e) { error_log('[hub-security] evento não registado: ' . $e->getMessage()); } } /** «a***@exemplo.pt» — chega para ver um ataque a uma conta sem mandar o email ao hub. */ function hub_security_mask_identifier(string $id): string { $id = mb_strtolower(trim($id)); $at = strrpos($id, '@'); if ($at === false) return mb_substr($id, 0, 1) . '***'; return mb_substr($id, 0, 1) . '***' . substr($id, $at); } /** * Chamado cedo em cada pedido (pelo platform_ip_guard). Só agenda: o trabalho * corre no fim, depois de a resposta sair quando o SAPI o permite. */ function hub_security_maybe_sync(PDO $pdo): void { static $registered = false; if ($registered) return; $registered = true; register_shutdown_function(static function () use ($pdo): void { try { if (!hub_security_due() || !hub_security_enabled()) return; if (function_exists('fastcgi_finish_request')) { @fastcgi_finish_request(); } hub_security_sync($pdo, 1); } catch (Throwable $e) { error_log('[hub-security] sincronização falhou: ' . $e->getMessage()); } }); } function hub_security_due(): bool { $attempt = hub_security_stamp('attempt'); $last = @filemtime($attempt) ?: 0; $every = is_file(hub_security_stamp('dirty')) && !is_file(hub_security_stamp('failing')) ? HUB_SECURITY_FLUSH_EVERY : HUB_SECURITY_SYNC_EVERY; if (time() - $last < $every) return false; // Marcar a tentativa ANTES de a fazer: os pedidos concorrentes desistem, e // um hub em baixo não é tentado a cada pedido. Sem pasta temporária // gravável não há como saber, e fica tudo para o cron. return @touch($attempt); } /** * Envia a caixa de saída e aplica a lista da frota que vem na resposta. * $maxBatches: 1 no fim de um pedido, mais no cron. */ function hub_security_sync(PDO $pdo, int $maxBatches = 25): array { if (!hub_security_enabled()) return ['ok' => false, 'error' => 'plataforma não emparelhada com o hub']; if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL indisponível']; hub_security_ensure_table($pdo); @touch(hub_security_stamp('attempt')); // Um só sincronizador de cada vez, entre todos os processos PHP do site. O // nome leva a base de dados: os locks do MariaDB são do servidor inteiro, e a // frota partilha o servidor — sem isto a sincronização de um site fazia os // outros desistir. if ((int) $pdo->query("SELECT GET_LOCK(CONCAT('hub_security_sync:', DATABASE()), 0)")->fetchColumn() !== 1) { return ['ok' => true, 'skipped' => 'já a correr']; } $out = ['ok' => true, 'sent' => 0, 'fleet' => null, 'error' => null]; try { hub_security_trim_outbox($pdo); for ($i = 0; $i < max(1, $maxBatches); $i++) { $rows = $pdo->query( 'SELECT * FROM hub_security_outbox ORDER BY id LIMIT ' . HUB_SECURITY_BATCH )->fetchAll(PDO::FETCH_ASSOC); $events = []; foreach ($rows as $r) { $events[] = [ 'kind' => $r['kind'], 'ip' => $r['ip'], 'detail' => $r['detail'], 'data' => $r['data'] !== null ? json_decode((string) $r['data'], true) : null, 'ts' => (int) $r['occurred_at'], ]; } $res = hub_security_post(['events' => $events]); if (!$res['ok']) { if ($rows) { $ids = implode(',', array_map('intval', array_column($rows, 'id'))); $pdo->exec("UPDATE hub_security_outbox SET attempts = attempts + 1 WHERE id IN ($ids)"); } @touch(hub_security_stamp('failing')); $out['ok'] = false; $out['error'] = $res['error']; error_log('[hub-security] envio falhou: ' . $res['error']); break; } @unlink(hub_security_stamp('failing')); if ($rows) { $ids = implode(',', array_map('intval', array_column($rows, 'id'))); $pdo->exec("DELETE FROM hub_security_outbox WHERE id IN ($ids)"); $out['sent'] += count($rows); } if (is_array($res['data']['fleet'] ?? null)) { $out['fleet'] = hub_security_apply_fleet($pdo, $res['data']['fleet']); } @touch(hub_security_stamp('synced')); if (count($rows) < HUB_SECURITY_BATCH) { // Contar de novo: um evento que entrou durante o envio tem de // manter a marca, senão esperava pela sincronização lenta. if ((int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn() === 0) { @unlink(hub_security_stamp('dirty')); } break; } } } finally { $pdo->query("SELECT RELEASE_LOCK(CONCAT('hub_security_sync:', DATABASE()))"); } return $out; } function hub_security_trim_outbox(PDO $pdo): void { $n = (int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn(); if ($n <= HUB_SECURITY_OUTBOX_MAX) return; $excess = $n - HUB_SECURITY_OUTBOX_MAX; $pdo->exec( "DELETE FROM hub_security_outbox WHERE kind NOT IN ('ip_ban', 'ip_unban') ORDER BY id LIMIT " . (int) $excess ); error_log("[hub-security] caixa de saída acima do tecto — $excess eventos antigos descartados"); } /** POST ao hub. ['ok'=>bool, 'data'=>array|null, 'error'=>string]. */ function hub_security_post(array $payload): array { $ch = curl_init(hub_url() . '/api/security_events.php'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), // Curto: no pior caso isto corre no fim de um pedido de um visitante. CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, // X-Hub-Key e não Authorization: o proxy come o Authorization (ver functions_hub.php). CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', 'X-Hub-Key: ' . hub_api_key()], CURLOPT_USERAGENT => 'andmore-platform-security/1.0', ]); $raw = curl_exec($ch); $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($raw === false || $err !== '') return ['ok' => false, 'data' => null, 'error' => $err ?: 'sem resposta']; $data = json_decode((string) $raw, true); if (!is_array($data)) return ['ok' => false, 'data' => null, 'error' => "resposta não-JSON (HTTP $http)"]; if (empty($data['ok'])) return ['ok' => false, 'data' => $data, 'error' => (string) ($data['error'] ?? "HTTP $http")]; return ['ok' => true, 'data' => $data, 'error' => '']; } /** * Aplica a lista da frota ao ip_bans local. * * Um banimento próprio activo ganha sempre à cópia — foi decidido aqui, com o * motivo daqui. As cópias que o hub deixou de mandar (levantadas ou expiradas) * saem, mas só quando a lista vem completa: uma lista truncada não diz nada * sobre o que ficou de fora. */ function hub_security_apply_fleet(PDO $pdo, array $fleet): array { require_once __DIR__ . '/functions_ip_ban.php'; platform_ip_ban_ensure_tables($pdo); $local = []; foreach ($pdo->query( "SELECT ip, source, reason, expires_at, (expires_at IS NULL OR expires_at > NOW()) AS active FROM ip_bans WHERE source = 'fleet' OR expires_at IS NULL OR expires_at > NOW()" ) as $r) { $local[$r['ip']] = $r; } $upsert = $pdo->prepare( "INSERT INTO ip_bans (ip, reason, source, expires_at, created_by) VALUES (?, ?, 'fleet', ?, NULL) ON DUPLICATE KEY UPDATE reason = VALUES(reason), source = 'fleet', expires_at = VALUES(expires_at), created_at = NOW(), created_by = NULL" ); $seen = []; $applied = 0; foreach ((array) ($fleet['bans'] ?? []) as $b) { $ip = trim((string) ($b['ip'] ?? '')); if (!filter_var($ip, FILTER_VALIDATE_IP) || platform_ip_ban_exempt($ip)) continue; $seen[$ip] = true; $cur = $local[$ip] ?? null; if ($cur !== null && $cur['source'] !== 'fleet' && (int) $cur['active'] === 1) continue; $ts = isset($b['expires_ts']) ? (int) $b['expires_ts'] : null; $expires = $ts ? date('Y-m-d H:i:s', $ts) : null; $reason = mb_substr('frota · ' . (string) ($b['origin'] ?? '?') . ' · ' . (string) ($b['reason'] ?? ''), 0, 190); if ($cur !== null && $cur['source'] === 'fleet' && $cur['reason'] === $reason && $cur['expires_at'] === $expires) { continue; } try { $upsert->execute([$ip, $reason, $expires]); $applied++; } catch (Throwable $e) { error_log('[hub-security] cópia de banimento não aplicada: ' . $e->getMessage()); } } $removed = 0; if (!empty($fleet['complete'])) { $del = $pdo->prepare("DELETE FROM ip_bans WHERE ip = ? AND source = 'fleet'"); foreach ($local as $ip => $r) { if ($r['source'] === 'fleet' && !isset($seen[$ip])) { $del->execute([$ip]); $removed++; } } } return ['received' => count($seen), 'applied' => $applied, 'removed' => $removed]; } /** * `checks.security` para o health do hub. A pergunta é «o envio está a * funcionar?»: eventos presos há mais de uma hora querem dizer que não. */ function hub_security_check(PDO $pdo): array { if (!hub_security_enabled()) return []; hub_security_ensure_table($pdo); $row = $pdo->query('SELECT COUNT(*) n, MIN(occurred_at) oldest FROM hub_security_outbox') ->fetch(PDO::FETCH_ASSOC); $pending = (int) ($row['n'] ?? 0); $oldest = $row['oldest'] !== null ? time() - (int) $row['oldest'] : 0; $fleet = 0; try { $fleet = (int) $pdo->query( "SELECT COUNT(*) FROM ip_bans WHERE source = 'fleet' AND (expires_at IS NULL OR expires_at > NOW())" )->fetchColumn(); } catch (Throwable) { /* ip_bans ainda por criar */ } $synced = @filemtime(hub_security_stamp('synced')) ?: null; $detail = $pending . ' por enviar' . ($synced ? ' · sincronizado há ' . max(0, (int) round((time() - $synced) / 60)) . ' min' : ' · nunca sincronizou') . ' · ' . $fleet . ' banimento(s) da frota'; return [ 'ok' => $oldest < 3600, 'detail' => $detail, 'pending' => $pending, 'oldest_seconds' => $oldest, 'fleet_bans' => $fleet, 'synced_at' => $synced ? date('c', $synced) : null, ]; }