Files
glchat/internal/gateway/session.go
T
grendervill 86dff94a02 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.
2026-09-19 21:42:41 +03:00

431 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}