Files
zeekay 2675910436 exclusive and auto-delete are different events, and a body size is a claim
Three defects an adversarial read found, each with a gate that bites.

An exclusive queue was made CO-OWNED by any connection that declared its name,
so either connection's close deleted the other's queue — the second declare
should have been refused. It is now, with 405 RESOURCE_LOCKED, on declare,
consume and get alike. Enforced within one gateway and stated as such: a
replica cannot see another replica's sockets.

Auto-delete was dying with the connection that DECLARED the queue, which took
queues other connections were still consuming. It names its last consumer
leaving, which is the event the consumer count already knows about.

A content header's body size was believed up to frame-max x 4096 — half a
gigabyte a channel, held while the body trickled in. It is refused on the
header against the bus's own MaxPayload, so the gateway names no second number
and a lying size never becomes an allocation.

And a redeclare that disagrees about durable, exclusive or auto-delete is
refused rather than quietly accepted: a client told ok for a durable queue that
is not durable believes its messages survive a restart.
2026-08-21 08:19:03 -07:00

492 lines
13 KiB
Go

package protocol
import (
"context"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/hanzoai/amqp/wire"
)
// Class ids (§1.2).
const (
classConnection = 10
classChannel = 20
classExchange = 40
classQueue = 50
classBasic = 60
classTx = 90
classConfirm = 85
)
// Method ids, by class.
const (
connStart = 10
connStartOK = 11
connTune = 30
connTuneOK = 31
connOpen = 40
connOpenOK = 41
connClose = 50
connCloseOK = 51
chanOpen = 10
chanOpenOK = 11
chanFlow = 20
chanFlowOK = 21
chanClose = 40
chanCloseOK = 41
exDeclare = 10
exDeclareOK = 11
exDelete = 20
exDeleteOK = 21
exBind = 30
exBindOK = 31
exUnbind = 40
exUnbindOK = 51
qDeclare = 10
qDeclareOK = 11
qBind = 20
qBindOK = 21
qPurge = 30
qPurgeOK = 31
qDelete = 40
qDeleteOK = 41
qUnbind = 50
qUnbindOK = 51
basicQos = 10
basicQosOK = 11
basicConsume = 20
basicConsumeOK = 21
basicCancel = 30
basicCancelOK = 31
basicPublish = 40
basicReturn = 50
basicDeliver = 60
basicGet = 70
basicGetOK = 71
basicGetEmpty = 72
basicAck = 80
basicReject = 90
basicRecoverA = 100
basicRecover = 110
basicRecoverOK = 111
basicNack = 120
confirmSelect = 10
confirmSelectOK = 11
)
// Reply codes (§4.3). Only the ones this gateway actually returns.
const (
replySuccess = 200
contentTooLarge = 311
noRoute = 312
accessRefused = 403
notFound = 404
resourceLocked = 405
preconditionFailed = 406
frameError = 501
syntaxError = 502
commandInvalid = 503
channelError = 504
unexpectedFrame = 505
notAllowed = 530
notImplemented = 540
internalError = 541
)
// fault is an AMQP exception: a reply code, prose, and the method that caused
// it. Conn says whether it kills the connection or only the channel (§1.5.2) —
// scope is a property of the fault, so the code that raises one states it and
// nothing downstream has to guess.
type fault struct {
Code uint16
Text string
Class uint16
Method uint16
Conn bool
}
func (f fault) Error() string {
return fmt.Sprintf("amqp %d %s (class %d method %d)", f.Code, f.Text, f.Class, f.Method)
}
func chanFault(code, class, method uint16, format string, a ...any) fault {
return fault{Code: code, Text: fmt.Sprintf(format, a...), Class: class, Method: method}
}
func connFault(code, class, method uint16, format string, a ...any) fault {
return fault{Code: code, Text: fmt.Sprintf(format, a...), Class: class, Method: method, Conn: true}
}
// absent is the answer to every method this gateway does not implement. It is
// the whole reason a client can trust the ones it does: an unknown method is
// told so on its channel, naming itself, rather than being acknowledged or
// silently dropped.
func absent(class, method uint16, why string) fault {
return chanFault(notImplemented, class, method, "NOT_IMPLEMENTED - %s", why)
}
// maxChannels is the channel-max offered in connection.tune. A channel is a
// map entry and a mutex, so the number is about bounding one peer's appetite,
// not about a cost per channel.
const maxChannels = 2047
// errBye ends the read loop after the client's connection.close was answered.
var errBye = errors.New("client closed the connection")
// session is one AMQP connection: its channels, its socket, and the negotiated
// limits both ends agreed to.
type session struct {
b *Broker
conn net.Conn
r *wire.Reader
w *wire.Writer
heartbeat time.Duration
maxChan uint16
mu sync.Mutex
chans map[uint16]*channel
closing map[uint16]bool // sent channel.close, awaiting the peer's close-ok
owned map[string]bool // queues that die with this connection
done chan struct{}
once sync.Once
}
func newSession(b *Broker, conn net.Conn) *session {
return &session{
b: b,
conn: conn,
r: wire.NewReader(conn, b.cfg.FrameMax),
w: wire.NewWriter(conn, b.cfg.FrameMax),
chans: map[uint16]*channel{},
closing: map[uint16]bool{},
owned: map[string]bool{},
done: make(chan struct{}),
}
}
func (s *session) close() {
s.once.Do(func() {
close(s.done)
s.conn.Close()
s.mu.Lock()
chans := make([]*channel, 0, len(s.chans))
for _, c := range s.chans {
chans = append(chans, c)
}
s.chans = map[uint16]*channel{}
owned := make([]string, 0, len(s.owned))
for q := range s.owned {
owned = append(owned, q)
}
s.mu.Unlock()
for _, c := range chans {
c.shutdown()
}
// Exclusive and auto-delete queues live exactly as long as the
// connection that asked for them (§1.7.2.1).
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, q := range owned {
_ = s.b.dropConsumer(ctx, q)
_ = s.b.top.DropQueue(ctx, q)
}
s.b.release(s)
})
}
func (s *session) run() {
if err := s.handshake(); err != nil {
logf("handshake from %s: %v", s.conn.RemoteAddr(), err)
return
}
go s.beats()
for {
if s.heartbeat > 0 {
// A peer that stops talking has to be noticed; two missed
// intervals is the spec's own threshold (§4.2.7).
_ = s.conn.SetReadDeadline(time.Now().Add(2 * s.heartbeat))
}
f, err := s.r.Read()
if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
logf("read from %s: %v", s.conn.RemoteAddr(), err)
}
return
}
if err := s.frame(f); err != nil {
var flt fault
switch {
case errors.Is(err, errBye):
return
case errors.As(err, &flt) && !flt.Conn:
s.killChannel(f.Channel, flt)
case errors.As(err, &flt):
s.killConn(flt)
return
default:
s.killConn(fault{Code: internalError, Text: err.Error(), Conn: true})
return
}
}
}
}
// beats keeps the connection warm. Both peers send their own; a heartbeat is a
// statement that this end is alive, never a request for a reply.
func (s *session) beats() {
if s.heartbeat <= 0 {
return
}
t := time.NewTicker(s.heartbeat / 2)
defer t.Stop()
for {
select {
case <-s.done:
return
case <-t.C:
if err := s.w.Beat(); err != nil {
return
}
}
}
}
// handshake runs the four exchanges that open a connection: protocol header,
// start/start-ok, tune/tune-ok, open/open-ok (§2.2.4).
func (s *session) handshake() error {
if err := s.r.Header(); err != nil {
// A peer that spoke a different protocol gets the version we do speak,
// which is what tells its client library to give up cleanly.
_ = s.w.Send(wire.Frame{Type: wire.Method, Payload: wire.Protocol})
return err
}
start := wire.NewMethod(classConnection, connStart).
Octet(0).Octet(9).
Table(wire.Table{
"product": s.b.cfg.Product,
"version": s.b.cfg.Version,
"platform": "Hanzo PubSub",
"capabilities": wire.Table{"publisher_confirms": true, "basic.nack": true, "consumer_cancel_notify": true},
}).
LongStr("PLAIN").
LongStr("en_US")
if err := s.w.Send(start.Frame(0)); err != nil {
return err
}
// start-ok: client-properties, mechanism, response, locale. The response
// is a SASL PLAIN payload; the bus behind this gateway is reached over the
// loopback or a credentials file, so there is no second credential here to
// check and inventing one would be a second identity for the same access.
if _, err := s.expect(classConnection, connStartOK); err != nil {
return err
}
tune := wire.NewMethod(classConnection, connTune).
Short(maxChannels). // channel-max
Long(s.b.cfg.FrameMax). // frame-max
Short(uint16(s.b.cfg.Heartbeat / time.Second))
if err := s.w.Send(tune.Frame(0)); err != nil {
return err
}
a, err := s.expect(classConnection, connTuneOK)
if err != nil {
return err
}
s.maxChan = a.Short()
frameMax := a.Long()
s.heartbeat = time.Duration(a.Short()) * time.Second
if err := a.Err(); err != nil {
return err
}
if s.maxChan == 0 || s.maxChan > maxChannels {
s.maxChan = maxChannels
}
if frameMax < wire.MinFrame || frameMax > s.b.cfg.FrameMax {
frameMax = s.b.cfg.FrameMax
}
s.w.SetMax(frameMax)
if _, err := s.expect(classConnection, connOpen); err != nil {
return err
}
return s.w.Send(wire.NewMethod(classConnection, connOpenOK).ShortStr("").Frame(0))
}
// expect reads the next frame and insists it is the method named. Anything
// else during the handshake is a protocol error, not something to skip past.
func (s *session) expect(class, method uint16) (*wire.Args, error) {
_ = s.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
f, err := s.r.Read()
if err != nil {
return nil, err
}
if f.Type != wire.Method {
return nil, fmt.Errorf("frame type %d during handshake, want a method", f.Type)
}
gotClass, gotMethod, err := wire.ClassMethod(f.Payload)
if err != nil {
return nil, err
}
if gotClass != class || gotMethod != method {
return nil, fmt.Errorf("method %d.%d during handshake, want %d.%d", gotClass, gotMethod, class, method)
}
return wire.NewArgs(f.Payload), nil
}
// frame routes one frame to the connection or to a channel.
func (s *session) frame(f wire.Frame) error {
if f.Type == wire.Heartbeat {
return nil // the read deadline already noted that the peer is alive
}
if f.Channel == 0 {
if f.Type != wire.Method {
return connFault(unexpectedFrame, 0, 0, "frame type %d on channel 0, which carries methods only", f.Type)
}
return s.connection(f.Payload)
}
if s.maxChan > 0 && f.Channel > s.maxChan {
return connFault(channelError, 0, 0, "channel %d is above the negotiated channel-max %d", f.Channel, s.maxChan)
}
class, method, err := wire.ClassMethod(f.Payload)
if f.Type == wire.Method && err != nil {
return connFault(frameError, 0, 0, "method frame is %d octets", len(f.Payload))
}
// A channel this end closed is silent until the peer's close-ok arrives.
// The peer had frames in flight when the exception went out; answering
// each with another exception would escalate one channel's mistake into
// the whole connection (§1.5.2).
s.mu.Lock()
shut := s.closing[f.Channel]
if shut && f.Type == wire.Method && class == classChannel && method == chanCloseOK {
delete(s.closing, f.Channel)
}
s.mu.Unlock()
if shut {
return nil
}
// channel.open is the one method allowed on a channel that is not open.
if f.Type == wire.Method && class == classChannel && method == chanOpen {
return s.open(f.Channel, f.Payload)
}
s.mu.Lock()
c := s.chans[f.Channel]
s.mu.Unlock()
if c == nil {
return connFault(channelError, class, method, "channel %d is not open", f.Channel)
}
return c.frame(f, class, method)
}
func (s *session) open(id uint16, payload []byte) error {
s.mu.Lock()
if _, dup := s.chans[id]; dup {
s.mu.Unlock()
return connFault(channelError, classChannel, chanOpen, "channel %d is already open", id)
}
s.chans[id] = newChannel(s, id)
s.mu.Unlock()
return s.w.Send(wire.NewMethod(classChannel, chanOpenOK).LongStr("").Frame(id))
}
// connection handles the methods that belong to the connection itself.
func (s *session) connection(payload []byte) error {
class, method, err := wire.ClassMethod(payload)
if err != nil {
return connFault(frameError, 0, 0, "method frame is %d octets", len(payload))
}
if class != classConnection {
return connFault(commandInvalid, class, method, "class %d is not valid on channel 0", class)
}
switch method {
case connClose:
if err := s.w.Send(wire.NewMethod(classConnection, connCloseOK).Frame(0)); err != nil {
return err
}
return errBye
case connCloseOK:
return errBye
default:
return connFault(commandInvalid, class, method,
"connection method %d is not valid once the connection is open", method)
}
}
// killChannel closes one channel with an exception and leaves the connection up.
func (s *session) killChannel(id uint16, f fault) {
s.mu.Lock()
c := s.chans[id]
delete(s.chans, id)
s.closing[id] = true
s.mu.Unlock()
if c != nil {
c.shutdown()
}
_ = s.w.Send(wire.NewMethod(classChannel, chanClose).
Short(f.Code).ShortStr(f.Text).Short(f.Class).Short(f.Method).Frame(id))
}
// killConn closes the whole connection with an exception, then waits briefly
// for the peer's close-ok so its client library reports the reason rather than
// a reset socket.
func (s *session) killConn(f fault) {
logf("closing %s: %v", s.conn.RemoteAddr(), f)
if err := s.w.Send(wire.NewMethod(classConnection, connClose).
Short(f.Code).ShortStr(f.Text).Short(f.Class).Short(f.Method).Frame(0)); err != nil {
return
}
_ = s.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
for {
fr, err := s.r.Read()
if err != nil {
return
}
if fr.Type == wire.Method {
if _, m, err := wire.ClassMethod(fr.Payload); err == nil && m == connCloseOK {
return
}
}
}
}
// forget drops a channel the client closed cleanly.
func (s *session) forget(id uint16) {
s.mu.Lock()
delete(s.chans, id)
delete(s.closing, id)
s.mu.Unlock()
}
// own marks a queue as dying with this connection.
func (s *session) own(name string) {
s.mu.Lock()
s.owned[name] = true
s.mu.Unlock()
}
func (s *session) disown(name string) {
s.mu.Lock()
delete(s.owned, name)
s.mu.Unlock()
}