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

Реализован Gateway по AGENT.md §8.3:

- оп-коды 0/1/2/3/4/7/10/11, единый конверт {op,t,d,s};
- HELLO с интервалом сердцебиения (45 с) и идентификатором сессии;
- IDENTIFY/RESUME с проверкой токена через auth.ResolveSession;
- READY: пользователь, серверы, видимые комнаты, роли, участники и права,
  собранные через движок прав (VIEW_CHANNEL-фильтрация);
- HEARTBEAT → HEARTBEAT_ACK, лимит частоты IDENTIFY (5 с);
- RESUME: буфер на 1000 событий с TTL 5 минут, догон пропущенных событий и
  подтверждение RESUMED, полный READY при расхождении состояния;
- рассылка Dispatch/SendToUser с буферизацией для отключённых сессий;
- проверка Origin по allowlist доменов инстанса (AGENT.md §9.7);
- httpx.statusRecorder пробрасывает Hijack, иначе апгрейд ломается.

Тесты: 6 сценариев Gateway (READY, скрытые комнаты, неверный токен,
heartbeat, dispatch, RESUME) и 2 теста маршрута /gateway через полный стек
middleware, включая отклонение чужого Origin.
This commit is contained in:
2026-09-19 21:42:41 +03:00
parent c0537d3c17
commit 86dff94a02
11 changed files with 1364 additions and 18 deletions
+3 -1
View File
@@ -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",
+1
View File
@@ -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
+2
View File
@@ -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=
+13
View File
@@ -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 }
+265
View File
@@ -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})
}
+311
View File
@@ -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)
}
}
+235
View File
@@ -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:])
}
+430
View File
@@ -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
}
+15
View File
@@ -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 {
+11
View File
@@ -14,6 +14,7 @@ import (
"glchat/internal/auth"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/httpx"
"glchat/internal/meta"
"glchat/internal/store"
@@ -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) {
+62 -1
View File
@@ -9,10 +9,14 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"glchat/internal/auth"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/store"
)
@@ -58,7 +62,64 @@ func newTestServer(t *testing.T) (*Server, *database.DB) {
if err != nil {
t.Fatalf("initialize authentication: %v", err)
}
return New(cfg, db, logger, Deps{Store: st, Auth: authService}), db
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins())
return New(cfg, db, logger, Deps{Store: st, Auth: authService, Gateway: gatewayService}), db
}
// TestGatewayRouteUpgrades проверяет связку: маршрут /gateway доступен через
// полный стек middleware, HELLO приходит до IDENTIFY (AGENT.md 8.3).
func TestGatewayRouteUpgrades(t *testing.T) {
srv, _ := newTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/gateway"
// coder/websocket закрывает тело ответа сам (dial.go: "You never need to
// close resp.Body yourself") — отсюда nolint:bodyclose.
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ //nolint:bodyclose // тело закрывает библиотека
HTTPHeader: map[string][]string{"Origin": {"https://gl.mhspx.su"}},
})
if err != nil {
t.Fatalf("dial /gateway: %v", err)
}
t.Cleanup(func() { _ = conn.CloseNow() })
_, data, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read HELLO: %v", err)
}
var envelope struct {
Op int `json:"op"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatalf("decode HELLO: %v", err)
}
if envelope.Op != gateway.OpHello {
t.Fatalf("op = %d, want HELLO (%d)", envelope.Op, gateway.OpHello)
}
}
// TestGatewayRejectsForeignOrigin: подключение с чужого домена отклоняется
// (AGENT.md 9.7).
func TestGatewayRejectsForeignOrigin(t *testing.T) {
srv, _ := newTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/gateway"
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ //nolint:bodyclose // тело закрывает библиотека
HTTPHeader: map[string][]string{"Origin": {"https://evil.example"}},
})
if err == nil {
_ = conn.CloseNow()
t.Fatal("connection from a foreign origin must be rejected")
}
}
func TestHealthz(t *testing.T) {