Зачем я поставил «калитку» перед reverse proxy — и почему обычного логина мне оказалось мало
У каждого из них уже есть собственная авторизация. Казалось бы, что ещё нужно?
Но меня долго смущала одна простая вещь: почему форму логина вообще должен видеть весь интернет?
Да, за ней стоит пароль. Возможно, MFA. Возможно, очень хороший механизм аутентификации.
Но сам сервис всё равно торчит наружу. Его можно сканировать, определять по заголовкам и поведению, искать конкретную версию, долбить login endpoint, перебирать известные CVE.
Можно решить вопрос VPN.
Можно поднять Authentik или Authelia.
Можно использовать Cloudflare Access или другой Zero Trust.
Можно добавить Basic Auth перед обычной авторизацией и получить прекрасный UX из двух логинов подряд.
Но для нескольких моих сервисов всё это казалось слишком большим решением для очень маленькой задачи.
Мне хотелось буквально следующего:
Если к закрытой двери подошёл незнакомый человек — пусть сначала позвонит.
А я решу, открывать ему или нет.
Так появилась Kalitka.
Почему «Калитка»
Калитка — это маленькая дверь в заборе рядом с большими воротами.
Она не заменяет дверь дома.
На ней не обязательно стоит сложная система идентификации личности.
Она просто не позволяет любому прохожему сразу оказаться у вашего крыльца.
Это довольно точно описывает идею проекта.
Kalitka не является Identity Provider.
Она не заменяет авторизацию приложения.
Она не является WAF.
Это небольшой pre-filter перед приложением:
visitor
│
▼
reverse proxy
│
│ forwardAuth
▼
Kalitka ──────────► Telegram
│ │
│ Allow?
│ │
◄────────────────────┘
│
▼
actual application
│
▼
its own loginПока я не разрешил запрос, пользователь вообще не доходит до настоящего приложения.
Как это выглядит для пользователя
Допустим, есть:
https://grafana.example.comTraefik получает запрос и перед тем, как отдать его Grafana, обращается к Kalitka через forwardAuth.
Если пользователь уже разрешён — Kalitka отвечает 200, и Traefik продолжает запрос.
Если нет — пользователь получает redirect на:
https://gate.example.com/request?target=grafana.example.comТам можно представиться.
Например:
sergey@example.comили просто:
SergeyПосле этого запрос переходит в состояние ожидания, а мне в Telegram приходит сообщение примерно с такой информацией:
Someone is at the gate
Target: grafana.example.com
Identity: sergey@example.com
IP: 203.0.113.42
Location: Cologne, GermanyИ две кнопки:
Allow DenyРазрешил — браузер получает подписанную session cookie и идёт дальше.
И только теперь пользователь видит настоящий login Grafana.
То есть наличие разрешения Kalitka ещё не означает наличие доступа к Grafana.
Это две разные вещи.
И это для меня было принципиально.
Почему не просто VPN
VPN прекрасно решает подобные задачи.
У меня нет никаких претензий к WireGuard.
Но VPN хорошо работает, если круг пользователей известен заранее.
Мои сценарии немного другие.
Например, мне нужно на несколько часов дать доступ человеку с ноутбука, который я не контролирую.
Или я сам нахожусь в дороге.
Или необходимо быстро показать staging-систему.
Начинается:
— установи WireGuard;
— вот конфиг;
— импортируй;
— включи tunnel;
— нет, на телефоне другая кнопка;
— а теперь попробуй открыть снова.
Для постоянного административного доступа — замечательно.
Для ситуации «открой человеку вот эту дверь один раз» — мне хотелось чего-то проще.
Почему не IP allowlist
Первой мыслью была обычная белая таблица IP.
Она действительно проще всего.
До первой поездки.
Мобильные сети, CGNAT, гостиничный Wi-Fi, домашний динамический IP — и список начинает жить своей жизнью.
При этом IP-фильтрация всё равно осталась полезной частью Kalitka.
Просто я перестал считать IP полноценной идентичностью.
Если адрес известен — можно пропустить его автоматически.
Если надоел — можно заблокировать.
Но неизвестный IP не означает автоматически злоумышленника.
Он просто звонит в калитку.
Почему не Authentik / Authelia
Потому что это немного другая задача.
Мне не хотелось строить ещё одну полноценную систему идентификации.
Если приложение умеет нормально логинить пользователя — пусть продолжает это делать.
Kalitka отвечает только на вопрос:
«Можно ли этому запросу вообще показать приложение?»
Это намеренно гораздо более слабая семантика.
Если человек написал в поле:
Elon MuskKalitka не делает вывод, что это действительно Илон Маск.
Она сообщает мне:
Кто-то представился как Elon Musk. Пустить?
Поэтому я сознательно называю это approval, а не authentication.
Впрочем, для пользователей, которых действительно можно идентифицировать заранее, я добавил опциональный Google Sign-In.
Но к нему мы ещё вернёмся — там возник интересный архитектурный нюанс.
ForwardAuth оказался простой частью
На первый взгляд весь сервис выглядит почти смешно.
Traefik делает примерно это:
GET /auth
X-Forwarded-Host: grafana.example.com
X-Forwarded-For: 203.0.113.42Ответ:
200— пропускаем.
Или:
302
Location: https://gate.example.com/request?target=grafana.example.com— отправляем к Kalitka.
Первая версия действительно появилась довольно быстро.
А потом начались вопросы, которые обычно начинаются после слов:
«Ну, оно уже работает».
Какой у клиента IP?
Кажется очевидным:
X-Forwarded-ForНо есть маленькая проблема.
Этот заголовок может прислать сам клиент.
Например:
X-Forwarded-For: 10.0.0.15Если 10.0.0.0/8 находится в bypass list и приложение слепо доверяет заголовку, поздравляю: мы только что самостоятельно добавили endpoint обхода собственной защиты.
Поэтому Kalitka доверяет forwarded headers только в том случае, если TCP-соединение пришло от явно доверенного proxy.
То есть конфиг содержит TrustedProxies.
Условно:
Internet
│
▼
Traefik
172.18.0.3
│
▼
KalitkaKalitka знает:
172.18.0.0/16 = trusted proxy networkЕсли запрос действительно пришёл оттуда — можно начинать разбирать X-Forwarded-For.
Если нет — заголовок игнорируется.
И тут появляется вторая ловушка
Многие берут первый IP из:
X-Forwarded-For:
client, proxy1, proxy2То есть самый левый.
Но именно его особенно легко контролировать клиенту, если proxy добавляет адрес к существующему заголовку.
Надёжнее идти справа налево:
client, proxy1, proxy2
◄──────Пока встречаются известные trusted proxies — пропускаем их.
Первый адрес, который не принадлежит доверенной proxy-инфраструктуре, и есть кандидат на client IP.
Это одна из тех мелочей, которые кажутся избыточными ровно до момента, когда от IP начинают зависеть:
allow list;
block list;
LAN bypass;
GeoIP;
audit;
уведомления.
Fail-open или fail-closed?
Следующий вопрос:
Что должно произойти, если Kalitka умерла?
Можно выбрать UX:
Kalitka unavailable → ну ладно, пропустимЭто fail-open.
Удобно.
Только тогда вся система является защитой ровно до первого:
docker stop kalitkaПоэтому я выбрал fail-closed.
Если reverse proxy не может получить разрешение — приложение недоступно.
Это создаёт другой риск: можно случайно запереть самого себя снаружи.
Поэтому есть BypassNetworks.
Например, локальная сеть:
192.168.1.0/24из которой сервисы доступны независимо от approval.
Но и здесь снова появился интересный edge case.
Представим:
BypassNetworks = Docker network
TrustedProxies = emptyЧто тогда видит Kalitka?
Все запросы приходят от Traefik.
А Traefik находится именно в bypass network.
Следовательно:
Internet user
↓
Traefik = trusted by accident
↓
everyone bypasses KalitkaПричём конфигурация внешне выглядит совершенно разумно.
Поэтому теперь такой конфиг вообще считается ошибкой, и приложение отказывается запускаться.
Мне всё больше нравится принцип:
неправильная security-конфигурация должна громко ломать deployment, а не тихо делать его небезопасным.
Host header — ещё один интересный момент
После approval нужно понимать, куда именно вернуть пользователя.
Наивная реализация:
target = Host
redirect("https://" + target)И у нас появляется open redirect / host-header injection.
Поэтому Kalitka не считает произвольный host допустимой целью.
Сначала администратор явно включает защиту хоста:
grafana.example.com
n8n.example.com
admin.example.comИ только такой host может использоваться как target.
То есть даже если кто-то подделает:
Host: evil.exampleэто не превратит Kalitka в redirector на произвольный домен.
Эта же идея позволяет удобно подключать middleware сразу к большому количеству сервисов.
Сам факт присутствия forwardAuth не означает, что хост закрыт.
Хост нужно отдельно arm.
А теперь cookies
После разрешения нужно как-то запомнить решение.
Мне не хотелось хранить server-side sessions.
Поэтому сессия Kalitka — это подписанная cookie.
Примерно концептуально:
host=grafana.example.com
expires=...
signature=HMAC(...)Cookie:
Secure
HttpOnly
SameSite=LaxПодпись не позволяет пользователю самостоятельно изменить host или срок действия.
Ротация HMAC secret автоматически инвалидирует все существующие сессии.
Удобно.
Но тут возникает гораздо более интересная проблема.
Почему cookie нельзя просто сделать Host-only
Kalitka находится здесь:
gate.example.comа защищаемое приложение здесь:
grafana.example.comCookie устанавливает gate.example.com.
Браузер не позволит этому хосту поставить Host-only cookie для:
grafana.example.comИ правильно сделает.
Поэтому текущая схема использует parent domain:
Domain=.example.comТеперь cookie браузер отправляет sibling-хостам.
Но внутри manual approval подписан конкретный target:
grafana.example.comПоэтому approval Grafana нельзя просто использовать для n8n.
То есть cookie физически доступна sibling hosts, но её логическое содержимое host-bound.
И здесь всё работало вполне неплохо.
До Google OAuth.
Google Sign-In и неожиданная разница в Session Scope
Для заранее известных пользователей я добавил Google Sign-In.
Например, можно разрешить:
*@example.comили конкретные аккаунты.
Человек подтверждает свою личность через Google и получает доступ без ожидания Telegram approval.
И тут возник вопрос:
А для какого host выпускать Google session?
Можно привязать её только к текущему target.
Но тогда при переходе:
grafana.example.com
↓
n8n.example.comчеловеку придётся снова идти через OAuth.
Можно дать global session.
Так сейчас и работает.
И это означает важное различие:
Manual approval
Approve Grafana
→ Grafanaне открывает автоматически:
n8n.example.comGoogle approval
Sign in
→ вся группа guarded hosts внутри CookieDomainТо есть security scope разный.
Мне кажется неправильным скрывать такие особенности за красивым README, поэтому это сейчас явно описано как sharp edge.
Следующая логичная архитектура — per-host session через одноразовый handoff token.
Как можно сделать настоящий Host-only session
Схема получается интересная.
После approval на:
gate.example.comKalitka выпускает короткоживущий одноразовый token:
aud = grafana.example.com
jti = random
exp = now + 30 secИ отправляет браузер:
https://grafana.example.com/?__kalitka=<token>Запрос снова попадает в Traefik.
Traefik снова вызывает forwardAuth.
Kalitka видит token, проверяет:
подпись;
TTL;
target host;
jti;что token ещё не использован.
После этого forwardAuth отвечает:
Set-Cookie: kalitka=...; Secure; HttpOnly; SameSite=Laxуже в контексте ответа для:
grafana.example.comTraefik пробрасывает Set-Cookie клиенту.
Теперь Domain не нужен.
Получается настоящая Host-only cookie.
После установки cookie браузер сразу редиректится на чистый URL без токена.
Например:
https://grafana.example.com/Так token не остаётся в location bar дольше одного запроса.
Это уже более сложный механизм.
И именно поэтому я пока не стал впихивать его в первую версию.
Один из принципов проекта — не добавлять complexity только потому, что архитектурно можно сделать красивее.
Telegram как control plane
Здесь мне особенно нравится одна вещь.
Для Kalitka Telegram — не identity provider.
Это практически control plane.
Через бота можно:
/allowed
/blocked
/hosts
/mute
/unmute
/sessionМожно заранее добавить:
/allow ip ...
/allow name ...или:
/block ip ...
/block name ...
/block country ...Но я специально не сделал:
/allow country DEПотому что:
«всех из Германии пускаем внутрь»
— это довольно странная модель безопасности.
Country имеет смысл как грубый отрицательный фильтр.
Но не как доказательство того, что человеку можно доверять.
Зачем нужен /mute
Есть ещё один забавный operational case.
Представьте, что кто-то решил автоматически дёргать endpoint.
Kalitka честно делает то, ради чего была создана:
🔔 Telegram
🔔 Telegram
🔔 Telegram
🔔 TelegramТехнически всё работает идеально.
Пользоваться невозможно.
Для этого появился:
/mute 480В этом режиме новые неизвестные callers не вызывают уведомление, а молча блокируются.
Это не замена rate limiting или WAF.
Смысл гораздо прозаичнее:
ночью мой телефон не должен становиться частью системы обнаружения ботов.
Почему Kalitka не должна становиться WAF
Во время разработки очень легко попасть в feature creep.
Раз у нас уже есть IP:
— давайте rate limits.
Раз есть GeoIP:
— давайте reputation score.
Раз есть headers:
— давайте SQL injection detection.
Раз есть reverse proxy:
— давайте TLS termination.
И через полгода маленький сервис превращается в самодельный Cloudflare.
Я этого не хочу.
Сейчас граница проекта выглядит примерно так:
Kalitka должна:
решить, нужно ли спрашивать владельца;
запомнить решение;
автоматически пропускать известных посетителей;
автоматически отказывать явно нежелательным;
передавать остальных на обычную авторизацию приложения.
Kalitka не должна:
искать malware;
анализировать payload;
предотвращать DDoS;
заменять OAuth/OIDC provider;
хранить пользователей организации;
заменять авторизацию backend-приложения.
Чем меньше security-продукт, тем проще понять, что именно он гарантирует.
А понимание security boundary зачастую важнее количества функций.
Отдельная проблема — polling
Waiting page должна как-то узнать:
администратор уже нажал Allow?
Telegram не умеет позвонить обратно в браузер пользователя.
Поэтому страница polling'ом спрашивает:
/wait/status?id=...Для обычного браузера ничего особенного.
Но CrowdSec, fail2ban и другие behaviour-based механизмы могут увидеть:
GET
GET
GET
GET
GETи решить, что кто-то занимается crawling.
Особенно весело, если бан применяется на уровне общего Traefik entrypoint.
Тогда пользователь пытается получить доступ к одной Grafana, а в результате оказывается заблокирован вообще для всех сайтов за этим reverse proxy.
Поэтому /wait приходится учитывать в правилах внешней защиты.
Это хороший пример того, как два абсолютно разумных security-механизма в комбинации могут создать совершенно неразумное поведение.
SPA тоже умеют удивлять
Есть ещё один corner case.
Обычный сайт открывается:
browser → GET → redirect → gateВсё понятно.
SPA может жить в уже закешированном shell и обращаться к backend через background fetch.
Теперь вместо API response прилетает:
302 → gateJS этого совершенно не ожидал.
Результат может выглядеть как:
blank page;
странная ошибка;
вечный spinner.
Поэтому Kalitka гораздо лучше подходит к вещам, которые пользователь открывает, чем к PWA, постоянно живущим в браузере.
Технически это можно обходить.
Но опять возникает вопрос: стоит ли усложнять маленький инструмент ради сценария, который можно решить другим способом?
Что получилось в итоге
Сейчас Kalitka умеет:
работать как
forwardAuth;спрашивать разрешение через Telegram;
approve / deny;
хранить allow/block lists;
блокировать IP, identity и country;
автоматически пропускать известных пользователей;
использовать Google Sign-In;
включать защиту отдельно для каждого host;
временно уходить в mute;
использовать подписанные sessions;
работать за trusted reverse proxies;
корректно учитывать
X-Forwarded-For;проверять допустимость target host;
fail closed;
работать в Docker;
интегрироваться с Traefik.
Основной код написан на .NET.
Но сейчас мне гораздо интереснее не добавлять очередную кнопку.
Следующий этап — автоматические security и integration tests.
Потому что для такого проекта тест:
known IP → 200не особенно интересен.
Гораздо интереснее:
attacker sends forged X-Forwarded-For
→ still deniedили:
request comes directly, bypassing trusted proxy
→ forwarded headers ignoredили:
cookie valid but for another host
→ deniedили:
Telegram webhook secret wrong
→ 403Security-код становится намного спокойнее развивать, когда подобные свойства системы существуют не только в голове разработчика.


зачем вообще это выкладывать в Open Source?
Изначально Kalitka была просто маленькой штукой для собственного infrastructure stack.
Но мне нравятся именно такие open-source проекты.
Не очередная универсальная enterprise platform.
А небольшой инструмент, который решает одну конкретную проблему и при этом достаточно прост, чтобы другой разработчик мог за вечер прочитать большую часть исходников и понять:
ага, вот здесь принимается решение, вот здесь подписывается cookie, вот здесь проверяется proxy.
Security через «никто не способен разобраться в этих 800 тысячах строк» мне нравится значительно меньше.
Проект распространяется под MIT.
Репозиторий:
https://github.com/everycore-net/kalitka
Если кто-то использует похожую модель перед своими внутренними сервисами — мне особенно интересно, какие corner cases я ещё не встретил.
Потому что главный урок этого маленького проекта пока такой:
Сам
forwardAuthпишется быстро.
Интересное начинается, когда начинаешь определять, кому именно можно доверять входные данные, которые пришли через proxy.
И, пожалуй, именно ради этого Kalitka оказалась для меня гораздо интереснее первоначальной задачи «спрятать Grafana от интернета».
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.