добавлен приём пакетов Health Auto Export

- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой
  архив, учёт доставки в SQLite
- код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое
  содержимое — 200, данные уже сохранены и доразберутся позже
- сохраняется полный набор заголовков запроса с вычисткой секретов по имени
  и по совпадению значения с токеном
This commit is contained in:
av
2026-08-01 12:37:17 +03:00
parent 5e2385ba6e
commit 41f2f05f2f
20 changed files with 1903 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os/signal"
"syscall"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/httpapi"
"git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/logging"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// shutdownTimeout — сколько ждём завершения активных запросов при остановке.
// Приём может быть в середине записи многомегабайтного тела в архив.
const shutdownTimeout = 30 * time.Second
func runServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
log := logging.New(cfg.Log.Level, cfg.Log.Format)
st, err := store.Open(cfg.Storage.DBPath)
if err != nil {
return err
}
defer func() { _ = st.Close() }()
arch, err := archive.New(cfg.Storage.ArchiveDir)
if err != nil {
return err
}
if len(cfg.Auth.WriteTokens) == 0 {
log.Warn("write auth disabled", "reason", "auth.write_tokens пуст")
}
handler := httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, log),
Log: log,
WriteTokens: cfg.Auth.WriteTokens,
MaxBodyMB: cfg.Ingest.MaxBodyMB,
})
srv := &http.Server{
Addr: cfg.Server.Addr,
Handler: handler,
// ReadTimeout щедрый (большой пакет по мобильной сети), но заголовки
// обязаны приехать быстро — иначе полуоткрытое соединение держит слот.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: cfg.Server.ReadTimeout.D(),
WriteTimeout: cfg.Server.WriteTimeout.D(),
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
log.Info("server started",
"addr", cfg.Server.Addr,
"db_path", cfg.Storage.DBPath,
"archive_dir", arch.Root(),
"max_body_mb", cfg.Ingest.MaxBodyMB)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("listen: %w", err)
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
log.Info("server stopping")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
return nil
}