Files
healthlog/internal/httpapi/httpapi.go
T
av 41f2f05f2f добавлен приём пакетов Health Auto Export
- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой
  архив, учёт доставки в SQLite
- код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое
  содержимое — 200, данные уже сохранены и доразберутся позже
- сохраняется полный набор заголовков запроса с вычисткой секретов по имени
  и по совпадению значения с токеном
2026-08-01 12:37:17 +03:00

139 lines
4.0 KiB
Go

// 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/ingest"
)
// Options — зависимости и настройки транспорта.
type Options struct {
Ingest *ingest.Service
Log *slog.Logger
WriteTokens []string
MaxBodyMB int
}
type api struct {
ingest *ingest.Service
log *slog.Logger
writeTokens []string
maxBody int64
}
// New собирает HTTP-роутер.
func New(o Options) http.Handler {
a := &api{
ingest: o.Ingest,
log: o.Log,
writeTokens: o.WriteTokens,
maxBody: int64(o.MaxBodyMB) << 20,
}
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(a.requireWriteToken).Post("/ingest", a.handleIngest)
})
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"}`))
}
// requireWriteToken проверяет токен приёма. Пустой список токенов = проверка
// выключена: локальный запуск в доверенной сети. О выключенной проверке
// сервис предупреждает на старте.
func (a *api) requireWriteToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(a.writeTokens) == 0 {
next.ServeHTTP(w, r)
return
}
if !tokenAllowed(bearer(r), a.writeTokens) {
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
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}