From d92859d6473a3a0fb107e9f2dd548bf7218b9567 Mon Sep 17 00:00:00 2001 From: Eugene Mainagashev Date: Fri, 24 May 2024 13:23:00 +0700 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B5=D0=B9=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5=20=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=BD=D0=B0=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=20=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=81=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 4 +- README.md | 18 +++--- cmd/agent/main.go | 2 +- internal/agent/agent.go | 12 +++- internal/agent/agent_test.go | 2 +- internal/agent/send.go | 8 +++ internal/server/app/config.go | 8 +++ .../handlers/json/update/json_update.go | 24 ++++++-- .../handlers/json/updates/json_updates.go | 58 +++++++++++++++++-- .../server/handlers/json/value/json_value.go | 15 ++++- internal/server/router/router.go | 4 +- .../server/storage/pgstorage/pgstorage.go | 2 +- pkg/sign/sign.go | 35 +++++++++++ 13 files changed, 163 insertions(+), 29 deletions(-) create mode 100644 pkg/sign/sign.go diff --git a/Makefile b/Makefile index d78852a..1d430ae 100644 --- a/Makefile +++ b/Makefile @@ -15,11 +15,11 @@ migrate: server: @echo "Starting server..." - @go run ./cmd/server/. -d $(DB_DSN) + @go run ./cmd/server/. -d $(DB_DSN) -k="private_key_example" agent: @echo "Starting agent..." - @go run ./cmd/agent/. + @go run ./cmd/agent/. -k="private_key_example" server_with_agent: @echo "Starting server with agent..." diff --git a/README.md b/README.md index 7ad6804..60b40f7 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,15 @@ - [x] Три повтора (всего 4 попытки). Интервалы между повторами должны увеличиваться: 1s, 3s, 5s. - [x] Использование golang-migrate для миграции таблички сервера - [x] Makefile для запуска миграций, сервера и агента. -- Iter14. Подпись передаваемых данных по алгоритму SHA256. Посчитать hash от всего тела запроса и разместить его в HTTP-заголовке HashSHA256. - - [ ] **Агент:** - - [ ] Добавьте поддержку аргумента через флаг -k=<КЛЮЧ> и переменную окружения KEY=<КЛЮЧ>. - - [ ] При наличии ключа агент должен вычислять хеш и передавать в HTTP-заголовке запроса с именем HashSHA256. - - [ ] **Сервер:** - - [ ] Добавьте поддержку аргумента через флаг -k=<КЛЮЧ> и переменную окружения KEY=<КЛЮЧ>. - - [ ] При наличии ключа во время обработки запроса сервер должен проверять соответствие полученного и вычисленного хеша. - - [ ] При несовпадении сервер должен отбрасывать полученные данные и возвращать http.StatusBadRequest. - - [ ] При наличии ключа на этапе формирования ответа сервер должен вычислять хеш и передавать его в HTTP-заголовке ответа с именем HashSHA256. +- **Iter14**. Подпись передаваемых данных по алгоритму SHA256. Посчитать hash от всего тела запроса и разместить его в HTTP-заголовке HashSHA256. + - [x] **Агент:** + - [x] Добавьте поддержку аргумента через флаг `-k=<КЛЮЧ>` и переменную окружения `KEY=<КЛЮЧ>`. + - [x] При наличии ключа агент должен вычислять хеш и передавать в HTTP-заголовке запроса с именем `HashSHA256`. + - [x] **Сервер:** + - [x] Добавьте поддержку аргумента через флаг `-k=<КЛЮЧ>` и переменную окружения `KEY=<КЛЮЧ`>. + - [x] При наличии ключа (хэша) во время обработки запроса сервер должен проверять соответствие полученного и вычисленного хеша. + - [x] При несовпадении сервер должен отбрасывать полученные данные и возвращать `http.StatusBadRequest`. + - [x] При наличии ключа на этапе формирования ответа сервер должен вычислять хеш и передавать его в HTTP-заголовке ответа с именем HashSHA256. ## Обновление шаблона diff --git a/cmd/agent/main.go b/cmd/agent/main.go index f3d02e0..66f55b7 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -17,7 +17,7 @@ func main() { pollInterval := time.Duration(flags.Server.PollInterval) * time.Second reportInterval := time.Duration(flags.Server.ReportInterval) * time.Second - a := agent.New(serverURL, pollInterval, reportInterval) + a := agent.New(serverURL, pollInterval, reportInterval, flags.PrivateKey) a.Run() } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f9a6b11..50e3023 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -19,6 +19,7 @@ type Agent struct { ReportInterval time.Duration ServerURL string SendCompressedData bool + PrivateKey string gauges map[string]float64 counters map[string]int64 @@ -30,12 +31,13 @@ type Agent struct { } // New создает новый экземпляр агента. -func New(url string, pollInterval time.Duration, reportInterval time.Duration) *Agent { +func New(url string, pollInterval time.Duration, reportInterval time.Duration, privateKey string) *Agent { return &Agent{ ServerURL: url, PollInterval: pollInterval, ReportInterval: reportInterval, SendCompressedData: true, // согласно условиям задачи, отправка сжатых данных включена по умолчанию + PrivateKey: privateKey, gauges: make(map[string]float64), counters: make(map[string]int64), client: resty.New().SetHeader("Content-Type", "text/plain"), @@ -44,6 +46,11 @@ func New(url string, pollInterval time.Duration, reportInterval time.Duration) * } } +// IsRequestSigningEnabled возвращает true, если задан приватный ключ и агент должен отправлять хэш на его основе. +func (a *Agent) IsRequestSigningEnabled() bool { + return a.PrivateKey != "" +} + // Run запускает агента и его воркеры. func (a *Agent) Run() { const goroutinesCount = 2 @@ -54,6 +61,9 @@ func (a *Agent) Run() { "server_url", a.ServerURL, "poll_interval", a.PollInterval, "report_interval", a.ReportInterval, + "send_compressed_data", a.SendCompressedData, + "private_key", a.PrivateKey, + "send_hash", a.IsRequestSigningEnabled(), ) go a.runPolls() go a.runReports() diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 57f4f06..8b14895 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -8,7 +8,7 @@ import ( ) func TestAgent_collectRuntimeMetrics(t *testing.T) { - a := agent.New("http://localhost:8080/metrics", 2*time.Second, 10*time.Second) + a := agent.New("http://localhost:8080/metrics", 2*time.Second, 10*time.Second, "") tests := []struct { name string want int diff --git a/internal/agent/send.go b/internal/agent/send.go index 182c6ce..267cac0 100644 --- a/internal/agent/send.go +++ b/internal/agent/send.go @@ -9,6 +9,8 @@ import ( "net/http" "time" + "github.com/maynagashev/go-metrics/pkg/sign" + "github.com/maynagashev/go-metrics/pkg/middleware/gzip" "github.com/maynagashev/go-metrics/internal/contracts/metrics" @@ -107,6 +109,12 @@ func (a *Agent) makeUpdatesRequest(items []*metrics.Metric, try int) error { return err } + // Если задан приватный ключ, добавляем хэш в заголовок запроса. + if a.IsRequestSigningEnabled() { + hash := sign.ComputeHMACSHA256(bytesBody, a.PrivateKey) + req.SetHeader(sign.HeaderKey, hash) + } + // Если включена сразу отправка сжатых данных, добавляем соответствующий заголовок. // Go клиент автоматом также добавляет заголовок "Accept-Encoding: gzip". if a.SendCompressedData { diff --git a/internal/server/app/config.go b/internal/server/app/config.go index ba69d87..f0d2c6a 100644 --- a/internal/server/app/config.go +++ b/internal/server/app/config.go @@ -10,6 +10,8 @@ type Config struct { Restore bool // Параметры базы данных Database DatabaseConfig + // Приватный ключ для подписи метрик. + PrivateKey string } type DatabaseConfig struct { @@ -27,6 +29,7 @@ func NewConfig(flags *Flags) *Config { DSN: flags.Database.DSN, MigrationsPath: flags.Database.MigrationsPath, }, + PrivateKey: flags.PrivateKey, } } @@ -59,3 +62,8 @@ func (cfg *Config) GetStoreInterval() int { func (cfg *Config) IsDatabaseEnabled() bool { return cfg.Database.DSN != "" } + +// IsRequestSigningEnabled включена ли проверка подписи метрик. +func (cfg *Config) IsRequestSigningEnabled() bool { + return cfg.PrivateKey != "" +} diff --git a/internal/server/handlers/json/update/json_update.go b/internal/server/handlers/json/update/json_update.go index 349d7fb..eda11ca 100644 --- a/internal/server/handlers/json/update/json_update.go +++ b/internal/server/handlers/json/update/json_update.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" + "github.com/maynagashev/go-metrics/pkg/sign" + "github.com/maynagashev/go-metrics/pkg/response" "go.uber.org/zap" @@ -28,10 +30,11 @@ type Metric struct { } // New возвращает http.HandlerFunc, который обновляет значение метрики в хранилище. -func New(_ *app.Config, strg storage.Repository, log *zap.Logger) http.HandlerFunc { +func New(cfg *app.Config, strg storage.Repository, log *zap.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - requestedMetric, err := parseMetricFromRequest(r, log) + requestedMetric, err := parseMetricFromRequest(r, log, cfg) if err != nil { + log.Error("error while parsing metric", zap.Error(err)) http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -64,7 +67,7 @@ func New(_ *app.Config, strg storage.Repository, log *zap.Logger) http.HandlerFu } // Читаем метрику из json запроса. -func parseMetricFromRequest(r *http.Request, log *zap.Logger) (Metric, error) { +func parseMetricFromRequest(r *http.Request, log *zap.Logger, cfg *app.Config) (Metric, error) { m := Metric{} buf := new(bytes.Buffer) _, err := buf.ReadFrom(r.Body) @@ -75,7 +78,20 @@ func parseMetricFromRequest(r *http.Request, log *zap.Logger) (Metric, error) { log.Debug("request body", zap.String("body", buf.String())) - err = json.Unmarshal(buf.Bytes(), &m) + body := buf.Bytes() + + if cfg.IsRequestSigningEnabled() { + // Проверяем подпись запроса + expectedHash := r.Header.Get(sign.HeaderKey) + requestHash, vErr := sign.VerifyHMACSHA256(body, cfg.PrivateKey, expectedHash) + if vErr != nil { + log.Error("error while verifying request signature", zap.Error(vErr), + zap.String("expected_hash", expectedHash), zap.String("request_hash", requestHash)) + return m, vErr + } + } + + err = json.Unmarshal(body, &m) if err != nil { return m, err } diff --git a/internal/server/handlers/json/updates/json_updates.go b/internal/server/handlers/json/updates/json_updates.go index 7763e6c..e89c436 100644 --- a/internal/server/handlers/json/updates/json_updates.go +++ b/internal/server/handlers/json/updates/json_updates.go @@ -1,9 +1,13 @@ package updates import ( + "bytes" "encoding/json" "net/http" + "github.com/maynagashev/go-metrics/internal/server/app" + sign "github.com/maynagashev/go-metrics/pkg/sign" + "github.com/maynagashev/go-metrics/pkg/response" "go.uber.org/zap" @@ -15,26 +19,36 @@ import ( // NewBulkUpdate возвращает http.HandlerFunc, который обновляет множество метрик в хранилище. // Метрики передаются в теле запроса в формате JSON. -func NewBulkUpdate(st storage.Repository, log *zap.Logger) http.HandlerFunc { +func NewBulkUpdate(cfg *app.Config, st storage.Repository, log *zap.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + var err error w.Header().Set("Content-Type", "application/json") - // Парсим тело запроса в слайс метрик + // Проверяем запрос на валидность и подпись если требуется. + body, err := validateRequest(r, log, cfg) + if err != nil { + log.Debug("validate request failed", zap.Error(err)) + response.Error(w, err, http.StatusBadRequest) + return + } + + // Парсим тело запроса в слайс метрик. var metricsToUpdate []metrics.Metric - err := json.NewDecoder(r.Body).Decode(&metricsToUpdate) + err = json.Unmarshal([]byte(body), &metricsToUpdate) if err != nil { + log.Debug("json decode failed", zap.Error(err)) response.Error(w, err, http.StatusBadRequest) return } - // Обновляем метрики в хранилище + // Обновляем метрики в хранилище. err = st.UpdateMetrics(metricsToUpdate) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - // Отправляем успешный ответ + // Отправляем успешный ответ. w.WriteHeader(http.StatusOK) // Логируем ответ для отладки @@ -44,3 +58,37 @@ func NewBulkUpdate(st storage.Repository, log *zap.Logger) http.HandlerFunc { response.OK(w, "Metrics updated successfully") } } + +// validateRequest проверяет запрос на валидность. +func validateRequest(r *http.Request, log *zap.Logger, cfg *app.Config) (string, error) { + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(r.Body) + if err != nil { + return "", err + } + + body := buf.Bytes() + + // Проверяем подпись запроса + if cfg.IsRequestSigningEnabled() { + hashFromRequest := r.Header.Get(sign.HeaderKey) + hash, vErr := sign.VerifyHMACSHA256(body, cfg.PrivateKey, hashFromRequest) + + log.Debug( + "validateRequest => sign.VerifyHMACSHA256", + zap.String("hash_from_request", hashFromRequest), + zap.Error( + vErr, + ), + zap.String("calc_hash", hash), + zap.Any("headers", r.Header), + zap.String("body", buf.String()), + ) + + if vErr != nil { + return "", vErr + } + } + + return buf.String(), nil +} diff --git a/internal/server/handlers/json/value/json_value.go b/internal/server/handlers/json/value/json_value.go index 691b1df..0ae2eaf 100644 --- a/internal/server/handlers/json/value/json_value.go +++ b/internal/server/handlers/json/value/json_value.go @@ -7,13 +7,16 @@ import ( "fmt" "net/http" + "github.com/maynagashev/go-metrics/internal/server/app" + "github.com/maynagashev/go-metrics/pkg/sign" + "github.com/maynagashev/go-metrics/internal/server/storage" "github.com/maynagashev/go-metrics/internal/contracts/metrics" ) // New хэндлер для получения значения метрики с сервера в ответ на запрос `POST /value`. -func New(storage storage.Repository) http.HandlerFunc { +func New(cfg *app.Config, storage storage.Repository) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var err error @@ -31,13 +34,19 @@ func New(storage storage.Repository) http.HandlerFunc { } // Отправляем json ответ с метрикой - encoded, err := json.Marshal(metric) + encodedBody, err := json.Marshal(metric) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - _, err = w.Write(encoded) + // Если задан приватный ключ, то подписываем ответ + if cfg.IsRequestSigningEnabled() { + signature := sign.ComputeHMACSHA256(encodedBody, cfg.PrivateKey) + w.Header().Set(sign.HeaderKey, signature) + } + + _, err = w.Write(encodedBody) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/internal/server/router/router.go b/internal/server/router/router.go index 49807d2..d0d7331 100644 --- a/internal/server/router/router.go +++ b/internal/server/router/router.go @@ -39,8 +39,8 @@ func New(config *app.Config, storage storage.Repository, log *zap.Logger) chi.Ro // Обработчики запросов r.Get("/", plainIndex.New(storage)) r.Post("/update", jsonUpdate.New(config, storage, log)) - r.Post("/updates", jsonUpdates.NewBulkUpdate(storage, log)) - r.Post("/value", jasonValue.New(storage)) + r.Post("/updates", jsonUpdates.NewBulkUpdate(config, storage, log)) + r.Post("/value", jasonValue.New(config, storage)) r.Get("/ping", ping.New(config, log)) // Первые версии обработчиков для работы тестов начальных итераций diff --git a/internal/server/storage/pgstorage/pgstorage.go b/internal/server/storage/pgstorage/pgstorage.go index 8a271fc..d7ecac0 100644 --- a/internal/server/storage/pgstorage/pgstorage.go +++ b/internal/server/storage/pgstorage/pgstorage.go @@ -116,7 +116,7 @@ func (p *PgStorage) GetMetric(mType metrics.MetricType, name string) (metrics.Me // Логируем и возвращаем ошибку, если не удалось получить метрику if err != nil { - p.log.Error(fmt.Sprintf("Failed to get metric: %v", err)) + p.log.Error(fmt.Sprintf("Failed to get metric after %d tries: %v", maxRetries+1, err)) } return metrics.Metric{}, false } diff --git a/pkg/sign/sign.go b/pkg/sign/sign.go new file mode 100644 index 0000000..a0ad340 --- /dev/null +++ b/pkg/sign/sign.go @@ -0,0 +1,35 @@ +package sign + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" +) + +const HeaderKey = "HashSHA256" + +// ComputeHMACSHA256 вычисляет хеш SHA256 от данных с использованием ключа. +func ComputeHMACSHA256(data []byte, key string) string { + h := hmac.New(sha256.New, []byte(key)) + h.Write(data) + return hex.EncodeToString(h.Sum(nil)) +} + +// VerifyHMACSHA256 проверяет, что хеш SHA256 от данных с использованием ключа совпадает с ожидаемым значением. +func VerifyHMACSHA256(data []byte, key string, expectedMAC string) (string, error) { + // Если хэш не задан, то и не проверяем. + // Тесты предполагают что с пустым хэшем его не следует проверять, даже если указан приватный ключ -k при старте. + // См. обсуждение в чате: https://app.pachca.com/chats/8850763?message=245816301 + if expectedMAC == "" { + return "", nil + } + + mac := ComputeHMACSHA256(data, key) + + if !hmac.Equal([]byte(mac), []byte(expectedMAC)) { + return mac, errors.New("invalid hash in request header") + } + + return mac, nil +}