хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// Своё правило ограничителя частоты под корнем приложения.
|
||||
//
|
||||
// Заводится потому, что правило хранилища настроено на **его** корень и наших
|
||||
// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с
|
||||
// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая —
|
||||
// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле.
|
||||
// Бюджет ограничителя частоты под корнем приложения.
|
||||
//
|
||||
// Числа скромные намеренно: сервисом пользуются единицы человек, а экран
|
||||
// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится
|
||||
@@ -22,71 +22,202 @@ const (
|
||||
appRateWindowSec = 60
|
||||
)
|
||||
|
||||
// ApplyAppRateLimit ставит правило ограничителя на корень приложения.
|
||||
//
|
||||
// Правило приводится к настройке при каждом подъёме, как и настройки провайдера:
|
||||
// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и
|
||||
// правило, положенное однажды, не пережило бы ни правки числа, ни чистого
|
||||
// каталога данных.
|
||||
func ApplyAppRateLimit(app core.App) error {
|
||||
settings := app.Settings()
|
||||
// staleBudgetAge — через сколько неиспользуемый счётчик выбрасывается. Карта
|
||||
// счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда
|
||||
// спрашивающий кладёт по строке на каждый свой адрес.
|
||||
const staleBudgetAge = 10 * time.Minute
|
||||
|
||||
rule := core.RateLimitRule{
|
||||
Label: AppRoot + "/",
|
||||
MaxRequests: appRateMaxRequests,
|
||||
Duration: appRateWindowSec,
|
||||
// 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
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for i, existing := range settings.RateLimits.Rules {
|
||||
if existing.Label == rule.Label {
|
||||
settings.RateLimits.Rules[i] = rule
|
||||
replaced = true
|
||||
break
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule)
|
||||
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
|
||||
}
|
||||
|
||||
settings.RateLimits.Enabled = true
|
||||
b.hits = fresh(b.hits, now.Add(-l.window))
|
||||
if len(b.hits) >= l.max {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
||||
}
|
||||
return nil
|
||||
b.hits = append(b.hits, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать
|
||||
// адрес спрашивающего.
|
||||
//
|
||||
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с
|
||||
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет
|
||||
// в этом случае общий на весь сервис: восемь одновременно открытых карточек
|
||||
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного
|
||||
// запроса. Норма при этом требует обратного — бюджет считается по адресу
|
||||
// спрашивающего.
|
||||
//
|
||||
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес
|
||||
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно
|
||||
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру,
|
||||
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**,
|
||||
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ
|
||||
// счётчика сам и обходит ограничитель, меняя значение.
|
||||
//
|
||||
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл,
|
||||
// по-прежнему решает адрес самого соединения.
|
||||
func ApplyTrustedProxyHeaders(app core.App) error {
|
||||
settings := app.Settings()
|
||||
// sweep выбрасывает счётчики, которыми давно не пользовались. Идёт не чаще раза
|
||||
// в окно: обход карты на каждом запросе стоил бы больше самого ограничителя.
|
||||
func (l *rateLimiter) sweep(now time.Time) {
|
||||
if now.Sub(l.swept) < l.window {
|
||||
return
|
||||
}
|
||||
l.swept = now
|
||||
|
||||
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"}
|
||||
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда
|
||||
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий,
|
||||
// чем один пустой ключ на всех.
|
||||
settings.TrustedProxy.UseLeftmostIP = true
|
||||
for key, b := range l.budgets {
|
||||
b.hits = fresh(b.hits, now.Add(-staleBudgetAge))
|
||||
if len(b.hits) == 0 {
|
||||
delete(l.budgets, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user