добавлен приём пакетов 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})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
const samplePayload = `{"data":{"metrics":[{"name":"step_count","units":"count","data":[{"qty":8,"date":"2026-07-31 12:00:00 +0300"}]}],"workouts":[]}}`
|
||||
|
||||
func TestIngestAcceptsPayload(t *testing.T) {
|
||||
h, st := newAPI(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("automation-name", "healthlog")
|
||||
req.Header.Set("automation-aggregation", "None")
|
||||
req.Header.Set("session-id", "sess-1")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус = %d, тело = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("разбор ответа: %v", err)
|
||||
}
|
||||
if resp.DeliveryID == "" || resp.SHA256 == "" {
|
||||
t.Errorf("ответ без идентификатора или хеша: %+v", resp)
|
||||
}
|
||||
if resp.Bytes != int64(len(samplePayload)) {
|
||||
t.Errorf("bytes = %d, ожидалось %d", resp.Bytes, len(samplePayload))
|
||||
}
|
||||
|
||||
if n, err := st.CountDeliveries(t.Context()); err != nil || n != 1 {
|
||||
t.Errorf("доставок = %d (err=%v), ожидалась 1", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Заголовки сохраняются целиком: документация HAE неполна, и незадокументированные
|
||||
// поля уже приносили самые полезные находки.
|
||||
func TestIngestStoresAllHeaders(t *testing.T) {
|
||||
h, st := newAPI(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
|
||||
req.Header.Set("automation-id", "BC99C8A3-8BE7-4519-B545-F3ED6212008E")
|
||||
req.Header.Set("automation-aggregation", "Minutes")
|
||||
req.Header.Set("x-unknown-future-header", "нечто новое")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус = %d", rec.Code)
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("LastDelivery: %v", err)
|
||||
}
|
||||
|
||||
var got map[string][]string
|
||||
if err := json.Unmarshal([]byte(d.Headers), &got); err != nil {
|
||||
t.Fatalf("разбор headers: %v (%q)", err, d.Headers)
|
||||
}
|
||||
for name, want := range map[string]string{
|
||||
"Automation-Id": "BC99C8A3-8BE7-4519-B545-F3ED6212008E",
|
||||
"Automation-Aggregation": "Minutes",
|
||||
"X-Unknown-Future-Header": "нечто новое",
|
||||
} {
|
||||
if len(got[name]) != 1 || got[name][0] != want {
|
||||
t.Errorf("заголовок %s = %v, ожидалось [%q]", name, got[name], want)
|
||||
}
|
||||
}
|
||||
if got["Host"] == nil {
|
||||
t.Error("Host не сохранён")
|
||||
}
|
||||
}
|
||||
|
||||
// Секреты в БД не попадают — ни по имени заголовка, ни по совпадению значения
|
||||
// с настроенным токеном (токен можно положить в заголовок с любым именем).
|
||||
func TestIngestRedactsSecretHeaders(t *testing.T) {
|
||||
const token = "очень-секретный-токен"
|
||||
h, st := newAPI(t, []string{token})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("X-Api-Key", "ключ-метабазы")
|
||||
req.Header.Set("X-Custom-Auth", token)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус = %d, тело %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("LastDelivery: %v", err)
|
||||
}
|
||||
if strings.Contains(d.Headers, token) {
|
||||
t.Errorf("токен утёк в headers: %s", d.Headers)
|
||||
}
|
||||
if strings.Contains(d.Headers, "ключ-метабазы") {
|
||||
t.Errorf("значение X-Api-Key утекло: %s", d.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestAcceptsGzip(t *testing.T) {
|
||||
h, st := newAPI(t, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write([]byte(samplePayload)); err != nil {
|
||||
t.Fatalf("подготовка gzip: %v", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatalf("подготовка gzip: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", &buf)
|
||||
req.Header.Set("Content-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус = %d, тело = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if n, err := st.CountDeliveries(t.Context()); err != nil || n != 1 {
|
||||
t.Errorf("доставок = %d (err=%v), ожидалась 1", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Битое тело — отказ доставки, о нём отправителю сообщаем.
|
||||
func TestIngestRejectsMalformed(t *testing.T) {
|
||||
h, st := newAPI(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(`{"data":{"metrics":[`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("статус = %d, ожидался 400", rec.Code)
|
||||
}
|
||||
if n, _ := st.CountDeliveries(t.Context()); n != 0 { //nolint:errcheck // проверяем только счётчик
|
||||
t.Errorf("доставок = %d, ожидалось 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestRejectsOversizedBody(t *testing.T) {
|
||||
h, _ := newAPI(t, nil)
|
||||
|
||||
// Лимит в тесте — 1 МиБ (см. newAPI), шлём заведомо больше.
|
||||
big := `{"data":{"note":"` + strings.Repeat("x", 2<<20) + `"}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(big))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("статус = %d, ожидался 413", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestChecksTokenWhenConfigured(t *testing.T) {
|
||||
h, _ := newAPI(t, []string{"верный-токен"})
|
||||
|
||||
cases := map[string]struct {
|
||||
header string
|
||||
want int
|
||||
}{
|
||||
"без токена": {"", http.StatusUnauthorized},
|
||||
"чужой токен": {"Bearer чужой", http.StatusUnauthorized},
|
||||
"без префикса": {"верный-токен", http.StatusUnauthorized},
|
||||
"верный токен": {"Bearer верный-токен", http.StatusOK},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("статус = %d, ожидался %d", rec.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
h, _ := newAPI(t, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("archive.New: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
h := httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, log),
|
||||
Log: log,
|
||||
WriteTokens: writeTokens,
|
||||
MaxBodyMB: 1,
|
||||
})
|
||||
return h, st
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
)
|
||||
|
||||
// ingestResponse — что видит Health Auto Export в ответ на принятый пакет.
|
||||
type ingestResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// handleIngest принимает пакет от Health Auto Export.
|
||||
//
|
||||
// Код ответа отражает ДОСТАВКУ, а не разбор: 200 означает «тело сохранено в
|
||||
// архив», и этого достаточно, потому что разобрать сохранённое можно всегда.
|
||||
func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := readBody(w, r, a.maxBody)
|
||||
if err != nil {
|
||||
writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := a.ingest.Accept(r.Context(), body, a.metaFromHeaders(r))
|
||||
if err != nil {
|
||||
// Ошибку уже залогировал use-case; транспорт только переводит её в ответ.
|
||||
if errors.Is(err, ingest.ErrMalformed) {
|
||||
writeError(w, http.StatusBadRequest, "некорректный формат пакета")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "внутренняя ошибка")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, ingestResponse{
|
||||
DeliveryID: res.DeliveryID,
|
||||
Bytes: res.Bytes,
|
||||
SHA256: res.SHA256,
|
||||
})
|
||||
}
|
||||
|
||||
// metaFromHeaders достаёт то, что автоматизация Health Auto Export
|
||||
// рассказывает о себе своими заголовками. Именованные поля — те, по которым
|
||||
// ходят запросы; полный набор кладётся рядом, потому что документация HAE
|
||||
// заведомо неполна.
|
||||
func (a *api) metaFromHeaders(r *http.Request) ingest.Meta {
|
||||
return ingest.Meta{
|
||||
AutomationName: r.Header.Get("automation-name"),
|
||||
AutomationID: r.Header.Get("automation-id"),
|
||||
Aggregation: r.Header.Get("automation-aggregation"),
|
||||
Period: r.Header.Get("automation-period"),
|
||||
SessionID: r.Header.Get("session-id"),
|
||||
Headers: a.safeHeaders(r),
|
||||
}
|
||||
}
|
||||
|
||||
// secretHeaders — заголовки, которые не сохраняем никогда, как бы ни был
|
||||
// настроен отправитель.
|
||||
var secretHeaders = map[string]bool{
|
||||
"authorization": true,
|
||||
"proxy-authorization": true,
|
||||
"cookie": true,
|
||||
"x-api-key": true,
|
||||
"api-key": true,
|
||||
"x-auth-token": true,
|
||||
}
|
||||
|
||||
// redacted подменяет значение, которое оказалось секретом.
|
||||
const redacted = "[redacted]"
|
||||
|
||||
// safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
|
||||
//
|
||||
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
|
||||
// совпавшее с настроенным токеном, подменяется — токен можно положить в
|
||||
// заголовок с произвольным именем, и угадать его мы не можем.
|
||||
func (a *api) safeHeaders(r *http.Request) map[string][]string {
|
||||
out := make(map[string][]string, len(r.Header)+1)
|
||||
for name, values := range r.Header {
|
||||
if secretHeaders[strings.ToLower(name)] {
|
||||
out[name] = []string{redacted}
|
||||
continue
|
||||
}
|
||||
safe := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
if tokenAllowed(v, a.writeTokens) || tokenAllowed(strings.TrimPrefix(v, "Bearer "), a.writeTokens) {
|
||||
safe[i] = redacted
|
||||
continue
|
||||
}
|
||||
safe[i] = v
|
||||
}
|
||||
out[name] = safe
|
||||
}
|
||||
// Host в r.Header не попадает — а знать, на какое имя пришёл запрос,
|
||||
// полезно, когда перед сервисом появится прокси.
|
||||
if r.Host != "" {
|
||||
out["Host"] = []string{r.Host}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readBody(w http.ResponseWriter, r *http.Request, maxBody int64) ([]byte, error) {
|
||||
var src io.Reader = http.MaxBytesReader(w, r.Body, maxBody)
|
||||
|
||||
if strings.Contains(strings.ToLower(r.Header.Get("Content-Encoding")), "gzip") {
|
||||
gz, err := gzip.NewReader(src)
|
||||
if err != nil {
|
||||
return nil, errBadGzip
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
src = gz
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return nil, err //nolint:wrapcheck // разбирается writeReadError по типу
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var errBadGzip = errors.New("тело не распаковывается как gzip")
|
||||
|
||||
func writeReadError(w http.ResponseWriter, err error) {
|
||||
var tooLarge *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &tooLarge):
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "пакет больше допустимого размера")
|
||||
case errors.Is(err, errBadGzip):
|
||||
writeError(w, http.StatusBadRequest, "тело не распаковывается как gzip")
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "не удалось прочитать тело запроса")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user