86dff94a02
Реализован Gateway по AGENT.md §8.3:
- оп-коды 0/1/2/3/4/7/10/11, единый конверт {op,t,d,s};
- HELLO с интервалом сердцебиения (45 с) и идентификатором сессии;
- IDENTIFY/RESUME с проверкой токена через auth.ResolveSession;
- READY: пользователь, серверы, видимые комнаты, роли, участники и права,
собранные через движок прав (VIEW_CHANNEL-фильтрация);
- HEARTBEAT → HEARTBEAT_ACK, лимит частоты IDENTIFY (5 с);
- RESUME: буфер на 1000 событий с TTL 5 минут, догон пропущенных событий и
подтверждение RESUMED, полный READY при расхождении состояния;
- рассылка Dispatch/SendToUser с буферизацией для отключённых сессий;
- проверка Origin по allowlist доменов инстанса (AGENT.md §9.7);
- httpx.statusRecorder пробрасывает Hijack, иначе апгрейд ломается.
Тесты: 6 сценариев Gateway (READY, скрытые комнаты, неверный токен,
heartbeat, dispatch, RESUME) и 2 теста маршрута /gateway через полный стек
middleware, включая отклонение чужого Origin.
196 lines
5.5 KiB
Go
196 lines
5.5 KiB
Go
package httpx
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Middleware func(http.Handler) http.Handler
|
|
|
|
func Chain(h http.Handler, mws ...Middleware) http.Handler {
|
|
for i := len(mws) - 1; i >= 0; i-- {
|
|
h = mws[i](h)
|
|
}
|
|
return h
|
|
}
|
|
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
}
|
|
|
|
func (r *statusRecorder) WriteHeader(status int) {
|
|
r.status = status
|
|
r.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (r *statusRecorder) Write(b []byte) (int, error) {
|
|
if r.status == 0 {
|
|
r.status = http.StatusOK
|
|
}
|
|
n, err := r.ResponseWriter.Write(b)
|
|
r.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
func (r *statusRecorder) Flush() {
|
|
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// Unwrap отдаёт исходный ResponseWriter: http.ResponseController и другие
|
|
// обёртки должны видеть возможности базового писателя.
|
|
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
|
|
|
// Hijack пробрасывает перехват соединения: без него не работает апгрейд на
|
|
// WebSocket через /gateway (AGENT.md 8.3).
|
|
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
hijacker, ok := r.ResponseWriter.(http.Hijacker)
|
|
if !ok {
|
|
return nil, nil, http.ErrNotSupported
|
|
}
|
|
return hijacker.Hijack()
|
|
}
|
|
|
|
// RequestInfoMiddleware кладёт IP и User-Agent запроса в контекст: huma-хендлеры
|
|
// не получают *http.Request, а сервисам эти данные нужны (аудит, безопасность).
|
|
func RequestInfoMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
next.ServeHTTP(w, r.WithContext(WithRequestInfo(r.Context(), r)))
|
|
})
|
|
}
|
|
|
|
func RequestID(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.Header.Get("X-Request-Id")
|
|
if id == "" {
|
|
id = newRequestID()
|
|
}
|
|
w.Header().Set("X-Request-Id", id)
|
|
next.ServeHTTP(w, r.WithContext(withRequestID(r.Context(), id)))
|
|
})
|
|
}
|
|
|
|
func Logger(logger *slog.Logger) Middleware {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rec := &statusRecorder{ResponseWriter: w}
|
|
next.ServeHTTP(rec, r)
|
|
if rec.status == 0 {
|
|
rec.status = http.StatusOK
|
|
}
|
|
logger.LogAttrs(r.Context(), slog.LevelInfo, "http request",
|
|
slog.String("request_id", RequestIDFrom(r.Context())),
|
|
slog.String("method", r.Method),
|
|
slog.String("path", r.URL.Path),
|
|
slog.Int("status", rec.status),
|
|
slog.Int("bytes", rec.bytes),
|
|
slog.String("remote_ip", ClientIP(r, nil)),
|
|
slog.Duration("duration", time.Since(start)),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Recoverer(logger *slog.Logger) Middleware {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
logger.ErrorContext(r.Context(), "panic recovered",
|
|
slog.String("request_id", RequestIDFrom(r.Context())),
|
|
slog.String("path", r.URL.Path),
|
|
slog.Any("panic", rec),
|
|
)
|
|
WriteError(w, NewError(CodeInternalError, "internal error"))
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func SecurityHeaders(filesDomain string) Middleware {
|
|
frameAncestors := "'none'"
|
|
csp := strings.Join([]string{
|
|
"default-src 'self'",
|
|
"base-uri 'self'",
|
|
"object-src 'none'",
|
|
"frame-ancestors " + frameAncestors,
|
|
"form-action 'self'",
|
|
}, "; ")
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("Content-Security-Policy", csp)
|
|
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Cross-Origin-Resource-Policy", "same-site")
|
|
if isTLS(r) {
|
|
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func isTLS(r *http.Request) bool {
|
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
}
|
|
|
|
func ClientIP(r *http.Request, trustedProxies []*net.IPNet) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
proxyTrusted := false
|
|
for _, n := range trustedProxies {
|
|
if ip := net.ParseIP(host); ip != nil && n.Contains(ip) {
|
|
proxyTrusted = true
|
|
break
|
|
}
|
|
}
|
|
if !proxyTrusted {
|
|
return host
|
|
}
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
// Caddy дописывает в конец цепочки адрес непосредственного клиента,
|
|
// поэтому при одном доверенном прокси последний элемент и есть клиент.
|
|
parts := strings.Split(xff, ",")
|
|
candidate := strings.TrimSpace(parts[len(parts)-1])
|
|
if ip := net.ParseIP(candidate); ip != nil {
|
|
return ip.String()
|
|
}
|
|
}
|
|
if realIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")); realIP != "" {
|
|
if ip := net.ParseIP(realIP); ip != nil {
|
|
return ip.String()
|
|
}
|
|
}
|
|
return host
|
|
}
|
|
|
|
type ctxKey int
|
|
|
|
const requestIDKey ctxKey = iota
|
|
|
|
func withRequestID(ctx context.Context, id string) context.Context {
|
|
return context.WithValue(ctx, requestIDKey, id)
|
|
}
|
|
|
|
func RequestIDFrom(ctx context.Context) string {
|
|
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|