package http import ( "net/http" "net/netip" "strings" "sync" "time" "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" ) // Бюджет ограничителя частоты под корнем приложения. // // Числа скромные намеренно: сервисом пользуются единицы человек, а экран // опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится // частота опроса, которую сервис объявляет приложению, — два числа об одном и // том же разъехались бы при первой правке. const ( appRateMaxRequests = 120 appRateWindowSec = 60 ) // staleBudgetAge — через сколько неиспользуемый счётчик выбрасывается. Карта // счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда // спрашивающий кладёт по строке на каждый свой адрес. const staleBudgetAge = 10 * time.Minute // RateLimit — свой ограничитель частоты под корнем приложения. // // Бюджет считается по адресу спрашивающего скользящим окном: столько-то запросов // за столько-то секунд. Отказ уходит **нашей** формой тела — слой отвечает // доменной ошибкой, а не телом библиотеки, и второй формы отказа на адресах // приложения не появляется. // // **Адрес спрашивающего берётся из заголовка пересылки — и только тогда, когда // соединение пришло с адреса из объявленного перечня доверенных.** Во всяком // другом случае адресом считается адрес пира, а пришедший заголовок на ключ // бюджета не влияет ничем. // // **Цепочка пересылки читается справа налево до первого недоверенного адреса.** // Значением слева распоряжается тот, кто шлёт запрос: прокси заголовок // дописывает, а не заменяет, и левое значение цепочки он берёт из присланного. // // Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, // и пир у него один на всех: бюджет, посчитанный по пиру, становится общим на // весь сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — // верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого // он ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт // запрос, и меняет он его на каждом запросе. // // **Узнавание и ограничитель берут адрес разными способами, и это намеренно.** // Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку // вообще. Ограничителю нужен адрес того, кого он ограничивает, а тот за прокси в // адресе соединения не виден вовсе. // // Слой стоит **перед** узнаванием: узнавание читает базу, а на новом имени ещё и // пишет в неё; выполненное раньше ограничителя, оно работало бы на запросах, // которые тот уже отверг, и поток отвергнутых обращений заводил бы учётные // записи, которые потом не убираются ничем. func RateLimit(trusted []netip.Prefix) func(http.Handler) http.Handler { limiter := newRateLimiter(appRateMaxRequests, appRateWindowSec*time.Second) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !limiter.allow(clientAddress(r, trusted)) { fail(w, contract.ErrTooManyRequests) return } next.ServeHTTP(w, r) }) } } // clientAddress — тот, кого ограничивают. func clientAddress(r *http.Request, trusted []netip.Prefix) string { peer, ok := peerAddress(r.RemoteAddr) if !ok { // Адрес пира не разобран — ключ берётся строкой как есть: общий бюджет // лучше отсутствующего. return r.RemoteAddr } if !isTrusted(trusted, peer) { return peer.String() } if client, ok := forwardedClient(r, trusted); ok { return client } // Прокси адреса не назвал либо назвал одними доверенными: счётчик падает // обратно на адрес пира — то есть на общий. Лучше общий, чем ключ, который // выбирает сам спрашивающий. return peer.String() } // forwardedClient достаёт из цепочки пересылки того, кого ограничивают. // // **Цепочка читается справа налево, а доверенные адреса отбрасываются.** // Значение слева пишет тот, кто шлёт запрос: прокси заголовок **дописывает**, а // не заменяет, поэтому левым значением цепочки распоряжается аноним, и ключ // бюджета, взятый оттуда, меняется у него на каждом запросе — бюджет обходится // с первого. Справа же стоит адрес, который приписал ближайший к нам прокси, и // каждый следующий шаг влево тем доверен, кто его приписал. Первый недоверенный // справа и есть последний адрес, за который отвечает контур, а не спрашивающий. // // Заголовок читается **всеми** строками, а не одной: цепочка законно приходит // несколькими заголовками, и разбор одной строки увидел бы только её часть — то // есть снова кусок, которым распоряжается аноним. func forwardedClient(r *http.Request, trusted []netip.Prefix) (string, bool) { hops := forwardedChain(r) for i := len(hops) - 1; i >= 0; i-- { addr, err := netip.ParseAddr(hops[i]) if err != nil { // Значение, которое не разбирается адресом, доверенным считать // нечем: цепочка на нём кончается. return "", false } addr = addr.Unmap() if isTrusted(trusted, addr) { continue } return addr.String(), true } return "", false } // forwardedChain — цепочка пересылки одним списком: все строки заголовка по // порядку, каждая разобрана по запятым. func forwardedChain(r *http.Request) []string { var hops []string for _, value := range r.Header.Values(ForwardedForHeader) { for _, hop := range strings.Split(value, ",") { hop = strings.TrimSpace(hop) if hop != "" { hops = append(hops, hop) } } } return hops } // rateLimiter — счётчики бюджета по ключу. // // Окно скользящее и хранится метками времени: счётчик с обнулением по границе // окна пропустил бы двойной бюджет на стыке двух окон. type rateLimiter struct { mu sync.Mutex max int window time.Duration budgets map[string]*budget swept time.Time } type budget struct { hits []time.Time } func newRateLimiter(maxRequests int, window time.Duration) *rateLimiter { return &rateLimiter{ max: maxRequests, window: window, budgets: map[string]*budget{}, swept: clock.Now(), } } func (l *rateLimiter) allow(key string) bool { now := clock.Now() l.mu.Lock() defer l.mu.Unlock() l.sweep(now) b, ok := l.budgets[key] if !ok { b = &budget{} l.budgets[key] = b } b.hits = fresh(b.hits, now.Add(-l.window)) if len(b.hits) >= l.max { return false } b.hits = append(b.hits, now) return true } // sweep выбрасывает счётчики, которыми давно не пользовались. Идёт не чаще раза // в окно: обход карты на каждом запросе стоил бы больше самого ограничителя. func (l *rateLimiter) sweep(now time.Time) { if now.Sub(l.swept) < l.window { return } l.swept = now for key, b := range l.budgets { b.hits = fresh(b.hits, now.Add(-staleBudgetAge)) if len(b.hits) == 0 { delete(l.budgets, key) } } } // fresh отбрасывает метки старше границы. Метки лежат по возрастанию, поэтому // достаточно найти первую свежую. func fresh(hits []time.Time, edge time.Time) []time.Time { for i, hit := range hits { if hit.After(edge) { return hits[i:] } } return nil }