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

- в конфиг добавлены секция [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
+4
View File
@@ -46,6 +46,10 @@ Thumbs.db
# Config files
config.toml
# Переменные окружения: файл читает сам бинарник при старте, и секрет в нём
# оказывается тем же способом, каким оказывается в конфиге.
.env
# Sample and test audio files
*.m4a
*.mp3
+7 -5
View File
@@ -152,7 +152,6 @@ go vet ./...
gofmt -l .
golangci-lint run
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
task front # приложение: зависимости, Biome, юнит-тесты, сборка
task image # docker-образ; тег и раскладка — docs/architecture.md
@@ -277,10 +276,13 @@ Node на машину **не ставится**: шаг сборки прило
`config.example.toml`.
**На машине без прокси представиться нечем**: сервис узнаёт
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
не ставит. На место контура встаёт подставной прокси —
`go run ./cmd/devtools proxy`: он слушает свой порт, ставит заголовок и
переправляет запрос сервису, а проверок не делает никаких. Приложение при этом
открывают **по адресу прокси**, а не по адресу сервиса. Ключей боевого
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
процессом: рецепт из трёх правок записан связным блоком в
`config.example.toml`, под перечнем доверенных адресов. Коротко: пара петлевых
адресов в перечень, `[server] debug = true`, раскомментированная секция
`[auth.test_headers]` с ключом `Remote-User`. Приложение при этом открывают по
адресу сервиса, второго порта нет. Заполненная имитация при выключенном
предохранителе роняет старт с именем ключа. Ключей боевого
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
+14 -9
View File
@@ -48,16 +48,21 @@
[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
Локально прокси нет, а браузер заголовков не ставит — на место контура встаёт
подставной прокси из оснастки:
Локально прокси нет, а браузер заголовков не ставит — заголовок подставляет сам
сервис по своим настройкам. Второго процесса для этого не нужно: приложение
открывают по адресу сервиса.
```bash
go run ./cmd/devtools proxy
```
Рецепт — три правки `config.toml` сверху вниз:
Приложение после этого открывают **по адресу прокси** — `http://localhost:9000`.
В перечне доверенных адресов при этом должен стоять петлевой; строки под это
стоят в `config.example.toml`.
1. добавить в перечень доверенных адресов пару петлевых — `127.0.0.1` и `::1`;
2. поставить в секции `[server]` ключ `debug = true`;
3. раскомментировать секцию `[auth.test_headers]` и назвать в ней `Remote-User`.
Тот же рецепт записан связным блоком в `config.example.toml`, под перечнем
доверенных адресов, — там же названы принимаемые имена заголовков и цена
включения. Заполненная секция имитации при выключенном предохранителе роняет
старт с именем ключа: сервис с включённым предохранителем называет пришедшего
сам, никого не спросив, и в бою этот ключ стоит `false`.
## Деплой
@@ -101,7 +106,7 @@ Prometheus с префиксом `transcriber_` — и `GET /health` — про
transcriber/
├── cmd/
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
│ └── devtools/ # Оснастка разработчика: подставной прокси и возврат записи в работу
│ └── devtools/ # Оснастка разработчика: возврат остановленной записи в работу
├── internal/
│ ├── entity/ # Модели: запись, файл, результат распознавания
│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID)
+5 -82
View File
@@ -7,13 +7,11 @@
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
//
// Подкоманд две: `proxy` — подставной обратный прокси, `resume` — возврат
// остановленной записи в работу. Вторая встала на место панели владельца:
// панели у сервиса больше нет, а экраны правки записи приносят отдельные задачи.
//
// Имена заголовков берутся **константами транспорта**, а не литералами: они
// нормативны, и второй список разошёлся бы с первым молча — локальный вход
// перестал бы узнавать кого бы то ни было, а искали бы поломку в сервисе.
// Подкоманда одна — `resume`, возврат остановленной записи в работу. Она встала
// на место панели владельца: панели у сервиса больше нет, а экраны правки
// записи приносят отдельные задачи. Подставной обратный прокси жил здесь второй
// подкомандой и убран 2026-08-23 задачей `config-test-headers-login`: заголовки
// входа локального прогона подставляет сам сервис по своим настройкам.
//
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
@@ -22,15 +20,9 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
)
func main() {
@@ -42,8 +34,6 @@ func main() {
}
switch os.Args[1] {
case "proxy":
runProxy(os.Args[2:])
case "resume":
runResume(os.Args[2:])
default:
@@ -57,74 +47,7 @@ func usage() {
fmt.Fprint(os.Stderr, `Оснастка разработчика.
Подкоманды:
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
resume вернуть остановленную запись в работу
`)
}
// runProxy поднимает подставной обратный прокси.
//
// Зачем он нужен: сервис узнаёт пришедшего по заголовку, который на сервере
// ставит Caddy, сходив к Authelia. На машине разработчика ни того ни другого
// нет, а браузер заголовков не ставит — значит приложение локально не
// открывалось бы вовсе. Прокси встаёт на их место: слушает свой порт, ставит
// заголовок и переправляет запрос сервису.
//
// Проверок он не делает никаких — ни пароля, ни группы, ни срока. Это его
// назначение, а не упущение: пускать он должен всякого, кто до него дошёл, а
// сам он слушает петлевой адрес.
func runProxy(args []string) {
flags := flag.NewFlagSet("proxy", flag.ExitOnError)
listen := flags.String("listen", "127.0.0.1:9000", "адрес, на котором слушать")
target := flags.String("target", "http://127.0.0.1:8080", "адрес сервиса")
login := flags.String("user", "dev", "логин, которым называть пришедшего")
name := flags.String("name", "Разработчик", "имя, пригодное к показу")
email := flags.String("email", "", "адрес почты; пустой не ставится вовсе")
if err := flags.Parse(args); err != nil {
os.Exit(2)
}
// Слушаем петлевой адрес по умолчанию, и это часть назначения: прокси
// пускает всякого, кто до него дошёл, и в чужой сети становится открытым
// входом в сервис.
upstream, err := url.Parse(*target)
if err != nil {
log.Fatalf("адрес сервиса не читается: %v", err)
}
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(upstream)
// Именно Set, а не Add. Прокси, который **добавляет** заголовок к
// присланному, оставляет рядом со своим значением чужое — и сервис
// отвергает такой запрос целиком, потому что двух значений он не
// разбирает. Настоящий Caddy обязан делать то же самое, и это
// записано требованием к контуру в модели угроз.
r.Out.Header.Set(httpcontroller.LoginHeader, *login)
r.Out.Header.Set(httpcontroller.NameHeader, *name)
if *email != "" {
r.Out.Header.Set(httpcontroller.EmailHeader, *email)
} else {
r.Out.Header.Del(httpcontroller.EmailHeader)
}
},
}
log.Printf("подставной прокси: %s → %s, пришедший — %q", *listen, *target, *login)
log.Printf("приложение открывать по http://%s", *listen)
server := &http.Server{
Addr: *listen,
Handler: proxy,
// Таймаута чтения нет намеренно: сервис принимает записи на несколько
// часов, и прокси, обрывающий такую загрузку, ловил бы разработчика на
// поломке, которой в сервисе нет.
ReadTimeout: 0,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("подставной прокси остановлен: %v", err)
}
}
+25 -1
View File
@@ -77,6 +77,30 @@ func run(logger *slog.Logger) error {
// доходит вовсе, — а это разные поломки в разных местах.
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
// Настройки отладочного входа: заполненная имитация без предохранителя, имя
// заголовка, которого сервис не читает, и имитация без годного логина роняют
// старт. Имена заголовков приходят проверке доводом — дом у них один,
// константы транспорта, — а пакет настроек транспорта не знает.
if err := cfg.ValidateTestHeaders(
httpcontroller.IdentityHeaderNames(), httpcontroller.LoginHeader,
); err != nil {
return err
}
// Представление предиката «подставляем ли» одно — непустота перечня, — и
// судят его одинаково строка журнала ниже, слой подстановки и проверка выше.
// Второе выражение того же предиката разошлось бы с первым молча.
substitution := cfg.HeaderSubstitution()
if len(substitution) > 0 {
// Уровень предупреждающий: сервис называет пришедшего сам, никого не
// спросив, — ровно то «может стать проблемой», ради которого заведён
// этот уровень. Идут имена заголовков; значений нет — логин это ключ к
// чужому архиву.
logger.Warn("Identity headers are substituted from configuration",
"headers", httpcontroller.SubstitutedHeaderNames(substitution),
"capability", "access")
}
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом.
@@ -190,7 +214,7 @@ func run(logger *slog.Logger) error {
// наблюдения, правило неизвестного пути у раздачи приложения и область
// действия узнавания.
mounts := httpcontroller.ServiceMounts(
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, logger),
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, substitution, logger),
promhttp.Handler(),
)
+44 -14
View File
@@ -4,6 +4,20 @@ port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Предохранитель отладочного запуска. Значения: false (по умолчанию) и true.
#
# Означает он одно: прогон идёт на машине разработчика, и сервису позволено
# подставить то, что в бою даёт обратный прокси, — заголовки входа из секции
# [auth.test_headers] ниже. Перечня следствий сверх этого у него нет: уровня
# журнала, текстов внутренних отказов, ограничителя частоты и подмены
# распознавателя признак не касается.
#
# Цена включения названа прямо: сервис с true и заполненной имитацией называет
# пришедшего сам, никого не спросив, и отдаёт архив всякому, чей запрос пришёл с
# доверенного адреса. В бою доверенный адрес — это адрес обратного прокси, то
# есть всякий, кто пришёл обычным путём. В боевом файле ключ стоит false.
debug = false
# Хранилище: каталог данных и числа его базы.
#
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
@@ -100,25 +114,41 @@ object_storage_endpoint = "https://storage.yandexcloud.net/"
# Caddy, — узкий и свой.
trusted_proxies = ["172.20.0.0/24"]
# Локальный вход без Authelia.
# Локальный вход без Authelia — рецепт целиком.
#
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
# приложение локально не открылось бы вовсе. На место контура встаёт подставной
# прокси из оснастки: он слушает свой порт, ставит заголовок и переправляет
# запрос сервису. Проверок он не делает никаких — ни пароля, ни группы, ни
# срока, — и это его назначение, а не упущение.
# приложение локально не открылось бы вовсе. Заголовки входа подставляет сам
# сервис: второго процесса и второго порта для этого не нужно, приложение
# открывают по адресу сервиса.
#
# go run ./cmd/devtools proxy
# Три правки этого файла сверху вниз, и других не нужно:
#
# Приложение после этого открывают по адресу прокси — http://localhost:9000, —
# а не по адресу сервиса: запрос мимо прокси приходит без заголовка и никого не
# узнаёт.
# 1. Добавить в перечень выше пару петлевых адресов — обе записи, а не одну:
#
# Под него в перечне выше должен стоять петлевой адрес:
# trusted_proxies = ["172.20.0.0/24", "127.0.0.1", "::1"]
#
# trusted_proxies = ["127.0.0.1"]
# Браузер разрешает localhost в IPv6 не реже, чем в IPv4, и перечень без
# `::1` даёт неузнанный запрос. Отказ подстановки при этом виден строкой
# журнала с адресом пира — по ней и опознаётся недостающая запись.
#
# Второй вошедший получается другим значением `-user`: логин у провайдера и есть
# ключ учётной записи.
# 2. Поставить в секции [server] выше:
#
# go run ./cmd/devtools proxy -user local-2 -name "Второй"
# debug = true
#
# 3. Раскомментировать секцию ниже и назвать в ней Remote-User. Ключ —
# имя заголовка, значение — то, чем сервис назовёт пришедшего. Принимаются
# три имени: Remote-User, Remote-Name, Remote-Email; иное роняет старт.
# Ключ Remote-User обязателен: без него сервис подставит всё прочее и не
# узнает никого.
#
# Второй вошедший получается другим значением Remote-User: логин и есть ключ
# учётной записи.
#
# Заполненная секция при debug = false роняет старт с именем ключа
# предохранителя: состояние «имитация есть, предохранителя нет» не читается
# никак, а обе его прочтения — поломка.
#
# [auth.test_headers]
# Remote-User = "local"
# Remote-Name = "Разработчик"
# Remote-Email = "local@example.com"
@@ -0,0 +1,76 @@
# Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина
- **Дата:** 2026-08-23
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
решение 4
## Решение
Отладочная подстановка заголовков входа не требует от настроек ничего сверх
самого предохранителя `[server] debug`. Решение владельца на чекпоинте записано
в источнике дословно:
> «предохранитель по адресам не делаем, полагаемся только на параметр debug».
Рассматривалось требование, чтобы при включённом предохранителе перечень
доверенных адресов состоял только из петлевых записей; оно снято вместе с
предикатом «петлевая запись», который заводился ровно ради него.
Согласованность с барьером узнавания при этом остаётся: подставленный заголовок
проходит тот же перечень доверенных адресов, что и пришедший, и судит адрес та
же функция. Предохранителем это не служит — «от конфига она не требует ничего и
круга тех, кто мог назваться кем угодно, не расширяет».
## Почему
Решение покупает работоспособность отладочного входа там, где адрес пира не
петлевой:
> отладочный вход работает **внутри контейнера** — адрес пира там принадлежит
> сети докера, и она же стоит в боевом перечне, — а локальный прогон не
> переставляет перечень доверенных адресов на петлевой: петлевые записи
> добавляются к тем, что в нём уже стоят.
Цена названа в источнике прямо, и владелец принял именно её:
> Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
> машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
> имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, —
> а доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
> пришедший обычным путём**.
Между боевой выкладкой и открытым входом остаётся три вещи, и других нет:
умолчание предохранителя «выключено»; отказ старта при заполненной имитации без
предохранителя; боевой конфиг, который рендерит шаблон Ansible, а не
копируют с машины разработчика.
Отвергнуты вместе с адресным предохранителем ещё два подхода. **Принудительно
слушать петлевой адрес при включённом предохранителе** — «меняет поведение молча
… и закрывает ровно то, что решение покупает: внутри контейнера сервис слушает не
петлю». **Новый ключ `[server] listen`** — «публичная поверхность настроек ради
предохранителя, которого решением владельца нет».
## Почему это ADR
Триггер — **намеренный отказ** от очевидного подхода. Требовать петлевой перечень
при включённом отладочном входе — первое, что предлагает всякий, кто читает
модель угроз; отказ от этого оставляет боевую поломку, которую машина не
исключает, и объяснить его надо один раз здесь, а не на каждом ревью, которое
эту дыру находит заново.
## Последствия
- `+` Отладочный вход работает и на машине разработчика, и внутри контейнера:
перечень доверенных адресов остаётся границей доверия, а не признаком отладки.
- `+` Локальный прогон не переставляет перечень на петлевой — петлевые записи к
нему добавляются.
- `+` Предиката «петлевая запись» в коде нет вовсе: он заводился ради одной этой
проверки.
- `` **Машина не исключает боевую поломку «конфиг с `debug = true` и
заполненной имитацией».** Такой сервис поднимется на любом перечне доверенных
адресов и назовёт своим именем всякого, кто пришёл обычным путём. Записано это в модели
угроз, [security.md](../security.md), «Периметр», и в спеке
[access](../../openspec/specs/access/spec.md).
- `` Одна из трёх опор лежит вне репозитория: шаблон Ansible из
`pet-project-server`. Проверить её отсюда нечем — тем же свойством обладает
правило прокси про заголовки `Remote-*`.
@@ -0,0 +1,86 @@
# Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс
- **Дата:** 2026-08-23
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
решения 1, 6, 7 и 10
## Решение
Заголовки входа на машине разработчика ставит сам сервис — отдельным слоем
цепочки корня приложения, а не вспомогательным процессом рядом:
> отдельный слой цепочки корня приложения, стоящий **перед**
> `TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
> запроса и ничего больше не делает: учётной записи не заводит, отказов не
> выдаёт, в контекст не пишет.
Включают слой два новых ключа настроек — предохранитель `[server] debug` и
секция значений `[auth.test_headers]`. Прежний вспомогательный процесс уходит:
> **Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
> остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
> входить локально не остаётся тоже.
Имена заголовков служат именами ключей секции, но набор принимаемых имён
порождают константы транспорта: дом у имён остаётся один, а ключ, не совпавший
ни с одним из них, роняет старт.
## Почему
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
Дизайн записал это целью:
> Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
> назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
> настроек, и ничем больше.
Довод в пользу слоя перед узнаванием, а не внутри него:
> Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
> неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
Отвергнуты три очевидных подхода, и у каждого названа цена. **Подстановка внутри
`TrustedHeaderIdentity`** — «узнавание получило бы второй источник значений и
ветку, которой в бою нет. Отлаживалась бы не боевая ветка, а её отладочный
двойник». **Произвольная карта имён заголовков в конфиге** — «опечатка
`Remote-Usr` даёт „сервис меня не узнаёт“ без единого следа». **Вырезать
подстановку из боевой сборки тегом сборки:**
> сборка образа в гейте не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег,
> забытый в одной ступени, дал бы ровно ту тишину, которой избегает пункт 3.
## Почему это ADR
Триггер сработал дважды. **Дорогой откат:** решение заводит два имени ключа
настроек, а имя ключа конфига `CLAUDE.md` называет необратимым; вернуться к
вспомогательному процессу значит поднять удалённую подкоманду, убрать оба ключа
из настроек и переписать рецепт локального запуска, разошедшийся по образцу
конфига, `README.md`, `CLAUDE.md` и конвенции настроек. **Намеренный отказ:**
вырезать отладочный код из боевой сборки тегом сборки — то, что делают по
умолчанию, и отказ от этого объясняется один раз здесь, а не на каждом вопросе
«почему подстановка вообще есть в боевом бинарнике».
## Последствия
- `+` Локальный запуск идёт одним процессом и одной командой; приложение
открывают по адресу сервиса, второго порта нет.
- `+` Отлаживается боевая ветка узнавания: подставленный заголовок неотличим от
пришедшего от прокси к моменту, когда его читают.
- `+` Второго способа входить локально не остаётся, и документация перестаёт
каждый раз говорить, какой из способов чей.
- `+` Имена заголовков остаются с одним домом — константами транспорта; ключ, не
совпавший ни с одним из них, роняет старт и называет принимаемые имена.
- `` **Местный инструмент больше не воспроизводит поломки контура.** Цена
названа в источнике прямо: два значения `Remote-User`, заголовок с
недоверенного адреса, цепочка `X-Forwarded-For` — всё это теперь
воспроизводит только автотест, ставящий заголовок сам.
- `` В боевом бинарнике появляется код, называющий пришедшего без провайдера.
Что его держит и чего у него нет — [ADR-2026-08-23-no-address-guard-for-debug-login](ADR-2026-08-23-no-address-guard-for-debug-login.md).
- `` У ключа `[server] debug` закрытый перечень следствий, и держать его
придётся руками: новое поведение привязывается к ключу только отдельным
решением владельца и получает своё требование спеки
[access](../../openspec/specs/access/spec.md). Ключ с открытым перечнем
следствий обрастает ими молча.
- `` Каждый новый логин имитации заводит учётную запись, а удалять их сервис не
умеет. Локальная база ронится и пересоздаётся свободно, в бою подстановка
выключена — но лишние записи копятся.
+2
View File
@@ -35,6 +35,8 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-23 | [Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина](ADR-2026-08-23-no-address-guard-for-debug-login.md) | |
| 2026-08-23 | [Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс](ADR-2026-08-23-test-headers-substituted-by-service.md) | |
| 2026-08-22 | [Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком](ADR-2026-08-22-storage-without-pocketbase.md) | |
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
+20 -3
View File
@@ -81,6 +81,18 @@
задача возвращается в работу, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения.
- **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему —
код или оснастка; в боевом бинарнике он появляется только отдельным решением
владельца и только под ключом, названным своим предметом: имитацию заголовков
входа объявляет секция `[auth] test_headers`, подмену распознавания — правка
кода (`internal/adapter/recognizer/memory.go`). Ключ, названный общим словом,
обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт
себя как в бою». Предохранитель `[server] debug` вторым именем собеседнику при
этом не служит и правилу не противоречит: собой он не называет ничего, а держит
**закрытый** перечень следствий, и перечень этот ведёт спека
[access](../openspec/specs/access/spec.md), «Предохранитель отладки включает
только подстановку заголовков». Новое следствие вешается на ключ только новым
требованием той же спеки.
- **Чистая архитектура.** Зависимости направлены внутрь, к домену: внутренний
слой не знает внешнего никогда. `internal/service` знает только
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в точке входа
@@ -149,7 +161,7 @@
| Компонент | Где | Что делает |
| --- | --- | --- |
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, узнавание, требование учётной записи |
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Подстановка — звено необязательное: при выключенном предохранителе `[server] debug` и при пустой имитации она в цепочку не встаёт вовсе |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
@@ -157,7 +169,7 @@
| Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
| Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
| Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
| Оснастка владельца | `cmd/devtools` | Подставной прокси для местного запуска и возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
| Оснастка владельца | `cmd/devtools` | Возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
@@ -200,7 +212,10 @@
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
сервис поднимает молча.
сервис поднимает молча. Чем это обеспечено и как проверено —
[research/toml-unknown-keys.md](research/toml-unknown-keys.md); тем же
свойством безопасно и обратное направление: прежний образ поднимается на
конфиге с ключами, которых он ещё не знает.
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
@@ -269,6 +284,8 @@
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository``internal/controller/http.TrustedHeaderIdentity` |
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth] test_headers`: ключ, не совпавший ни с одним, роняет старт |
| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go``peerAddress` и `isTrusted`. Зовут их двое: узнавание и подстановка заголовков отладочного запуска. Второй сверщик разошёлся бы с первым молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
+24 -3
View File
@@ -63,12 +63,23 @@ force_shutdown_timeout = <N> # ждать остановки ворке
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
входом.
Там же, комментарием под секцией, стоит **второе значение перечня — петлевой
адрес, под подставной прокси `cmd/devtools proxy`**. Оно стоит закомментированным, и это
намеренно: образец описывает боевую выкладку, а локальный вход — способ до неё
Там же, комментарием под секцией, стоит **рецепт локального входа одним связным
блоком**: три правки сверху вниз — пара петлевых адресов в перечень доверенных,
`[server] debug = true`, раскомментированная секция `[auth.test_headers]` с
ключом `Remote-User`. Блок один, а не три комментария по месту: правки связаны
между собой, и применённая порознь любая из них роняет старт либо оставляет
сервис никого не узнающим. Рабочей строкой в образце стоит боевое значение —
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
какое из них чьё.
*Расхождение:* петлевые адреса в рецепте названы **парой**`127.0.0.1` и
`::1`, — а не одним значением, хотя правило секции требует от образца только
формы значения. Причина в цене: браузер разрешает `localhost` в IPv6 не реже,
чем в IPv4, и перечень без `::1` даёт неузнанный запрос там, где человек ждёт
входа.
## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
@@ -137,6 +148,16 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
**Проверка, охватывающая две секции разом, живёт методом на корневой `Config`.**
Такая сегодня одна — `ValidateTestHeaders`: она судит `[server] debug` против
`[auth.test_headers]`, и ни в `Validate()` секции сервера, ни в `Validate()`
секции входа не помещается — секция начала бы знать о чужой секции. Зовётся она
из `cmd/transcriber` рядом с остальными. Имена принимаемых заголовков приходят
ей **доводом**, а не читаются из пакета настроек: дом у них один — константы
транспорта, — а `internal/config` транспорта не знает и знать не должен, иначе
`cmd/devtools`, которому нужен один разбор конфига, линковал бы всю поверхность
HTTP.
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
+7
View File
@@ -173,3 +173,10 @@ transcriber — **приложение, а не библиотека**: внеш
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не
устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка
возвращается на первом несовпадении. Заметило ревью задачи
`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили
`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг
впервые, чинит одну ошибку за прогон.
+5
View File
@@ -87,6 +87,11 @@ stdlib-логом в поток ошибок. Это выбор, а не дол
*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
*Изъятие:* строка о подставленных заголовках входа
(`internal/controller/http/substitute.go`) адресована разработчику, а идёт на
`INFO`: `DEBUG` включить нечем, а в бою она не пишется вовсе — подстановку
держит выключенный умолчанием предохранитель `[server] debug`.
## Время
- Поле — `time` (ключ `slog` по умолчанию).
+10 -3
View File
@@ -65,9 +65,16 @@ Telegram.
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
имя, названное провайдером, — заводит строку при первом обращении под новым
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
сервис не решает никогда. Исключений у этого больше нет: панель администратора
со своим паролем владельца жила здесь с 2026-08-11 по 2026-08-22 и ушла вместе
со встроенным хранилищем — своего входа сервис не ведёт вовсе.
сервис не решает никогда. Панель администратора со своим паролем владельца жила
здесь с 2026-08-11 по 2026-08-22 и ушла вместе со встроенным хранилищем.
*Изъятие одно:* при включённом предохранителе `[server] debug`, выключенном по
умолчанию, сервис подставляет запросу те заголовки входа, которые в бою даёт
обратный прокси. Своего входа, регистрации и проверки допуска он от этого не
заводит: подставленное имя проходит то же узнавание, что и пришедшее. Кого
пускать, провайдер решает во всяком прогоне без изъятия; в самом изъятии его не
спрашивают вовсе — сервис называет пришедшего сам. Тем изъятие и держится
выключенным умолчанием, а границу его держит спека
[access](../openspec/specs/access/spec.md).
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
+1
View File
@@ -27,6 +27,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-23 | [Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина](toml-unknown-keys.md) | `err == nil` на опечатке в имени секции, потерянное только в `MetaData.Undecoded()`, безопасное направление отката в BurntSushi/toml v1.5.0 |
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети |
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
+67
View File
@@ -0,0 +1,67 @@
# Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина
Отвечает на вопрос, возникший по ходу задачи `config-test-headers-login`: что
делает декодер настроек с ключом и секцией, которых структура не знает, и виден
ли этот случай хоть чем-нибудь. Наблюдение понадобилось потому, что ревью нашло
опечатку в имени новой секции `[auth.test_headers]`, проходящую молча, и без
разреза нельзя было сказать, где кончается предмет задачи и начинается свойство
самой библиотеки.
Соседняя записка о той же библиотеке — [toml-decode-errors.md](toml-decode-errors.md)
— разбирает семейства **отказов**; здесь предмет обратный: случай, отказа не
дающий.
## Как снималось
Прогонами на зависимости, зафиксированной в `go.mod`:
`github.com/BurntSushi/toml` версии **v1.5.0**. Оба уровня снял триаж ревью
2026-08-23, отчёт —
[triage-2026-08-23.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md),
находка 2 и факт, подтверждённый разбором прохода `operations`. Временные файлы
прогонов удалены, бинарник поднимался в каталог вне репозитория, не в `data/`.
- **Модульный.** Вход
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"`
опечатка в имени секции.
- **Сквозной.** Настоящий бинарник на конфиге с той же опечаткой, порт 18099,
каталог данных вне репозитория; проба `curl /app/me`.
## Что выяснилось
- **Незнакомая секция и незнакомый ключ отказа не дают: `decode err=<nil>`.**
Разбор проходит целиком, поля структуры остаются нулевыми, и отличить «в файле
этого нет» от «в файле это написано с опечаткой» по результату разбора нельзя.
В прогоне: `Server.Debug=true len(TestHeaders)=0`.
- **Потерянное называет только `MetaData.Undecoded()`.** Он возвращает перечень
путей, которых структура не знала: `[auth.test_headrs
auth.test_headrs.Remote-User]`. Значение это в проекте не читает никто — ни
загрузка настроек, ни проверки старта.
- **Контроль показывает, что дело в уровне, а не в разборе вообще.** Ту же
опечатку **внутри** известной секции (`Remote-Usr` вместо `Remote-User`)
ловит проверка старта — `auth: секция [auth.test_headers] называет
заголовок, которого сервис не читает: Remote-Usr`, — потому что судит её код
проекта, а не библиотека. Ошибка в имени самой секции до этого кода не
доходит.
- **Сквозной прогон следа не оставляет вовсе.** Бинарник поднимается без
предупреждения, `curl /app/me` отвечает `401`, а в журнале стоит только
`INFO "Incoming request" … http.status_code=401`.
- **Отсюда направление отката бинарника безопасно.** Прежний образ, получивший
конфиг с ключами, которых его структура ещё не знает, эти ключи игнорирует и
поднимается. Свойство держится ровно на тишине выше: перечень
`MetaData.Undecoded()` никто не судит.
## Что из этого следует для кода
Свойство сегодня используется, а не терпится: правило выкладки «конфиг после
образа» опирается именно на него, и его дом — [../architecture.md](../architecture.md),
«Эксплуатация». Здесь записано, чем свойство обеспечено и как проверено, а не
надо ли его менять.
Отсюда же цена любой будущей проверки `MetaData.Undecoded()`: непонятый ключ,
роняющий старт, закрывает опечатки во всех секциях разом — и тем же движением
снимает безопасность отката, потому что прежний образ перестанет поднимать
конфиг новее себя. Разменивать одно на другое — отдельное решение владельца,
а не попутная правка.
**Наблюдение привязано к версии.** Версия, начавшая судить незнакомые ключи
сама, сменит оба следствия разом — молчаливую опечатку и безопасный откат.
+23 -9
View File
@@ -31,6 +31,13 @@
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
остаётся сверка триажа.
Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
не единичный случай: строки о потолке не дал ни один из шести проходов, а
заметил это снова только триаж. Прогон при этом шёл с меткой `large`, то есть с
самым широким составом, — и разница с прошлым разом ровно в числе проходов,
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
а не как свойство одного из них.
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
@@ -178,12 +185,17 @@
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте
`cmd/devtools proxy`, а проверки ставят заголовок сами и подставного собеседника
не держат вовсе. Тем же вопросом судится подставной распознаватель. Записанной
конвенции о единственном доме подставных внешних собеседников у проекта нет,
поэтому спрашивать надо, а не считать нарушением (ревью задачи про заглушку
OIDC, 2026-08-15).
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал
`cmd/devtools proxy`, а 2026-08-23 задачей `config-test-headers-login` убран и
он: заголовки входа подставляет сам сервис под предохранителем
`[server] debug`. Проверки ставят заголовок сами и подставного собеседника не
держат вовсе. Тем же вопросом судится подставной распознаватель. **Пробел
закрыт той же задачей:** норма о подставных собеседниках записана в
[architecture.md](architecture.md), «Принципы» — пункт «Подставной собеседник
в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается.
Вопрос при этом остаётся вопросом:
норма называет, где собеседнику жить, а не сколько домов у него уже завелось
(ревью задачи про заглушку OIDC, 2026-08-15).
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
внешнего собеседника и это держат правила `noctx` и `contextcheck`
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
@@ -332,9 +344,11 @@ API и имя не откатываются обратной правкой по
остановку; нельзя — расшифровку и заливку.
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
сессию в прогоне выдать было нечем; теперь заголовок ставит `cmd/devtools
proxy`, и живьём проверяются узнавание, заведение учётной записи первым
обращением, отказ с недоверенного адреса и отказ старта на пустом перечне.
сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
секции `[auth] test_headers` под предохранителем `[server] debug` — прежде
`cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
заведение учётной записи первым обращением, отказ с недоверенного адреса и
отказ старта на пустом перечне.
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
контуре (см. «Не проверит ни один проход»).
+34 -9
View File
@@ -77,6 +77,29 @@ Telegram — связи чата с учётной записью сервис
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
человек.
**Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23**
задачей `config-test-headers-login`. При включённом предохранителе
`[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из
секции `[auth] test_headers`: на машине разработчика прокси нет, а браузер
заголовков не ставит. Узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует
изъятие спека [access](../openspec/specs/access/spec.md).
Держится оно тремя вещами, и других нет: умолчание предохранителя —
«выключено»; заполненная имитация при выключенном предохранителе роняет старт с
именем ключа; боевой конфиг рендерится шаблоном Ansible, а не копируется с
машины разработчика. Подставленный заголовок проходит тот же барьер доверенного
адреса, что и пришедший, и судит адрес та же функция — но барьером отладочному
входу это не служит: перечень доверенных адресов включению предохранителя не
мешает.
**Боевая поломка машиной не исключена, и это названо прямо.** Сервис, поднятый в
бою с включённым предохранителем и заполненной имитацией, поднимется на любом
перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл
через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного
предохранителя у изъятия нет: требование петлевого перечня рассматривалось и
снято решением владельца на чекпоинте задачи.
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка:
иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и
@@ -120,7 +143,7 @@ Telegram — связи чата с учётной записью сервис
| Вход | Канал | Кто может слать |
| --- | --- | --- |
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения |
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения. Слать тройку может ещё и сам сервис — при включённом предохранителе `[server] debug`, значением из настроек; изъятие целиком описано в «Периметре» выше |
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
@@ -382,14 +405,16 @@ Storage, оттуда его читает SpeechKit. Третий путь —
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница.
- **Машина разработчика и то, что он на ней поднимает.** В репозитории лежит
`cmd/devtools` — оснастка разработчика; её подкоманда `proxy` встаёт на место
контура: ставит заголовок `Remote-User` и переправляет запрос сервису, не
проверяя ничего. С 2026-08-15 по 2026-08-22 ту же роль играл `cmd/oidcstub`,
подставной провайдер OIDC. Двух вещей это не отменяет, и обе проверяемы: в
образ оснастка не едет (ступень собирает `./cmd/transcriber` поимённо), а
слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял
её у себя в чужой сети, отвечает за это сам.
- **Машина разработчика и то, что он на ней поднимает.** На место контура встаёт
сам сервис: при включённом предохранителе `[server] debug` он подставляет
заголовки входа значениями из конфига. Прежде эту роль играли отдельные
процессы — `cmd/oidcstub` с 2026-08-15 по 2026-08-22 и подкоманда
`cmd/devtools proxy` с 2026-08-22 по 2026-08-23; ни того, ни другой в
репозитории больше нет. Периметра выкладки отладочный запуск не касается,
пока предохранитель выключен, а выключен он по умолчанию; кто включил его у
себя в чужой сети, отвечает за это сам. В оснастке `cmd/devtools` осталась
одна подкоманда — `resume`, — и в образ она не едет: ступень собирает
`./cmd/transcriber` поимённо.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
+22
View File
@@ -62,6 +62,16 @@ type ServerConfig struct {
Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"`
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
// Debug — предохранитель отладочного запуска. Умолчание — «выключено»:
// отсутствие ключа читается как боевой прогон, а не как отладочный.
//
// Означает он одно: прогон идёт на машине разработчика, и сервису позволено
// подставить то, что в бою даёт окружение. Сегодня подставляется ровно одна
// вещь — заголовки входа, — и перечень следствий закрыт: уровня журнала,
// текстов внутренних отказов, ограничителя частоты, подмены распознавателя и
// проверок старта признак не касается. Новое следствие вешается на него
// только отдельной нормой спеки `access`.
Debug bool `toml:"debug"`
}
// StorageConfig — хранилище сервиса: каталог данных и числа его базы.
@@ -121,6 +131,18 @@ type AuthConfig struct {
// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
// той же строкой, которой он обходится, не барьер вовсе.
TrustedProxies []string `toml:"trusted_proxies"`
// TestHeaders — имитация заголовков, которые в бою ставит обратный прокси.
// Ключ — имя заголовка, значение — то, чем сервис назовёт пришедшего сам.
//
// Работает только при включённом предохранителе `[server] debug`, и
// заполненная секция без него роняет старт: состояние «имитация есть,
// предохранителя нет» не читается никак, а обе его прочтения — поломка.
//
// Умолчание — пустая секция. Непустой она считается по наличию ключа, каким
// бы ни было его значение: `Remote-User = ""` — заполненная имитация, а не
// отсутствие её.
TestHeaders map[string]string `toml:"test_headers"`
}
// TrustedNetworks разбирает перечень доверенных адресов.
+136
View File
@@ -0,0 +1,136 @@
package config
import (
"fmt"
"net/textproto"
"sort"
"strings"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Имена ключей в отказах старта. Литералы по одному на пакет: два разошлись бы
// молча, и владелец искал бы в конфиге ключ, которого там нет.
const (
debugKey = "[server] debug"
testHeadersKey = "[auth.test_headers]"
)
// HeaderSubstitution отдаёт **что** сервис подставит запросу вместо заголовков
// обратного прокси — и этим же перечнем отвечает на «подставляет ли»: пустой
// перечень значит «не подставляет».
//
// Признака вторым значением нет намеренно. Предикат, посчитанный дважды —
// булевым ответом здесь и непустотой карты у потребителя, — разошёлся бы молча,
// и сервис либо подставлял бы молча, либо молча не подставлял. Представление
// одно: непустота карты, и судят её все одинаково — проверка старта, строка
// журнала при старте и слой подстановки.
//
// Ключи возвращаемой карты приведены к каноническому виду имени заголовка: в
// HTTP имя нечувствительно к регистру, а ключ TOML чувствителен.
//
// Перечень пуст и тогда, когда предохранитель включён, а имитация пуста: сам по
// себе признак ничего не включает.
func (c *Config) HeaderSubstitution() map[string]string {
if !c.Server.Debug || len(c.Auth.TestHeaders) == 0 {
return nil
}
headers := make(map[string]string, len(c.Auth.TestHeaders))
for name, value := range c.Auth.TestHeaders {
headers[textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name))] = value
}
return headers
}
// ValidateTestHeaders судит настройки отладочного входа на старте, до приёма
// трафика: настройка, отданная на честность выкладки, проверяется только тем,
// что чужой архив уже уехал не тому.
//
// Имена заголовков приходят доводом, а не читаются отсюда: дом у них один —
// константы транспорта, — а пакет настроек транспорта не знает и знать не
// должен. Обратное ребро сделало бы `cmd/devtools`, которому нужен один разбор
// конфига, линкующим всю поверхность HTTP.
//
// Отказов четыре, и каждый закрывает своё «не читается никак»:
//
// - имитация заполнена, предохранитель выключен. Либо человек забыл включить
// предохранитель и будет искать поломку везде, кроме одного ключа, либо
// забыл убрать имитацию из боевого файла — и тогда до открытого архива
// остаётся одно слово;
// - два ключа секции дают одно каноническое имя заголовка. `Remote-User` и
// `remote-user` для TOML — два ключа, для HTTP — одно имя, и одно из двух
// значений потерялось бы молча;
// - имитация называет имя, которого сервис не читает. Опечатка `Remote-Usr`
// иначе кончается сервисом, который никого не узнаёт, без единого следа;
// - имитация непуста, а годного логина в ней нет. Секция с одним
// `Remote-Email` подняла бы сервис, который подставит почту, удалит логин и
// не узнает никого.
//
// Значений отказы не называют: логин — ключ к чужому архиву, и запрет печатать
// его действует на подставленное значение наравне с пришедшим.
func (c *Config) ValidateTestHeaders(accepted []string, loginHeader string) error {
headers := c.HeaderSubstitution()
if len(headers) == 0 {
if len(c.Auth.TestHeaders) > 0 {
return fmt.Errorf(
"auth: секция %s заполнена, а предохранитель %s выключен: "+
"либо включите предохранитель, либо уберите имитацию",
testHeadersKey, debugKey,
)
}
// Включённый предохранитель при пустой имитации законен: сам по себе он
// ничего не включает.
return nil
}
// Два ключа, различающиеся только регистром, дали бы одно имя заголовка и
// одно значение — второе потерялось бы молча.
if len(headers) != len(c.Auth.TestHeaders) {
return fmt.Errorf(
"auth: в секции %s два ключа называют один заголовок: "+
"имя заголовка нечувствительно к регистру, и одно из значений потерялось бы молча",
testHeadersKey,
)
}
known := make(map[string]bool, len(accepted))
for _, name := range accepted {
known[textproto.CanonicalMIMEHeaderKey(name)] = true
}
unknown := make([]string, 0, len(headers))
for name := range headers {
if !known[name] {
unknown = append(unknown, name)
}
}
if len(unknown) > 0 {
// Порядок перебора карты свой у каждого прогона, а отказ старта читает
// человек: без сортировки один и тот же конфиг давал бы разный текст.
sort.Strings(unknown)
return fmt.Errorf(
"auth: секция %s называет заголовок, которого сервис не читает: %s; принимаются %s",
testHeadersKey, strings.Join(unknown, ", "), strings.Join(accepted, ", "),
)
}
login, named := headers[textproto.CanonicalMIMEHeaderKey(loginHeader)]
if !named {
return fmt.Errorf(
"auth: секция %s не называет ключа %s: сервис подставил бы всё прочее и не узнал бы никого",
testHeadersKey, loginHeader,
)
}
if _, ok := entity.AcceptProviderLogin(login); !ok {
return fmt.Errorf(
"auth: значение ключа %s в секции %s не годится в логин: "+
"пустое, из одних пробельных знаков, длиннее %d знаков либо с управляющими знаками",
loginHeader, testHeadersKey, entity.MaxProviderLoginLength,
)
}
return nil
}
+224
View File
@@ -0,0 +1,224 @@
package config
import (
"strings"
"testing"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
)
// Проверки этого файла судят требование «Настройка, открывающая вход всем,
// роняет старт». Предмет у них один: сервис, поднявшийся на настройках, при
// которых отладочный вход становится открытым входом либо не работает вовсе.
//
// Имена заголовков берутся у транспорта, а не выписываются здесь: дом у них
// один, и проверка со своим списком зеленела бы на разошедшемся коде.
func acceptedHeaderNames() []string { return httpcontroller.IdentityHeaderNames() }
// validateTestHeaders зовёт проверку так же, как её зовёт точка входа.
func validateTestHeaders(cfg *Config) error {
return cfg.ValidateTestHeaders(acceptedHeaderNames(), httpcontroller.LoginHeader)
}
// debugConfig собирает настройки отладочного запуска: предохранитель и имитация.
func debugConfig(debug bool, headers map[string]string) *Config {
cfg := defaultConfig()
cfg.Server.Debug = debug
cfg.Auth.TrustedProxies = []string{"127.0.0.1"}
cfg.Auth.TestHeaders = headers
return cfg
}
// Первый отказ: имитация заполнена, предохранителя нет. Состояние не читается
// никак — либо человек забыл включить предохранитель, либо забыл убрать
// имитацию из боевого файла, и до открытого архива остаётся одно слово.
func TestTestHeadersWithoutDebugFailsStartup(t *testing.T) {
err := validateTestHeaders(debugConfig(false, map[string]string{
httpcontroller.LoginHeader: "local",
}))
if err == nil {
t.Fatal("имитация без предохранителя принята: сервис поднялся бы никого не узнающим")
}
if !strings.Contains(err.Error(), "[server] debug") {
t.Fatalf("имя ключа предохранителя не названо, чинить нечего: %v", err)
}
}
// Пустое значение — заполненная имитация, а не отсутствие её: человек, написавший
// ключ, имитацию завёл, и пустое значение у него вторая поломка, а не первая.
func TestEmptyLoginValueCountsAsFilledSection(t *testing.T) {
err := validateTestHeaders(debugConfig(false, map[string]string{
httpcontroller.LoginHeader: "",
}))
if err == nil {
t.Fatal("секция с пустым значением сочтена пустой")
}
if !strings.Contains(err.Error(), "[server] debug") {
t.Fatalf("имя ключа предохранителя не названо: %v", err)
}
}
// Второй отказ: имя, которого сервис не читает. Опечатка иначе кончается
// сервисом, который никого не узнаёт, без единого следа.
func TestUnknownHeaderNameFailsStartup(t *testing.T) {
err := validateTestHeaders(debugConfig(true, map[string]string{
httpcontroller.LoginHeader: "local",
"Remote-Usr": "local",
}))
if err == nil {
t.Fatal("неизвестное имя заголовка принято")
}
message := err.Error()
if !strings.Contains(message, "Remote-Usr") {
t.Fatalf("неизвестное имя не названо: %v", err)
}
for _, name := range acceptedHeaderNames() {
if !strings.Contains(message, name) {
t.Fatalf("принимаемое имя %s не названо, чинить нечего: %v", name, err)
}
}
}
// Третий отказ, первая его половина: ключа логина нет вовсе. Такая секция
// подняла бы сервис, который подставит почту, удалит логин и не узнает никого.
func TestSectionWithoutLoginKeyFailsStartup(t *testing.T) {
err := validateTestHeaders(debugConfig(true, map[string]string{
httpcontroller.EmailHeader: "local@example.com",
}))
if err == nil {
t.Fatal("имитация без ключа логина принята")
}
if !strings.Contains(err.Error(), httpcontroller.LoginHeader) {
t.Fatalf("имя недостающего ключа не названо: %v", err)
}
}
// Третий отказ, вторая половина: логин судится **тем же** приёмом, каким
// узнавание судит пришедшее значение. Иначе сервис поднимается, ставит
// заголовок, получает отказ приёма и отвечает неузнанным на всё.
func TestUnacceptableLoginValueFailsStartup(t *testing.T) {
cases := map[string]string{
"пустое": "",
"пробельное": " ",
"сверх предела": strings.Repeat("x", 300),
"с управляющим знаком": "loc\x00al",
}
for name, login := range cases {
t.Run(name, func(t *testing.T) {
err := validateTestHeaders(debugConfig(true, map[string]string{
httpcontroller.LoginHeader: login,
}))
if err == nil {
t.Fatal("негодное значение логина принято")
}
message := err.Error()
if !strings.Contains(message, httpcontroller.LoginHeader) {
t.Fatalf("имя ключа не названо, чинить нечего: %v", err)
}
// Значение в отказ не идёт: логин — ключ к чужому архиву.
if login != "" && strings.Contains(message, login) {
t.Fatalf("значение логина уехало в отказ старта: %v", err)
}
})
}
}
// Два ключа, различающиеся регистром, назвали бы один заголовок: имя заголовка
// нечувствительно к регистру, а ключ TOML чувствителен, и одно из значений
// потерялось бы молча.
func TestDuplicateHeaderKeyFailsStartup(t *testing.T) {
err := validateTestHeaders(debugConfig(true, map[string]string{
"Remote-User": "one",
"remote-user": "two",
}))
if err == nil {
t.Fatal("два ключа на один заголовок приняты: одно значение потерялось бы молча")
}
}
// Законный случай: предохранитель включён, имитация пуста. Сам по себе признак
// ничего не включает, и перечень доверенных адресов ему не судья.
func TestDebugWithoutTestHeadersStarts(t *testing.T) {
cfg := debugConfig(true, nil)
cfg.Auth.TrustedProxies = []string{"172.20.0.0/24"}
if err := validateTestHeaders(cfg); err != nil {
t.Fatalf("включённый предохранитель при пустой имитации уронил старт: %v", err)
}
if len(cfg.HeaderSubstitution()) > 0 {
t.Fatal("пустая имитация включила подстановку")
}
}
// Законный случай главный: конфиг сегодняшнего дня, не называющий ни одного
// нового ключа, ведёт себя ровно как вёл.
func TestConfigWithoutNewKeysStartsUnchanged(t *testing.T) {
path := writeConfig(t, "[auth]\ntrusted_proxies = [\"172.20.0.0/24\"]\n"+validConfigBody)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг без новых ключей не прочитан: %v", err)
}
if cfg.Server.Debug {
t.Fatal("отсутствие ключа предохранителя прочитано как «включено»")
}
if len(cfg.Auth.TestHeaders) != 0 {
t.Fatalf("отсутствие секции имитации прочитано как заполненная: %v", cfg.Auth.TestHeaders)
}
if err := validateTestHeaders(cfg); err != nil {
t.Fatalf("конфиг без новых ключей уронил старт: %v", err)
}
if cfg.HeaderSubstitution() != nil {
t.Fatal("конфиг без новых ключей включил подстановку")
}
}
// Негодное значение предохранителя роняет старт разбором, а не читается как
// «включено»: ошибка разбора не вправе открывать вход.
func TestMalformedDebugValueFailsLoad(t *testing.T) {
path := writeConfig(t, "[server]\ndebug = \"yes\"\n"+validConfigBody)
if _, err := LoadConfig(path); err == nil {
t.Fatal("строка вместо булева значения принята")
}
}
// Ключи имитации приезжают из файла, а на выходе приведены к каноническому виду
// имени заголовка: в HTTP имя нечувствительно к регистру, а ключ TOML — нет.
func TestHeaderSubstitutionReadsSectionAndCanonicalizes(t *testing.T) {
path := writeConfig(t, `
[server]
debug = true
[auth]
trusted_proxies = ["127.0.0.1"]
[auth.test_headers]
remote-user = "local"
REMOTE-EMAIL = "local@example.com"
`+validConfigBody)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг с имитацией не прочитан: %v", err)
}
if err := validateTestHeaders(cfg); err != nil {
t.Fatalf("годная имитация уронила старт: %v", err)
}
headers := cfg.HeaderSubstitution()
if len(headers) == 0 {
t.Fatal("заполненная имитация при включённом предохранителе не включила подстановку")
}
if headers[httpcontroller.LoginHeader] != "local" {
t.Fatalf("логин не приведён к каноническому имени заголовка: %v", headers)
}
if headers[httpcontroller.EmailHeader] != "local@example.com" {
t.Fatalf("адрес почты не приведён к каноническому имени заголовка: %v", headers)
}
if _, named := headers[httpcontroller.NameHeader]; named {
t.Fatalf("не названный секцией заголовок появился в перечне: %v", headers)
}
}
+1 -1
View File
@@ -464,7 +464,7 @@ func TestPanickingHandlerAnswersOurFailureFormAndProcessLives(t *testing.T) {
panic("шаг обработчика упал")
})
mounts := ServiceMounts(
AppChain(panicking, users, testTrustedNetworks(t), logger),
AppChain(panicking, users, testTrustedNetworks(t), nil, logger),
http.NotFoundHandler(),
)
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
+9
View File
@@ -23,6 +23,15 @@ const (
EmailHeader = "Remote-Email"
)
// IdentityHeaderNames отдаёт имена заголовков входа целиком, в одном порядке.
//
// Перечисление тройки живёт здесь и только здесь. Всякий, кому нужен её состав —
// слой подстановки, проверка старта, строка журнала, — берёт его отсюда: второй
// список разошёлся бы с первым молча, а имена нормативны.
func IdentityHeaderNames() []string {
return []string{LoginHeader, NameHeader, EmailHeader}
}
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
// Читает его только ограничитель частоты: барьером узнавания он не служит и
// служить не может — кто пришёл, решает адрес самого соединения.
+14 -3
View File
@@ -10,28 +10,39 @@ import (
// AppChain одевает адреса приложения в их слои.
//
// Порядок один и он несущий: ограничитель частоты → узнавание → требование
// учётной записи → обработчик.
// Порядок один и он несущий: ограничитель частоты → подстановка заголовков →
// узнавание → требование учётной записи → обработчик.
//
// - ограничитель стоит первым, потому что узнавание читает базу, а на новом
// имени ещё и пишет в неё: поставленное раньше, оно работало бы на запросах,
// которые ограничитель уже отверг, и поток отвергнутых обращений заводил бы
// учётные записи, которые потом не убираются ничем;
// - подстановка стоит перед узнаванием и после ограничителя: она правит
// заголовки, а ограничитель считает по адресу спрашивающего, и заголовки
// входа его не касаются;
// - требование учётной записи стоит перед чтением тела: запись, за которую не
// заплатит узнанный отправитель, не должна попасть даже в память, а позже
// пришлось бы убирать уже уложенный файл — чего сервис не умеет вовсе.
//
// Область действия всех трёх — корень приложения, и берётся она из перечня
// Подстановка — звено необязательное: пустой перечень значит, что отладочный
// запуск выключен, и слой отдаёт цепочку нетронутой — следующее звено как есть.
// Цепочка при этом ровно та же, что была до появления предохранителя, — лишнего
// звена в бою нет. Судит эту пустоту сам слой, и предикат «подставляем ли» имеет
// поэтому одно представление — непустоту перечня; здесь не решается ничего.
//
// Область действия всех — корень приложения, и берётся она из перечня
// адресного пространства: цепочка вешается на корень целиком, вторым списком
// адресов её не описывают.
func AppChain(
routes http.Handler,
users contract.UserRepository,
trusted []netip.Prefix,
substitution map[string]string,
logger *slog.Logger,
) http.Handler {
handler := RequireUser()(routes)
handler = TrustedHeaderIdentity(users, trusted, logger)(handler)
handler = SubstituteIdentityHeaders(substitution, trusted, logger)(handler)
handler = RateLimit(trusted)(handler)
return handler
}
+1 -1
View File
@@ -317,7 +317,7 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
)
mounts := ServiceMounts(
AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger),
AppChain(handler.Routes(), env.users, testTrustedNetworks(t), nil, logger),
http.NotFoundHandler(),
)
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
+138
View File
@@ -0,0 +1,138 @@
package http
import (
"log/slog"
"net/http"
"net/netip"
)
// SubstituteIdentityHeaders — слой отладочного запуска: ставит запросу заголовки
// входа значениями из настроек.
//
// # Зачем он есть
//
// В бою заголовки ставит обратный прокси, сходивший к провайдеру. На машине
// разработчика прокси нет, а браузер заголовков не ставит — и приложение
// локально не открылось бы вовсе. Слой встаёт на место контура **внутри самого
// сервиса**: второго процесса и второго порта для этого не нужно.
//
// # Чего слой не делает
//
// Он правит заголовки запроса и ничего больше: учётной записи не заводит,
// отказов не выдаёт, в контекст не пишет. Узнавание получает запрос,
// неотличимый от пришедшего с сервера, и отлаживается поэтому боевая ветка, а
// не её отладочный двойник.
//
// # Что ему запрещено
//
// Подстановка — это настройка, которой сервис называет пришедшего сам, никого
// не спросив. Ограничений поэтому три, и каждое отсекает свой способ отдать
// чужой архив:
//
// - пустой перечень значит «подставлять нечего», и слой отдаёт цепочку
// нетронутой: звено не встаёт в неё вовсе, а запрос доходит до узнавания
// тем же, каким пришёл. Судится пустота здесь, и представление у предиката
// «подставляем ли» одно на всех — непустота самого перечня. Иначе
// включённый предохранитель при пустой имитации срезал бы `Remote-Email` у
// запроса, пришедшего без `Remote-User`;
// - подставляется только при **отсутствии** `Remote-User`. Пришедший заголовок
// не подменяется и не дополняется: узнавание отвергает запрос с двумя
// значениями, и слой, дописавший второе, превратил бы законный отказ в
// отладочный проход;
// - подставляется только с адреса из перечня доверенных, и судит адрес та же
// функция, какой судит его узнавание. Второй сверщик разошёлся бы с первым
// молча: узнавание разворачивает IPv4 в оболочке IPv6.
//
// Подставляя, слой распоряжается **всей** тройкой заголовков входа: названные
// настройками ставит их значением, не названные удаляет. Запрос без
// `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
// источников — логин свой, почта чужая.
func SubstituteIdentityHeaders(
headers map[string]string,
trusted []netip.Prefix,
logger *slog.Logger,
) func(http.Handler) http.Handler {
// Подставлять нечего: цепочка слоёв остаётся ровно той, что в бою, —
// следующее звено возвращается как есть, лишнего звена не появляется.
if len(headers) == 0 {
return func(next http.Handler) http.Handler { return next }
}
if logger == nil {
logger = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Решает **наличие** заголовка, а не его значение: пустое значение
// штатно шлёт прокси там, где никого не назвал, и подстановка на
// этом месте назвала бы человеком того, кому провайдер отказал.
if len(r.Header.Values(LoginHeader)) > 0 {
next.ServeHTTP(w, r)
return
}
peer, ok := peerAddress(r.RemoteAddr)
if !ok || !isTrusted(trusted, peer) {
// Своя строка, и уровень предупреждающий. Узнавание здесь
// молчит предупреждением: заголовка нет, и оно считает случай
// штатным. Без этой строки самый частый локальный отказ —
// браузер пришёл с `::1`, а перечень называет `127.0.0.1` — не
// отличим от поломки узнавания: человек видит отказ на всём
// приложении и ни одного следа о том, почему подстановки не
// было.
//
// Подставляемых значений в строке нет: логин — ключ к чужому
// архиву, и запрет печатать его действует на подставленное
// значение наравне с пришедшим.
logger.Warn("Identity headers are not substituted for an untrusted peer",
"http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http")
next.ServeHTTP(w, r)
return
}
// Тройка берётся из своего единственного дома, а не перечисляется
// здесь заново.
for _, name := range IdentityHeaderNames() {
if value, named := headers[name]; named {
r.Header.Set(name, value)
} else {
r.Header.Del(name)
}
}
// Уровень `INFO`, и боевой журнал он не топит по построению: строка
// пишется только тогда, когда подстановка действительно работает, а
// в бою она выключена умолчанием предохранителя. Появляется строка
// ровно в одном месте — в локальном прогоне, где стоит на каждом
// запросе и где она и нужна: ею «узнан подстановкой» отличается от
// «узнан прокси». На отладочном уровне её не видел бы никто:
// настройки под уровень журнала у сервиса нет, и он зашит `INFO`.
//
// Подставленных значений в строке нет по той же причине, по какой их
// нет в строке отказа выше.
logger.Info("Identity headers substituted from configuration",
"http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http")
next.ServeHTTP(w, r)
})
}
}
// SubstitutedHeaderNames называет заголовки, которые слой подставит, в порядке
// объявления тройки.
//
// Нужен строке журнала при старте: перебор карты дал бы порядок, меняющийся от
// запуска к запуску, а перечисление имён по месту завело бы третий список.
func SubstitutedHeaderNames(headers map[string]string) []string {
names := make([]string, 0, len(headers))
for _, name := range IdentityHeaderNames() {
if _, named := headers[name]; named {
names = append(names, name)
}
}
return names
}
+297
View File
@@ -0,0 +1,297 @@
package http
import (
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Проверки этого файла судят отладочный запуск: чем сервис называет пришедшего,
// когда обратного прокси рядом нет, и чего подстановке при этом не позволено.
// substituted — имитация заголовков, которой пользуются проверки. Значения
// приметные: по ним же судится запрет на печать подставленного в журнал.
var substituted = map[string]string{
LoginHeader: "local-dev-login",
EmailHeader: "local-dev@example.com",
}
// substitutingLayer собирает слой с журналом боевого уровня и обработчиком,
// запоминающим дошедший запрос.
//
// Уровень именно боевой — умолчание обработчика, то есть `INFO`: строки слоя
// судятся такими, какими их увидит настоящий прогон, а поднятый ради проверки
// `DEBUG` показал бы строку, которой в жизни не видит никто.
func substitutingLayer(t *testing.T, headers map[string]string) (http.Handler, *journalBuffer, *http.Request) {
t.Helper()
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
var seen http.Request
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = *r.Clone(r.Context())
})
return SubstituteIdentityHeaders(headers, testTrustedNetworks(t), logger)(next), journal, &seen
}
// serveSubstituted гоняет запрос через слой и отдаёт то, что дошло до узнавания.
func serveSubstituted(
t *testing.T, headers map[string]string, build func(*http.Request),
) (*http.Request, string) {
t.Helper()
layer, journal, seen := substitutingLayer(t, headers)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.RemoteAddr = trustedPeer
build(req)
layer.ServeHTTP(httptest.NewRecorder(), req)
return seen, journal.String()
}
// Запрос без единого заголовка входа получает имя из настроек, и получает
// именно то, что в них названо.
func TestSubstitutionNamesRequestWithoutHeaders(t *testing.T) {
seen, journal := serveSubstituted(t, substituted, func(*http.Request) {})
assert.Equal(t, []string{"local-dev-login"}, seen.Header.Values(LoginHeader))
assert.Equal(t, "local-dev@example.com", seen.Header.Get(EmailHeader))
// Строка о подстановке идёт на `INFO` и видна журналу боевой настройки: ею
// «узнан подстановкой» отличается от «узнан прокси».
assert.Contains(t, journal, "level=INFO")
assert.Contains(t, journal, "Identity headers substituted")
for _, value := range substituted {
assert.NotContains(t, journal, value,
"подставленное значение уехало в журнал: логин — ключ к чужому архиву")
}
}
// Пришедший заголовок не подменяется: решает **наличие**, а не значение.
func TestSubstitutionKeepsIncomingLoginHeader(t *testing.T) {
seen, _ := serveSubstituted(t, substituted, func(r *http.Request) {
r.Header.Set(LoginHeader, "came-from-proxy")
})
assert.Equal(t, []string{"came-from-proxy"}, seen.Header.Values(LoginHeader))
}
// Два значения остаются двумя: узнавание отвергает такой запрос, и слой,
// дописавший третье или заменивший оба, превратил бы законный отказ в проход.
func TestSubstitutionLeavesDoubledLoginHeaderAlone(t *testing.T) {
seen, _ := serveSubstituted(t, substituted, func(r *http.Request) {
r.Header.Add(LoginHeader, "first")
r.Header.Add(LoginHeader, "second")
})
assert.Equal(t, []string{"first", "second"}, seen.Header.Values(LoginHeader))
}
// Недоверенный адрес имени не получает, и отказ этот оставляет **свою** строку:
// узнавание здесь молчит предупреждением, потому что заголовка нет вовсе.
func TestSubstitutionRefusesUntrustedPeer(t *testing.T) {
layer, journal, seen := substitutingLayer(t, substituted)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.RemoteAddr = untrustedPeer
layer.ServeHTTP(httptest.NewRecorder(), req)
assert.Empty(t, seen.Header.Values(LoginHeader),
"запрос с недоверенного адреса получил имя из настроек")
text := journal.String()
assert.Contains(t, text, "level=WARN")
assert.Contains(t, text, "203.0.113.9", "адреса пира в строке нет: поломку не отличить")
for _, value := range substituted {
assert.NotContains(t, text, value, "подставляемое значение уехало в журнал")
}
}
// Слой владеет тройкой целиком: чужая почта при своём логине собрала бы личность
// из двух источников.
func TestSubstitutionOwnsWholeHeaderSet(t *testing.T) {
seen, _ := serveSubstituted(t, map[string]string{LoginHeader: "local-dev-login"},
func(r *http.Request) {
r.Header.Set(EmailHeader, "someone-else@example.com")
r.Header.Set(NameHeader, "Чужое имя")
})
assert.Equal(t, "local-dev-login", seen.Header.Get(LoginHeader))
assert.Empty(t, seen.Header.Get(EmailHeader),
"чужой адрес почты дошёл до узнавания вместе со своим логином")
assert.Empty(t, seen.Header.Get(NameHeader),
"чужое имя дошло до узнавания вместе со своим логином")
}
// setupSubstitutingEnv собирает поверхность сервиса с включённым отладочным
// входом — тем же способом, каким её собирает точка входа.
func setupSubstitutingEnv(t *testing.T, headers map[string]string) *testEnv {
t.Helper()
return setupEnv(t, envOptions{
metaviewer: readableMetaViewer(),
dist: builtDist(),
built: true,
substitution: headers,
})
}
// Сквозной сценарий: запрос без заголовков узнаётся, и учётная запись заводится
// под именем из настроек.
func TestSubstitutedRequestIsIdentifiedEndToEnd(t *testing.T) {
env := setupSubstitutingEnv(t, substituted)
before := countAccounts(t, env)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.RemoteAddr = trustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "запрос без заголовков остался неузнанным")
assert.Equal(t, before+1, countAccounts(t, env),
"учётная запись подставленного логина не завелась")
}
// Подставленное значение не встречается ни в одной журнальной записи: заведение
// учётной записи идёт на INFO, и логин в эту строку попасть не вправе.
func TestSubstitutedValueNeverReachesJournal(t *testing.T) {
env := setupSubstitutingEnv(t, substituted)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.RemoteAddr = trustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
journal := env.journal.String()
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
for _, value := range substituted {
assert.NotContains(t, journal, value, "подставленное значение уехало в журнал")
}
}
// Наблюдение подстановки не видит: область слоя равна области узнавания и
// выводится из неё, а не перечисляется вторым списком.
func TestObservationAddressesIgnoreSubstitution(t *testing.T) {
plain := setupTestEnv(t, readableMetaViewer())
debug := setupSubstitutingEnv(t, substituted)
before := countAccounts(t, debug)
for _, path := range []string{HealthPath, MetricsPath} {
t.Run(path, func(t *testing.T) {
request := func(env *testEnv) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = trustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
return w
}
plainResponse := request(plain)
debugResponse := request(debug)
assert.Equal(t, plainResponse.Code, debugResponse.Code)
assert.Equal(t, plainResponse.Body.String(), debugResponse.Body.String())
})
}
assert.Equal(t, before, countAccounts(t, debug),
"проба здоровья завела учётную запись подставленного логина")
}
// Ограничитель частоты работает и в отладочном запуске: предохранитель — это
// закрытый перечень следствий, а не режим.
func TestRateLimitStillCutsInDebugRun(t *testing.T) {
env := setupSubstitutingEnv(t, substituted)
refused := 0
for range appRateMaxRequests + 10 {
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.RemoteAddr = trustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
refused++
}
}
assert.Positive(t, refused, "ограничитель частоты в отладочном запуске не сработал")
}
// Пустая имитация заголовков не трогает: слой отдаёт цепочку нетронутой, и до
// узнавания доходит ровно тот запрос, который пришёл.
//
// Судится дошедшее до узнавания, а не строка журнала: запрос идёт с доверенного
// адреса и без `Remote-User` — ровно тот случай, в котором слой, встань он в
// цепочку, срезал бы чужие `Remote-Email` и `Remote-Name`.
func TestEmptySectionLeavesHeadersUntouched(t *testing.T) {
seen, _ := serveSubstituted(t, nil, func(r *http.Request) {
r.Header.Set(EmailHeader, "came-from-proxy@example.com")
r.Header.Set(NameHeader, "Пришедшее имя")
})
assert.Equal(t, "came-from-proxy@example.com", seen.Header.Get(EmailHeader),
"пустая имитация срезала пришедший адрес почты")
assert.Equal(t, "Пришедшее имя", seen.Header.Get(NameHeader),
"пустая имитация срезала пришедшее имя")
assert.Empty(t, seen.Header.Values(LoginHeader),
"пустая имитация назвала пришедшего")
}
// Запрос без заголовка с недоверенного адреса оставляет предупреждение с
// адресом пира: без него самый частый локальный отказ — браузер пришёл с `::1`,
// а перечень называет `127.0.0.1` — не отличим от поломки узнавания.
func TestUntrustedPeerWithoutHeaderIsLoggedInDebugRun(t *testing.T) {
env := setupSubstitutingEnv(t, substituted)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.RemoteAddr = untrustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
journal := env.journal.String()
assert.Contains(t, journal, "Identity headers are not substituted")
assert.Contains(t, journal, "203.0.113.9")
for _, value := range substituted {
assert.NotContains(t, journal, value)
}
}
// Конфиг без новых ключей даёт ту же цепочку слоёв и те же ответы: отсутствие
// предохранителя не меняет ни одного байта поведения.
func TestChainWithoutSubstitutionAnswersAsBefore(t *testing.T) {
plain := setupTestEnv(t, readableMetaViewer())
debug := setupSubstitutingEnv(t, nil)
paths := []string{"/app/me", "/app/audiorecords", HealthPath, "/nothing-was-ever-here"}
for _, path := range paths {
t.Run(strings.TrimPrefix(path, "/"), func(t *testing.T) {
request := func(env *testEnv) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = trustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
return w
}
plainResponse := request(plain)
debugResponse := request(debug)
assert.Equal(t, plainResponse.Code, debugResponse.Code)
assert.Equal(t, plainResponse.Body.String(), debugResponse.Body.String())
})
}
}
+5 -1
View File
@@ -254,6 +254,10 @@ type envOptions struct {
metaviewer contract.AudioMetaViewer
dist fs.FS
built bool
// substitution — имитация заголовков входа отладочного запуска. Пустая
// значит «предохранитель выключен»: слой подстановки в цепочку не встаёт,
// и поверхность собирается ровно та же, что в бою.
substitution map[string]string
}
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
@@ -306,7 +310,7 @@ func setupEnv(t *testing.T, opts envOptions) *testEnv {
handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)
mounts := ServiceMounts(
AppChain(handler.Routes(), users, testTrustedNetworks(t), logger),
AppChain(handler.Routes(), users, testTrustedNetworks(t), opts.substitution, logger),
http.NotFoundHandler(),
)
webapp := NewWebappHandler(opts.dist, opts.built, logger)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-23
@@ -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
Развилок не осталось: все закрыты владельцем на чекпоинте.
@@ -0,0 +1,74 @@
## Why
Чтобы открыть приложение на своей машине, сегодня мало запустить сервис: рядом
надо поднять второй процесс — подставной обратный прокси из оснастки, — потому
что сервис узнаёт пришедшего по заголовку, а браузер заголовков не ставит. Два
процесса вместо одного, свой порт у каждого и правило «приложение открывают по
адресу прокси, а не по адресу сервиса» — всё это надо помнить и объяснять
каждому, кто пришёл в проект.
Владелец назвал желаемое прямо: один бинарник, а различия между запусками
задаются конфигом. Тестовый прогон должен подниматься тем же
`go run ./cmd/transcriber -c config.toml`, что и всякий другой, и отличаться от
боевого только содержимым файла настроек.
## What Changes
- В секцию `[auth]` добавляется подсекция `test_headers` — имитация заголовков,
которые сервису на сервере ставит обратный прокси. Ключи подсекции задают
значения, которыми сервис назовёт пришедшего сам, когда тот пришёл ни с чем.
- В секцию `[server]` добавляется предохранитель `debug` — признак отладочного
запуска со значением по умолчанию «выключено». Подстановка заголовков работает
только при включённом признаке.
- Старт получает новые правила отказа. Заполненная имитация при выключенном
предохранителе роняет сервис, а не игнорируется молча; имитация, при которой
узнавание не состоится ни при каком запросе — неизвестное имя заголовка,
отсутствующий или негодный `Remote-User`, — роняет его тоже.
- Владелец обязан видеть отладочный запуск в журнале сервиса — предупреждением
при старте и на каждом подставленном запросе.
- Подставной обратный прокси `go run ./cmd/devtools proxy` убирается целиком:
всё, ради чего его поднимали, делает сам сервис, и второго способа входить
локально не остаётся. Решение владельца на чекпоинте; цена — поломки контура
местным инструментом больше не воспроизводятся.
- Образец конфига и памятка описывают локальный вход через настройки вместо
запуска второго процесса.
## Capabilities
### New Capabilities
Новых capability изменение не заводит: предмет прежний — кто пришёл в сервис и
пускают ли его дальше.
### Modified Capabilities
- `access`: узнавание получает второй источник заголовков — настройки сервиса —
и правила, при которых этот источник законен. Требования: подстановка идёт
только при включённом предохранителе, только при отсутствии пришедшего
заголовка, только с адресов, которым сервис и так верит; заполненная имитация
без предохранителя роняет старт; отладочный запуск виден в журнале, а значения
подставленного заголовка в журнале по-прежнему нет.
## Impact
- Настройки: `internal/config` — структуры секций `[server]` и `[auth]`,
умолчания, проверки старта. Имя нового ключа конфига необратимо и решается
человеком.
- Вход: `internal/controller/http` — цепочка слоёв корня приложения и
узнавание по заголовку.
- Точка входа: `cmd/transcriber` — порядок проверок при старте и сборка
цепочки.
- Оснастка: `cmd/devtools` — подкоманда `proxy` удаляется вместе с флагами,
шапкой пакета и строкой `usage()`.
- Документы: `config.example.toml`, `CLAUDE.md`, `README.md`,
`docs/security.md`, `docs/conventions/config.md`, `docs/architecture.md`
везде, где локальный вход описан через второй процесс.
- Граница домена: `docs/passport.md`, «Управление учётными записями». Сегодня
он утверждает, что кто пришёл, сервис не решает никогда, и что исключений у
этого больше нет. Изменение заводит исключение, и паспорт обязан назвать его
одной строкой с границей — предохранитель `[server] debug`, выключенный по
умолчанию, — и ссылкой на спеку `access`.
- Периметр: изменение заводит настройку, которой сервис пускает кого угодно.
Машиной боевая поломка не исключена: сервис с включённым предохранителем и
заполненной имитацией поднимется на любом перечне доверенных адресов. Что
стоит между боевой выкладкой и открытым входом — в `design.md`, решение 4.
@@ -0,0 +1,725 @@
# Триаж ревью — `config-test-headers-login`, 2026-08-23
## Сводка
- **Режим прогона:** по графу. Стадия — ревью кода.
- **База диффа:** `origin/master` совпадает с `HEAD`; предмет ревью — рабочее
дерево, включая неотслеживаемые файлы.
- **Размер:** 16 изменённых файлов + 4 новых (`test_headers.go`,
`test_headers_test.go`, `substitute.go`, `substitute_test.go`), ~240 строк
правок в коде; удалена подкоманда `proxy` пакета `cmd/devtools`.
- **Сложность:** новый слой транспорта, новая подсекция конфига с проверкой
старта, снятая оснастка; трогает барьер входа.
- **Метка:** `large`. Обоснование разметки — своих тем проекта сверх ядра не
заведено, поэтому `basics` не запускается: все темы разобраны именными
проходами.
- **Состояние гейта:** ЗЕЛЁНЫЙ. `task gate BASE=origin/master`, 13 шагов,
`go test -race` с работающим детектором, `golangci-lint` 0 issues,
`govulncheck` без находок. Сообщено проходом `autotests`; триаж гейт
не перезапускал.
- **Находок на входе:** 21 (A1; S1S4; C1C5; R1R3; V1V3 + три свойства
`adversary` без пути; O1). **Осталось в первых двух секциях:** 7.
### План с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| `requirements` | `openspec/specs/access/spec.md` + дельта | разбор | `specs` | **закрыта** проходом `specs`, 4 находки (S1S4) |
| `autotests` | `CLAUDE.md`, «Гейт» | — | `autotests` | **закрыта** проходом `autotests`, 1 находка (A1), гейт зелёный |
| `conventions` | `docs/conventions/` целиком | разбор | `code` | **закрыта** проходом `code`, 5 находок (C1C5) |
| `architecture` | `docs/architecture.md` + источник `docs/passport.md` | доказательство | `architecture` | **закрыта** проходом `architecture`, 3 находки (R1–R3) + 2 замечания «дешевле до мерджа» |
| `security` | `docs/security.md`, «Периметр» | доказательство | `adversary` | **закрыта** проходом `adversary`, 3 находки (V1–V3) + 3 свойства без построенного пути; пути обхода **самой подстановки** не построено (6 отрицательных проб) |
| `operations` | `docs/architecture.md`, «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта** проходом `ops`, 1 находка (O1) + подтверждённый факт по постмортему 2 |
| тема проекта | своих тем нет | — | `basics` не запускался | **дома у темы нет**; `basics` на метке `large` не запускается по плану — это не пропуск |
**Тем без отчёта нет.** Все шесть заявленных домов дали отчёт.
### Сигнал о заниженной метке
**Пришёл от одного прохода, возражений нет.** `review-code` метку `large`
подтвердил и занижения не увидел. `review-basics` на этой метке не запускался,
поэтому второго независимого голоса нет — согласия двух проходов здесь не было
и быть не могло.
### Находка о самом прогоне
**Ни один из шести проходов не объявил свой потолок.** Устав велит называть
строкой, сколько находок показано, каков был потолок и что осталось за срезом;
такой строки нет ни у `specs`, ни у `code`, ни у `architecture`, ни у
`adversary`, ни у `ops`, ни у `autotests`. Это ровно тот повторяющийся пробел,
который записан в [docs/review.md](../../../../docs/review.md) строками 2532 по
прогону `telegram-enabled-flag`: «находок больше нет» в отчёте прохода
неотличимо от «больше не поместилось». Механизации нет; сверка триажа —
единственное, что осталось, и она этот пробел только называет, а не закрывает.
### Дедупликация
- `A1` + `S2` — одна причина (строка журнала старта строится непроверенной
функцией). Сведены; **понижены**: триаж добыл им однократный оракул прогоном
бинарника, см. ниже.
- `S3` + `O1`**две причины, а не одна**, вопреки подсказке задания: первая —
отброшенные метаданные разбора (`MetaData.Undecoded()`); вторая — зашитый
уровень журнала. Разведены в находки 1 и 2: чинятся порознь, и починка одной
не закрывает другую.
- `S4` + второе звено `O1` — одна причина (зашитый `LevelInfo`). Сведены в
находку 1.
- `R3` + замечание `specs` про два места — одна причина. Сведены в находку 6
вместе с `C1`: обе легли на одну строку `server.go:44`.
- `R1` + `R2` — разные причины в разных домах, сведены в одну находку 7 двумя
названными правками: обе — один абзац, обе про то, что нормативный документ
описывает изъятие мягче, чем оно есть.
- **Согласие проходов приоритет поднимало, `Confidence` — нет.** Ни одна
формулировка ниже не опирается на «подтверждено N проходами».
---
## Блокирует мердж
### 1. Каждый отладочный след нового входа пишется уровнем, который в настоящем прогоне выключен: разработчик получает `401` на всём приложении и пустой журнал
- Файл: `cmd/transcriber/main.go:39-41`; `internal/controller/http/substitute.go:97-99`;
`internal/controller/http/identity.go:106-110`
- Severity: major
- Confidence: high
- Оракул: **свой прогон настоящего бинарника.**
`go build -o $S/transcriber ./cmd/transcriber`, конфиг из `config.example.toml`
с `debug = true`, `trusted_proxies = ["127.0.0.1", "::1"]` и правильной секцией
`[auth.test_headers]`, запрос `curl http://127.0.0.1:18099/app/me``200`.
В журнале — стартовая `WARN "Identity headers are substituted from configuration" headers=[Remote-User]`
и `INFO "Account created from login header"`; **строки
`"Identity headers substituted from configuration"` нет ни одной**.
Второй прогон, тот же конфиг с опечаткой в имени секции → `код=401`, и в
журнале ровно `INFO "Incoming request" ... http.status_code=401` без единой
строки о причине.
Подтверждающая команда:
`grep -rn "\.Debug(" --include=*.go internal/ cmd/ | grep -v _test` → 6 мест;
`grep -rn "LevelDebug\|Level:" --include=*.go cmd/ internal/ | grep -v _test`
→ единственная точка сборки логгера `cmd/transcriber/main.go:40`
`Level: slog.LevelInfo`, ключа конфига у уровня нет, `os.Getenv`/`os.LookupEnv`
в непроверочном коде нет вовсе.
- Последствие: **все шесть отладочных строк сервиса недостижимы в любом
настоящем прогоне** — и обе, которые завела эта задача. Дельта-спека при этом
требует отладочную строку прямо (`specs/access/spec.md:193`: «Запрос, которому
заголовки подставлены, SHALL писаться на отладочном уровне») и в том же файле,
строка 394, сама формулирует цену: «поломка, записанная уровнем, который в бою
выключен, не записана вовсе». Критерий приёмки 9 — различимость «узнан
подстановкой» от «узнан прокси» на уровне запроса — в действительности не
достигнут ничем. Второе звено дороже первого: `identity.go:106` пишет «Request
carries no login header» тоже отладочным уровнем, а путь «заголовка нет,
предохранитель не включён» стал **единственным путём первого локального
запуска у каждого** после удаления подкоманды `devtools proxy`. Человек видит
`401` на всём приложении и пустой журнал — то самое «без единого следа», ради
устранения которого заведены отказы старта.
- Предложение: развилка ниже.
- Найдено проходами: `specs` (S4), `ops` (O1, второе звено); оракул добыт триажем
заново.
- **Действие: развилка.**
> Отладочные строки сервиса не печатаются никогда: уровень журнала зашит
> `LevelInfo` в единственной точке сборки логгера, ключа конфига у него нет.
> Спека этой задачи требует отладочную строку подстановки, и критерий 9 на ней
> стоит. Что делаем?
>
> 1. **Завести ключ уровня журнала** (`[server] log_level` либо уровень,
> выводимый из `[server] debug`). Спека прямо запрещает предохранителю
> трогать уровень журнала, значит это отдельный ключ и правка спеки
> `access` — расширение scope и новое имя ключа конфига, а имя ключа
> `CLAUDE.md` относит к необратимому и требует спросить человека.
> 2. **Поднять уровень двух строк до `Info`** — «подстановка сработала» и
> «заголовка входа нет». Обе штатны и частотны; в бою первая не печатается
> вовсе (предохранитель выключен), вторая станет строкой на каждый
> неузнанный запрос. Правка спеки нужна тоже — она уровень назначает
> поимённо.
> 3. **Снять требование отладочной строки из спеки и критерий 9 вместе с ним**,
> записав строкой цену: наблюдаемости подстановки на уровне запроса у
> сервиса нет, стартовой `WARN`-строки признано достаточно.
### 2. Опечатка в имени самой секции `[auth.test_headers]` не судится ничем: сервис поднимается, никого не узнаёт и следа не оставляет — ровно тот исход, ради которого заведён второй отказ старта
- Файл: `internal/config/config.go:242-256` (метаданные разбора отбрасываются);
`internal/config/test_headers.go:33-43`
- Severity: major
- Confidence: high
- Оракул: **свой прогон, два уровня.**
(1) Модульный: декодер `BurntSushi/toml v1.5.0` на входе
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"` даёт
`decode err=<nil>`, `undecoded=[auth.test_headrs auth.test_headrs.Remote-User]`,
`Server.Debug=true len(TestHeaders)=0 substituting=false`,
`ValidateTestHeaders err = <nil>`. Контроль: та же опечатка **внутри** секции
(`Remote-Usr`) ловится —
`auth: секция [auth.test_headers] называет заголовок, которого сервис не читает: Remote-Usr`.
(2) Сквозной: настоящий бинарник на конфиге с `[auth.test_headrs]` поднимается
без единого предупреждения, `curl /app/me``401`, в журнале только
`INFO "Incoming request" ... http.status_code=401`.
- Последствие: `design.md` заявляет защиту словами «опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа», и защита эта
покрывает опечатку в имени **ключа**, но не в имени **секции**. Разница для
человека нулевая — он ошибается в обеих строках одинаково, — а исход
противоположный: в одном случае отказ старта с именем ключа, в другом
безмолвный `401` на всём приложении. Стоимость несёт каждый первый локальный
запуск, потому что подкоманды `devtools proxy` больше нет и обходного пути не
осталось. Отдельно: та же дыра проглатывает любую опечатку в **любой** секции
конфига, не только в новой.
- Предложение: развилка ниже.
- Найдено проходами: `specs` (S3), `ops` (O1, первое звено); оракул добыт триажем
заново.
- **Действие: развилка.**
> `toml.DecodeFile` возвращает `MetaData`, и в нём лежит перечень ключей,
> которых структура не знает. Сервис его отбрасывает, поэтому опечатка в имени
> секции проходит молча. Что делаем?
>
> 1. **Узкая правка в границах задачи:** при `[server] debug = true` и пустой
> секции имитации ронять старт (или писать `WARN`), если
> `MetaData.Undecoded()` называет что-нибудь под `auth`. Ловит ровно этот
> класс, не трогает остальной конфиг, требует одного нового абзаца в
> дельта-спеке.
> 2. **Правило шире задачи:** судить `MetaData.Undecoded()` на всём конфиге —
> непонятый ключ роняет старт с его именем. Закрывает опечатки во всех
> секциях разом, но это новая норма в `docs/conventions/config.md` и
> отдельный change: сегодняшние конфиги на сервере могут нести ключи,
> которых структура уже не знает (постмортем 2 подтвердил, что откат
> бинарника поверх нового конфига сегодня безопасен **именно** потому, что
> лишние ключи молча игнорируются, — правило это свойство снимет).
> 3. **Ничего не делать в коде**, а записать исход строкой в спеке и в
> `config.example.toml`: «опечатка в имени секции даёт `401` без следа;
> сверяйте имя секции по образцу». Дешевле всего и честнее молчания.
### 3. Спека объявляет матрицу отказов старта полной и называет три случая, а код даёт четвёртый: следующий change снимет его как самодеятельность, и одно из двух значений секции начнёт теряться молча
- Файл: `internal/config/test_headers.go:86-93`;
`openspec/changes/config-test-headers-login/specs/access/spec.md:109`;
`openspec/changes/config-test-headers-login/tasks.md:227-231`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Вход
`[server] debug = true` + `[auth.test_headers]` с `"Remote-User" = "one"` и
`"remote-user" = "two"`: декодер даёт `len(c.Auth.TestHeaders) == 2`,
`HeaderSubstitution()` возвращает `map[Remote-User:two]` — **значение `"one"`
потеряно**, — а `ValidateTestHeaders` роняет старт строкой
`auth: в секции [auth.test_headers] два ключа называют один заголовок: имя
заголовка нечувствительно к регистру, и одно из значений потерялось бы молча`.
Дословный текст спеки на строке 109: «Отказом MUST быть **каждый из трёх**
случаев»; сценария про совпадение канонических имён в дельте нет. Дословный
текст `tasks.md:227`: «Матрица отказов старта **полна и тотальна**: у каждой
комбинации новых ключей объявлен исход».
- Последствие: реализатор, читающий спеку как полную матрицу — а `tasks.md`
велит читать её именно так, — снимет четвёртую проверку как не подпёртую
требованием. После снятия конфиг с двумя ключами разного регистра поднимает
сервис, схлопывает их в один и молча берёт тот, который последним лёг в карту:
порядок перебора TOML не гарантирован, и человек получает то одно значение, то
другое от запуска к запуску. Это в точности класс «молчаливая потеря
значения», ради которого проверка и написана. Правка целиком в границах
задачи: дельта-спека — часть этого change.
- Предложение: дописать четвёртый случай в требование «Настройка, открывающая
вход всем, роняет старт» и завести к нему сценарий; поправить `tasks.md:227`
так, чтобы перечисление читалось «предохранитель × (пустая / заполненная /
заполненная негодно / заполненная неоднозначно)».
- Найдено проходом: `specs` (S1); оракул добыт триажем заново.
- **Действие: инлайн.**
---
## Стоит исправить сейчас
### 4. Бинарник читает `.env`, проект называет его секретосодержащим, а `.gitignore` его не игнорирует: первый же ключ, положенный туда, уезжает в git необратимо
- Файл: `.gitignore` (нет записи); `.dockerignore:21`;
`cmd/transcriber/main.go:114-117`
- Severity: major
- Confidence: high
- Оракул: **свои команды.** `git check-ignore -v .env` → пусто, `exit=1` (файл не
игнорируется). `.dockerignore:19-21` — комментарий «Настройки с секретами.
Образ берёт конфиг на сервере, а не из дерева.» и следом строка `.env`.
`cmd/transcriber/main.go:115``godotenv.Load()`. Прогон настоящего бинарника
печатает при каждом старте
`level=WARN msg="Warning: .env file not found, using system environment variables"`.
При этом `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ | grep -v _test`
**ни одного совпадения**: переменных окружения сервис не читает нигде.
- Последствие: инвариант `CLAUDE.md` — «**Секрет не покидает конфиг.** Ключ
SpeechKit и пара ключей Object Storage не попадают в git… Нарушение
необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки.
**critical**». Путь к нарушению короткий и сервис сам его подсказывает:
стартовая `WARN`-строка сообщает владельцу, что `.env` ожидается, тот заводит
файл с ключами — и `git add .` кладёт его в индекс, потому что
`.gitignore` про `.env` не знает. Единственный барьер — `gitleaks git --staged`
на pre-commit, а он судит по образцам и на произвольном ключе SpeechKit молчать
вправе. Сама подсказка при этом ложная: ключи из `.env` не сработали бы, читать
переменные окружения сервису нечем. **`critical` не ставлю:** пути до
настоящего секрета в git я не построил — работающего мотива положить ключ в
`.env` у владельца нет, — значит по контракту выше `major` эта находка не
поднимается.
- Предложение: строку `.env` в `.gitignore` рядом с `config.toml` — это одна
строка и весь инлайн. Мёртвый `godotenv.Load()` вместе с зависимостью и
вводящей в заблуждение `WARN`-строкой уходит задачей: правка не этого change.
- Найдено проходом: `adversary` (свойство без построенного пути); оракул и
переформулировка — триаж (проход утверждал, что `.env` читается, но не заметил,
что читать из него некому).
- **Действие: инлайн** — только строка в `.gitignore`. Снятие `godotenv`
задачей, см. «Урожай».
### 5. Чужой адрес почты, занятый первым пришедшим, навсегда и молча оставляет без почты его настоящего владельца — и `docs/security.md` утверждает обратное
- Файл: `internal/controller/http/identity.go:134-138`;
`internal/adapter/repo/sqlite/identity.go:77-110`; текст
`docs/security.md:71-76`
- Severity: major
- Confidence: high
- Оракул: **свой падающий сценарий, прогнан на настоящей поверхности сервиса**
(`setupEnv` + `AppChain`, база SQLite во временном каталоге, маршрут
`/app/me`). Два шага:
1. Запрос с `Remote-User: squatter` и `Remote-Email: victim@corp.example` с
доверенного адреса → `200`. `SELECT email FROM users WHERE provider_login='squatter'`
`victim@corp.example`.
2. Запрос настоящего владельца: `Remote-User: victim`,
`Remote-Email: victim@corp.example``200`, `Account created from login header`.
`SELECT email ... WHERE provider_login='victim'`**`""`**. Строк журнала о
занятом адресе — ноль.
Второй прогон показывает асимметрию правила прямо: два значения `Remote-User`
`401`; два значения `Remote-Email``200`, и в базу ложится **первое**
(`attacker-first@example.com`).
- Последствие: `docs/security.md:71-74` называет прокси, **добавляющий** заголовок
вместо замены, главной угрозой периметра и пишет: «Половину этой беды сервис
закрывает сам: запрос с двумя значениями `Remote-User` не узнаёт никого».
Утверждение верно ровно для одного заголовка из трёх. `Remote-Name` и
`Remote-Email` берутся `Get()` — первым значением, то есть присланным анонимом,
— и логин от них не защищает. Уникальный индекс `idx_users_email` не даёт
двум записям делить адрес, `EnsureUser` найденную запись не переписывает
никогда, а ветвь отката заводит настоящего владельца через
`insertUser(tx, login, name, "")` **без строки журнала**. Порча тихая и
постоянная: обратного пути у неё в коде нет. **Сегодня цена почти нулевая**
`contract.UserAccount` поля `Email` не имеет вовсе, приложение почту не
показывает, доступ по ней не выдаётся; поэтому не блокирует мердж. Цена
появляется в тот день, когда почту начнут читать (в бэклоге лежит
`api-tokens`), и к этому дню колонка уже будет испорчена молча.
- Предложение: судить `Remote-Name` и `Remote-Email` тем же правилом, что
`Remote-User``len(r.Header.Values(name)) != 1` даёт пустое значение, а не
первое; ветвь отката уникальности почты обязана оставить строку журнала
видимого уровня.
- Найдено проходом: `adversary` (V1); оракул добыт триажем заново, обоими
звеньями.
- **Действие: развилка.**
> Правка меняет боевой путь узнавания, а не отладочную подстановку, — то есть
> выходит за scope этой задачи и требует нового требования в спеке `access`
> (сегодня спека нормирует многозначность только для `Remote-User`). Что
> делаем?
>
> 1. **Взять в этот change:** три строки в `identity.go` + строка журнала в
> ветви отката + абзац в требование спеки. Логика ровно та же, что слой
> подстановки уже применил к себе («Запрос без `Remote-User`, но с
> `Remote-Email` иначе собрал бы личность из двух источников»), — правило
> просто не донесено до соседних двадцати строк.
> 2. **Отдельной задачей**, потому что предмет другой и цена сегодня нулевая:
> почту никто не читает. Тогда `docs/security.md:71-74` надо поправить в
> этом мердже — фраза «половину этой беды сервис закрывает сам» описывает
> периметр точнее, чем он есть, и ссылаться на неё нельзя.
> 3. **Только документ:** записать асимметрию как принятую цену, потребовав от
> прокси перезаписи всех трёх `Remote-*` (требование к контуру уже записано,
> см. «Недоступно проверке»), и колонку почты не трогать.
### 6. Страж, отменяющий слой подстановки, не защищён ни одной проверкой и считает «подставляем ли» вторым выражением: убрать его можно молча, а разойтись он может сам
- Файл: `internal/controller/http/server.go:27-49` (строка 44
`if len(substitution) > 0`); `internal/config/test_headers.go:33-43`;
`internal/controller/http/substitute_test.go:229-246`
- Severity: minor
- Confidence: high
- Оракул: чтение с двумя дословными сопоставлениями.
(1) Комментарий `server.go:30-31` утверждает: «Судит эту пустоту точка входа:
перечень приходит сюда уже готовым», — а строка 44 судит её второй раз своим
выражением. Тождество двух формулировок (`c.Server.Debug && len(TestHeaders) > 0`
против `len(substitution) > 0`) сегодня держится только тем, что
`HeaderSubstitution` возвращает `nil` в ложной ветке; ничем другим оно не
закреплено.
(2) Единственная проверка на пустую имитацию — `TestEmptySectionLeavesHeadersUntouched`
— судит три вещи: код `401`, неизменное число учётных записей и отсутствие в
журнале строки `"Identity headers are not substituted"`. Строка эта пишется
**только** в ветке недоверенного пира (`substitute.go:80`), а запрос проверки
идёт с `trustedPeer`, — значит утверждение истинно при любом исходе. Первые два
утверждения тоже истинны при снятом страже: заголовка `Remote-User` в запросе
нет, слой удалил бы `Remote-Email` и пропустил дальше, узнавание всё равно
ответило бы `401` и записи бы не завело.
Мутационную сверку — снять страж и убедиться, что проверка зеленеет, — не
делал: `CLAUDE.md`, «Запреты», прямо запрещает мутационную сверку оракулов.
- Последствие: сценарий дельта-спеки «Пустая секция имитации ничего не трогает»
не проверен ничем, хотя в наборе проверок выглядит проверенным, — а именно к
этому сценарию спека привязывает своё «поведение, которого в бою нет»
(срезанный `Remote-Email` у запроса без `Remote-User`). Дубль предиката при
этом расходится в первой же правке, дающей непустой признак при пустой карте:
исход будет «старт предупреждает о подстановке, а слоя в цепочке нет» — и
предупреждение соврёт.
- Предложение: `AppChain` принимает вторым значением сам предикат
(`substituting bool`) вместо того, чтобы выводить его из длины карты; проверка
судит **дошедшее до узнавания** — что `Remote-Email`, посланный запросом,
доехал до обработчика нетронутым, — а не журнал.
- Найдено проходами: `code/техника` (C1), `architecture` (R3), `specs`
(замечание про два места).
- **Действие: инлайн.**
### 7. Два нормативных документа описывают изъятие мягче, чем оно есть: паспорт обосновывает его доводом, который в изъятии не действует, а принцип архитектуры запрещает ровно то, что сам приводит примером
- Файл: `docs/passport.md:65-75`; `docs/architecture.md:84-91`
- Severity: minor
- Confidence: high
- Оракул: дословное сопоставление внутри одного изменения.
(1) `docs/passport.md:73` — «подставленное имя проходит то же узнавание, что и
пришедшее, **а кого пускать, по-прежнему решает провайдер**». Дельта-спека в
том же изменении: «Подстановка — это настройка, которой сервис называет
пришедшего сам, **никого не спросив**» (`substitute.go:32-33` теми же словами).
Провайдера в изъятии нет вовсе. Там же паспорт пишет «*Изъятие одно, и оно про
прогон без контура*» — код таким не ограничен: подстановка работает везде, где
адрес пира доверенный, и `docs/security.md:97-102` это признаёт прямо
(«Боевая поломка машиной не исключена»). Два документа одного изменения
расходятся об одном изъятии.
(2) `docs/architecture.md:84-91` — заголовок нормы «Подставной собеседник
живёт **в коде или в оснастке**», тело — «только под ключом, названным своим
предметом, — **не под общим словом вроде «режим отладки»**», и следом
собственным примером норма называет случай, который живёт в боевом бинарнике и
стоит под `[server] debug`. Единственная операционная формулировка изъятия —
закрытый перечень следствий — живёт в спеке, и принцип на неё не ссылается.
- Последствие: `docs/passport.md` — единственный дом границы домена, и `CLAUDE.md`
велит читать его перед задачей. Записанная так граница перестаёт исключать то,
ради чего заведена: следующая задача (в бэклоге лежит `api-tokens`) сможет
сослаться на строку «кого пускать, решает провайдер» как на действующее
ограничение, которого нет. Норма архитектуры, обратно, читается как «шипящая
реализация нарушает наш собственный принцип» — а ключ `[server] debug`
владелец подтвердил дважды и переоткрывать его нельзя; значит расходится норма,
а не код. `docs/review.md` при этом уже объявил пробел закрытым этой нормой,
то есть на неё уже сослались.
- Предложение: в паспорте заменить довод на верный («сервис называет пришедшего
сам; границы домена это не двигает, потому что учётных записей он по-прежнему
не заводит и допуска не проверяет — заведение строки первым обращением
остаётся зеркалированием») и снять слова «оно про прогон без контура». В
`docs/architecture.md` свести принцип к связывающему правилу со ссылкой на
требование спеки (закрытый перечень следствий предохранителя), не повторяя
перечень второй копией.
- Найдено проходом: `architecture` (R1, R2).
- **Действие: инлайн** (две правки, оба файла — документация).
---
## Гипотезы без доказательства
Понижены: оракула на этом прогоне триаж не добыл — по бюджету (одна попытка на
находку ушла на `critical`/`major`) либо по запрету проекта.
### Имя копии в каталоге данных выводится из идентификатора записи одним инкрементом
- Файл: `internal/ident/ident.go:79-99`; `internal/service/transcribe.go:176-201`
- Было: minor / high (`adversary`, V2). **Осталось minor:** оракул у прохода
есть (`TestProbeStorageNameDerivesFromRecordID`, `TestProbeDerivationRate`
49 из 50 приёмов подряд), но триаж его не воспроизводил, а согласие одного
прохода с самим собой подтверждением не является.
- Последствие: инвариант `CLAUDE.md` «Имя файла на диске задаёт сервис, а в
журнал не идёт… строка журнала иначе стала бы бессрочным ключом к чужой
записи» соблюдён по букве и не по назначению: `record_id` и `file_ext`
присутствуют в журнале порознь и дают имя арифметикой. Доступа это не даёт —
карточка и файл сужены владельцем, — поэтому не выше minor.
- Предложение: случайное положительное приращение вместо единицы либо снять
посылку из инварианта. Уходит задачей.
### Управляющий знак в расширении имени даёт отправителю `500`, а владельцу — `ERROR`, неотличимый от аварии хранилища
- Файл: `internal/service/transcribe.go:171-188`;
`internal/adapter/repo/sqlite/file_repo.go:56-67`
- Было: minor / high (`adversary`, V3). **Осталось minor:** падающий тест у
прохода есть, триаж его не воспроизводил.
- Последствие: комментарий `transcribe.go:171-175` объявляет этот исход
недопустимым и ради него заведён `maxExtLen`; разрез сделан по длине и не
сделан по составу знаков. Побочно `newWorkFile` не снимает путь с
`*os.PathError`, и путь во временном каталоге уезжает в строку `ERROR`.
- Предложение: судить состав знаков расширения тем же приёмом, что и длину.
Уходит задачей.
### Текст отказа провайдера уезжает в `error_text` и в журнал целиком, а самый ожидаемый его вид несёт `sourceURI` — бакет и ключ объекта
- Файл: не локализован проходом точнее уровня «ветвь отказа распознавания»
- Было: minor / medium, **оракула нет и быть не может на этом прогоне**:
настоящий SpeechKit нужен, а `CLAUDE.md`, «Запреты», прогон на реальных ключах
запрещает («Yandex Cloud за деньги»). Строка живёт в
`docs/review.md`, «Недоступно проверке», первым списком.
- Последствие (условное): ключ объекта в `error_text` виден владельцу записи
карточкой; инвариант «Содержимое записи остаётся приватным» им прямо не
нарушен, но перечень того, что уходит в текст ошибки, ничем не ограничен.
- Предложение: задачей — ограничить текст внешнего отказа перечнем полей, а не
пересказывать его дословно.
### Требования «прокси обязан дописывать `X-Forwarded-For`» нигде не записано, в отличие от `Remote-*`
- Файл: `docs/security.md:103-113`
- Было: minor / medium (`adversary`). **Оракула нет:** правило живёт в
`files/caddyproxy/Caddyfile.template` репозитория `pet-project-server`, и
`docs/review.md`, «Недоступно проверке», относит поведение обратного прокси к
тому, чего не проверит ни один проход.
- Последствие (условное): `docs/security.md` описывает чтение цепочки
справа налево словами «правое приписал ближайший к нам прокси» — это
предположение о прокси, а не требование к нему. Прокси, который `X-Forwarded-For`
не дописывает вовсе, оставляет ключ бюджета ограничителя целиком в руках
спрашивающего.
- Предложение: задачей — дописать требование к контуру рядом с уже записанным
требованием про `Remote-*`.
---
## Promote candidates
- **Правило «`internal/config` не знает транспорта» механизируемо, но не
механизировано.** Норма записана этим же изменением
(`docs/conventions/config.md`, «Проверка, охватывающая две секции разом»), а
`grep -n "config" internal/archrules/arch_test.go` не даёт ни одного
совпадения: сканера у правила нет. Кандидат — правило `internal/archrules` по
нетестовым файлам пакета. Предложил проход `architecture`.
**Оговорка к механизации:** `internal/config/test_headers_test.go:1-8`
объявлен `package config` и импортирует `internal/controller/http` — то самое
ребро. В рабочем бинарнике его нет, но `go test ./internal/config` линкует всю
поверхность HTTP, а будущая надобность конфига в транспорте даст цикл импорта
на уровне проверок. Правило по нетестовым файлам этот файл пропустит; правило
по всем — уронит гейт до переноса файла во внешний `package config_test`.
- **`errors.Join` в проверках конфига.** `docs/conventions/errors.md`,
«Несколько ошибок», называет проверку конфига поимённо и требует «все проблемы
разом»; `ValidateTestHeaders` возвращается на первом несовпадении, записи
«*Расхождение:*» у пункта нет. Это претензия на правило, а не на этот код:
проверки остальных секций ведут себя так же. Предложил проход `code` (C5).
- **Сверка `toml.MetaData.Undecoded()` как норма конфига.** Вариант 2 развилки
находки 2. Если владелец выберет узкую правку, кандидат остаётся здесь:
непонятый ключ в любой секции сегодня проходит молча.
---
## Границы покрытия
### План: темы, глубины, дома
Воспроизведён таблицей в сводке выше. Дома: `openspec/specs/access/spec.md` +
дельта (`requirements`, разбор); `CLAUDE.md`, «Гейт» (`autotests`);
`docs/conventions/` целиком (`conventions`, разбор); `docs/architecture.md` +
источник `docs/passport.md` (`architecture`, доказательство); `docs/security.md`,
«Периметр» (`security`, доказательство); `docs/architecture.md`, «Эксплуатация» +
источник `docs/database.md` (`operations`, доказательство). **Тема без дома
одна** — «тема проекта»: своих тем сверх ядра проект не завёл, и это состояние
плана, а не пропуск.
### Какие проходы запускались
Метка `large`, режим «по графу». Запускались шесть: `autotests`, `specs`, `code`,
`architecture`, `adversary`, `ops`. Триаж — седьмой.
### Какие не запускались и почему
- `basics`**по метке**: на `large` он не запускается, потому что все темы
ядра разобраны именными проходами, а своих тем у проекта нет. Прямое следствие:
второго независимого голоса о заниженности метки нет — сигнал пришёл только от
`review-code`.
- Проход независимой реализации — снят из конвейера по стоимости.
- Проход про идиоматичность — упразднён.
### Чего каждый запущенный проход не мог проверить в принципе
- `autotests` — судит гейт и покрытие, но не судит, **что** проверяют зелёные
проверки: находка 6 (проверка, которая не может упасть) из его charter'а не
видна, её принёс `code`.
- `specs` — судит соответствие кода дельте и полноту дельты; не судит, работает
ли требование в настоящем прогоне (потому S4 у него остался рассуждением, а
оракул добыл триаж).
- `code` — судит записанные конвенции и технику; периметра и эксплуатации не
касается.
- `architecture` — судит направления зависимостей и нормы домов; карту графа
собирал **вручную**, подготовленной команды у проекта нет.
- `adversary` — строит пути; поведения настоящих внешних собеседников не
проверяет вовсе.
- `ops` — судит эксплуатацию по замерам своего прогона; профиля настоящей
нагрузки у него нет (проект работает на единицах записей в день).
- **Триаж (этот проход)** — ничего нового не находит по определению: работает с
чужими выводами, кода в поисках дефектов не читает. Пропуск любого прохода —
пропуск триажа тоже, и единственное средство против него — поимённая сверка
плана выше.
### Что осталось целиком на человеке
**Не проверит ни один проход** (`docs/review.md`, «Недоступно проверке», первый
список — воспроизводится отдельно от второго намеренно):
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
сервиса — **от прокси зависит весь барьер**, он обязан заголовки `Remote-*`
перезаписывать, а не пропускать пришедшие; правило живёт в
`pet-project-server`. **Прямо относится к находке 5:** сценарий
дописывающего прокси проверить отсюда нечем, а асимметричную реакцию сервиса
на него — можно, и она проверена;
- `security`: поведение браузера с куками (класс пуст с 2026-08-22, строка стоит
как маркер).
**Перестали проверять сознательно** (второй список, тот же раздел):
- `autotests`: разбор вывода настоящего `ffprobe` — проверки получают
длительность от подставного источника; решение и цена в
`adr/ADR-2026-08-11-stub-adapters-in-tests.md`;
- работа сервиса с настоящими внешними собеседниками. Сам сервис поднять можно и
этот прогон его поднимал; **остаток** — за настоящие SpeechKit и Object
Storage живой прогон не отвечает, ключи выдуманные, распознавание подменяется
в коде. Вход живой прогон с 2026-08-22 проверяет целиком, и раздел уже
переписан этой задачей: подставного прокси нет, заголовок ставит сам сервис.
**Общее, вне обоих списков:** история инцидентов, поведение под реальным
потоком, поведение внешних систем в их версиях, завязка потребителей на текущее
поведение и вопрос «а нужна ли эта функциональность вообще» — ничем из
перечисленного конвейер не занимался.
### Четыре строки, которых не принесёт ни один проход
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
его не открывает. Расхождение изменения с записанным решением ловит скилл
`av-dev:doc-healthcheck`, а не ревью. В этом изменении есть чему разойтись:
`ADR-2026-08-22-login-by-trusted-header.md` описывает вход, к которому
заведено изъятие, и сверен с ним никем не был.
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
процессный. Всякое число в отчёте снято проходом на этом прогоне: бенчмарк
`BenchmarkSubstituteIdentityHeaders` (2972 ns/op, 5718 B/op, 17 allocs/op) —
замер прохода `ops`, покрытие (`test_headers.go` 100 %,
`SubstituteIdentityHeaders` 94,4 %, `SubstitutedHeaderNames` 0,0 %) — вывод
`go tool cover -func` прохода `autotests`, остальные числа — прогоны триажа с
приложенными командами.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
проходом.** Различение «идиоматично против распространено» не спрашивает
никто с тех пор, как упразднён проход про идиоматичность.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру; «не
знаю, чего не знаю» на этом прогоне не доставал никто.
Метка — `large`, поэтому пятой строки (сужение `security`/`operations`/
`architecture` до записанных инвариантов) здесь нет: все три темы разобраны
именными проходами на глубине «доказательство».
### Каких документов проекта не хватило
- **`docs/review.md`, «Типовые ложноположительные» — есть и непуст**, прочитан
целиком (5 записей, две из них отменённые намеренно). Ни одна находка этого
прогона под них не подпадает; отсев шёл и по проектному входу, и по общим
критериям.
- **Раздела инвариантов `CLAUDE.md` хватило.** На него опираются находки 4
(«Секрет не покидает конфиг», **critical**, необратимо) и гипотеза про имя
копии («Имя файла на диске задаёт сервис, а в журнал не идёт», **critical**).
Ранжирование по обратимости взято оттуда же, а не выведено из кода.
- **Не хватило: у `[server] debug` нет дома, называющего исчерпывающий перечень
его следствий в одном месте.** Перечень объявлен трижды — комментарием
`internal/config/config.go:65-71`, комментарием `config.example.toml:7-13` и
требованием дельта-спеки, — и три копии уже расходятся: комментарий конфига
называет пять исключённых следствий, образец — четыре (в нём нет «проверок
старта»). Строка о том, где перечень канонический, отсутствует, и триаж
выбирал спеку по общему правилу «спека нормативна», а не по записанному
указателю.
- **Не хватило: `docs/conventions/logging.md` не называет, каким уровнем
пользоваться, когда отладочный недостижим.** Уровень журнала у сервиса зашит,
ключа у него нет, и конвенция про это молчит — поэтому находка 1 подана
развилкой, а не инлайном: своего основания выбрать вариант у триажа нет.
- **Оракул из журнала дефектов не применялся:** ни одна запись журнала
`docs/review.md` не описывает класс, совпадающий с находками этого прогона.
Ближайшая — «ключ бюджета ограничителя выбирал тот, кого ограничивают»
(2026-08-23) — соседствует с гипотезой про `X-Forwarded-For`, но предмет у неё
другой.
### Сработавшие потолки
- **По проходам: ни один не сообщил свой потолок.** Ни `autotests` (1 находка),
ни `specs` (4), ни `code` (5), ни `architecture` (3 + 2 замечания), ни
`adversary` (3 + 3 свойства), ни `ops` (1) не сказали, каков был их предел и
что осталось за срезом. Это находка о прогоне, вынесенная в сводку отдельной
строкой; закрыть её отсюда нечем.
- **Потолок триажа сработал.** На входе 21 находка, в первые две секции влезло
7. **Не влезло и здесь названо поимённо:**
- `A1`/`S2``SubstitutedHeaderNames` не вызвана ни одним тестом
(`go tool cover -func` → 0,0 %). Триаж добыл ей однократный оракул: прогон
настоящего бинарника печатает `headers=[Remote-User]` — имена, не значения,
— то есть **дефекта сегодня нет**, есть отсутствие защиты от регрессии
против MUST NOT дельта-спеки («строка старта MUST не нести значений»).
Понижено до minor, инлайн-правка на один тест, если у оркестратора останется
бюджет;
- `C3` — умолчания `Debug` и `TestHeaders` заданы нулём типа, а не в
`defaultConfig()`. Нарушена записанная конвенция
`docs/conventions/config.md:181-191`: «Умолчания задаются в `defaultConfig()`
Новое поле требует правки обоих мест», и там же форма обязательного поля —
«умолчания нет ни в `defaultConfig()`, ни по нулевому значению типа». Форма
записи необязательного поля стала неотличима от формы обязательного. `nit`,
инлайн;
- `C4``internal/config/test_headers_test.go` объявлен `package config` и
импортирует `internal/controller/http`, ребро запрещено той же правкой в
`docs/conventions/config.md`. `nit`; отражён оговоркой в Promote candidates,
потому что чинить его в одиночку смысла меньше, чем вместе со сканером;
- `C5``ValidateTestHeaders` возвращается на первом несовпадении вместо
`errors.Join`. Уехал в Promote candidates: правило шире этого кода;
- `C2` — комментарий `Dockerfile:49-50` объясняет исключение `cmd/devtools`
словами «оснастка разработчика (подставной прокси)», а подкоманды больше нет;
приёмочный шаг `tasks.md:88` искал следы по строке `devtools proxy` и эту не
поймал. Плюс `internal/controller/http/mounts.go:54-56` перечисляет три звена
цепочки, тогда как `AppChain` одевает четыре. `nit`, инлайн, две строки;
- **расхождение записи имени ключа**: `[auth] test_headers` в дельта-спеке,
`docs/security.md`, `docs/architecture.md` и `docs/review.md` против
`[auth.test_headers]` в `config.example.toml`, `README.md`, `CLAUDE.md`,
`docs/conventions/config.md` и в текстах отказов старта самого кода
(`test_headers.go:16`). Человек, ищущий в конфиге то, что назвал отказ
старта, найдёт вторую форму; первая в файле не встречается вовсе. **Не
находка этого прохода:** `CLAUDE.md`, «Гейт», относит согласованность
документов между собой и с кодом к скиллу `av-dev:doc-healthcheck`. Названо
здесь, чтобы не потерялось;
- **рецепт локального входа записан четырьмя копиями**`config.example.toml`
(канон), `README.md:48-61`, `CLAUDE.md:276-288`,
`docs/conventions/config.md:66-75`. Замечание `architecture` «дешевле
переделать до мерджа»; по той же причине уходит в `doc-healthcheck`;
- границы прохода `specs`, перенесённые без изменений: значения
`Remote-Name`/`Remote-Email` в секции имитации на старте не судятся ничем
(годность проверяется только у логина); `strings.TrimSpace` над именем ключа
— поведение сверх спеки, последствий у него не найдено.
### Урожай враждебного прохода
Пути обхода **самой подстановки** — предмета этого изменения — не построено:
шесть отрицательных проб (два значения `Remote-User` при включённой подстановке
`401`; чужие `remote-email`/`remote-name` в нижнем регистре на живом сервере →
слой владеет тройкой целиком; сверка адреса идёт той же функцией, что у
узнавания; неразобранный `RemoteAddr` → отказ подстановки; канонизация ключей
против регистра, NBSP, ZWSP → старт падает; у `[server] debug` второго
потребителя нет). Состояния «подстановка работает, а владелец не знает» не
найдено. **Это положительный исход по теме, а не отсутствие работы.**
Построенное легло на соседнюю поверхность. Судьба каждой:
| Находка | Чинится этим мерджем? | Куда |
|---|---|---|
| V1 — почта берётся первым значением, сквоттинг молчит | **развилка**, вариант 1 берёт в мердж | находка 5 |
| `.env` не игнорируется git | **да**, одной строкой | находка 4 (инлайн) |
| `godotenv.Load()` мёртв (ничто не читает переменные окружения), а `WARN` на каждом старте приглашает завести `.env` | нет | **задачей**: снять зависимость `github.com/joho/godotenv` из `go.mod`, четыре строки из `cmd/transcriber/main.go:114-117` и строку `.env` из `.dockerignore` — либо, если переменные окружения нужны, завести им читателя. Оракул для задачи: `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ \| grep -v _test` → пусто; прогон бинарника печатает `level=WARN msg="Warning: .env file not found, using system environment variables"` |
| V2 — имя копии выводится из `record_id` инкрементом | нет | **задачей**, гипотеза выше; оракулы прохода названы поимённо |
| V3 — управляющий знак в расширении → `500` и `ERROR` | нет | **задачей**, гипотеза выше |
| Текст отказа провайдера с `sourceURI` в `error_text` | нет | **задачей**, оракула нет и на этом прогоне быть не может (запрет на реальный SpeechKit) |
| Требование к прокси про `X-Forwarded-For` не записано | нет | **задачей** в `docs/security.md`, рядом с уже записанным требованием про `Remote-*` |
Заведение задач — не работа триажа; формулировки выше отданы с оракулами, чтобы
ни одна не потерялась при переносе.
### Оракулы, добытые триажем
Все тесты писались во временные файлы пакетов и **удалены** после прогона;
рабочее дерево на момент сдачи отчёта содержит ровно те изменения, что были на
входе (`git status --short` сверен). Прогоны бинарника шли в каталог скретчпада,
не в `data/`; поднятый сервис остановлен за собой (`pkill`), порт освобождён —
правило `docs/review.md:16-23`.
1. `go test ./internal/config/ -run TestTriage -v` — три случая разбора TOML
(дубль по регистру, опечатка в имени секции, опечатка в имени ключа).
2. `go test ./internal/controller/http/ -run TestTriage -v` — сквозной сквоттинг
почты на настоящей поверхности `AppChain` и асимметрия правила многозначности.
3. `go build -o <скретчпад>/transcriber ./cmd/transcriber` + два прогона
настоящего бинарника на порту 18099 с каталогами данных в скретчпаде: конфиг
с опечаткой в имени секции и конфиг правильный.
4. `git check-ignore -v .env`; `grep -rn "\.Debug(" --include=*.go internal/ cmd/`;
`grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/`;
`grep -n "config" internal/archrules/arch_test.go`.
Гейт триаж не перезапускал: его исход взят у прохода `autotests` как замер,
снятый на этом прогоне.
@@ -0,0 +1,508 @@
## ADDED Requirements
### Requirement: Отладочный запуск называет пришедшего настройками
Сервис SHALL подставлять запросу заголовки входа значениями из секции
`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST
делать это так, чтобы узнавание не отличало подставленный заголовок от
поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана
быть той же, что работает в бою: отладке подлежит боевой путь, а не его
отладочный двойник.
Подстановка MUST происходить только при **отсутствии** заголовка `Remote-User` в
запросе. Заголовок, пришедший в любом числе значений, MUST оставаться нетронутым:
узнавание отвергает запрос с более чем одним значением, и подстановка, дописавшая
второе, превратила бы законный отказ в проход.
Подстановка MUST происходить только тогда, когда адрес соединения попадает в
перечень `[auth] trusted_proxies`, и адрес этот MUST судиться **той же
функцией**, какой судит его узнавание: и разбор адреса пира, и сверка с
перечнем берутся из одного дома, а не пишутся вторым списком. Второй перечень
разошёлся бы с первым молча — так же, как разошлись бы два списка имён
заголовков. Второго барьера у подстановки нет и не заводится: круг тех, кто
вправе назвать пришедшего, уже очерчен этим перечнем.
Подстановка MUST действовать только при **непустой** секции имитации. Пустая
секция значит «подставлять нечего»: цепочка слоёв при ней та же, что при
выключенном предохранителе, и ни одного заголовка запроса слой не трогает.
Иначе включённый предохранитель при пустой секции срезал бы `Remote-Email` у
запроса, пришедшего без `Remote-User`, — поведение, которого в бою нет.
Подставляя, сервис MUST распоряжаться всей тройкой заголовков `Remote-*`
целиком: названные секцией ставятся её значением, не названные удаляются.
Запрос без `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
источников — логин свой, почту чужую.
Подстановка MUST действовать только под корнем приложения — там же, где действует
узнавание. Проба здоровья, метрики и раздача приложения её не видят.
#### Scenario: Запрос без заголовка входа получает имя из настроек
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса без единого заголовка `Remote-*`
- **THEN** сервис узнаёт пришедшего под именем из секции
- **AND** учётная запись заводится, если её не было
#### Scenario: Пришедший заголовок не подменяется
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса с заголовком `Remote-User`
- **THEN** узнавание получает значение из запроса, а не из настроек
#### Scenario: Два значения заголовка остаются отказом
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с двумя значениями заголовка `Remote-User`
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Недоверенный адрес имени не получает
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос без заголовка приходит с адреса вне перечня доверенных
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Чужой заголовок почты не смешивается со своим логином
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и не
называет `Remote-Email`
- **WHEN** запрос приходит без `Remote-User`, но с заголовком `Remote-Email`
- **THEN** узнавание получает логин из настроек и не получает адреса почты вовсе
#### Scenario: Пустая секция имитации заголовков не трогает
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без `Remote-User`, но с
заголовком `Remote-Email`
- **THEN** слой заголовков не трогает, и `Remote-Email` доходит до узнавания
нетронутым
#### Scenario: Выключенный предохранитель имён не раздаёт
- **GIVEN** предохранитель выключен, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без заголовка `Remote-User`
- **THEN** запрос остаётся неузнанным
#### Scenario: Наблюдение подстановки не видит
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ тот же, что и при выключенном предохранителе
### Requirement: Настройка, открывающая вход всем, роняет старт
Сервис SHALL отказываться подниматься на настройках, при которых отладочный вход
становится открытым входом, и MUST называть в отказе имя ключа. Проверка идёт на
старте, до приёма трафика: настройка, отданная на честность выкладки, проверяется
только тем, что чужой архив уже уехал не тому.
Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server]
debug` MUST читаться как выключенный предохранитель, а отсутствие секции
`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый
ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST
кончаться отказом старта, а не прочтением «включено».
Секция имитации MUST считаться **непустой**, как только в ней назван хотя бы
один ключ, каким бы ни было его значение. `Remote-User = ""` — заполненная
секция, а не пустая: человек, написавший ключ, имитацию завёл, и пустое значение
у него — вторая поломка, а не отсутствие первой.
Отказом MUST быть каждый из четырёх случаев.
Первый — секция имитации заполнена при выключенном предохранителе. Состояние это
не читается никак: либо человек забыл включить предохранитель и будет искать
поломку везде, кроме одного ключа, либо забыл убрать имитацию из боевого файла — и
тогда до открытого архива остаётся одно слово. Молчаливое игнорирование и
предупреждение в журнале оба оставляют вторую поломку жить.
Второй — секция имитации называет ключ, не совпадающий ни с одним заголовком
входа, который сервис читает. Перечень принимаемых имён MUST порождаться теми же
именами заголовков, которыми пользуется узнавание, а не перечисляться вторым
списком; сравнение MUST идти по каноническому виду имени, потому что в HTTP имя
нечувствительно к регистру, а ключ конфига чувствителен. Опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа.
Третий — секция имитации непуста, а годного `Remote-User` в ней нет. Ключ
логина MUST быть назван: секция, называющая один `Remote-Email`, поднимает
сервис, который подставит почту, удалит логин и не узнает никого. Значение
логина MUST проходить **тот же приём**, каким узнавание судит пришедшее
значение (`internal/entity.AcceptProviderLogin`): пустое, из одних пробельных
знаков, сверх предела длины и с управляющими знаками — отказ старта. Иначе
сервис поднимается, ставит заголовок, получает отказ приёма и отвечает
неузнанным на всё, оставляя за собой одну отладочную строку. Оба состояния — то
самое «не читается никак», ради которого заведён первый случай.
Четвёртый — два ключа секции дают одно каноническое имя заголовка. `Remote-User`
и `remote-user` для TOML — два ключа, для HTTP — одно имя: сервис подставил бы
одно значение, а второе потерял бы молча, и человек искал бы поломку в значении,
которого сервис не читал вовсе. Отказ MUST называть секцию и причину, а значений
MUST не называть.
Включённый предохранитель при пустой секции имитации отказом MUST не быть: сам по
себе он ничего не включает.
#### Scenario: Имитация без предохранителя не поднимается
- **GIVEN** секция имитации заполнена, предохранитель выключен
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Неизвестное имя заголовка не поднимается
- **GIVEN** предохранитель включён, секция имитации называет ключ, которого нет
среди читаемых заголовков входа
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет принимаемые имена
#### Scenario: Имитация без ключа логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет только
`Remote-Email`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя недостающего ключа логина
#### Scenario: Два ключа одного заголовка не поднимаются
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и
`remote-user`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет секцию, в которой два ключа дали одно имя
заголовка
#### Scenario: Негодное значение логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
значением в 300 знаков
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа, а не его значение
#### Scenario: Пустое значение логина считается заполненной имитацией
- **GIVEN** предохранитель выключен, секция имитации называет `Remote-User = ""`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Конфиг без ключа предохранителя ведёт себя как с выключенным
- **GIVEN** конфиг не называет ни `[server] debug`, ни секции имитации
- **WHEN** сервис запускают
- **THEN** сервис поднимается, подстановки не заводит и никого сам не называет
#### Scenario: Предохранитель без имитации поднимается
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис запускают
- **THEN** сервис поднимается
### Requirement: Отладочный запуск виден в журнале
Сервис SHALL сообщать владельцу о включённой подстановке заголовков строкой
журнала при старте и MUST не печатать при этом ни одного подставляемого значения.
Сервис, называющий пришедшего сам, — это ровно то «может стать проблемой», ради
которого заведён предупреждающий уровень: строка старта идёт на `WARN`.
Строка старта MUST нести имена подставляемых заголовков и MUST не нести их
значений. Логин — ключ к чужому архиву, и запрет на печать значения, дающего
доступ, действует на подставленное значение наравне с пришедшим.
Запрос, которому заголовки подставлены, SHALL писаться на уровне `INFO`:
этой строкой «узнан подстановкой» отличается от «узнан прокси». Боевой журнал
она не топит по построению — пишется только там, где подстановка работает, а в
бою предохранитель выключен умолчанием; на отладочном уровне её не увидел бы
никто, потому что настройки под уровень журнала у сервиса нет и он зашит `INFO`.
Строка MUST нести адрес пира и не нести подставленных значений.
Запрос, которому подставить нельзя из-за недоверенного адреса, MUST оставлять
**свою** строку предупреждающим уровнем. Узнавание на этом месте предупреждения
не пишет: подстановка работает при отсутствии `Remote-User`, а отсутствие
заголовка узнавание считает случаем штатным и пишет о нём отладочную строку;
предупреждение о недоверенном адресе оно бережёт для заголовка, который
**пришёл**. Без своей строки самый частый локальный отказ — браузер пришёл
с `::1`, а перечень называет `127.0.0.1` — не отличим от поломки узнавания:
человек видит `401` на всём приложении, заполненную секцию имитации и
включённый предохранитель. Строка MUST нести адрес пира и MUST не нести
подставляемых значений.
#### Scenario: Старт с подстановкой предупреждает владельца
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** сервис поднимается
- **THEN** журнал несёт предупреждение с именами подставляемых заголовков
#### Scenario: Подставленного значения нет в журнале
- **GIVEN** предохранитель включён, секция имитации называет логин и адрес почты
- **WHEN** сервис поднимается и принимает запрос без заголовка
- **THEN** ни логин, ни адрес почты не встречаются ни в одной журнальной записи
#### Scenario: Отказ подстановки на недоверенном адресе виден в журнале
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос без заголовка `Remote-User` приходит с адреса вне перечня
доверенных
- **THEN** журнал несёт предупреждение с адресом пира
- **AND** подставляемых значений в строке нет
#### Scenario: Подстановка на запросе видна при боевом уровне журнала
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос с доверенного адреса получает подставленные заголовки
- **THEN** строка об этом имеет уровень `INFO` и видна журналу, настроенному
по-боевому
### Requirement: Предохранитель отладки включает только подстановку заголовков
Предохранитель `[server] debug` SHALL менять одно поведение сервиса — подстановку
заголовков входа — и MUST не менять никакого другого. Перечень следствий закрыт, и
новое следствие вешается на этот ключ только отдельным требованием спеки: ключ,
названный общим словом, иначе обрастает всем подряд, и выключить его перестаёт
означать «сервис ведёт себя как в бою».
Сам по себе включённый предохранитель не включает и подстановки: она MUST
действовать только при непустой секции имитации, и при пустой цепочка слоёв MUST
быть та же, что при выключенном предохранителе, — требование «Отладочный запуск
называет пришедшего настройками».
Включённый предохранитель MUST не менять уровень журнала, не добавлять в ответы
тексты внутренних отказов и следы стека, не выводить тела запросов и ответов
внешних сервисов, не снимать и не ослаблять ограничитель частоты, не подменять
распознаватель, не ослаблять ни одной проверки старта и не открывать неузнанному
ни одного адреса.
#### Scenario: Включённый предохранитель без имитации ничего не меняет
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис принимает запросы
- **THEN** он ведёт себя ровно так же, как с выключенным предохранителем
#### Scenario: Ограничитель частоты работает и в отладочном запуске
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запросы идут чаще дозволенного
- **THEN** ограничитель частоты режет их так же, как при выключенном
предохранителе
## MODIFIED Requirements
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
сервиса — настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и правило, настроенное слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
жизни этого значения. Теперь отзыв действует со следующего запроса.
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` доверенным источником становятся настройки сервиса: сервис
называет пришедшего сам, никого не спросив. Это единственное место, где имя
берётся не от провайдера. Требования к нему стоят выше отдельными строками, и
снимать их поодиночке нельзя: барьер держится всеми разом.
Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию
выключен, а заполненная имитация без него роняет старт. Боевой перечень
доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с
включённым предохранителем и заполненной имитацией, назовёт своим именем всякого,
чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным
путём.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
прокси
- **THEN** доступ открывается, а учётная запись заводится, если её не было
- **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа
и адреса почты
#### Scenario: Отзыв у провайдера действует со следующего запроса
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
#### Scenario: Без отладочного запуска имя приходит только от провайдера
- **GIVEN** предохранитель отладки выключен
- **WHEN** запрос приходит с доверенного адреса без заголовка
- **THEN** сервис никого не узнаёт и своего имени пришедшему не назначает
### Requirement: Пришедшего называет доверенный источник
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` заголовок входа ставит не прокси, а сам сервис значением из
секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает. Условия, при которых источник этот законен,
и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск
называет пришедшего настройками», «Настройка, открывающая вход всем, роняет
старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает
только подстановку заголовков». Держится изъятие умолчанием предохранителя
«выключено» и отказом старта при заполненной имитации без него; боевой перечень
доверенных адресов включению предохранителя не мешает.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
барьер вовсе.
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
требованием учётной записи на адресах приложения.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а
на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало
бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы
учётные записи, которые потом не убираются ничем.
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
Область — корень приложения; она MUST выводиться из объявленного адресного
пространства сервиса, а не перечисляться вторым списком. Прежде область была
шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни
адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность
хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в
коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой
нет, и вычитать больше нечего.
Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание
MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения.
Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос
с новым именем — записи в неё.
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
без этой нормы все неназванные собрались бы в одну учётную запись с общим
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
бы вход анониму. Значение сверх объявленного предела длины и значение с
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
считает колонка.
Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
настройке журнала: обе строки означают поломку контура, а поломка, записанная
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
которого никто не может войти, не отличит своей поломки (перечень доверенных
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
нести значения заголовка.
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла.
Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию,
что и всё прочее, и отзыв доступа доходит до него сразу.
Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не
однажды выданный срок.
Собственных токенов сервис не принимает: значения, предъявленного запросом и
дающего доступ помимо заголовка, у него не существует. Прежде такое значение
било заголовок — им пользовался владелец панели; панели нет, и правило приоритета
осталось бы правилом без предмета.
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
адресом его почты.
#### Scenario: Заголовок с доверенного адреса узнаёт человека
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
- **THEN** запрос идёт от имени учётной записи с этим значением
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
- **GIVEN** адреса источника в перечне доверенных нет
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
- **THEN** ответ имеет код `401`
- **AND** учётной записи с этим значением не появляется
#### Scenario: Предъявленного значения сервис не признаёт
- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа
в заголовке или в параметре
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается названный заголовком
#### Scenario: Пустой заголовок не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Два значения одного заголовка не узнают никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Значение сверх предела длины не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Область узнавания — корень приложения
- **GIVEN** сервис поднялся
- **WHEN** смотрят, на каких адресах срабатывает узнавание
- **THEN** это адреса под корнем приложения, и второго списка адресов нет
#### Scenario: Проба здоровья учётной записи не заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
- **THEN** учётной записи не появляется
#### Scenario: Недоверенный источник виден в журнале
- **WHEN** запрос с заголовком приходит с недоверенного адреса
- **THEN** журнал несёт строку об этом исходе с адресом пира
- **AND** значения заголовка в ней нет
#### Scenario: Сервис не ставит браузеру куки
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения проходит с заголовком
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
@@ -0,0 +1,263 @@
## 0. Чекпоинт человека
Чекпоинт пройден: развилок не осталось, и решения владельца записаны здесь и в
`design.md`.
- [x] 0.1 Имена ключей конфига подтверждены дословно — `[server] debug` и
`[auth.test_headers]` (необратимое: имя ключа конфига)
- [x] 0.2 Форма имён в секции имитации подтверждена: ключ — имя заголовка, набор
принимаемых имён порождается константами транспорта, неизвестный ключ
роняет старт (`design.md`, решение 6)
- [x] 0.3 Адресного предохранителя не делаем — «полагаемся только на параметр
debug». Требование петлевого перечня доверенных адресов при включённом
предохранителе снято вместе с предикатом «петлевая запись»
(`design.md`, решение 4)
- [x] 0.4 Подкоманда `cmd/devtools proxy` убирается целиком (`design.md`,
решение 10)
- [x] 0.5 Критерии приёмки подтверждены владельцем; пункты 2 и 7 переписаны под
решение 0.3
## 1. Настройки
- [x] 1.1 Завести в `ServerConfig` ключ предохранителя с умолчанием «выключено» в
`defaultConfig()`; отсутствие ключа в конфиге читается как «выключено», а
негодное значение роняет старт, а не читается как «включено»
- [x] 1.2 Завести в `AuthConfig` подсекцию имитации заголовков в форме, выбранной
шагом 0.2; умолчание — пустая секция. Непустой секция считается при наличии
хотя бы одного ключа, каким бы ни было его значение: `Remote-User = ""`
заполненная секция
- [x] 1.3 Вывести перечень принимаемых имён заголовков из констант транспорта
`internal/controller/http/identity.go`, а не вторым списком; сравнение по
каноническому виду имени
- [x] 1.4 Завести проверку старта, охватывающую обе секции, методом на корневой
`Config` (`design.md`, решение 8), и звать её из `cmd/transcriber` рядом с
имеющимися проверками
- [x] 1.5 Отказ старта при заполненной имитации и выключенном предохранителе;
текст называет имя ключа предохранителя
- [x] 1.6 Отказ старта при неизвестном имени заголовка в секции; текст называет
принимаемые имена
- [x] 1.7 Отказ старта при непустой секции без годного `Remote-User`: ключ не
назван либо его значение не проходит `internal/entity.AcceptProviderLogin`
(пустое, пробельное, сверх предела длины, с управляющими знаками); текст
называет имя ключа и не называет значения
- [x] 1.8 Включённый предохранитель при пустой секции старт не роняет. Перечень
доверенных адресов старту при этом не судья: боевая запись перечня
включению предохранителя не мешает (решение 0.3)
## 2. Подстановка на входе
- [x] 2.1 Завести слой транспорта, подставляющий заголовки, в
`internal/controller/http`
- [x] 2.2 Поставить слой в цепочку корня приложения между ограничителем частоты и
узнаванием; при выключенном предохранителе и при пустой секции имитации
слой в цепочку не встаёт вовсе — цепочка та же, что сегодня
- [x] 2.3 Подставлять только при отсутствии `Remote-User`; запрос с любым числом
значений проходит нетронутым
- [x] 2.4 Подставлять только при попадании адреса пира в перечень доверенных, и
судить адрес **той же функцией**, какой судит узнавание (`peerAddress` и
сверка с перечнем из `internal/controller/http/identity.go`), а не вторым
списком
- [x] 2.5 Распоряжаться всей тройкой `Remote-*`: названные секцией ставить,
не названные удалять
- [x] 2.6 Проверить, что `TrustedHeaderIdentity` не изменён: отлаживается боевая
ветка узнавания
## 3. Журнал
- [x] 3.1 Предупреждение при старте с включённой подстановкой: имена заголовков,
поле `capability`, без значений
- [x] 3.2 Строка уровня `INFO` на запросе с подставленными заголовками: адрес
пира, `capability`, `transport`, без значений. Уровень боевой, и боевой
журнал строка не топит: пишется она только там, где подстановка работает,
а в бою предохранитель выключен умолчанием
- [x] 3.3 Предупреждение на запросе, которому подставить нельзя из-за
недоверенного адреса: адрес пира, `capability`, `transport`, без
подставляемых значений. Узнавание на этом месте молчит — заголовка нет, и
оно пишет отладочную строку
## 4. Оснастка
- [x] 4.1 Удалить подкоманду `proxy` из `cmd/devtools`: ветку `case "proxy"` в
`main()`, функцию `runProxy` и её флаги `-listen`, `-target`, `-user`,
`-name`, `-email`; неиспользуемые импорты уходят вместе с ней
- [x] 4.2 Править шапку пакета `cmd/devtools/main.go`: она говорит «Подкоманд
две: `proxy` — подставной обратный прокси, `resume` — …» и объясняет, зачем
прокси нужен. Остаётся одна подкоманда. Абзац про имена заголовков
константами вместе с прокси теряет предмет — оснастка их больше не читает
- [x] 4.3 Править `usage()` в `cmd/devtools/main.go`: убрать строку
`proxy подставной обратный прокси…`
- [x] 4.4 Убрать упоминания подкоманды из документов. Адреса — все, что нашёл
`grep -rn "devtools proxy"`, кроме каталога этого change и архива
`openspec/changes/archive/`:
- `README.md`, блок локального запуска («Локально прокси нет…» и команда
`go run ./cmd/devtools proxy`) — содержимое пишет шаг 6.5;
- `CLAUDE.md`, раздел «Команды», строка `go run ./cmd/devtools proxy`, и
раздел «Запреты», абзац «Локальный запуск не ходит наружу» — содержимое
пишет шаг 6.4;
- `config.example.toml`, блок «Локальный вход без Authelia» с двумя
вызовами подкоманды — содержимое пишут шаги 6.1 и 6.2;
- `docs/conventions/config.md`, абзац про закомментированный петлевой
адрес «под подставной прокси `cmd/devtools proxy`» — содержимое пишет
шаг 6.3;
- `docs/architecture.md`, строка таблицы «Оснастка владельца |
`cmd/devtools`»: «Подставной прокси для местного запуска и возврат
остановленной записи в работу» — содержимое пишет шаг 6.7;
- `docs/security.md`, перечень вне периметра, пункт «Машина разработчика и
то, что он на ней поднимает»: «её подкоманда `proxy` встаёт на место
контура» — содержимое пишет шаг 6.6;
- `docs/review.md`, запись о единственном доме подставных внешних
собеседников: «на их месте `cmd/devtools proxy`». Документ процессный, и
запись историческая: правится строкой о том, что прокси убран задачей
`config-test-headers-login`, а не переписывается
## 5. Тесты
- [x] 5.1 Тесты проверки старта по требованию «Настройка, открывающая вход всем,
роняет старт»: три отказа (имитация без предохранителя; неизвестное имя
заголовка; непустая секция без годного `Remote-User` — не названного и в
300 знаков) и законные случаи (предохранитель без имитации; конфиг, не
называющий ни одного нового ключа)
- [x] 5.2 Тесты слоя подстановки по сценариям требования «Отладочный запуск
называет пришедшего настройками», включая два значения `Remote-User`,
недоверенный адрес и чужой `Remote-Email`
- [x] 5.3 Тест: подставленное значение не встречается ни в одной журнальной
записи
- [x] 5.4 Тест: `GET /health` при включённом предохранителе отвечает так же, как
при выключенном
- [x] 5.5 Тест: ограничитель частоты работает при включённом предохранителе
- [x] 5.6 Тест: при включённом предохранителе и пустой секции запрос без
`Remote-User`, но с `Remote-Email` доходит до узнавания нетронутым
- [x] 5.7 Тест: запрос без заголовка с недоверенного адреса при включённом
предохранителе оставляет предупреждение с адресом пира
- [x] 5.8 Тест: конфиг без новых ключей даёт ту же цепочку слоёв и те же ответы,
что до изменения
## 6. Документы
- [x] 6.1 `config.example.toml`: обе новые настройки с комментарием — зачем,
допустимые значения и цена включения. Форма названа прямо: боевой перечень
доверенных адресов остаётся рабочим значением, `[server] debug` стоит рабочей строкой
`false`, секция `[auth.test_headers]` целиком закомментирована, а петлевые
адреса в перечне стоят комментарием **парой**`127.0.0.1` и `::1`:
браузер разрешает `localhost` в IPv6 не реже, чем в IPv4, и перечень без
`::1` даёт неузнанный запрос без единой понятной строки
- [x] 6.2 Записать рецепт локального входа одним связным блоком — списком правок
сверху вниз, а не тремя комментариями по месту: (1) добавить в перечень
доверенных адресов пару петлевых, (2) поставить `debug = true`,
(3) раскомментировать секцию имитации и назвать в ней `Remote-User`. Дом
блока — `config.example.toml`, и та же последовательность повторена ссылкой
в `README.md` и `CLAUDE.md`
- [x] 6.3 `docs/conventions/config.md`: локальный вход описан настройками; абзац
про закомментированный петлевой адрес под подставной прокси приведён в
соответствие с формой шага 6.1 (пара петлевых адресов, закомментированная
секция имитации, рецепт одним блоком) — строкой «*Расхождение:*», если
форма образца расходится с общим правилом секции
- [x] 6.4 `CLAUDE.md`: раздел «Запреты», абзац о локальном запуске — вход идёт
настройками, второго процесса нет; раздел «Команды», строка подкоманды
`proxy` убирается
- [x] 6.5 `README.md`: инструкция локального запуска
- [x] 6.6 `docs/security.md`, «Периметр»: изъятие отладочного запуска, чем оно
держится (умолчание предохранителя, отказ старта при имитации без него,
шаблон выкладки) и что боевая поломка машиной не исключена. Там же —
пункт «Машина разработчика и то, что он на ней поднимает»: подкоманды
`proxy` больше нет
- [x] 6.7 `docs/architecture.md`: строка таблицы про оснастку разработчика (без
прокси) и новый слой в перечне слоёв транспорта
- [x] 6.8 `docs/passport.md`, «Управление учётными записями»: сегодня раздел
утверждает, что кто пришёл, сервис не решает никогда, и что исключений у
этого больше нет. Назвать изъятие одной строкой с его границей —
«держится предохранителем `[server] debug`, выключенным по умолчанию» — и
ссылкой на спеку `access`
- [x] 6.9 Записать норму о подставных собеседниках — решение владельца, работа
сверх прежнего перечня шагов. Одной строкой в документ канона: подставной
собеседник живёт в коде или в оснастке, а в боевом бинарнике появляется
только отдельным решением владельца и только под ключом, названным своим
предметом. Сегодня таких два — подмена распознавания правкой
`internal/adapter/recognizer/memory.go` и имитация заголовков конфигом, —
а записанной нормы нет вовсе, и `docs/review.md` этот пробел отмечает
вопросом темы `operations`. Дом нормы — `docs/architecture.md`, «Принципы»:
она называет, где живёт часть системы, а не как оформляют код. Запись
вопроса в `docs/review.md` правится строкой о закрытом пробеле
## 7. Приёмка
- [x] 7.1 `task gate` зелёный
- [x] 7.2 Проверить руками: сервис поднят с заполненной имитацией, приложение
открыто по адресу сервиса, второго процесса нет
- [x] 7.3 Проверить руками **записанную форму рецепта**: скопировать свежий
`config.example.toml`, применить рецепт шага 6.2 дословно и ничего сверх
него, поднять сервис, открыть приложение. Отказ старта на полпути —
поломка рецепта, а не копии
- [x] 7.4 Критерии приёмки из блока ниже проверены поимённо
## Критерии приёмки
Рубрика ревью дизайна. Проверяется поимённо шагом 7.4.
- [x] 1. **Fail-closed по умолчанию.** Отсутствие новых ключей в существующем
конфиге не меняет ни одного байта поведения; умолчание предохранителя —
«выключено»; ни одна ошибка разбора не даёт «включено». Проверяемо: конфиг
сегодняшнего дня, не тронутый, даёт ту же цепочку слоёв и те же ответы.
- [x] 2. **У каждой комбинации новых ключей назван исход, а боевая поломка
названа поимённо и не выдана за исключённую.** Машина её не исключает:
сервис с включённым предохранителем и заполненной имитацией поднимается на
любом перечне доверенных адресов, боевом в том числе, и называет пришедшего
сам всякому, чей запрос пришёл через обратный прокси. Защита названа
поимённо и целиком — умолчание предохранителя «выключено», отказ старта при
заполненной имитации без предохранителя, боевой конфиг, рендеримый шаблоном
Ansible, — и ни один документ не утверждает, что этого набора хватает на
механическую несовместимость.
- [x] 3. **Подстановка заменяет, а не дополняет.** Слой либо владеет всем
набором заголовков входа целиком, либо не трогает запрос; второго значения
того же заголовка он не создаёт никогда. Проверяемо: запрос с двумя
`Remote-User` остаётся отказом, запрос с чужим `Remote-Email` и без
`Remote-User` не собирает личность из двух источников.
- [x] 4. **Барьер доверенного адреса подставленный заголовок проходит наравне с
пришедшим, и предикат доверия у обоих один дом.** Проверяемо: запрос с
недоверенного адреса не узнаётся при включённом предохранителе так же, как
при выключенном; отбор адреса берётся из той же точки, что и у узнавания,
а не пишется вторым списком.
- [x] 5. **Отлаживается боевая ветка.** Узнавание не получает ни второго
источника значений, ни ветки «если отладка»; к моменту чтения заголовок
неотличим от поставленного прокси. Проверяемо: файл узнавания изменением
не тронут.
- [x] 6. **Область слоя равна области узнавания и выводится из неё, а не
перечисляется вторым списком.** Проверяемо: проба здоровья, метрики и
раздача приложения отвечают одинаково при включённом и выключенном
предохранителе.
- [x] 7. **Матрица отказов старта полна и тотальна: у каждой комбинации новых
ключей назван исход, и «поднялся, но никого не узнаёт» среди исходов
нет.** Отказ идёт до приёма трафика, называет имя ключа и не называет
значения. Проверяемо перечислением: предохранитель × (пустая / заполненная
/ заполненная негодно) имитация, и отдельной строкой — два ключа секции,
дающие одно каноническое имя заголовка: `Remote-User` и `remote-user`
различимы для TOML и неразличимы для HTTP, и одно из значений терялось бы
молча.
- [x] 8. **Настройка, при которой узнавание не может состояться ни при каком
запросе, — отказ старта, а не запуск.** Имитация без ключа логина, пустое,
пробельное, слишком длинное значение и значение с управляющими знаками
судятся на старте тем же правилом, каким узнавание судит пришедшее
значение. Проверяемо: конфиг с логином в 300 знаков даёт отказ старта с
именем ключа, а не сервис, отвечающий отказом на всё.
- [x] 9. **Отладочный запуск громкий и различимый.** Одна строка при старте (без
значений) и различимость «узнан подстановкой» от «узнан прокси» на уровне
запроса. Отдельно — путь, на котором подстановка не сработала: у него
обязан быть след, иначе самый частый локальный отказ (браузер пришёл
`::1`, конфиг называет `127.0.0.1`) не отличим от поломки узнавания.
- [x] 10. **Слой без состояния между запросами: исход запроса — функция конфига
и самого запроса.** Ничего не кэширует, ничего не заводит, в контекст не
пишет; заведение учётной записи остаётся там, где было, и параллельные
первые запросы с одним логином не зависят от порядка. Проверяемо: два
одновременных первых запроса дают одну учётную запись, а не две и не
отказ.
- [x] 11. **Предохранитель — закрытый перечень следствий, а не режим.** Названо,
чего он не включает; новое следствие требует отдельной нормы. Проверяемо:
включённый предохранитель при пустой имитации ведёт себя ровно как
выключенный, включая ограничитель частоты и уровень журнала.
- [x] 12. **Записанный рецепт и объявленная граница совпадают с задуманным.**
Форма, записанная в образце конфига и в памятке, — та самая, которую
проверяют; ни один документ канона не продолжает утверждать отсутствие
механизма, который изменение заводит. Проверяемо: свежая копия
`config.example.toml` доводится до рабочего локального входа ровно теми
правками, что записаны, и не даёт отказа старта на полпути.
+314
View File
@@ -72,6 +72,22 @@
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` доверенным источником становятся настройки сервиса: сервис
называет пришедшего сам, никого не спросив. Это единственное место, где имя
берётся не от провайдера. Условия, при которых источник этот законен, стоят
требованиями «Отладочный запуск называет пришедшего настройками», «Настройка,
открывающая вход всем, роняет старт», «Отладочный запуск виден в журнале» и
«Предохранитель отладки включает только подстановку заголовков»; снимать их
поодиночке нельзя: барьер держится всеми разом.
Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию
выключен, а заполненная имитация без него роняет старт. Боевой перечень
доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с
включённым предохранителем и заполненной имитацией, назовёт своим именем всякого,
чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным
путём.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
@@ -86,6 +102,12 @@
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
#### Scenario: Без отладочного запуска имя приходит только от провайдера
- **GIVEN** предохранитель отладки выключен
- **WHEN** запрос приходит с доверенного адреса без заголовка
- **THEN** сервис никого не узнаёт и своего имени пришедшему не назначает
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
@@ -251,6 +273,17 @@ MUST отвечать отказом `401`, когда пришедший не
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` заголовок входа ставит не прокси, а сам сервис значением из
секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает. Условия, при которых источник этот законен,
и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск
называет пришедшего настройками», «Настройка, открывающая вход всем, роняет
старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает
только подстановку заголовков». Держится изъятие умолчанием предохранителя
«выключено» и отказом старта при заполненной имитации без него; боевой перечень
доверенных адресов включению предохранителя не мешает.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
@@ -554,3 +587,284 @@ MUST не выдавать вовсе — ни куки, ни токена се
- **WHEN** сервис поднимается с заполненным перечнем
- **THEN** журнал подъёма называет доверенные адреса
### Requirement: Отладочный запуск называет пришедшего настройками
Сервис SHALL подставлять запросу заголовки входа значениями из секции
`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST
делать это так, чтобы узнавание не отличало подставленный заголовок от
поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана
быть той же, что работает в бою: отладке подлежит боевой путь, а не его
отладочный двойник.
Подстановка MUST происходить только при **отсутствии** заголовка `Remote-User` в
запросе. Заголовок, пришедший в любом числе значений, MUST оставаться нетронутым:
узнавание отвергает запрос с более чем одним значением, и подстановка, дописавшая
второе, превратила бы законный отказ в проход.
Подстановка MUST происходить только тогда, когда адрес соединения попадает в
перечень `[auth] trusted_proxies`, и адрес этот MUST судиться **той же
функцией**, какой судит его узнавание: и разбор адреса пира, и сверка с
перечнем берутся из одного дома, а не пишутся вторым списком. Второй перечень
разошёлся бы с первым молча — так же, как разошлись бы два списка имён
заголовков. Второго барьера у подстановки нет и не заводится: круг тех, кто
вправе назвать пришедшего, уже очерчен этим перечнем.
Подстановка MUST действовать только при **непустой** секции имитации. Пустая
секция значит «подставлять нечего»: цепочка слоёв при ней та же, что при
выключенном предохранителе, и ни одного заголовка запроса слой не трогает.
Иначе включённый предохранитель при пустой секции срезал бы `Remote-Email` у
запроса, пришедшего без `Remote-User`, — поведение, которого в бою нет.
Подставляя, сервис MUST распоряжаться всей тройкой заголовков `Remote-*`
целиком: названные секцией ставятся её значением, не названные удаляются.
Запрос без `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
источников — логин свой, почту чужую.
Подстановка MUST действовать только под корнем приложения — там же, где действует
узнавание. Проба здоровья, метрики и раздача приложения её не видят.
#### Scenario: Запрос без заголовка входа получает имя из настроек
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса без единого заголовка `Remote-*`
- **THEN** сервис узнаёт пришедшего под именем из секции
- **AND** учётная запись заводится, если её не было
#### Scenario: Пришедший заголовок не подменяется
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса с заголовком `Remote-User`
- **THEN** узнавание получает значение из запроса, а не из настроек
#### Scenario: Два значения заголовка остаются отказом
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с двумя значениями заголовка `Remote-User`
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Недоверенный адрес имени не получает
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос без заголовка приходит с адреса вне перечня доверенных
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Чужой заголовок почты не смешивается со своим логином
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и не
называет `Remote-Email`
- **WHEN** запрос приходит без `Remote-User`, но с заголовком `Remote-Email`
- **THEN** узнавание получает логин из настроек и не получает адреса почты вовсе
#### Scenario: Пустая секция имитации заголовков не трогает
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без `Remote-User`, но с
заголовком `Remote-Email`
- **THEN** слой заголовков не трогает, и `Remote-Email` доходит до узнавания
нетронутым
#### Scenario: Выключенный предохранитель имён не раздаёт
- **GIVEN** предохранитель выключен, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без заголовка `Remote-User`
- **THEN** запрос остаётся неузнанным
#### Scenario: Наблюдение подстановки не видит
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ тот же, что и при выключенном предохранителе
### Requirement: Настройка, открывающая вход всем, роняет старт
Сервис SHALL отказываться подниматься на настройках, при которых отладочный вход
становится открытым входом, и MUST называть в отказе имя ключа. Проверка идёт на
старте, до приёма трафика: настройка, отданная на честность выкладки, проверяется
только тем, что чужой архив уже уехал не тому.
Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server]
debug` MUST читаться как выключенный предохранитель, а отсутствие секции
`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый
ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST
кончаться отказом старта, а не прочтением «включено».
Секция имитации MUST считаться **непустой**, как только в ней назван хотя бы
один ключ, каким бы ни было его значение. `Remote-User = ""` — заполненная
секция, а не пустая: человек, написавший ключ, имитацию завёл, и пустое значение
у него — вторая поломка, а не отсутствие первой.
Отказом MUST быть каждый из четырёх случаев.
Первый — секция имитации заполнена при выключенном предохранителе. Состояние это
не читается никак: либо человек забыл включить предохранитель и будет искать
поломку везде, кроме одного ключа, либо забыл убрать имитацию из боевого файла — и
тогда до открытого архива остаётся одно слово. Молчаливое игнорирование и
предупреждение в журнале оба оставляют вторую поломку жить.
Второй — секция имитации называет ключ, не совпадающий ни с одним заголовком
входа, который сервис читает. Перечень принимаемых имён MUST порождаться теми же
именами заголовков, которыми пользуется узнавание, а не перечисляться вторым
списком; сравнение MUST идти по каноническому виду имени, потому что в HTTP имя
нечувствительно к регистру, а ключ конфига чувствителен. Опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа.
Третий — секция имитации непуста, а годного `Remote-User` в ней нет. Ключ
логина MUST быть назван: секция, называющая один `Remote-Email`, поднимает
сервис, который подставит почту, удалит логин и не узнает никого. Значение
логина MUST проходить **тот же приём**, каким узнавание судит пришедшее
значение (`internal/entity.AcceptProviderLogin`): пустое, из одних пробельных
знаков, сверх предела длины и с управляющими знаками — отказ старта. Иначе
сервис поднимается, ставит заголовок, получает отказ приёма и отвечает
неузнанным на всё, оставляя за собой одну отладочную строку. Оба состояния — то
самое «не читается никак», ради которого заведён первый случай.
Четвёртый — два ключа секции дают одно каноническое имя заголовка. `Remote-User`
и `remote-user` для TOML — два ключа, для HTTP — одно имя: сервис подставил бы
одно значение, а второе потерял бы молча, и человек искал бы поломку в значении,
которого сервис не читал вовсе. Отказ MUST называть секцию и причину, а значений
MUST не называть.
Включённый предохранитель при пустой секции имитации отказом MUST не быть: сам по
себе он ничего не включает.
#### Scenario: Имитация без предохранителя не поднимается
- **GIVEN** секция имитации заполнена, предохранитель выключен
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Неизвестное имя заголовка не поднимается
- **GIVEN** предохранитель включён, секция имитации называет ключ, которого нет
среди читаемых заголовков входа
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет принимаемые имена
#### Scenario: Имитация без ключа логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет только
`Remote-Email`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя недостающего ключа логина
#### Scenario: Два ключа одного заголовка не поднимаются
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и
`remote-user`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет секцию, в которой два ключа дали одно имя
заголовка
#### Scenario: Негодное значение логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
значением в 300 знаков
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа, а не его значение
#### Scenario: Пустое значение логина считается заполненной имитацией
- **GIVEN** предохранитель выключен, секция имитации называет `Remote-User = ""`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Конфиг без ключа предохранителя ведёт себя как с выключенным
- **GIVEN** конфиг не называет ни `[server] debug`, ни секции имитации
- **WHEN** сервис запускают
- **THEN** сервис поднимается, подстановки не заводит и никого сам не называет
#### Scenario: Предохранитель без имитации поднимается
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис запускают
- **THEN** сервис поднимается
### Requirement: Отладочный запуск виден в журнале
Сервис SHALL сообщать владельцу о включённой подстановке заголовков строкой
журнала при старте и MUST не печатать при этом ни одного подставляемого значения.
Сервис, называющий пришедшего сам, — это ровно то «может стать проблемой», ради
которого заведён предупреждающий уровень: строка старта идёт на `WARN`.
Строка старта MUST нести имена подставляемых заголовков и MUST не нести их
значений. Логин — ключ к чужому архиву, и запрет на печать значения, дающего
доступ, действует на подставленное значение наравне с пришедшим.
Запрос, которому заголовки подставлены, SHALL писаться на уровне `INFO`:
этой строкой «узнан подстановкой» отличается от «узнан прокси». Боевой журнал
она не топит по построению — пишется только там, где подстановка работает, а в
бою предохранитель выключен умолчанием; на отладочном уровне её не увидел бы
никто, потому что настройки под уровень журнала у сервиса нет и он зашит `INFO`.
Строка MUST нести адрес пира и не нести подставленных значений.
Запрос, которому подставить нельзя из-за недоверенного адреса, MUST оставлять
**свою** строку предупреждающим уровнем. Узнавание на этом месте предупреждения
не пишет: подстановка работает при отсутствии `Remote-User`, а отсутствие
заголовка узнавание считает случаем штатным и пишет о нём отладочную строку;
предупреждение о недоверенном адресе оно бережёт для заголовка, который
**пришёл**. Без своей строки самый частый локальный отказ — браузер пришёл
с `::1`, а перечень называет `127.0.0.1` — не отличим от поломки узнавания:
человек видит `401` на всём приложении, заполненную секцию имитации и
включённый предохранитель. Строка MUST нести адрес пира и MUST не нести
подставляемых значений.
#### Scenario: Старт с подстановкой предупреждает владельца
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** сервис поднимается
- **THEN** журнал несёт предупреждение с именами подставляемых заголовков
#### Scenario: Подставленного значения нет в журнале
- **GIVEN** предохранитель включён, секция имитации называет логин и адрес почты
- **WHEN** сервис поднимается и принимает запрос без заголовка
- **THEN** ни логин, ни адрес почты не встречаются ни в одной журнальной записи
#### Scenario: Отказ подстановки на недоверенном адресе виден в журнале
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос без заголовка `Remote-User` приходит с адреса вне перечня
доверенных
- **THEN** журнал несёт предупреждение с адресом пира
- **AND** подставляемых значений в строке нет
#### Scenario: Подстановка на запросе видна при боевом уровне журнала
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос с доверенного адреса получает подставленные заголовки
- **THEN** строка об этом имеет уровень `INFO` и видна журналу, настроенному
по-боевому
### Requirement: Предохранитель отладки включает только подстановку заголовков
Предохранитель `[server] debug` SHALL менять одно поведение сервиса — подстановку
заголовков входа — и MUST не менять никакого другого. Перечень следствий закрыт, и
новое следствие вешается на этот ключ только отдельным требованием спеки: ключ,
названный общим словом, иначе обрастает всем подряд, и выключить его перестаёт
означать «сервис ведёт себя как в бою».
Сам по себе включённый предохранитель не включает и подстановки: она MUST
действовать только при непустой секции имитации, и при пустой цепочка слоёв MUST
быть та же, что при выключенном предохранителе, — требование «Отладочный запуск
называет пришедшего настройками».
Включённый предохранитель MUST не менять уровень журнала, не добавлять в ответы
тексты внутренних отказов и следы стека, не выводить тела запросов и ответов
внешних сервисов, не снимать и не ослаблять ограничитель частоты, не подменять
распознаватель, не ослаблять ни одной проверки старта и не открывать неузнанному
ни одного адреса.
#### Scenario: Включённый предохранитель без имитации ничего не меняет
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис принимает запросы
- **THEN** он ведёт себя ровно так же, как с выключенным предохранителем
#### Scenario: Ограничитель частоты работает и в отладочном запуске
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запросы идут чаще дозволенного
- **THEN** ограничитель частоты режет их так же, как при выключенном
предохранителе