Skip to content

Commit

Permalink
Использование подписей при отправке метрик на сервер и получении метр…
Browse files Browse the repository at this point in the history
…ики с сервера
  • Loading branch information
maynagashev committed May 24, 2024
1 parent 1adad02 commit d92859d
Show file tree
Hide file tree
Showing 13 changed files with 163 additions and 29 deletions.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

## Обновление шаблона

Expand Down
2 changes: 1 addition & 1 deletion cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
12 changes: 11 additions & 1 deletion internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Agent struct {
ReportInterval time.Duration
ServerURL string
SendCompressedData bool
PrivateKey string

gauges map[string]float64
counters map[string]int64
Expand All @@ -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"),
Expand All @@ -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
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/agent/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions internal/server/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ type Config struct {
Restore bool
// Параметры базы данных
Database DatabaseConfig
// Приватный ключ для подписи метрик.
PrivateKey string
}

type DatabaseConfig struct {
Expand All @@ -27,6 +29,7 @@ func NewConfig(flags *Flags) *Config {
DSN: flags.Database.DSN,
MigrationsPath: flags.Database.MigrationsPath,
},
PrivateKey: flags.PrivateKey,
}
}

Expand Down Expand Up @@ -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 != ""
}
24 changes: 20 additions & 4 deletions internal/server/handlers/json/update/json_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
58 changes: 53 additions & 5 deletions internal/server/handlers/json/updates/json_updates.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)

// Логируем ответ для отладки
Expand All @@ -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
}
15 changes: 12 additions & 3 deletions internal/server/handlers/json/value/json_value.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/server/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

// Первые версии обработчиков для работы тестов начальных итераций
Expand Down
2 changes: 1 addition & 1 deletion internal/server/storage/pgstorage/pgstorage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
35 changes: 35 additions & 0 deletions pkg/sign/sign.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit d92859d

Please sign in to comment.