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

- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
This commit is contained in:
av
2026-08-23 13:12:47 +03:00
parent 75c6f0168a
commit 52fe31319a
35 changed files with 3548 additions and 144 deletions
@@ -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
Развилок не осталось: все закрыты владельцем на чекпоинте.