Главная/Статьи/Битрикс: 404 для несуществующих поддоменов

Битрикс: 404 для несуществующих поддоменов

Обработка wildcard-поддоменов в мультирегиональном сайте. Показ 404 ошибки для несуществующих региональных поддоменов.

ДМ
Дмитрий Мещеряков
📅 29 мая 2021 г.📖 7 мин чтения

Wildcard-DNS удобен: завёл *.domain.ru — и новый регион добавляется без похода к администратору. Обратная сторона в том, что вместе с рабочими поддоменами открывается и бесконечное множество нерабочих, причём с кодом 200 и полной копией сайта.

Для поисковика это дубликаты контента на неограниченном числе хостов. Разберём, как отсекать несуществующие поддомены — и почему делать это в PHP хуже, чем в nginx.

Проблема

При настройке wildcard DNS (*.domain.ru) для мультирегионального сайта любой несуществующий поддомен открывает главную страницу:

  • moscow.domain.ru → Московская версия ✓
  • spb.domain.ru → Петербургская версия ✓
  • abrakadabra.domain.ru → Тоже открывается! ✗

Это плохо для SEO и создаёт бесконечное количество дублей контента.

Решение: обработчик OnEpilog

⚠️ Важно

OnEpilog — поздняя точка, и для этой задачи она выбрана неудачно. К моменту его вызова страница уже сформирована: компоненты отработали, запросы к базе выполнены, кэш создан. Отдав 404 на этом этапе, вы решаете SEO-задачу, но не решаете задачу нагрузки — а несуществующие поддомены обычно перебирает как раз бот, и в объёме.

Для проверки, которая ничего не знает о содержимом страницы, правильная точка — OnPageStart или OnBeforeProlog: ядро уже поднято, а генерация ещё не началась. А самый дешёвый вариант — вообще не доводить запрос до PHP, см. раздел про nginx ниже.

Добавьте в /local/php_interface/init.php:

php
<?php

use Bitrix\Main\EventManager;
use Bitrix\Main\Application;

EventManager::getInstance()->addEventHandler(
    'main',
    'OnEpilog',
    'checkSubdomainExists'
);

function checkSubdomainExists(): void
{
    // Только для frontend
    if (defined('ADMIN_SECTION') && ADMIN_SECTION === true) {
        return;
    }

    // preg_replace с якорем ^, а не str_replace: последний вырежет "www."
    // в любом месте строки и сломает хосты вроде "mywww.domain.ru"
    $host = preg_replace('/^www\./i', '', (string) ($_SERVER['HTTP_HOST'] ?? ''));
    $parts = explode('.', $host);

    // Проверяем только поддомены третьего уровня
    if (count($parts) <= 2) {
        return; // Это основной домен
    }

    $subdomain = $parts[0];

    // Получаем список валидных поддоменов
    $validSubdomains = getValidSubdomains();

    if (!in_array($subdomain, $validSubdomains)) {
        // Поддомен не существует — показываем 404
        show404Page();
    }
}

/**
 * Получение списка валидных поддоменов из БД
 */
function getValidSubdomains(): array
{
    static $cache = null;
    
    if ($cache !== null) {
        return $cache;
    }

    // Кэшируем на время запроса
    $cacheId = 'valid_subdomains';
    $cacheDir = '/valid_subdomains/';
    $cacheTime = 3600; // 1 час

    $obCache = new \CPHPCache();
    
    if ($obCache->InitCache($cacheTime, $cacheId, $cacheDir)) {
        $cache = $obCache->GetVars()['subdomains'];
        return $cache;
    }

    $cache = [];

    // Вариант 1: Сотбит.Мультирегиональность
    if (\Bitrix\Main\Loader::includeModule('sotbit.regions')) {
        $connection = Application::getConnection();
        $result = $connection->query("SELECT CODE FROM sotbit_regions WHERE ACTIVE = 'Y'");
        
        while ($row = $result->fetch()) {
            $subdomain = extractSubdomain($row['CODE']);
            if ($subdomain) {
                $cache[] = $subdomain;
            }
        }
    }

    // Вариант 2: Из инфоблока
    // $cache = getSubdomainsFromIblock(REGIONS_IBLOCK_ID);

    // Вариант 3: Из конфига
    // $cache = ['moscow', 'spb', 'ekb', 'nsk', 'kzn'];

    if ($obCache->StartDataCache()) {
        $obCache->EndDataCache(['subdomains' => $cache]);
    }

    return $cache;
}

