Блокировка доступа по IP в .htaccess и PHP: лёгкий антибот с кэшем SQLite

Обновлено: 25.03.2026 17612

Блокировка доступа по IP — это один из самых простых способов отсечь часть нежелательного трафика: сканеры, мусорных ботов, повторяющиеся подозрительные обращения и IP-адреса с плохой репутацией. Но если строить такую защиту только на статичном списке в .htaccess, она быстро устаревает и превращается в неудобный для поддержки набор ручных правил.

Блокировка доступа по IP в .htaccess и PHP: SQLite-кэш и проверка через CleanTalk

Время прочтения:

Более практичный вариант для небольшого проекта — сделать лёгкий антибот на PHP: при первом появлении IP мы один раз проверяем его по публичной карточке репутации, сохраняем результат в SQLite, а затем повторно перепроверяем этот IP, например, раз в 30 дней. Так мы не дёргаем внешний сервис на каждом запросе, уменьшаем нагрузку и получаем понятный локальный кэш.

Важно: такой вариант — это именно лёгкая вспомогательная защита. Он не заменяет WAF, Cloudflare, fail2ban, rate limiting и другие полноценные механизмы безопасности. Кроме того, парсинг публичной HTML-страницы внешнего сервиса всегда остаётся компромиссным решением.

Блокировка доступа по IP в .htaccess и PHP: лёгкий антибот с кэшем SQLite

Когда такой подход уместен

Схема “внешняя проверка → локальный кэш в SQLite → повторная перепроверка раз в месяц” полезна, если:

  • нужна лёгкая защита без тяжёлой инфраструктуры;
  • хочется сократить число запросов к внешнему сервису репутации;
  • нужна локальная история по IP и статусам;
  • нужно быстро встроить антибот в существующий PHP-проект;
  • важно, чтобы защита не зависела от внешнего сервиса на каждом заходе.

На практике это особенно удобно для небольших сайтов, самописных админок, служебных кабинетов, внутренних разделов и старых PHP-проектов, где нужен аккуратный, но не слишком тяжёлый уровень фильтрации.

Почему лучше использовать SQLite-кэш

SQLite — хороший вариант для такого сценария, потому что база не требует отдельного сервера, хранится в одном файле, быстро подключается и отлично подходит для лёгких локальных служебных данных: denylist, логов, кэша и технических статусов.

Если проверять IP напрямую через внешний источник на каждом запросе, вы получаете сразу несколько проблем:

  • задержка ответа из-за сетевого запроса;
  • зависимость от доступности стороннего сервиса;
  • лишняя нагрузка;
  • риск ложных срабатываний при временных ошибках;
  • постоянный парсинг HTML-страницы, которая может измениться.

Локальный SQLite-кэш решает эту проблему: один раз проверили IP, записали статус, а затем до истечения TTL просто используем локальные данные.

Какую логику лучше использовать

Самая частая ошибка в подобных скриптах — делать слишком грубую бинарную модель: “если нашли на странице слово spam — сразу баним”. Это плохая идея. В реальности карточка IP может содержать одновременно исторические признаки активности и текущую пометку о том, что адрес сейчас не находится в активном blacklist.

Поэтому разумнее использовать не два, а четыре статуса:

  • clean — чистый адрес, сейчас не в списках;
  • suspicious — есть спорные или исторические сигналы, но нет жёсткого подтверждения;
  • blacklisted — есть явные признаки активной плохой репутации;
  • unknown — не удалось корректно определить статус.

Практический совет: для лёгкого антибота лучше жёстко блокировать только blacklisted, а для suspicious использовать мягкие меры: дополнительную проверку, капчу, ограничение частоты запросов или логирование.

Структура таблицы SQLite

Сначала создадим таблицу для кэша IP-адресов. Она будет хранить сам IP, статус, источник, дату последней проверки, срок следующей перепроверки и технические данные.

CREATE TABLE IF NOT EXISTS ip_reputation_cache (
    ip TEXT PRIMARY KEY,
    status TEXT NOT NULL DEFAULT 'unknown',
    source TEXT NOT NULL DEFAULT 'cleantalk_html',
    score INTEGER NOT NULL DEFAULT 0,
    first_seen_at TEXT NOT NULL,
    last_checked_at TEXT,
    next_check_at TEXT,
    expires_at TEXT,
    block_until TEXT,
    hit_count INTEGER NOT NULL DEFAULT 0,
    raw_excerpt TEXT,
    notes TEXT
);

CREATE INDEX IF NOT EXISTS idx_ip_reputation_next_check
    ON ip_reputation_cache(next_check_at);

