Files
transcriber/internal/controller/http/substitute.go
T
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00

139 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}