/**
 * Извлечение поддомена из URL
 */
function extractSubdomain(string $url): ?string
{
    $url = str_replace(['https://', 'http://'], '', $url);
    $parts = explode('.', $url);
    
    if (count($parts) > 2) {
        return $parts[0];
    }
    
    return null;
}

/**
 * Показ страницы 404
 */
function show404Page(): void
{
    global $APPLICATION;
    
    // Устанавливаем HTTP-статус
    \CHTTP::SetStatus('404 Not Found');
    
    // Подключаем шаблон 404
    $file404 = $_SERVER['DOCUMENT_ROOT'] . '/404.php';
    
    if (file_exists($file404)) {
        include $file404;
    } else {
        echo '<!DOCTYPE html><html><head><title>404 Not Found</title></head>';
        echo '<body><h1>404 - Страница не найдена</h1>';
        echo '<p>Запрошенный регион не существует.</p></body></html>';
    }
    
    die();
}

Расширенная версия с логированием

php
<?php

namespace Local\Multiregion;

use Bitrix\Main\Application;
use Bitrix\Main\EventManager;
use Bitrix\Main\Data\Cache;

class SubdomainValidator
{
    private const CACHE_TTL = 3600;
    private const CACHE_DIR = '/subdomain_validator/';
    
    /**
     * Основной домен сайта (без www)
     */
    private static string $mainDomain = '';

    /**
     * Регистрация обработчика
     */
    public static function register(string $mainDomain): void
    {
        self::$mainDomain = $mainDomain;

        EventManager::getInstance()->addEventHandler(
            'main',
            'OnEpilog',
            [self::class, 'validate']
        );
    }

    /**
     * Валидация поддомена
     */
    public static function validate(): void
    {
        if (defined('ADMIN_SECTION') && ADMIN_SECTION === true) {
            return;
        }

        $subdomain = self::getCurrentSubdomain();
        
        if ($subdomain === null) {
            return; // Основной домен
        }

        $validSubdomains = self::getValidSubdomains();

        if (!in_array($subdomain, $validSubdomains, true)) {
            self::logInvalidSubdomain($subdomain);
            self::show404();
        }
    }

    /**
     * Получение текущего поддомена
     */
    private static function getCurrentSubdomain(): ?string
    {
        $host = strtolower($_SERVER['HTTP_HOST']);
        $host = preg_replace('/^www\./', '', $host);
        
        // Проверяем, что это наш домен
        if (!str_ends_with($host, self::$mainDomain)) {
            return null;
        }

        // Извлекаем поддомен
        $subdomain = str_replace('.' . self::$mainDomain, '', $host);
        
        // Если совпадает с основным доменом — нет поддомена
        if ($subdomain === self::$mainDomain || $subdomain === '') {
            return null;
        }

        return $subdomain;
    }

    /**
     * Получение валидных поддоменов
     */
    private static function getValidSubdomains(): array
    {
        $cache = Cache::createInstance();
        
        if ($cache->initCache(self::CACHE_TTL, 'valid_subdomains', self::CACHE_DIR)) {
            return $cache->getVars()['list'];
        }

        $cache->startDataCache();
        
        $list = self::loadSubdomainsFromDatabase();

        $cache->endDataCache(['list' => $list]);

        return $list;
    }

    /**
     * Загрузка из БД (пример для Сотбит)
     */
    private static function loadSubdomainsFromDatabase(): array
    {
        $subdomains = [];
        
        try {
            $connection = Application::getConnection();
            
            // Сотбит.Мультирегиональность
            $sql = "SELECT CODE FROM sotbit_regions WHERE ACTIVE = 'Y'";
            $result = $connection->query($sql);
            
            while ($row = $result->fetch()) {
                $url = $row['CODE'];
                $parts = explode('.', str_replace(['http://', 'https://'], '', $url));
                
                if (count($parts) > 2) {
                    $subdomains[] = $parts[0];
                }
            }
        } catch (\Throwable $e) {
            // Логируем ошибку, но не падаем
            AddMessage2Log("SubdomainValidator error: " . $e->getMessage());
        }

        return array_unique($subdomains);
    }

