локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
This commit is contained in:
@@ -46,6 +46,10 @@ Thumbs.db
|
||||
# Config files
|
||||
config.toml
|
||||
|
||||
# Переменные окружения: файл читает сам бинарник при старте, и секрет в нём
|
||||
# оказывается тем же способом, каким оказывается в конфиге.
|
||||
.env
|
||||
|
||||
# Sample and test audio files
|
||||
*.m4a
|
||||
*.mp3
|
||||
|
||||
@@ -152,7 +152,6 @@ go vet ./...
|
||||
gofmt -l .
|
||||
golangci-lint run
|
||||
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
|
||||
go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально
|
||||
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
|
||||
task front # приложение: зависимости, Biome, юнит-тесты, сборка
|
||||
task image # docker-образ; тег и раскладка — docs/architecture.md
|
||||
@@ -277,10 +276,13 @@ Node на машину **не ставится**: шаг сборки прило
|
||||
`config.example.toml`.
|
||||
**На машине без прокси представиться нечем**: сервис узнаёт
|
||||
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
|
||||
не ставит. На место контура встаёт подставной прокси —
|
||||
`go run ./cmd/devtools proxy`: он слушает свой порт, ставит заголовок и
|
||||
переправляет запрос сервису, а проверок не делает никаких. Приложение при этом
|
||||
открывают **по адресу прокси**, а не по адресу сервиса. Ключей боевого
|
||||
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
|
||||
процессом: рецепт из трёх правок записан связным блоком в
|
||||
`config.example.toml`, под перечнем доверенных адресов. Коротко: пара петлевых
|
||||
адресов в перечень, `[server] debug = true`, раскомментированная секция
|
||||
`[auth.test_headers]` с ключом `Remote-User`. Приложение при этом открывают по
|
||||
адресу сервиса, второго порта нет. Заполненная имитация при выключенном
|
||||
предохранителе роняет старт с именем ключа. Ключей боевого
|
||||
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
|
||||
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
||||
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
||||
|
||||
@@ -48,16 +48,21 @@
|
||||
[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
|
||||
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
|
||||
|
||||
Локально прокси нет, а браузер заголовков не ставит — на место контура встаёт
|
||||
подставной прокси из оснастки:
|
||||
Локально прокси нет, а браузер заголовков не ставит — заголовок подставляет сам
|
||||
сервис по своим настройкам. Второго процесса для этого не нужно: приложение
|
||||
открывают по адресу сервиса.
|
||||
|
||||
```bash
|
||||
go run ./cmd/devtools proxy
|
||||
```
|
||||
Рецепт — три правки `config.toml` сверху вниз:
|
||||
|
||||
Приложение после этого открывают **по адресу прокси** — `http://localhost:9000`.
|
||||
В перечне доверенных адресов при этом должен стоять петлевой; строки под это
|
||||
стоят в `config.example.toml`.
|
||||
1. добавить в перечень доверенных адресов пару петлевых — `127.0.0.1` и `::1`;
|
||||
2. поставить в секции `[server]` ключ `debug = true`;
|
||||
3. раскомментировать секцию `[auth.test_headers]` и назвать в ней `Remote-User`.
|
||||
|
||||
Тот же рецепт записан связным блоком в `config.example.toml`, под перечнем
|
||||
доверенных адресов, — там же названы принимаемые имена заголовков и цена
|
||||
включения. Заполненная секция имитации при выключенном предохранителе роняет
|
||||
старт с именем ключа: сервис с включённым предохранителем называет пришедшего
|
||||
сам, никого не спросив, и в бою этот ключ стоит `false`.
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -101,7 +106,7 @@ Prometheus с префиксом `transcriber_` — и `GET /health` — про
|
||||
transcriber/
|
||||
├── cmd/
|
||||
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
|
||||
│ └── devtools/ # Оснастка разработчика: подставной прокси и возврат записи в работу
|
||||
│ └── devtools/ # Оснастка разработчика: возврат остановленной записи в работу
|
||||
├── internal/
|
||||
│ ├── entity/ # Модели: запись, файл, результат распознавания
|
||||
│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID)
|
||||
|
||||
+5
-82
@@ -7,13 +7,11 @@
|
||||
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
|
||||
//
|
||||
// Подкоманд две: `proxy` — подставной обратный прокси, `resume` — возврат
|
||||
// остановленной записи в работу. Вторая встала на место панели владельца:
|
||||
// панели у сервиса больше нет, а экраны правки записи приносят отдельные задачи.
|
||||
//
|
||||
// Имена заголовков берутся **константами транспорта**, а не литералами: они
|
||||
// нормативны, и второй список разошёлся бы с первым молча — локальный вход
|
||||
// перестал бы узнавать кого бы то ни было, а искали бы поломку в сервисе.
|
||||
// Подкоманда одна — `resume`, возврат остановленной записи в работу. Она встала
|
||||
// на место панели владельца: панели у сервиса больше нет, а экраны правки
|
||||
// записи приносят отдельные задачи. Подставной обратный прокси жил здесь второй
|
||||
// подкомандой и убран 2026-08-23 задачей `config-test-headers-login`: заголовки
|
||||
// входа локального прогона подставляет сам сервис по своим настройкам.
|
||||
//
|
||||
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
|
||||
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
|
||||
@@ -22,15 +20,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -42,8 +34,6 @@ func main() {
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "proxy":
|
||||
runProxy(os.Args[2:])
|
||||
case "resume":
|
||||
runResume(os.Args[2:])
|
||||
default:
|
||||
@@ -57,74 +47,7 @@ func usage() {
|
||||
fmt.Fprint(os.Stderr, `Оснастка разработчика.
|
||||
|
||||
Подкоманды:
|
||||
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
|
||||
resume вернуть остановленную запись в работу
|
||||
|
||||
`)
|
||||
}
|
||||
|
||||
// runProxy поднимает подставной обратный прокси.
|
||||
//
|
||||
// Зачем он нужен: сервис узнаёт пришедшего по заголовку, который на сервере
|
||||
// ставит Caddy, сходив к Authelia. На машине разработчика ни того ни другого
|
||||
// нет, а браузер заголовков не ставит — значит приложение локально не
|
||||
// открывалось бы вовсе. Прокси встаёт на их место: слушает свой порт, ставит
|
||||
// заголовок и переправляет запрос сервису.
|
||||
//
|
||||
// Проверок он не делает никаких — ни пароля, ни группы, ни срока. Это его
|
||||
// назначение, а не упущение: пускать он должен всякого, кто до него дошёл, а
|
||||
// сам он слушает петлевой адрес.
|
||||
func runProxy(args []string) {
|
||||
flags := flag.NewFlagSet("proxy", flag.ExitOnError)
|
||||
listen := flags.String("listen", "127.0.0.1:9000", "адрес, на котором слушать")
|
||||
target := flags.String("target", "http://127.0.0.1:8080", "адрес сервиса")
|
||||
login := flags.String("user", "dev", "логин, которым называть пришедшего")
|
||||
name := flags.String("name", "Разработчик", "имя, пригодное к показу")
|
||||
email := flags.String("email", "", "адрес почты; пустой не ставится вовсе")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Слушаем петлевой адрес по умолчанию, и это часть назначения: прокси
|
||||
// пускает всякого, кто до него дошёл, и в чужой сети становится открытым
|
||||
// входом в сервис.
|
||||
upstream, err := url.Parse(*target)
|
||||
if err != nil {
|
||||
log.Fatalf("адрес сервиса не читается: %v", err)
|
||||
}
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(upstream)
|
||||
|
||||
// Именно Set, а не Add. Прокси, который **добавляет** заголовок к
|
||||
// присланному, оставляет рядом со своим значением чужое — и сервис
|
||||
// отвергает такой запрос целиком, потому что двух значений он не
|
||||
// разбирает. Настоящий Caddy обязан делать то же самое, и это
|
||||
// записано требованием к контуру в модели угроз.
|
||||
r.Out.Header.Set(httpcontroller.LoginHeader, *login)
|
||||
r.Out.Header.Set(httpcontroller.NameHeader, *name)
|
||||
if *email != "" {
|
||||
r.Out.Header.Set(httpcontroller.EmailHeader, *email)
|
||||
} else {
|
||||
r.Out.Header.Del(httpcontroller.EmailHeader)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
log.Printf("подставной прокси: %s → %s, пришедший — %q", *listen, *target, *login)
|
||||
log.Printf("приложение открывать по http://%s", *listen)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: proxy,
|
||||
// Таймаута чтения нет намеренно: сервис принимает записи на несколько
|
||||
// часов, и прокси, обрывающий такую загрузку, ловил бы разработчика на
|
||||
// поломке, которой в сервисе нет.
|
||||
ReadTimeout: 0,
|
||||
}
|
||||
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatalf("подставной прокси остановлен: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -77,6 +77,30 @@ func run(logger *slog.Logger) error {
|
||||
// доходит вовсе, — а это разные поломки в разных местах.
|
||||
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
|
||||
|
||||
// Настройки отладочного входа: заполненная имитация без предохранителя, имя
|
||||
// заголовка, которого сервис не читает, и имитация без годного логина роняют
|
||||
// старт. Имена заголовков приходят проверке доводом — дом у них один,
|
||||
// константы транспорта, — а пакет настроек транспорта не знает.
|
||||
if err := cfg.ValidateTestHeaders(
|
||||
httpcontroller.IdentityHeaderNames(), httpcontroller.LoginHeader,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Представление предиката «подставляем ли» одно — непустота перечня, — и
|
||||
// судят его одинаково строка журнала ниже, слой подстановки и проверка выше.
|
||||
// Второе выражение того же предиката разошлось бы с первым молча.
|
||||
substitution := cfg.HeaderSubstitution()
|
||||
if len(substitution) > 0 {
|
||||
// Уровень предупреждающий: сервис называет пришедшего сам, никого не
|
||||
// спросив, — ровно то «может стать проблемой», ради которого заведён
|
||||
// этот уровень. Идут имена заголовков; значений нет — логин это ключ к
|
||||
// чужому архиву.
|
||||
logger.Warn("Identity headers are substituted from configuration",
|
||||
"headers", httpcontroller.SubstitutedHeaderNames(substitution),
|
||||
"capability", "access")
|
||||
}
|
||||
|
||||
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
|
||||
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
|
||||
// ней значит остановить всякую запись первым же захватом.
|
||||
@@ -190,7 +214,7 @@ func run(logger *slog.Logger) error {
|
||||
// наблюдения, правило неизвестного пути у раздачи приложения и область
|
||||
// действия узнавания.
|
||||
mounts := httpcontroller.ServiceMounts(
|
||||
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, logger),
|
||||
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, substitution, logger),
|
||||
promhttp.Handler(),
|
||||
)
|
||||
|
||||
|
||||
+44
-14
@@ -4,6 +4,20 @@ port = 8080
|
||||
shutdown_timeout = 5
|
||||
force_shutdown_timeout = 20
|
||||
|
||||
# Предохранитель отладочного запуска. Значения: false (по умолчанию) и true.
|
||||
#
|
||||
# Означает он одно: прогон идёт на машине разработчика, и сервису позволено
|
||||
# подставить то, что в бою даёт обратный прокси, — заголовки входа из секции
|
||||
# [auth.test_headers] ниже. Перечня следствий сверх этого у него нет: уровня
|
||||
# журнала, текстов внутренних отказов, ограничителя частоты и подмены
|
||||
# распознавателя признак не касается.
|
||||
#
|
||||
# Цена включения названа прямо: сервис с true и заполненной имитацией называет
|
||||
# пришедшего сам, никого не спросив, и отдаёт архив всякому, чей запрос пришёл с
|
||||
# доверенного адреса. В бою доверенный адрес — это адрес обратного прокси, то
|
||||
# есть всякий, кто пришёл обычным путём. В боевом файле ключ стоит false.
|
||||
debug = false
|
||||
|
||||
# Хранилище: каталог данных и числа его базы.
|
||||
#
|
||||
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
|
||||
@@ -100,25 +114,41 @@ object_storage_endpoint = "https://storage.yandexcloud.net/"
|
||||
# Caddy, — узкий и свой.
|
||||
trusted_proxies = ["172.20.0.0/24"]
|
||||
|
||||
# Локальный вход без Authelia.
|
||||
# Локальный вход без Authelia — рецепт целиком.
|
||||
#
|
||||
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
|
||||
# приложение локально не открылось бы вовсе. На место контура встаёт подставной
|
||||
# прокси из оснастки: он слушает свой порт, ставит заголовок и переправляет
|
||||
# запрос сервису. Проверок он не делает никаких — ни пароля, ни группы, ни
|
||||
# срока, — и это его назначение, а не упущение.
|
||||
# приложение локально не открылось бы вовсе. Заголовки входа подставляет сам
|
||||
# сервис: второго процесса и второго порта для этого не нужно, приложение
|
||||
# открывают по адресу сервиса.
|
||||
#
|
||||
# go run ./cmd/devtools proxy
|
||||
# Три правки этого файла сверху вниз, и других не нужно:
|
||||
#
|
||||
# Приложение после этого открывают по адресу прокси — http://localhost:9000, —
|
||||
# а не по адресу сервиса: запрос мимо прокси приходит без заголовка и никого не
|
||||
# узнаёт.
|
||||
# 1. Добавить в перечень выше пару петлевых адресов — обе записи, а не одну:
|
||||
#
|
||||
# Под него в перечне выше должен стоять петлевой адрес:
|
||||
# trusted_proxies = ["172.20.0.0/24", "127.0.0.1", "::1"]
|
||||
#
|
||||
# trusted_proxies = ["127.0.0.1"]
|
||||
# Браузер разрешает localhost в IPv6 не реже, чем в IPv4, и перечень без
|
||||
# `::1` даёт неузнанный запрос. Отказ подстановки при этом виден строкой
|
||||
# журнала с адресом пира — по ней и опознаётся недостающая запись.
|
||||
#
|
||||
# Второй вошедший получается другим значением `-user`: логин у провайдера и есть
|
||||
# ключ учётной записи.
|
||||
# 2. Поставить в секции [server] выше:
|
||||
#
|
||||
# go run ./cmd/devtools proxy -user local-2 -name "Второй"
|
||||
# debug = true
|
||||
#
|
||||
# 3. Раскомментировать секцию ниже и назвать в ней Remote-User. Ключ —
|
||||
# имя заголовка, значение — то, чем сервис назовёт пришедшего. Принимаются
|
||||
# три имени: Remote-User, Remote-Name, Remote-Email; иное роняет старт.
|
||||
# Ключ Remote-User обязателен: без него сервис подставит всё прочее и не
|
||||
# узнает никого.
|
||||
#
|
||||
# Второй вошедший получается другим значением Remote-User: логин и есть ключ
|
||||
# учётной записи.
|
||||
#
|
||||
# Заполненная секция при debug = false роняет старт с именем ключа
|
||||
# предохранителя: состояние «имитация есть, предохранителя нет» не читается
|
||||
# никак, а обе его прочтения — поломка.
|
||||
#
|
||||
# [auth.test_headers]
|
||||
# Remote-User = "local"
|
||||
# Remote-Name = "Разработчик"
|
||||
# Remote-Email = "local@example.com"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина
|
||||
|
||||
- **Дата:** 2026-08-23
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
|
||||
решение 4
|
||||
|
||||
## Решение
|
||||
|
||||
Отладочная подстановка заголовков входа не требует от настроек ничего сверх
|
||||
самого предохранителя `[server] debug`. Решение владельца на чекпоинте записано
|
||||
в источнике дословно:
|
||||
|
||||
> «предохранитель по адресам не делаем, полагаемся только на параметр debug».
|
||||
|
||||
Рассматривалось требование, чтобы при включённом предохранителе перечень
|
||||
доверенных адресов состоял только из петлевых записей; оно снято вместе с
|
||||
предикатом «петлевая запись», который заводился ровно ради него.
|
||||
|
||||
Согласованность с барьером узнавания при этом остаётся: подставленный заголовок
|
||||
проходит тот же перечень доверенных адресов, что и пришедший, и судит адрес та
|
||||
же функция. Предохранителем это не служит — «от конфига она не требует ничего и
|
||||
круга тех, кто мог назваться кем угодно, не расширяет».
|
||||
|
||||
## Почему
|
||||
|
||||
Решение покупает работоспособность отладочного входа там, где адрес пира не
|
||||
петлевой:
|
||||
|
||||
> отладочный вход работает **внутри контейнера** — адрес пира там принадлежит
|
||||
> сети докера, и она же стоит в боевом перечне, — а локальный прогон не
|
||||
> переставляет перечень доверенных адресов на петлевой: петлевые записи
|
||||
> добавляются к тем, что в нём уже стоят.
|
||||
|
||||
Цена названа в источнике прямо, и владелец принял именно её:
|
||||
|
||||
> Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
|
||||
> машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
|
||||
> имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, —
|
||||
> а доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
|
||||
> пришедший обычным путём**.
|
||||
|
||||
Между боевой выкладкой и открытым входом остаётся три вещи, и других нет:
|
||||
умолчание предохранителя «выключено»; отказ старта при заполненной имитации без
|
||||
предохранителя; боевой конфиг, который рендерит шаблон Ansible, а не
|
||||
копируют с машины разработчика.
|
||||
|
||||
Отвергнуты вместе с адресным предохранителем ещё два подхода. **Принудительно
|
||||
слушать петлевой адрес при включённом предохранителе** — «меняет поведение молча
|
||||
… и закрывает ровно то, что решение покупает: внутри контейнера сервис слушает не
|
||||
петлю». **Новый ключ `[server] listen`** — «публичная поверхность настроек ради
|
||||
предохранителя, которого решением владельца нет».
|
||||
|
||||
## Почему это ADR
|
||||
|
||||
Триггер — **намеренный отказ** от очевидного подхода. Требовать петлевой перечень
|
||||
при включённом отладочном входе — первое, что предлагает всякий, кто читает
|
||||
модель угроз; отказ от этого оставляет боевую поломку, которую машина не
|
||||
исключает, и объяснить его надо один раз здесь, а не на каждом ревью, которое
|
||||
эту дыру находит заново.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Отладочный вход работает и на машине разработчика, и внутри контейнера:
|
||||
перечень доверенных адресов остаётся границей доверия, а не признаком отладки.
|
||||
- `+` Локальный прогон не переставляет перечень на петлевой — петлевые записи к
|
||||
нему добавляются.
|
||||
- `+` Предиката «петлевая запись» в коде нет вовсе: он заводился ради одной этой
|
||||
проверки.
|
||||
- `−` **Машина не исключает боевую поломку «конфиг с `debug = true` и
|
||||
заполненной имитацией».** Такой сервис поднимется на любом перечне доверенных
|
||||
адресов и назовёт своим именем всякого, кто пришёл обычным путём. Записано это в модели
|
||||
угроз, [security.md](../security.md), «Периметр», и в спеке
|
||||
[access](../../openspec/specs/access/spec.md).
|
||||
- `−` Одна из трёх опор лежит вне репозитория: шаблон Ansible из
|
||||
`pet-project-server`. Проверить её отсюда нечем — тем же свойством обладает
|
||||
правило прокси про заголовки `Remote-*`.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс
|
||||
|
||||
- **Дата:** 2026-08-23
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
|
||||
решения 1, 6, 7 и 10
|
||||
|
||||
## Решение
|
||||
|
||||
Заголовки входа на машине разработчика ставит сам сервис — отдельным слоем
|
||||
цепочки корня приложения, а не вспомогательным процессом рядом:
|
||||
|
||||
> отдельный слой цепочки корня приложения, стоящий **перед**
|
||||
> `TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
|
||||
> запроса и ничего больше не делает: учётной записи не заводит, отказов не
|
||||
> выдаёт, в контекст не пишет.
|
||||
|
||||
Включают слой два новых ключа настроек — предохранитель `[server] debug` и
|
||||
секция значений `[auth.test_headers]`. Прежний вспомогательный процесс уходит:
|
||||
|
||||
> **Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
|
||||
> остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
|
||||
> входить локально не остаётся тоже.
|
||||
|
||||
Имена заголовков служат именами ключей секции, но набор принимаемых имён
|
||||
порождают константы транспорта: дом у имён остаётся один, а ключ, не совпавший
|
||||
ни с одним из них, роняет старт.
|
||||
|
||||
## Почему
|
||||
|
||||
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
|
||||
Дизайн записал это целью:
|
||||
|
||||
> Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
|
||||
> назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
|
||||
> настроек, и ничем больше.
|
||||
|
||||
Довод в пользу слоя перед узнаванием, а не внутри него:
|
||||
|
||||
> Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
|
||||
> неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
|
||||
|
||||
Отвергнуты три очевидных подхода, и у каждого названа цена. **Подстановка внутри
|
||||
`TrustedHeaderIdentity`** — «узнавание получило бы второй источник значений и
|
||||
ветку, которой в бою нет. Отлаживалась бы не боевая ветка, а её отладочный
|
||||
двойник». **Произвольная карта имён заголовков в конфиге** — «опечатка
|
||||
`Remote-Usr` даёт „сервис меня не узнаёт“ без единого следа». **Вырезать
|
||||
подстановку из боевой сборки тегом сборки:**
|
||||
|
||||
> сборка образа в гейте не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег,
|
||||
> забытый в одной ступени, дал бы ровно ту тишину, которой избегает пункт 3.
|
||||
|
||||
## Почему это ADR
|
||||
|
||||
Триггер сработал дважды. **Дорогой откат:** решение заводит два имени ключа
|
||||
настроек, а имя ключа конфига `CLAUDE.md` называет необратимым; вернуться к
|
||||
вспомогательному процессу значит поднять удалённую подкоманду, убрать оба ключа
|
||||
из настроек и переписать рецепт локального запуска, разошедшийся по образцу
|
||||
конфига, `README.md`, `CLAUDE.md` и конвенции настроек. **Намеренный отказ:**
|
||||
вырезать отладочный код из боевой сборки тегом сборки — то, что делают по
|
||||
умолчанию, и отказ от этого объясняется один раз здесь, а не на каждом вопросе
|
||||
«почему подстановка вообще есть в боевом бинарнике».
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Локальный запуск идёт одним процессом и одной командой; приложение
|
||||
открывают по адресу сервиса, второго порта нет.
|
||||
- `+` Отлаживается боевая ветка узнавания: подставленный заголовок неотличим от
|
||||
пришедшего от прокси к моменту, когда его читают.
|
||||
- `+` Второго способа входить локально не остаётся, и документация перестаёт
|
||||
каждый раз говорить, какой из способов чей.
|
||||
- `+` Имена заголовков остаются с одним домом — константами транспорта; ключ, не
|
||||
совпавший ни с одним из них, роняет старт и называет принимаемые имена.
|
||||
- `−` **Местный инструмент больше не воспроизводит поломки контура.** Цена
|
||||
названа в источнике прямо: два значения `Remote-User`, заголовок с
|
||||
недоверенного адреса, цепочка `X-Forwarded-For` — всё это теперь
|
||||
воспроизводит только автотест, ставящий заголовок сам.
|
||||
- `−` В боевом бинарнике появляется код, называющий пришедшего без провайдера.
|
||||
Что его держит и чего у него нет — [ADR-2026-08-23-no-address-guard-for-debug-login](ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||||
- `−` У ключа `[server] debug` закрытый перечень следствий, и держать его
|
||||
придётся руками: новое поведение привязывается к ключу только отдельным
|
||||
решением владельца и получает своё требование спеки
|
||||
[access](../../openspec/specs/access/spec.md). Ключ с открытым перечнем
|
||||
следствий обрастает ими молча.
|
||||
- `−` Каждый новый логин имитации заводит учётную запись, а удалять их сервис не
|
||||
умеет. Локальная база ронится и пересоздаётся свободно, в бою подстановка
|
||||
выключена — но лишние записи копятся.
|
||||
@@ -35,6 +35,8 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-23 | [Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина](ADR-2026-08-23-no-address-guard-for-debug-login.md) | |
|
||||
| 2026-08-23 | [Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс](ADR-2026-08-23-test-headers-substituted-by-service.md) | |
|
||||
| 2026-08-22 | [Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком](ADR-2026-08-22-storage-without-pocketbase.md) | |
|
||||
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
|
||||
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
|
||||
|
||||
+20
-3
@@ -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` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
|
||||
|
||||
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
|
||||
|
||||
@@ -63,12 +63,23 @@ force_shutdown_timeout = <N> # ждать остановки ворке
|
||||
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
|
||||
входом.
|
||||
|
||||
Там же, комментарием под секцией, стоит **второе значение перечня — петлевой
|
||||
адрес, под подставной прокси `cmd/devtools proxy`**. Оно стоит закомментированным, и это
|
||||
намеренно: образец описывает боевую выкладку, а локальный вход — способ до неё
|
||||
Там же, комментарием под секцией, стоит **рецепт локального входа одним связным
|
||||
блоком**: три правки сверху вниз — пара петлевых адресов в перечень доверенных,
|
||||
`[server] debug = true`, раскомментированная секция `[auth.test_headers]` с
|
||||
ключом `Remote-User`. Блок один, а не три комментария по месту: правки связаны
|
||||
между собой, и применённая порознь любая из них роняет старт либо оставляет
|
||||
сервис никого не узнающим. Рабочей строкой в образце стоит боевое значение —
|
||||
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
|
||||
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
|
||||
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
|
||||
какое из них чьё.
|
||||
|
||||
*Расхождение:* петлевые адреса в рецепте названы **парой** — `127.0.0.1` и
|
||||
`::1`, — а не одним значением, хотя правило секции требует от образца только
|
||||
формы значения. Причина в цене: браузер разрешает `localhost` в IPv6 не реже,
|
||||
чем в IPv4, и перечень без `::1` даёт неузнанный запрос там, где человек ждёт
|
||||
входа.
|
||||
|
||||
## Поля по дискриминатору `type`
|
||||
|
||||
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
|
||||
@@ -137,6 +148,16 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
||||
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
||||
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
||||
|
||||
**Проверка, охватывающая две секции разом, живёт методом на корневой `Config`.**
|
||||
Такая сегодня одна — `ValidateTestHeaders`: она судит `[server] debug` против
|
||||
`[auth.test_headers]`, и ни в `Validate()` секции сервера, ни в `Validate()`
|
||||
секции входа не помещается — секция начала бы знать о чужой секции. Зовётся она
|
||||
из `cmd/transcriber` рядом с остальными. Имена принимаемых заголовков приходят
|
||||
ей **доводом**, а не читаются из пакета настроек: дом у них один — константы
|
||||
транспорта, — а `internal/config` транспорта не знает и знать не должен, иначе
|
||||
`cmd/devtools`, которому нужен один разбор конфига, линковал бы всю поверхность
|
||||
HTTP.
|
||||
|
||||
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
|
||||
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
|
||||
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
|
||||
|
||||
@@ -173,3 +173,10 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
|
||||
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
|
||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||
|
||||
*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не
|
||||
устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка
|
||||
возвращается на первом несовпадении. Заметило ревью задачи
|
||||
`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили
|
||||
`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг
|
||||
впервые, чинит одну ошибку за прогон.
|
||||
|
||||
@@ -87,6 +87,11 @@ stdlib-логом в поток ошибок. Это выбор, а не дол
|
||||
*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
|
||||
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
|
||||
|
||||
*Изъятие:* строка о подставленных заголовках входа
|
||||
(`internal/controller/http/substitute.go`) адресована разработчику, а идёт на
|
||||
`INFO`: `DEBUG` включить нечем, а в бою она не пишется вовсе — подстановку
|
||||
держит выключенный умолчанием предохранитель `[server] debug`.
|
||||
|
||||
## Время
|
||||
|
||||
- Поле — `time` (ключ `slog` по умолчанию).
|
||||
|
||||
+10
-3
@@ -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).
|
||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||
обрабатываем.
|
||||
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина
|
||||
|
||||
Отвечает на вопрос, возникший по ходу задачи `config-test-headers-login`: что
|
||||
делает декодер настроек с ключом и секцией, которых структура не знает, и виден
|
||||
ли этот случай хоть чем-нибудь. Наблюдение понадобилось потому, что ревью нашло
|
||||
опечатку в имени новой секции `[auth.test_headers]`, проходящую молча, и без
|
||||
разреза нельзя было сказать, где кончается предмет задачи и начинается свойство
|
||||
самой библиотеки.
|
||||
|
||||
Соседняя записка о той же библиотеке — [toml-decode-errors.md](toml-decode-errors.md)
|
||||
— разбирает семейства **отказов**; здесь предмет обратный: случай, отказа не
|
||||
дающий.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Прогонами на зависимости, зафиксированной в `go.mod`:
|
||||
`github.com/BurntSushi/toml` версии **v1.5.0**. Оба уровня снял триаж ревью
|
||||
2026-08-23, отчёт —
|
||||
[triage-2026-08-23.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md),
|
||||
находка 2 и факт, подтверждённый разбором прохода `operations`. Временные файлы
|
||||
прогонов удалены, бинарник поднимался в каталог вне репозитория, не в `data/`.
|
||||
|
||||
- **Модульный.** Вход
|
||||
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"` —
|
||||
опечатка в имени секции.
|
||||
- **Сквозной.** Настоящий бинарник на конфиге с той же опечаткой, порт 18099,
|
||||
каталог данных вне репозитория; проба `curl /app/me`.
|
||||
|
||||
## Что выяснилось
|
||||
|
||||
- **Незнакомая секция и незнакомый ключ отказа не дают: `decode err=<nil>`.**
|
||||
Разбор проходит целиком, поля структуры остаются нулевыми, и отличить «в файле
|
||||
этого нет» от «в файле это написано с опечаткой» по результату разбора нельзя.
|
||||
В прогоне: `Server.Debug=true len(TestHeaders)=0`.
|
||||
- **Потерянное называет только `MetaData.Undecoded()`.** Он возвращает перечень
|
||||
путей, которых структура не знала: `[auth.test_headrs
|
||||
auth.test_headrs.Remote-User]`. Значение это в проекте не читает никто — ни
|
||||
загрузка настроек, ни проверки старта.
|
||||
- **Контроль показывает, что дело в уровне, а не в разборе вообще.** Ту же
|
||||
опечатку **внутри** известной секции (`Remote-Usr` вместо `Remote-User`)
|
||||
ловит проверка старта — `auth: секция [auth.test_headers] называет
|
||||
заголовок, которого сервис не читает: Remote-Usr`, — потому что судит её код
|
||||
проекта, а не библиотека. Ошибка в имени самой секции до этого кода не
|
||||
доходит.
|
||||
- **Сквозной прогон следа не оставляет вовсе.** Бинарник поднимается без
|
||||
предупреждения, `curl /app/me` отвечает `401`, а в журнале стоит только
|
||||
`INFO "Incoming request" … http.status_code=401`.
|
||||
- **Отсюда направление отката бинарника безопасно.** Прежний образ, получивший
|
||||
конфиг с ключами, которых его структура ещё не знает, эти ключи игнорирует и
|
||||
поднимается. Свойство держится ровно на тишине выше: перечень
|
||||
`MetaData.Undecoded()` никто не судит.
|
||||
|
||||
## Что из этого следует для кода
|
||||
|
||||
Свойство сегодня используется, а не терпится: правило выкладки «конфиг после
|
||||
образа» опирается именно на него, и его дом — [../architecture.md](../architecture.md),
|
||||
«Эксплуатация». Здесь записано, чем свойство обеспечено и как проверено, а не
|
||||
надо ли его менять.
|
||||
|
||||
Отсюда же цена любой будущей проверки `MetaData.Undecoded()`: непонятый ключ,
|
||||
роняющий старт, закрывает опечатки во всех секциях разом — и тем же движением
|
||||
снимает безопасность отката, потому что прежний образ перестанет поднимать
|
||||
конфиг новее себя. Разменивать одно на другое — отдельное решение владельца,
|
||||
а не попутная правка.
|
||||
|
||||
**Наблюдение привязано к версии.** Версия, начавшая судить незнакомые ключи
|
||||
сама, сменит оба следствия разом — молчаливую опечатку и безопасный откат.
|
||||
+23
-9
@@ -31,6 +31,13 @@
|
||||
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
|
||||
остаётся сверка триажа.
|
||||
|
||||
Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
|
||||
не единичный случай: строки о потолке не дал ни один из шести проходов, а
|
||||
заметил это снова только триаж. Прогон при этом шёл с меткой `large`, то есть с
|
||||
самым широким составом, — и разница с прошлым разом ровно в числе проходов,
|
||||
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
|
||||
а не как свойство одного из них.
|
||||
|
||||
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
|
||||
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
|
||||
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
|
||||
@@ -178,12 +185,17 @@
|
||||
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
|
||||
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
|
||||
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
|
||||
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте
|
||||
`cmd/devtools proxy`, а проверки ставят заголовок сами и подставного собеседника
|
||||
не держат вовсе. Тем же вопросом судится подставной распознаватель. Записанной
|
||||
конвенции о единственном доме подставных внешних собеседников у проекта нет,
|
||||
поэтому спрашивать надо, а не считать нарушением (ревью задачи про заглушку
|
||||
OIDC, 2026-08-15).
|
||||
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал
|
||||
`cmd/devtools proxy`, а 2026-08-23 задачей `config-test-headers-login` убран и
|
||||
он: заголовки входа подставляет сам сервис под предохранителем
|
||||
`[server] debug`. Проверки ставят заголовок сами и подставного собеседника не
|
||||
держат вовсе. Тем же вопросом судится подставной распознаватель. **Пробел
|
||||
закрыт той же задачей:** норма о подставных собеседниках записана в
|
||||
[architecture.md](architecture.md), «Принципы» — пункт «Подставной собеседник
|
||||
в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается.
|
||||
Вопрос при этом остаётся вопросом:
|
||||
норма называет, где собеседнику жить, а не сколько домов у него уже завелось
|
||||
(ревью задачи про заглушку OIDC, 2026-08-15).
|
||||
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||||
@@ -332,9 +344,11 @@ API и имя не откатываются обратной правкой по
|
||||
остановку; нельзя — расшифровку и заливку.
|
||||
|
||||
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
|
||||
сессию в прогоне выдать было нечем; теперь заголовок ставит `cmd/devtools
|
||||
proxy`, и живьём проверяются узнавание, заведение учётной записи первым
|
||||
обращением, отказ с недоверенного адреса и отказ старта на пустом перечне.
|
||||
сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
|
||||
секции `[auth] test_headers` под предохранителем `[server] debug` — прежде
|
||||
`cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
|
||||
заведение учётной записи первым обращением, отказ с недоверенного адреса и
|
||||
отказ старта на пустом перечне.
|
||||
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
|
||||
контуре (см. «Не проверит ни один проход»).
|
||||
|
||||
|
||||
+34
-9
@@ -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` поимённо.
|
||||
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
||||
доверяем полностью.
|
||||
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
||||
|
||||
@@ -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 разбирает перечень доверенных адресов.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -23,6 +23,15 @@ const (
|
||||
EmailHeader = "Remote-Email"
|
||||
)
|
||||
|
||||
// IdentityHeaderNames отдаёт имена заголовков входа целиком, в одном порядке.
|
||||
//
|
||||
// Перечисление тройки живёт здесь и только здесь. Всякий, кому нужен её состав —
|
||||
// слой подстановки, проверка старта, строка журнала, — берёт его отсюда: второй
|
||||
// список разошёлся бы с первым молча, а имена нормативны.
|
||||
func IdentityHeaderNames() []string {
|
||||
return []string{LoginHeader, NameHeader, EmailHeader}
|
||||
}
|
||||
|
||||
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
|
||||
// Читает его только ограничитель частоты: барьером узнавания он не служит и
|
||||
// служить не может — кто пришёл, решает адрес самого соединения.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,10 @@ type envOptions struct {
|
||||
metaviewer contract.AudioMetaViewer
|
||||
dist fs.FS
|
||||
built bool
|
||||
// substitution — имитация заголовков входа отладочного запуска. Пустая
|
||||
// значит «предохранитель выключен»: слой подстановки в цепочку не встаёт,
|
||||
// и поверхность собирается ровно та же, что в бою.
|
||||
substitution map[string]string
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
@@ -306,7 +310,7 @@ func setupEnv(t *testing.T, opts envOptions) *testEnv {
|
||||
handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)
|
||||
|
||||
mounts := ServiceMounts(
|
||||
AppChain(handler.Routes(), users, testTrustedNetworks(t), logger),
|
||||
AppChain(handler.Routes(), users, testTrustedNetworks(t), opts.substitution, logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
webapp := NewWebappHandler(opts.dist, opts.built, logger)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-23
|
||||
@@ -0,0 +1,361 @@
|
||||
## Context
|
||||
|
||||
Сервис не ведёт входа сам: кто пришёл, называет обратный прокси заголовками
|
||||
`Remote-User`, `Remote-Name` и `Remote-Email`, а сервис верит им, когда
|
||||
соединение открыто с адреса из перечня `[auth] trusted_proxies`. Имена
|
||||
заголовков объявлены константами транспорта в
|
||||
`internal/controller/http/identity.go`, и там же записано, что настройкой их не
|
||||
делают. Барьер описан в `docs/security.md`, «Периметр», и нормирован спекой
|
||||
`access`.
|
||||
|
||||
На машине разработчика прокси нет, браузер заголовков не ставит, и приложение
|
||||
локально не открылось бы вовсе. На это место сегодня встаёт подставной прокси
|
||||
`go run ./cmd/devtools proxy`: отдельный процесс, слушающий свой порт, ставящий
|
||||
три заголовка и переправляющий запрос сервису. Локальный запуск поэтому идёт в
|
||||
два процесса, приложение открывают по адресу прокси, а перечень доверенных
|
||||
адресов в конфиге приходится переставлять на петлевой.
|
||||
|
||||
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
|
||||
Изменение переносит подстановку заголовков в сам сервис и закрывает её
|
||||
предохранителем.
|
||||
|
||||
Ограничение, из которого читается всё остальное: **подстановка заголовков — это
|
||||
настройка, которой сервис называет пришедшего сам, никого не спросив.** Всякий,
|
||||
до кого она дотянется, получит чужой архив. Дизайн поэтому наполовину состоит из
|
||||
того, что подстановке запрещено.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
|
||||
назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
|
||||
настроек, и ничем больше.
|
||||
- Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
|
||||
неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
|
||||
- Настройка, при которой отладочный вход становится открытым входом, роняет
|
||||
старт, а не работает молча.
|
||||
- Отладочный запуск виден владельцу в журнале.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Уровень журнала, тексты внутренних отказов в ответах, подмена распознавателя,
|
||||
снятие ограничителя частоты, послабления проверок конфига — ничего этого новый
|
||||
признак не включает (см. решение 7).
|
||||
- Своего входа, ролей и проверки допуска сервис по-прежнему не заводит:
|
||||
подстановка называет пришедшего, а не судит его.
|
||||
- Автотесты и разработческие сценарии, которым нужна учётная запись, продолжают
|
||||
ставить заголовок сами и в подстановке не нуждаются.
|
||||
- Граница работы — заголовки входа. Подмена распознавания в конфиг не
|
||||
переезжает: `internal/adapter/recognizer/memory.go` подставляется правкой
|
||||
кода, и решением владельца так и остаётся.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Где подставляются заголовки: отдельный слой перед узнаванием
|
||||
|
||||
**Решено:** отдельный слой цепочки корня приложения, стоящий **перед**
|
||||
`TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
|
||||
запроса и ничего больше не делает: учётной записи не заводит, отказов не
|
||||
выдаёт, в контекст не пишет. Узнавание получает запрос, неотличимый от
|
||||
пришедшего с сервера.
|
||||
|
||||
Слой собирается только при включённом предохранителе и непустой имитации. При
|
||||
выключенном — цепочка та же, что сегодня, без единого лишнего звена.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Подстановка внутри `TrustedHeaderIdentity`** — узнавание получило бы второй
|
||||
источник значений и ветку, которой в бою нет. Отлаживалась бы не боевая ветка,
|
||||
а её отладочный двойник, и это ровно то, чего изменение обязано избежать.
|
||||
- **Подстановка в точке входа, до сборки цепочки** — подставлять нечего: значения
|
||||
ставятся на запрос, а не на сервис.
|
||||
- **Слой снаружи ограничителя частоты** — ограничитель считает по адресу
|
||||
спрашивающего, и заголовки входа его не касаются. Ставить слой снаружи значит
|
||||
трогать порядок, который сегодня верен, без единой причины.
|
||||
|
||||
Что человек увидит иначе: приложение открывается по адресу сервиса, второго
|
||||
порта нет, и в журнале запроса стоят те же строки узнавания, что на сервере.
|
||||
|
||||
### 2. Область слоя: только корень приложения
|
||||
|
||||
Слой вешается туда же, где висит узнавание, — на цепочку корня приложения, — и
|
||||
область его выводится из того же перечня адресов, а не перечисляется вторым
|
||||
списком. Проба здоровья, метрики и раздача приложения подстановки не видят: там
|
||||
учётной записи нет и не нужно.
|
||||
|
||||
### 3. `debug = false` при непустой имитации: отказ старта
|
||||
|
||||
**Решено:** старт роняется с именем ключа. Не предупреждение и не молчаливое
|
||||
игнорирование.
|
||||
|
||||
Причина: состояние «имитация заполнена, предохранитель выключен» не читается
|
||||
никак, и обе его прочтения — поломка.
|
||||
|
||||
- Человек забыл включить предохранитель. Сервис поднимется, никого не узнает, и
|
||||
человек будет искать поломку во входе, в прокси и в перечне адресов — везде,
|
||||
кроме одного булева ключа. Предупреждение в журнале его не спасёт: журнал
|
||||
локального запуска читают тогда, когда уже что-то сломалось.
|
||||
- Человек забыл убрать имитацию из боевого файла. Тогда в выкладке лежит конфиг,
|
||||
которому недостаёт одного слова, чтобы открыть архив всем. Отказ старта — тот
|
||||
случай, когда поломку ловят на порядок раньше, чем она стоит денег.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Молчаливое игнорирование** — прямо против тона проекта: инвариант «Принятая
|
||||
запись не теряется молча» описывает ту же болезнь на соседнем предмете.
|
||||
- **Предупреждение в журнале** — оставляет заполненную имитацию в боевом файле
|
||||
жить сколько угодно.
|
||||
|
||||
Обратная сторона: `debug = true` при пустой имитации отказом **не** считается.
|
||||
Это законный запуск — признак сам по себе ничего не включает.
|
||||
|
||||
### 4. `debug = true` в бою: чем опасно и что ограничивает подстановку
|
||||
|
||||
Опасность названа моделью угроз прямо: всякий, кто дотянулся до сервиса,
|
||||
называет себя кем угодно и получает чужой архив. Подстановка делает это хуже
|
||||
барьера `trusted_proxies` по двум причинам. Первая: подставляется по
|
||||
**отсутствию** заголовка, то есть запрос, пришедший мимо прокси, — именно тот,
|
||||
который сегодня остаётся неузнанным, — получит имя. Вторая: требование к Caddy
|
||||
«заголовки `Remote-*` перезаписывать, а не пропускать» здесь не помогает вовсе,
|
||||
потому что перезаписывать нечего.
|
||||
|
||||
**Решено — одно ограничение: подставленный заголовок проходит тот же барьер,
|
||||
что и настоящий.** Слой подставляет значения только тогда, когда адрес
|
||||
соединения попадает в
|
||||
`trusted_proxies`. Судит он его **той же функцией**, какой судит узнавание:
|
||||
разбор адреса пира и сверка с перечнем берутся из одного дома
|
||||
(`internal/controller/http/identity.go`), а не пишутся вторым списком, — так же,
|
||||
как решение 6 поступает с именами заголовков. Второй сверщик разошёлся бы с
|
||||
первым молча: узнавание разворачивает IPv4 в оболочке IPv6, и слой, написавший
|
||||
свою сверку, отличался бы от него ровно на тех адресах, ради которых заводится.
|
||||
|
||||
Это не предохранитель, а согласованность с барьером узнавания: от конфига она не
|
||||
требует ничего и круга тех, кто мог назваться кем угодно, не расширяет — он и
|
||||
так очерчен перечнем.
|
||||
|
||||
**Адресного предохранителя не делаем.** Решение владельца на чекпоинте,
|
||||
дословно: «предохранитель по адресам не делаем, полагаемся только на параметр
|
||||
debug». Рассматривалось требование, чтобы при включённом предохранителе перечень
|
||||
доверенных адресов состоял только из петлевых записей; оно снято вместе с
|
||||
предикатом «петлевая запись», который заводился ровно ради него.
|
||||
|
||||
Что этим куплено: отладочный вход работает **внутри контейнера** — адрес пира
|
||||
там принадлежит сети докера, и она же стоит в боевом перечне, — а локальный
|
||||
прогон не переставляет перечень доверенных адресов на петлевой: петлевые записи
|
||||
добавляются к тем, что в нём уже стоят.
|
||||
|
||||
Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
|
||||
машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
|
||||
имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, — а
|
||||
доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
|
||||
пришедший обычным путём**. Между боевой выкладкой и открытым входом остаётся
|
||||
три вещи, и других нет: умолчание предохранителя «выключено»; отказ старта при
|
||||
заполненной имитации без предохранителя (решение 3); боевой конфиг, который
|
||||
рендерится шаблоном Ansible, а не копируется с машины разработчика.
|
||||
|
||||
Что человек увидит иначе: отладочный вход работает везде, где адрес соединения
|
||||
попадает в перечень доверенных, — и на машине разработчика, и внутри
|
||||
контейнера. Перечень при этом остаётся тем, что он есть: границей доверия, а не
|
||||
признаком отладки.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Требовать петлевой перечень при включённом предохранителе** — снято
|
||||
решением владельца, причина выше. Держалось предикатом «петлевая запись»,
|
||||
который заводился ради этой одной проверки.
|
||||
- **Принудительно слушать петлевой адрес при включённом предохранителе** —
|
||||
меняет поведение молча (адрес прослушивания задан числом порта, и человек
|
||||
увидел бы «порт занят никем»), и закрывает ровно то, что решение покупает:
|
||||
внутри контейнера сервис слушает не петлю.
|
||||
- **Новый ключ `[server] listen`** — публичная поверхность настроек ради
|
||||
предохранителя, которого решением владельца нет.
|
||||
- **Вырезать подстановку из боевой сборки тегом сборки** — сборка образа в гейте
|
||||
не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег, забытый в одной ступени,
|
||||
дал бы ровно ту тишину, которой избегает пункт 3.
|
||||
|
||||
### 5. Уже пришедший заголовок: подставляем только при отсутствии `Remote-User`
|
||||
|
||||
**Решено:** решение принимает **наличие `Remote-User`**, а не его значение.
|
||||
Заголовок есть в любом числе значений — слой не трогает запрос вовсе и передаёт
|
||||
его дальше как есть. Заголовка нет ни одного — слой ставит всю тройку
|
||||
`Remote-*`, которой распоряжается: названные имитацией — значением из конфига,
|
||||
не названные — удаляет.
|
||||
|
||||
Почему по наличию, а не по значению: узнавание отвергает запрос с более чем
|
||||
одним `Remote-User`, и это его главная защита от прокси, который заголовок
|
||||
добавляет вместо замены. Слой, который «дополнил бы пустое место», сам создал бы
|
||||
второе значение и превратил бы законный отказ в отладочный проход.
|
||||
|
||||
Почему удаляем не названные имитацией: запрос без `Remote-User`, но с
|
||||
`Remote-Email`, иначе собрал бы личность из конфига и из присланного — логин
|
||||
свой, почта чужая. Слой владеет тройкой целиком или не трогает её вовсе.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Заменять всегда** — сервис, стирающий сказанное прокси, перестаёт быть
|
||||
похожим на боевой, и ветки «пришло два значения» и «заголовок с недоверенного
|
||||
адреса» становятся локально невоспроизводимыми.
|
||||
- **Подставлять по пустому значению `Remote-User`** — пустое значение штатно
|
||||
шлёт прокси там, где никого не назвал; подстановка на этом месте назвала бы
|
||||
человеком того, кому провайдер отказал.
|
||||
|
||||
### 6. Имена заголовков: ключами конфига, но перечень порождается константами
|
||||
|
||||
Цена названа прямо: сегодня имена — константы транспорта, и у них один дом.
|
||||
Секция, где имя заголовка служит именем ключа, даёт этим именам **второе
|
||||
упоминание**, а второе упоминание расходится с первым молча.
|
||||
|
||||
**Решено:** имя ключа секции — имя заголовка, но набор принимаемых имён
|
||||
порождается теми же константами транспорта. Ключ, не совпавший ни с одной из
|
||||
них, роняет старт и называет принимаемые имена. Сравнение идёт по каноническому
|
||||
виду имени заголовка, потому что в HTTP имя нечувствительно к регистру, а в TOML
|
||||
ключ чувствителен.
|
||||
|
||||
Так второго **дома** не появляется: дом остаётся один, а конфиг лишь называет
|
||||
его содержимое, и совпадение сверяет машина, а не внимательность.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Произвольная карта имён** — опечатка `Remote-Usr` даёт «сервис меня не
|
||||
узнаёт» без единого следа, а выгоды нет: сервис читает ровно три имени и
|
||||
четвёртое читать не умеет.
|
||||
- **Свои имена ключей вместо имён заголовков** (`login`, `name`, `email`) — самое
|
||||
дешёвое для инварианта, но теряет то, ради чего секция заводится: конфиг
|
||||
перестаёт выглядеть как то, что ставит Caddy, и владелец назвал форму прямо.
|
||||
Оставлено вариантом на случай, если человек решит платить иначе.
|
||||
|
||||
Замечание по форме: в TOML пара пишется через `=`, а не через `:`. Пример
|
||||
владельца записывается так:
|
||||
|
||||
```toml
|
||||
[auth.test_headers]
|
||||
Remote-User = "foobar"
|
||||
Remote-Email = "foobar@example.com"
|
||||
```
|
||||
|
||||
### 7. Что означает `[server] debug` и чего он не включает
|
||||
|
||||
Признак означает одно: **этот прогон идёт на машине разработчика, и сервису
|
||||
позволено подставить то, что в бою даёт окружение.** Сегодня подставляется
|
||||
ровно одна вещь — заголовки входа.
|
||||
|
||||
Чего признак **не** включает и включать не должен без отдельного решения
|
||||
человека:
|
||||
|
||||
- уровень журнала (он зашит и настройкой не является);
|
||||
- тексты внутренних отказов и следы стека в ответах;
|
||||
- вывод тел запросов и ответов внешних сервисов;
|
||||
- снятие или послабление ограничителя частоты;
|
||||
- подмену распознавателя памятью — это подстановка в коде,
|
||||
`internal/adapter/recognizer/memory.go`, и настройкой она не делается;
|
||||
- послабление любой проверки старта;
|
||||
- открытие любого адреса неузнанному.
|
||||
|
||||
Правило для следующей задачи: новое поведение вешается на этот ключ только
|
||||
решением владельца и получает **свою** строку в спеке `access` или в спеке своей
|
||||
capability. Ключ — предохранитель с закрытым перечнем следствий, а не режим с
|
||||
открытым.
|
||||
|
||||
### 8. Где живёт проверка
|
||||
|
||||
Проверка охватывает две секции разом — `[server] debug` против
|
||||
`[auth] test_headers` и `[auth] trusted_proxies`, — и потому не помещается ни в
|
||||
`AuthConfig.Validate()`, ни в проверку секции сервера. **Решено:** метод на
|
||||
корневой `Config`, названный своим предметом, зовётся из `cmd/transcriber` рядом
|
||||
с тремя имеющимися проверками. Закрыть расхождение «единого места проверки нет»,
|
||||
названное в `docs/conventions/config.md`, он не пытается: это отдельная работа.
|
||||
|
||||
Рассмотрено и отвергнуто: передавать признак отладки внутрь
|
||||
`AuthConfig.Validate()` — секция начала бы знать о чужой секции ради одного
|
||||
булева значения.
|
||||
|
||||
### 9. Журнал
|
||||
|
||||
- **Старт с включённой подстановкой** — уровень `WARN`, один раз. Адресат —
|
||||
владелец, и сообщение ровно того рода, который конвенция называет «может стать
|
||||
проблемой»: сервис называет пришедшего сам. Поля: `capability` `access`,
|
||||
перечень **имён** подставляемых заголовков. Значений в строке нет: логин — это
|
||||
ключ к чужому архиву, и спека `access` запрещает его печатать наравне с
|
||||
адресом почты.
|
||||
- **Запрос, которому заголовки подставлены** — уровень `DEBUG`. Случай штатный и
|
||||
частый: так выглядит каждый запрос локального прогона, и `INFO` затопил бы
|
||||
журнал. Поля те же, что у узнавания: `http.peer_addr`, `capability`,
|
||||
`transport`.
|
||||
- **Запрос, которому подставить нельзя** (предохранитель включён, но адрес пира
|
||||
не доверенный) — уровень `WARN`, своей строкой. Поля: `http.peer_addr`,
|
||||
`capability`, `transport`; подставляемых значений в ней нет.
|
||||
|
||||
Прежняя посылка «узнавание уже пишет здесь предупреждение» неверна, и это
|
||||
проверено по коду. Ветка узнавания выбирается по числу значений `Remote-User`
|
||||
**до** сверки адреса пира: заголовка нет — пишется `Debug("Request carries no
|
||||
login header")`, и запрос уходит дальше. Предупреждение `Login header came
|
||||
from an untrusted peer` достижимо только тогда, когда заголовок **есть**, а
|
||||
подстановка работает ровно при его отсутствии. На отказном пути подстановки
|
||||
предупреждения не бывает никогда.
|
||||
|
||||
Без своей строки самый частый локальный отказ остаётся без следа: браузер идёт
|
||||
на `localhost`, тот разрешается в `::1`, а в `trusted_proxies` по образцу
|
||||
стоит `127.0.0.1`. Человек видит `401` на всём приложении, заполненную секцию
|
||||
имитации, включённый предохранитель — и ни одной строки о том, что подстановка
|
||||
не сработала. Отсюда же требование к образцу конфига: рядом с `127.0.0.1` в
|
||||
перечне обязан стоять `::1`.
|
||||
- Строка `Account created from login header` работает как работала: первый
|
||||
запрос локального прогона заводит учётную запись и виден на `INFO`.
|
||||
|
||||
### 10. Подкоманда `cmd/devtools proxy` убирается целиком
|
||||
|
||||
**Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
|
||||
остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
|
||||
входить локально не остаётся тоже.
|
||||
|
||||
Цена названа и принята: поломки контура — два значения `Remote-User`, заголовок
|
||||
с недоверенного адреса, цепочка `X-Forwarded-For` — местным инструментом больше
|
||||
не воспроизводятся. Воспроизводит их теперь только автотест, ставящий заголовок
|
||||
сам.
|
||||
|
||||
Уходит вместе с подкомандой: её флаги, шапка пакета `cmd/devtools/main.go` — она
|
||||
сегодня говорит «подкоманд две» и описывает прокси, — строка подкоманды в
|
||||
`usage()` и упоминания прокси в документах. Адреса перечислены шагом 4.4
|
||||
`tasks.md`.
|
||||
|
||||
Рассмотрено и отвергнуто владельцем:
|
||||
|
||||
- **Оставить как есть** — два способа войти локально, и документация обязана
|
||||
каждый раз говорить, какой из них чей.
|
||||
- **Оставить, сузив назначение** до воспроизведения поломок контура — тот же
|
||||
второй способ, только не названный в инструкции локального запуска.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Отладочный вход уезжает в боевой конфиг** → умолчание предохранителя
|
||||
«выключено» и отказ старта при заполненной имитации без предохранителя
|
||||
(решение 3). Проверка идёт на старте, до приёма трафика, и ловит она ровно
|
||||
этот случай — заполненную имитацию при выключенном предохранителе.
|
||||
- **Разработчик оставил `debug = true` и выложился** → выкладка рендерит конфиг
|
||||
из шаблона Ansible, а не копирует локальный файл. Другой защиты нет, и это
|
||||
принято решением владельца (решение 4): боевой сервис с включённым
|
||||
предохранителем и заполненной имитацией отдаст архив всякому, чей запрос
|
||||
пришёл через обратный прокси, то есть всякому, кто пришёл обычным путём.
|
||||
- **Имя заголовка в конфиге разошлось с константой транспорта** → перечень
|
||||
принимаемых имён порождается константами, неизвестный ключ роняет старт
|
||||
(решение 6).
|
||||
- **Поломки контура перестали воспроизводиться локально** → подкоманда `proxy`
|
||||
убрана (решение 10); остаются автотесты, которые ставят заголовок сами.
|
||||
- **Ключ `debug` обрастёт чужими следствиями** → закрытый перечень в решении 7 и
|
||||
правило «новое следствие — своя строка в спеке».
|
||||
- **Тестовый пользователь копится в базе.** Каждый новый логин в имитации заводит
|
||||
учётную запись, а удалять учётные записи сервис не умеет. Локальная база
|
||||
ронется и пересоздаётся свободно; в бою подстановка выключена, а включённая
|
||||
была бы поломкой много хуже лишней учётной записи.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Шага схемы изменение не требует, данных не трогает. Существующий конфиг
|
||||
продолжает работать без правки: умолчание предохранителя — «выключено», секция
|
||||
имитации по умолчанию пуста, и цепочка слоёв при этом та же, что сегодня.
|
||||
Откат — удаление двух ключей из конфига.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Развилок не осталось: все закрыты владельцем на чекпоинте.
|
||||
@@ -0,0 +1,74 @@
|
||||
## Why
|
||||
|
||||
Чтобы открыть приложение на своей машине, сегодня мало запустить сервис: рядом
|
||||
надо поднять второй процесс — подставной обратный прокси из оснастки, — потому
|
||||
что сервис узнаёт пришедшего по заголовку, а браузер заголовков не ставит. Два
|
||||
процесса вместо одного, свой порт у каждого и правило «приложение открывают по
|
||||
адресу прокси, а не по адресу сервиса» — всё это надо помнить и объяснять
|
||||
каждому, кто пришёл в проект.
|
||||
|
||||
Владелец назвал желаемое прямо: один бинарник, а различия между запусками
|
||||
задаются конфигом. Тестовый прогон должен подниматься тем же
|
||||
`go run ./cmd/transcriber -c config.toml`, что и всякий другой, и отличаться от
|
||||
боевого только содержимым файла настроек.
|
||||
|
||||
## What Changes
|
||||
|
||||
- В секцию `[auth]` добавляется подсекция `test_headers` — имитация заголовков,
|
||||
которые сервису на сервере ставит обратный прокси. Ключи подсекции задают
|
||||
значения, которыми сервис назовёт пришедшего сам, когда тот пришёл ни с чем.
|
||||
- В секцию `[server]` добавляется предохранитель `debug` — признак отладочного
|
||||
запуска со значением по умолчанию «выключено». Подстановка заголовков работает
|
||||
только при включённом признаке.
|
||||
- Старт получает новые правила отказа. Заполненная имитация при выключенном
|
||||
предохранителе роняет сервис, а не игнорируется молча; имитация, при которой
|
||||
узнавание не состоится ни при каком запросе — неизвестное имя заголовка,
|
||||
отсутствующий или негодный `Remote-User`, — роняет его тоже.
|
||||
- Владелец обязан видеть отладочный запуск в журнале сервиса — предупреждением
|
||||
при старте и на каждом подставленном запросе.
|
||||
- Подставной обратный прокси `go run ./cmd/devtools proxy` убирается целиком:
|
||||
всё, ради чего его поднимали, делает сам сервис, и второго способа входить
|
||||
локально не остаётся. Решение владельца на чекпоинте; цена — поломки контура
|
||||
местным инструментом больше не воспроизводятся.
|
||||
- Образец конфига и памятка описывают локальный вход через настройки вместо
|
||||
запуска второго процесса.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых capability изменение не заводит: предмет прежний — кто пришёл в сервис и
|
||||
пускают ли его дальше.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `access`: узнавание получает второй источник заголовков — настройки сервиса —
|
||||
и правила, при которых этот источник законен. Требования: подстановка идёт
|
||||
только при включённом предохранителе, только при отсутствии пришедшего
|
||||
заголовка, только с адресов, которым сервис и так верит; заполненная имитация
|
||||
без предохранителя роняет старт; отладочный запуск виден в журнале, а значения
|
||||
подставленного заголовка в журнале по-прежнему нет.
|
||||
|
||||
## Impact
|
||||
|
||||
- Настройки: `internal/config` — структуры секций `[server]` и `[auth]`,
|
||||
умолчания, проверки старта. Имя нового ключа конфига необратимо и решается
|
||||
человеком.
|
||||
- Вход: `internal/controller/http` — цепочка слоёв корня приложения и
|
||||
узнавание по заголовку.
|
||||
- Точка входа: `cmd/transcriber` — порядок проверок при старте и сборка
|
||||
цепочки.
|
||||
- Оснастка: `cmd/devtools` — подкоманда `proxy` удаляется вместе с флагами,
|
||||
шапкой пакета и строкой `usage()`.
|
||||
- Документы: `config.example.toml`, `CLAUDE.md`, `README.md`,
|
||||
`docs/security.md`, `docs/conventions/config.md`, `docs/architecture.md` —
|
||||
везде, где локальный вход описан через второй процесс.
|
||||
- Граница домена: `docs/passport.md`, «Управление учётными записями». Сегодня
|
||||
он утверждает, что кто пришёл, сервис не решает никогда, и что исключений у
|
||||
этого больше нет. Изменение заводит исключение, и паспорт обязан назвать его
|
||||
одной строкой с границей — предохранитель `[server] debug`, выключенный по
|
||||
умолчанию, — и ссылкой на спеку `access`.
|
||||
- Периметр: изменение заводит настройку, которой сервис пускает кого угодно.
|
||||
Машиной боевая поломка не исключена: сервис с включённым предохранителем и
|
||||
заполненной имитацией поднимется на любом перечне доверенных адресов. Что
|
||||
стоит между боевой выкладкой и открытым входом — в `design.md`, решение 4.
|
||||
+725
@@ -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=<nil>`, `undecoded=[auth.test_headrs auth.test_headrs.Remote-User]`,
|
||||
`Server.Debug=true len(TestHeaders)=0 substituting=false`,
|
||||
`ValidateTestHeaders err = <nil>`. Контроль: та же опечатка **внутри** секции
|
||||
(`Remote-Usr`) ловится —
|
||||
`auth: секция [auth.test_headers] называет заголовок, которого сервис не читает: Remote-Usr`.
|
||||
(2) Сквозной: настоящий бинарник на конфиге с `[auth.test_headrs]` поднимается
|
||||
без единого предупреждения, `curl /app/me` → `401`, в журнале только
|
||||
`INFO "Incoming request" ... http.status_code=401`.
|
||||
- Последствие: `design.md` заявляет защиту словами «опечатка в имени иначе
|
||||
кончается сервисом, который никого не узнаёт, без единого следа», и защита эта
|
||||
покрывает опечатку в имени **ключа**, но не в имени **секции**. Разница для
|
||||
человека нулевая — он ошибается в обеих строках одинаково, — а исход
|
||||
противоположный: в одном случае отказ старта с именем ключа, в другом
|
||||
безмолвный `401` на всём приложении. Стоимость несёт каждый первый локальный
|
||||
запуск, потому что подкоманды `devtools proxy` больше нет и обходного пути не
|
||||
осталось. Отдельно: та же дыра проглатывает любую опечатку в **любой** секции
|
||||
конфига, не только в новой.
|
||||
- Предложение: развилка ниже.
|
||||
- Найдено проходами: `specs` (S3), `ops` (O1, первое звено); оракул добыт триажем
|
||||
заново.
|
||||
- **Действие: развилка.**
|
||||
|
||||
> `toml.DecodeFile` возвращает `MetaData`, и в нём лежит перечень ключей,
|
||||
> которых структура не знает. Сервис его отбрасывает, поэтому опечатка в имени
|
||||
> секции проходит молча. Что делаем?
|
||||
>
|
||||
> 1. **Узкая правка в границах задачи:** при `[server] debug = true` и пустой
|
||||
> секции имитации ронять старт (или писать `WARN`), если
|
||||
> `MetaData.Undecoded()` называет что-нибудь под `auth`. Ловит ровно этот
|
||||
> класс, не трогает остальной конфиг, требует одного нового абзаца в
|
||||
> дельта-спеке.
|
||||
> 2. **Правило шире задачи:** судить `MetaData.Undecoded()` на всём конфиге —
|
||||
> непонятый ключ роняет старт с его именем. Закрывает опечатки во всех
|
||||
> секциях разом, но это новая норма в `docs/conventions/config.md` и
|
||||
> отдельный change: сегодняшние конфиги на сервере могут нести ключи,
|
||||
> которых структура уже не знает (постмортем 2 подтвердил, что откат
|
||||
> бинарника поверх нового конфига сегодня безопасен **именно** потому, что
|
||||
> лишние ключи молча игнорируются, — правило это свойство снимет).
|
||||
> 3. **Ничего не делать в коде**, а записать исход строкой в спеке и в
|
||||
> `config.example.toml`: «опечатка в имени секции даёт `401` без следа;
|
||||
> сверяйте имя секции по образцу». Дешевле всего и честнее молчания.
|
||||
|
||||
### 3. Спека объявляет матрицу отказов старта полной и называет три случая, а код даёт четвёртый: следующий change снимет его как самодеятельность, и одно из двух значений секции начнёт теряться молча
|
||||
|
||||
- Файл: `internal/config/test_headers.go:86-93`;
|
||||
`openspec/changes/config-test-headers-login/specs/access/spec.md:109`;
|
||||
`openspec/changes/config-test-headers-login/tasks.md:227-231`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: **свой прогон.** Вход
|
||||
`[server] debug = true` + `[auth.test_headers]` с `"Remote-User" = "one"` и
|
||||
`"remote-user" = "two"`: декодер даёт `len(c.Auth.TestHeaders) == 2`,
|
||||
`HeaderSubstitution()` возвращает `map[Remote-User:two]` — **значение `"one"`
|
||||
потеряно**, — а `ValidateTestHeaders` роняет старт строкой
|
||||
`auth: в секции [auth.test_headers] два ключа называют один заголовок: имя
|
||||
заголовка нечувствительно к регистру, и одно из значений потерялось бы молча`.
|
||||
Дословный текст спеки на строке 109: «Отказом MUST быть **каждый из трёх**
|
||||
случаев»; сценария про совпадение канонических имён в дельте нет. Дословный
|
||||
текст `tasks.md:227`: «Матрица отказов старта **полна и тотальна**: у каждой
|
||||
комбинации новых ключей объявлен исход».
|
||||
- Последствие: реализатор, читающий спеку как полную матрицу — а `tasks.md`
|
||||
велит читать её именно так, — снимет четвёртую проверку как не подпёртую
|
||||
требованием. После снятия конфиг с двумя ключами разного регистра поднимает
|
||||
сервис, схлопывает их в один и молча берёт тот, который последним лёг в карту:
|
||||
порядок перебора TOML не гарантирован, и человек получает то одно значение, то
|
||||
другое от запуска к запуску. Это в точности класс «молчаливая потеря
|
||||
значения», ради которого проверка и написана. Правка целиком в границах
|
||||
задачи: дельта-спека — часть этого change.
|
||||
- Предложение: дописать четвёртый случай в требование «Настройка, открывающая
|
||||
вход всем, роняет старт» и завести к нему сценарий; поправить `tasks.md:227`
|
||||
так, чтобы перечисление читалось «предохранитель × (пустая / заполненная /
|
||||
заполненная негодно / заполненная неоднозначно)».
|
||||
- Найдено проходом: `specs` (S1); оракул добыт триажем заново.
|
||||
- **Действие: инлайн.**
|
||||
|
||||
---
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 4. Бинарник читает `.env`, проект называет его секретосодержащим, а `.gitignore` его не игнорирует: первый же ключ, положенный туда, уезжает в git необратимо
|
||||
|
||||
- Файл: `.gitignore` (нет записи); `.dockerignore:21`;
|
||||
`cmd/transcriber/main.go:114-117`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: **свои команды.** `git check-ignore -v .env` → пусто, `exit=1` (файл не
|
||||
игнорируется). `.dockerignore:19-21` — комментарий «Настройки с секретами.
|
||||
Образ берёт конфиг на сервере, а не из дерева.» и следом строка `.env`.
|
||||
`cmd/transcriber/main.go:115` — `godotenv.Load()`. Прогон настоящего бинарника
|
||||
печатает при каждом старте
|
||||
`level=WARN msg="Warning: .env file not found, using system environment variables"`.
|
||||
При этом `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ | grep -v _test`
|
||||
→ **ни одного совпадения**: переменных окружения сервис не читает нигде.
|
||||
- Последствие: инвариант `CLAUDE.md` — «**Секрет не покидает конфиг.** Ключ
|
||||
SpeechKit и пара ключей Object Storage не попадают в git… Нарушение
|
||||
необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки.
|
||||
**critical**». Путь к нарушению короткий и сервис сам его подсказывает:
|
||||
стартовая `WARN`-строка сообщает владельцу, что `.env` ожидается, тот заводит
|
||||
файл с ключами — и `git add .` кладёт его в индекс, потому что
|
||||
`.gitignore` про `.env` не знает. Единственный барьер — `gitleaks git --staged`
|
||||
на pre-commit, а он судит по образцам и на произвольном ключе SpeechKit молчать
|
||||
вправе. Сама подсказка при этом ложная: ключи из `.env` не сработали бы, читать
|
||||
переменные окружения сервису нечем. **`critical` не ставлю:** пути до
|
||||
настоящего секрета в git я не построил — работающего мотива положить ключ в
|
||||
`.env` у владельца нет, — значит по контракту выше `major` эта находка не
|
||||
поднимается.
|
||||
- Предложение: строку `.env` в `.gitignore` рядом с `config.toml` — это одна
|
||||
строка и весь инлайн. Мёртвый `godotenv.Load()` вместе с зависимостью и
|
||||
вводящей в заблуждение `WARN`-строкой уходит задачей: правка не этого change.
|
||||
- Найдено проходом: `adversary` (свойство без построенного пути); оракул и
|
||||
переформулировка — триаж (проход утверждал, что `.env` читается, но не заметил,
|
||||
что читать из него некому).
|
||||
- **Действие: инлайн** — только строка в `.gitignore`. Снятие `godotenv` —
|
||||
задачей, см. «Урожай».
|
||||
|
||||
### 5. Чужой адрес почты, занятый первым пришедшим, навсегда и молча оставляет без почты его настоящего владельца — и `docs/security.md` утверждает обратное
|
||||
|
||||
- Файл: `internal/controller/http/identity.go:134-138`;
|
||||
`internal/adapter/repo/sqlite/identity.go:77-110`; текст
|
||||
`docs/security.md:71-76`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: **свой падающий сценарий, прогнан на настоящей поверхности сервиса**
|
||||
(`setupEnv` + `AppChain`, база SQLite во временном каталоге, маршрут
|
||||
`/app/me`). Два шага:
|
||||
1. Запрос с `Remote-User: squatter` и `Remote-Email: victim@corp.example` с
|
||||
доверенного адреса → `200`. `SELECT email FROM users WHERE provider_login='squatter'`
|
||||
→ `victim@corp.example`.
|
||||
2. Запрос настоящего владельца: `Remote-User: victim`,
|
||||
`Remote-Email: victim@corp.example` → `200`, `Account created from login header`.
|
||||
`SELECT email ... WHERE provider_login='victim'` → **`""`**. Строк журнала о
|
||||
занятом адресе — ноль.
|
||||
|
||||
Второй прогон показывает асимметрию правила прямо: два значения `Remote-User`
|
||||
→ `401`; два значения `Remote-Email` → `200`, и в базу ложится **первое**
|
||||
(`attacker-first@example.com`).
|
||||
- Последствие: `docs/security.md:71-74` называет прокси, **добавляющий** заголовок
|
||||
вместо замены, главной угрозой периметра и пишет: «Половину этой беды сервис
|
||||
закрывает сам: запрос с двумя значениями `Remote-User` не узнаёт никого».
|
||||
Утверждение верно ровно для одного заголовка из трёх. `Remote-Name` и
|
||||
`Remote-Email` берутся `Get()` — первым значением, то есть присланным анонимом,
|
||||
— и логин от них не защищает. Уникальный индекс `idx_users_email` не даёт
|
||||
двум записям делить адрес, `EnsureUser` найденную запись не переписывает
|
||||
никогда, а ветвь отката заводит настоящего владельца через
|
||||
`insertUser(tx, login, name, "")` **без строки журнала**. Порча тихая и
|
||||
постоянная: обратного пути у неё в коде нет. **Сегодня цена почти нулевая** —
|
||||
`contract.UserAccount` поля `Email` не имеет вовсе, приложение почту не
|
||||
показывает, доступ по ней не выдаётся; поэтому не блокирует мердж. Цена
|
||||
появляется в тот день, когда почту начнут читать (в бэклоге лежит
|
||||
`api-tokens`), и к этому дню колонка уже будет испорчена молча.
|
||||
- Предложение: судить `Remote-Name` и `Remote-Email` тем же правилом, что
|
||||
`Remote-User` — `len(r.Header.Values(name)) != 1` даёт пустое значение, а не
|
||||
первое; ветвь отката уникальности почты обязана оставить строку журнала
|
||||
видимого уровня.
|
||||
- Найдено проходом: `adversary` (V1); оракул добыт триажем заново, обоими
|
||||
звеньями.
|
||||
- **Действие: развилка.**
|
||||
|
||||
> Правка меняет боевой путь узнавания, а не отладочную подстановку, — то есть
|
||||
> выходит за scope этой задачи и требует нового требования в спеке `access`
|
||||
> (сегодня спека нормирует многозначность только для `Remote-User`). Что
|
||||
> делаем?
|
||||
>
|
||||
> 1. **Взять в этот change:** три строки в `identity.go` + строка журнала в
|
||||
> ветви отката + абзац в требование спеки. Логика ровно та же, что слой
|
||||
> подстановки уже применил к себе («Запрос без `Remote-User`, но с
|
||||
> `Remote-Email` иначе собрал бы личность из двух источников»), — правило
|
||||
> просто не донесено до соседних двадцати строк.
|
||||
> 2. **Отдельной задачей**, потому что предмет другой и цена сегодня нулевая:
|
||||
> почту никто не читает. Тогда `docs/security.md:71-74` надо поправить в
|
||||
> этом мердже — фраза «половину этой беды сервис закрывает сам» описывает
|
||||
> периметр точнее, чем он есть, и ссылаться на неё нельзя.
|
||||
> 3. **Только документ:** записать асимметрию как принятую цену, потребовав от
|
||||
> прокси перезаписи всех трёх `Remote-*` (требование к контуру уже записано,
|
||||
> см. «Недоступно проверке»), и колонку почты не трогать.
|
||||
|
||||
### 6. Страж, отменяющий слой подстановки, не защищён ни одной проверкой и считает «подставляем ли» вторым выражением: убрать его можно молча, а разойтись он может сам
|
||||
|
||||
- Файл: `internal/controller/http/server.go:27-49` (строка 44
|
||||
`if len(substitution) > 0`); `internal/config/test_headers.go:33-43`;
|
||||
`internal/controller/http/substitute_test.go:229-246`
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: чтение с двумя дословными сопоставлениями.
|
||||
(1) Комментарий `server.go:30-31` утверждает: «Судит эту пустоту точка входа:
|
||||
перечень приходит сюда уже готовым», — а строка 44 судит её второй раз своим
|
||||
выражением. Тождество двух формулировок (`c.Server.Debug && len(TestHeaders) > 0`
|
||||
против `len(substitution) > 0`) сегодня держится только тем, что
|
||||
`HeaderSubstitution` возвращает `nil` в ложной ветке; ничем другим оно не
|
||||
закреплено.
|
||||
(2) Единственная проверка на пустую имитацию — `TestEmptySectionLeavesHeadersUntouched`
|
||||
— судит три вещи: код `401`, неизменное число учётных записей и отсутствие в
|
||||
журнале строки `"Identity headers are not substituted"`. Строка эта пишется
|
||||
**только** в ветке недоверенного пира (`substitute.go:80`), а запрос проверки
|
||||
идёт с `trustedPeer`, — значит утверждение истинно при любом исходе. Первые два
|
||||
утверждения тоже истинны при снятом страже: заголовка `Remote-User` в запросе
|
||||
нет, слой удалил бы `Remote-Email` и пропустил дальше, узнавание всё равно
|
||||
ответило бы `401` и записи бы не завело.
|
||||
Мутационную сверку — снять страж и убедиться, что проверка зеленеет, — не
|
||||
делал: `CLAUDE.md`, «Запреты», прямо запрещает мутационную сверку оракулов.
|
||||
- Последствие: сценарий дельта-спеки «Пустая секция имитации ничего не трогает»
|
||||
не проверен ничем, хотя в наборе проверок выглядит проверенным, — а именно к
|
||||
этому сценарию спека привязывает своё «поведение, которого в бою нет»
|
||||
(срезанный `Remote-Email` у запроса без `Remote-User`). Дубль предиката при
|
||||
этом расходится в первой же правке, дающей непустой признак при пустой карте:
|
||||
исход будет «старт предупреждает о подстановке, а слоя в цепочке нет» — и
|
||||
предупреждение соврёт.
|
||||
- Предложение: `AppChain` принимает вторым значением сам предикат
|
||||
(`substituting bool`) вместо того, чтобы выводить его из длины карты; проверка
|
||||
судит **дошедшее до узнавания** — что `Remote-Email`, посланный запросом,
|
||||
доехал до обработчика нетронутым, — а не журнал.
|
||||
- Найдено проходами: `code/техника` (C1), `architecture` (R3), `specs`
|
||||
(замечание про два места).
|
||||
- **Действие: инлайн.**
|
||||
|
||||
### 7. Два нормативных документа описывают изъятие мягче, чем оно есть: паспорт обосновывает его доводом, который в изъятии не действует, а принцип архитектуры запрещает ровно то, что сам приводит примером
|
||||
|
||||
- Файл: `docs/passport.md:65-75`; `docs/architecture.md:84-91`
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: дословное сопоставление внутри одного изменения.
|
||||
(1) `docs/passport.md:73` — «подставленное имя проходит то же узнавание, что и
|
||||
пришедшее, **а кого пускать, по-прежнему решает провайдер**». Дельта-спека в
|
||||
том же изменении: «Подстановка — это настройка, которой сервис называет
|
||||
пришедшего сам, **никого не спросив**» (`substitute.go:32-33` теми же словами).
|
||||
Провайдера в изъятии нет вовсе. Там же паспорт пишет «*Изъятие одно, и оно про
|
||||
прогон без контура*» — код таким не ограничен: подстановка работает везде, где
|
||||
адрес пира доверенный, и `docs/security.md:97-102` это признаёт прямо
|
||||
(«Боевая поломка машиной не исключена»). Два документа одного изменения
|
||||
расходятся об одном изъятии.
|
||||
(2) `docs/architecture.md:84-91` — заголовок нормы «Подставной собеседник
|
||||
живёт **в коде или в оснастке**», тело — «только под ключом, названным своим
|
||||
предметом, — **не под общим словом вроде «режим отладки»**», и следом
|
||||
собственным примером норма называет случай, который живёт в боевом бинарнике и
|
||||
стоит под `[server] debug`. Единственная операционная формулировка изъятия —
|
||||
закрытый перечень следствий — живёт в спеке, и принцип на неё не ссылается.
|
||||
- Последствие: `docs/passport.md` — единственный дом границы домена, и `CLAUDE.md`
|
||||
велит читать его перед задачей. Записанная так граница перестаёт исключать то,
|
||||
ради чего заведена: следующая задача (в бэклоге лежит `api-tokens`) сможет
|
||||
сослаться на строку «кого пускать, решает провайдер» как на действующее
|
||||
ограничение, которого нет. Норма архитектуры, обратно, читается как «шипящая
|
||||
реализация нарушает наш собственный принцип» — а ключ `[server] debug`
|
||||
владелец подтвердил дважды и переоткрывать его нельзя; значит расходится норма,
|
||||
а не код. `docs/review.md` при этом уже объявил пробел закрытым этой нормой,
|
||||
то есть на неё уже сослались.
|
||||
- Предложение: в паспорте заменить довод на верный («сервис называет пришедшего
|
||||
сам; границы домена это не двигает, потому что учётных записей он по-прежнему
|
||||
не заводит и допуска не проверяет — заведение строки первым обращением
|
||||
остаётся зеркалированием») и снять слова «оно про прогон без контура». В
|
||||
`docs/architecture.md` свести принцип к связывающему правилу со ссылкой на
|
||||
требование спеки (закрытый перечень следствий предохранителя), не повторяя
|
||||
перечень второй копией.
|
||||
- Найдено проходом: `architecture` (R1, R2).
|
||||
- **Действие: инлайн** (две правки, оба файла — документация).
|
||||
|
||||
---
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
Понижены: оракула на этом прогоне триаж не добыл — по бюджету (одна попытка на
|
||||
находку ушла на `critical`/`major`) либо по запрету проекта.
|
||||
|
||||
### Имя копии в каталоге данных выводится из идентификатора записи одним инкрементом
|
||||
|
||||
- Файл: `internal/ident/ident.go:79-99`; `internal/service/transcribe.go:176-201`
|
||||
- Было: minor / high (`adversary`, V2). **Осталось minor:** оракул у прохода
|
||||
есть (`TestProbeStorageNameDerivesFromRecordID`, `TestProbeDerivationRate` —
|
||||
49 из 50 приёмов подряд), но триаж его не воспроизводил, а согласие одного
|
||||
прохода с самим собой подтверждением не является.
|
||||
- Последствие: инвариант `CLAUDE.md` «Имя файла на диске задаёт сервис, а в
|
||||
журнал не идёт… строка журнала иначе стала бы бессрочным ключом к чужой
|
||||
записи» соблюдён по букве и не по назначению: `record_id` и `file_ext`
|
||||
присутствуют в журнале порознь и дают имя арифметикой. Доступа это не даёт —
|
||||
карточка и файл сужены владельцем, — поэтому не выше minor.
|
||||
- Предложение: случайное положительное приращение вместо единицы либо снять
|
||||
посылку из инварианта. Уходит задачей.
|
||||
|
||||
### Управляющий знак в расширении имени даёт отправителю `500`, а владельцу — `ERROR`, неотличимый от аварии хранилища
|
||||
|
||||
- Файл: `internal/service/transcribe.go:171-188`;
|
||||
`internal/adapter/repo/sqlite/file_repo.go:56-67`
|
||||
- Было: minor / high (`adversary`, V3). **Осталось minor:** падающий тест у
|
||||
прохода есть, триаж его не воспроизводил.
|
||||
- Последствие: комментарий `transcribe.go:171-175` объявляет этот исход
|
||||
недопустимым и ради него заведён `maxExtLen`; разрез сделан по длине и не
|
||||
сделан по составу знаков. Побочно `newWorkFile` не снимает путь с
|
||||
`*os.PathError`, и путь во временном каталоге уезжает в строку `ERROR`.
|
||||
- Предложение: судить состав знаков расширения тем же приёмом, что и длину.
|
||||
Уходит задачей.
|
||||
|
||||
### Текст отказа провайдера уезжает в `error_text` и в журнал целиком, а самый ожидаемый его вид несёт `sourceURI` — бакет и ключ объекта
|
||||
|
||||
- Файл: не локализован проходом точнее уровня «ветвь отказа распознавания»
|
||||
- Было: minor / medium, **оракула нет и быть не может на этом прогоне**:
|
||||
настоящий SpeechKit нужен, а `CLAUDE.md`, «Запреты», прогон на реальных ключах
|
||||
запрещает («Yandex Cloud за деньги»). Строка живёт в
|
||||
`docs/review.md`, «Недоступно проверке», первым списком.
|
||||
- Последствие (условное): ключ объекта в `error_text` виден владельцу записи
|
||||
карточкой; инвариант «Содержимое записи остаётся приватным» им прямо не
|
||||
нарушен, но перечень того, что уходит в текст ошибки, ничем не ограничен.
|
||||
- Предложение: задачей — ограничить текст внешнего отказа перечнем полей, а не
|
||||
пересказывать его дословно.
|
||||
|
||||
### Требования «прокси обязан дописывать `X-Forwarded-For`» нигде не записано, в отличие от `Remote-*`
|
||||
|
||||
- Файл: `docs/security.md:103-113`
|
||||
- Было: minor / medium (`adversary`). **Оракула нет:** правило живёт в
|
||||
`files/caddyproxy/Caddyfile.template` репозитория `pet-project-server`, и
|
||||
`docs/review.md`, «Недоступно проверке», относит поведение обратного прокси к
|
||||
тому, чего не проверит ни один проход.
|
||||
- Последствие (условное): `docs/security.md` описывает чтение цепочки
|
||||
справа налево словами «правое приписал ближайший к нам прокси» — это
|
||||
предположение о прокси, а не требование к нему. Прокси, который `X-Forwarded-For`
|
||||
не дописывает вовсе, оставляет ключ бюджета ограничителя целиком в руках
|
||||
спрашивающего.
|
||||
- Предложение: задачей — дописать требование к контуру рядом с уже записанным
|
||||
требованием про `Remote-*`.
|
||||
|
||||
---
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **Правило «`internal/config` не знает транспорта» механизируемо, но не
|
||||
механизировано.** Норма записана этим же изменением
|
||||
(`docs/conventions/config.md`, «Проверка, охватывающая две секции разом»), а
|
||||
`grep -n "config" internal/archrules/arch_test.go` не даёт ни одного
|
||||
совпадения: сканера у правила нет. Кандидат — правило `internal/archrules` по
|
||||
нетестовым файлам пакета. Предложил проход `architecture`.
|
||||
**Оговорка к механизации:** `internal/config/test_headers_test.go:1-8`
|
||||
объявлен `package config` и импортирует `internal/controller/http` — то самое
|
||||
ребро. В рабочем бинарнике его нет, но `go test ./internal/config` линкует всю
|
||||
поверхность HTTP, а будущая надобность конфига в транспорте даст цикл импорта
|
||||
на уровне проверок. Правило по нетестовым файлам этот файл пропустит; правило
|
||||
по всем — уронит гейт до переноса файла во внешний `package config_test`.
|
||||
- **`errors.Join` в проверках конфига.** `docs/conventions/errors.md`,
|
||||
«Несколько ошибок», называет проверку конфига поимённо и требует «все проблемы
|
||||
разом»; `ValidateTestHeaders` возвращается на первом несовпадении, записи
|
||||
«*Расхождение:*» у пункта нет. Это претензия на правило, а не на этот код:
|
||||
проверки остальных секций ведут себя так же. Предложил проход `code` (C5).
|
||||
- **Сверка `toml.MetaData.Undecoded()` как норма конфига.** Вариант 2 развилки
|
||||
находки 2. Если владелец выберет узкую правку, кандидат остаётся здесь:
|
||||
непонятый ключ в любой секции сегодня проходит молча.
|
||||
|
||||
---
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
### План: темы, глубины, дома
|
||||
|
||||
Воспроизведён таблицей в сводке выше. Дома: `openspec/specs/access/spec.md` +
|
||||
дельта (`requirements`, разбор); `CLAUDE.md`, «Гейт» (`autotests`);
|
||||
`docs/conventions/` целиком (`conventions`, разбор); `docs/architecture.md` +
|
||||
источник `docs/passport.md` (`architecture`, доказательство); `docs/security.md`,
|
||||
«Периметр» (`security`, доказательство); `docs/architecture.md`, «Эксплуатация» +
|
||||
источник `docs/database.md` (`operations`, доказательство). **Тема без дома
|
||||
одна** — «тема проекта»: своих тем сверх ядра проект не завёл, и это состояние
|
||||
плана, а не пропуск.
|
||||
|
||||
### Какие проходы запускались
|
||||
|
||||
Метка `large`, режим «по графу». Запускались шесть: `autotests`, `specs`, `code`,
|
||||
`architecture`, `adversary`, `ops`. Триаж — седьмой.
|
||||
|
||||
### Какие не запускались и почему
|
||||
|
||||
- `basics` — **по метке**: на `large` он не запускается, потому что все темы
|
||||
ядра разобраны именными проходами, а своих тем у проекта нет. Прямое следствие:
|
||||
второго независимого голоса о заниженности метки нет — сигнал пришёл только от
|
||||
`review-code`.
|
||||
- Проход независимой реализации — снят из конвейера по стоимости.
|
||||
- Проход про идиоматичность — упразднён.
|
||||
|
||||
### Чего каждый запущенный проход не мог проверить в принципе
|
||||
|
||||
- `autotests` — судит гейт и покрытие, но не судит, **что** проверяют зелёные
|
||||
проверки: находка 6 (проверка, которая не может упасть) из его charter'а не
|
||||
видна, её принёс `code`.
|
||||
- `specs` — судит соответствие кода дельте и полноту дельты; не судит, работает
|
||||
ли требование в настоящем прогоне (потому S4 у него остался рассуждением, а
|
||||
оракул добыл триаж).
|
||||
- `code` — судит записанные конвенции и технику; периметра и эксплуатации не
|
||||
касается.
|
||||
- `architecture` — судит направления зависимостей и нормы домов; карту графа
|
||||
собирал **вручную**, подготовленной команды у проекта нет.
|
||||
- `adversary` — строит пути; поведения настоящих внешних собеседников не
|
||||
проверяет вовсе.
|
||||
- `ops` — судит эксплуатацию по замерам своего прогона; профиля настоящей
|
||||
нагрузки у него нет (проект работает на единицах записей в день).
|
||||
- **Триаж (этот проход)** — ничего нового не находит по определению: работает с
|
||||
чужими выводами, кода в поисках дефектов не читает. Пропуск любого прохода —
|
||||
пропуск триажа тоже, и единственное средство против него — поимённая сверка
|
||||
плана выше.
|
||||
|
||||
### Что осталось целиком на человеке
|
||||
|
||||
**Не проверит ни один проход** (`docs/review.md`, «Недоступно проверке», первый
|
||||
список — воспроизводится отдельно от второго намеренно):
|
||||
|
||||
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
|
||||
SpeechKit и Object Storage поднять в тесте нечем;
|
||||
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
|
||||
условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу;
|
||||
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
|
||||
сервиса — **от прокси зависит весь барьер**, он обязан заголовки `Remote-*`
|
||||
перезаписывать, а не пропускать пришедшие; правило живёт в
|
||||
`pet-project-server`. **Прямо относится к находке 5:** сценарий
|
||||
дописывающего прокси проверить отсюда нечем, а асимметричную реакцию сервиса
|
||||
на него — можно, и она проверена;
|
||||
- `security`: поведение браузера с куками (класс пуст с 2026-08-22, строка стоит
|
||||
как маркер).
|
||||
|
||||
**Перестали проверять сознательно** (второй список, тот же раздел):
|
||||
|
||||
- `autotests`: разбор вывода настоящего `ffprobe` — проверки получают
|
||||
длительность от подставного источника; решение и цена в
|
||||
`adr/ADR-2026-08-11-stub-adapters-in-tests.md`;
|
||||
- работа сервиса с настоящими внешними собеседниками. Сам сервис поднять можно и
|
||||
этот прогон его поднимал; **остаток** — за настоящие SpeechKit и Object
|
||||
Storage живой прогон не отвечает, ключи выдуманные, распознавание подменяется
|
||||
в коде. Вход живой прогон с 2026-08-22 проверяет целиком, и раздел уже
|
||||
переписан этой задачей: подставного прокси нет, заголовок ставит сам сервис.
|
||||
|
||||
**Общее, вне обоих списков:** история инцидентов, поведение под реальным
|
||||
потоком, поведение внешних систем в их версиях, завязка потребителей на текущее
|
||||
поведение и вопрос «а нужна ли эта функциональность вообще» — ничем из
|
||||
перечисленного конвейер не занимался.
|
||||
|
||||
### Четыре строки, которых не принесёт ни один проход
|
||||
|
||||
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
|
||||
его не открывает. Расхождение изменения с записанным решением ловит скилл
|
||||
`av-dev:doc-healthcheck`, а не ревью. В этом изменении есть чему разойтись:
|
||||
`ADR-2026-08-22-login-by-trusted-header.md` описывает вход, к которому
|
||||
заведено изъятие, и сверен с ним никем не был.
|
||||
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
|
||||
процессный. Всякое число в отчёте снято проходом на этом прогоне: бенчмарк
|
||||
`BenchmarkSubstituteIdentityHeaders` (2972 ns/op, 5718 B/op, 17 allocs/op) —
|
||||
замер прохода `ops`, покрытие (`test_headers.go` 100 %,
|
||||
`SubstituteIdentityHeaders` 94,4 %, `SubstitutedHeaderNames` 0,0 %) — вывод
|
||||
`go tool cover -func` прохода `autotests`, остальные числа — прогоны триажа с
|
||||
приложенными командами.
|
||||
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
|
||||
проходом.** Различение «идиоматично против распространено» не спрашивает
|
||||
никто с тех пор, как упразднён проход про идиоматичность.
|
||||
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||
нет.** Проход независимой реализации снят по стоимости, а не по замеру; «не
|
||||
знаю, чего не знаю» на этом прогоне не доставал никто.
|
||||
|
||||
Метка — `large`, поэтому пятой строки (сужение `security`/`operations`/
|
||||
`architecture` до записанных инвариантов) здесь нет: все три темы разобраны
|
||||
именными проходами на глубине «доказательство».
|
||||
|
||||
### Каких документов проекта не хватило
|
||||
|
||||
- **`docs/review.md`, «Типовые ложноположительные» — есть и непуст**, прочитан
|
||||
целиком (5 записей, две из них отменённые намеренно). Ни одна находка этого
|
||||
прогона под них не подпадает; отсев шёл и по проектному входу, и по общим
|
||||
критериям.
|
||||
- **Раздела инвариантов `CLAUDE.md` хватило.** На него опираются находки 4
|
||||
(«Секрет не покидает конфиг», **critical**, необратимо) и гипотеза про имя
|
||||
копии («Имя файла на диске задаёт сервис, а в журнал не идёт», **critical**).
|
||||
Ранжирование по обратимости взято оттуда же, а не выведено из кода.
|
||||
- **Не хватило: у `[server] debug` нет дома, называющего исчерпывающий перечень
|
||||
его следствий в одном месте.** Перечень объявлен трижды — комментарием
|
||||
`internal/config/config.go:65-71`, комментарием `config.example.toml:7-13` и
|
||||
требованием дельта-спеки, — и три копии уже расходятся: комментарий конфига
|
||||
называет пять исключённых следствий, образец — четыре (в нём нет «проверок
|
||||
старта»). Строка о том, где перечень канонический, отсутствует, и триаж
|
||||
выбирал спеку по общему правилу «спека нормативна», а не по записанному
|
||||
указателю.
|
||||
- **Не хватило: `docs/conventions/logging.md` не называет, каким уровнем
|
||||
пользоваться, когда отладочный недостижим.** Уровень журнала у сервиса зашит,
|
||||
ключа у него нет, и конвенция про это молчит — поэтому находка 1 подана
|
||||
развилкой, а не инлайном: своего основания выбрать вариант у триажа нет.
|
||||
- **Оракул из журнала дефектов не применялся:** ни одна запись журнала
|
||||
`docs/review.md` не описывает класс, совпадающий с находками этого прогона.
|
||||
Ближайшая — «ключ бюджета ограничителя выбирал тот, кого ограничивают»
|
||||
(2026-08-23) — соседствует с гипотезой про `X-Forwarded-For`, но предмет у неё
|
||||
другой.
|
||||
|
||||
### Сработавшие потолки
|
||||
|
||||
- **По проходам: ни один не сообщил свой потолок.** Ни `autotests` (1 находка),
|
||||
ни `specs` (4), ни `code` (5), ни `architecture` (3 + 2 замечания), ни
|
||||
`adversary` (3 + 3 свойства), ни `ops` (1) не сказали, каков был их предел и
|
||||
что осталось за срезом. Это находка о прогоне, вынесенная в сводку отдельной
|
||||
строкой; закрыть её отсюда нечем.
|
||||
- **Потолок триажа сработал.** На входе 21 находка, в первые две секции влезло
|
||||
7. **Не влезло и здесь названо поимённо:**
|
||||
- `A1`/`S2` — `SubstitutedHeaderNames` не вызвана ни одним тестом
|
||||
(`go tool cover -func` → 0,0 %). Триаж добыл ей однократный оракул: прогон
|
||||
настоящего бинарника печатает `headers=[Remote-User]` — имена, не значения,
|
||||
— то есть **дефекта сегодня нет**, есть отсутствие защиты от регрессии
|
||||
против MUST NOT дельта-спеки («строка старта MUST не нести значений»).
|
||||
Понижено до minor, инлайн-правка на один тест, если у оркестратора останется
|
||||
бюджет;
|
||||
- `C3` — умолчания `Debug` и `TestHeaders` заданы нулём типа, а не в
|
||||
`defaultConfig()`. Нарушена записанная конвенция
|
||||
`docs/conventions/config.md:181-191`: «Умолчания задаются в `defaultConfig()`…
|
||||
Новое поле требует правки обоих мест», и там же форма обязательного поля —
|
||||
«умолчания нет ни в `defaultConfig()`, ни по нулевому значению типа». Форма
|
||||
записи необязательного поля стала неотличима от формы обязательного. `nit`,
|
||||
инлайн;
|
||||
- `C4` — `internal/config/test_headers_test.go` объявлен `package config` и
|
||||
импортирует `internal/controller/http`, ребро запрещено той же правкой в
|
||||
`docs/conventions/config.md`. `nit`; отражён оговоркой в Promote candidates,
|
||||
потому что чинить его в одиночку смысла меньше, чем вместе со сканером;
|
||||
- `C5` — `ValidateTestHeaders` возвращается на первом несовпадении вместо
|
||||
`errors.Join`. Уехал в Promote candidates: правило шире этого кода;
|
||||
- `C2` — комментарий `Dockerfile:49-50` объясняет исключение `cmd/devtools`
|
||||
словами «оснастка разработчика (подставной прокси)», а подкоманды больше нет;
|
||||
приёмочный шаг `tasks.md:88` искал следы по строке `devtools proxy` и эту не
|
||||
поймал. Плюс `internal/controller/http/mounts.go:54-56` перечисляет три звена
|
||||
цепочки, тогда как `AppChain` одевает четыре. `nit`, инлайн, две строки;
|
||||
- **расхождение записи имени ключа**: `[auth] test_headers` в дельта-спеке,
|
||||
`docs/security.md`, `docs/architecture.md` и `docs/review.md` против
|
||||
`[auth.test_headers]` в `config.example.toml`, `README.md`, `CLAUDE.md`,
|
||||
`docs/conventions/config.md` и в текстах отказов старта самого кода
|
||||
(`test_headers.go:16`). Человек, ищущий в конфиге то, что назвал отказ
|
||||
старта, найдёт вторую форму; первая в файле не встречается вовсе. **Не
|
||||
находка этого прохода:** `CLAUDE.md`, «Гейт», относит согласованность
|
||||
документов между собой и с кодом к скиллу `av-dev:doc-healthcheck`. Названо
|
||||
здесь, чтобы не потерялось;
|
||||
- **рецепт локального входа записан четырьмя копиями** — `config.example.toml`
|
||||
(канон), `README.md:48-61`, `CLAUDE.md:276-288`,
|
||||
`docs/conventions/config.md:66-75`. Замечание `architecture` «дешевле
|
||||
переделать до мерджа»; по той же причине уходит в `doc-healthcheck`;
|
||||
- границы прохода `specs`, перенесённые без изменений: значения
|
||||
`Remote-Name`/`Remote-Email` в секции имитации на старте не судятся ничем
|
||||
(годность проверяется только у логина); `strings.TrimSpace` над именем ключа
|
||||
— поведение сверх спеки, последствий у него не найдено.
|
||||
|
||||
### Урожай враждебного прохода
|
||||
|
||||
Пути обхода **самой подстановки** — предмета этого изменения — не построено:
|
||||
шесть отрицательных проб (два значения `Remote-User` при включённой подстановке
|
||||
→ `401`; чужие `remote-email`/`remote-name` в нижнем регистре на живом сервере →
|
||||
слой владеет тройкой целиком; сверка адреса идёт той же функцией, что у
|
||||
узнавания; неразобранный `RemoteAddr` → отказ подстановки; канонизация ключей
|
||||
против регистра, NBSP, ZWSP → старт падает; у `[server] debug` второго
|
||||
потребителя нет). Состояния «подстановка работает, а владелец не знает» не
|
||||
найдено. **Это положительный исход по теме, а не отсутствие работы.**
|
||||
|
||||
Построенное легло на соседнюю поверхность. Судьба каждой:
|
||||
|
||||
| Находка | Чинится этим мерджем? | Куда |
|
||||
|---|---|---|
|
||||
| V1 — почта берётся первым значением, сквоттинг молчит | **развилка**, вариант 1 берёт в мердж | находка 5 |
|
||||
| `.env` не игнорируется git | **да**, одной строкой | находка 4 (инлайн) |
|
||||
| `godotenv.Load()` мёртв (ничто не читает переменные окружения), а `WARN` на каждом старте приглашает завести `.env` | нет | **задачей**: снять зависимость `github.com/joho/godotenv` из `go.mod`, четыре строки из `cmd/transcriber/main.go:114-117` и строку `.env` из `.dockerignore` — либо, если переменные окружения нужны, завести им читателя. Оракул для задачи: `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ \| grep -v _test` → пусто; прогон бинарника печатает `level=WARN msg="Warning: .env file not found, using system environment variables"` |
|
||||
| V2 — имя копии выводится из `record_id` инкрементом | нет | **задачей**, гипотеза выше; оракулы прохода названы поимённо |
|
||||
| V3 — управляющий знак в расширении → `500` и `ERROR` | нет | **задачей**, гипотеза выше |
|
||||
| Текст отказа провайдера с `sourceURI` в `error_text` | нет | **задачей**, оракула нет и на этом прогоне быть не может (запрет на реальный SpeechKit) |
|
||||
| Требование к прокси про `X-Forwarded-For` не записано | нет | **задачей** в `docs/security.md`, рядом с уже записанным требованием про `Remote-*` |
|
||||
|
||||
Заведение задач — не работа триажа; формулировки выше отданы с оракулами, чтобы
|
||||
ни одна не потерялась при переносе.
|
||||
|
||||
### Оракулы, добытые триажем
|
||||
|
||||
Все тесты писались во временные файлы пакетов и **удалены** после прогона;
|
||||
рабочее дерево на момент сдачи отчёта содержит ровно те изменения, что были на
|
||||
входе (`git status --short` сверен). Прогоны бинарника шли в каталог скретчпада,
|
||||
не в `data/`; поднятый сервис остановлен за собой (`pkill`), порт освобождён —
|
||||
правило `docs/review.md:16-23`.
|
||||
|
||||
1. `go test ./internal/config/ -run TestTriage -v` — три случая разбора TOML
|
||||
(дубль по регистру, опечатка в имени секции, опечатка в имени ключа).
|
||||
2. `go test ./internal/controller/http/ -run TestTriage -v` — сквозной сквоттинг
|
||||
почты на настоящей поверхности `AppChain` и асимметрия правила многозначности.
|
||||
3. `go build -o <скретчпад>/transcriber ./cmd/transcriber` + два прогона
|
||||
настоящего бинарника на порту 18099 с каталогами данных в скретчпаде: конфиг
|
||||
с опечаткой в имени секции и конфиг правильный.
|
||||
4. `git check-ignore -v .env`; `grep -rn "\.Debug(" --include=*.go internal/ cmd/`;
|
||||
`grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/`;
|
||||
`grep -n "config" internal/archrules/arch_test.go`.
|
||||
|
||||
Гейт триаж не перезапускал: его исход взят у прохода `autotests` как замер,
|
||||
снятый на этом прогоне.
|
||||
@@ -0,0 +1,508 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отладочный запуск называет пришедшего настройками
|
||||
|
||||
Сервис SHALL подставлять запросу заголовки входа значениями из секции
|
||||
`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST
|
||||
делать это так, чтобы узнавание не отличало подставленный заголовок от
|
||||
поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана
|
||||
быть той же, что работает в бою: отладке подлежит боевой путь, а не его
|
||||
отладочный двойник.
|
||||
|
||||
Подстановка MUST происходить только при **отсутствии** заголовка `Remote-User` в
|
||||
запросе. Заголовок, пришедший в любом числе значений, MUST оставаться нетронутым:
|
||||
узнавание отвергает запрос с более чем одним значением, и подстановка, дописавшая
|
||||
второе, превратила бы законный отказ в проход.
|
||||
|
||||
Подстановка MUST происходить только тогда, когда адрес соединения попадает в
|
||||
перечень `[auth] trusted_proxies`, и адрес этот MUST судиться **той же
|
||||
функцией**, какой судит его узнавание: и разбор адреса пира, и сверка с
|
||||
перечнем берутся из одного дома, а не пишутся вторым списком. Второй перечень
|
||||
разошёлся бы с первым молча — так же, как разошлись бы два списка имён
|
||||
заголовков. Второго барьера у подстановки нет и не заводится: круг тех, кто
|
||||
вправе назвать пришедшего, уже очерчен этим перечнем.
|
||||
|
||||
Подстановка MUST действовать только при **непустой** секции имитации. Пустая
|
||||
секция значит «подставлять нечего»: цепочка слоёв при ней та же, что при
|
||||
выключенном предохранителе, и ни одного заголовка запроса слой не трогает.
|
||||
Иначе включённый предохранитель при пустой секции срезал бы `Remote-Email` у
|
||||
запроса, пришедшего без `Remote-User`, — поведение, которого в бою нет.
|
||||
|
||||
Подставляя, сервис MUST распоряжаться всей тройкой заголовков `Remote-*`
|
||||
целиком: названные секцией ставятся её значением, не названные удаляются.
|
||||
Запрос без `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
|
||||
источников — логин свой, почту чужую.
|
||||
|
||||
Подстановка MUST действовать только под корнем приложения — там же, где действует
|
||||
узнавание. Проба здоровья, метрики и раздача приложения её не видят.
|
||||
|
||||
#### Scenario: Запрос без заголовка входа получает имя из настроек
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
|
||||
- **WHEN** запрос приходит с доверенного адреса без единого заголовка `Remote-*`
|
||||
- **THEN** сервис узнаёт пришедшего под именем из секции
|
||||
- **AND** учётная запись заводится, если её не было
|
||||
|
||||
#### Scenario: Пришедший заголовок не подменяется
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
|
||||
- **WHEN** запрос приходит с доверенного адреса с заголовком `Remote-User`
|
||||
- **THEN** узнавание получает значение из запроса, а не из настроек
|
||||
|
||||
#### Scenario: Два значения заголовка остаются отказом
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
|
||||
- **WHEN** запрос приходит с двумя значениями заголовка `Remote-User`
|
||||
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
|
||||
|
||||
#### Scenario: Недоверенный адрес имени не получает
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
|
||||
- **WHEN** запрос без заголовка приходит с адреса вне перечня доверенных
|
||||
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
|
||||
|
||||
#### Scenario: Чужой заголовок почты не смешивается со своим логином
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и не
|
||||
называет `Remote-Email`
|
||||
- **WHEN** запрос приходит без `Remote-User`, но с заголовком `Remote-Email`
|
||||
- **THEN** узнавание получает логин из настроек и не получает адреса почты вовсе
|
||||
|
||||
#### Scenario: Пустая секция имитации заголовков не трогает
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации пуста
|
||||
- **WHEN** запрос приходит с доверенного адреса без `Remote-User`, но с
|
||||
заголовком `Remote-Email`
|
||||
- **THEN** слой заголовков не трогает, и `Remote-Email` доходит до узнавания
|
||||
нетронутым
|
||||
|
||||
#### Scenario: Выключенный предохранитель имён не раздаёт
|
||||
|
||||
- **GIVEN** предохранитель выключен, секция имитации пуста
|
||||
- **WHEN** запрос приходит с доверенного адреса без заголовка `Remote-User`
|
||||
- **THEN** запрос остаётся неузнанным
|
||||
|
||||
#### Scenario: Наблюдение подстановки не видит
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
|
||||
- **WHEN** запрос приходит на `GET /health` без заголовка
|
||||
- **THEN** ответ тот же, что и при выключенном предохранителе
|
||||
|
||||
### Requirement: Настройка, открывающая вход всем, роняет старт
|
||||
|
||||
Сервис SHALL отказываться подниматься на настройках, при которых отладочный вход
|
||||
становится открытым входом, и MUST называть в отказе имя ключа. Проверка идёт на
|
||||
старте, до приёма трафика: настройка, отданная на честность выкладки, проверяется
|
||||
только тем, что чужой архив уже уехал не тому.
|
||||
|
||||
Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server]
|
||||
debug` MUST читаться как выключенный предохранитель, а отсутствие секции
|
||||
`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый
|
||||
ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST
|
||||
кончаться отказом старта, а не прочтением «включено».
|
||||
|
||||
Секция имитации MUST считаться **непустой**, как только в ней назван хотя бы
|
||||
один ключ, каким бы ни было его значение. `Remote-User = ""` — заполненная
|
||||
секция, а не пустая: человек, написавший ключ, имитацию завёл, и пустое значение
|
||||
у него — вторая поломка, а не отсутствие первой.
|
||||
|
||||
Отказом MUST быть каждый из четырёх случаев.
|
||||
|
||||
Первый — секция имитации заполнена при выключенном предохранителе. Состояние это
|
||||
не читается никак: либо человек забыл включить предохранитель и будет искать
|
||||
поломку везде, кроме одного ключа, либо забыл убрать имитацию из боевого файла — и
|
||||
тогда до открытого архива остаётся одно слово. Молчаливое игнорирование и
|
||||
предупреждение в журнале оба оставляют вторую поломку жить.
|
||||
|
||||
Второй — секция имитации называет ключ, не совпадающий ни с одним заголовком
|
||||
входа, который сервис читает. Перечень принимаемых имён MUST порождаться теми же
|
||||
именами заголовков, которыми пользуется узнавание, а не перечисляться вторым
|
||||
списком; сравнение MUST идти по каноническому виду имени, потому что в HTTP имя
|
||||
нечувствительно к регистру, а ключ конфига чувствителен. Опечатка в имени иначе
|
||||
кончается сервисом, который никого не узнаёт, без единого следа.
|
||||
|
||||
Третий — секция имитации непуста, а годного `Remote-User` в ней нет. Ключ
|
||||
логина MUST быть назван: секция, называющая один `Remote-Email`, поднимает
|
||||
сервис, который подставит почту, удалит логин и не узнает никого. Значение
|
||||
логина MUST проходить **тот же приём**, каким узнавание судит пришедшее
|
||||
значение (`internal/entity.AcceptProviderLogin`): пустое, из одних пробельных
|
||||
знаков, сверх предела длины и с управляющими знаками — отказ старта. Иначе
|
||||
сервис поднимается, ставит заголовок, получает отказ приёма и отвечает
|
||||
неузнанным на всё, оставляя за собой одну отладочную строку. Оба состояния — то
|
||||
самое «не читается никак», ради которого заведён первый случай.
|
||||
|
||||
Четвёртый — два ключа секции дают одно каноническое имя заголовка. `Remote-User`
|
||||
и `remote-user` для TOML — два ключа, для HTTP — одно имя: сервис подставил бы
|
||||
одно значение, а второе потерял бы молча, и человек искал бы поломку в значении,
|
||||
которого сервис не читал вовсе. Отказ MUST называть секцию и причину, а значений
|
||||
MUST не называть.
|
||||
|
||||
Включённый предохранитель при пустой секции имитации отказом MUST не быть: сам по
|
||||
себе он ничего не включает.
|
||||
|
||||
#### Scenario: Имитация без предохранителя не поднимается
|
||||
|
||||
- **GIVEN** секция имитации заполнена, предохранитель выключен
|
||||
- **WHEN** сервис запускают
|
||||
- **THEN** старт отказывает и называет имя ключа предохранителя
|
||||
|
||||
#### Scenario: Неизвестное имя заголовка не поднимается
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет ключ, которого нет
|
||||
среди читаемых заголовков входа
|
||||
- **WHEN** сервис запускают
|
||||
- **THEN** старт отказывает и называет принимаемые имена
|
||||
|
||||
#### Scenario: Имитация без ключа логина не поднимается
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет только
|
||||
`Remote-Email`
|
||||
- **WHEN** сервис запускают
|
||||
- **THEN** старт отказывает и называет имя недостающего ключа логина
|
||||
|
||||
#### Scenario: Два ключа одного заголовка не поднимаются
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и
|
||||
`remote-user`
|
||||
- **WHEN** сервис запускают
|
||||
- **THEN** старт отказывает и называет секцию, в которой два ключа дали одно имя
|
||||
заголовка
|
||||
|
||||
#### Scenario: Негодное значение логина не поднимается
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
|
||||
значением в 300 знаков
|
||||
- **WHEN** сервис запускают
|
||||
- **THEN** старт отказывает и называет имя ключа, а не его значение
|
||||
|
||||
#### Scenario: Пустое значение логина считается заполненной имитацией
|
||||
|
||||
- **GIVEN** предохранитель выключен, секция имитации называет `Remote-User = ""`
|
||||
- **WHEN** сервис запускают
|
||||
- **THEN** старт отказывает и называет имя ключа предохранителя
|
||||
|
||||
#### Scenario: Конфиг без ключа предохранителя ведёт себя как с выключенным
|
||||
|
||||
- **GIVEN** конфиг не называет ни `[server] debug`, ни секции имитации
|
||||
- **WHEN** сервис запускают
|
||||
- **THEN** сервис поднимается, подстановки не заводит и никого сам не называет
|
||||
|
||||
#### Scenario: Предохранитель без имитации поднимается
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации пуста
|
||||
- **WHEN** сервис запускают
|
||||
- **THEN** сервис поднимается
|
||||
|
||||
### Requirement: Отладочный запуск виден в журнале
|
||||
|
||||
Сервис SHALL сообщать владельцу о включённой подстановке заголовков строкой
|
||||
журнала при старте и MUST не печатать при этом ни одного подставляемого значения.
|
||||
Сервис, называющий пришедшего сам, — это ровно то «может стать проблемой», ради
|
||||
которого заведён предупреждающий уровень: строка старта идёт на `WARN`.
|
||||
|
||||
Строка старта MUST нести имена подставляемых заголовков и MUST не нести их
|
||||
значений. Логин — ключ к чужому архиву, и запрет на печать значения, дающего
|
||||
доступ, действует на подставленное значение наравне с пришедшим.
|
||||
|
||||
Запрос, которому заголовки подставлены, SHALL писаться на уровне `INFO`:
|
||||
этой строкой «узнан подстановкой» отличается от «узнан прокси». Боевой журнал
|
||||
она не топит по построению — пишется только там, где подстановка работает, а в
|
||||
бою предохранитель выключен умолчанием; на отладочном уровне её не увидел бы
|
||||
никто, потому что настройки под уровень журнала у сервиса нет и он зашит `INFO`.
|
||||
Строка MUST нести адрес пира и не нести подставленных значений.
|
||||
|
||||
Запрос, которому подставить нельзя из-за недоверенного адреса, MUST оставлять
|
||||
**свою** строку предупреждающим уровнем. Узнавание на этом месте предупреждения
|
||||
не пишет: подстановка работает при отсутствии `Remote-User`, а отсутствие
|
||||
заголовка узнавание считает случаем штатным и пишет о нём отладочную строку;
|
||||
предупреждение о недоверенном адресе оно бережёт для заголовка, который
|
||||
**пришёл**. Без своей строки самый частый локальный отказ — браузер пришёл
|
||||
с `::1`, а перечень называет `127.0.0.1` — не отличим от поломки узнавания:
|
||||
человек видит `401` на всём приложении, заполненную секцию имитации и
|
||||
включённый предохранитель. Строка MUST нести адрес пира и MUST не нести
|
||||
подставляемых значений.
|
||||
|
||||
#### Scenario: Старт с подстановкой предупреждает владельца
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации заполнена
|
||||
- **WHEN** сервис поднимается
|
||||
- **THEN** журнал несёт предупреждение с именами подставляемых заголовков
|
||||
|
||||
#### Scenario: Подставленного значения нет в журнале
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации называет логин и адрес почты
|
||||
- **WHEN** сервис поднимается и принимает запрос без заголовка
|
||||
- **THEN** ни логин, ни адрес почты не встречаются ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Отказ подстановки на недоверенном адресе виден в журнале
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации заполнена
|
||||
- **WHEN** запрос без заголовка `Remote-User` приходит с адреса вне перечня
|
||||
доверенных
|
||||
- **THEN** журнал несёт предупреждение с адресом пира
|
||||
- **AND** подставляемых значений в строке нет
|
||||
|
||||
#### Scenario: Подстановка на запросе видна при боевом уровне журнала
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации заполнена
|
||||
- **WHEN** запрос с доверенного адреса получает подставленные заголовки
|
||||
- **THEN** строка об этом имеет уровень `INFO` и видна журналу, настроенному
|
||||
по-боевому
|
||||
|
||||
### Requirement: Предохранитель отладки включает только подстановку заголовков
|
||||
|
||||
Предохранитель `[server] debug` SHALL менять одно поведение сервиса — подстановку
|
||||
заголовков входа — и MUST не менять никакого другого. Перечень следствий закрыт, и
|
||||
новое следствие вешается на этот ключ только отдельным требованием спеки: ключ,
|
||||
названный общим словом, иначе обрастает всем подряд, и выключить его перестаёт
|
||||
означать «сервис ведёт себя как в бою».
|
||||
|
||||
Сам по себе включённый предохранитель не включает и подстановки: она MUST
|
||||
действовать только при непустой секции имитации, и при пустой цепочка слоёв MUST
|
||||
быть та же, что при выключенном предохранителе, — требование «Отладочный запуск
|
||||
называет пришедшего настройками».
|
||||
|
||||
Включённый предохранитель MUST не менять уровень журнала, не добавлять в ответы
|
||||
тексты внутренних отказов и следы стека, не выводить тела запросов и ответов
|
||||
внешних сервисов, не снимать и не ослаблять ограничитель частоты, не подменять
|
||||
распознаватель, не ослаблять ни одной проверки старта и не открывать неузнанному
|
||||
ни одного адреса.
|
||||
|
||||
#### Scenario: Включённый предохранитель без имитации ничего не меняет
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации пуста
|
||||
- **WHEN** сервис принимает запросы
|
||||
- **THEN** он ведёт себя ровно так же, как с выключенным предохранителем
|
||||
|
||||
#### Scenario: Ограничитель частоты работает и в отладочном запуске
|
||||
|
||||
- **GIVEN** предохранитель включён, секция имитации заполнена
|
||||
- **WHEN** запросы идут чаще дозволенного
|
||||
- **THEN** ограничитель частоты режет их так же, как при выключенном
|
||||
предохранителе
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Кого пускать, решает провайдер
|
||||
|
||||
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
|
||||
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
|
||||
сервиса — настройка выкладки, лежащая вне репозитория.
|
||||
|
||||
Требование записано именно как решение с ценой, а не как умолчание: провайдер
|
||||
общий для контура, и правило, настроенное слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
|
||||
поэтому граница названа здесь и повторена в модели угроз.
|
||||
|
||||
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
|
||||
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
|
||||
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
|
||||
жизни этого значения. Теперь отзыв действует со следующего запроса.
|
||||
|
||||
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
|
||||
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
|
||||
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
|
||||
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
|
||||
|
||||
**Изъятие одно — отладочный запуск.** При включённом предохранителе
|
||||
`[server] debug` доверенным источником становятся настройки сервиса: сервис
|
||||
называет пришедшего сам, никого не спросив. Это единственное место, где имя
|
||||
берётся не от провайдера. Требования к нему стоят выше отдельными строками, и
|
||||
снимать их поодиночке нельзя: барьер держится всеми разом.
|
||||
|
||||
Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию
|
||||
выключен, а заполненная имитация без него роняет старт. Боевой перечень
|
||||
доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с
|
||||
включённым предохранителем и заполненной имитацией, назовёт своим именем всякого,
|
||||
чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным
|
||||
путём.
|
||||
|
||||
#### Scenario: Названный провайдером получает доступ
|
||||
|
||||
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
|
||||
прокси
|
||||
- **THEN** доступ открывается, а учётная запись заводится, если её не было
|
||||
- **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа
|
||||
и адреса почты
|
||||
|
||||
#### Scenario: Отзыв у провайдера действует со следующего запроса
|
||||
|
||||
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
|
||||
- **WHEN** приходит следующий его запрос
|
||||
- **THEN** прокси заголовка не ставит, и запрос получает отказ
|
||||
|
||||
#### Scenario: Без отладочного запуска имя приходит только от провайдера
|
||||
|
||||
- **GIVEN** предохранитель отладки выключен
|
||||
- **WHEN** запрос приходит с доверенного адреса без заголовка
|
||||
- **THEN** сервис никого не узнаёт и своего имени пришедшему не назначает
|
||||
|
||||
### Requirement: Пришедшего называет доверенный источник
|
||||
|
||||
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
|
||||
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
|
||||
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
|
||||
не остаётся.
|
||||
|
||||
**Изъятие одно — отладочный запуск.** При включённом предохранителе
|
||||
`[server] debug` заголовок входа ставит не прокси, а сам сервис значением из
|
||||
секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного
|
||||
заголовка от пришедшего не отличает. Условия, при которых источник этот законен,
|
||||
и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск
|
||||
называет пришедшего настройками», «Настройка, открывающая вход всем, роняет
|
||||
старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает
|
||||
только подстановку заголовков». Держится изъятие умолчанием предохранителя
|
||||
«выключено» и отказом старта при заполненной имитации без него; боевой перечень
|
||||
доверенных адресов включению предохранителя не мешает.
|
||||
|
||||
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
|
||||
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
|
||||
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
|
||||
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
|
||||
барьер вовсе.
|
||||
|
||||
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
|
||||
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
|
||||
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
|
||||
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
|
||||
требованием учётной записи на адресах приложения.
|
||||
|
||||
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а
|
||||
на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало
|
||||
бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы
|
||||
учётные записи, которые потом не убираются ничем.
|
||||
|
||||
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
|
||||
Область — корень приложения; она MUST выводиться из объявленного адресного
|
||||
пространства сервиса, а не перечисляться вторым списком. Прежде область была
|
||||
шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни
|
||||
адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность
|
||||
хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в
|
||||
коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой
|
||||
нет, и вычитать больше нечего.
|
||||
|
||||
Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание
|
||||
MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения.
|
||||
Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос
|
||||
с новым именем — записи в неё.
|
||||
|
||||
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
|
||||
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
|
||||
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
|
||||
без этой нормы все неназванные собрались бы в одну учётную запись с общим
|
||||
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
|
||||
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
|
||||
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
|
||||
бы вход анониму. Значение сверх объявленного предела длины и значение с
|
||||
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
|
||||
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
|
||||
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
|
||||
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
|
||||
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
|
||||
считает колонка.
|
||||
|
||||
Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым
|
||||
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
|
||||
|
||||
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
|
||||
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
|
||||
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
|
||||
настройке журнала: обе строки означают поломку контура, а поломка, записанная
|
||||
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
|
||||
которого никто не может войти, не отличит своей поломки (перечень доверенных
|
||||
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
|
||||
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
|
||||
нести значения заголовка.
|
||||
|
||||
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
|
||||
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
|
||||
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
|
||||
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
|
||||
|
||||
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
|
||||
MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла.
|
||||
Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию,
|
||||
что и всё прочее, и отзыв доступа доходит до него сразу.
|
||||
|
||||
Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не
|
||||
однажды выданный срок.
|
||||
|
||||
Собственных токенов сервис не принимает: значения, предъявленного запросом и
|
||||
дающего доступ помимо заголовка, у него не существует. Прежде такое значение
|
||||
било заголовок — им пользовался владелец панели; панели нет, и правило приоритета
|
||||
осталось бы правилом без предмета.
|
||||
|
||||
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
|
||||
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
|
||||
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
|
||||
адресом его почты.
|
||||
|
||||
#### Scenario: Заголовок с доверенного адреса узнаёт человека
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
|
||||
- **THEN** запрос идёт от имени учётной записи с этим значением
|
||||
|
||||
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
|
||||
|
||||
- **GIVEN** адреса источника в перечне доверенных нет
|
||||
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи с этим значением не появляется
|
||||
|
||||
#### Scenario: Предъявленного значения сервис не признаёт
|
||||
|
||||
- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа
|
||||
в заголовке или в параметре
|
||||
- **WHEN** сервис решает, кто пришёл
|
||||
- **THEN** пришедшим считается названный заголовком
|
||||
|
||||
#### Scenario: Пустой заголовок не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Два значения одного заголовка не узнают никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Значение сверх предела длины не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Область узнавания — корень приложения
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** смотрят, на каких адресах срабатывает узнавание
|
||||
- **THEN** это адреса под корнем приложения, и второго списка адресов нет
|
||||
|
||||
#### Scenario: Проба здоровья учётной записи не заводит
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
|
||||
- **THEN** учётной записи не появляется
|
||||
|
||||
#### Scenario: Недоверенный источник виден в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком приходит с недоверенного адреса
|
||||
- **THEN** журнал несёт строку об этом исходе с адресом пира
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
#### Scenario: Сервис не ставит браузеру куки
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения проходит с заголовком
|
||||
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
|
||||
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
@@ -0,0 +1,263 @@
|
||||
## 0. Чекпоинт человека
|
||||
|
||||
Чекпоинт пройден: развилок не осталось, и решения владельца записаны здесь и в
|
||||
`design.md`.
|
||||
|
||||
- [x] 0.1 Имена ключей конфига подтверждены дословно — `[server] debug` и
|
||||
`[auth.test_headers]` (необратимое: имя ключа конфига)
|
||||
- [x] 0.2 Форма имён в секции имитации подтверждена: ключ — имя заголовка, набор
|
||||
принимаемых имён порождается константами транспорта, неизвестный ключ
|
||||
роняет старт (`design.md`, решение 6)
|
||||
- [x] 0.3 Адресного предохранителя не делаем — «полагаемся только на параметр
|
||||
debug». Требование петлевого перечня доверенных адресов при включённом
|
||||
предохранителе снято вместе с предикатом «петлевая запись»
|
||||
(`design.md`, решение 4)
|
||||
- [x] 0.4 Подкоманда `cmd/devtools proxy` убирается целиком (`design.md`,
|
||||
решение 10)
|
||||
- [x] 0.5 Критерии приёмки подтверждены владельцем; пункты 2 и 7 переписаны под
|
||||
решение 0.3
|
||||
|
||||
## 1. Настройки
|
||||
|
||||
- [x] 1.1 Завести в `ServerConfig` ключ предохранителя с умолчанием «выключено» в
|
||||
`defaultConfig()`; отсутствие ключа в конфиге читается как «выключено», а
|
||||
негодное значение роняет старт, а не читается как «включено»
|
||||
- [x] 1.2 Завести в `AuthConfig` подсекцию имитации заголовков в форме, выбранной
|
||||
шагом 0.2; умолчание — пустая секция. Непустой секция считается при наличии
|
||||
хотя бы одного ключа, каким бы ни было его значение: `Remote-User = ""` —
|
||||
заполненная секция
|
||||
- [x] 1.3 Вывести перечень принимаемых имён заголовков из констант транспорта
|
||||
`internal/controller/http/identity.go`, а не вторым списком; сравнение по
|
||||
каноническому виду имени
|
||||
- [x] 1.4 Завести проверку старта, охватывающую обе секции, методом на корневой
|
||||
`Config` (`design.md`, решение 8), и звать её из `cmd/transcriber` рядом с
|
||||
имеющимися проверками
|
||||
- [x] 1.5 Отказ старта при заполненной имитации и выключенном предохранителе;
|
||||
текст называет имя ключа предохранителя
|
||||
- [x] 1.6 Отказ старта при неизвестном имени заголовка в секции; текст называет
|
||||
принимаемые имена
|
||||
- [x] 1.7 Отказ старта при непустой секции без годного `Remote-User`: ключ не
|
||||
назван либо его значение не проходит `internal/entity.AcceptProviderLogin`
|
||||
(пустое, пробельное, сверх предела длины, с управляющими знаками); текст
|
||||
называет имя ключа и не называет значения
|
||||
- [x] 1.8 Включённый предохранитель при пустой секции старт не роняет. Перечень
|
||||
доверенных адресов старту при этом не судья: боевая запись перечня
|
||||
включению предохранителя не мешает (решение 0.3)
|
||||
|
||||
## 2. Подстановка на входе
|
||||
|
||||
- [x] 2.1 Завести слой транспорта, подставляющий заголовки, в
|
||||
`internal/controller/http`
|
||||
- [x] 2.2 Поставить слой в цепочку корня приложения между ограничителем частоты и
|
||||
узнаванием; при выключенном предохранителе и при пустой секции имитации
|
||||
слой в цепочку не встаёт вовсе — цепочка та же, что сегодня
|
||||
- [x] 2.3 Подставлять только при отсутствии `Remote-User`; запрос с любым числом
|
||||
значений проходит нетронутым
|
||||
- [x] 2.4 Подставлять только при попадании адреса пира в перечень доверенных, и
|
||||
судить адрес **той же функцией**, какой судит узнавание (`peerAddress` и
|
||||
сверка с перечнем из `internal/controller/http/identity.go`), а не вторым
|
||||
списком
|
||||
- [x] 2.5 Распоряжаться всей тройкой `Remote-*`: названные секцией ставить,
|
||||
не названные удалять
|
||||
- [x] 2.6 Проверить, что `TrustedHeaderIdentity` не изменён: отлаживается боевая
|
||||
ветка узнавания
|
||||
|
||||
## 3. Журнал
|
||||
|
||||
- [x] 3.1 Предупреждение при старте с включённой подстановкой: имена заголовков,
|
||||
поле `capability`, без значений
|
||||
- [x] 3.2 Строка уровня `INFO` на запросе с подставленными заголовками: адрес
|
||||
пира, `capability`, `transport`, без значений. Уровень боевой, и боевой
|
||||
журнал строка не топит: пишется она только там, где подстановка работает,
|
||||
а в бою предохранитель выключен умолчанием
|
||||
- [x] 3.3 Предупреждение на запросе, которому подставить нельзя из-за
|
||||
недоверенного адреса: адрес пира, `capability`, `transport`, без
|
||||
подставляемых значений. Узнавание на этом месте молчит — заголовка нет, и
|
||||
оно пишет отладочную строку
|
||||
|
||||
## 4. Оснастка
|
||||
|
||||
- [x] 4.1 Удалить подкоманду `proxy` из `cmd/devtools`: ветку `case "proxy"` в
|
||||
`main()`, функцию `runProxy` и её флаги `-listen`, `-target`, `-user`,
|
||||
`-name`, `-email`; неиспользуемые импорты уходят вместе с ней
|
||||
- [x] 4.2 Править шапку пакета `cmd/devtools/main.go`: она говорит «Подкоманд
|
||||
две: `proxy` — подставной обратный прокси, `resume` — …» и объясняет, зачем
|
||||
прокси нужен. Остаётся одна подкоманда. Абзац про имена заголовков
|
||||
константами вместе с прокси теряет предмет — оснастка их больше не читает
|
||||
- [x] 4.3 Править `usage()` в `cmd/devtools/main.go`: убрать строку
|
||||
`proxy подставной обратный прокси…`
|
||||
- [x] 4.4 Убрать упоминания подкоманды из документов. Адреса — все, что нашёл
|
||||
`grep -rn "devtools proxy"`, кроме каталога этого change и архива
|
||||
`openspec/changes/archive/`:
|
||||
- `README.md`, блок локального запуска («Локально прокси нет…» и команда
|
||||
`go run ./cmd/devtools proxy`) — содержимое пишет шаг 6.5;
|
||||
- `CLAUDE.md`, раздел «Команды», строка `go run ./cmd/devtools proxy`, и
|
||||
раздел «Запреты», абзац «Локальный запуск не ходит наружу» — содержимое
|
||||
пишет шаг 6.4;
|
||||
- `config.example.toml`, блок «Локальный вход без Authelia» с двумя
|
||||
вызовами подкоманды — содержимое пишут шаги 6.1 и 6.2;
|
||||
- `docs/conventions/config.md`, абзац про закомментированный петлевой
|
||||
адрес «под подставной прокси `cmd/devtools proxy`» — содержимое пишет
|
||||
шаг 6.3;
|
||||
- `docs/architecture.md`, строка таблицы «Оснастка владельца |
|
||||
`cmd/devtools`»: «Подставной прокси для местного запуска и возврат
|
||||
остановленной записи в работу» — содержимое пишет шаг 6.7;
|
||||
- `docs/security.md`, перечень вне периметра, пункт «Машина разработчика и
|
||||
то, что он на ней поднимает»: «её подкоманда `proxy` встаёт на место
|
||||
контура» — содержимое пишет шаг 6.6;
|
||||
- `docs/review.md`, запись о единственном доме подставных внешних
|
||||
собеседников: «на их месте `cmd/devtools proxy`». Документ процессный, и
|
||||
запись историческая: правится строкой о том, что прокси убран задачей
|
||||
`config-test-headers-login`, а не переписывается
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
- [x] 5.1 Тесты проверки старта по требованию «Настройка, открывающая вход всем,
|
||||
роняет старт»: три отказа (имитация без предохранителя; неизвестное имя
|
||||
заголовка; непустая секция без годного `Remote-User` — не названного и в
|
||||
300 знаков) и законные случаи (предохранитель без имитации; конфиг, не
|
||||
называющий ни одного нового ключа)
|
||||
- [x] 5.2 Тесты слоя подстановки по сценариям требования «Отладочный запуск
|
||||
называет пришедшего настройками», включая два значения `Remote-User`,
|
||||
недоверенный адрес и чужой `Remote-Email`
|
||||
- [x] 5.3 Тест: подставленное значение не встречается ни в одной журнальной
|
||||
записи
|
||||
- [x] 5.4 Тест: `GET /health` при включённом предохранителе отвечает так же, как
|
||||
при выключенном
|
||||
- [x] 5.5 Тест: ограничитель частоты работает при включённом предохранителе
|
||||
- [x] 5.6 Тест: при включённом предохранителе и пустой секции запрос без
|
||||
`Remote-User`, но с `Remote-Email` доходит до узнавания нетронутым
|
||||
- [x] 5.7 Тест: запрос без заголовка с недоверенного адреса при включённом
|
||||
предохранителе оставляет предупреждение с адресом пира
|
||||
- [x] 5.8 Тест: конфиг без новых ключей даёт ту же цепочку слоёв и те же ответы,
|
||||
что до изменения
|
||||
|
||||
## 6. Документы
|
||||
|
||||
- [x] 6.1 `config.example.toml`: обе новые настройки с комментарием — зачем,
|
||||
допустимые значения и цена включения. Форма названа прямо: боевой перечень
|
||||
доверенных адресов остаётся рабочим значением, `[server] debug` стоит рабочей строкой
|
||||
`false`, секция `[auth.test_headers]` целиком закомментирована, а петлевые
|
||||
адреса в перечне стоят комментарием **парой** — `127.0.0.1` и `::1`:
|
||||
браузер разрешает `localhost` в IPv6 не реже, чем в IPv4, и перечень без
|
||||
`::1` даёт неузнанный запрос без единой понятной строки
|
||||
- [x] 6.2 Записать рецепт локального входа одним связным блоком — списком правок
|
||||
сверху вниз, а не тремя комментариями по месту: (1) добавить в перечень
|
||||
доверенных адресов пару петлевых, (2) поставить `debug = true`,
|
||||
(3) раскомментировать секцию имитации и назвать в ней `Remote-User`. Дом
|
||||
блока — `config.example.toml`, и та же последовательность повторена ссылкой
|
||||
в `README.md` и `CLAUDE.md`
|
||||
- [x] 6.3 `docs/conventions/config.md`: локальный вход описан настройками; абзац
|
||||
про закомментированный петлевой адрес под подставной прокси приведён в
|
||||
соответствие с формой шага 6.1 (пара петлевых адресов, закомментированная
|
||||
секция имитации, рецепт одним блоком) — строкой «*Расхождение:*», если
|
||||
форма образца расходится с общим правилом секции
|
||||
- [x] 6.4 `CLAUDE.md`: раздел «Запреты», абзац о локальном запуске — вход идёт
|
||||
настройками, второго процесса нет; раздел «Команды», строка подкоманды
|
||||
`proxy` убирается
|
||||
- [x] 6.5 `README.md`: инструкция локального запуска
|
||||
- [x] 6.6 `docs/security.md`, «Периметр»: изъятие отладочного запуска, чем оно
|
||||
держится (умолчание предохранителя, отказ старта при имитации без него,
|
||||
шаблон выкладки) и что боевая поломка машиной не исключена. Там же —
|
||||
пункт «Машина разработчика и то, что он на ней поднимает»: подкоманды
|
||||
`proxy` больше нет
|
||||
- [x] 6.7 `docs/architecture.md`: строка таблицы про оснастку разработчика (без
|
||||
прокси) и новый слой в перечне слоёв транспорта
|
||||
- [x] 6.8 `docs/passport.md`, «Управление учётными записями»: сегодня раздел
|
||||
утверждает, что кто пришёл, сервис не решает никогда, и что исключений у
|
||||
этого больше нет. Назвать изъятие одной строкой с его границей —
|
||||
«держится предохранителем `[server] debug`, выключенным по умолчанию» — и
|
||||
ссылкой на спеку `access`
|
||||
|
||||
- [x] 6.9 Записать норму о подставных собеседниках — решение владельца, работа
|
||||
сверх прежнего перечня шагов. Одной строкой в документ канона: подставной
|
||||
собеседник живёт в коде или в оснастке, а в боевом бинарнике появляется
|
||||
только отдельным решением владельца и только под ключом, названным своим
|
||||
предметом. Сегодня таких два — подмена распознавания правкой
|
||||
`internal/adapter/recognizer/memory.go` и имитация заголовков конфигом, —
|
||||
а записанной нормы нет вовсе, и `docs/review.md` этот пробел отмечает
|
||||
вопросом темы `operations`. Дом нормы — `docs/architecture.md`, «Принципы»:
|
||||
она называет, где живёт часть системы, а не как оформляют код. Запись
|
||||
вопроса в `docs/review.md` правится строкой о закрытом пробеле
|
||||
|
||||
## 7. Приёмка
|
||||
|
||||
- [x] 7.1 `task gate` зелёный
|
||||
- [x] 7.2 Проверить руками: сервис поднят с заполненной имитацией, приложение
|
||||
открыто по адресу сервиса, второго процесса нет
|
||||
- [x] 7.3 Проверить руками **записанную форму рецепта**: скопировать свежий
|
||||
`config.example.toml`, применить рецепт шага 6.2 дословно и ничего сверх
|
||||
него, поднять сервис, открыть приложение. Отказ старта на полпути —
|
||||
поломка рецепта, а не копии
|
||||
- [x] 7.4 Критерии приёмки из блока ниже проверены поимённо
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
Рубрика ревью дизайна. Проверяется поимённо шагом 7.4.
|
||||
|
||||
- [x] 1. **Fail-closed по умолчанию.** Отсутствие новых ключей в существующем
|
||||
конфиге не меняет ни одного байта поведения; умолчание предохранителя —
|
||||
«выключено»; ни одна ошибка разбора не даёт «включено». Проверяемо: конфиг
|
||||
сегодняшнего дня, не тронутый, даёт ту же цепочку слоёв и те же ответы.
|
||||
- [x] 2. **У каждой комбинации новых ключей назван исход, а боевая поломка
|
||||
названа поимённо и не выдана за исключённую.** Машина её не исключает:
|
||||
сервис с включённым предохранителем и заполненной имитацией поднимается на
|
||||
любом перечне доверенных адресов, боевом в том числе, и называет пришедшего
|
||||
сам всякому, чей запрос пришёл через обратный прокси. Защита названа
|
||||
поимённо и целиком — умолчание предохранителя «выключено», отказ старта при
|
||||
заполненной имитации без предохранителя, боевой конфиг, рендеримый шаблоном
|
||||
Ansible, — и ни один документ не утверждает, что этого набора хватает на
|
||||
механическую несовместимость.
|
||||
- [x] 3. **Подстановка заменяет, а не дополняет.** Слой либо владеет всем
|
||||
набором заголовков входа целиком, либо не трогает запрос; второго значения
|
||||
того же заголовка он не создаёт никогда. Проверяемо: запрос с двумя
|
||||
`Remote-User` остаётся отказом, запрос с чужим `Remote-Email` и без
|
||||
`Remote-User` не собирает личность из двух источников.
|
||||
- [x] 4. **Барьер доверенного адреса подставленный заголовок проходит наравне с
|
||||
пришедшим, и предикат доверия у обоих один дом.** Проверяемо: запрос с
|
||||
недоверенного адреса не узнаётся при включённом предохранителе так же, как
|
||||
при выключенном; отбор адреса берётся из той же точки, что и у узнавания,
|
||||
а не пишется вторым списком.
|
||||
- [x] 5. **Отлаживается боевая ветка.** Узнавание не получает ни второго
|
||||
источника значений, ни ветки «если отладка»; к моменту чтения заголовок
|
||||
неотличим от поставленного прокси. Проверяемо: файл узнавания изменением
|
||||
не тронут.
|
||||
- [x] 6. **Область слоя равна области узнавания и выводится из неё, а не
|
||||
перечисляется вторым списком.** Проверяемо: проба здоровья, метрики и
|
||||
раздача приложения отвечают одинаково при включённом и выключенном
|
||||
предохранителе.
|
||||
- [x] 7. **Матрица отказов старта полна и тотальна: у каждой комбинации новых
|
||||
ключей назван исход, и «поднялся, но никого не узнаёт» среди исходов
|
||||
нет.** Отказ идёт до приёма трафика, называет имя ключа и не называет
|
||||
значения. Проверяемо перечислением: предохранитель × (пустая / заполненная
|
||||
/ заполненная негодно) имитация, и отдельной строкой — два ключа секции,
|
||||
дающие одно каноническое имя заголовка: `Remote-User` и `remote-user`
|
||||
различимы для TOML и неразличимы для HTTP, и одно из значений терялось бы
|
||||
молча.
|
||||
- [x] 8. **Настройка, при которой узнавание не может состояться ни при каком
|
||||
запросе, — отказ старта, а не запуск.** Имитация без ключа логина, пустое,
|
||||
пробельное, слишком длинное значение и значение с управляющими знаками
|
||||
судятся на старте тем же правилом, каким узнавание судит пришедшее
|
||||
значение. Проверяемо: конфиг с логином в 300 знаков даёт отказ старта с
|
||||
именем ключа, а не сервис, отвечающий отказом на всё.
|
||||
- [x] 9. **Отладочный запуск громкий и различимый.** Одна строка при старте (без
|
||||
значений) и различимость «узнан подстановкой» от «узнан прокси» на уровне
|
||||
запроса. Отдельно — путь, на котором подстановка не сработала: у него
|
||||
обязан быть след, иначе самый частый локальный отказ (браузер пришёл
|
||||
`::1`, конфиг называет `127.0.0.1`) не отличим от поломки узнавания.
|
||||
- [x] 10. **Слой без состояния между запросами: исход запроса — функция конфига
|
||||
и самого запроса.** Ничего не кэширует, ничего не заводит, в контекст не
|
||||
пишет; заведение учётной записи остаётся там, где было, и параллельные
|
||||
первые запросы с одним логином не зависят от порядка. Проверяемо: два
|
||||
одновременных первых запроса дают одну учётную запись, а не две и не
|
||||
отказ.
|
||||
- [x] 11. **Предохранитель — закрытый перечень следствий, а не режим.** Названо,
|
||||
чего он не включает; новое следствие требует отдельной нормы. Проверяемо:
|
||||
включённый предохранитель при пустой имитации ведёт себя ровно как
|
||||
выключенный, включая ограничитель частоты и уровень журнала.
|
||||
- [x] 12. **Записанный рецепт и объявленная граница совпадают с задуманным.**
|
||||
Форма, записанная в образце конфига и в памятке, — та самая, которую
|
||||
проверяют; ни один документ канона не продолжает утверждать отсутствие
|
||||
механизма, который изменение заводит. Проверяемо: свежая копия
|
||||
`config.example.toml` доводится до рабочего локального входа ровно теми
|
||||
правками, что записаны, и не даёт отказа старта на полпути.
|
||||
@@ -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** ограничитель частоты режет их так же, как при выключенном
|
||||
предохранителе
|
||||
|
||||
Reference in New Issue
Block a user