// Package httpapi — HTTP-транспорт healthlog: приём пакетов и (позже) read API. // // Транспорт тонкий: разбирает запрос, зовёт use-case, переводит его ошибку в // ответ. Исход операции логирует use-case, а не транспорт. package httpapi import ( "crypto/subtle" "encoding/json" "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/ingest" "git.vakhrushev.me/av/healthlog/internal/points" ) // Options — зависимости и настройки транспорта. type Options struct { Ingest *ingest.Service Catalog *catalog.Service Points *points.Service Log *slog.Logger WriteTokens []string ReadTokens []string MaxBodyMB int // IngestWriteBudget — сколько отводится маршруту приёма на чтение тела // вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout // сервера», и полагаться на него нельзя, см. handleIngest. IngestWriteBudget time.Duration } type api struct { ingest *ingest.Service catalog *catalog.Service points *points.Service log *slog.Logger writeTokens []string readTokens []string maxBody int64 ingestBudget time.Duration } // New собирает HTTP-роутер. func New(o Options) http.Handler { a := &api{ ingest: o.Ingest, catalog: o.Catalog, points: o.Points, log: o.Log, writeTokens: o.WriteTokens, readTokens: o.ReadTokens, maxBody: int64(o.MaxBodyMB) << 20, ingestBudget: o.IngestWriteBudget, } r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(a.accessLog) r.Get("/healthz", a.handleHealthz) r.Route("/api/v1", func(r chi.Router) { r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest) r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics) // Маршрут точек стоит РЯДОМ с каталогом, а не поверх него: у chi // литеральный `/metrics` и шаблон `/metrics/{metric}` — разные узлы, и // каталог остаётся достижим. Утверждается это тестом, а не верой. r.With(requireToken(a.readTokens)).Get("/metrics/{metric}", a.handlePoints) }) return r } func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) } // requireToken проверяет токен контура. Пустой список токенов = проверка // выключена: локальный запуск в доверенной сети. О выключенной проверке // сервис предупреждает на старте. // // Проверка ОДНА на оба контура, параметризованная списком. Копия отличалась бы // одним полем и несла бы три решения сразу — сравнение за постоянное время, // «пустой список = выключено» и текст 401; правка любого из них в одном месте // не дала бы ни ошибки компиляции, ни красного теста, а речь о контуре чтения // данных о здоровье. // // Контуры при этом раздельны: списки разные, и токен приёма маршрут чтения не // открывает. Схема строгая — токеном считается только значение после `Bearer `. func requireToken(tokens []string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if len(tokens) == 0 { next.ServeHTTP(w, r) return } if !tokenAllowed(bearer(r), tokens) { writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен") return } next.ServeHTTP(w, r) }) } } func bearer(r *http.Request) string { const prefix = "Bearer " h := r.Header.Get("Authorization") if len(h) > len(prefix) && h[:len(prefix)] == prefix { return h[len(prefix):] } return "" } // tokenAllowed сравнивает токен за постоянное время: побайтовое сравнение с // ранним выходом утекает длину совпадающего префикса. func tokenAllowed(got string, allowed []string) bool { ok := false for _, want := range allowed { if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 { ok = true } } return ok } // accessLog пишет одну запись на запрос. Рутинно-частые эндпоинты // (healthcheck) — на DEBUG, чтобы не забивать аудит. func (a *api) accessLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) next.ServeHTTP(ww, r) level := slog.LevelInfo if r.URL.Path == "/healthz" { level = slog.LevelDebug } a.log.Log(r.Context(), level, "http request", "transport", "http", "http.method", r.Method, "http.route", routePattern(r), "http.status_code", ww.Status(), "duration_ms", time.Since(start).Milliseconds()) }) } func routePattern(r *http.Request) string { if rctx := chi.RouteContext(r.Context()); rctx != nil { if p := rctx.RoutePattern(); p != "" { return p } } return r.URL.Path } // writeJSON — единственный сериализатор тел ответа. // // Экранирование HTML ВЫКЛЮЧЕНО, и это не косметика. `encoding/json` по // умолчанию превращает `&`, `<` и `>` в `\u0026`, `\u003c`, `\u003e`; на // маршруте, отдающем дословно сохранённое содержимое точки, это прямо ломает // обещание дословности — имя источника приходит с телефона пользовательской // строкой и законно содержит `&`. Хранилище этот же капкан уже проходило и // обезвредило тем же способом (`store.encodePayload`). // // Правило общее для всех читающих маршрутов намеренно: механизм один, и // решать его заново на каждом маршруте значило бы завести второй способ. // Отказ записи ВОЗВРАЩАЕТСЯ, а не глушится: код ответа отдан до сериализации, // поэтому оборванное на середине тело снаружи неотличимо от успеха, а // `accessLog` честно напишет `200`. Кто из вызывающих обязан об этом сказать — // решает он сам; глушить молча нельзя ни одному. func writeJSON(w http.ResponseWriter, status int, v any) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) enc := json.NewEncoder(w) enc.SetEscapeHTML(false) return enc.Encode(v) } // errorWire — форма провода тела отказа, общая для всех маршрутов. // // Объявленный тип, а не `map[string]string`: у читающего маршрута тело отказа // такая же часть публичного контракта, как и успешный ответ, и клиент видит его // чаще. Карта же делает «два ответа совпадают побайтово» свойством библиотеки, а // не решения, и переименование ключа `error` не увидел бы ни один сторож — ни // обход графа типов (карта строк проходит как стандартный тип), ни байтовый // литерал (тел отказа он не закреплял). type errorWire struct { Error string `json:"error"` } // writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте // имена колонок и форма запроса. func writeError(w http.ResponseWriter, status int, msg string) { // Тело отказа — десятки байт: оборваться на нём нечему, и сообщать о // таком обрыве было бы шумом. _ = writeJSON(w, status, errorWire{Error: msg}) }