CREATE INDEX IF NOT EXISTS idx_ip_reputation_status
    ON ip_reputation_cache(status);

Подключение SQLite в PHP

Для работы с таким кэшем удобно использовать PDO и отдельный файл базы, например: /var/security/ip_reputation.sqlite.

<?php
declare(strict_types=1);

$pdo = new PDO('sqlite:' . __DIR__ . '/var/security/ip_reputation.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$pdo->exec("
CREATE TABLE IF NOT EXISTS ip_reputation_cache (
    ip TEXT PRIMARY KEY,
    status TEXT NOT NULL DEFAULT 'unknown',
    source TEXT NOT NULL DEFAULT 'cleantalk_html',
    score INTEGER NOT NULL DEFAULT 0,
    first_seen_at TEXT NOT NULL,
    last_checked_at TEXT,
    next_check_at TEXT,
    expires_at TEXT,
    block_until TEXT,
    hit_count INTEGER NOT NULL DEFAULT 0,
    raw_excerpt TEXT,
    notes TEXT
);

CREATE INDEX IF NOT EXISTS idx_ip_reputation_next_check
    ON ip_reputation_cache(next_check_at);

CREATE INDEX IF NOT EXISTS idx_ip_reputation_status
    ON ip_reputation_cache(status);
");

PHP-класс для проверки IP через SQLite-кэш

Ниже — пример более аккуратной реализации. Логика такая:

  1. Определяем IP клиента.
  2. Проверяем, есть ли он в SQLite.
  3. Если записи нет или наступило время перепроверки — один раз идём во внешний источник.
  4. Классифицируем статус.
  5. Сохраняем результат в SQLite.
  6. До следующей даты перепроверки работаем только из локального кэша.
<?php
declare(strict_types=1);

final class LiteAntiBot
{
    private PDO $pdo;
    private int $refreshDays;

    public function __construct(PDO $pdo, int $refreshDays = 30)
    {
        $this->pdo = $pdo;
        $this->refreshDays = $refreshDays;
    }

    public function handle(): void
    {
        $ip = $this->detectClientIp();

        if ($ip === null) {
            return;
        }

        $record = $this->getIpRecord($ip);

        if ($record === null || $this->shouldRefresh($record)) {
            $fresh = $this->checkRemoteReputation($ip);
            $this->saveIpRecord($ip, $fresh);
            $record = $this->getIpRecord($ip);
        } else {
            $this->increaseHitCount($ip);
        }

        if (!$record) {
            return;
        }

        // Жёстко блокируем только blacklisted.
        if ($record['status'] === 'blacklisted') {
            $this->deny();
        }

        // Для suspicious можно включить мягкую реакцию.
        // Например: логирование, rate limit, captcha, challenge.
    }

    private function detectClientIp(): ?string
    {
        $ip = $_SERVER['REMOTE_ADDR'] ?? '';

        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            return null;
        }

        return $ip;
    }

    private function getIpRecord(string $ip): ?array
    {
        $stmt = $this->pdo->prepare("
            SELECT *
            FROM ip_reputation_cache
            WHERE ip = :ip
            LIMIT 1
        ");
        $stmt->execute([':ip' => $ip]);
        $row = $stmt->fetch();

        return $row ?: null;
    }

    private function shouldRefresh(array $record): bool
    {
        if (empty($record['next_check_at'])) {
            return true;
        }

        $nextCheck = strtotime((string) $record['next_check_at']);
        if ($nextCheck === false) {
            return true;
        }

        return time() >= $nextCheck;
    }

    private function checkRemoteReputation(string $ip): array
    {
        $url = 'https://cleantalk.org/ru/blacklists/' . rawurlencode($ip);

        $html = $this->fetchUrl($url);

        if ($html === null || $html === '') {
            return [
                'status' => 'unknown',
                'score' => 0,
                'raw_excerpt' => null,
                'notes' => 'Не удалось получить ответ от внешнего источника',
            ];
        }

        $normalized = mb_strtolower($html, 'UTF-8');
        $excerpt = $this->extractExcerpt($html, 1500);

        // Приоритет 1: явный текущий чистый статус
        if (
            str_contains($normalized, 'сейчас адрес не в черном списке') ||
            str_contains($normalized, 'anti-spam отсутствует в списках')
        ) {
            return [
                'status' => 'clean',
                'score' => 0,
                'raw_excerpt' => $excerpt,
                'notes' => 'Текущий статус не показывает активного попадания в blacklist',
            ];
        }

        // Приоритет 2: явный чёрный список / плохой статус
        if (
            str_contains($normalized, 'в черном списке') ||
            str_contains($normalized, 'blacklisted') ||
            str_contains($normalized, 'spam active')
        ) {
            return [
                'status' => 'blacklisted',
                'score' => 90,
                'raw_excerpt' => $excerpt,
                'notes' => 'Обнаружены явные признаки плохой репутации',
            ];
        }

        // Приоритет 3: историческая или спорная активность
        if (
            str_contains($normalized, 'отмечен как спам') ||
            str_contains($normalized, 'подозрительная активность') ||
            str_contains($normalized, 'spam report')
        ) {
            return [
                'status' => 'suspicious',
                'score' => 40,
                'raw_excerpt' => $excerpt,
                'notes' => 'Есть спорные или исторические сигналы, но без жёсткого current-ban',
            ];
        }

        return [
            'status' => 'unknown',
            'score' => 0,
            'raw_excerpt' => $excerpt,
            'notes' => 'Статус не удалось уверенно классифицировать',
        ];
    }

    private function fetchUrl(string $url): ?string
    {
        $ch = curl_init($url);

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LiteAntiBot/1.0; +https://example.com/bot)',
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HTTPHEADER => [
                'Accept-Language: ru,en;q=0.8',
                'Cache-Control: no-cache',
            ],
        ]);

        $body = curl_exec($ch);
        $error = curl_error($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

        curl_close($ch);

        if ($body === false || $error || $code < 200 || $code >= 400) {
            return null;
        }

        return (string) $body;
    }

    private function saveIpRecord(string $ip, array $result): void
    {
        $now = gmdate('Y-m-d H:i:s');
        $nextCheck = gmdate('Y-m-d H:i:s', strtotime('+' . $this->refreshDays . ' days'));

        $blockUntil = null;
        if ($result['status'] === 'blacklisted') {
            $blockUntil = $nextCheck;
        }

        $stmt = $this->pdo->prepare("
            INSERT INTO ip_reputation_cache (
                ip,
                status,
                source,
                score,
                first_seen_at,
                last_checked_at,
                next_check_at,
                expires_at,
                block_until,
                hit_count,
                raw_excerpt,
                notes
            )
            VALUES (
                :ip,
                :status,
                'cleantalk_html',
                :score,
                :first_seen_at,
                :last_checked_at,
                :next_check_at,
                :expires_at,
                :block_until,
                1,
                :raw_excerpt,
                :notes
            )
            ON CONFLICT(ip) DO UPDATE SET
                status = excluded.status,
                source = excluded.source,
                score = excluded.score,
                last_checked_at = excluded.last_checked_at,
                next_check_at = excluded.next_check_at,
                expires_at = excluded.expires_at,
                block_until = excluded.block_until,
                raw_excerpt = excluded.raw_excerpt,
                notes = excluded.notes,
                hit_count = ip_reputation_cache.hit_count + 1
        ");

        $stmt->execute([
            ':ip' => $ip,
            ':status' => $result['status'],
            ':score' => (int) $result['score'],
            ':first_seen_at' => $now,
            ':last_checked_at' => $now,
            ':next_check_at' => $nextCheck,
            ':expires_at' => $nextCheck,
            ':block_until' => $blockUntil,
            ':raw_excerpt' => $result['raw_excerpt'],
            ':notes' => $result['notes'],
        ]);
    }

    private function increaseHitCount(string $ip): void
    {
        $stmt = $this->pdo->prepare("
            UPDATE ip_reputation_cache
            SET hit_count = hit_count + 1
            WHERE ip = :ip
        ");
        $stmt->execute([':ip' => $ip]);
    }

    private function extractExcerpt(string $html, int $limit = 1500): string
    {
        $text = strip_tags($html);
        $text = preg_replace('/\s+/u', ' ', $text);
        $text = trim((string) $text);

        if (mb_strlen($text, 'UTF-8') > $limit) {
            return mb_substr($text, 0, $limit, 'UTF-8') . '…';
        }

        return $text;
    }

    private function deny(): void
    {
        http_response_code(403);
        header('Content-Type: text/plain; charset=UTF-8');
        header('X-Content-Type-Options: nosniff');
        echo "403 Forbidden\n";
        exit;
    }
}

Пример инициализации

Теперь подключаем класс в точке входа приложения — например, в самом начале index.php, перед выполнением основной логики сайта.

<?php
declare(strict_types=1);

$pdo = new PDO('sqlite:' . __DIR__ . '/var/security/ip_reputation.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$pdo->exec("
CREATE TABLE IF NOT EXISTS ip_reputation_cache (
    ip TEXT PRIMARY KEY,
    status TEXT NOT NULL DEFAULT 'unknown',
    source TEXT NOT NULL DEFAULT 'cleantalk_html',
    score INTEGER NOT NULL DEFAULT 0,
    first_seen_at TEXT NOT NULL,
    last_checked_at TEXT,
    next_check_at TEXT,
    expires_at TEXT,
    block_until TEXT,
    hit_count INTEGER NOT NULL DEFAULT 0,
    raw_excerpt TEXT,
    notes TEXT
);
");

$bot = new LiteAntiBot($pdo, 30);
$bot->handle();

Как работает логика по шагам

  1. Пользователь приходит на сайт.
  2. Скрипт берёт его IP через REMOTE_ADDR.
  3. Ищет этот IP в SQLite-кэше.
  4. Если IP уже есть и срок перепроверки не наступил — используется локальный статус.
  5. Если IP новый или запись устарела — выполняется одна внешняя проверка.
  6. Результат сохраняется в SQLite.
  7. Если статус blacklisted — запрос блокируется.
  8. Если статус suspicious — можно включить мягкую защиту.

Что важно улучшить в боевом проекте

Даже для лёгкого антибота такую схему желательно немного усилить.

  • Добавить таблицу логов с датой, URI, user-agent и результатом решения.
  • Сделать whitelist для своих IP и доверенных сервисов.
  • Не блокировать поисковых ботов только по одной репутационной проверке.
  • Для suspicious лучше делать не блок, а challenge или rate limit.
  • Хранить отдельное поле с числом повторных подозрительных запросов.
  • Добавить автоочистку старых записей по TTL.
  • При большом трафике выносить такую логику выше — в nginx, fail2ban, WAF или CDN.

Критично: не стоит строить жёсткую защиту сайта только на парсинге внешней HTML-страницы. Такой вариант допустим как лёгкий фильтр и вспомогательный сигнал, но не как единственный источник истины для полноценной системы безопасности.

Почему не нужно проверять внешний источник на каждом запросе

Это одна из самых типовых ошибок. Если на каждый HTTP-запрос выполнять сетевую проверку наружу, сайт становится зависимым от чужого сервиса и начинает отвечать медленнее. Кроме того, при временном сбое внешнего источника вы рискуете получить хаотичное поведение защиты.

Намного безопаснее и практичнее хранить результат в локальном кэше. В таком случае внешний источник используется только:

  • при первом появлении нового IP;
  • при плановой перепроверке через заданный интервал;
  • при ручной диагностике проблемного адреса.

Когда такой антибот подходит лучше всего

Этот вариант особенно уместен, если:

  • у вас небольшой или средний PHP-сайт без тяжёлой инфраструктуры;
  • нужна быстрая базовая защита без дорогих интеграций;
  • важно минимально нагружать сервер и не плодить сложные сервисы;
  • нужен простой локальный кэш репутации IP в одном файле;
  • нужен понятный и легко расширяемый механизм.

Вывод

Лёгкий антибот на связке “внешняя репутационная проверка + SQLite-кэш + повторная перепроверка раз в месяц” — это рабочий компромисс для небольших проектов. Он лучше, чем постоянные ручные правки .htaccess, и намного практичнее, чем запрос во внешний источник на каждом обращении.

Самая разумная схема здесь такая: первый раз IP проверяем, сохраняем локально, далее работаем по кэшу, а перепроверку запускаем по TTL, например раз в 30 дней. При этом жёсткий блок лучше применять только к явно плохим адресам, а для спорных случаев — использовать мягкие меры.

FAQ

Можно ли хранить такой кэш в обычном текстовом файле?

Можно, но SQLite намного удобнее: есть структура, индексы, быстрый поиск, обновление записей и нормальная работа с датами и статусами.

Нормально ли перепроверять IP раз в месяц?

Для лёгкого антибота это вполне разумный интервал. При необходимости можно сократить его до 7–14 дней для подозрительных IP и оставить 30 дней для обычных записей.

Нужно ли сразу блокировать suspicious?

Обычно нет. Лучше использовать мягкую реакцию: логирование, rate limiting, challenge, задержку ответа или капчу.

Можно ли на этом строить полноценную защиту сайта?

Нет. Это вспомогательный уровень фильтрации. Для серьёзной защиты нужны дополнительные механизмы: WAF, firewall, ограничения частоты запросов, анализ логов, fail2ban и защита на уровне инфраструктуры.

Связаться в Telegram

Рейтинг: 5/5
5 голосов

Следующие страницы вас также могут заинтересовать:

Обновлено: 25.03.2026