локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user