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

- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
This commit is contained in:
av
2026-08-23 13:12:47 +03:00
parent 75c6f0168a
commit 52fe31319a
35 changed files with 3548 additions and 144 deletions
+138
View File
@@ -0,0 +1,138 @@
package http
import (
"log/slog"
"net/http"
"net/netip"
)
// SubstituteIdentityHeaders — слой отладочного запуска: ставит запросу заголовки
// входа значениями из настроек.
//
// # Зачем он есть
//
// В бою заголовки ставит обратный прокси, сходивший к провайдеру. На машине
// разработчика прокси нет, а браузер заголовков не ставит — и приложение
// локально не открылось бы вовсе. Слой встаёт на место контура **внутри самого
// сервиса**: второго процесса и второго порта для этого не нужно.
//
// # Чего слой не делает
//
// Он правит заголовки запроса и ничего больше: учётной записи не заводит,
// отказов не выдаёт, в контекст не пишет. Узнавание получает запрос,
// неотличимый от пришедшего с сервера, и отлаживается поэтому боевая ветка, а
// не её отладочный двойник.
//
// # Что ему запрещено
//
// Подстановка — это настройка, которой сервис называет пришедшего сам, никого
// не спросив. Ограничений поэтому три, и каждое отсекает свой способ отдать
// чужой архив:
//
// - пустой перечень значит «подставлять нечего», и слой отдаёт цепочку
// нетронутой: звено не встаёт в неё вовсе, а запрос доходит до узнавания
// тем же, каким пришёл. Судится пустота здесь, и представление у предиката
// «подставляем ли» одно на всех — непустота самого перечня. Иначе
// включённый предохранитель при пустой имитации срезал бы `Remote-Email` у
// запроса, пришедшего без `Remote-User`;
// - подставляется только при **отсутствии** `Remote-User`. Пришедший заголовок
// не подменяется и не дополняется: узнавание отвергает запрос с двумя
// значениями, и слой, дописавший второе, превратил бы законный отказ в
// отладочный проход;
// - подставляется только с адреса из перечня доверенных, и судит адрес та же
// функция, какой судит его узнавание. Второй сверщик разошёлся бы с первым
// молча: узнавание разворачивает IPv4 в оболочке IPv6.
//
// Подставляя, слой распоряжается **всей** тройкой заголовков входа: названные
// настройками ставит их значением, не названные удаляет. Запрос без
// `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
// источников — логин свой, почта чужая.
func SubstituteIdentityHeaders(
headers map[string]string,
trusted []netip.Prefix,
logger *slog.Logger,
) func(http.Handler) http.Handler {
// Подставлять нечего: цепочка слоёв остаётся ровно той, что в бою, —
// следующее звено возвращается как есть, лишнего звена не появляется.
if len(headers) == 0 {
return func(next http.Handler) http.Handler { return next }
}
if logger == nil {
logger = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Решает **наличие** заголовка, а не его значение: пустое значение
// штатно шлёт прокси там, где никого не назвал, и подстановка на
// этом месте назвала бы человеком того, кому провайдер отказал.
if len(r.Header.Values(LoginHeader)) > 0 {
next.ServeHTTP(w, r)
return
}
peer, ok := peerAddress(r.RemoteAddr)
if !ok || !isTrusted(trusted, peer) {
// Своя строка, и уровень предупреждающий. Узнавание здесь
// молчит предупреждением: заголовка нет, и оно считает случай
// штатным. Без этой строки самый частый локальный отказ —
// браузер пришёл с `::1`, а перечень называет `127.0.0.1` — не
// отличим от поломки узнавания: человек видит отказ на всём
// приложении и ни одного следа о том, почему подстановки не
// было.
//
// Подставляемых значений в строке нет: логин — ключ к чужому
// архиву, и запрет печатать его действует на подставленное
// значение наравне с пришедшим.
logger.Warn("Identity headers are not substituted for an untrusted peer",
"http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http")
next.ServeHTTP(w, r)
return
}
// Тройка берётся из своего единственного дома, а не перечисляется
// здесь заново.
for _, name := range IdentityHeaderNames() {
if value, named := headers[name]; named {
r.Header.Set(name, value)
} else {
r.Header.Del(name)
}
}
// Уровень `INFO`, и боевой журнал он не топит по построению: строка
// пишется только тогда, когда подстановка действительно работает, а
// в бою она выключена умолчанием предохранителя. Появляется строка
// ровно в одном месте — в локальном прогоне, где стоит на каждом
// запросе и где она и нужна: ею «узнан подстановкой» отличается от
// «узнан прокси». На отладочном уровне её не видел бы никто:
// настройки под уровень журнала у сервиса нет, и он зашит `INFO`.
//
// Подставленных значений в строке нет по той же причине, по какой их
// нет в строке отказа выше.
logger.Info("Identity headers substituted from configuration",
"http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http")
next.ServeHTTP(w, r)
})
}
}
// SubstitutedHeaderNames называет заголовки, которые слой подставит, в порядке
// объявления тройки.
//
// Нужен строке журнала при старте: перебор карты дал бы порядок, меняющийся от
// запуска к запуску, а перечисление имён по месту завело бы третий список.
func SubstitutedHeaderNames(headers map[string]string) []string {
names := make([]string, 0, len(headers))
for _, name := range IdentityHeaderNames() {
if _, named := headers[name]; named {
names = append(names, name)
}
}
return names
}