feat(gateway): WebSocket Gateway с HELLO, IDENTIFY и READY

Реализован 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.
This commit is contained in:
2026-09-19 21:42:41 +03:00
parent c0537d3c17
commit 86dff94a02
11 changed files with 1364 additions and 18 deletions
+27 -16
View File
@@ -14,6 +14,7 @@ import (
"glchat/internal/auth"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/httpx"
"glchat/internal/meta"
"glchat/internal/store"
@@ -22,29 +23,32 @@ import (
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
// В Фазе 0 они могут отсутствовать (инстанс без секретов ещё поднимается).
type Deps struct {
Store *store.Store
Auth *auth.Service
Store *store.Store
Auth *auth.Service
Gateway *gateway.Service
}
type Server struct {
cfg config.Config
db *database.DB
store *store.Store
auth *auth.Service
logger *slog.Logger
http *http.Server
static *staticHandler
api huma.API
cfg config.Config
db *database.DB
store *store.Store
auth *auth.Service
gateway *gateway.Service
logger *slog.Logger
http *http.Server
static *staticHandler
api huma.API
}
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
s := &Server{
cfg: cfg,
db: db,
store: deps.Store,
auth: deps.Auth,
logger: logger,
static: newStaticHandler(cfg.WebRoot),
cfg: cfg,
db: db,
store: deps.Store,
auth: deps.Auth,
gateway: deps.Gateway,
logger: logger,
static: newStaticHandler(cfg.WebRoot),
}
router := chi.NewRouter()
@@ -95,6 +99,13 @@ func (s *Server) registerAPI(router chi.Router) huma.API {
func (s *Server) registerRoutes(router chi.Router) {
router.Get("/healthz", s.handleHealthz)
router.Get("/readyz", s.handleReadyz)
if s.gateway != nil {
router.Handle("/gateway", s.gateway.Handler())
} else {
router.HandleFunc("/gateway", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, auth.ErrSessionExpired)
})
}
router.NotFound(s.handleFallback)
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
+62 -1
View File
@@ -9,10 +9,14 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"glchat/internal/auth"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/store"
)
@@ -58,7 +62,64 @@ func newTestServer(t *testing.T) (*Server, *database.DB) {
if err != nil {
t.Fatalf("initialize authentication: %v", err)
}
return New(cfg, db, logger, Deps{Store: st, Auth: authService}), db
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins())
return New(cfg, db, logger, Deps{Store: st, Auth: authService, Gateway: gatewayService}), db
}
// TestGatewayRouteUpgrades проверяет связку: маршрут /gateway доступен через
// полный стек middleware, HELLO приходит до IDENTIFY (AGENT.md 8.3).
func TestGatewayRouteUpgrades(t *testing.T) {
srv, _ := newTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/gateway"
// coder/websocket закрывает тело ответа сам (dial.go: "You never need to
// close resp.Body yourself") — отсюда nolint:bodyclose.
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ //nolint:bodyclose // тело закрывает библиотека
HTTPHeader: map[string][]string{"Origin": {"https://gl.mhspx.su"}},
})
if err != nil {
t.Fatalf("dial /gateway: %v", err)
}
t.Cleanup(func() { _ = conn.CloseNow() })
_, data, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read HELLO: %v", err)
}
var envelope struct {
Op int `json:"op"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatalf("decode HELLO: %v", err)
}
if envelope.Op != gateway.OpHello {
t.Fatalf("op = %d, want HELLO (%d)", envelope.Op, gateway.OpHello)
}
}
// TestGatewayRejectsForeignOrigin: подключение с чужого домена отклоняется
// (AGENT.md 9.7).
func TestGatewayRejectsForeignOrigin(t *testing.T) {
srv, _ := newTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/gateway"
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ //nolint:bodyclose // тело закрывает библиотека
HTTPHeader: map[string][]string{"Origin": {"https://evil.example"}},
})
if err == nil {
_ = conn.CloseNow()
t.Fatal("connection from a foreign origin must be rejected")
}
}
func TestHealthz(t *testing.T) {