10 DNS-запитів SPF: чому виникає permerror і як правильно робити flattening для великої інфраструктури
permerror у SPF майже ніколи не про «забагато відправників» — це про кількість DNS-лукапів, які тихо накопичуються через вкладені include хмарних провайдерів. Розбираємо точні правила підрахунку з RFC 7208, вчимося рахувати лукапи руками, чистимо мертві include і показуємо, коли flattening виправданий, а коли це постріл собі в ногу.
EvilMail Team14 липня 2026 р.11 хв читання
Симптом виглядає невинно: листи від твого легітимного домену раптом починають осідати у спамі Gmail, а частина взагалі відбивається з 550 5.7.23. Ти дивишся на SPF-запис — він на місці, синтаксично коректний, «виглядає нормально». А в заголовках вхідного листа стоїть Received-SPF: permerror. Нічого не зламано у звичному розумінні. Просто твій SPF перевищив ліміт у 10 DNS-запитів, і кожен приймач, що поважає RFC, тепер трактує його як недійсний.
Ключове непорозуміння, через яке люди тижнями шукають не там: permerror — це не про кількість IP-адрес, з яких ти шлеш пошту. Ти можеш мати ip4: на тисячу мереж і нуль проблем. Проблема — у кількості DNS-лукапів, які доводиться зробити, щоб оцінити запис. І більшість «фіксів» з перших сторінок пошуку («додай ще один include», «розбий на два записи») або марні, або роблять гірше.
Що насправді означає ліміт «10 lookups» (RFC 7208 §4.6.4)
RFC 7208 задає жорстке правило: під час однієї оцінки SPF приймач має право зробити не більше 10 DNS-запитів на механізми та модифікатори, що вимагають резолву
SPF permerror і ліміт 10 DNS-запитів: діагностика та flattening | evilmail — EvilMail Blog
. Одинадцятий — і результат негайно
permerror
.
Лукап коштують такі механізми:
include
a
mx
ptr
exists
модифікатор redirect
Лукап не коштують (їх можна ставити скільки завгодно):
ip4
ip6
all
exp
Головний нюанс, який усі недооцінюють — рекурсія. Коли ти пишеш include:_spf.google.com, це не «+1». Це +1 за сам include плюс усе, що розкриється всередині чужого запису. А _spf.google.com усередині тягне ще три вкладені include:_netblocks*.google.com. Тобто один рядок у твоєму записі коштує тобі ~4 лукапи з твого бюджету 10. Ти не контролюєш чужий SPF — а платиш за нього ти.
Є ще два окремі підлічильники, про які часто забувають:
`mx` не може повертати більше 10 MX-хостів для подальшого резолву A/AAAA. Одинадцятий MX → permerror.
Void-лукапи (запити, що повертають NXDOMAIN або порожню відповідь) — максимум 2. Третій «порожній» лукап теж дає permerror. Це рятує від записів, що резолвлять неіснуючі домени, але й ловить тебе, коли якийсь include вказує на мертвий сервіс.
І не плутай permerror з temperror. permerror — постійна структурна помилка (у тому числі перевищення ліміту): вона не «розсмокчеться». temperror — тимчасовий DNS-збій, лист варто перевідправити. Найгірше в permerror те, як на нього реагують приймачі: Gmail, Outlook і Yahoo здебільшого трактують його як відсутній SPF. А немає SPF-пасу → немає SPF-вирівнювання для DMARC → якщо DKIM теж не вирівняний, лист отримує DMARC fail і йде під твою ж політику p=reject. Ти сам собі виписав відбій.
Бачиш: _spf.google.com сам по собі — один лукап, але тягне ще три. Це чотири з твоїх десяти на єдиний рядок.
Руками це швидко набридає, тому користуйся автоматикою. Найнадійніший інструмент — checkdmarc:
bash
pip install checkdmarc
checkdmarc evilmail.pro
Він друкує дерево include, рахує сумарні лукапи і прямо каже, коли їх більше 10. Для перевірки конкретного відправника є pyspf:
bash
pip install pyspf
python3 -c "import spf; print(spf.check2(i='209.85.220.41', s='[email protected]', h='mail.google.com'))"
# ('pass', 'sender SPF authorized') або ('permerror', 'Too many DNS lookups')
Онлайн-валідатори теж працюють, але майте на увазі: вони інколи розходяться між собою й з реальністю. Причини — кешування DNS (хтось бачить старий запис), різне трактування void-лукапів і те, що деякі чекери зупиняються на 10 і не показують «справжню» глибину. Довіряй checkdmarc над браузерним віджетом.
Нарешті, навчися читати правду з заголовків уже доставленого листа. У Gmail: «Показати оригінал» → шукай Authentication-Results та Received-SPF:
Ось воно чорним по білому: spf=permerror → dmarc=fail. Це фінальна інстанція, а не здогадки чекера.
Чому запис розростається: анатомія include-sprawl
Ніхто не додає 13 лукапів за раз. Вони накопичуються місяцями. Типовий сценарій великої компанії:
Пошта співробітників на Google Workspace → _spf.google.com (~4).
Частина на Microsoft 365 після поглинання іншої фірми → spf.protection.outlook.com (~1–2).
Транзакційні листи через Amazon SES → amazonses.com (~2).
Розсилки через SendGrid → sendgrid.net (~2).
Тригерні листи з Mailgun → mailgun.org (~2).
Плюс include: від CRM, тікетниці й маркетингової платформи.
П'ять «безневинних» рядків — і ти вже на 12–14. А найгірший тип запису — той, де половина includeмертва: сервіс, яким користувалися три роки тому, відключили, але SPF ніхто не почистив. Ці лукапи так само з'їдають бюджет і ще й ризикують перетворитися на void-лукапи, якщо чужий домен зник.
Що зробити ДО flattening (дешеві перемоги)
Перш ніж лізти у flattening — а це, забігаючи наперед, борг, який ти береш на себе — вичерпай прості кроки. Часто вони самі повертають тебе під 10.
1.Видали невикористовувані include. Пройдись логами MTA за 90 днів і подивись, хто реально шле від твого домену. Немає трафіку через SendGrid — прибирай include:sendgrid.net. Це найдешевша перемога, і вона в 70% випадків вирішує проблему.
2.Прибери `ptr`. Механізм ptr офіційно застарілий (RFC 7208 радить його не використовувати): він повільний, ненадійний і їсть лукап. Якщо він у тебе є — його там бути не повинно.
3.Розведи відправку по піддоменах. Це найпотужніший архітектурний прийом. Транзакційка йде з mg.evilmail.pro, маркетинг — з news.evilmail.pro, і кожен піддомен має свій короткий SPF:
DMARC-вирівнювання при relaxed-режимі (за замовчуванням) зберігається, бо піддомен в межах організаційного домену. Кореневий запис худне до 4 лукапів, і кожен потік ізольований: проблема Mailgun не валить корпоративну пошту.
1.Для рідкісних відправників використовуй exists-макроси або делегування замість повноцінного include.
SPF flattening: як це працює і чим платиш
Суть flattening проста: ти резолвиш усі механізми, що вимагають лукапів, до кінцевих ip4/ip6 і замінюєш include на плаский список мереж. Лукапів стає нуль (крім самого all, який їх не коштує). Було:
Виглядає як перемога. Насправді це відкладена проблема. Головна ціна: Google, Microsoft і SES ротують та додають IP без жодного попередження. Плаский запис про це не знає. У момент, коли провайдер вивів новий діапазон, твій SPF починає тихо давати fail для частини легітимної пошти — без помилок, без алертів, просто листи повільно осідають у спамі. Це найпідступніший вид поломки, бо симптом розмитий у часі.
Звідси головне правило: flattening без щоденної автоматики перегенерації — це антипатерн. Якщо ти не готовий підтримувати cron, що щодня перерезолвлює джерела й оновлює запис — не роби flattening взагалі. Роби піддомени.
Ще одна дрібниця, що ламає записи: 255-символьний ліміт на один quoted-string у TXT. Плаский SPF легко переростає його. Рішення — ділити значення на кілька рядків у межах одного TXT ("v=spf1 ip4:... " "ip4:... -all"), вони конкатенуються. Стеж і за загальним розміром DNS-відповіді, щоб не вилізти за межу UDP і не спровокувати fallback на TCP.
Практична реалізація flattening з моніторингом
Робочий процес виглядає так: скрипт резолвить визначений набір include у мережі, збирає їх у плаский v=spf1 ... -all, порівнює з тим, що зараз у DNS, і лише при відмінності оновлює запис через API DNS-провайдера та шле алерт інженеру.
Скелет такого скрипта (спрощено, dnspython):
python
import dns.resolver
SOURCES = ["_spf.google.com", "amazonses.com"] # лише стабільні провайдери
def resolve_spf(domain, seen=None):
seen = seen or set()
nets = set()
for rr in dns.resolver.resolve(domain, "TXT"):
txt = b"".join(rr.strings).decode()
if not txt.startswith("v=spf1"):
continue
for tok in txt.split():
if tok.startswith(("ip4:", "ip6:")):
nets.add(tok)
elif tok.startswith("include:"):
sub = tok.split(":", 1)[1]
if sub not in seen:
seen.add(sub)
nets |= resolve_spf(sub, seen)
return nets
nets = set()
for src in SOURCES:
nets |= resolve_spf(src)
flat = "v=spf1 " + " ".join(sorted(nets)) + " ~all"
# порівняти з поточним TXT; при відмінності — PATCH через API DNS + повідомити в Slack
Два акценти. Перший: -all (hardfail) вмикай тільки після кількох чистих циклів на ~all (softfail) — інакше перша ж ротація IP заб'є легітимну пошту намертво. Другий, і найважливіший: не роби плоским те, що не варто. SendGrid і Mailgun офіційно не рекомендують flattening своїх include саме через часту ротацію. Правильний підхід — частковий flattening: пласко тільки для стабільних діапазонів (умовно, власна інфраструктура), а для «живих» провайдерів лишай звичайний include: і виноси їх на окремий піддомен.
Чек-лист
Порахуй лукапи через checkdmarc — не на око, а числом.
Вичисти мертві include за логами MTA. Часто це вже вирішує все.
Прибери ptr повністю.
Розведи відправку по піддоменах (mg., news.) з вузькими SPF — це знімає більшість тиску без flattening.
Чесно спитай себе, чи потрібен flattening взагалі. Для більшості — ні.
Якщо потрібен: автоматизуй перегенерацію (cron) + алерт на дрейф. Ручний flattening заборонений.
Тримай ~all до стабілізації, тоді переходь на -all.
Роби плоскими лише стабільні джерела; SendGrid/Mailgun лишай через include:.
Стеж за 255-символьним лімітом і розміром TXT-відповіді.
Після кожної зміни перевіряй Authentication-Results реального листа.
Flattening — інструмент для великих, у кого вже є автоматика і хто готовий доглядати за пласким записом як за живим сервісом. Для всіх інших правильна відповідь простіша й надійніша: гігієна include та розведення потоків по піддоменах. Плаский запис, який ти згенерував один раз і забув, не економить тобі лукапи — він призначає тобі побачення о третій ночі з 550 5.7.23.