From 86dff94a02b3e913077300c11ad524796ca8dcfd Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 19 Sep 2026 21:42:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20WebSocket=20Gateway=20=D1=81?= =?UTF-8?q?=20HELLO,=20IDENTIFY=20=D0=B8=20READY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован 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. --- cmd/glchat/main.go | 4 +- go.mod | 1 + go.sum | 2 + internal/config/config.go | 13 + internal/gateway/gateway.go | 265 +++++++++++++++++++ internal/gateway/gateway_test.go | 311 ++++++++++++++++++++++ internal/gateway/ready.go | 235 +++++++++++++++++ internal/gateway/session.go | 430 +++++++++++++++++++++++++++++++ internal/httpx/middleware.go | 15 ++ internal/server/server.go | 43 ++-- internal/server/server_test.go | 63 ++++- 11 files changed, 1364 insertions(+), 18 deletions(-) create mode 100644 internal/gateway/gateway.go create mode 100644 internal/gateway/gateway_test.go create mode 100644 internal/gateway/ready.go create mode 100644 internal/gateway/session.go diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index b85f3e0..ba02e75 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -16,6 +16,7 @@ import ( "glchat/internal/auth" "glchat/internal/config" "glchat/internal/database" + "glchat/internal/gateway" "glchat/internal/server" "glchat/internal/store" ) @@ -145,7 +146,8 @@ func run() error { return fmt.Errorf("initialize authentication: %w", err) } - srv := server.New(cfg, db, logger, server.Deps{Store: st, Auth: authService}) + gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins()) + srv := server.New(cfg, db, logger, server.Deps{Store: st, Auth: authService, Gateway: gatewayService}) errCh := make(chan error, 1) go func() { logger.Info("http server listening", diff --git a/go.mod b/go.mod index 4dddb12..967c77a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module glchat go 1.26.0 require ( + github.com/coder/websocket v1.8.15 github.com/danielgtaylor/huma/v2 v2.39.1 github.com/go-chi/chi/v5 v5.3.2 github.com/mattn/go-sqlite3 v1.14.52 diff --git a/go.sum b/go.sum index fc4de1c..db9cc9a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g= github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/config/config.go b/internal/config/config.go index 9dbc9bd..853f517 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,6 +100,19 @@ func (c Config) Scheme() string { return "http" } +// AllowedOrigins возвращает домены, с которых разрешены WebSocket и +// state-changing запросы (AGENT.md 9.7: точный allowlist, никаких *). +func (c Config) AllowedOrigins() []string { + origins := []string{ + "https://" + c.Domain, + "http://" + c.Domain, + } + if c.FilesDomain != "" && c.FilesDomain != c.Domain { + origins = append(origins, "https://"+c.FilesDomain, "http://"+c.FilesDomain) + } + return origins +} + func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain } func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go new file mode 100644 index 0000000..1d4c83a --- /dev/null +++ b/internal/gateway/gateway.go @@ -0,0 +1,265 @@ +// Package gateway реализует WebSocket Gateway glchat (AGENT.md 8.3): +// HELLO/IDENTIFY/READY/HEARTBEAT/RESUME, единый JSON-конверт и рассылку +// событий с фильтрацией по правам. +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "glchat/internal/auth" + "glchat/internal/store" +) + +// Оп-коды протокола (AGENT.md 8.3). +const ( + OpDispatch = 0 + OpHeartbeat = 1 + OpIdentify = 2 + OpResume = 3 + OpInvalidSess = 4 + OpReconnect = 7 + OpHello = 10 + OpHeartbeatAck = 11 +) + +// Ограничения протокола (AGENT.md 8.3). +const ( + HeartbeatInterval = 45 * time.Second + IdentifyRateLimit = 5 * time.Second + MaxIncomingFrame = 16 << 10 + MaxOutgoingFrame = 64 << 10 + ResumeBufferSize = 1000 + ResumeBufferTTL = 5 * time.Minute + WriteTimeout = 10 * time.Second +) + +// Envelope — единый конверт сообщений Gateway. +type Envelope struct { + Op int `json:"op"` + T string `json:"t,omitempty"` + D json.RawMessage `json:"d,omitempty"` + S int64 `json:"s,omitempty"` +} + +// Ready — снапшот состояния пользователя при подключении (AGENT.md 8.3). +type Ready struct { + User ReadyUser `json:"user"` + Guilds []ReadyGuild `json:"guilds"` + DMChannels []ReadyChannel `json:"dm_channels"` + ReadStates []ReadyRead `json:"read_states"` + SessionID string `json:"session_id"` + HeartbeatMS int `json:"heartbeat_interval_ms"` +} + +type ReadyUser struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + AvatarFileID string `json:"avatar_file_id,omitempty"` + IsInstanceAdmin bool `json:"is_instance_admin"` + Badges []string `json:"badges"` +} + +type ReadyGuild struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + IsMain bool `json:"is_main"` + Channels []ReadyChannel `json:"channels"` + Roles []ReadyRole `json:"roles"` + MemberIDs []string `json:"member_ids"` + MyRoles []string `json:"my_role_ids"` + MyNickname string `json:"my_nickname,omitempty"` + MyPerms []string `json:"my_permissions"` +} + +type ReadyChannel struct { + ID string `json:"id"` + GuildID string `json:"guild_id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Position int `json:"position"` + ParentID string `json:"parent_id,omitempty"` + UserLimit int `json:"user_limit,omitempty"` + Slowmode int `json:"slowmode_seconds,omitempty"` + CanSend bool `json:"can_send"` + CanConnect bool `json:"can_connect"` +} + +type ReadyRole struct { + ID string `json:"id"` + Name string `json:"name"` + Color int64 `json:"color"` + Position int `json:"position"` + Permissions string `json:"permissions"` + IsDefault bool `json:"is_default"` + Mentionable bool `json:"mentionable"` + Hoist bool `json:"hoist"` +} + +type ReadyRead struct { + ChannelID string `json:"channel_id"` + LastMessageID string `json:"last_message_id,omitempty"` + MentionCount int `json:"mention_count"` +} + +// serverFrame — входящее сообщение от клиента. +type serverFrame struct { + Op int `json:"op"` + D json.RawMessage `json:"d"` + T string `json:"t"` + S int64 `json:"s"` +} + +type identifyPayload struct { + Token string `json:"token"` + ResumeSeq int64 `json:"resume_seq"` + SessionID string `json:"session_id"` +} + +// Service — Gateway: управляет подключениями и рассылкой событий. +type Service struct { + store *store.Store + auth *auth.Service + readiness SnapshotBuilder + logger *slog.Logger + // allowedOrigins — домены, с которых разрешено подключаться (AGENT.md 9.7). + allowedOrigins []string + + mu sync.RWMutex + sessions map[string]*clientSession + buffers map[string]*resumeBuffer + seq int64 +} + +// SnapshotBuilder собирает READY-снапшот (вынесено для тестируемости). +type SnapshotBuilder interface { + Build(ctx context.Context, user *store.User) (*Ready, error) +} + +func New(st *store.Store, authService *auth.Service, builder SnapshotBuilder, logger *slog.Logger, allowedOrigins []string) *Service { + return &Service{ + store: st, + auth: authService, + readiness: builder, + logger: logger, + allowedOrigins: allowedOrigins, + sessions: map[string]*clientSession{}, + buffers: map[string]*resumeBuffer{}, + } +} + +// Handler отдаёт http.Handler для маршрута /gateway. +func (s *Service) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.ServeHTTP(w, r) + }) +} + +// Dispatch рассылает событие всем сессиям пользователя (AGENT.md 8.3). +func (s *Service) Dispatch(event string, payload any) { + raw, err := json.Marshal(payload) + if err != nil { + s.logger.Error("marshal gateway payload", slog.String("event", event), slog.Any("error", err)) + return + } + s.mu.Lock() + s.seq++ + seq := s.seq + envelope := Envelope{Op: OpDispatch, T: event, D: raw, S: seq} + encoded, err := json.Marshal(envelope) + if err != nil { + s.mu.Unlock() + s.logger.Error("marshal gateway envelope", slog.Any("error", err)) + return + } + sessions := make([]*clientSession, 0, len(s.sessions)) + for _, session := range s.sessions { + sessions = append(sessions, session) + } + // Буфер RESUME наполняем для всех известных сессий, включая недавно + // отключённые: иначе события, пришедшие в разрыв связи, потеряются + // (AGENT.md 8.3). Буферы ограничены по размеру и времени жизни. + for _, buffer := range s.buffers { + buffer.append(seq, encoded) + } + s.mu.Unlock() + + for _, session := range sessions { + if err := session.write(encoded); err != nil { + s.logger.Debug("gateway write failed", slog.Any("error", err)) + } + } + if s.seq%512 == 0 { + s.cleanupBuffers() + } +} + +func (s *Service) cleanupBuffers() { + cutoff := time.Now().Add(-ResumeBufferTTL) + s.mu.Lock() + defer s.mu.Unlock() + for id, buffer := range s.buffers { + if buffer.lastSeen().Before(cutoff) { + delete(s.buffers, id) + } + } +} + +// ActiveSessions возвращает число активных сессий (метрики и тесты). +func (s *Service) ActiveSessions() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.sessions) +} + +// SendToUser доставляет событие конкретному пользователю. +func (s *Service) SendToUser(userID uint64, event string, payload any) { + raw, err := json.Marshal(payload) + if err != nil { + return + } + s.mu.Lock() + s.seq++ + envelope := Envelope{Op: OpDispatch, T: event, D: raw, S: s.seq} + encoded, err := json.Marshal(envelope) + if err != nil { + s.mu.Unlock() + return + } + targets := make([]*clientSession, 0, 2) + for _, session := range s.sessions { + if session.userID == userID { + targets = append(targets, session) + } + } + // Событие попадает и в буфер отключённого пользователя: при RESUME он + // получит пропущенное (AGENT.md 8.3). + for _, buffer := range s.buffers { + if buffer.owner == userID { + buffer.append(envelope.S, encoded) + } + } + s.mu.Unlock() + for _, session := range targets { + _ = session.write(encoded) + } +} + +func frame(op int, payload any) ([]byte, error) { + var encoded json.RawMessage + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + encoded = raw + } + return json.Marshal(Envelope{Op: op, D: encoded}) +} diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go new file mode 100644 index 0000000..2a53ba3 --- /dev/null +++ b/internal/gateway/gateway_test.go @@ -0,0 +1,311 @@ +package gateway_test + +import ( + "context" + "encoding/json" + "log/slog" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "glchat/internal/auth" + "glchat/internal/bootstrap" + "glchat/internal/config" + "glchat/internal/database" + "glchat/internal/gateway" + "glchat/internal/permissions" + "glchat/internal/store" +) + +type fixture struct { + service *gateway.Service + store *store.Store + auth *auth.Service + server *httptest.Server +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + ctx := context.Background() + db, err := database.Open(ctx, database.Options{ + Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true, + }) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + st := store.New(db) + logger := slog.New(slog.DiscardHandler) + cfg := config.Config{ + Domain: "gl.test", + SessionPepper: "gateway-pepper", + MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + Argon2MemoryKiB: 1024, + Argon2Iterations: 1, + Argon2Parallelism: 1, + } + authService, err := auth.New(ctx, cfg, st, logger) + if err != nil { + t.Fatalf("auth.New: %v", err) + } + // Главный сервер с ролями и общей комнатой создаёт bootstrap. + if _, err := bootstrap.New(st, authService, logger).Run(ctx, bootstrap.Options{ + Email: "owner@example.com", Password: "correct-horse-battery", + }); err != nil { + t.Fatalf("bootstrap: %v", err) + } + + service := gateway.New(st, authService, gateway.NewSnapshot(st), logger, []string{"https://gl.test"}) + server := httptest.NewServer(service.Handler()) + t.Cleanup(server.Close) + + return &fixture{service: service, store: st, auth: authService, server: server} +} + +// dial подключается к Gateway и читает HELLO. +func (f *fixture) dial(t *testing.T) (*websocket.Conn, gateway.Envelope) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + url := "ws" + strings.TrimPrefix(f.server.URL, "http") + conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + HTTPHeader: map[string][]string{"Origin": {"https://gl.test"}}, + }) + if err != nil { + t.Fatalf("dial gateway: %v", err) + } + t.Cleanup(func() { _ = conn.CloseNow() }) + + envelope := readEnvelope(t, conn) + if envelope.Op != gateway.OpHello { + t.Fatalf("first frame op = %d, want HELLO (%d)", envelope.Op, gateway.OpHello) + } + return conn, envelope +} + +func readEnvelope(t *testing.T, conn *websocket.Conn) gateway.Envelope { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, data, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read frame: %v", err) + } + var envelope gateway.Envelope + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatalf("decode frame: %v (raw: %s)", err, data) + } + return envelope +} + +func send(t *testing.T, conn *websocket.Conn, op int, payload any) { + t.Helper() + body, err := json.Marshal(map[string]any{"op": op, "d": payload}) + if err != nil { + t.Fatalf("marshal frame: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := conn.Write(ctx, websocket.MessageText, body); err != nil { + t.Fatalf("write frame: %v", err) + } +} + +func registerUser(t *testing.T, f *fixture, username, email string) string { + t.Helper() + _, token, _, err := f.auth.Register(context.Background(), auth.RegisterInput{ + Username: username, Email: email, Password: "correct-horse-battery", + }) + if err != nil { + t.Fatalf("register %s: %v", username, err) + } + return token +} + +func TestHelloAndReady(t *testing.T) { + f := newFixture(t) + token := registerUser(t, f, "gateway_user", "gateway@example.com") + + conn, hello := f.dial(t) + var helloPayload struct { + HeartbeatMS int `json:"heartbeat_interval_ms"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(hello.D, &helloPayload); err != nil { + t.Fatalf("decode HELLO: %v", err) + } + if helloPayload.HeartbeatMS != int(gateway.HeartbeatInterval.Milliseconds()) { + t.Fatalf("heartbeat interval = %d", helloPayload.HeartbeatMS) + } + if helloPayload.SessionID == "" { + t.Fatal("HELLO must carry a session id") + } + + send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) + ready := readEnvelope(t, conn) + if ready.Op != gateway.OpDispatch || ready.T != "READY" { + t.Fatalf("frame = op:%d t:%q, want DISPATCH/READY", ready.Op, ready.T) + } + if ready.S <= 0 { + t.Fatalf("READY sequence = %d, want > 0", ready.S) + } + + var snapshot gateway.Ready + if err := json.Unmarshal(ready.D, &snapshot); err != nil { + t.Fatalf("decode READY: %v", err) + } + if snapshot.User.Username != "gateway_user" { + t.Fatalf("READY user = %q", snapshot.User.Username) + } + if len(snapshot.Guilds) != 1 { + t.Fatalf("new user must see exactly the main guild, got %d", len(snapshot.Guilds)) + } + guild := snapshot.Guilds[0] + if !guild.IsMain { + t.Fatal("the main guild must be marked is_main") + } + if len(guild.Roles) != 2 { + t.Fatalf("main guild roles = %d, want 2", len(guild.Roles)) + } + if len(guild.Channels) != 1 || guild.Channels[0].Name != "общий" { + t.Fatalf("unexpected channels: %+v", guild.Channels) + } + if !guild.Channels[0].CanSend { + t.Fatal("default role must allow sending messages") + } + if len(guild.MemberIDs) == 0 { + t.Fatal("READY must include guild members") + } + if len(guild.MyRoles) != 1 { + t.Fatalf("new user must have the default role, got %v", guild.MyRoles) + } + if snapshot.HeartbeatMS != int(gateway.HeartbeatInterval.Milliseconds()) { + t.Fatalf("READY heartbeat interval = %d", snapshot.HeartbeatMS) + } +} + +func TestReadyHidesPrivateChannels(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + // Приватная комната: @user не видит. + mainGuild, err := f.store.GetMainGuild(ctx) + if err != nil { + t.Fatalf("GetMainGuild: %v", err) + } + defaultRole, err := f.store.DefaultRole(ctx, mainGuild.ID) + if err != nil { + t.Fatalf("DefaultRole: %v", err) + } + private, err := f.store.CreateChannel(ctx, store.CreateChannelParams{ + GuildID: &mainGuild.ID, Type: store.ChannelText, Name: "приватный", + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := f.store.SetChannelOverride(ctx, store.ChannelOverride{ + ChannelID: private.ID, TargetType: "role", TargetID: defaultRole.ID, + Deny: uint64(permissions.ViewChannel), + }); err != nil { + t.Fatalf("SetChannelOverride: %v", err) + } + + token := registerUser(t, f, "private_user", "private@example.com") + conn, _ := f.dial(t) + send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) + ready := readEnvelope(t, conn) + + var snapshot gateway.Ready + if err := json.Unmarshal(ready.D, &snapshot); err != nil { + t.Fatalf("decode READY: %v", err) + } + for _, channel := range snapshot.Guilds[0].Channels { + if channel.Name == "приватный" { + t.Fatal("private channel must not appear in READY") + } + } +} + +func TestIdentifyWithInvalidToken(t *testing.T) { + f := newFixture(t) + conn, _ := f.dial(t) + + send(t, conn, gateway.OpIdentify, map[string]any{"token": "not-a-real-token"}) + response := readEnvelope(t, conn) + if response.Op != gateway.OpInvalidSess { + t.Fatalf("op = %d, want INVALID_SESSION (%d)", response.Op, gateway.OpInvalidSess) + } +} + +func TestHeartbeatAck(t *testing.T) { + f := newFixture(t) + token := registerUser(t, f, "heartbeat_user", "heartbeat@example.com") + conn, _ := f.dial(t) + send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) + if ready := readEnvelope(t, conn); ready.T != "READY" { + t.Fatalf("expected READY, got %q", ready.T) + } + + send(t, conn, gateway.OpHeartbeat, nil) + ack := readEnvelope(t, conn) + if ack.Op != gateway.OpHeartbeatAck { + t.Fatalf("op = %d, want HEARTBEAT_ACK (%d)", ack.Op, gateway.OpHeartbeatAck) + } +} + +func TestDispatchReachesConnectedClient(t *testing.T) { + f := newFixture(t) + token := registerUser(t, f, "dispatch_user", "dispatch@example.com") + conn, _ := f.dial(t) + send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) + if ready := readEnvelope(t, conn); ready.T != "READY" { + t.Fatalf("expected READY, got %q", ready.T) + } + + f.service.Dispatch("MEMBER_UPDATE", map[string]any{"nickname": "новый ник"}) + event := readEnvelope(t, conn) + if event.T != "MEMBER_UPDATE" { + t.Fatalf("event = %q, want MEMBER_UPDATE", event.T) + } + if event.S <= 1 { + t.Fatalf("dispatch sequence = %d, want greater than READY", event.S) + } +} + +func TestResumeReplaysMissedEvents(t *testing.T) { + f := newFixture(t) + token := registerUser(t, f, "resume_user", "resume@example.com") + + conn, _ := f.dial(t) + send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) + ready := readEnvelope(t, conn) + + // Пока клиент отключён, приходят события. + _ = conn.Close(websocket.StatusNormalClosure, "test disconnect") + deadline := time.Now().Add(2 * time.Second) + for f.service.ActiveSessions() > 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + f.service.Dispatch("CHANNEL_CREATE", map[string]any{"name": "новый"}) + + // Переподключение с RESUME по последней известной последовательности. + second, _ := f.dial(t) + send(t, second, gateway.OpResume, map[string]any{"token": token, "resume_seq": ready.S}) + replayed := readEnvelope(t, second) + if replayed.T != "CHANNEL_CREATE" { + t.Fatalf("resume replay = %q, want CHANNEL_CREATE", replayed.T) + } + + // RESUME с неизвестной последовательностью приводит к полному READY. + third, _ := f.dial(t) + send(t, third, gateway.OpResume, map[string]any{"token": token, "resume_seq": ready.S + 10000}) + full := readEnvelope(t, third) + if full.T != "READY" { + t.Fatalf("stale resume = %q, want full READY", full.T) + } +} diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go new file mode 100644 index 0000000..cf8a849 --- /dev/null +++ b/internal/gateway/ready.go @@ -0,0 +1,235 @@ +package gateway + +import ( + "context" + "fmt" + "strconv" + "time" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// Snapshot собирает READY-снапшот из хранилища с учётом прав пользователя +// (AGENT.md 8.3: только серверы и комнаты, которые он видит). +type Snapshot struct { + store *store.Store + calculator *permissions.Calculator +} + +func NewSnapshot(st *store.Store) *Snapshot { + return &Snapshot{store: st, calculator: permissions.NewCalculator(newPermissionSource(st))} +} + +var _ SnapshotBuilder = (*Snapshot)(nil) + +func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) { + ready := &Ready{ + User: ReadyUser{ + ID: formatID(user.ID), + Username: user.Username, + DisplayName: user.DisplayName, + IsInstanceAdmin: user.IsInstanceAdmin, + Badges: user.Badges, + }, + Guilds: []ReadyGuild{}, + DMChannels: []ReadyChannel{}, + ReadStates: []ReadyRead{}, + } + if ready.User.Badges == nil { + ready.User.Badges = []string{} + } + if user.AvatarFileID != nil { + ready.User.AvatarFileID = formatID(*user.AvatarFileID) + } + + guilds, err := s.store.ListGuildsForUser(ctx, user.ID) + if err != nil { + return nil, fmt.Errorf("list user guilds: %w", err) + } + for _, guild := range guilds { + resolved, err := s.calculator.Guild(ctx, guild.ID, user.ID, user.IsInstanceAdmin) + if err != nil { + return nil, err + } + // Сервер без VIEW_GUILD не показываем (AGENT.md 6.2). + if !resolved.Has(permissions.ViewGuild) { + continue + } + readyGuild, err := s.buildGuild(ctx, guild, user, resolved) + if err != nil { + return nil, err + } + ready.Guilds = append(ready.Guilds, readyGuild) + } + return ready, nil +} + +func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *store.User, resolved permissions.Resolved) (ReadyGuild, error) { + readyGuild := ReadyGuild{ + ID: formatID(guild.ID), + Name: guild.Name, + OwnerID: formatID(guild.OwnerID), + IsMain: guild.IsMain, + Channels: []ReadyChannel{}, + Roles: []ReadyRole{}, + MemberIDs: []string{}, + MyRoles: []string{}, + MyPerms: []string{}, + } + + roles, err := s.store.ListGuildRoles(ctx, guild.ID) + if err != nil { + return readyGuild, err + } + for _, role := range roles { + readyGuild.Roles = append(readyGuild.Roles, ReadyRole{ + ID: formatID(role.ID), + Name: role.Name, + Color: role.Color, + Position: role.Position, + Permissions: strconv.FormatUint(role.Permissions, 10), + IsDefault: role.IsDefault, + Mentionable: role.Mentionable, + Hoist: role.Hoist, + }) + } + memberRoles, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID) + if err != nil { + return readyGuild, err + } + for _, roleID := range memberRoles { + readyGuild.MyRoles = append(readyGuild.MyRoles, formatID(roleID)) + } + + channels, err := s.store.ListGuildChannels(ctx, guild.ID) + if err != nil { + return readyGuild, err + } + for _, channel := range channels { + // Категории не запрашивают права: они видны вместе с сервером. + if channel.Type == store.ChannelCategory && !resolved.Has(permissions.ManageChannels) { + readyGuild.Channels = append(readyGuild.Channels, channelPayload(channel, false, false)) + continue + } + channelResolved, err := s.calculator.Channel(ctx, guild.ID, channel.ID, user.ID, user.IsInstanceAdmin) + if err != nil { + return readyGuild, err + } + if !channelResolved.CanViewChannel() { + continue + } + readyGuild.Channels = append(readyGuild.Channels, channelPayload(channel, + channelResolved.Can(permissions.SendMessages), channelResolved.Can(permissions.ConnectVoice))) + } + + members, err := s.store.ListGuildMembers(ctx, guild.ID) + if err != nil { + return readyGuild, err + } + for _, member := range members { + readyGuild.MemberIDs = append(readyGuild.MemberIDs, formatID(member.UserID)) + if member.UserID == user.ID { + readyGuild.MyNickname = member.Nickname + } + } + return readyGuild, nil +} + +func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel { + payload := ReadyChannel{ + ID: formatID(channel.ID), + Type: string(channel.Type), + Name: channel.Name, + Position: channel.Position, + UserLimit: channel.UserLimit, + Slowmode: channel.SlowmodeSeconds, + CanSend: canSend, + CanConnect: canConnect, + } + if channel.GuildID != nil { + payload.GuildID = formatID(*channel.GuildID) + } + if channel.ParentID != nil { + payload.ParentID = formatID(*channel.ParentID) + } + return payload +} + +// permissionSource читает роли, оверрайды и участников для движка прав. +type permissionSource struct{ store *store.Store } + +func newPermissionSource(st *store.Store) *permissionSource { return &permissionSource{store: st} } + +func (p *permissionSource) GuildRoles(ctx context.Context, guildID uint64) ([]permissions.RoleData, error) { + roles, err := p.store.ListGuildRoles(ctx, guildID) + if err != nil { + return nil, err + } + result := make([]permissions.RoleData, 0, len(roles)) + for _, role := range roles { + result = append(result, permissions.RoleData{ + ID: role.ID, + Permissions: permissions.Permission(role.Permissions), + IsDefault: role.IsDefault, + Position: role.Position, + }) + } + return result, nil +} + +func (p *permissionSource) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) { + return p.store.MemberRoleIDs(ctx, guildID, userID) +} + +func (p *permissionSource) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) { + overrides, err := p.store.ListChannelOverrides(ctx, channelID) + if err != nil { + return nil, err + } + result := make([]permissions.OverrideData, 0, len(overrides)) + for _, override := range overrides { + result = append(result, permissions.OverrideData{ + TargetType: override.TargetType, + TargetID: override.TargetID, + Allow: permissions.Permission(override.Allow), + Deny: permissions.Permission(override.Deny), + }) + } + return result, nil +} + +func (p *permissionSource) GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error) { + guild, err := p.store.GetGuild(ctx, guildID) + if err != nil { + return 0, err + } + return guild.OwnerID, nil +} + +func (p *permissionSource) MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error) { + member, err := p.store.GetGuildMember(ctx, guildID, userID) + if err != nil { + // Участника нет — тайм-аут не действует; прочие ошибки чтения не + // должны блокировать выдачу прав (AGENT.md 7.4). + return false, nil //nolint:nilerr // отсутствие участника не является ошибкой прав + } + if member.TimeoutUntil == nil { + return false, nil + } + return member.TimeoutUntil.After(time.Now().UTC()), nil +} + +func formatID(id uint64) string { + if id == 0 { + return "" + } + var buf [20]byte + pos := len(buf) + for id > 0 { + pos-- + buf[pos] = byte('0' + id%10) + id /= 10 + } + return string(buf[pos:]) +} diff --git a/internal/gateway/session.go b/internal/gateway/session.go new file mode 100644 index 0000000..a46fa23 --- /dev/null +++ b/internal/gateway/session.go @@ -0,0 +1,430 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/coder/websocket" +) + +// clientSession — одно подключение клиента (AGENT.md 8.3: сессий может быть +// несколько, события доставляются во все). +type clientSession struct { + id string + userID uint64 + conn *websocket.Conn + send chan outbound + closeOnce sync.Once + closed chan struct{} + buffer *resumeBuffer +} + +// outbound — кадр в очереди на отправку. done используется, когда отправитель +// ждёт фактической записи в сокет (например, перед закрытием соединения). +type outbound struct { + payload []byte + done chan error +} + +func (s *clientSession) write(payload []byte) error { + return s.enqueue(outbound{payload: payload}) +} + +// writeSync ставит кадр в очередь и дожидается результата записи. +func (s *clientSession) writeSync(ctx context.Context, payload []byte) error { + item := outbound{payload: payload, done: make(chan error, 1)} + if err := s.enqueue(item); err != nil { + return err + } + select { + case err := <-item.done: + return err + case <-ctx.Done(): + return ctx.Err() + case <-s.closed: + return errors.New("session closed") + } +} + +func (s *clientSession) enqueue(item outbound) error { + select { + case s.send <- item: + return nil + case <-s.closed: + return errors.New("session closed") + case <-time.After(WriteTimeout): + // Медленный потребитель: отключаем, чтобы не держать буфер (AGENT.md 8.3). + s.close() + return errors.New("slow consumer") + } +} + +func (s *clientSession) close() { + s.closeOnce.Do(func() { + close(s.closed) + _ = s.conn.Close(websocket.StatusNormalClosure, "closed") + }) +} + +// ServeHTTP обрабатывает подключение к /gateway. +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: s.originPatterns(), + // Клиенты десктопа обращаются без Origin: проверяем вручную ниже. + InsecureSkipVerify: origin == "", + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + s.logger.DebugContext(r.Context(), "gateway accept failed", slog.Any("error", err)) + return + } + defer func() { _ = conn.CloseNow() }() + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + session := &clientSession{ + id: newSessionID(), + conn: conn, + send: make(chan outbound, 128), + closed: make(chan struct{}), + } + conn.SetReadLimit(MaxIncomingFrame) + + // HELLO с интервалом heartbeat и идентификатором сессии. + hello, err := frame(OpHello, map[string]any{ + "heartbeat_interval_ms": HeartbeatInterval.Milliseconds(), + "session_id": session.id, + }) + if err != nil { + return + } + if err := writeDirect(ctx, conn, hello); err != nil { + return + } + + writerDone := make(chan struct{}) + go func() { + defer close(writerDone) + s.writeLoop(ctx, session) + }() + + s.readLoop(ctx, session) + session.close() + cancel() + <-writerDone + + s.unregister(session) +} + +func (s *Service) writeLoop(ctx context.Context, session *clientSession) { + for { + select { + case <-ctx.Done(): + return + case <-session.closed: + return + case item := <-session.send: + err := writeDirect(ctx, session.conn, item.payload) + if item.done != nil { + item.done <- err + close(item.done) + } + if err != nil { + session.close() + return + } + } + } +} + +// readLoop читает кадры клиента: IDENTIFY, RESUME, HEARTBEAT. +func (s *Service) readLoop(ctx context.Context, session *clientSession) { + identified := false + lastIdentify := time.Time{} + + for { + _, data, err := session.conn.Read(ctx) + if err != nil { + return + } + var incoming serverFrame + if err := json.Unmarshal(data, &incoming); err != nil { + s.sendInvalidSession(session, "malformed frame") + return + } + + switch incoming.Op { + case OpHeartbeat: + ack, err := frame(OpHeartbeatAck, nil) + if err == nil { + _ = session.write(ack) + } + if !identified { + // До IDENTIFY сердцебиение не подтверждает сессию: ждём идентификацию. + continue + } + case OpIdentify, OpResume: + if time.Since(lastIdentify) < IdentifyRateLimit { + s.sendInvalidSession(session, "identify rate limited") + return + } + lastIdentify = time.Now() + + payload, err := decodeIdentify(incoming.D) + if err != nil { + s.sendInvalidSession(session, "malformed identify") + return + } + if err := s.handleIdentify(ctx, session, payload, incoming.Op == OpResume); err != nil { + s.logger.DebugContext(ctx, "gateway identify failed", slog.Any("error", err)) + return + } + identified = true + default: + // Неизвестные оп-коды игнорируем: протокол расширяемый. + } + if !identified { + continue + } + } +} + +// sendInvalidSession сообщает об ошибке идентификации и дожидается отправки +// кадра: соединение закрывается сразу после этого (AGENT.md 8.3). +func (s *Service) sendInvalidSession(session *clientSession, reason string) { + payload, err := frame(OpInvalidSess, map[string]any{"reason": reason}) + if err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), WriteTimeout) + defer cancel() + if err := session.writeSync(ctx, payload); err != nil { + s.logger.DebugContext(ctx, "gateway invalid session not delivered", slog.Any("error", err)) + } +} + +// handleIdentify проверяет токен, регистрирует сессию и отправляет READY +// либо догоняет пропущенные события при RESUME. +func (s *Service) handleIdentify(ctx context.Context, session *clientSession, payload identifyPayload, resume bool) error { + user, authSession, err := s.auth.ResolveSession(ctx, payload.Token) + if err != nil { + s.sendInvalidSession(session, "invalid token") + return err + } + session.userID = user.ID + session.buffer = s.bufferFor(authSession.TokenHash) + + if resume && payload.ResumeSeq > 0 && session.buffer != nil { + if missed, ok := session.buffer.since(payload.ResumeSeq); ok { + for _, item := range missed { + if err := session.write(item); err != nil { + return err + } + } + // Подтверждаем продолжение сессии: клиент знает, что состояние + // восстановлено без полного READY (AGENT.md 8.3). + s.mu.Lock() + s.seq++ + resumed := Envelope{Op: OpDispatch, T: "RESUMED", S: s.seq} + s.mu.Unlock() + encoded, err := json.Marshal(resumed) + if err != nil { + return err + } + if err := session.write(encoded); err != nil { + return err + } + s.register(session, authSession.TokenHash) + return nil + } + // Буфер не содержит нужную последовательность: полный READY (AGENT.md 8.3). + } + + snapshot, err := s.readiness.Build(ctx, user) + if err != nil { + s.sendInvalidSession(session, "ready failed") + return err + } + snapshot.SessionID = session.id + snapshot.HeartbeatMS = int(HeartbeatInterval.Milliseconds()) + + raw, err := json.Marshal(snapshot) + if err != nil { + return err + } + s.mu.Lock() + s.seq++ + envelope := Envelope{Op: OpDispatch, T: "READY", D: raw, S: s.seq} + encoded, err := json.Marshal(envelope) + s.mu.Unlock() + if err != nil { + return err + } + if err := session.write(encoded); err != nil { + return err + } + s.register(session, authSession.TokenHash) + return nil +} + +func (s *Service) register(session *clientSession, tokenHash string) { + s.mu.Lock() + s.sessions[session.id] = session + buffer, ok := s.buffers[tokenHash] + if !ok { + buffer = newResumeBuffer(ResumeBufferSize) + s.buffers[tokenHash] = buffer + } + // Владелец буфера нужен, чтобы доставлять адресные события (SendToUser) + // даже когда все сессии пользователя отключены. + buffer.owner = session.userID + session.buffer = buffer + s.mu.Unlock() +} + +func (s *Service) unregister(session *clientSession) { + s.mu.Lock() + delete(s.sessions, session.id) + s.mu.Unlock() +} + +func (s *Service) bufferFor(tokenHash string) *resumeBuffer { + s.mu.Lock() + defer s.mu.Unlock() + if buffer, ok := s.buffers[tokenHash]; ok { + return buffer + } + buffer := newResumeBuffer(ResumeBufferSize) + s.buffers[tokenHash] = buffer + return buffer +} + +// originPatterns разрешает подключения с домена инстанса (AGENT.md 9.7). +// coder/websocket сопоставляет шаблон с хостом из Origin, поэтому из +// настроенных адресов берём только хост; пустой список означает проверку +// на совпадение с Host запроса. +func (s *Service) originPatterns() []string { + patterns := make([]string, 0, len(s.allowedOrigins)) + for _, origin := range s.allowedOrigins { + origin = strings.TrimSpace(origin) + if origin == "" { + continue + } + if origin == "*" { + return []string{"*"} + } + if parsed, err := url.Parse(origin); err == nil && parsed.Host != "" { + patterns = append(patterns, parsed.Host) + continue + } + patterns = append(patterns, origin) + } + return patterns +} + +func writeDirect(ctx context.Context, conn *websocket.Conn, payload []byte) error { + writeCtx, cancel := context.WithTimeout(ctx, WriteTimeout) + defer cancel() + return conn.Write(writeCtx, websocket.MessageText, payload) +} + +func decodeIdentify(raw json.RawMessage) (identifyPayload, error) { + var payload identifyPayload + if len(raw) == 0 { + return payload, errors.New("empty identify payload") + } + if err := json.Unmarshal(raw, &payload); err != nil { + return payload, err + } + if payload.Token == "" { + return payload, errors.New("token is required") + } + return payload, nil +} + +func newSessionID() string { + const alphabet = "0123456789abcdef" + buf := make([]byte, 16) + now := time.Now().UnixNano() + for i := range buf { + buf[i] = alphabet[(now>>uint(i*4))&0xf] + } + return string(buf) +} + +// resumeBuffer хранит последние события для RESUME (AGENT.md 8.3). +type resumeBuffer struct { + mu sync.Mutex + owner uint64 + limit int + items []bufferedEvent + updated time.Time +} + +type bufferedEvent struct { + seq int64 + payload []byte +} + +func newResumeBuffer(limit int) *resumeBuffer { + return &resumeBuffer{limit: limit, updated: time.Now()} +} + +func (b *resumeBuffer) append(seq int64, payload []byte) { + if b == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + b.items = append(b.items, bufferedEvent{seq: seq, payload: payload}) + if len(b.items) > b.limit { + b.items = b.items[len(b.items)-b.limit:] + } + b.updated = time.Now() +} + +// since возвращает события после указанной последовательности; ok=false, +// если буфер уже не содержит их (нужен полный READY). +func (b *resumeBuffer) since(seq int64) ([][]byte, bool) { + if b == nil { + return nil, false + } + b.mu.Lock() + defer b.mu.Unlock() + if len(b.items) == 0 { + return nil, false + } + if b.items[0].seq > seq+1 { + return nil, false + } + // Клиент заявляет последовательность впереди сервера: состояние разошлось + // (например, сервер перезапускался) — нужен полный READY. + if seq > b.items[len(b.items)-1].seq { + return nil, false + } + result := make([][]byte, 0, len(b.items)) + for _, item := range b.items { + if item.seq > seq { + result = append(result, item.payload) + } + } + return result, true +} + +func (b *resumeBuffer) lastSeen() time.Time { + if b == nil { + return time.Time{} + } + b.mu.Lock() + defer b.mu.Unlock() + return b.updated +} diff --git a/internal/httpx/middleware.go b/internal/httpx/middleware.go index 0c04148..4457a0a 100644 --- a/internal/httpx/middleware.go +++ b/internal/httpx/middleware.go @@ -1,6 +1,7 @@ package httpx import ( + "bufio" "context" "log/slog" "net" @@ -44,6 +45,20 @@ func (r *statusRecorder) 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 { diff --git a/internal/server/server.go b/internal/server/server.go index b4d61ae..3ff62cc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6b87f50..c063469 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) {