    /**
     * Логирование невалидных поддоменов
     */
    private static function logInvalidSubdomain(string $subdomain): void
    {
        $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
        $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
        
        AddMessage2Log(
            sprintf(
                "Invalid subdomain: %s | IP: %s | UA: %s",
                $subdomain,
                $ip,
                substr($ua, 0, 100)
            ),
            'subdomain_validator'
        );
    }

    /**
     * Показ 404 страницы
     */
    private static function show404(): void
    {
        \CHTTP::SetStatus('404 Not Found');

        // Очищаем буфер вывода
        while (ob_get_level()) {
            ob_end_clean();
        }

        $file404 = Application::getDocumentRoot() . '/404.php';
        
        if (file_exists($file404)) {
            include $file404;
        } else {
            header('Content-Type: text/html; charset=UTF-8');
            echo '<!DOCTYPE html><html lang="ru"><head>';
            echo '<meta charset="UTF-8"><title>404 - Регион не найден</title></head>';
            echo '<body><h1>Регион не найден</h1>';
            echo '<p>Запрошенный регион не существует.</p>';
            echo '<a href="https://' . self::$mainDomain . '/">Перейти на главную</a>';
            echo '</body></html>';
        }
        
        die();
    }

    /**
     * Сброс кэша
     */
    public static function clearCache(): void
    {
        $cache = Cache::createInstance();
        $cache->cleanDir(self::CACHE_DIR);
    }
}

Регистрация

php
<?php
// /local/php_interface/init.php

\Local\Multiregion\SubdomainValidator::register('domain.ru');

Альтернатива: проверка на уровне Nginx (предпочтительный вариант)

Если список регионов меняется редко — а он меняется редко, — то проверять поддомен в PHP незачем. nginx отсечёт запрос до запуска PHP-FPM, и стоимость обработки мусорного хоста упадёт с миллисекунд до микросекунд.

Если список поддоменов фиксированный, эффективнее проверять на уровне веб-сервера:

nginx
# Список валидных поддоменов
map $host $valid_subdomain {
    default                    0;
    ~^(moscow|spb|ekb|nsk|kzn)\. 1;
    ~^www\.                    1;  # www разрешён
    domain.ru                  1;  # основной домен
}

server {
    listen 443 ssl;
    server_name *.domain.ru domain.ru;

    # Проверка валидности поддомена
    if ($valid_subdomain = 0) {
        return 404;
    }

    # ... остальная конфигурация
}
💡 Совет

Как держать список регионов в актуальном состоянии. Конфиг nginx с перечислением поддоменов — не самый удобный вариант, если регионы заводят маркетологи через админку. Рабочая схема: список хранится в Битриксе, а обработчик события на сохранении региона генерирует map-файл для nginx и перезагружает конфиг. Тогда добавление региона остаётся операцией внутри админки, а проверка выполняется на входе.

Если такой автоматики нет — оставляйте проверку в PHP, но перенесите её на OnPageStart и обязательно кэшируйте список валидных поддоменов: обращение к базе на каждый запрос по несуществующему хосту как раз и создаёт ту нагрузку, от которой вы защищаетесь.

Итоги

Проверяйте как можно раньше. Порядок предпочтений: nginx → OnPageStart в PHP → OnEpilog. Разница между первым и последним вариантом — полный цикл генерации страницы на каждый мусорный запрос.

Отдавайте 404, а не редирект на основной домен. Редирект сообщает поисковику, что хост существует, и робот продолжит его проверять.

Не забудьте про www и про регистр — хост из заголовка приходит в том виде, в каком его прислал клиент.

Каждая региональная версия должна иметь свой canonical на собственный поддомен, а не на основной домен: иначе вы своими руками говорите поисковику, что региональных страниц не существует, и весь смысл мультирегиональности пропадает.

⚠️ Важно

Про совет закрывать несуществующие поддомены в robots.txt: он не работает. robots.txt читается для каждого хоста отдельно, то есть для несуществующего поддомена робот запросит его же несуществующий robots.txt — и получит копию основного. Отсекать нужно кодом ответа, а не файлом.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.