Files
smux/session.go
T
xtaci c4b7d47a00 Squashed commit of the following:
commit c8a401fdb4
Author: xtaci <imap@live.com>
Date:   Sat Jan 24 22:29:03 2026 +0800

    fix(session): reject cmdUPD in protocol version 1

    cmdUPD is a v2-only command used for flow control window updates.
    Previously, recvLoop would accept and process cmdUPD even when
    the session was configured for protocol version 1, which violates
    the protocol specification.

    This change adds a version check before processing cmdUPD. If the
    session is not running v2, it now correctly returns ErrInvalidProtocol
    and terminates the receive loop.
2026-01-24 22:29:37 +08:00

675 lines
16 KiB
Go

// MIT License
//
// Copyright (c) 2016-2017 xtaci
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package smux
import (
"encoding/binary"
"errors"
"io"
"net"
"runtime"
"sync"
"sync/atomic"
"time"
)
const (
defaultAcceptBacklog = 1024
minShaperNotifySize = 16
maxShaperSize = 1024
openCloseTimeout = 30 * time.Second // Timeout for opening/closing streams
)
// CLASSID represents the class of a frame
type CLASSID int
const (
CLSCTRL CLASSID = iota // prioritized control signal
CLSDATA
)
// timeoutError representing timeouts for operations such as accept, read and write
//
// To better cooperate with the standard library, timeoutError should implement the standard library's `net.Error`.
//
// For example, using smux to implement net.Listener and work with http.Server, the keep-alive connection (*smux.Stream) will be unexpectedly closed.
// For more details, see https://github.com/xtaci/smux/pull/99.
type timeoutError struct{}
func (timeoutError) Error() string { return "timeout" }
func (timeoutError) Temporary() bool { return true }
func (timeoutError) Timeout() bool { return true }
var (
ErrInvalidProtocol = errors.New("invalid protocol")
ErrConsumed = errors.New("peer consumed more than sent")
ErrGoAway = errors.New("stream id overflows, should start a new connection")
ErrTimeout net.Error = &timeoutError{}
ErrWouldBlock = errors.New("operation would block on IO")
)
// writeRequest represents a request to write a frame
type writeRequest struct {
class CLASSID
frame Frame
seq uint32
result chan writeResult
}
// writeResult represents the result of a write request
type writeResult struct {
n int
err error
}
// Session defines a multiplexed connection for streams
type Session struct {
conn io.ReadWriteCloser
config *Config
goAway int32 // flag id exhausted
nextStreamID uint32 // next stream identifier
nextStreamIDLock sync.Mutex
bucket int32 // token bucket
bucketNotify chan struct{} // used for waiting for tokens
streams map[uint32]*stream // all streams in this session
streamLock sync.Mutex // locks streams
die chan struct{} // flag session has died
dieOnce sync.Once
// socket error handling
socketReadError atomic.Value
socketWriteError atomic.Value
chSocketReadError chan struct{}
chSocketWriteError chan struct{}
socketReadErrorOnce sync.Once
socketWriteErrorOnce sync.Once
// smux protocol errors
protoError atomic.Value
chProtoError chan struct{}
protoErrorOnce sync.Once
chAccepts chan *stream
sessionIsActive int32 // flag session is active
acceptDeadline atomic.Value // deadline for Accept()
requestID uint32 // Monotonic increasing write request ID
shaper chan writeRequest // a shaper for writing
sq *shaperQueue
chShaperPending chan struct{}
chShaperConsumed chan struct{}
}
func newSession(config *Config, conn io.ReadWriteCloser, client bool) *Session {
s := new(Session)
s.die = make(chan struct{})
s.conn = conn
s.config = config
s.streams = make(map[uint32]*stream)
s.chAccepts = make(chan *stream, defaultAcceptBacklog)
s.bucket = int32(config.MaxReceiveBuffer)
s.bucketNotify = make(chan struct{}, 1)
s.shaper = make(chan writeRequest, maxShaperSize)
s.chSocketReadError = make(chan struct{})
s.chSocketWriteError = make(chan struct{})
s.chProtoError = make(chan struct{})
s.chShaperPending = make(chan struct{}, 1)
s.chShaperConsumed = make(chan struct{}, 1)
s.sq = NewShaperQueue()
if client {
s.nextStreamID = 1
} else {
s.nextStreamID = 0
}
go s.shaperLoop()
go s.recvLoop()
go s.sendLoop()
if !config.KeepAliveDisabled {
go s.keepalive()
}
return s
}
// OpenStream is used to create a new stream
func (s *Session) OpenStream() (*Stream, error) {
if s.IsClosed() {
return nil, io.ErrClosedPipe
}
// generate stream id
s.nextStreamIDLock.Lock()
if s.goAway > 0 {
s.nextStreamIDLock.Unlock()
return nil, ErrGoAway
}
// check for stream id overflow
if s.nextStreamID+2 < s.nextStreamID {
s.goAway = 1
s.nextStreamIDLock.Unlock()
return nil, ErrGoAway
}
// allocate next stream id
s.nextStreamID += 2
sid := s.nextStreamID
s.nextStreamIDLock.Unlock()
stream := newStream(sid, s.config.MaxFrameSize, s)
if _, err := s.writeControlFrame(newFrame(byte(s.config.Version), cmdSYN, sid)); err != nil {
return nil, err
}
s.streamLock.Lock()
defer s.streamLock.Unlock()
select {
case <-s.chSocketReadError:
return nil, s.socketReadError.Load().(error)
case <-s.chSocketWriteError:
return nil, s.socketWriteError.Load().(error)
case <-s.die:
return nil, io.ErrClosedPipe
default:
s.streams[sid] = stream
wrapper := &Stream{stream: stream}
// NOTE(x): disabled finalizer for issue #997
/*
runtime.SetFinalizer(wrapper, func(s *Stream) {
s.Close()
})
*/
return wrapper, nil
}
}
// Open returns a generic ReadWriteCloser
func (s *Session) Open() (io.ReadWriteCloser, error) {
return s.OpenStream()
}
// AcceptStream is used to block until the next available stream
// is ready to be accepted.
func (s *Session) AcceptStream() (*Stream, error) {
var deadline <-chan time.Time
if d, ok := s.acceptDeadline.Load().(time.Time); ok && !d.IsZero() {
timer := time.NewTimer(time.Until(d))
defer timer.Stop()
deadline = timer.C
}
select {
case stream := <-s.chAccepts:
wrapper := &Stream{stream: stream}
runtime.SetFinalizer(wrapper, func(s *Stream) {
s.Close()
})
return wrapper, nil
case <-deadline:
return nil, ErrTimeout
case <-s.chSocketReadError:
return nil, s.socketReadError.Load().(error)
case <-s.chProtoError:
return nil, s.protoError.Load().(error)
case <-s.die:
return nil, io.ErrClosedPipe
}
}
// Accept Returns a generic ReadWriteCloser instead of smux.Stream
func (s *Session) Accept() (io.ReadWriteCloser, error) {
return s.AcceptStream()
}
// Close is used to close the session and all streams.
func (s *Session) Close() error {
var once bool
s.dieOnce.Do(func() {
close(s.die)
once = true
})
if !once {
return io.ErrClosedPipe
}
s.streamLock.Lock()
for k := range s.streams {
s.streams[k].sessionClose()
}
s.streamLock.Unlock()
return s.conn.Close()
}
// CloseChan can be used by someone who wants to be notified immediately when this
// session is closed
func (s *Session) CloseChan() <-chan struct{} {
return s.die
}
// notifyBucket notifies recvLoop that bucket is available
func (s *Session) notifyBucket() {
select {
case s.bucketNotify <- struct{}{}:
default:
}
}
func (s *Session) notifyReadError(err error) {
s.socketReadErrorOnce.Do(func() {
s.socketReadError.Store(err)
close(s.chSocketReadError)
})
}
func (s *Session) notifyWriteError(err error) {
s.socketWriteErrorOnce.Do(func() {
s.socketWriteError.Store(err)
close(s.chSocketWriteError)
})
}
func (s *Session) notifyProtoError(err error) {
s.protoErrorOnce.Do(func() {
s.protoError.Store(err)
close(s.chProtoError)
})
}
// IsClosed does a safe check to see if we have shutdown
func (s *Session) IsClosed() bool {
select {
case <-s.die:
return true
default:
return false
}
}
// NumStreams returns the number of currently open streams
func (s *Session) NumStreams() int {
if s.IsClosed() {
return 0
}
s.streamLock.Lock()
defer s.streamLock.Unlock()
return len(s.streams)
}
// SetDeadline sets a deadline used by Accept* calls.
// A zero time value disables the deadline.
func (s *Session) SetDeadline(t time.Time) error {
s.acceptDeadline.Store(t)
return nil
}
// LocalAddr satisfies net.Conn interface
func (s *Session) LocalAddr() net.Addr {
if ts, ok := s.conn.(interface {
LocalAddr() net.Addr
}); ok {
return ts.LocalAddr()
}
return nil
}
// RemoteAddr satisfies net.Conn interface
func (s *Session) RemoteAddr() net.Addr {
if ts, ok := s.conn.(interface {
RemoteAddr() net.Addr
}); ok {
return ts.RemoteAddr()
}
return nil
}
// notify the session that a stream has closed
func (s *Session) streamClosed(sid uint32) {
s.streamLock.Lock()
defer s.streamLock.Unlock()
stream, ok := s.streams[sid]
if !ok {
return
}
if n := stream.recycleTokens(); n > 0 {
// return remaining tokens to the bucket
if atomic.AddInt32(&s.bucket, int32(n)) > 0 {
s.notifyBucket()
}
}
delete(s.streams, sid)
}
// returnTokens is called by stream to return token after read
func (s *Session) returnTokens(n int) {
if atomic.AddInt32(&s.bucket, int32(n)) > 0 {
s.notifyBucket()
}
}
// recvLoop keeps on reading from underlying connection if tokens are available
func (s *Session) recvLoop() {
var hdr rawHeader
var updHdr updHeader
for {
// Wait until we have tokens or session is closed.
for atomic.LoadInt32(&s.bucket) <= 0 && !s.IsClosed() {
select {
case <-s.bucketNotify:
case <-s.die:
// If it returns here, Accept() and OpenStream() are unblocked with io.ErrClosedPipe,
// causing recvLoop to exit gracefully. If recvLoop is blocked in io.ReadFull, however,
// it will be unblocked by a socket read error instead.
return
}
}
// As long as we have tokens, try to read frames.
// read header first
_, err := io.ReadFull(s.conn, hdr[:])
if err != nil {
s.notifyReadError(err)
return
}
// Mark the session as active
atomic.StoreInt32(&s.sessionIsActive, 1)
// validate protocol version
if hdr.Version() != byte(s.config.Version) {
s.notifyProtoError(ErrInvalidProtocol)
return
}
// handle different command types
sid := hdr.StreamID()
switch hdr.Cmd() {
case cmdNOP:
case cmdSYN: // stream opening
var accepted *stream
s.streamLock.Lock()
if _, ok := s.streams[sid]; !ok {
stream := newStream(sid, s.config.MaxFrameSize, s)
s.streams[sid] = stream
accepted = stream
}
s.streamLock.Unlock()
if accepted != nil {
select {
case s.chAccepts <- accepted:
case <-s.die:
}
}
case cmdFIN: // stream closing
s.streamLock.Lock()
if stream, ok := s.streams[sid]; ok {
stream.fin() // fin unblocks the readers and writers
}
s.streamLock.Unlock()
case cmdPSH: // data frame
if hdr.Length() == 0 {
continue
}
// read payload from the underlying connection
pNewbuf := defaultAllocator.Get(int(hdr.Length()))
written, err := io.ReadFull(s.conn, *pNewbuf)
if err != nil {
s.notifyReadError(err)
// recycle the buffer immediately.
defaultAllocator.Put(pNewbuf)
return
}
// push data to the corresponding stream
s.streamLock.Lock()
if stream, ok := s.streams[sid]; ok {
stream.pushBytes(pNewbuf)
// deduct tokens from the bucket
atomic.AddInt32(&s.bucket, -int32(written))
stream.wakeupReader()
} else {
// data directed to a missing/closed stream, recycle the buffer immediately.
defaultAllocator.Put(pNewbuf)
}
s.streamLock.Unlock()
case cmdUPD: // a window update signal (v2 only)
if s.config.Version != 2 {
s.notifyProtoError(ErrInvalidProtocol)
return
}
_, err := io.ReadFull(s.conn, updHdr[:])
if err != nil {
s.notifyReadError(err)
return
}
// update the window size for the corresponding stream
s.streamLock.Lock()
if stream, ok := s.streams[sid]; ok {
stream.update(updHdr.Consumed(), updHdr.Window())
}
s.streamLock.Unlock()
default:
s.notifyProtoError(ErrInvalidProtocol)
return
}
}
}
// keepalive sends NOP frames periodically to keep the connection alive
func (s *Session) keepalive() {
tickerPing := time.NewTicker(s.config.KeepAliveInterval)
tickerTimeout := time.NewTicker(s.config.KeepAliveTimeout)
defer tickerPing.Stop()
defer tickerTimeout.Stop()
for {
select {
case <-tickerPing.C:
s.writeFrameInternal(newFrame(byte(s.config.Version), cmdNOP, 0), tickerPing.C, CLSCTRL)
s.notifyBucket() // force a wakeup signal to the recvLoop
case <-tickerTimeout.C:
if !atomic.CompareAndSwapInt32(&s.sessionIsActive, 1, 0) {
// recvLoop may block while bucket is 0, in this case,
// session should not be closed.
if atomic.LoadInt32(&s.bucket) > 0 {
s.Close()
return
}
}
case <-s.die:
return
}
}
}
// shaperLoop implements a priority queue and bandwidth shaping for write requests.
// Eg: Control messages are prioritized over data messages, and shaper tries
// it's best to keep fair bandwidth among streams.
func (s *Session) shaperLoop() {
chShaper := s.shaper
for {
select {
case <-s.die:
return
case r := <-chShaper:
s.sq.Push(r)
// notify sendLoop there are pending requests
if len(chShaper) == 0 || s.sq.Len() > minShaperNotifySize {
s.notifyShaperPending()
}
if s.sq.Len() >= maxShaperSize {
// stop accepting new requests temporarily if shaper queue is full
chShaper = nil
}
case <-s.chShaperConsumed:
// re-enable shaper channel
chShaper = s.shaper
}
}
}
// notifyShaperPending notifies sendLoop that there are pending requests
func (s *Session) notifyShaperPending() {
select {
case s.chShaperPending <- struct{}{}:
default:
}
}
// notifyShaperConsumed notifies when shaper queue is being consumed
func (s *Session) notifyShaperConsumed() {
select {
case s.chShaperConsumed <- struct{}{}:
default:
}
}
// sendLoop sends frames over the underlying connection
func (s *Session) sendLoop() {
var buf []byte
var n int
var err error
var vec [][]byte // vector for writeBuffers
bw, ok := s.conn.(interface {
WriteBuffers(v [][]byte) (n int, err error)
})
if ok {
buf = make([]byte, headerSize)
vec = make([][]byte, 2)
} else {
buf = make([]byte, (1<<16)+headerSize)
}
EVENT_LOOP:
for {
select {
case <-s.die:
return
case <-s.chShaperPending:
for {
request, ok := s.sq.Pop()
if !ok {
// notify shaperLoop to accept new requests
s.notifyShaperConsumed()
goto EVENT_LOOP
}
buf[0] = request.frame.ver
buf[1] = request.frame.cmd
binary.LittleEndian.PutUint16(buf[2:], uint16(len(request.frame.data)))
binary.LittleEndian.PutUint32(buf[4:], request.frame.sid)
// support for scatter-gather I/O
if len(vec) > 0 {
vec[0] = buf[:headerSize]
vec[1] = request.frame.data
n, err = bw.WriteBuffers(vec)
} else {
copy(buf[headerSize:], request.frame.data)
n, err = s.conn.Write(buf[:headerSize+len(request.frame.data)])
}
n -= headerSize
if n < 0 {
n = 0
}
result := writeResult{
n: n,
err: err,
}
request.result <- result
close(request.result)
// store conn error
if err != nil {
s.notifyWriteError(err)
return
}
}
}
}
}
// writeControlFrame writes the control frame to the underlying connection
// and returns the number of bytes written if successful
func (s *Session) writeControlFrame(f Frame) (n int, err error) {
timer := time.NewTimer(openCloseTimeout)
defer timer.Stop()
return s.writeFrameInternal(f, timer.C, CLSCTRL)
}
// internal writeFrame version to support deadline used in keepalive
func (s *Session) writeFrameInternal(f Frame, deadline <-chan time.Time, class CLASSID) (int, error) {
req := writeRequest{
class: class,
frame: f,
seq: atomic.AddUint32(&s.requestID, 1),
result: make(chan writeResult, 1),
}
select {
case s.shaper <- req:
case <-s.die:
return 0, io.ErrClosedPipe
case <-s.chSocketWriteError:
return 0, s.socketWriteError.Load().(error)
case <-deadline:
return 0, ErrTimeout
}
select {
case result := <-req.result:
return result.n, result.err
case <-s.die:
return 0, io.ErrClosedPipe
case <-s.chSocketWriteError:
return 0, s.socketWriteError.Load().(error)
case <-deadline:
return 0, ErrTimeout
}
}