diff --git a/.gitignore b/.gitignore index 7dd4067..98398ee 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,10 @@ Thumbs.db # Config files config.toml +# Переменные окружения: файл читает сам бинарник при старте, и секрет в нём +# оказывается тем же способом, каким оказывается в конфиге. +.env + # Sample and test audio files *.m4a *.mp3 diff --git a/CLAUDE.md b/CLAUDE.md index 37e8f70..ebab1ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 # вернуть остановленную запись в работу 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 оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — diff --git a/README.md b/README.md index 23c9b71..9058e75 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/cmd/devtools/main.go b/cmd/devtools/main.go index 5938892..1b677f1 100644 --- a/cmd/devtools/main.go +++ b/cmd/devtools/main.go @@ -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) - } -} diff --git a/cmd/transcriber/main.go b/cmd/transcriber/main.go index 39a30b0..abfc362 100644 --- a/cmd/transcriber/main.go +++ b/cmd/transcriber/main.go @@ -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(), ) diff --git a/config.example.toml b/config.example.toml index ba219c6..0599b0a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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" diff --git a/docs/adr/ADR-2026-08-23-no-address-guard-for-debug-login.md b/docs/adr/ADR-2026-08-23-no-address-guard-for-debug-login.md new file mode 100644 index 0000000..7475c94 --- /dev/null +++ b/docs/adr/ADR-2026-08-23-no-address-guard-for-debug-login.md @@ -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-*`. diff --git a/docs/adr/ADR-2026-08-23-test-headers-substituted-by-service.md b/docs/adr/ADR-2026-08-23-test-headers-substituted-by-service.md new file mode 100644 index 0000000..c58435a --- /dev/null +++ b/docs/adr/ADR-2026-08-23-test-headers-substituted-by-service.md @@ -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). Ключ с открытым перечнем + следствий обрастает ими молча. +- `−` Каждый новый логин имитации заводит учётную запись, а удалять их сервис не + умеет. Локальная база ронится и пересоздаётся свободно, в бою подстановка + выключена — но лишние записи копятся. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7f7fa74..49210aa 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) | | diff --git a/docs/architecture.md b/docs/architecture.md index 949dbc7..c43dea3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса | Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось. diff --git a/docs/conventions/config.md b/docs/conventions/config.md index d2728af..eb8400c 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -63,12 +63,23 @@ force_shutdown_timeout = # ждать остановки ворке Секретов в этой секции больше нет: они ушли 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]` это ожидание занятой diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index 3b8a888..d1066e5 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -173,3 +173,10 @@ transcriber — **приложение, а не библиотека**: внеш - Сбор независимых ошибок (проверка конфига — все проблемы разом) — `errors.Join`; проверка собранного по-прежнему через `errors.Is`. + +*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не +устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка +возвращается на первом несовпадении. Заметило ревью задачи +`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили +`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг +впервые, чинит одну ошибку за прогон. diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index dd5a6c9..e83d52e 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -87,6 +87,11 @@ stdlib-логом в поток ошибок. Это выбор, а не дол *Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем. Пустой прогон воркера не логируется вовсе — и это правилу не противоречит. +*Изъятие:* строка о подставленных заголовках входа +(`internal/controller/http/substitute.go`) адресована разработчику, а идёт на +`INFO`: `DEBUG` включить нечем, а в бою она не пишется вовсе — подстановку +держит выключенный умолчанием предохранитель `[server] debug`. + ## Время - Поле — `time` (ключ `slog` по умолчанию). diff --git a/docs/passport.md b/docs/passport.md index f9efb46..14c4e1e 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -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). - **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не обрабатываем. - **Диктофон.** Запись звука делает телефон, а приложение принимает готовый diff --git a/docs/research/README.md b/docs/research/README.md index 268f863..48a7387 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -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 | diff --git a/docs/research/toml-unknown-keys.md b/docs/research/toml-unknown-keys.md new file mode 100644 index 0000000..e817b1a --- /dev/null +++ b/docs/research/toml-unknown-keys.md @@ -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=`.** + Разбор проходит целиком, поля структуры остаются нулевыми, и отличить «в файле + этого нет» от «в файле это написано с опечаткой» по результату разбора нельзя. + В прогоне: `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()`: непонятый ключ, +роняющий старт, закрывает опечатки во всех секциях разом — и тем же движением +снимает безопасность отката, потому что прежний образ перестанет поднимать +конфиг новее себя. Разменивать одно на другое — отдельное решение владельца, +а не попутная правка. + +**Наблюдение привязано к версии.** Версия, начавшая судить незнакомые ключи +сама, сменит оба следствия разом — молчаливую опечатку и безопасный откат. diff --git a/docs/review.md b/docs/review.md index f782849..ba279b9 100644 --- a/docs/review.md +++ b/docs/review.md @@ -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 по-прежнему недоступна — её правило на домен живёт в контуре (см. «Не проверит ни один проход»). diff --git a/docs/security.md b/docs/security.md index 0295127..eb54982 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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` поимённо. - **Злоупотребление со стороны пользователя из белого списка.** Приглашённому доверяем полностью. - **Достоверность расшифровки.** Подмена или искажение текста на стороне diff --git a/internal/config/config.go b/internal/config/config.go index 9ba0e49..aab05c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 разбирает перечень доверенных адресов. diff --git a/internal/config/test_headers.go b/internal/config/test_headers.go new file mode 100644 index 0000000..41979db --- /dev/null +++ b/internal/config/test_headers.go @@ -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 +} diff --git a/internal/config/test_headers_test.go b/internal/config/test_headers_test.go new file mode 100644 index 0000000..11c0dc5 --- /dev/null +++ b/internal/config/test_headers_test.go @@ -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) + } +} diff --git a/internal/controller/http/contract_test.go b/internal/controller/http/contract_test.go index 4b67d1d..8af2b05 100644 --- a/internal/controller/http/contract_test.go +++ b/internal/controller/http/contract_test.go @@ -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) diff --git a/internal/controller/http/identity.go b/internal/controller/http/identity.go index 582f7c8..58cab59 100644 --- a/internal/controller/http/identity.go +++ b/internal/controller/http/identity.go @@ -23,6 +23,15 @@ const ( EmailHeader = "Remote-Email" ) +// IdentityHeaderNames отдаёт имена заголовков входа целиком, в одном порядке. +// +// Перечисление тройки живёт здесь и только здесь. Всякий, кому нужен её состав — +// слой подстановки, проверка старта, строка журнала, — берёт его отсюда: второй +// список разошёлся бы с первым молча, а имена нормативны. +func IdentityHeaderNames() []string { + return []string{LoginHeader, NameHeader, EmailHeader} +} + // ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего. // Читает его только ограничитель частоты: барьером узнавания он не служит и // служить не может — кто пришёл, решает адрес самого соединения. diff --git a/internal/controller/http/server.go b/internal/controller/http/server.go index 9c603a2..22b211c 100644 --- a/internal/controller/http/server.go +++ b/internal/controller/http/server.go @@ -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 } diff --git a/internal/controller/http/status_test.go b/internal/controller/http/status_test.go index 1b81d97..48885b1 100644 --- a/internal/controller/http/status_test.go +++ b/internal/controller/http/status_test.go @@ -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) diff --git a/internal/controller/http/substitute.go b/internal/controller/http/substitute.go new file mode 100644 index 0000000..6747c1b --- /dev/null +++ b/internal/controller/http/substitute.go @@ -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 +} diff --git a/internal/controller/http/substitute_test.go b/internal/controller/http/substitute_test.go new file mode 100644 index 0000000..02962a7 --- /dev/null +++ b/internal/controller/http/substitute_test.go @@ -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()) + }) + } +} diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index 1075d96..e2a6294 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -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) diff --git a/openspec/changes/archive/2026-08-23-config-test-headers-login/.openspec.yaml b/openspec/changes/archive/2026-08-23-config-test-headers-login/.openspec.yaml new file mode 100644 index 0000000..44f55ff --- /dev/null +++ b/openspec/changes/archive/2026-08-23-config-test-headers-login/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-23 diff --git a/openspec/changes/archive/2026-08-23-config-test-headers-login/design.md b/openspec/changes/archive/2026-08-23-config-test-headers-login/design.md new file mode 100644 index 0000000..d3833d9 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-config-test-headers-login/design.md @@ -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 + +Развилок не осталось: все закрыты владельцем на чекпоинте. diff --git a/openspec/changes/archive/2026-08-23-config-test-headers-login/proposal.md b/openspec/changes/archive/2026-08-23-config-test-headers-login/proposal.md new file mode 100644 index 0000000..e8e8b7a --- /dev/null +++ b/openspec/changes/archive/2026-08-23-config-test-headers-login/proposal.md @@ -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. diff --git a/openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md b/openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md new file mode 100644 index 0000000..46e58cc --- /dev/null +++ b/openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md @@ -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; S1–S4; C1–C5; R1–R3; V1–V3 + три свойства + `adversary` без пути; O1). **Осталось в первых двух секциях:** 7. + +### План с исходом по каждой теме + +| Тема | Дом | Глубина | Кто закрывает | Исход | +|---|---|---|---|---| +| `requirements` | `openspec/specs/access/spec.md` + дельта | разбор | `specs` | **закрыта** проходом `specs`, 4 находки (S1–S4) | +| `autotests` | `CLAUDE.md`, «Гейт» | — | `autotests` | **закрыта** проходом `autotests`, 1 находка (A1), гейт зелёный | +| `conventions` | `docs/conventions/` целиком | разбор | `code` | **закрыта** проходом `code`, 5 находок (C1–C5) | +| `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) строками 25–32 по +прогону `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=`, `undecoded=[auth.test_headrs auth.test_headrs.Remote-User]`, + `Server.Debug=true len(TestHeaders)=0 substituting=false`, + `ValidateTestHeaders err = `. Контроль: та же опечатка **внутри** секции + (`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` как замер, +снятый на этом прогоне. diff --git a/openspec/changes/archive/2026-08-23-config-test-headers-login/specs/access/spec.md b/openspec/changes/archive/2026-08-23-config-test-headers-login/specs/access/spec.md new file mode 100644 index 0000000..f18994a --- /dev/null +++ b/openspec/changes/archive/2026-08-23-config-test-headers-login/specs/access/spec.md @@ -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** значение заголовка не встречается ни в одной журнальной записи diff --git a/openspec/changes/archive/2026-08-23-config-test-headers-login/tasks.md b/openspec/changes/archive/2026-08-23-config-test-headers-login/tasks.md new file mode 100644 index 0000000..98930da --- /dev/null +++ b/openspec/changes/archive/2026-08-23-config-test-headers-login/tasks.md @@ -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` доводится до рабочего локального входа ровно теми + правками, что записаны, и не даёт отказа старта на полпути. diff --git a/openspec/specs/access/spec.md b/openspec/specs/access/spec.md index 1cd051d..cab38e1 100644 --- a/openspec/specs/access/spec.md +++ b/openspec/specs/access/spec.md @@ -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** ограничитель частоты режет их так же, как при выключенном + предохранителе