- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
224 lines
10 KiB
Go
224 lines
10 KiB
Go
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
|
|
}
|