- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
200 lines
9.2 KiB
Go
200 lines
9.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"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) {
|
|
a.extendWriteDeadline(w, r)
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// extendWriteDeadline даёт маршруту приёма собственный бюджет ответа.
|
|
//
|
|
// `WriteTimeout` сервера ставится в `readRequest`, то есть ДО вызова
|
|
// обработчика, и потому покрывает не только запись ответа, но и чтение тела:
|
|
// при `read_timeout` в пять минут и `write_timeout` в тридцать секунд загрузка
|
|
// длиннее тридцати секунд обрывается, а `read_timeout` при этом обещает пять
|
|
// минут. Обрывается молча — обработчик ошибки записи не видит, а `accessLog`
|
|
// пишет `status_code=200`.
|
|
//
|
|
// Лечится это здесь, а не подъёмом общего таймаута: длинный бюджет нужен
|
|
// одному маршруту, и подъём снял бы защиту от застрявшей записи со всех
|
|
// остальных.
|
|
//
|
|
// Транспорт, не умеющий дедлайнов, отказом приёма не является: приём —
|
|
// единственное место, где поток вообще существует, и терять доставку из-за
|
|
// неподдержанной оптимизации нельзя.
|
|
func (a *api) extendWriteDeadline(w http.ResponseWriter, r *http.Request) {
|
|
if a.ingestBudget <= 0 {
|
|
return
|
|
}
|
|
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(a.ingestBudget)); err != nil {
|
|
a.log.DebugContext(r.Context(), "write deadline not set", "error", err)
|
|
}
|
|
}
|
|
|
|
// 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]"
|
|
|
|
// isSecretValue — совпало ли значение заголовка с каким-нибудь настроенным
|
|
// токеном, всё равно какого контура.
|
|
func (a *api) isSecretValue(v string) bool {
|
|
return tokenAllowed(v, a.writeTokens) || tokenAllowed(v, a.readTokens)
|
|
}
|
|
|
|
// 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 a.isSecretValue(v) || a.isSecretValue(strings.TrimPrefix(v, "Bearer ")) {
|
|
safe[i] = redacted
|
|
continue
|
|
}
|
|
safe[i] = v
|
|
}
|
|
out[name] = safe
|
|
}
|
|
// Host в r.Header не попадает — а знать, на какое имя пришёл запрос,
|
|
// полезно, когда перед сервисом появится прокси.
|
|
if r.Host != "" {
|
|
out["Host"] = []string{r.Host}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// readBody читает тело запроса, при необходимости распаковывая gzip.
|
|
//
|
|
// Граница стоит на РАСПАКОВАННОМ потоке, а не только на сжатом. Иначе
|
|
// `MaxBytesReader` ограничивает то, что приехало по сети, а в память попадает
|
|
// то, что из этого развернулось: измерено — 400 КиБ сжатого тела дают 400 МиБ,
|
|
// принимаются со статусом 200 и выедают гигабайт. Потолок степени сжатия gzip
|
|
// около 1030:1, так что при штатных 64 МиБ речь о десятках гигабайт, и
|
|
// параллельные запросы складываются.
|
|
//
|
|
// Цена отказа здесь наивысшая в проекте: приём — единственное место, где поток
|
|
// вообще существует. Доставка, не попавшая в архив, не попадает и в журнал —
|
|
// телефон её не перешлёт.
|
|
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 = io.LimitReader(gz, maxBody+1)
|
|
}
|
|
|
|
body, err := io.ReadAll(src)
|
|
if err != nil {
|
|
return nil, err //nolint:wrapcheck // разбирается writeReadError по типу
|
|
}
|
|
if int64(len(body)) > maxBody {
|
|
return nil, errTooLargeUnpacked
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
var (
|
|
errBadGzip = errors.New("тело не распаковывается как gzip")
|
|
errTooLargeUnpacked = errors.New("распакованное тело больше допустимого размера")
|
|
)
|
|
|
|
func writeReadError(w http.ResponseWriter, err error) {
|
|
var tooLarge *http.MaxBytesError
|
|
switch {
|
|
case errors.As(err, &tooLarge), errors.Is(err, errTooLargeUnpacked):
|
|
writeError(w, http.StatusRequestEntityTooLarge, "пакет больше допустимого размера")
|
|
case errors.Is(err, errBadGzip):
|
|
writeError(w, http.StatusBadRequest, "тело не распаковывается как gzip")
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "не удалось прочитать тело запроса")
|
|
}
|
|
}
|