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

- в конфиг добавлены секция [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
+5 -82
View File
@@ -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
View File
@@ -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(),
)