добавлен приём пакетов Health Auto Export
- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой архив, учёт доставки в SQLite - код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200, данные уже сохранены и доразберутся позже - сохраняется полный набор заголовков запроса с вычисткой секретов по имени и по совпадению значения с токеном
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// 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})
|
||||
}
|
||||
Reference in New Issue
Block a user