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]" // 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 } // 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, "не удалось прочитать тело запроса") } }