CI / build (push) Successful in 30s
The client doesn't ping while it's sending, so when the proxy answers its frames itself (everything it sends in a hosted room), IMVU's gateway saw an idle connection and closed it after about a minute. A ping now goes to the gateway in place of each answered frame. And after a reconnect, hosted rooms' queues are taken out of C2gOpenFloodgates and answered here (the gateway never answered them, so the client gave up and closed the room). Co-Authored-By: Claude Opus 5.5 <[email protected]>
328 lines
9.2 KiB
Go
328 lines
9.2 KiB
Go
package proxy
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
|
)
|
|
|
|
// serveConnect connects the client's plaintext stream to host:port over TLS.
|
|
func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
|
|
id := s.requests.Add(1)
|
|
start := time.Now()
|
|
target := r.Host
|
|
host, _, err := net.SplitHostPort(target)
|
|
if err != nil {
|
|
http.Error(w, "t5de-proxy: expected CONNECT host:port", http.StatusBadRequest)
|
|
return
|
|
}
|
|
config := &tls.Config{}
|
|
if s.TLSConfig != nil {
|
|
config = s.TLSConfig.Clone()
|
|
}
|
|
config.ServerName = host
|
|
dial := s.Dial
|
|
if dial == nil {
|
|
dial = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
|
}
|
|
raw, err := dial(r.Context(), "tcp", target)
|
|
var upstream *tls.Conn
|
|
if err == nil {
|
|
upstream = tls.Client(raw, config)
|
|
if err = upstream.HandshakeContext(r.Context()); err != nil {
|
|
raw.Close()
|
|
}
|
|
}
|
|
if err != nil {
|
|
s.logf("#%d CONNECT %s failed after %s: %v", id, target, since(start), err)
|
|
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer upstream.Close()
|
|
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
s.logf("#%d CONNECT %s: %v", id, target, err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
if _, err := io.WriteString(conn, "HTTP/1.1 200 Connection established\r\n\r\n"); err != nil {
|
|
return
|
|
}
|
|
s.logf("#%d CONNECT %s open (%s)", id, target, since(start))
|
|
|
|
// Whatever the client sent after its CONNECT and the server already read is the stream's start.
|
|
var client io.Reader = conn
|
|
if n := buffered.Reader.Buffered(); n > 0 {
|
|
head, _ := buffered.Reader.Peek(n)
|
|
client = io.MultiReader(bytes.NewReader(bytes.Clone(head)), conn)
|
|
}
|
|
|
|
// Frames go to the client whole, from the gateway or from the proxy answering a dropped message, so they
|
|
// never interleave.
|
|
toClient := &lockedWriter{w: conn}
|
|
down := &stream{s: s, id: id, name: "g2c"}
|
|
up := &stream{s: s, id: id, name: "c2g", filter: s.IMQFilter, reply: toClient}
|
|
if w, ok := s.IMQFilter.(IMQWatcher); ok {
|
|
c := &IMQConn{ID: id, w: toClient}
|
|
down.watcher, down.conn = w, c
|
|
up.watcher, up.conn = w, c
|
|
w.Opened(c)
|
|
defer w.Closed(c)
|
|
}
|
|
// IMQ is a session: when either side closes, it's over, so both connections close.
|
|
var once sync.Once
|
|
closeBoth := func() {
|
|
once.Do(func() {
|
|
conn.Close()
|
|
upstream.Close()
|
|
})
|
|
}
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer closeBoth()
|
|
up.copy(upstream, client)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
defer closeBoth()
|
|
down.copy(toClient, upstream)
|
|
}()
|
|
wg.Wait()
|
|
s.logf("#%d CONNECT %s closed after %s: %s, %s", id, target, since(start), up.summary(), down.summary())
|
|
}
|
|
|
|
// IMQFilter sees the client's outgoing IMQ messages and can change or drop them.
|
|
type IMQFilter interface {
|
|
// Outgoing returns the message to send instead of message, or drop to send nothing and tell the client it
|
|
// was sent.
|
|
Outgoing(queue, mount string, message []byte) (out []byte, drop bool)
|
|
}
|
|
|
|
// IMQWatcher is an IMQFilter that also follows each IMQ connection: the queues the client subscribes to, the
|
|
// messages the gateway delivers (which it can change), and it can deliver messages of its own.
|
|
type IMQWatcher interface {
|
|
IMQFilter
|
|
// Opened: the client opened an IMQ connection (its user queue comes with it, without a subscription).
|
|
Opened(c *IMQConn)
|
|
// Subscribed and Unsubscribed see the client's subscriptions on a connection.
|
|
Subscribed(c *IMQConn, queues []string)
|
|
Unsubscribed(c *IMQConn, queues []string)
|
|
// Incoming returns the message to deliver instead of m's (m.Message itself to leave it).
|
|
Incoming(c *IMQConn, m imq.Delivered) []byte
|
|
// Closed: the connection is over (and with it, its subscriptions).
|
|
Closed(c *IMQConn)
|
|
}
|
|
|
|
// IMQRouter is an IMQWatcher that serves some queues itself (T5DE-hosted rooms' queues): it takes them out of the
|
|
// client's subscriptions before they go to the gateway, and answers the client for them.
|
|
type IMQRouter interface {
|
|
// Route returns the part of a subscription (or unsubscription) for the gateway.
|
|
Route(c *IMQConn, unsubscribe bool, s imq.Subscription) imq.Subscription
|
|
}
|
|
|
|
// IMQConn is one IMQ connection of the client's.
|
|
type IMQConn struct {
|
|
ID int64
|
|
w io.Writer
|
|
}
|
|
|
|
// NewIMQConn is a connection writing frames to w (for tests of watchers).
|
|
func NewIMQConn(id int64, w io.Writer) *IMQConn { return &IMQConn{ID: id, w: w} }
|
|
|
|
// Send gives the client a frame of the given type as if the gateway had sent it.
|
|
func (c *IMQConn) Send(frameType uint64, data []byte) error {
|
|
_, err := c.w.Write(imq.EncodeFrame(frameType, data))
|
|
return err
|
|
}
|
|
|
|
// Deliver gives the client a message as if the gateway had delivered it.
|
|
func (c *IMQConn) Deliver(m imq.Delivered) error {
|
|
_, err := c.w.Write(imq.EncodeFrame(imq.G2cSendMessage, m.Marshal()))
|
|
return err
|
|
}
|
|
|
|
// lockedWriter serializes writes, each a whole number of frames.
|
|
type lockedWriter struct {
|
|
mu sync.Mutex
|
|
w io.Writer
|
|
}
|
|
|
|
func (l *lockedWriter) Write(b []byte) (int, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.w.Write(b)
|
|
}
|
|
|
|
// stream is one direction of a CONNECT. It passes IMQ frames on whole, counting them (and, outgoing, filtering
|
|
// them), or bytes as they come once the stream turns out not to be IMQ.
|
|
type stream struct {
|
|
s *Server
|
|
id int64
|
|
name string
|
|
bytes int64
|
|
frames int
|
|
other bool // not IMQ framing
|
|
pending []byte
|
|
filter IMQFilter // outgoing only
|
|
reply io.Writer // outgoing only: to the client
|
|
watcher IMQWatcher
|
|
conn *IMQConn
|
|
}
|
|
|
|
func (st *stream) copy(dst io.Writer, src io.Reader) {
|
|
buf := make([]byte, 32<<10)
|
|
for {
|
|
n, err := src.Read(buf)
|
|
if n > 0 {
|
|
st.bytes += int64(n)
|
|
if werr := st.pass(dst, buf[:n]); werr != nil {
|
|
return
|
|
}
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// pass writes on the frames data completes.
|
|
func (st *stream) pass(dst io.Writer, data []byte) error {
|
|
if st.other {
|
|
_, err := dst.Write(data)
|
|
return err
|
|
}
|
|
st.pending = append(st.pending, data...)
|
|
var out []byte
|
|
for {
|
|
f, n, err := imq.Next(st.pending)
|
|
if err != nil {
|
|
st.other = true
|
|
st.s.logf("#%d %s isn't IMQ framing; passing it through", st.id, st.name)
|
|
out = append(out, st.pending...)
|
|
st.pending = nil
|
|
break
|
|
}
|
|
if n == 0 {
|
|
break
|
|
}
|
|
frame := st.pending[:n]
|
|
st.pending = st.pending[n:]
|
|
st.frames++
|
|
if st.s.Trace {
|
|
st.s.logf("#%d %s %s %dB", st.id, st.name, imq.TypeName(f.Type), f.Size)
|
|
}
|
|
out = append(out, st.filtered(f, frame)...)
|
|
}
|
|
if len(st.pending) == 0 {
|
|
st.pending = nil
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
_, err := dst.Write(out)
|
|
return err
|
|
}
|
|
|
|
// filtered is the frame to send on: as is, changed by the filter, or none (answered here).
|
|
func (st *stream) filtered(f imq.Frame, frame []byte) []byte {
|
|
if st.watcher != nil {
|
|
switch f.Type {
|
|
case imq.C2gSubscribe, imq.C2gUnsubscribe:
|
|
sub, err := imq.ParseSubscriptionOps(f.Data)
|
|
if err != nil {
|
|
return frame
|
|
}
|
|
if f.Type == imq.C2gSubscribe {
|
|
st.watcher.Subscribed(st.conn, sub.All())
|
|
} else {
|
|
st.watcher.Unsubscribed(st.conn, sub.All())
|
|
}
|
|
router, ok := st.watcher.(IMQRouter)
|
|
if !ok {
|
|
return frame
|
|
}
|
|
rest := router.Route(st.conn, f.Type == imq.C2gUnsubscribe, sub)
|
|
if rest.Empty() {
|
|
return keepalive()
|
|
}
|
|
if len(rest.All()) == len(sub.All()) {
|
|
return frame
|
|
}
|
|
return imq.EncodeFrame(f.Type, rest.Marshal())
|
|
case imq.C2gOpenFloodgates:
|
|
// Resuming queues after a reconnect: the router's are answered here, the rest go to the gateway.
|
|
router, ok := st.watcher.(IMQRouter)
|
|
if !ok {
|
|
return frame
|
|
}
|
|
states, err := imq.ParseFloodgates(f.Data)
|
|
if err != nil {
|
|
return frame
|
|
}
|
|
var rest []imq.QueueOp
|
|
for _, q := range states {
|
|
if router.Route(st.conn, false, imq.Subscription{WithResults: []imq.QueueOp{q}}).Empty() {
|
|
continue
|
|
}
|
|
rest = append(rest, q)
|
|
}
|
|
if len(rest) == len(states) {
|
|
return frame
|
|
}
|
|
return imq.EncodeFrame(imq.C2gOpenFloodgates, imq.MarshalFloodgates(rest))
|
|
case imq.G2cSendMessage:
|
|
m, err := imq.ParseDelivered(f.Data)
|
|
if err != nil {
|
|
return frame
|
|
}
|
|
out := st.watcher.Incoming(st.conn, m)
|
|
if bytes.Equal(out, m.Message) {
|
|
return frame
|
|
}
|
|
m.Message = out
|
|
return imq.EncodeFrame(imq.G2cSendMessage, m.Marshal())
|
|
}
|
|
}
|
|
if st.filter == nil || f.Type != imq.C2gSendMessage {
|
|
return frame
|
|
}
|
|
m, err := imq.ParseSendMessage(f.Data)
|
|
if err != nil {
|
|
return frame
|
|
}
|
|
out, drop := st.filter.Outgoing(m.Queue, m.Mount, m.Message)
|
|
if drop {
|
|
if st.reply != nil {
|
|
st.reply.Write(imq.EncodeFrame(imq.G2cResult, imq.Result(m.OpID, 0)))
|
|
}
|
|
return keepalive()
|
|
}
|
|
if bytes.Equal(out, m.Message) {
|
|
return frame
|
|
}
|
|
m.Message = out
|
|
return imq.EncodeFrame(imq.C2gSendMessage, m.Marshal())
|
|
}
|
|
|
|
// keepalive is what goes to the gateway in place of a frame the proxy answered itself: a ping. The client doesn't
|
|
// ping while it's sending, so without it the gateway sees an idle connection and closes it (in a hosted room, where
|
|
// everything the client sends is answered here).
|
|
func keepalive() []byte { return imq.EncodeFrame(imq.C2gPing, nil) }
|
|
|
|
func (st *stream) summary() string {
|
|
if st.other {
|
|
return fmt.Sprintf("%s %dB", st.name, st.bytes)
|
|
}
|
|
return fmt.Sprintf("%s %dB in %d frames", st.name, st.bytes, st.frames)
|
|
}
|