package mtprotoedge import ( "context" "crypto/rsa" "encoding/hex" "errors" "fmt" "io" "net" "net/http" "sync" "time" "go.uber.org/zap" "github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/clock" "github.com/iamxvbaba/td/crypto" "github.com/iamxvbaba/td/exchange" "github.com/iamxvbaba/td/mt" "github.com/iamxvbaba/td/proto" "github.com/iamxvbaba/td/proto/codec" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tmap" "github.com/iamxvbaba/td/transport" "github.com/iamxvbaba/td/tlprofile" "telesrv/internal/store" "telesrv/internal/store/memory" ) // legacyRPCHandler routes a decrypted canonical RPC request body to a response. // // b is the plaintext RPC request (MTProto envelope already stripped); the returned // bin.Encoder is wrapped into rpc_result. When *tgerr.Error is returned, the // connection layer converts it to rpc_error and sends it back; other errors are // treated as connection-level failures. // // It is retained only for regression tests of this package's old connection state // machine. Production API RPCs must go through LayerRPCHandler, so that admission, // the request/result TypeRef and the exact profile cannot be bypassed. type legacyRPCHandler interface { Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error) // NegotiatedLayer returns the TL layer proven via invokeWithLayer for this // exact (auth_key_id, session_id). It must never infer from API ID, device // metadata, authorization rows, or another session on the same auth key. NegotiatedLayer(authKeyID [8]byte, sessionID int64) (int, bool) } // legacyRPCHandlerWithMethod returns the canonical innermost RPC method after the // router has peeled invokeWithLayer/initConnection/invokeAfter wrappers. Egress // scheduling must use this identity: the outer wrapper is not a useful signal // for prioritizing updates convergence over catalog/media responses. type legacyRPCHandlerWithMethod interface { DispatchWithMethod(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, string, error) } // LayerRPCHandler is the production API-RPC boundary. Admission is a separate // allocation-bounded phase so the edge can freeze the connection profile, // validate wrapper dependencies and establish exact request identity before // execution-ledger/scheduler ownership is acquired. type LayerRPCHandler interface { AdmitLayer(profile tlprofile.Profile, b *bin.Buffer, limits tlprofile.Limits) (tlprofile.Admission, error) AdmitUnprofiled(b *bin.Buffer, limits tlprofile.Limits) (tlprofile.Admission, error) DispatchAdmitted( ctx context.Context, authKeyID [8]byte, sessionID int64, msgID int64, admissionSeq uint64, request tlprofile.Admission, ) (tlprofile.Result, string, error) } // LayerRPCOptionsAdmitter extends the stable handler boundary with // caller-owned admission capabilities. Implementations that do not expose it // remain usable for requests that need only Limits. type LayerRPCOptionsAdmitter interface { AdmitLayerWithOptions(profile tlprofile.Profile, b *bin.Buffer, options tlprofile.AdmissionOptions) (tlprofile.Admission, error) AdmitUnprofiledWithOptions(b *bin.Buffer, options tlprofile.AdmissionOptions) (tlprofile.Admission, error) } // LayerRPCDefaultProfileAdmitter decodes with a recoverable inherited/default // profile. Production handlers should implement it with the same sparse // tlprofile dispatcher and semantic adapter registry used by AdmitLayer. The split keeps old // test doubles source-compatible while allowing invokeWithLayer to correct even // a previously explicit Conn profile. type LayerRPCDefaultProfileAdmitter interface { AdmitDefaultLayer(profile tlprofile.Profile, b *bin.Buffer, limits tlprofile.Limits) (tlprofile.Admission, error) } // LayerRPCDefaultProfileOptionsAdmitter is the capability-aware form used when // exact admission needs caller-owned resources such as bounded gzip expansion. type LayerRPCDefaultProfileOptionsAdmitter interface { AdmitDefaultLayerWithOptions(profile tlprofile.Profile, b *bin.Buffer, options tlprofile.AdmissionOptions) (tlprofile.Admission, error) } // LayerRPCFlatBytesPayloadSizer is an optional, allocation-free admission // capability for exact terminal requests whose generated object graph contains // one already-bounded flat bytes payload. The handler may return ok only after // proving the complete terminal wire shape and every semantic field cap it // relies on. The edge still owns the multiplier, graph slack and all process / // connection budgets; an absent or invalid hint falls back to the conservative // generic graph charge. type LayerRPCFlatBytesPayloadSizer interface { LayerRPCFlatBytesPayloadSize(wire []byte) (payloadBytes int, ok bool) } // LayerRPCSessionProfileResolver may restore an exact profile only when it was // previously proven for this same (auth_key_id, session_id). Auth-key-wide // device metadata is intentionally ineligible: a client upgrade can reuse its // auth key while opening a new session at a newer Layer. type LayerRPCSessionProfileResolver interface { NegotiatedSessionLayer(authKeyID [8]byte, sessionID int64) (int, bool) } // LayerRPCOrderedSessionProfileResolver restores both the selected Layer and // the newest invokeWithLayer client msg_id which proved it. The cursor prevents // an old retained request replay on a replacement physical connection from // rolling the logical session back to an older profile. type LayerRPCOrderedSessionProfileResolver interface { NegotiatedSessionLayerEvidence(authKeyID [8]byte, sessionID int64) (layer int, msgID int64, ok bool) } // LayerRPCInheritedAuthKeyProfileResolver resolves the best persisted // auth-key-wide client Layer for a new session. Unlike exact same-session // evidence this value is only an inherited default: a later invokeWithLayer may // correct it. Implementations may resolve a temporary raw key through its bound // permanent key, but must not infer from API ID or device strings. type LayerRPCInheritedAuthKeyProfileResolver interface { ResolveInheritedAuthKeyLayer(ctx context.Context, rawAuthKeyID [8]byte) (layer int, found bool, err error) } // LayerRPCSessionProfileRegistry atomically freezes and invalidates the exact // same-session profile. Its bounded retention may survive a TCP reconnect and // admit a naked replay while the entry remains live. Expiry or capacity eviction // loses only that proof and therefore fails closed until fresh invokeWithLayer; // it never falls back to auth-key-wide metadata. type LayerRPCSessionProfileRegistry interface { LayerRPCSessionProfileResolver FreezeNegotiatedSessionLayer(authKeyID [8]byte, sessionID int64, layer int) error ForgetNegotiatedSessionLayer(authKeyID [8]byte, sessionID int64) ForgetNegotiatedAuthKey(authKeyID [8]byte) } // LayerRPCOrderedSessionProfileRegistry linearizes explicit Layer evidence by // MTProto client msg_id. applied is false for an older or identical duplicate; // the same msg_id with another Layer must return ErrLayerProfileConflict (or an // error wrapping it). Implementations advance the cursor even when Layer is // unchanged. type LayerRPCOrderedSessionProfileRegistry interface { LayerRPCOrderedSessionProfileResolver FreezeNegotiatedSessionLayerAt(authKeyID [8]byte, sessionID int64, layer int, msgID int64) (applied bool, err error) } // LayerRPCDurableSessionProfileResolver restores restart-safe raw Layer // evidence. layer may be newer than this binary's generated codec universe; // msgID remains the ordering authority in that case. type LayerRPCDurableSessionProfileResolver interface { ResolveNegotiatedSessionLayerEvidence( ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, ) (layer int, msgID int64, found bool, err error) } // LayerRPCDurableSessionProfileAdvancer atomically advances exact-session and // auth-key shared-default evidence. publishShared is true only when this call // established a different durable profile generation which still owns the // shared default; a same-generation msg_id high-water advance needs no second // process-local default publication. type LayerRPCDurableSessionProfileAdvancer interface { AdvanceNegotiatedSessionLayerEvidence( ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, layer int, msgID int64, ) (currentLayer int, currentMsgID int64, publishShared bool, err error) } type LayerRPCDurableSessionProfileDeleter interface { DeleteNegotiatedSessionLayerEvidence( ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, ) (deleted bool, err error) } // LayerRPCReplayPreparer reapplies connection-local wrapper state for an // already-executed exact request without consuming its one-shot business // dispatch lease. The returned callback is safe to run only after a successful // replayed rpc_result reaches the replacement physical connection. type LayerRPCReplayPreparer interface { PrepareAdmittedReplay( ctx context.Context, authKeyID [8]byte, sessionID int64, msgID int64, admissionSeq uint64, request tlprofile.Admission, ) (afterSuccessfulDelivery func() error, err error) } // LayerRPCProfileEvidenceContext lets a generated/semantic handler carry the // edge's MTProto msg_id freshness decision through its existing context-based // dispatch API. fresh=false means the request remains fully request-bound: it // may decode, execute and produce an exact result, but it must not publish // mutable Layer/init/readiness/auth-bind state. Production Router implements // this optional decorator; older handlers which have no such shared state stay // source compatible. type LayerRPCProfileEvidenceContext interface { WithLayerRPCProfileEvidenceFresh(ctx context.Context, fresh bool) context.Context } // LayerRPCAdmissionProfilePublisher advances the auth-key-wide inherited // default for fresh explicit evidence. admissionSeq is allocated once by the // edge's exact flight owner and globally orders different MTProto sessions; // joined/replayed requests never call this hook again. type LayerRPCAdmissionProfilePublisher interface { PublishAdmittedLayerProfileEvidence( ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, msgID int64, admissionSeq uint64, safeFloor uint64, layer int, ) error } // RPCInitConnectionObserver records wrapper metadata when the edge aliases an // initConnection reissue to an already-running request and therefore correctly // skips a second business Dispatch. type RPCInitConnectionObserver interface { ObserveInitConnection( ctx context.Context, authKeyID [8]byte, sessionID int64, layer, apiID int, deviceModel, systemVersion, appVersion, systemLangCode, langPack, langCode string, ) error } // Options configures Server. type Options struct { // Logger is the logger. Defaults to zap.NewNop(). Logger *zap.Logger // Codec is the transport codec constructor. nil means auto-detect // (intermediate/abridged/full). A custom codec must be one of gotd's four // built-in codecs (optionally wrapping NoHeader), or implement // InboundFrameBudgetedCodec; a codec that cannot pre-check its length before // payload allocation fails closed. Codec func() transport.Codec // ObfuscatedTCP allows bare TCP to use MTProto transport obfuscation. When // enabled, the first 1/4/8 bytes of each physical connection are used to // automatically distinguish plaintext transport from 64-byte obfuscated2, then // the wire mode + codec are frozen onto the same bidirectional connection; // Telegram Desktop's tcpo_only and third-party clients without obfuscation can // share the same port. ObfuscatedTCP bool // WebSocket accepts MTProto over WebSocket (/apiws*) on the same listener. // When enabled, only the first 4 bytes are read at connection setup to split // HTTP/TCP; MTProto TCP then still follows the original TCP wire-mode + codec // detection path. WebSocket bool // WebSocketAllowedOrigins is the page origins allowed to initiate a WebSocket // upgrade from a browser. An empty list means only non-browser clients without // Origin are accepted; "*" allows all origins (debug only). WebSocketAllowedOrigins []string // SamePortHTTP handles non-/apiws* requests on the HTTP side of the same port // (for example the public server-info description, see internal/serverinfo). // The edge only routes and does not interpret the content; when nil these paths // all return 404. Reachable only when WebSocket is enabled (same-port splitting // in effect). SamePortHTTP http.Handler // ReadTimeout is the single-read timeout. Defaults to 5m. ReadTimeout time.Duration // HandshakeIdleTimeout is the read timeout before a connection "establishes a // session" (handshake + before the first encrypted message); it is shorter than // ReadTimeout, used to quickly reclaim half-open/aberrant connections that go // silent after the handshake. Defaults to 60s. HandshakeIdleTimeout time.Duration // HandshakeMaxDuration is the overall duration bound of a single key exchange // (serverExchange). HandshakeIdleTimeout only constrains a "single read idle" // and is useless against a client that keeps sending—if a client falls into a // "receive ResPQ→nonce desync→resend req_pq" handshake restart loop (see // docs/client-compat-notes.md), an unbounded serverExchange would blindly reply // ResPQ to every req_pq, spinning forever and flushing logs/burning CPU. This // bound sets an overall budget for the whole handshake, abandoning and closing // on timeout, and the client reconnecting with a fresh handshake (no residual // phase offset) recovers. A normal handshake is <1s. Defaults to 20s. HandshakeMaxDuration time.Duration // WriteTimeout is the single-write timeout. Defaults to 30s. WriteTimeout time.Duration // MaxConnections is the total cap on raw physical connections accepted by the // process, covering codec sniff, handshake and the full lifetime of // authenticated connections. Defaults to 200000; a negative value means no limit. MaxConnections int // MaxConnectionsPerIP is the raw physical connection cap per remote IP. // Defaults to 4096, leaving ample burst headroom for shared NAT and TDesktop // multi-candidate connections; a negative value means no limit. MaxConnectionsPerIP int // MaxConcurrentHandshakes is the cap on simultaneous auth_key_id=0 RSA/DH // exchanges. On reaching the cap, connections that have completed transport // framing receive -429 and are closed. Defaults to 256; a negative value means no limit. MaxConcurrentHandshakes int // RPCMaxInflight is the cap on RPCs processed simultaneously by one connection. Defaults to 32. RPCMaxInflight int // RPCQueueSize is the length of the per-connection queue of RPCs awaiting // processing. Defaults to 64; the queue is lazily allocated on the first request. RPCQueueSize int // RPCTimeout is the maximum processing duration of a single RPC at the // connection layer. Defaults to 30s. The timeout is counted from pre-Copy // budget/enqueue, including queue time. RPCTimeout time.Duration // RPCGlobalWorkers is the number of Server-shared inbound RPC workers. Defaults to 256. RPCGlobalWorkers int // RPCGlobalMaxTasks is the process-wide cap on reserved, queued and executing // RPCs. Defaults to 32768; request materialization is still separately hard // limited by RPCGlobalMaxBytes. RPCGlobalMaxTasks int // RPCGlobalMaxBytes is the process-level memory charge budget for the above // RPCs. The legacy charge equals the copied body; the exact charge is the // conservative materialization bound before typed decode, so this setting does // not mean 512 MiB of wire body can be received concurrently. Defaults to 512 MiB. RPCGlobalMaxBytes int64 // RPCDeliveryHookWorkers bounds concurrent post-response correctness work // such as delivered-cursor commits and first-session readiness. The pending // limit separately covers reserved + queued + running hooks so a 10k startup // burst remains bounded without forcing socket writers to wait. Defaults are // 32 workers and 16,384 pending hooks. RPCDeliveryHookWorkers int RPCDeliveryHookMaxPending int // RPCExecution*Entries bound in-flight owners and compact completed // receipts. Payload bytes are not charged here: the logical-session // outbox owns them under OutboundTrackedGlobalMaxBytes until ACK. ACK removes // the receipt immediately; 331 seconds is only the no-ACK safety horizon. RPCExecutionMaxEntries int RPCExecutionAuthMaxEntries int RPCExecutionSessionMaxEntries int // RPCExecutionPendingPerAuth is an additional active-owner bound, independent // from the retained entry limits and RPCGlobalMaxTasks. Default 2048. RPCExecutionPendingPerAuth int // InboundFrameGlobalMaxBytes is the total budget for the transport wire buffers // and the maximum decrypted plaintext buffer currently being processed across // all physical connections. Reserved after reading the length prefix and before // payload allocation; defaults to 512 MiB, and a non-positive value uses the default. InboundFrameGlobalMaxBytes int64 // OutboundQueueSize / OutboundControlQueueSize are the per-connection ordinary // and control mailbox capacities. Default 128/32; the control queue keeps strict // priority in the actor. OutboundQueueSize int OutboundControlQueueSize int // OutboundTrackedGlobalMaxBytes is the total budget for the RPC/update bodies // kept by all connections for msg_resend_req. Defaults to 512 MiB; encoded // MTProto service frames and control vectors use a separate 64 MiB control // budget (including new_session_created etc. that need resend tracking), so body // pressure does not block connection-maintenance messages. When a reliable // response cannot be tracked, that connection is terminated; a durable // best-effort update only drops the online acceleration and is recovered by difference. OutboundTrackedGlobalMaxBytes int64 // OutboundCriticalGlobalMaxBytes is an independent retained-body reserve for // bootstrap/convergence RPC results. Bulk traffic cannot consume it. OutboundCriticalGlobalMaxBytes int64 // OutboundWriteGlobalMaxBytes bounds concurrent encrypted wire/codec/obfuscation scratch. // Scratch is shared and pooled across connections; default 512 MiB. OutboundWriteGlobalMaxBytes int64 // DC is this server's DC ID. Defaults to 2. DC int // StrictDC enables DC-label validation during key exchange. It is false by // default: this single physical backend accepts every wire int32 label for // permanent and temporary keys, and the label never changes auth-key // persistence, session identity, or business state. When enabled, // permanent labels must equal DC and temporary labels may equal +/-DC. // This diagnostic switch does not itself provide multi-DC isolation. StrictDC bool // RSAKey is the server RSA private key, used for the key exchange. When nil the handshake cannot complete. RSAKey *rsa.PrivateKey // AuthKeys persists auth keys. Defaults to the in-memory implementation. AuthKeys store.AuthKeyStore // ActiveSessions manages active connections. Defaults to a new one; passing one // lets the RPC layer share the same registry. ActiveSessions *SessionManager // legacyRPC is an internal test hook for the pre-exact RPC state machine. // It is deliberately unexported so production callers cannot bypass // generated Layer admission by configuring the canonical-only route. legacyRPC legacyRPCHandler // LayerRPC is the generated exact-profile production path. When configured, // every API request must complete admission before execution-ledger scheduling. LayerRPC LayerRPCHandler // Metrics receives connection-layer metrics. Defaults to NopMetrics. Metrics Metrics // OnServing is called after the connection intake loops have been installed. // It is a platform-neutral observation hook; all slow initialization must // finish before ListenAndServe is entered. OnServing func(net.Addr) // Clock is used for message IDs and timestamps. Defaults to clock.System. Clock clock.Clock // Rand is the random source. Defaults to crypto.DefaultRand(). Rand io.Reader } func (o *Options) setDefaults() { if o.Logger == nil { o.Logger = zap.NewNop() } if o.ReadTimeout == 0 { o.ReadTimeout = 5 * time.Minute } if o.HandshakeIdleTimeout == 0 { o.HandshakeIdleTimeout = 60 * time.Second } if o.HandshakeMaxDuration == 0 { o.HandshakeMaxDuration = 20 * time.Second } if o.WriteTimeout == 0 { o.WriteTimeout = 30 * time.Second } if o.MaxConnections == 0 { o.MaxConnections = defaultMaxConnections } if o.MaxConnectionsPerIP == 0 { o.MaxConnectionsPerIP = defaultMaxConnectionsPerIP } if o.MaxConcurrentHandshakes == 0 { o.MaxConcurrentHandshakes = defaultMaxConcurrentHandshakes } if o.RPCMaxInflight <= 0 { o.RPCMaxInflight = 32 } if o.RPCQueueSize <= 0 { o.RPCQueueSize = 64 } if o.RPCTimeout == 0 { o.RPCTimeout = 30 * time.Second } if o.RPCGlobalWorkers <= 0 { o.RPCGlobalWorkers = 256 } if o.RPCGlobalMaxTasks <= 0 { o.RPCGlobalMaxTasks = rpcResultFlightDefaultMaxPending } if o.RPCGlobalMaxBytes <= 0 { o.RPCGlobalMaxBytes = 512 << 20 } if o.RPCDeliveryHookWorkers <= 0 { o.RPCDeliveryHookWorkers = defaultRPCDeliveryHookWorkers } if o.RPCDeliveryHookMaxPending <= 0 { o.RPCDeliveryHookMaxPending = defaultRPCDeliveryHookMaxPending } if o.RPCExecutionMaxEntries == 0 { o.RPCExecutionMaxEntries = rpcExecutionMaxEntries } if o.RPCExecutionAuthMaxEntries == 0 { o.RPCExecutionAuthMaxEntries = rpcExecutionAuthMaxEntries } if o.RPCExecutionSessionMaxEntries == 0 { o.RPCExecutionSessionMaxEntries = rpcExecutionSessionMaxEntries } if o.RPCExecutionPendingPerAuth == 0 { o.RPCExecutionPendingPerAuth = rpcExecutionPendingPerAuth if o.RPCExecutionPendingPerAuth > o.RPCGlobalMaxTasks { o.RPCExecutionPendingPerAuth = o.RPCGlobalMaxTasks } } if o.InboundFrameGlobalMaxBytes <= 0 { o.InboundFrameGlobalMaxBytes = defaultInboundFrameGlobalMaxBytes } if o.OutboundQueueSize <= 0 { o.OutboundQueueSize = defaultOutboundQueueSize } if o.OutboundControlQueueSize <= 0 { o.OutboundControlQueueSize = defaultOutboundControlQueueSize } if o.OutboundTrackedGlobalMaxBytes <= 0 { o.OutboundTrackedGlobalMaxBytes = defaultOutboundTrackedMaxBytes } if o.OutboundCriticalGlobalMaxBytes <= 0 { o.OutboundCriticalGlobalMaxBytes = defaultOutboundCriticalMaxBytes } if o.OutboundWriteGlobalMaxBytes <= 0 { o.OutboundWriteGlobalMaxBytes = defaultOutboundWriteMaxBytes } if o.DC == 0 { o.DC = 2 } if o.AuthKeys == nil { o.AuthKeys = memory.NewAuthKeyStore() } if o.Metrics == nil { o.Metrics = NopMetrics{} } if o.Clock == nil { o.Clock = clock.System } if o.Rand == nil { o.Rand = crypto.DefaultRand() } } func validateRPCExecutionOptions(o Options) error { if o.RPCDeliveryHookWorkers <= 0 || o.RPCDeliveryHookMaxPending < o.RPCDeliveryHookWorkers { return fmt.Errorf("rpc delivery hook capacity must satisfy pending >= workers > 0: %d/%d", o.RPCDeliveryHookMaxPending, o.RPCDeliveryHookWorkers) } if o.RPCExecutionMaxEntries <= 0 || o.RPCExecutionAuthMaxEntries <= 0 || o.RPCExecutionSessionMaxEntries <= 0 { return fmt.Errorf("rpc execution ledger entry limits must be positive") } if o.RPCExecutionMaxEntries < o.RPCExecutionAuthMaxEntries || o.RPCExecutionAuthMaxEntries < o.RPCExecutionSessionMaxEntries { return fmt.Errorf("rpc execution ledger entry hierarchy must satisfy global >= auth >= session: %d/%d/%d", o.RPCExecutionMaxEntries, o.RPCExecutionAuthMaxEntries, o.RPCExecutionSessionMaxEntries) } if o.RPCExecutionPendingPerAuth <= 0 || o.RPCExecutionPendingPerAuth > o.RPCGlobalMaxTasks || o.RPCExecutionPendingPerAuth > o.RPCExecutionAuthMaxEntries { return fmt.Errorf("rpc execution per-auth pending limit %d must be positive and <= global pending %d and auth entries %d", o.RPCExecutionPendingPerAuth, o.RPCGlobalMaxTasks, o.RPCExecutionAuthMaxEntries) } return nil } // Server is the MTProto connection layer (mtprotoedge). // // See doc.go for its responsibilities. It turns the raw TCP byte stream into // "decrypted RPC requests for an identified session": it accepts connections, // negotiates the codec, completes the key exchange, decrypts encrypted messages // and dispatches them to the RPC router, handles service messages, and registers // active connections with SessionManager to support proactive pushes (updates // etc.). It contains no business logic. type Server struct { log *zap.Logger codec func() transport.Codec obfuscated bool websocket bool websocketOrigins []string samePortHTTP http.Handler readTimeout time.Duration handshakeTimeout time.Duration handshakeMaxDur time.Duration writeTimeout time.Duration rpcInflight int rpcQueueSize int rpcTimeout time.Duration rpcScheduler *inboundRPCScheduler bulkRPCScheduler *bulkRPCScheduler rpcDeliveryHooks *rpcDeliveryHookExecutor frameBudget *inboundFrameBudget outboundQueueSize int outboundControlQueueSize int outboundTrackedBudget *outboundTrackedBudget outboundControlBudget *outboundTrackedBudget outboundCriticalBudget *outboundTrackedBudget outboundScratchPool *outboundScratchPool outboundOpPool *outboundOpPool outboundReplayBodyPool *outboundReplayBodyPool dc int strictDC bool key exchange.PrivateKey authKeys store.AuthKeyStore conns *SessionManager rpc legacyRPCHandler layerRPC LayerRPCHandler metrics Metrics onServing func(net.Addr) cipher crypto.Cipher clock clock.Clock rand io.Reader types *tmap.Map admission *admissionController rpcResults *rpcExecutionLedger rpcRewrap *rpcRewrapRegistry // onFrame is a test hook: it is called back with a frame's byte count when one is received; nil in production. onFrame func(n int) } // New creates a Server. func New(opts Options) *Server { opts.setDefaults() if err := validateRPCExecutionOptions(opts); err != nil { panic(fmt.Sprintf("mtprotoedge: invalid rpc execution options: %v", err)) } conns := opts.ActiveSessions if conns == nil { conns = NewSessionManager(opts.Logger.Named("sessions")) } server := &Server{ log: opts.Logger, codec: opts.Codec, obfuscated: opts.ObfuscatedTCP, websocket: opts.WebSocket, websocketOrigins: append([]string(nil), opts.WebSocketAllowedOrigins...), samePortHTTP: opts.SamePortHTTP, readTimeout: opts.ReadTimeout, handshakeTimeout: opts.HandshakeIdleTimeout, handshakeMaxDur: opts.HandshakeMaxDuration, writeTimeout: opts.WriteTimeout, rpcInflight: opts.RPCMaxInflight, rpcQueueSize: opts.RPCQueueSize, rpcTimeout: opts.RPCTimeout, rpcScheduler: newInboundRPCScheduler(opts.RPCGlobalWorkers, opts.RPCGlobalMaxTasks, opts.RPCGlobalMaxBytes), bulkRPCScheduler: newBulkRPCScheduler(max(1, opts.RPCGlobalWorkers/2)), rpcDeliveryHooks: newRPCDeliveryHookExecutor(opts.RPCDeliveryHookWorkers, opts.RPCDeliveryHookMaxPending), frameBudget: newInboundFrameBudget(opts.InboundFrameGlobalMaxBytes), outboundQueueSize: opts.OutboundQueueSize, outboundControlQueueSize: opts.OutboundControlQueueSize, outboundTrackedBudget: newOutboundTrackedBudget(opts.OutboundTrackedGlobalMaxBytes), outboundControlBudget: newOutboundTrackedBudget(defaultOutboundControlMaxBytes), outboundCriticalBudget: newOutboundTrackedBudget(opts.OutboundCriticalGlobalMaxBytes), outboundScratchPool: newOutboundScratchPool(opts.OutboundWriteGlobalMaxBytes), outboundOpPool: newOutboundOpPool(defaultOutboundOpPoolSize), outboundReplayBodyPool: newOutboundReplayBodyPool(defaultOutboundReplayBodyClasses), dc: opts.DC, strictDC: opts.StrictDC, key: exchange.PrivateKey{RSA: opts.RSAKey}, authKeys: opts.AuthKeys, conns: conns, rpc: opts.legacyRPC, layerRPC: opts.LayerRPC, metrics: opts.Metrics, onServing: opts.OnServing, cipher: crypto.NewServerCipher(opts.Rand), clock: opts.Clock, rand: opts.Rand, types: tmap.New(tg.TypesMap(), mt.TypesMap(), proto.TypesMap()), rpcResults: newRPCExecutionLedger(opts.Clock.Now, rpcExecutionLedgerCapacity{ maxPending: opts.RPCGlobalMaxTasks, maxPendingPerAuth: opts.RPCExecutionPendingPerAuth, globalMaxEntries: opts.RPCExecutionMaxEntries, authMaxEntries: opts.RPCExecutionAuthMaxEntries, sessionMaxEntries: opts.RPCExecutionSessionMaxEntries, replayStore: conns, }), rpcRewrap: newRPCRewrapRegistry(opts.RPCGlobalMaxTasks), admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes), } conns.setLogicalSessionReleaseHook(func(key sessionKey) { server.rpcResults.forgetSession(key.authKeyID, key.sessionID) }) return server } // ListenAndServe binds the public MTProto socket and immediately enters Serve. // Keeping listener ownership at the connection edge prevents callers from // exposing a TCP port and then performing slow seed/cache initialization while // clients are already completing handshakes into an unread accept backlog. func (s *Server) ListenAndServe(ctx context.Context, addr string) error { var lc net.ListenConfig ln, err := lc.Listen(ctx, "tcp", addr) if err != nil { return fmt.Errorf("listen %q: %w", addr, err) } return s.Serve(ctx, ln) } // newConn creates a sendable connection object based on one decryption result. func (s *Server) newConn(tc transport.Conn, key crypto.AuthKey, sessionID, salt int64) *Conn { if lease, ok := tc.(*physicalTransportLease); ok { return s.newConnWithLease(lease, key, sessionID, salt) } if tc != nil { _, lease := newPhysicalTransportOwner(tc) return s.newConnWithLease(lease, key, sessionID, salt) } // Preserve the nil transport used by construction-only tests. return s.buildConn(nil, nil, key, sessionID, salt) } // newConnWithLease attaches a logical Conn to an explicitly owned physical // transport generation. Production session replacement must Transfer the old // lease first; it must never wrap the same raw transport in a second owner. func (s *Server) newConnWithLease(lease *physicalTransportLease, key crypto.AuthKey, sessionID, salt int64) *Conn { if lease == nil { panic("mtprotoedge: nil physical transport lease") } c := s.buildConn(lease, lease, key, sessionID, salt) lease.bindLogicalConn(c) return c } func (s *Server) buildConn(tc transport.Conn, lease *physicalTransportLease, key crypto.AuthKey, sessionID, salt int64) *Conn { c := &Conn{ transport: tc, transportLease: lease, writer: tc, cipher: s.cipher, msgID: proto.NewMessageIDGen(s.clock.Now), writeTimeout: s.writeTimeout, metrics: s.metrics, now: s.clock.Now, authKeyID: key.ID, authKeyHex: hex.EncodeToString(key.ID[:]), sessionID: sessionID, salt: salt, key: key, createdAt: s.clock.Now(), outboundQueueSize: s.outboundQueueSize, outboundControlQueueSize: s.outboundControlQueueSize, outboundTrackedBudget: s.outboundTrackedBudget, outboundControlTrackedBudget: s.outboundControlBudget, outboundCriticalTrackedBudget: s.outboundCriticalBudget, outboundScratchPool: s.outboundScratchPool, outboundOpPool: s.outboundOpPool, outboundReplayBodyPool: s.outboundReplayBodyPool, rpcDeliveryHooks: s.rpcDeliveryHooks, rpcResultAcked: func(conn *Conn, reqMsgID int64) { // The sole outbound actor invokes this only after resolving a client // msgs_ack server msg_id through its tracked resend frame. The actor has // already removed the sole outbox frame; now delete its receipt and // retire any init-rewrap bookkeeping. s.rpcResults.Acknowledge(conn.authKeyID, conn.sessionID, reqMsgID) s.rpcRewrap.acknowledge(conn, reqMsgID) }, } s.conns.attachLogicalSession(c, s.outboundTrackedBudget) c.startOutbound() c.startInboundRPCScheduler(s.rpcScheduler, s.rpcInflight, s.rpcQueueSize, s.rpcTimeout) return c } // Serve runs the MTProto connection loop on ln until ctx is canceled or an // unrecoverable error occurs. On ctx cancellation it exits gracefully: it closes // the listener and waits for in-flight connection handling to finish. func (s *Server) Serve(ctx context.Context, ln net.Listener) error { // The shared worker pool may consume only after the Server actually Serves, and // starts lazily when the first RPC arrives. Before serveTCP/serveMixed return // they wait for connection goroutines to converge, and each Conn has already // drained/canceled its tasks; only then is the shared pool stopped and the // reserved delivery hooks drained, avoiding leftover queues that no one consumes // but that still occupy budget during shutdown. s.rpcScheduler.start() defer s.rpcDeliveryHooks.stop(rpcCloseWaitTimeout) defer s.conns.releaseAllLogicalSessions() defer s.rpcScheduler.stop(rpcCloseWaitTimeout) defer s.bulkRPCScheduler.close() // Wrap only once at the outermost listener, ensuring the same-port mux's // sniff/HTTP upgrade is also counted in raw admission, rather than counting only // after the connection has been split. ln = s.observeRawAccepts(s.admission.wrapListener(ln)) if s.websocket { return s.serveMixed(ctx, ln) } return s.serveTCP(ctx, ln) } func (s *Server) serveTCP(ctx context.Context, ln net.Listener) error { ctx, cancel := context.WithCancel(ctx) defer cancel() s.log.Info("Serving", zap.String("addr", ln.Addr().String()), zap.Int("dc", s.dc), zap.String("tcp_transport_mode", intakeTransport(s.obfuscated)), ) defer s.log.Info("Stopped") errCh := make(chan error, 1) go func() { errCh <- s.acceptLoop(ctx, ln, s.obfuscated) }() s.signalServing(ln.Addr()) return <-errCh } func (s *Server) serveMixed(ctx context.Context, ln net.Listener) error { ctx, cancel := context.WithCancel(ctx) defer cancel() // The sniff read timeout (reading the first 4 bytes for HTTP/TCP splitting) must // align with the "before establishing a session" read timeout // s.handshakeTimeout (default 60s)—exactly matching serveDetectedConn/serveConn's // pre-session read deadline. A connection that has not yet sent its first frame // is in the "pre-session idle" state: legitimate MTProto clients (such as DrKLO) // pre-open "warm" connections and do not immediately send the obfuscated2 init // before there is a request. Previously minDuration(5s,...) forced the sniff down // to 5s, 12x more aggressive than the non-mux path, which wrongly killed these // warm connections at 5s and triggered a client 6s reconnect storm and a wrong // "backend unhealthy" fallback to external DNS. The per-conn goroutine model has // eliminated slow-loris intake starvation, so using the full handshakeTimeout for // the sniff is safe. mux := newSamePortMux(ln, s.handshakeTimeout, s.observeConnectionIntake) wsRawLn, wsHandler := transport.WebsocketListener(ln.Addr()) wsLn := newTransportPacketMessageListener(wsRawLn) httpServer := &http.Server{ Handler: websocketRouteHandler(wsHandler, s.websocketOrigins, s.samePortHTTP), ReadHeaderTimeout: minDuration(10*time.Second, s.handshakeTimeout), BaseContext: func(net.Listener) context.Context { return ctx }, } s.log.Info("Serving", zap.String("addr", ln.Addr().String()), zap.Int("dc", s.dc), zap.String("tcp_transport_mode", intakeTransport(s.obfuscated)), zap.Bool("websocket", true), zap.Strings("websocket_origins", s.websocketOrigins), ) defer s.log.Info("Stopped") stopAll := func() { cancel() _ = mux.Close() _ = httpServer.Close() _ = wsLn.Close() } go func() { <-ctx.Done() stopAll() }() errCh := make(chan error, 4) var wg sync.WaitGroup wg.Add(4) // Splitter: sniff the first 4 bytes to separate HTTP (WebSocket upgrade) from bare MTProto TCP. go func() { defer wg.Done() errCh <- mux.Serve(ctx) }() // Bare MTProto TCP: each connection completes wire mode + codec detection in its own goroutine. go func() { defer wg.Done() errCh <- s.acceptLoop(ctx, mux.TCP(), s.obfuscated) }() // WebSocket: the gotd upgrade handler has already stripped obfuscated2 and restored the codec tag, so here only the codec needs detecting. go func() { defer wg.Done() errCh <- s.acceptLoopTransport(ctx, wsLn, false, "websocket") }() go func() { defer wg.Done() if err := httpServer.Serve(mux.HTTP()); err != nil { if errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) { errCh <- nil return } errCh <- fmt.Errorf("websocket http serve: %w", err) return } errCh <- nil }() s.signalServing(ln.Addr()) // The four services form one lifecycle: even a clean/closed-listener return from any one // component means the remaining three can no longer make forward progress as a complete // same-port server. Stop them immediately, then collect their terminal results. var firstErr error if err := <-errCh; err != nil { firstErr = err } stopAll() for i := 1; i < 4; i++ { if err := <-errCh; err != nil && firstErr == nil { firstErr = err } } wg.Wait() return firstErr } // acceptLoop accepts bare connections and starts a separate goroutine per // connection to complete "wire mode + codec detection + serveConn". Detection runs // outside the accept loop under the handshake timeout—a slow/half-open/bad-init // client only occupies its own goroutine and never blocks the intake of other // connections; a single connection's handshake failure only closes that // connection and does not bring down the whole listen loop. When obfuscated is // true it automatically distinguishes plain from obfuscated2 (bare MTProto TCP); // WebSocket connections pass false (the gotd upgrade handler has already // de-obfuscated). func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, obfuscated bool) error { return s.acceptLoopTransport(ctx, ln, obfuscated, intakeTransport(obfuscated)) } func (s *Server) acceptLoopTransport(ctx context.Context, ln net.Listener, obfuscated bool, transportName string) error { ctx, cancel := context.WithCancel(ctx) var wg sync.WaitGroup defer func() { // A permanent Accept error is itself a terminal lifecycle event. Cancel accepted // connections and close the listener before waiting; otherwise a live connection can // keep the WaitGroup blocked forever and prevent the accept error from being returned. cancel() _ = ln.Close() wg.Wait() }() go func() { <-ctx.Done() _ = ln.Close() }() var tempDelay time.Duration for { raw, err := ln.Accept() if err != nil { if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { return nil } if isTemporaryAcceptError(err) { tempDelay = nextAcceptRetryDelay(tempDelay) s.log.Debug("Temporary accept error; retrying", zap.Duration("backoff", tempDelay), zap.Error(err)) if !waitAcceptRetry(ctx, tempDelay) { return nil } continue } return fmt.Errorf("accept: %w", err) } tempDelay = 0 wg.Add(1) go func() { defer wg.Done() s.observeConnectionIntake(connectionIntakeEvent{ stage: "transport_dispatch", outcome: "accepted", transport: transportName, remote: connRemote(raw), local: connLocal(raw), }) s.serveDetectedConn(ctx, raw, obfuscated, transportName) }() } } // serveDetectedConn promotes a bare connection to a transport.Conn (de-obfuscation // + codec detection) and then runs the MTProto connection loop. The reads during // promotion happen on this goroutine and are bounded by the handshake read // timeout, rather than being stuffed into the accept loop, so a slow connection // does not block other connections' intake and the de-obfuscation/codec handshake // itself has a time bound. func (s *Server) serveDetectedConn(ctx context.Context, raw net.Conn, obfuscated bool, transportName string) { started := time.Now() remote, local := connRemote(raw), connLocal(raw) // The handshake read timeout only covers the small wire-mode + codec detection // stretch; it uses real wall-clock time (SetReadDeadline semantics) and does not // go through a logical clock that tests may inject. if err := raw.SetReadDeadline(time.Now().Add(s.handshakeTimeout)); err != nil { _ = raw.Close() return } // If ctx is canceled during detection, actively close raw to unblock the read—otherwise // the de-obfuscation read would hang until the handshake timeout, dragging the // half-open connection into the graceful-shutdown wait. Detection end stops this // watcher, and the serving phase is taken over by serveConn's own ctx watcher. promoted := make(chan struct{}) go func() { select { case <-ctx.Done(): _ = raw.Close() case <-promoted: } }() conn, detectedTransport, err := s.promoteConn(raw, obfuscated) if detectedTransport != "" { transportName = detectedTransport } close(promoted) if err != nil { outcome := "error" if isClientDisconnect(err) { outcome = "client_disconnect" } s.observeConnectionIntake(connectionIntakeEvent{ stage: "transport_promote", outcome: outcome, transport: transportName, remote: remote, local: local, duration: time.Since(started), err: err, }) _ = raw.Close() return } s.observeConnectionIntake(connectionIntakeEvent{ stage: "transport_promote", outcome: "ready", transport: transportName, remote: remote, local: local, duration: time.Since(started), }) // Detection is complete, so the handshake read timeout is removed; per-frame // reads and writes are then subject to the timeouts managed by serveConn / the transport layer. if err := raw.SetReadDeadline(time.Time{}); err != nil { _ = conn.Close() return } if err := s.serveConn(ctx, conn, remote, local); err != nil && !isClientDisconnect(err) { s.log.Info("Connection closed with error", zap.String("remote_addr", remote), zap.String("local_addr", local), zap.Error(err)) } } // promoteConn performs one transport promotion for a single connection. // obfuscated=true means bare TCP allows obfuscation and automatically distinguishes // plain/obfuscated2; WebSocket must pass false because the gotd upgrade handler has // already stripped obfuscated2 and restored the codec tag. func (s *Server) promoteConn(raw net.Conn, obfuscated bool) (transport.Conn, string, error) { if obfuscated { return s.promoteMixedTCP(raw) } conn, err := newCompatTransportConn(s.codec, raw, s.frameBudget) return conn, "", err } // serveConn handles a single transport connection: it reads frames and splits by auth_key_id. // // - auth_key_id == 0: an unencrypted key-exchange start message, performing the handshake and persisting the auth key. // - auth_key_id registered: an encrypted message, decrypted, registering the connection and dispatching to the RPC router. // - auth_key_id not registered: reply AuthKeyNotFound, prompting the client to redo the handshake. // // The connection is registered with SessionManager after establishing a session and // unregistered when it ends. func (s *Server) serveConn(ctx context.Context, raw transport.Conn, remote, local string) (err error) { transportOwner, conn := newPhysicalTransportOwner(raw) s.metrics.ConnOpened() s.log.Debug("MTProto connection loop started", zap.String("remote_addr", remote), zap.String("local_addr", local)) var current *Conn firstFrameStarted := time.Now() firstFrameSeen := false defer func() { // A successful Recv transfers the frame reservation to serveConn. Release it only after // this stack has stopped using b/plain; transport.Close may have raced us earlier and must // not return that memory budget prematurely. releaseInboundFrameOwnership(conn) // Publish the terminal/RPC-cancel gates before index removal or lifecycle // observers. Physical close then releases a writer already inside Send; the // final Close only waits for the now-fenced actors to converge. if current != nil { current.beginTerminalShutdown() } _ = transportOwner.CloseAny() if current != nil { s.conns.Unregister(current) current.Close() } s.metrics.ConnClosed() s.log.Debug("Connection closed", zap.String("remote_addr", remote), zap.String("local_addr", local), zap.Error(err)) }() // Close the connection when ctx is canceled or handling ends, unblocking Recv. ctx, cancel := context.WithCancel(ctx) defer cancel() go func() { <-ctx.Done() _ = transportOwner.CloseAny() }() cs := newConnState() var b bin.Buffer // plain is this connection's reused plaintext buffer: decryptClientFrame // decrypts each frame into it, avoiding per-frame whole-plaintext allocation; // the in-frame slices are valid until the next frame is read (the RPC body is // already copied in dispatch). var plain bin.Buffer var replay *bin.Buffer for { if replay != nil { b.ResetTo(replay.Buf) replay = nil } else { // Before a session is established (current==nil, before the handshake + // first encrypted message) use the shorter handshakeTimeout to quickly // reclaim silent half-open/aberrant connections; after a session is // established use readTimeout (the client has a ping heartbeat). timeout := s.readTimeout if current == nil || !current.isActive() { timeout = s.handshakeTimeout } if err := s.recv(ctx, conn, &b, timeout); err != nil { if !firstFrameSeen { outcome := "error" if isClientDisconnect(err) { outcome = "client_disconnect" } s.observeConnectionIntake(connectionIntakeEvent{ stage: "first_frame", outcome: outcome, remote: remote, local: local, duration: time.Since(firstFrameStarted), err: err, }) } return err } if !firstFrameSeen { firstFrameSeen = true s.observeConnectionIntake(connectionIntakeEvent{ stage: "first_frame", outcome: "ready", remote: remote, local: local, duration: time.Since(firstFrameStarted), bytes: b.Len(), }) } if s.onFrame != nil { s.onFrame(b.Len()) } } authKeyID, err := peekAuthKeyID(&b) if err != nil { return fmt.Errorf("peek auth key id: %w", err) } if authKeyID == emptyAuthKeyID { // A physical socket may perform key exchange only before it owns an // encrypted logical session. Mixing the direct exchange writer with an // active outbound actor would bypass generation/write serialization. if current != nil { return errors.New("unencrypted exchange on established encrypted connection") } releaseHandshake, admitted := s.admission.tryAcquireHandshake() if !admitted { if err := s.sendProtoError(ctx, conn, codec.CodeTransportFlood); err != nil { return err } return nil } next, err := s.handleExchange(ctx, conn, &b) releaseHandshake() if err != nil { return err } replay = next // Exchange has finished consuming the original transport frame. Drop its // potentially near-16MiB backing immediately. A replay frame is the gotd // encrypted-frame copy and keeps the existing frame reservation until it is // dispatched; a completed handshake has no surviving frame and can release now. trimOversizedInboundBuffer(&b) if replay == nil { releaseInboundFrameOwnership(conn) } else { retainInboundFrameBackings(conn, replay) } continue } // An established connection reuses the cached key on the fast path // (fetchedKey=nil): avoiding a per-frame AuthKeyStore lookup—the hottest // library access point in the mtprotoedge layer. Key material is immutable // after creation; destruction (destroy_auth_key)/revocation is invalidated by // SessionManager actively Closing the connection, not relying on the passive // "next frame -404". A bad-salt provisional not yet in SessionManager is // precisely rechecked once after handleEncrypted establishes the activation // claim, which linearizes revocation with activation and also does not amplify // a salt storm into a PG write storm. A temporary key's absolute expiry is // cached on the Conn and only needs an in-memory comparison per frame; on // expiry it must return -404 before RPC, letting official clients rotate only // the temp key. It must never fall through to the Router and degrade to a raw // business identity, ending with a 401 that triggers a whole-account logout. if current != nil && current.authKeyID == authKeyID && authKeyProtocolUnavailable(current.authKeyExpiresAt, s.clock.Now()) { s.log.Info("Rejecting unavailable temporary or legacy auth key", zap.String("auth_key_id", current.authKeyHex), zap.Int("expires_at", current.authKeyExpiresAt), ) if err := s.sendTerminalProtoError(ctx, current, codec.CodeAuthKeyNotFound); err != nil { return err } return nil } var fetchedKey *store.AuthKeyData if current == nil || current.authKeyID != authKeyID { d, found, err := s.authKeys.Get(ctx, authKeyID) if err != nil { return fmt.Errorf("lookup auth key: %w", err) } if !found { var sendErr error if current != nil { sendErr = s.sendTerminalProtoError(ctx, current, codec.CodeAuthKeyNotFound) } else { sendErr = s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound) } if sendErr != nil { return sendErr } // -404 is a terminal key failure for TDesktop; keeping the socket would // only let the same client repeatedly trigger AuthKeyStore lookups. // Reply once and then disconnect immediately. return nil } if authKeyProtocolUnavailable(d.ExpiresAt, s.clock.Now()) { s.log.Info("Rejecting unavailable temporary or legacy auth key", zap.String("auth_key_id", hex.EncodeToString(d.ID[:])), zap.Int("expires_at", d.ExpiresAt), ) var sendErr error if current != nil { sendErr = s.sendTerminalProtoError(ctx, current, codec.CodeAuthKeyNotFound) } else { sendErr = s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound) } if sendErr != nil { return sendErr } return nil } fetchedKey = &d } current, err = s.handleEncrypted(ctx, conn, cs, current, remote, fetchedKey, &b, &plain) if errors.Is(err, errActivationAuthKeyRejected) { // handleEncrypted writes -404 while its activation claim still owns the // physical writer, then its deferred abort removes/closes the claim. return nil } if err != nil { return err } trimOversizedInboundBuffer(&b) trimOversizedInboundBuffer(&plain) retainInboundFrameBackings(conn, &b, &plain) } } func authKeyProtocolUnavailable(expiresAt int, now time.Time) bool { // -1 is migration 0086's explicit legacy-unknown sentinel. Reject it once // instead of guessing permanent and allowing account authorization on a temp key. return expiresAt < 0 || (expiresAt > 0 && int64(expiresAt) <= now.Unix()) } // maxRetainedConnBuffer keeps normal upload/download frames allocation-free while preventing one // exceptional near-16MiB transport frame from pinning that capacity for the lifetime of a long // connection. RPC bodies that outlive dispatch already own a budgeted Copy. const maxRetainedConnBuffer = 2 << 20 func trimOversizedInboundBuffer(b *bin.Buffer) { if b != nil && cap(b.Buf) > maxRetainedConnBuffer { b.Buf = nil } } // deadlineReceiver is an optional direct read-timeout interface: the telesrv-owned // compat transport implements it so each frame read only does one SetReadDeadline // and no longer allocates a per-frame context timer. ctx cancellation is still // handled by serveConn's watcher closing the underlying connection to unblock the // read (matching the ctx-deadline path's behavior). type deadlineReceiver interface { RecvDeadline(deadline time.Time, b *bin.Buffer) error } func (s *Server) recv(ctx context.Context, conn transport.Conn, b *bin.Buffer, timeout time.Duration) error { b.Reset() if dr, ok := conn.(deadlineReceiver); ok { return dr.RecvDeadline(time.Now().Add(timeout), b) } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() return conn.Recv(ctx, b) } // isClientDisconnect determines whether an error is a normal client disconnect / // service shutdown and should not be logged as an anomaly. func isClientDisconnect(err error) bool { switch { case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, net.ErrClosed), errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): return true } var nerr *net.OpError if errors.As(err, &nerr) && (nerr.Op == "read" || nerr.Op == "write") { return true } return false }