| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- package toc
- import (
- "bufio"
- "bytes"
- "context"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "net"
- "net/http"
- "net/netip"
- "sync"
- "syscall"
- "time"
- "github.com/patrickmn/go-cache"
- "golang.org/x/sync/errgroup"
- "golang.org/x/time/rate"
- "github.com/mk6i/retro-aim-server/state"
- "github.com/mk6i/retro-aim-server/wire"
- )
- var (
- // errClientReq indicates that an error occurred while reading a client request
- errClientReq = errors.New("failed to read client request")
- // errServerWrite indicates that an error occurred while writing a server response
- errServerWrite = errors.New("failed to send server response")
- // errTOCProcessing indicates that an error occurred in the TOC handler
- errTOCProcessing = errors.New("failed to process TOC request")
- )
- // bufferedConn is a wrapper around net.Conn that allows peeking into the
- // incoming connection without consuming data. It is useful for multiplexing
- // TOC/HTTP and TOC/FLAP connections.
- //
- // It embeds net.Conn, so all standard connection methods remain available.
- type bufferedConn struct {
- r *bufio.Reader
- net.Conn
- }
- // newBufferedConn wraps a net.Conn with buffered reading capabilities.
- func newBufferedConn(c net.Conn) bufferedConn {
- return bufferedConn{bufio.NewReader(c), c}
- }
- // Peek returns the next n bytes from the buffer without advancing the reader.
- // If fewer than n bytes are available, it returns an error.
- func (b bufferedConn) Peek(n int) ([]byte, error) {
- return b.r.Peek(n)
- }
- // Read reads data into p from the buffered connection.
- // It prioritizes buffered data before reading from the underlying connection.
- func (b bufferedConn) Read(p []byte) (int, error) {
- return b.r.Read(p)
- }
- // channelListener is an implementation of net.Listener that accepts connections
- // from a channel instead of a network socket. It is useful for attaching an
- // HTTP service to a connection on the fly.
- type channelListener struct {
- ch chan net.Conn // Channel used to receive connections.
- }
- // Accept waits for and returns the next connection from the channel.
- // If the channel is closed, it returns io.EOF to indicate no more connections.
- func (l *channelListener) Accept() (net.Conn, error) {
- ch, ok := <-l.ch
- if !ok {
- return nil, io.EOF
- }
- return ch, nil
- }
- // Close closes the listener. Since channelListener does not manage an actual
- // network connection, this is a no-op and always returns nil.
- func (l *channelListener) Close() error {
- return nil
- }
- // Addr returns the network address of the listener.
- // Since channelListener is not bound to a real network address, it returns nil.
- func (l *channelListener) Addr() net.Addr {
- return nil
- }
- // IPRateLimiter provides per-IP rate limiting using a token bucket algorithm.
- // It caches individual rate limiters per IP address with automatic TTL expiration.
- type IPRateLimiter struct {
- cache *cache.Cache // In-memory cache of rate limiters keyed by IP
- rate rate.Limit // Allowed request rate (events per second)
- burst int // Maximum burst size
- }
- // NewIPRateLimiter returns a new IPRateLimiter that limits each IP to the specified
- // rate and burst, with limiter state expiring after the given TTL.
- // Entries are retained for up to 2×TTL to reduce churn under frequent lookups.
- func NewIPRateLimiter(rate rate.Limit, burst int, ttl time.Duration) *IPRateLimiter {
- return &IPRateLimiter{
- cache: cache.New(ttl, 2*ttl),
- rate: rate,
- burst: burst,
- }
- }
- // Allow returns true if the request from the given IP is allowed under its rate limit.
- // If no limiter exists for the IP, one is created and tracked in the cache.
- func (l *IPRateLimiter) Allow(ip string) (allowed bool) {
- limiter, found := l.cache.Get(ip)
- if !found {
- limiter = rate.NewLimiter(l.rate, l.burst)
- l.cache.Set(ip, limiter, cache.DefaultExpiration)
- }
- return limiter.(*rate.Limiter).Allow()
- }
- // Server implements a TOC protocol server that multiplexes TOC/HTTP and
- // TOC/FLAP requests. It acts as a gateway, forwarding all TOC requests
- // to the OSCAR server for processing.
- type Server struct {
- BOSProxy OSCARProxy
- ListenAddr string
- Logger *slog.Logger
- LoginIPRateLimiter *IPRateLimiter
- }
- func (rt Server) Start(ctx context.Context) error {
- listener, err := net.Listen("tcp", rt.ListenAddr)
- if err != nil {
- return fmt.Errorf("unable to start TOC server: %w", err)
- }
- rt.Logger.InfoContext(ctx, "starting server", "listen_host", rt.ListenAddr)
- go func() {
- <-ctx.Done()
- _ = listener.Close()
- }()
- httpServer := &http.Server{
- Handler: rt.BOSProxy.NewServeMux(),
- BaseContext: func(net.Listener) context.Context {
- return ctx
- },
- }
- httpCh := make(chan net.Conn)
- defer close(httpCh)
- go func() {
- _ = httpServer.Serve(&channelListener{ch: httpCh})
- }()
- wg := sync.WaitGroup{}
- for {
- conn, err := listener.Accept()
- if err != nil {
- if errors.Is(err, net.ErrClosed) {
- break
- }
- rt.Logger.ErrorContext(ctx, "accept failed", "err", err.Error())
- continue
- }
- wg.Add(1)
- go func() {
- defer wg.Done()
- if err := rt.dispatchConn(conn, ctx, httpCh); err != nil {
- rt.Logger.ErrorContext(ctx, "client disconnected with error", "err", err.Error())
- }
- }()
- }
- if !waitForShutdown(&wg) {
- rt.Logger.ErrorContext(ctx, "shutdown complete, but connections didn't close cleanly")
- } else {
- rt.Logger.InfoContext(ctx, "shutdown complete")
- }
- return nil
- }
- // dispatchConn inspects and routes an incoming connection. If the connection
- // starts with "FLAP", handle as TOC/FLAP; otherwise, dispatch for HTTP
- // processing.
- func (rt Server) dispatchConn(conn net.Conn, ctx context.Context, httpCh chan net.Conn) error {
- bufCon := newBufferedConn(conn)
- doFlap := "FLAP"
- buf, err := bufCon.Peek(len(doFlap))
- if err != nil {
- return fmt.Errorf("bufCon.Peek: %w", err)
- }
- // handle TOC/FLAP
- if string(buf) == doFlap {
- if err = rt.dispatchFLAP(ctx, bufCon); err != nil {
- switch {
- case errors.Is(err, io.EOF):
- case errors.Is(err, net.ErrClosed):
- case errors.Is(err, syscall.ECONNRESET):
- return nil
- default:
- return fmt.Errorf("rt.dispatchFLAP: %w", err)
- }
- }
- return nil
- }
- // handle TOC/HTTP
- select {
- case httpCh <- bufCon:
- return nil
- case <-ctx.Done():
- return nil
- }
- }
- func (rt Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
- var once sync.Once
- closeConn := func() {
- once.Do(func() {
- _ = conn.Close()
- })
- }
- defer closeConn()
- ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
- clientFlap, err := rt.initFLAP(conn)
- if err != nil {
- return err
- }
- ip, _, err := net.SplitHostPort(conn.RemoteAddr().String())
- if err != nil {
- rt.Logger.Error("failed to parse remote address", "err", err.Error())
- return err
- }
- if ok := rt.LoginIPRateLimiter.Allow(ip); !ok {
- if err := clientFlap.SendDataFrame([]byte("ERROR:983")); err != nil {
- return fmt.Errorf("clientFlap.SendDataFrame: %w", err)
- }
- return nil
- }
- sessBOS, err := rt.login(ctx, clientFlap)
- if err != nil {
- return fmt.Errorf("rt.login: %w", err)
- }
- if sessBOS == nil {
- return nil // user not found
- }
- ctx = context.WithValue(ctx, "screenName", sessBOS.IdentScreenName())
- remoteAddr, ok := ctx.Value("ip").(string)
- if ok {
- ip, err := netip.ParseAddrPort(remoteAddr)
- if err != nil {
- return errors.New("unable to parse ip addr")
- }
- sessBOS.SetRemoteAddr(&ip)
- }
- chatRegistry := NewChatRegistry()
- defer rt.BOSProxy.Signout(ctx, sessBOS, chatRegistry)
- return rt.handleTOCRequest(ctx, closeConn, sessBOS, chatRegistry, clientFlap)
- }
- // handleTOCRequest processes incoming TOC requests and coordinates their handling.
- // It reads client requests, processes TOC commands, and sends responses back to the client.
- //
- // Returns:
- // - errClientReq if an error occurs while reading the TOC request. wraps
- // io.EOF if the client disconnected.
- // - errTOCProcessing if an error occurs while processing the TOC command.
- // - errServerWrite if an error occurs while sending the TOC response.
- func (rt Server) handleTOCRequest(
- ctx context.Context,
- closeConn func(),
- sessBOS *state.Session,
- chatRegistry *ChatRegistry,
- clientFlap *wire.FlapClient,
- ) error {
- // TOC response queue
- msgCh := make(chan []byte, 1)
- g, ctx := errgroup.WithContext(ctx)
- // process TOC client requests and enqueue TOC server responses
- g.Go(func() error {
- err := rt.runClientCommands(ctx, g.Go, sessBOS, chatRegistry, clientFlap, msgCh)
- return errors.Join(err, errClientReq)
- })
- // translate OSCAR server responses to TOC responses and enqueue them
- g.Go(func() error {
- err := rt.BOSProxy.RecvBOS(ctx, sessBOS, chatRegistry, msgCh)
- closeConn() // unblock runClientCommands
- return errors.Join(err, errTOCProcessing)
- })
- // send TOC server responses to the client
- g.Go(func() error {
- err := rt.sendToClient(ctx, msgCh, clientFlap)
- closeConn() // unblock runClientCommands
- return errors.Join(err, errServerWrite)
- })
- return g.Wait()
- }
- func (rt Server) runClientCommands(ctx context.Context, doAsync func(f func() error), sessBOS *state.Session, chatRegistry *ChatRegistry, clientFlap *wire.FlapClient, toCh chan<- []byte) error {
- for {
- clientFrame, err := clientFlap.ReceiveFLAP()
- if err != nil {
- return err
- }
- switch clientFrame.FrameType {
- case wire.FLAPFrameSignoff:
- return io.EOF // client disconnected
- case wire.FLAPFrameKeepAlive:
- // keep alive heartbeat, do nothing for now.
- // todo set connection deadline to future time
- case wire.FLAPFrameData:
- clientFrame.Payload = bytes.TrimRight(clientFrame.Payload, "\x00") // trim null terminator
- if len(clientFrame.Payload) == 0 {
- return errors.New("TOC command is empty")
- }
- if len(clientFrame.Payload) > 2048 {
- return errors.New("TOC command exceeds maximum length (2048)")
- }
- msg := rt.BOSProxy.RecvClientCmd(ctx, sessBOS, chatRegistry, clientFrame.Payload, toCh, doAsync)
- if len(msg) > 0 {
- select {
- case toCh <- []byte(msg):
- case <-ctx.Done():
- return nil
- }
- }
- default:
- return fmt.Errorf("unexpected clientFlap clientFrame type %d", clientFrame.FrameType)
- }
- }
- }
- func (rt Server) sendToClient(ctx context.Context, toClient <-chan []byte, clientFlap *wire.FlapClient) error {
- for {
- select {
- case <-ctx.Done():
- return nil
- case msg := <-toClient:
- if err := clientFlap.SendDataFrame(msg); err != nil {
- return fmt.Errorf("clientFlap.SendDataFrame: %w", err)
- }
- if rt.Logger.Enabled(ctx, slog.LevelDebug) {
- rt.Logger.DebugContext(ctx, "server response", "command", msg)
- } else {
- // just log the command, omit params
- idx := len(msg)
- if col := bytes.IndexByte(msg, ':'); col > -1 {
- idx = col
- }
- rt.Logger.InfoContext(ctx, "server response", "command", msg[0:idx])
- }
- }
- }
- }
- func (rt Server) login(ctx context.Context, clientFlap *wire.FlapClient) (*state.Session, error) {
- clientFrame, err := clientFlap.ReceiveFLAP()
- if err != nil {
- if errors.Is(err, io.EOF) {
- return nil, nil
- }
- return nil, fmt.Errorf("clientFlap.ReceiveFLAP: %w", err)
- }
- cmd := clientFrame.Payload
- var args []byte
- if idx := bytes.IndexByte(clientFrame.Payload, ' '); idx > -1 {
- cmd, args = clientFrame.Payload[:idx], clientFrame.Payload[idx:]
- }
- if string(cmd) != "toc_signon" {
- return nil, errors.New("expected toc_signon")
- }
- sessBOS, reply := rt.BOSProxy.Signon(ctx, args)
- for _, m := range reply {
- if err := clientFlap.SendDataFrame([]byte(m)); err != nil {
- return nil, fmt.Errorf("clientFlap.SendDataFrame: %w", err)
- }
- }
- return sessBOS, nil
- }
- // initFLAP sets up a new FLAP connection. It returns a flap client if the
- // connection successfully initialized.
- func (rt Server) initFLAP(rw io.ReadWriter) (*wire.FlapClient, error) {
- expected := "FLAPON\r\n\r\n"
- buf := make([]byte, len(expected))
- _, err := io.ReadFull(rw, buf)
- if err != nil {
- return nil, fmt.Errorf("io.ReadFull: %w", err)
- }
- if expected != string(buf) {
- return nil, fmt.Errorf("expected FLAPON, got %s", buf)
- }
- clientFlap := wire.NewFlapClient(0, rw, rw)
- if err := clientFlap.SendSignonFrame(nil); err != nil {
- return nil, fmt.Errorf("clientFlap.SendSignonFrame: %w", err)
- }
- if _, err := clientFlap.ReceiveSignonFrame(); err != nil {
- return nil, fmt.Errorf("clientFlap.ReceiveSignonFrame: %w", err)
- }
- return clientFlap, nil
- }
- // waitForShutdown returns when either the wg completes or 5 seconds has
- // passed. This is a temporary hack to ensure that the server shuts down even
- // if all the TCP connections do not drain. Return true if the shutdown is
- // clean.
- func waitForShutdown(wg *sync.WaitGroup) bool {
- ch := make(chan struct{})
- go func() {
- wg.Wait() // goroutine leak if wg never completes
- close(ch)
- }()
- select {
- case <-ch:
- return true
- case <-time.After(time.Second * 5):
- return false
- }
- }
|