Рубрика: Разборы

  • Запасное хранилище S3 для картинок сайта: авария провайдера и случайное удаление

    Если картинки сайта лежат в облачном хранилище S3, у них два разных риска: хранилище недоступно (авария у провайдера) и файлы удалены (ошибка, скрипт, взлом). Простая копия у другого провайдера защищает только от первого. Удаление она послушно повторит. Для защиты от удаления нужны версии файлов на основном хранилище и копирование без удалений. Самое дешевое средство из всех - отдельный ключ доступа для сайта без права удалять.

    Повод для разговора появился в октябре 2026 года, после атак на дата-центры Яндекса. У проекта, который я сопровождаю, картинки пережили это без потерь. Но когда я проверил, что будет, если файлы удалят, ответ оказался простым: восстанавливать будет неоткуда.

    Шаг 1. Проверить, что у вас есть сейчас

    Три запроса к API хранилища. Пример для aws cli, адрес хранилища и имя бакета подставьте свои:

    aws s3api get-bucket-versioning     --bucket my-bucket --endpoint-url https://storage.yandexcloud.net
    aws s3api get-object-lock-configuration --bucket my-bucket --endpoint-url https://storage.yandexcloud.net
    aws s3api get-bucket-lifecycle-configuration --bucket my-bucket --endpoint-url https://storage.yandexcloud.net

    В моем случае ответы были такие:

    МеханизмОтветЧто значит
    Версионированиепустая конфигурацияне включено
    Блокировка от удаления (Object Lock)ObjectLockConfigurationNotFoundErrorне настроена
    Правила жизненного циклаNoSuchLifecycleConfigurationнет

    То есть ни версий, ни блокировки, ни корзины. Готовой услуги резервного копирования бакетов российские провайдеры не продают: ни Яндекс, ни Selectel, ни Beget. У Яндекса в документации прямо написано, что копировать в другое хранилище надо своими силами. Yandex Cloud Backup покрывает виртуальные машины, бакеты в него не входят, похоже только название.

    Что делает Битрикс при удалении файла

    Модуль clouds подписан на событие удаления файла и стирает объект из облака сразу, в том же запросе. Корзины и отложенного удаления на стороне Битрикса нет.

    Отдельный риск: штатный инструмент "Оценить объем и количество дубликатов" удаляет дубликаты из облака пачками.

    Шаг 2. Ключ без права удаления

    Создайте для сайта отдельный ключ доступа с политикой бакета, в которой нет действия s3:DeleteObject. Это ничего не стоит и полностью закрывает сценарий "админка или скрипт стерли файлы". Минус один: удаление картинок с сайта перестанет чистить облако, и место будет расти. На большинстве сайтов это копейки.

    Шаг 3. Версионирование на основном бакете

    Если версионирование включено, удаленный или перезаписанный файл остается старой версией, и его можно вернуть. Чтобы старые версии не копились вечно, нужно правило жизненного цикла, которое удаляет их через 30-60 дней.

    Проверьте у своего провайдера оба механизма до того, как на них рассчитывать:

    МеханизмYandexSelectelBeget
    Версионированиеесть, выключить потом нельзя, только приостановитьестьне заявлено
    Object Lockестьестьнет
    Автоудаление старых версийестьнетнет

    У Selectel нет правил жизненного цикла, поэтому старые версии придется чистить своим заданием по расписанию. По той же причине не ставьте там строгую блокировку на долгий срок: удалить версии досрочно будет нельзя, и счет будет расти.

    Шаг 4. Свой домен для картинок

    Пока картинки отдаются по адресу провайдера вида bucket.storage.yandexcloud.net, любая смена хранилища меняет все адреса картинок на сайте и сбрасывает их индексацию. Со своим поддоменом вида img.example.ru переключение на резервное хранилище делается правкой одной DNS-записи.

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

    У Selectel имя бакета с доменом совпадать не обязано, но сертификат надо обновлять вручную.

    Шаг 5. Копия у другого провайдера, в другом регионе

    Копия обновляется по расписанию через rclone. Ключевое здесь: не повторять удаления вслепую.

    rclone sync main:my-bucket reserve:my-bucket-backup \
      --backup-dir reserve:my-bucket-trash/$(date +%F) \
      --max-delete 200 --max-delete-size 1G \
      --fast-list --transfers 16 --checkers 32 \
      --log-file /var/log/rclone/s3-backup.log --log-level INFO
    • --backup-dir в отдельный бакет: то, что исчезло из основного хранилища, не удаляется из копии, а переезжает в папку с датой. Корзина не должна лежать внутри бакета-приемника, rclone этого не допускает;
    • --max-delete: если в основном хранилище разом пропало много файлов, rclone остановится с ошибкой, а не разнесет удаление по копии. Эта ошибка должна приходить вам уведомлением, а не тонуть в логе;
    • первый запуск всегда с --dry-run;
    • rclone copy вместо sync удаления не повторяет вообще, но копия тогда только растет. Для десятков гигабайт это приемлемо.

    Корзину чистить по возрасту: rclone delete reserve:my-bucket-trash --min-age 30d.

    Для первоначальной заливки десятков гигабайт rclone между хранилищами напрямую быстрее любых средств Битрикса.

    Резервное хранилище в самом Битриксе

    В модуле clouds есть штатный механизм резервного хранилища, но в админке он скрыт. Блок настроек появляется, только если включить опцию:

    COption::SetOptionString('clouds', 'failover_enabled', 'Y');

    После этого у бакета появляются поля: резервный бакет, копировать ли новые файлы, удалять ли вслед за основным с задержкой, и флаг "режим аварии". Что важно знать:

    • агент, который разбирает очередь копирования, модуль сам не регистрирует. Без него очередь растет, а резерв остается пустым. Ставится так: CAgent::AddAgent("CCloudFailover::queueAgent();", "clouds", "N", 60);
    • копирование идет через ваш сервер: скачал, залил. Для больших объемов есть многопоточный разбор: php bitrix/modules/clouds/tools/sync.php <число потоков>;
    • копирование берет файл по публичной ссылке, для приватного бакета этот путь не сработает;
    • автоматического переключения при аварии нет, флаг ставится руками или своим мониторингом;
    • после переключения нужно сбросить кеш файлов, кеш страниц и кеш веб-сервера. Иначе сайт продолжит отдавать ссылки на упавшее хранилище.

    Я проверил всю цепочку на тестовом стенде: файл сохранен, задача в очереди, объект появился в резерве, переключение, картинка отдается из резерва с тем же содержимым, возврат обратно.

    Как выбрать резервного провайдера

    **Модуль Битрикса ставит права на каждый файл заголовком x-amz-acl: public-read.** Провайдер должен его понимать или хотя бы спокойно игнорировать. У MWS (МТС) ACL не поддерживаются принципиально. У Selectel в таблице совместимости ACL помечены как неподдерживаемые, хотя у Битрикса есть официальная инструкция по подключению Selectel. До обещаний проверьте одной командой:

    aws s3api put-object --bucket test --key t.txt --body t.txt --acl public-read --endpoint-url https://адрес-хранилища

    Ответ 400 или 501 означает, что понадобится правка модуля.

    Еще несколько правил:

    • не храните копию у того же провайдера, где стоит сайт. Вы соберете все риски в одном месте;
    • смотрите на регион, а не на количество дата-центров. Мультизонный пул Selectel ru-6 это три площадки в пределах Москвы, в 10-15 км друг от друга. Он переживет пожар на одной площадке, но не событие масштаба региона. Резерв к московскому хранилищу логичнее держать в Петербурге;
    • проверяйте, чьи это мощности. Timeweb в Петербурге арендует стойки в дата-центре Selectel, Cloud4U и Cloud4Y работают на одной платформе. Посмотрите адрес хранилища в документации, пробейте его IP по whois, сравните подсети. Это занимает десять минут;
    • хранилище за границей не подходит, если в файлах есть персональные данные.

    Сколько это стоит

    Хранение десятков гигабайт у российских провайдеров стоит порядка 100 руб. в месяц (цены октября 2026 года, около 1,1-2,6 руб. за гигабайт). Отдельно считайте исходящий трафик: у Яндекса первые 100 ГБ бесплатны, у Selectel бесплатного объема нет. Трафик тратится, когда вы восстанавливаете файлы из копии.

    Список для проверки

    1. Включено ли версионирование на основном бакете?
    2. Может ли ключ сайта удалять файлы?
    3. Картинки отдаются со своего домена или с адреса провайдера?
    4. Есть ли копия у другого провайдера и в другом регионе?
    5. Повторяет ли копирование удаления и есть ли порог на массовое удаление?
    6. Приходит ли уведомление, если копирование упало?
    7. Пробовали ли вы хоть раз восстановить файл из копии или переключить сайт на резерв?

    Копия, из которой ни разу ничего не восстанавливали, остается непроверенной гипотезой. Если хотите разобраться со своим хранилищем, напишите мне. Начну с этих трех запросов к API и с проверки, кто может удалять файлы.

  • Сайт на Битриксе взломали: как найти все закладки, а не только то, что нашел хостер

    Хостер заблокировал аккаунт за вредоносную активность и прислал список зараженных файлов. Самое частое решение: удалить файлы из списка и попросить разблокировку. Через несколько дней все возвращается. В одной из моих чисток в списке хостера было 2 файла из примерно 25 работавших закладок, зато там же был помечен штатный файл Битрикса, без которого сайт падает целиком. Чтобы заражение не вернулось, чистить надо по-другому.

    Как выглядит взломанный сайт

    Чаще всего никак. Все разделы открываются, ошибок нет, владелец заходит на сайт и видит обычную страницу. При этом:

    • сайт падает в поиске Google или страницы выпадают из индекса;
    • в Search Console появляются чужие адреса или меры за спам;
    • хостер присылает уведомление о вредоносной активности или блокирует аккаунт.

    Это клоакинг: подмена показывается только поисковому роботу. Посетителям из определенных стран дополнительно может включаться переадресация на чужой магазин. Обычная проверка в браузере заражение не покажет.

    Проверка одной командой

    Запросить главную страницу дважды: как Googlebot и как обычный браузер.

    curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" https://example.ru/ -o gb.html -w "%{size_download}\n"
    curl -s -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0" https://example.ru/ -o br.html -w "%{size_download}\n"
    grep -o -i "<title>[^<]*</title>" gb.html br.html

    Если размер ответа и заголовок страницы различаются, клоакинг есть. В разобранном случае робот получал 183 КБ с японским заголовком про копии люксовых часов, браузер 54 КБ обычной главной.

    Три уточнения:

    • проверяйте и Яндекс: подмена часто ловит только Google, YandexBot получает чистую страницу;
    • подмена бывает навешана только на главную, внутренние страницы чистые;
    • проверку лучше делать с самого сервера. Серия запросов с подделанным Googlebot снаружи может закончиться баном вашего IP фаерволом хостинга.

    Где прячут закладку

    Главная цель - файл, который выполняется на каждом запросе: bitrix/modules/main/include/prolog.php. Чужой код ставится в самое начало, до подключения движка. Оригинальный файл короткий, около 380 байт, и все, что стоит выше строки require_once, лишнее.

    Самого спамного текста на сайте нет. Он подтягивается на лету с другого взломанного сайта, поэтому поиск по словам из подмены ничего не найдет.

    Другие места, которые я находил:

    • языковые файлы в глубине bitrix/, которые никто не открывает;
    • редко используемые каталоги мастеров и публичных шаблонов;
    • PHP-файлы в каталогах картинок;
    • PHP внутри настоящей картинки: валидный PNG, после заголовка которого лежит код. Сам он не выполняется, его подключает однострочная закладка в безобидном файле страницы;
    • первая строка включаемой области: обычный текст страницы, а перед ним одна строка PHP;
    • вложенные каталоги-двойники вида kontakty/kontakty/;
    • .htaccess двух видов. Одни разрешают выполнять PHP в каталоге со шеллом. Другие запрещают весь PHP, кроме шеллов атакующего, и заодно ломают штатные скрипты Битрикса в этом каталоге.

    Почему антивирус хостинга не помогает

    Он ищет сигнатуры известных шеллов и пропускает:

    • короткие врезки в рабочие файлы: 900 байт внутри prolog.php ни под одну сигнатуру не подходят;
    • незнакомые ему обфускаторы;
    • честные файловые менеджеры, которые используются как шелл;
    • PHP в картинках и однострочные закладки;
    • .htaccess.

    И при этом он может пометить штатный файл Битрикса bitrix/modules/main/lib/security/w/wwall.php. Это встроенный фаервол Битрикса, его код закрыт лицензионной обфускацией, и он скачивает правила с серверов Битрикса. Для эвристики антивируса это выглядит как троян. Удалять его нельзя: ядро вызывает его на каждом запросе, и без него падают все страницы. Правильно добавить файл в исключения сканера и объяснить хостеру.

    Поэтому список хостера это отправная точка, а не задание на чистку.

    Порядок работ

    1. Ничего не удалять сразу

    Сначала снять картину, потом менять. Все изменения вести скриптом с журналом действий и списком контрольных сумм. Если перезаписать зараженный файл до того, как сделана копия, образец потерян, а вместе с ним и понимание, как вас взломали.

    2. Посмотреть весь аккаунт, а не один сайт

    ls -la ~/domains/*/public_html/bitrix     # не общее ли ядро у нескольких сайтов
    crontab -l                                # чужие задания по расписанию
    cat ~/.ssh/authorized_keys                # чужие ключи
    grep -rn "auto_prepend_file" ~/domains --include=.htaccess --include=.user.ini

    Если bitrix на боевом сайте это ссылка на ядро тестового, то дыра на тесте автоматически работает на боевом.

    3. Искать по дате изменения метаданных, а не по дате файла

    Дату файла подделывают одной командой touch. В одном случае набор шеллов стоял с датой ровно на два года раньше реальной, секунда в секунду. Дату изменения метаданных (ctime) так не подделать, она меняется при любой записи.

    find ~/domains -type f \( -iname "*.php*" -o -name ".htaccess" \) -newerct "2026-08-01" \
      ! -path "*/cache/*" ! -path "*/managed_cache/*" \
      -printf "%CY-%Cm-%Cd %CH:%CM | %TY-%Tm-%Td | %s | %p\n" | sort

    Самый сильный признак - файлы, у которых дата файла старше даты метаданных больше чем на сутки. Значит, дату подделали:

    find ~/domains -type f -newerct "2026-08-01" -printf "%C@ %T@ %p\n" | awk '($1-$2)>86400'

    4. Сигнатуры и картинки

    Шаблоны держать в файле, а не в командной строке: при запуске через ssh экранирование ломается, и grep молча ничего не находит. Проверьте шаблоны на заведомо зараженном файле.

    grep -rlIE -f sig.txt --include="*.php" ~/domains
    find ~/domains -type f -size -3M \( -iname "*.png" -o -iname "*.jpg" -o -iname "*.gif" -o -iname "*.ico" \) \
      ! -path "*/resize_cache/*" -exec grep -laE "<\?php|eval\(" {} +

    В sig.txt строки вида eval\(base64_decode, eval\(gzinflate, eval\(\$_(POST|GET|REQUEST|COOKIE), Priv8, YAK Pro.

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

    5. Карантин вместо удаления

    Подозрительные файлы переносить в отдельный каталог с сохранением путей и списком контрольных сумм. Перед запуском антивируса хостера карантин зашифровать: антивирус проверяет весь домашний каталог и найдет шеллы в открытой папке.

    Файлы ядра восстанавливать из оригинала. Эталоны для сверки: официальные пакеты обновлений в bitrix/updates/, чистая копия ядра на соседнем сайте, резервная копия.

    После карантина прогнать все поиски заново.

    6. Сменить доступы одновременно с чисткой

    Не после, а вместе:

    • пароль панели хостинга и SSH, завершить все сеансы, включить двухфакторный вход;
    • пользователи FTP: лишних удалить, остальным новые пароли;
    • администраторы сайта: проверить список, лишних отключить;
    • пароль базы данных;
    • ключи платежных систем и API, которые лежат в файлах сайта. Шелл их читал.

    7. Проверить результат и следить неделю

    • проверка подмены с сервера под Googlebot и браузером, все разделы, оплата;
    • неделю каждый день поиск новых файлов по ctime в ядре и корнях сайтов. Если закладки восстанавливаются, значит остался канал;
    • Search Console: чужие подтверждения домена, ручные меры, удаление мусорных адресов.

    Грабли

    Чистка не закончена, пока жив хоть один шелл на любом сайте аккаунта. Все сайты на одном аккаунте работают под одним пользователем, и шелл на заброшенном поддомене пишет в ядро боевого сайта. В одном случае каталог появился снова через 0,26 секунды после переноса, а врезка в ядро вернулась через три дня. Причиной были несколько шеллов на других доменах того же аккаунта.

    Журналы хостер хранит недолго, иногда около 15 дней. Снимайте их в первые дни, иначе выяснить, как вас взломали, будет не по чему.

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

    Сколько это занимает

    По моим двум последним чисткам: около 6 часов до письма хостеру (поиск и карантин, разбор находок и ложных срабатываний, шифрование и итоговая проверка, разбор блокировки) и еще 3-4 часа после разблокировки (пароли, проверка сайта и оплаты, контроль, поиск точки входа). На аккаунте с одним сайтом обычно быстрее.

    Список для проверки

    1. Сравнили ответ сайта для Googlebot и для браузера?
    2. Посмотрели все сайты аккаунта, а не только тот, на который жаловались?
    3. Искали по ctime, а не по дате файла?
    4. Проверили картинки и .htaccess?
    5. Сменили все пароли вместе с чисткой?
    6. Не удалили штатные файлы Битрикса по списку антивируса?
    7. Следите за новыми файлами хотя бы неделю?

    Если хостер уже заблокировал ваш сайт или вы видите подозрительные признаки, напишите мне. Начну с проверки подмены и списка того, что лежит на аккаунте, это занимает около часа.

  • Сайт открывается 8 секунд, а сервер простаивает: виноват DNS

    Если сайт открывается за 8-9 секунд, а сервер при этом почти не нагружен, проверьте DNS сервера. В случае, который я разбирал, виноват был не шаблон и не хостинг: сервер по 8 секунд ждал ответа от DNS-резолвера, который не работал. После правки двух файлов главная стала открываться за полсекунды.

    Как это выглядело

    Страницы отдавались за 8,6 секунды. Нагрузка на сервер около единицы, память свободна, база данных отвечает за миллисекунды. Сайт ничего не считал, он ждал.

    Второй признак я заметил не сразу: время ответа было одинаковым. Три замера подряд дали 8,6, 8,5 и 8,6 секунды. Тяжелый шаблон или холодный кеш так себя не ведут, у них всегда разброс. Ровные 8 секунд похожи на таймаут.

    Почему сломанный DNS не дает ошибку

    Нерабочий DNS-сервер не отказывает сразу, он молчит. Система ждет ответа 5 секунд, потом повторяет запрос и только после этого получает ответ от другого сервера из списка. Одно разрешение имени легко стоит 8 секунд.

    Платит за это каждый запрос сайта к чужому серверу по имени: облачное хранилище картинок, CRM, платежный шлюз, почта, проверка лицензии. Если CMS обращается к такому серверу при сборке страницы, каждый посетитель ждет те же 8 секунд.

    Коварство в том, что ответ в итоге приходит. Ничего не падает, в логах чисто, сайт просто "всегда был медленный". Виноватым назначают шаблон или хостинг и могут месяцами оптимизировать не то.

    Диагностика за одну команду

    curl умеет показывать, сколько времени ушло на каждый этап запроса:

    curl -s -o /dev/null -w "dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" https://адрес-внешнего-сервиса/

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

    dns=8.401  conn=8.409  tls=8.434  ttfb=8.536  total=8.581

    Восемь секунд ушло на первый этап, разрешение имени. Дальше этапы занимали по 10-30 миллисекунд, то есть сеть в порядке.

    Проверить каждый DNS-сервер отдельно

    for s in 127.0.0.53 1.1.1.1 77.88.8.8 8.8.8.8; do
      echo "--- $s"
      ( time dig +short +tries=1 +time=3 A example.com @$s ) 2>&1 | tail -4
    done

    Без +tries=1 проблему не видно. dig сам сделает повтор, получит ответ и покажет "5 секунд", что выглядит как "медленно, но работает". Из-за этого такая неисправность и живет на серверах месяцами.

    Рабочий резолвер отвечает за 20-40 миллисекунд. В разобранном случае серверы Google 8.8.8.8 и 8.8.4.4 с этой машины не отвечали вообще, Яндекс 77.88.8.8 отвечал за 23 мс, Cloudflare 1.1.1.1 за 36 мс.

    Стоит проверить и getent hosts имя-сервиса. PHP и curl разрешают имена именно так, через системную библиотеку, а dig ходит в обход и может показать другую картину.

    Откуда сервер берет адреса DNS

    На современных Ubuntu и Debian работает systemd-resolved, и адреса задаются в нескольких местах:

    ГдеЧто задает
    /etc/systemd/resolved.conf, строка DNS=общие серверы
    настройки сетевого интерфейса (DHCP, netplan)серверы для интерфейса, у них приоритет выше общих
    /etc/resolv.confто, что реально читают программы

    Посмотреть все сразу:

    resolvectl status
    ls -l /etc/resolv.conf

    В выводе resolvectl status строка Current DNS Server показывает, к кому система обращается прямо сейчас. Там вполне может оказаться IPv6-адрес, до которого у сервера нет маршрута.

    Исправление

    Правка нужна в двух местах: одна действует сразу, вторая сохраняет настройку после перезагрузки.

    # сохранить исходное состояние
    ls -l /etc/resolv.conf > /root/resolv.conf.was.txt
    cp /etc/systemd/resolved.conf /root/resolved.conf.backup
    
    # 1. то, что читают программы
    printf 'nameserver 77.88.8.8\nnameserver 1.1.1.1\nnameserver 77.88.8.1\noptions timeout:2 attempts:2\n' > /etc/resolv.conf.new
    mv /etc/resolv.conf.new /etc/resolv.conf
    
    # 2. чтобы пережило перезагрузку
    sed -i 's/^#\?DNS=.*/DNS=77.88.8.8 77.88.8.1 1.1.1.1/' /etc/systemd/resolved.conf
    systemctl restart systemd-resolved

    timeout:2 attempts:2 вместо стандартных пяти секунд нужен на будущее. Если резолвер снова сломается, сайт потеряет две секунды, а не восемь.

    Новый файл записывается рядом и переименовывается одной командой mv, чтобы сайт не поймал момент, когда файл пустой.

    Результат

    ЗамерБылоСтало
    Разрешение имени8,4 с0,012 с
    Запрос к облачному хранилищу8,58 с0,12 с
    Главная страница8,6 с0,56 с
    Каталог9,3 с1,42 с
    Фоновая обработка одного файла16,5 с0,49 с

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

    Что проверить после

    • Панель хостинга или cloud-init при перезагрузке могут вернуть старый /etc/resolv.conf. После каждой перезагрузки смотреть ls -l /etc/resolv.conf: сайт снова замедлится молча, без единой ошибки.
    • Если на сервере есть сервисы, которые работают только по IPv6, им нужен рабочий IPv6-резолвер. Убирая IPv6-адреса, убедитесь, что они не нужны.
    • Все прежние замеры скорости недействительны. Отчеты PageSpeed и аудиторов снимались, когда сервер ждал по 8 секунд. Замеряйте заново.

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

  • Деньги списались, а заказ не оплачен: уведомления банка в интернет-магазине

    Покупатель заплатил, банк деньги списал, а в админке заказ висит неоплаченным. Чаще всего причина в обработчике уведомлений от банка. В таких обработчиках я раз за разом нахожу одни и те же четыре ошибки. Есть и пятая ситуация, которую никакой обработчик не исправит.

    Речь про интернет-эквайринг Альфа-банка и Сбербанка: у них один платежный шлюз (RBS) и одинаковые правила уведомлений. Документация Сбера подробнее, и она подходит для Альфы.

    Как банк сообщает об оплате

    После оплаты шлюз отправляет на адрес магазина обычный GET-запрос:

    ?orderNumber=12345&mdOrder=057a6b30-...&operation=deposited&status=1
    ПараметрЧто значит
    mdOrderномер заказа в системе банка, по нему нужно перепроверять статус
    orderNumberномер заказа на сайте
    operationapproved (деньги заблокированы), deposited (списаны), reversed (отмена), refunded (возврат)
    status1 успех, 0 неуспех
    checksumподпись, приходит, только если в кабинете банка задан ключ

    Банк считает уведомление доставленным, только если сайт ответил кодом 200. На любой другой ответ шлюз повторяет запрос каждые 10 минут, пока не получит 200 или пока не наберется четыре неудачи подряд. После этого он прекращает попытки.

    Ошибка 1: обработчик всегда отвечает 200

    Самая дорогая из четырех. Обработчик получил уведомление, что-то не сошлось, он записал строчку в лог и завершился. А завершение скрипта без ошибки и есть ответ 200. Банк считает, что все доставлено, и больше не повторяет.

    Получается, что механизм повторов, придуманный ровно на такой случай, выключен руками. Любой сбой превращается в потерянную оплату.

    Нужно различать два вида причин:

    • временные: банк не ответил на проверочный запрос, моргнула сеть, недоступна база. Здесь надо отвечать не 200, тогда банк повторит;
    • устойчивые: суммы не сходятся, заказа нет. Повтор не поможет, поэтому 200, но случай надо громко залогировать и показать человеку, например письмом менеджеру.

    Ошибка 2: обработчик верит параметрам запроса

    Если в кабинете банка не задан ключ подписи, уведомление приходит без нее. Тогда адрес обработчика это просто ссылка, которую может открыть кто угодно, зная mdOrder. А mdOrder покупатель видит в адресной строке платежной формы.

    Минимальная защита: на каждое уведомление перезапрашивать статус у банка методом getOrderStatus.do и проверять, что OrderStatus равен 2.

    Проверки "заказ в банке существует и у него есть сумма" мало: у неоплаченного заказа статус 0, а сумма есть. В одном из проектов была ровно эта дыра, и оплату можно было проставить себе из браузера.

    Коды статуса: 0 не оплачен, 1 деньги заблокированы, 2 оплачен, 3 отменен, 4 возврат, 6 отклонен. Надежнее всего включить ключ подписи в кабинете и проверять checksum через hash_hmac('sha256', ...).

    Ошибка 3: сумма должна совпасть до копейки

    Условие вида

    intval($order['PRICE'] * 100) == $answer['Amount']

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

    Правильнее накапливать оплаченную сумму заказа и проводить заказ, когда набралась полная цена, а расхождение отправлять человеку письмом.

    Ошибка 4: чек пробивается внутри обработчика

    Если обработчик сам обращается в облачную кассу за чеком, ответ банку задерживается. Я замерял 5-7,5 секунды на одно уведомление, и все это время шлюз ждет. Фискализацию надо выносить в отложенную задачу, а банку отвечать сразу.

    Где задается адрес уведомлений

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

    • в кабинете банка: раздел настроек callback-уведомлений, поле со ссылкой, список событий и ключ подписи. Работает, даже если в коде сайта про уведомления ничего нет;
    • при создании платежа: параметр dynamicCallbackUrl. Он работает, только если банк включил эту опцию, иначе молча игнорируется.

    Прежде чем править обработчик, посмотрите в кабинете банка, какой адрес там стоит.

    Чего не исправит никакой обработчик

    Банк не отправил уведомление, а покупатель после оплаты не вернулся на сайт. Обработчик в этом случае просто не запускается.

    Я видел это на живом заказе: по неудачной попытке покупателя уведомление пришло, а по успешной оплате через четыре минуты в логах сервера тишина.

    Страхует только сверка на своей стороне. Раз в сутки забирать у банка список оплат методом getLastOrdersForMerchants.do и сравнивать со своими заказами. Две детали, на которых легко споткнуться:

    • параметр merchants обязателен и требует логин магазина, а не логин API. С логином API банк ответит errorCode 5, Отсутствует обязательное поле [merchants];
    • суммировать надо все операции по заказу, а не первую. Доплата приходит отдельной операцией и иначе дает ложное расхождение.

    Короткий список для проверки

    1. Отвечает ли обработчик не 200 при временных сбоях?
    2. Перезапрашивается ли статус у банка и проверяется ли OrderStatus == 2?
    3. Учитываются ли частичные оплаты и доплаты?
    4. Не ждет ли банк, пока пробьется чек?
    5. Есть ли ежедневная сверка оплат с банком?

    Если хотя бы на один вопрос ответ "нет" или "не знаю", оплаты рано или поздно будут теряться. Могу проверить ваш обработчик: обычно для этого хватает доступа к коду и журналу сервера.

    Источники: уведомления обратного вызова, документация Сбербанка, callback-уведомления, developer.sberbank.ru.

  • Письма с сайта попадают в спам, хотя SPF настроен: DKIM, PTR и HELO

    Письма с сайта или из CRM уходят в спам, хотя SPF настроен и даже DMARC есть. Обычно не хватает одного из двух: подпись DKIM не проверяется, потому что в DNS нет ключа нужного селектора, или у сервера не сходятся обратная запись IP и имя, которым он представляется. В разобранном случае после обеих правок оценка на mail-tester выросла с 4,9 до 9,7 из 10.

    С чего начать: заголовки письма

    Откройте письмо из спама и посмотрите оригинал. В Gmail это "Показать оригинал", в Яндексе "Свойства письма". Нужна строка Authentication-Results, ее добавляет сервер получателя:

    spf=pass  dmarc=pass  dkim=permerror (no key for signature) header.s=mail

    header.s это селектор, которым подписано письмо. dkim=permerror (no key for signature) значит, что получатель пошел за ключом по адресу mail._domainkey.ваш-домен и не нашел там записи.

    Часть первая: селектор DKIM

    У одного домена может быть несколько ключей DKIM одновременно, и это нормально. Яндекс 360 подписывает письма из веб-почты своим селектором, а сервер сайта или коробочного Битрикс24 своим. Получатель берет тот селектор, который указан в подписи письма, и ищет ровно его ключ.

    Отсюда правило: у каждого источника писем должен быть свой опубликованный ключ. Типичная картина: ключ Яндекса в DNS есть, а ключа сервера, с которого уходят письма сайта, нет.

    Проверить, опубликован ли ключ:

    dig +short TXT mail._domainkey.example.ru @1.1.1.1

    Пустой ответ значит, что ключа нет.

    Где взять ключ на сервере

    На BitrixVM письма уходят так: PHP -> msmtp -> Postfix -> OpenDKIM ставит подпись -> наружу. Нужные файлы:

    • /etc/opendkim/SigningTable - какой адрес каким селектором подписывать;
    • /etc/opendkim/KeyTable - где лежит закрытый ключ селектора;
    • /etc/opendkim/keys/<домен>.txt - готовая запись для DNS.
    cat /etc/opendkim/SigningTable /etc/opendkim/KeyTable
    cat /etc/opendkim/keys/example.ru.txt

    Содержимое .txt публикуется в DNS как TXT-запись <селектор>._domainkey. Ключ 2048 бит длиннее 255 символов. Большинство DNS-панелей принимают его одной строкой и сами делят на части. Если панель не принимает, вставьте в двухстрочном виде, как в файле.

    На серверах с Exim4 и FastPanel ключ лежит в /etc/exim4/dkim/, селектор по умолчанию dkim.

    Часть вторая: PTR и HELO

    Бывает, что все три проверки проходят (spf=pass, dkim=pass, dmarc=pass), а письма все равно в спаме. Тогда дело в том, как представляется сам сервер. Должны сходиться три вещи:

    ЧтоПримерКто настраивает
    PTR, обратная запись IPIP -> mail.example.ruхостер, владелец IP
    A-записьmail.example.ru -> тот же IPвы, в DNS домена
    HELO, имя сервера при отправкеmail.example.ruнастройка почтового сервера

    Google с 2024 года требует PTR от всех отправителей, SpamAssassin добавляет штраф RDNS_NONE, Яндекс пишет в заголовках unknown [IP].

    Проверка:

    dig +short -x 203.0.113.10 @1.1.1.1      # PTR
    dig +short mail.example.ru @1.1.1.1      # A-запись
    postconf myhostname                      # HELO в Postfix

    Подводные камни

    • PTR ставит хостер, а не владелец домена. Обычно в личном кабинете хостинга есть поле для обратной записи, иначе пишите в поддержку.
    • Исходящий IP может отличаться от IP сайта, если у хостера NAT. Какой адрес видят получатели, смотрите в заголовке Received доставленного письма. PTR и A-запись делайте на него.
    • Порядок важен: сначала A-запись, потом PTR, и только потом меняйте имя сервера. Иначе сервер начнет представляться именем, которое еще не существует.

    Смена имени в Postfix:

    cp /etc/postfix/main.cf /etc/postfix/main.cf.bak-$(date +%F)
    postconf -e 'myhostname = mail.example.ru'
    postfix check && systemctl reload postfix

    Проверка результата

    Отправьте письмо с сервера на адрес, который выдаст mail-tester.com, и откройте отчет. Тему пишите латиницей: кириллица в письме без кодировки ловит ложный штраф самого теста.

    printf 'From: box@example.ru\nTo: test-xxxx@srv1.mail-tester.com\nSubject: Deliverability test\n\nBody text.\n' | sendmail -f box@example.ru test-xxxx@srv1.mail-tester.com

    Чего эти правки не дают сразу

    Репутация домена и IP у Gmail и Яндекса восстанавливается 2-4 недели после исправлений. Следить удобно в Google Postmaster Tools и Постмастере Яндекса. Для массовых рассылок нужен еще заголовок List-Unsubscribe, такие письма лучше отправлять через модуль рассылок, а не как письма из CRM.

    Список для проверки

    1. В Authentication-Results все три pass?
    2. Ключ селектора из header.s опубликован в DNS?
    3. PTR исходящего IP указывает на имя, у которого есть A-запись на этот же IP?
    4. Сервер представляется этим же именем?
    5. В DMARC стоит adkim=r, а не строгий adkim=s?

    Если письма с вашего сайта не доходят до клиентов, пришлите мне заголовки одного письма из спама. По ним обычно сразу видно, какой из пунктов не выполнен.

  • Умный фильтр Битрикса потерял параметры: где на самом деле хранится настройка

    Из умного фильтра каталога пропали параметры подбора. В карточке свойства галочки стоят, кеш сброшен, а параметров нет. Дело в том, что признак "показывать в умном фильтре" хранится не в свойстве, а в отдельной таблице привязок к разделам. После импорта каталога эти привязки часто пропадают. Разбирался с этим, когда у магазина из 16 параметров подбора осталось 9.

    Проверено на Битриксе 26-й версии с шаблоном Аспро.

    Где на самом деле лежит настройка

    В таблице b_iblock_section_property, это привязка свойства к разделу каталога:

    ПолеСмысл
    IBLOCK_ID, SECTION_ID, PROPERTY_IDключ; SECTION_ID = 0 значит весь каталог
    SMART_FILTERY или N, участвует ли свойство в фильтре
    DISPLAY_TYPEвид: A ползунок, F флажки, K переключатель, P выпадающий список
    DISPLAY_EXPANDEDразвернут ли блок по умолчанию

    Чего нет там, где его обычно ищут:

    • в таблице свойств b_iblock_property колонки SMART_FILTER нет вообще;
    • в b_iblock_property_feature лежат только признаки показа в списке и карточке товара;
    • вкладки "Умный фильтр" в настройках каталога может не быть.

    Поэтому **нет строки в b_iblock_section_property - свойство выключено**, и никакая правка карточки свойства этого не изменит. Импорт каталога такие строки затирает, а номера самих свойств сохраняет. Снаружи это выглядит как "свойства пересоздались", хотя это не так.

    Диагностика

    Какие свойства реально включены:

    SELECT * FROM b_iblock_section_property WHERE IBLOCK_ID = 10 ORDER BY SECTION_ID, PROPERTY_ID;

    Есть ли у свойств значения (свойство без значений тоже исчезнет из фильтра):

    SELECT IBLOCK_PROPERTY_ID, COUNT(*) AS CNT, MIN(VALUE_NUM) AS MN, MAX(VALUE_NUM) AS MX
    FROM b_iblock_element_property
    WHERE IBLOCK_PROPERTY_ID IN (101, 102, 103) GROUP BY IBLOCK_PROPERTY_ID;

    Нет ли дублей после импорта, когда включить легко не тот экземпляр:

    SELECT NAME, COUNT(*) AS CNT, GROUP_CONCAT(ID ORDER BY ID) AS IDS
    FROM b_iblock_property WHERE IBLOCK_ID = 10 GROUP BY NAME HAVING CNT > 1;

    Вместо 10 и 101-103 подставьте номер своего каталога и своих свойств.

    Как включить свойство

    Правильнее через API, потому что он сам сбрасывает связанные кеши: \Bitrix\Iblock\SectionPropertyTable или старый CIBlockSectionPropertyLink::Set(). Прямой INSERT в таблицу работает, но кеш потом придется чистить руками.

    Фасетный индекс: кнопки пересборки нет

    Страница "Контент -> Инфоблоки -> Фасетные индексы" только показывает состояние и пишет "Все индексы созданы". Пересобрать оттуда нельзя, а без пересборки новые свойства в фильтре не появятся. Делается кодом из командной PHP-строки в админке:

    \Bitrix\Main\Loader::includeModule('iblock');
    $id = 10;
    \Bitrix\Iblock\PropertyIndex\Manager::markAsInvalid($id);
    \Bitrix\Iblock\PropertyIndex\Manager::deleteIndex($id);
    $indexer = \Bitrix\Iblock\PropertyIndex\Manager::createIndexer($id);
    $indexer->startIndex();
    $guard = 0;
    while ($indexer->continueIndex(30) && ++$guard < 200) {}
    $indexer->endIndex();

    Метода Manager::checkAdminNotice(), который встречается в примерах, нет в некоторых версиях: его вызов роняет скрипт. Из-за этого плашка "Необходимо создать фасетный индекс" может висеть, хотя индекс уже собран. Проверяйте по таблице, а не по плашке:

    SELECT FACET_ID, COUNT(*) FROM b_iblock_10_index GROUP BY FACET_ID;

    FACET_ID равен номеру свойства, умноженному на 2.

    Кеш, который не протухает

    У компонента фильтра время кеша нередко стоит огромным. В разобранном проекте было CACHE_TIME = 36000000, это 416 дней. Если меняли таблицу мимо API, чистить так:

    BXClearCache(true);
    $GLOBALS['CACHE_MANAGER']->CleanAll();
    $GLOBALS['stackCacheManager']->CleanAll();

    Признак того, что вы смотрите на кеш, а не на результат: страница отдается за 1-2 секунды и полностью совпадает с прошлым замером.

    У пересборки оказался полезный побочный эффект. Страница подбора, которая на холодном кеше собиралась больше 100 секунд и обрывалась по таймауту, стала отвечать за 1,4 секунды.

    Шаблон может выбросить свойство без следов

    В шаблонах умного фильтра Аспро (catalog.smart.filter/*/result_modifier.php) есть три места, где параметр пропадает молча:

    1. Жесткий список номеров свойств для сортировки. Чего нет в списке, того не будет в фильтре. У шаблонов для компьютера и телефона списки разные, поэтому набор параметров на телефоне может отличаться.
    2. Черный список свойств в начале файла: перечисленные там удаляются принудительно.
    3. Правило "убрать пустой ползунок": если максимум минус минимум не больше нуля, блок удаляется. Свойство без заполненных значений исчезнет именно так.

    Мобильный шаблон еще и делит параметры на группы по шесть. Отсюда эффект "было три вкладки, стало две": верстка не сломалась, просто параметров стало меньше.

    Порядок работ

    1. Проверить строки в b_iblock_section_property, а не карточку свойства.
    2. Убедиться, что у свойств есть значения и что включаете не дубль.
    3. Включить нужные свойства через SectionPropertyTable.
    4. Пересобрать фасетный индекс кодом.
    5. Очистить кеш всеми тремя вызовами.
    6. Сверить набор со списками в result_modifier.php обоих шаблонов.

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

  • Ошибка 503 после обновления Битрикса: обновление установилось не целиком

    После обновления Битрикса одна функция сайта перестала работать: форма с прикрепленным файлом отвечает "Произошла ошибка", остальное в порядке. Админка при этом пишет "система обновлена", версии модулей свежие. Причина оказалась в том, что обновление установилось не целиком: один модуль обновился, а файл другого модуля, который от него зависит, остался старым.

    Случай разобран на коробочном Битрикс24, но механизм общий для сайтов на Битриксе.

    Как это выглядело

    Посетители заполняли форму CRM с полем "файл" и видели "Произошла ошибка". Запрос на загрузку файла получал ответ 503 с заголовком Retry-After: 600 и стандартной страницей "Упс... Похоже возникла ошибка в нашем программном коде".

    503 вместе с Retry-After похоже на защиту от нагрузки или блокировку по IP, и это уводит в сторону. На самом деле так Битрикс отвечает на любую необработанную ошибку в коде.

    Поломка кода или блокировка: три запроса

    Все запросы форм идут через один адрес /bitrix/services/main/ajax.php, отличается только параметр action. Сравните три ответа:

    ЗапросОтвет
    проблемное действие503, фатальная ошибка
    любое другое действие того же модуля200 и JSON с понятной ошибкой
    заведомо несуществующее действие, например zzz.qqq200 и JSON "модуль не найден"

    Если выдуманное действие обрабатывается нормально, а конкретное падает, это не блокировка и не защита, а поломка в коде именно этого действия.

    Если сайт стоит за защитой от DDoS или CDN, повторите запрос по прямому адресу сервера. Одинаковый ответ и nginx в заголовке Server окончательно снимают подозрение с защиты.

    Где искать текст ошибки

    Путь к журналу задан в /bitrix/.settings.php, в секции exception_handling:

    'log' => ['settings' => ['file' => '/home/bitrix/logs/php_error.log']],

    Журнал пишется даже при выключенной отладке, включать ничего не нужно. Читайте его по SSH.

    Не открывайте .settings.php через файловый менеджер админки: страница редактирования открывается пустой, и если нажать "Сохранить", файл обнулится вместе с настройками подключения к базе.

    Причина

    В журнале было:

    FATAL - [ErrorException] E_COMPILE_ERROR
    Declaration of Bitrix\Crm\Controller\Site\FileUploader::uploadAction(...)
    must be compatible with Bitrix\UI\Controller\FileUploader::uploadAction(..., ?string $strategy = null)

    Модуль ui обновился, и в его методе появился новый параметр. Файл модуля crm, который наследует этот метод, остался прежним. PHP отказывается собирать такой класс.

    Проверяется по датам файлов:

    ls -la bitrix/modules/crm/lib/controller/site/fileuploader.php \
           bitrix/modules/ui/lib/controller/fileuploader.php

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

    Исправление

    Привести объявление метода в наследнике в соответствие с родительским и передать новый параметр дальше в parent::. Перед правкой сохранить копию файла рядом.

    Это правка в ядре. Следующее корректное обновление модуля ее перезапишет, и это правильно. Но если обновление снова не доедет, поломка вернется. Поэтому в техподдержку Битрикса писать все равно стоит: у них не установилось обновление файла, и то же самое может случиться у других.

    Почему этого никто не заметил

    В журнале нашлось около 400 таких ошибок за месяц. Посетители просто уходили. А в описании формы кто-то заранее дописал: "если после отправки вы получили сообщение об ошибке, отправьте документы на почту". То есть о проблеме знали, но обходили ее, вместо того чтобы искать причину.

    Журнал ошибок стоит просматривать раз в месяц, а не только по жалобам. Обновление ядра раз за разом проходит без последствий, а потом один недоехавший файл месяц стоит вам заявок.

    Что проверить у себя

    1. После каждого обновления пройти основные сценарии: заказ, формы с файлами, оплату, личный кабинет.
    2. Знать, где лежит журнал ошибок (exception_handling в .settings.php), и заглядывать в него.
    3. При ошибке 503 на одном действии сравнить его с соседними, а не искать блокировку.
    4. При подозрении на неполное обновление сравнивать даты файлов, а не версии модулей.

    Если на вашем сайте после обновления что-то перестало работать, напишите мне. Обычно причина находится по журналу за час.