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:
+3
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
type Deps struct {
|
||||
Store *store.Store
|
||||
Auth *auth.Service
|
||||
Gateway *gateway.Service
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -31,6 +33,7 @@ type Server struct {
|
||||
db *database.DB
|
||||
store *store.Store
|
||||
auth *auth.Service
|
||||
gateway *gateway.Service
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
@@ -43,6 +46,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
db: db,
|
||||
store: deps.Store,
|
||||
auth: deps.Auth,
|
||||
gateway: deps.Gateway,
|
||||
logger: logger,
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user