локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
## Context
|
||||
|
||||
Сервис не ведёт входа сам: кто пришёл, называет обратный прокси заголовками
|
||||
`Remote-User`, `Remote-Name` и `Remote-Email`, а сервис верит им, когда
|
||||
соединение открыто с адреса из перечня `[auth] trusted_proxies`. Имена
|
||||
заголовков объявлены константами транспорта в
|
||||
`internal/controller/http/identity.go`, и там же записано, что настройкой их не
|
||||
делают. Барьер описан в `docs/security.md`, «Периметр», и нормирован спекой
|
||||
`access`.
|
||||
|
||||
На машине разработчика прокси нет, браузер заголовков не ставит, и приложение
|
||||
локально не открылось бы вовсе. На это место сегодня встаёт подставной прокси
|
||||
`go run ./cmd/devtools proxy`: отдельный процесс, слушающий свой порт, ставящий
|
||||
три заголовка и переправляющий запрос сервису. Локальный запуск поэтому идёт в
|
||||
два процесса, приложение открывают по адресу прокси, а перечень доверенных
|
||||
адресов в конфиге приходится переставлять на петлевой.
|
||||
|
||||
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
|
||||
Изменение переносит подстановку заголовков в сам сервис и закрывает её
|
||||
предохранителем.
|
||||
|
||||
Ограничение, из которого читается всё остальное: **подстановка заголовков — это
|
||||
настройка, которой сервис называет пришедшего сам, никого не спросив.** Всякий,
|
||||
до кого она дотянется, получит чужой архив. Дизайн поэтому наполовину состоит из
|
||||
того, что подстановке запрещено.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
|
||||
назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
|
||||
настроек, и ничем больше.
|
||||
- Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
|
||||
неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
|
||||
- Настройка, при которой отладочный вход становится открытым входом, роняет
|
||||
старт, а не работает молча.
|
||||
- Отладочный запуск виден владельцу в журнале.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Уровень журнала, тексты внутренних отказов в ответах, подмена распознавателя,
|
||||
снятие ограничителя частоты, послабления проверок конфига — ничего этого новый
|
||||
признак не включает (см. решение 7).
|
||||
- Своего входа, ролей и проверки допуска сервис по-прежнему не заводит:
|
||||
подстановка называет пришедшего, а не судит его.
|
||||
- Автотесты и разработческие сценарии, которым нужна учётная запись, продолжают
|
||||
ставить заголовок сами и в подстановке не нуждаются.
|
||||
- Граница работы — заголовки входа. Подмена распознавания в конфиг не
|
||||
переезжает: `internal/adapter/recognizer/memory.go` подставляется правкой
|
||||
кода, и решением владельца так и остаётся.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Где подставляются заголовки: отдельный слой перед узнаванием
|
||||
|
||||
**Решено:** отдельный слой цепочки корня приложения, стоящий **перед**
|
||||
`TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
|
||||
запроса и ничего больше не делает: учётной записи не заводит, отказов не
|
||||
выдаёт, в контекст не пишет. Узнавание получает запрос, неотличимый от
|
||||
пришедшего с сервера.
|
||||
|
||||
Слой собирается только при включённом предохранителе и непустой имитации. При
|
||||
выключенном — цепочка та же, что сегодня, без единого лишнего звена.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Подстановка внутри `TrustedHeaderIdentity`** — узнавание получило бы второй
|
||||
источник значений и ветку, которой в бою нет. Отлаживалась бы не боевая ветка,
|
||||
а её отладочный двойник, и это ровно то, чего изменение обязано избежать.
|
||||
- **Подстановка в точке входа, до сборки цепочки** — подставлять нечего: значения
|
||||
ставятся на запрос, а не на сервис.
|
||||
- **Слой снаружи ограничителя частоты** — ограничитель считает по адресу
|
||||
спрашивающего, и заголовки входа его не касаются. Ставить слой снаружи значит
|
||||
трогать порядок, который сегодня верен, без единой причины.
|
||||
|
||||
Что человек увидит иначе: приложение открывается по адресу сервиса, второго
|
||||
порта нет, и в журнале запроса стоят те же строки узнавания, что на сервере.
|
||||
|
||||
### 2. Область слоя: только корень приложения
|
||||
|
||||
Слой вешается туда же, где висит узнавание, — на цепочку корня приложения, — и
|
||||
область его выводится из того же перечня адресов, а не перечисляется вторым
|
||||
списком. Проба здоровья, метрики и раздача приложения подстановки не видят: там
|
||||
учётной записи нет и не нужно.
|
||||
|
||||
### 3. `debug = false` при непустой имитации: отказ старта
|
||||
|
||||
**Решено:** старт роняется с именем ключа. Не предупреждение и не молчаливое
|
||||
игнорирование.
|
||||
|
||||
Причина: состояние «имитация заполнена, предохранитель выключен» не читается
|
||||
никак, и обе его прочтения — поломка.
|
||||
|
||||
- Человек забыл включить предохранитель. Сервис поднимется, никого не узнает, и
|
||||
человек будет искать поломку во входе, в прокси и в перечне адресов — везде,
|
||||
кроме одного булева ключа. Предупреждение в журнале его не спасёт: журнал
|
||||
локального запуска читают тогда, когда уже что-то сломалось.
|
||||
- Человек забыл убрать имитацию из боевого файла. Тогда в выкладке лежит конфиг,
|
||||
которому недостаёт одного слова, чтобы открыть архив всем. Отказ старта — тот
|
||||
случай, когда поломку ловят на порядок раньше, чем она стоит денег.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Молчаливое игнорирование** — прямо против тона проекта: инвариант «Принятая
|
||||
запись не теряется молча» описывает ту же болезнь на соседнем предмете.
|
||||
- **Предупреждение в журнале** — оставляет заполненную имитацию в боевом файле
|
||||
жить сколько угодно.
|
||||
|
||||
Обратная сторона: `debug = true` при пустой имитации отказом **не** считается.
|
||||
Это законный запуск — признак сам по себе ничего не включает.
|
||||
|
||||
### 4. `debug = true` в бою: чем опасно и что ограничивает подстановку
|
||||
|
||||
Опасность названа моделью угроз прямо: всякий, кто дотянулся до сервиса,
|
||||
называет себя кем угодно и получает чужой архив. Подстановка делает это хуже
|
||||
барьера `trusted_proxies` по двум причинам. Первая: подставляется по
|
||||
**отсутствию** заголовка, то есть запрос, пришедший мимо прокси, — именно тот,
|
||||
который сегодня остаётся неузнанным, — получит имя. Вторая: требование к Caddy
|
||||
«заголовки `Remote-*` перезаписывать, а не пропускать» здесь не помогает вовсе,
|
||||
потому что перезаписывать нечего.
|
||||
|
||||
**Решено — одно ограничение: подставленный заголовок проходит тот же барьер,
|
||||
что и настоящий.** Слой подставляет значения только тогда, когда адрес
|
||||
соединения попадает в
|
||||
`trusted_proxies`. Судит он его **той же функцией**, какой судит узнавание:
|
||||
разбор адреса пира и сверка с перечнем берутся из одного дома
|
||||
(`internal/controller/http/identity.go`), а не пишутся вторым списком, — так же,
|
||||
как решение 6 поступает с именами заголовков. Второй сверщик разошёлся бы с
|
||||
первым молча: узнавание разворачивает IPv4 в оболочке IPv6, и слой, написавший
|
||||
свою сверку, отличался бы от него ровно на тех адресах, ради которых заводится.
|
||||
|
||||
Это не предохранитель, а согласованность с барьером узнавания: от конфига она не
|
||||
требует ничего и круга тех, кто мог назваться кем угодно, не расширяет — он и
|
||||
так очерчен перечнем.
|
||||
|
||||
**Адресного предохранителя не делаем.** Решение владельца на чекпоинте,
|
||||
дословно: «предохранитель по адресам не делаем, полагаемся только на параметр
|
||||
debug». Рассматривалось требование, чтобы при включённом предохранителе перечень
|
||||
доверенных адресов состоял только из петлевых записей; оно снято вместе с
|
||||
предикатом «петлевая запись», который заводился ровно ради него.
|
||||
|
||||
Что этим куплено: отладочный вход работает **внутри контейнера** — адрес пира
|
||||
там принадлежит сети докера, и она же стоит в боевом перечне, — а локальный
|
||||
прогон не переставляет перечень доверенных адресов на петлевой: петлевые записи
|
||||
добавляются к тем, что в нём уже стоят.
|
||||
|
||||
Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
|
||||
машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
|
||||
имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, — а
|
||||
доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
|
||||
пришедший обычным путём**. Между боевой выкладкой и открытым входом остаётся
|
||||
три вещи, и других нет: умолчание предохранителя «выключено»; отказ старта при
|
||||
заполненной имитации без предохранителя (решение 3); боевой конфиг, который
|
||||
рендерится шаблоном Ansible, а не копируется с машины разработчика.
|
||||
|
||||
Что человек увидит иначе: отладочный вход работает везде, где адрес соединения
|
||||
попадает в перечень доверенных, — и на машине разработчика, и внутри
|
||||
контейнера. Перечень при этом остаётся тем, что он есть: границей доверия, а не
|
||||
признаком отладки.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Требовать петлевой перечень при включённом предохранителе** — снято
|
||||
решением владельца, причина выше. Держалось предикатом «петлевая запись»,
|
||||
который заводился ради этой одной проверки.
|
||||
- **Принудительно слушать петлевой адрес при включённом предохранителе** —
|
||||
меняет поведение молча (адрес прослушивания задан числом порта, и человек
|
||||
увидел бы «порт занят никем»), и закрывает ровно то, что решение покупает:
|
||||
внутри контейнера сервис слушает не петлю.
|
||||
- **Новый ключ `[server] listen`** — публичная поверхность настроек ради
|
||||
предохранителя, которого решением владельца нет.
|
||||
- **Вырезать подстановку из боевой сборки тегом сборки** — сборка образа в гейте
|
||||
не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег, забытый в одной ступени,
|
||||
дал бы ровно ту тишину, которой избегает пункт 3.
|
||||
|
||||
### 5. Уже пришедший заголовок: подставляем только при отсутствии `Remote-User`
|
||||
|
||||
**Решено:** решение принимает **наличие `Remote-User`**, а не его значение.
|
||||
Заголовок есть в любом числе значений — слой не трогает запрос вовсе и передаёт
|
||||
его дальше как есть. Заголовка нет ни одного — слой ставит всю тройку
|
||||
`Remote-*`, которой распоряжается: названные имитацией — значением из конфига,
|
||||
не названные — удаляет.
|
||||
|
||||
Почему по наличию, а не по значению: узнавание отвергает запрос с более чем
|
||||
одним `Remote-User`, и это его главная защита от прокси, который заголовок
|
||||
добавляет вместо замены. Слой, который «дополнил бы пустое место», сам создал бы
|
||||
второе значение и превратил бы законный отказ в отладочный проход.
|
||||
|
||||
Почему удаляем не названные имитацией: запрос без `Remote-User`, но с
|
||||
`Remote-Email`, иначе собрал бы личность из конфига и из присланного — логин
|
||||
свой, почта чужая. Слой владеет тройкой целиком или не трогает её вовсе.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Заменять всегда** — сервис, стирающий сказанное прокси, перестаёт быть
|
||||
похожим на боевой, и ветки «пришло два значения» и «заголовок с недоверенного
|
||||
адреса» становятся локально невоспроизводимыми.
|
||||
- **Подставлять по пустому значению `Remote-User`** — пустое значение штатно
|
||||
шлёт прокси там, где никого не назвал; подстановка на этом месте назвала бы
|
||||
человеком того, кому провайдер отказал.
|
||||
|
||||
### 6. Имена заголовков: ключами конфига, но перечень порождается константами
|
||||
|
||||
Цена названа прямо: сегодня имена — константы транспорта, и у них один дом.
|
||||
Секция, где имя заголовка служит именем ключа, даёт этим именам **второе
|
||||
упоминание**, а второе упоминание расходится с первым молча.
|
||||
|
||||
**Решено:** имя ключа секции — имя заголовка, но набор принимаемых имён
|
||||
порождается теми же константами транспорта. Ключ, не совпавший ни с одной из
|
||||
них, роняет старт и называет принимаемые имена. Сравнение идёт по каноническому
|
||||
виду имени заголовка, потому что в HTTP имя нечувствительно к регистру, а в TOML
|
||||
ключ чувствителен.
|
||||
|
||||
Так второго **дома** не появляется: дом остаётся один, а конфиг лишь называет
|
||||
его содержимое, и совпадение сверяет машина, а не внимательность.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Произвольная карта имён** — опечатка `Remote-Usr` даёт «сервис меня не
|
||||
узнаёт» без единого следа, а выгоды нет: сервис читает ровно три имени и
|
||||
четвёртое читать не умеет.
|
||||
- **Свои имена ключей вместо имён заголовков** (`login`, `name`, `email`) — самое
|
||||
дешёвое для инварианта, но теряет то, ради чего секция заводится: конфиг
|
||||
перестаёт выглядеть как то, что ставит Caddy, и владелец назвал форму прямо.
|
||||
Оставлено вариантом на случай, если человек решит платить иначе.
|
||||
|
||||
Замечание по форме: в TOML пара пишется через `=`, а не через `:`. Пример
|
||||
владельца записывается так:
|
||||
|
||||
```toml
|
||||
[auth.test_headers]
|
||||
Remote-User = "foobar"
|
||||
Remote-Email = "foobar@example.com"
|
||||
```
|
||||
|
||||
### 7. Что означает `[server] debug` и чего он не включает
|
||||
|
||||
Признак означает одно: **этот прогон идёт на машине разработчика, и сервису
|
||||
позволено подставить то, что в бою даёт окружение.** Сегодня подставляется
|
||||
ровно одна вещь — заголовки входа.
|
||||
|
||||
Чего признак **не** включает и включать не должен без отдельного решения
|
||||
человека:
|
||||
|
||||
- уровень журнала (он зашит и настройкой не является);
|
||||
- тексты внутренних отказов и следы стека в ответах;
|
||||
- вывод тел запросов и ответов внешних сервисов;
|
||||
- снятие или послабление ограничителя частоты;
|
||||
- подмену распознавателя памятью — это подстановка в коде,
|
||||
`internal/adapter/recognizer/memory.go`, и настройкой она не делается;
|
||||
- послабление любой проверки старта;
|
||||
- открытие любого адреса неузнанному.
|
||||
|
||||
Правило для следующей задачи: новое поведение вешается на этот ключ только
|
||||
решением владельца и получает **свою** строку в спеке `access` или в спеке своей
|
||||
capability. Ключ — предохранитель с закрытым перечнем следствий, а не режим с
|
||||
открытым.
|
||||
|
||||
### 8. Где живёт проверка
|
||||
|
||||
Проверка охватывает две секции разом — `[server] debug` против
|
||||
`[auth] test_headers` и `[auth] trusted_proxies`, — и потому не помещается ни в
|
||||
`AuthConfig.Validate()`, ни в проверку секции сервера. **Решено:** метод на
|
||||
корневой `Config`, названный своим предметом, зовётся из `cmd/transcriber` рядом
|
||||
с тремя имеющимися проверками. Закрыть расхождение «единого места проверки нет»,
|
||||
названное в `docs/conventions/config.md`, он не пытается: это отдельная работа.
|
||||
|
||||
Рассмотрено и отвергнуто: передавать признак отладки внутрь
|
||||
`AuthConfig.Validate()` — секция начала бы знать о чужой секции ради одного
|
||||
булева значения.
|
||||
|
||||
### 9. Журнал
|
||||
|
||||
- **Старт с включённой подстановкой** — уровень `WARN`, один раз. Адресат —
|
||||
владелец, и сообщение ровно того рода, который конвенция называет «может стать
|
||||
проблемой»: сервис называет пришедшего сам. Поля: `capability` `access`,
|
||||
перечень **имён** подставляемых заголовков. Значений в строке нет: логин — это
|
||||
ключ к чужому архиву, и спека `access` запрещает его печатать наравне с
|
||||
адресом почты.
|
||||
- **Запрос, которому заголовки подставлены** — уровень `DEBUG`. Случай штатный и
|
||||
частый: так выглядит каждый запрос локального прогона, и `INFO` затопил бы
|
||||
журнал. Поля те же, что у узнавания: `http.peer_addr`, `capability`,
|
||||
`transport`.
|
||||
- **Запрос, которому подставить нельзя** (предохранитель включён, но адрес пира
|
||||
не доверенный) — уровень `WARN`, своей строкой. Поля: `http.peer_addr`,
|
||||
`capability`, `transport`; подставляемых значений в ней нет.
|
||||
|
||||
Прежняя посылка «узнавание уже пишет здесь предупреждение» неверна, и это
|
||||
проверено по коду. Ветка узнавания выбирается по числу значений `Remote-User`
|
||||
**до** сверки адреса пира: заголовка нет — пишется `Debug("Request carries no
|
||||
login header")`, и запрос уходит дальше. Предупреждение `Login header came
|
||||
from an untrusted peer` достижимо только тогда, когда заголовок **есть**, а
|
||||
подстановка работает ровно при его отсутствии. На отказном пути подстановки
|
||||
предупреждения не бывает никогда.
|
||||
|
||||
Без своей строки самый частый локальный отказ остаётся без следа: браузер идёт
|
||||
на `localhost`, тот разрешается в `::1`, а в `trusted_proxies` по образцу
|
||||
стоит `127.0.0.1`. Человек видит `401` на всём приложении, заполненную секцию
|
||||
имитации, включённый предохранитель — и ни одной строки о том, что подстановка
|
||||
не сработала. Отсюда же требование к образцу конфига: рядом с `127.0.0.1` в
|
||||
перечне обязан стоять `::1`.
|
||||
- Строка `Account created from login header` работает как работала: первый
|
||||
запрос локального прогона заводит учётную запись и виден на `INFO`.
|
||||
|
||||
### 10. Подкоманда `cmd/devtools proxy` убирается целиком
|
||||
|
||||
**Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
|
||||
остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
|
||||
входить локально не остаётся тоже.
|
||||
|
||||
Цена названа и принята: поломки контура — два значения `Remote-User`, заголовок
|
||||
с недоверенного адреса, цепочка `X-Forwarded-For` — местным инструментом больше
|
||||
не воспроизводятся. Воспроизводит их теперь только автотест, ставящий заголовок
|
||||
сам.
|
||||
|
||||
Уходит вместе с подкомандой: её флаги, шапка пакета `cmd/devtools/main.go` — она
|
||||
сегодня говорит «подкоманд две» и описывает прокси, — строка подкоманды в
|
||||
`usage()` и упоминания прокси в документах. Адреса перечислены шагом 4.4
|
||||
`tasks.md`.
|
||||
|
||||
Рассмотрено и отвергнуто владельцем:
|
||||
|
||||
- **Оставить как есть** — два способа войти локально, и документация обязана
|
||||
каждый раз говорить, какой из них чей.
|
||||
- **Оставить, сузив назначение** до воспроизведения поломок контура — тот же
|
||||
второй способ, только не названный в инструкции локального запуска.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Отладочный вход уезжает в боевой конфиг** → умолчание предохранителя
|
||||
«выключено» и отказ старта при заполненной имитации без предохранителя
|
||||
(решение 3). Проверка идёт на старте, до приёма трафика, и ловит она ровно
|
||||
этот случай — заполненную имитацию при выключенном предохранителе.
|
||||
- **Разработчик оставил `debug = true` и выложился** → выкладка рендерит конфиг
|
||||
из шаблона Ansible, а не копирует локальный файл. Другой защиты нет, и это
|
||||
принято решением владельца (решение 4): боевой сервис с включённым
|
||||
предохранителем и заполненной имитацией отдаст архив всякому, чей запрос
|
||||
пришёл через обратный прокси, то есть всякому, кто пришёл обычным путём.
|
||||
- **Имя заголовка в конфиге разошлось с константой транспорта** → перечень
|
||||
принимаемых имён порождается константами, неизвестный ключ роняет старт
|
||||
(решение 6).
|
||||
- **Поломки контура перестали воспроизводиться локально** → подкоманда `proxy`
|
||||
убрана (решение 10); остаются автотесты, которые ставят заголовок сами.
|
||||
- **Ключ `debug` обрастёт чужими следствиями** → закрытый перечень в решении 7 и
|
||||
правило «новое следствие — своя строка в спеке».
|
||||
- **Тестовый пользователь копится в базе.** Каждый новый логин в имитации заводит
|
||||
учётную запись, а удалять учётные записи сервис не умеет. Локальная база
|
||||
ронется и пересоздаётся свободно; в бою подстановка выключена, а включённая
|
||||
была бы поломкой много хуже лишней учётной записи.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Шага схемы изменение не требует, данных не трогает. Существующий конфиг
|
||||
продолжает работать без правки: умолчание предохранителя — «выключено», секция
|
||||
имитации по умолчанию пуста, и цепочка слоёв при этом та же, что сегодня.
|
||||
Откат — удаление двух ключей из конфига.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Развилок не осталось: все закрыты владельцем на чекпоинте.
|
||||
Reference in New Issue
Block a user