Кожна інструкція з ротації секретів закінчується «а потім оновіть споживачів». Саме на цьому реченні гине прод. Я веду дві self-hosted n8n-інсталяції для студії автоматизації в Ізраїлі. Сьогодні зранку я порахував: 114 облікових даних, що згадуються в 1 311 вузлах у 260 workflow. Найпопулярніші — підключення Postgres: 233 вузли в 80 workflow, з них 40 активних. Між 20 і 25 серпня я обернув чотири секрети. Три з них щось зламали — і жодного разу всередині n8n.
Проблема не в ротації: інвентар споживачів кульгає
Сама ротація не була проблемою. Впав інвентар споживачів. Список систем, які залежать від секрету, виявився неповним або застарілим. Це і є точка відмови, де «оновіть споживачів» перетворюється на «і нехай щось випаде». Коли дайте пряме запитання — відповідь проста: порахуйте всі місця, де живе значення, не лише там, де воно створюється.
Дві інсталяції n8n тримають 114 credentialів, вплетених у 1 311 вузлів і 260 workflow. Один credential Postgres з’являється у 233 вузлах і 80 workflow, з них 40 активні. Ключ шлюзу WhatsApp використовується у 106 вузлах. Такі масштаби роблять будь-яку помилку дорогою й тихою.
Три збої показали три різні механіки поломки. Стара копія жила у двох з шести мостових застосунків. Похідне значення було зареєстроване у стороннього провайдера. Жорстко заданий оверрайд перекривав правильну змінну. Модулі були в строю, зелені дашборди не моргали, а дані падали між шарами.
Перед тим як чіпати credential, я тепер ставлю питання і до себе, і до системи. Де ще живе копія? Чи є похідні токени, проксі, файли на клієнтському хості, URL у третьої сторони? Готові перейти до конкретних інцидентів і перевірок?
Збій 1: копія, про яку забули (2,5 дні, жодного алерта)
Відповідь проста: дві з шести копій залишилися на старому токені. 20 серпня я повернув Chatwoot API token. Міст WAHA→Chatwoot зберігає свій токен окремо для кожного inbox-застосунку. Було шість застосунків — я оновив чотири. Два мовчали й продовжували працювати на старому значенні.
Вхідні звернення летіли на POST /contacts/filter, отримували 401 Invalid Access Token і зникали. Вихідні виглядали нормальними для агента: рядок створювався у Chatwoot, а потім зависав на тому ж 401. Алертів не було взагалі, бо «нічого не впало»: процес працював, endpoint відповідав, черга просто наповнювалась невдалими задачами. Я помітив 23 серпня. Дві з половиною доби.
Побічний ефект виявився гіршим за простій. Пошук контакту вимагає токен, але створення — ні, бо йде через публічний endpoint. Кожне вхідне повідомлення за час простою створювало новий рядок у contact_inboxes. Я нарахував 18 489 дублікатів, більшість саме з цього інциденту. Потім це породжувало 404 на update_last_seen: міст брав одну відповідність, а розмова лежала на іншій.
Фікс, який прижився: денна перевірка, що валідуює токен кожного bridge app напряму у Chatwoot. Один рядок на застосунок, ненульовий exit, якщо щось не сходиться. Хеш порівнюється без виводу самого значення — локально і в таблиці Chatwoot.
sha256(config.accountToken) проти left(encode(sha256(token::bytea),'hex'),16) у access_tokens
Збій 2: похідне значення (14 годин відхилених дзвінків)
Пряма причина: токен URL був похідним від ключа підпису. Увечері 20 серпня о 22:55 я повернув ключ підпису сервісу голосових відповідей. Ключ підписує magic links і адмінські cookies — це я врахував. Але ще він породжує URL-токен, який Telnyx використовує для доставки вхідних дзвінків на /voice/telnyx/<token>. Про це я забув.
Telnyx продовжив постити на старий URL. Кожен вхідний дзвінок отримував 403 bad URL token до 13:07 наступного дня. Шість дзвінків з чотирьох номерів були відхилені сервісом, чия єдина робота — відповідати на дзвінки. Жоден етап ротації не торкався Telnyx, тож попередити мене було нічому.
Я додав три рівні захисту. На кожному старті сервіс порівнює свій webhook URL з тим, що збережено у Telnyx, і виправляє розбіжності. Будь-який 403 на токені миттєво тригерить ресинхронізацію і Telegram-алерт. А fallback URL у Telnyx веде на інший сервер із workflow n8n, який відтворює запис вибачення. Розрив у детекції скоротився з 14 годин до секунд.
Я перевірив це, здійснивши контрольний дзвінок. Нічого складного, але саме такі дрібні «похідні» значення впиваються найболючіше. Ви теж маєте токени, що будуються з ключів? Тоді перевірка й самовиправлення мають бути частиною завантаження сервісу.
Три шари: автопорівняння й автокорекція webhook URL на старті; миттєвий ресинк і Telegram-алерт на 403; fallback URL на інший сервер з вибаченням.
Збій 3: тіньовий оверрайд (858 × 401 за 38 годин)
Суть: жорстко заданий заголовок перебив правильну змінну. 23 серпня я повернув WAHA API ключ. Він жив у шести місцях: дві лінії в .env, сніпет nginx, що інжектує ключ на публічний шлях медіа, двоє облікових даних n8n і локальний keychain. На всі шість був перевірений порядок дій.
Процедура оновила сніпет nginx. Але в конфігурації віртуального хоста лишилася друга, жорстко прописана лінія proxy_set_header X-Api-Key <old value> усередині location /api/files/. Вона з’явилася ще до появи сніпета і «перемагала» змінну. У сніпеті значення було правильним, та його ніхто не читав.
Наслідок: 858 запитів повертали 401 за 38 годин. Будь-які медіа-повідомлення — голосові, зображення, стікери — на п’яти WhatsApp-сесіях перестали синхронізуватися в Chatwoot. В інбоксі це виглядає як «unsupported message type» нотатки, а не як помилки. Публічний vhost увесь цей час світився зеленим. Реальні клієнти — єдині, хто тягне медіа з нього.
Перевірка, яку я використовую зараз: із середини контейнера WAHA зробити curl на реальний медіа-шлях через публічний домен. Localhost 200 плюс публічний 401 означає, що якийсь прошарок інжекції тримає старе значення. Проста асиметрія показує тіньовий оверрайд одразу.
З контейнера WAHA: curl на публічний медіа-шлях. Localhost 200 + публічний 401 = застаріле інжектоване значення у проксі-шарі.
Як це зупинити наступного разу: верифікація і інвентар
Відповідь: пишіть верифікацію до ротації й тримайте живий інвентар. 25 серпня я міняв секрет вебхука клієнтського сайту після витоку. Тригер вебхука зберігає повні заголовки запиту в даних виконання, включно з X-Webhook-Secret, і я відкривав це виконання для відладки. Було чотири точки призначення: рядок конфігу в Supabase, файл на WordPress-кості клієнта, credential в n8n і keychain. Різниця — я спочатку написав перевірку. Новий секрет дає 200, старий — 401. Потім я дочекався, поки два розклади (cron на 2 та 5 хвилин) відрапортують success. Лише після цього видалив старе значення. Це зайняло довше, але не вимагало пояснень клієнту.
Інвентар n8n — це запит. Вузли workflow зберігаються як JSON, а кожен вузол з credential має credentials: { <type>: { id, name } }. Я розплющую це в таблицю «радіус ураження», не торкаючись секретів. Далі можна порахувати вузли, workflow і активні workflow на кожен credential, побачити «вузли в повітрі» і визначити одинарні точки відмови ще до ротації.
WITH n AS ( SELECT w.id AS wid, w.active, w."isArchived" AS arch, jsonb_array_elements(w.nodes::jsonb) AS node FROM workflow_entity w ), c AS ( SELECT wid, active, arch, e.key AS ctype, e.value->>'id' AS cid FROM n, LATERAL jsonb_each(node->'credentials') e WHERE node ? 'credentials' ) SELECT c.cid, coalesce(ce.name, '<deleted>') AS name, c.ctype, count(*) AS nodes, count(DISTINCT wid) AS workflows, count(DISTINCT wid) FILTER (WHERE active AND NOT arch) AS active_workflows FROM c LEFT JOIN credentials_entity ce ON ce.id = c.cid GROUP BY 1, 2, 3 ORDER BY nodes DESC;
Три речі, які цей запит показав сьогодні зранку. Вісім ID credentialів усе ще згадуються у workflow, але вже відсутні в credentials_entity. Вони — «<deleted>», усі в неактивних або архівних workflow, та один з них — Telegram credential у 43 вузлах. 44 з 100 credentialів на головній інстанції змінені за останні 30 днів. Ми обертаємо постійно, тож це має бути рутиною. Postgres credential із 233 посиланнями — одиночна точка відмови, яку не показує жоден дашборд. Якщо коли-небудь зміню цей пароль, порядок дій важить більше за сам пароль.
Для оновлення: на n8n 2.36 публічний API приймає PATCH /api/v1/credentials/{id} з об’єктом data. Я перевірив маршрут зранку тілом, яке мав проігнорувати, і отримав 200. Десять днів тому я ще створював новий credential, переписував усі посилання вузлів і видаляв старий: три записи й шанс щось пропустити. Тепер — один PATCH, а потім підтвердити, що versionId дорівнює activeVersionId на кожному споживачі, бо збережений workflow не обов’язково є тим, що реально виконується. Секрет, який уже витік? Не відправляйте його в JSON вузла. httpCustomAuth вливає тіло прямо в запит, а httpHeaderAuth — у заголовок. Приберіть колонку з SELECT, підв’яжіть credential до HTTP-вузла, і значення ніколи не потрапить у JSON. Далі — зачистка збережених виконань.
UPDATE execution_data SET data = replace(data, :'sec', '***REDACTED***') WHERE position(:'sec' IN data) > 0; (Запустіть скриптом, що читає значення зі stdin, аби воно не лишилось в історії shell.)
Останнє запитання просте і неприємне. Для вашого найчастіше згадуваного credential: скільки копій існує поза системою, що ним володіє? Мости, проксі, файл на клієнтському хості, URL у третьої сторони, похідний токен. Якщо маєте число — як ви тримаєте його актуальним? Якщо не маєте — ось ваше число.
На основі матеріалу оригінального джерела.