Explorar o código

move service logic out of main.go

Mike %!s(int64=2) %!d(string=hai) anos
pai
achega
184ebc85fb
Modificáronse 14 ficheiros con 595 adicións e 569 borrados
  1. 18 152
      cmd/main.go
  2. 39 0
      oscar/snacs.go
  3. 79 0
      server/bucp.go
  4. 1 1
      server/buddy_test.go
  5. 18 0
      server/config.go
  6. 311 0
      server/connection.go
  7. 1 1
      server/feedbag.go
  8. 1 1
      server/feedbag_test.go
  9. 5 5
      server/icbm.go
  10. 6 6
      server/icbm_test.go
  11. 2 2
      server/locate.go
  12. 2 2
      server/locate_test.go
  13. 0 399
      server/protocol.go
  14. 112 0
      server/router.go

+ 18 - 152
cmd/main.go

@@ -1,20 +1,15 @@
 package main
 package main
 
 
 import (
 import (
-	"context"
 	"fmt"
 	"fmt"
-	"log"
-	"log/slog"
-	"net"
 	"os"
 	"os"
+	"sync"
 
 
-	"github.com/google/uuid"
 	"github.com/kelseyhightower/envconfig"
 	"github.com/kelseyhightower/envconfig"
 	"github.com/mkaminski/goaim/server"
 	"github.com/mkaminski/goaim/server"
 )
 )
 
 
 func main() {
 func main() {
-
 	var cfg server.Config
 	var cfg server.Config
 	err := envconfig.Process("", &cfg)
 	err := envconfig.Process("", &cfg)
 	if err != nil {
 	if err != nil {
@@ -22,164 +17,35 @@ func main() {
 		os.Exit(1)
 		os.Exit(1)
 	}
 	}
 
 
-	logger := server.NewLogger(cfg)
-
 	fm, err := server.NewFeedbagStore(cfg.DBPath)
 	fm, err := server.NewFeedbagStore(cfg.DBPath)
 	if err != nil {
 	if err != nil {
-		logger.Error("unable to create feedbag store", "err", err.Error())
+		_, _ = fmt.Fprintf(os.Stderr, "unable to create feedbag store: %s", err.Error())
 		os.Exit(1)
 		os.Exit(1)
 	}
 	}
 
 
-	go server.StartManagementAPI(fm, logger)
-
+	logger := server.NewLogger(cfg)
 	sm := server.NewSessionManager(logger)
 	sm := server.NewSessionManager(logger)
 	cr := server.NewChatRegistry()
 	cr := server.NewChatRegistry()
 
 
-	go listenBOS(cfg, sm, fm, cr, logger.With("svc", "BOS"))
-	go listenChat(cfg, fm, cr, logger.With("svc", "CHAT"))
-
-	addr := server.Address("", cfg.OSCARPort)
-	listener, err := net.Listen("tcp", addr)
-	if err != nil {
-		logger.Error("unable to bind OSCAR server address", "err", err.Error())
-		os.Exit(1)
-	}
-	defer listener.Close()
-
-	logger.Info("starting OSCAR server", "addr", addr)
-
-	for {
-		conn, err := listener.Accept()
-		if err != nil {
-			log.Println(err)
-			continue
-		}
-
-		go handleAuthConnection(cfg, sm, fm, conn)
-	}
-}
-
-func listenBOS(cfg server.Config, sm *server.InMemorySessionManager, fm *server.FeedbagStore, cr *server.ChatRegistry, logger *slog.Logger) {
-	addr := server.Address("", cfg.BOSPort)
-	listener, err := net.Listen("tcp", addr)
-	if err != nil {
-		logger.Error("unable to bind BOS server address", "err", err.Error())
-		os.Exit(1)
-	}
-	defer listener.Close()
-
-	logger.Info("starting service", "addr", addr)
-
-	router := server.NewRouter(logger)
-	for {
-		conn, err := listener.Accept()
-		if err != nil {
-			log.Println(err)
-			continue
-		}
-		ctx := context.Background()
-		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
-		logger.DebugContext(ctx, "accepted connection")
-		go handleBOSConnection(ctx, cfg, sm, fm, cr, conn, router, logger)
-	}
-}
-
-func listenChat(cfg server.Config, fm *server.FeedbagStore, cr *server.ChatRegistry, logger *slog.Logger) {
-	addr := server.Address("", cfg.ChatPort)
-	listener, err := net.Listen("tcp", addr)
-	if err != nil {
-		logger.Error("unable to bind chat server address", "err", err.Error())
-		os.Exit(1)
-	}
-	defer listener.Close()
-
-	logger.Info("starting service", "addr", addr)
-
-	router := server.NewRouterForChat(logger)
-	for {
-		conn, err := listener.Accept()
-		if err != nil {
-			log.Println(err)
-			continue
-		}
-		ctx := context.Background()
-		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
-		logger.DebugContext(ctx, "accepted connection")
-		go handleChatConnection(ctx, cfg, fm, cr, conn, router, logger)
-	}
-}
-
-func handleAuthConnection(cfg server.Config, sm *server.InMemorySessionManager, fm *server.FeedbagStore, conn net.Conn) {
-	defer conn.Close()
-	seq := uint32(100)
-	_, err := server.SendAndReceiveSignonFrame(conn, &seq)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-
-	err = server.ReceiveAndSendAuthChallenge(cfg, fm, conn, conn, &seq, uuid.New)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-
-	err = server.ReceiveAndSendBUCPLoginRequest(cfg, sm, fm, conn, conn, &seq, uuid.New)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-}
-
-func handleBOSConnection(ctx context.Context, cfg server.Config, sm *server.InMemorySessionManager, fm *server.FeedbagStore, cr *server.ChatRegistry, conn net.Conn, router server.Router, logger *slog.Logger) {
-	sess, seq, err := server.VerifyLogin(sm, conn)
-	if err != nil {
-		logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
-		return
-	}
-
-	defer sess.Close()
-	defer conn.Close()
+	wg := sync.WaitGroup{}
+	wg.Add(4)
 
 
 	go func() {
 	go func() {
-		<-sess.Closed()
-		server.Signout(ctx, logger, sess, sm, fm)
+		server.StartManagementAPI(fm, logger)
+		wg.Done()
 	}()
 	}()
-
-	ctx = context.WithValue(ctx, "screenName", sess.ScreenName)
-
-	server.ReadBos(ctx, cfg, sess, seq, sm, fm, cr, conn, server.ChatRoom{}, router, logger)
-}
-
-func handleChatConnection(ctx context.Context, cfg server.Config, fm *server.FeedbagStore, cr *server.ChatRegistry, conn net.Conn, router server.Router, logger *slog.Logger) {
-	cookie, seq, err := server.VerifyChatLogin(conn)
-	if err != nil {
-		logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
-		return
-	}
-
-	room, err := cr.Retrieve(string(cookie.Cookie))
-	if err != nil {
-		logger.ErrorContext(ctx, "unable to find chat room", "err", err.Error())
-		return
-	}
-
-	chatSess, found := room.Retrieve(cookie.SessID)
-	if !found {
-		logger.ErrorContext(ctx, "unable to find user for session", "sessID", cookie.SessID)
-		return
-	}
-
-	defer chatSess.Close()
 	go func() {
 	go func() {
-		<-chatSess.Closed()
-		server.AlertUserLeft(ctx, chatSess, room)
-		room.Remove(chatSess)
-		cr.MaybeRemoveRoom(room.Cookie)
-		conn.Close()
+		server.ListenBOS(cfg, sm, fm, cr, logger.With("svc", "BOS"))
+		wg.Done()
+	}()
+	go func() {
+		server.ListenChat(cfg, fm, cr, logger.With("svc", "CHAT"))
+		wg.Done()
+	}()
+	go func() {
+		server.ListenBUCPLogin(cfg, err, logger, sm, fm)
+		wg.Done()
 	}()
 	}()
 
 
-	ctx = context.WithValue(ctx, "screenName", chatSess.ScreenName)
-
-	server.ReadBos(ctx, cfg, chatSess, seq, room.SessionManager, fm, cr, conn, room, router, logger)
+	wg.Wait()
 }
 }

+ 39 - 0
oscar/snacs.go

@@ -31,6 +31,45 @@ const (
 	ARS           uint16 = 0x044A
 	ARS           uint16 = 0x044A
 )
 )
 
 
+//
+// General Error Codes
+//
+
+const (
+	ErrorCodeInvalidSnac          uint16 = 0x01
+	ErrorCodeRateToHost           uint16 = 0x02
+	ErrorCodeRateToClient         uint16 = 0x03
+	ErrorCodeNotLoggedOn          uint16 = 0x04
+	ErrorCodeServiceUnavailable   uint16 = 0x05
+	ErrorCodeServiceNotDefined    uint16 = 0x06
+	ErrorCodeObsoleteSnac         uint16 = 0x07
+	ErrorCodeNotSupportedByHost   uint16 = 0x08
+	ErrorCodeNotSupportedByClient uint16 = 0x09
+	ErrorCodeRefusedByClient      uint16 = 0x0A
+	ErrorCodeReplyTooBig          uint16 = 0x0B
+	ErrorCodeResponsesLost        uint16 = 0x0C
+	ErrorCodeRequestDenied        uint16 = 0x0D
+	ErrorCodeBustedSnacPayload    uint16 = 0x0E
+	ErrorCodeInsufficientRights   uint16 = 0x0F
+	ErrorCodeInLocalPermitDeny    uint16 = 0x10
+	ErrorCodeTooEvilSender        uint16 = 0x11
+	ErrorCodeTooEvilReceiver      uint16 = 0x12
+	ErrorCodeUserTempUnavail      uint16 = 0x13
+	ErrorCodeNoMatch              uint16 = 0x14
+	ErrorCodeListOverflow         uint16 = 0x15
+	ErrorCodeRequestAmbigous      uint16 = 0x16
+	ErrorCodeQueueFull            uint16 = 0x17
+	ErrorCodeNotWhileOnAol        uint16 = 0x18
+	ErrorCodeQueryFail            uint16 = 0x19
+	ErrorCodeTimeout              uint16 = 0x1A
+	ErrorCodeErrorText            uint16 = 0x1B
+	ErrorCodeGeneralFailure       uint16 = 0x1C
+	ErrorCodeProgress             uint16 = 0x1D
+	ErrorCodeInFreeArea           uint16 = 0x1E
+	ErrorCodeRestrictedByPc       uint16 = 0x1F
+	ErrorCodeRemoteRestrictedByPc uint16 = 0x20
+)
+
 //
 //
 // 0x01: OService
 // 0x01: OService
 //
 //

+ 79 - 0
server/bucp.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
 	"errors"
 	"errors"
+	"fmt"
 	"io"
 	"io"
 
 
 	"github.com/google/uuid"
 	"github.com/google/uuid"
@@ -153,3 +154,81 @@ func ReceiveAndSendBUCPLoginRequest(cfg Config, sm SessionManager, fm *FeedbagSt
 	}
 	}
 	return writeOutSNAC(snac, snacFrameOut, snacPayloadOut, sequence, w)
 	return writeOutSNAC(snac, snacFrameOut, snacPayloadOut, sequence, w)
 }
 }
+
+func SendAndReceiveSignonFrame(rw io.ReadWriter, sequence *uint32) (oscar.FlapSignonFrame, error) {
+	flapFrameOut := oscar.FlapFrame{
+		StartMarker:   42,
+		FrameType:     oscar.FlapFrameSignon,
+		Sequence:      uint16(*sequence),
+		PayloadLength: 4, // size of FlapSignonFrame
+	}
+	if err := oscar.Marshal(flapFrameOut, rw); err != nil {
+		return oscar.FlapSignonFrame{}, err
+	}
+	flapSignonFrameOut := oscar.FlapSignonFrame{
+		FlapVersion: 1,
+	}
+	if err := oscar.Marshal(flapSignonFrameOut, rw); err != nil {
+		return oscar.FlapSignonFrame{}, err
+	}
+
+	// receive
+	flapFrameIn := oscar.FlapFrame{}
+	if err := oscar.Unmarshal(&flapFrameIn, rw); err != nil {
+		return oscar.FlapSignonFrame{}, err
+	}
+	b := make([]byte, flapFrameIn.PayloadLength)
+	if _, err := rw.Read(b); err != nil {
+		return oscar.FlapSignonFrame{}, err
+	}
+	flapSignonFrameIn := oscar.FlapSignonFrame{}
+	if err := oscar.Unmarshal(&flapSignonFrameIn, bytes.NewBuffer(b)); err != nil {
+		return oscar.FlapSignonFrame{}, err
+	}
+
+	*sequence++
+
+	return flapSignonFrameIn, nil
+}
+
+func VerifyLogin(sm SessionManager, rw io.ReadWriter) (*Session, uint32, error) {
+	seq := uint32(100)
+
+	flap, err := SendAndReceiveSignonFrame(rw, &seq)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	var ok bool
+	ID, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return nil, 0, errors.New("unable to get session ID from payload")
+	}
+
+	sess, ok := sm.Retrieve(string(ID))
+	if !ok {
+		return nil, 0, fmt.Errorf("unable to find session by ID %s", ID)
+	}
+
+	return sess, seq, nil
+}
+
+func VerifyChatLogin(rw io.ReadWriter) (*ChatCookie, uint32, error) {
+	seq := uint32(100)
+
+	flap, err := SendAndReceiveSignonFrame(rw, &seq)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	var ok bool
+	buf, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return nil, 0, errors.New("unable to get session ID from payload")
+	}
+
+	cookie := ChatCookie{}
+	err = oscar.Unmarshal(&cookie, bytes.NewBuffer(buf))
+
+	return &cookie, seq, err
+}

+ 1 - 1
server/buddy_test.go

@@ -61,7 +61,7 @@ func TestBuddyRouter_RouteBuddy(t *testing.T) {
 			input: XMessage{
 			input: XMessage{
 				snacFrame: oscar.SnacFrame{
 				snacFrame: oscar.SnacFrame{
 					FoodGroup: oscar.BUDDY,
 					FoodGroup: oscar.BUDDY,
-					SubGroup:  ErrorCodeReplyTooBig,
+					SubGroup:  oscar.ErrorCodeReplyTooBig,
 				},
 				},
 				snacOut: struct{}{}, // empty SNAC
 				snacOut: struct{}{}, // empty SNAC
 			},
 			},

+ 18 - 0
server/config.go

@@ -0,0 +1,18 @@
+package server
+
+import "fmt"
+
+type Config struct {
+	BOSPort     int    `envconfig:"BOS_PORT" default:"5191"`
+	ChatPort    int    `envconfig:"CHAT_PORT" default:"5192"`
+	DBPath      string `envconfig:"DB_PATH" required:"true"`
+	DisableAuth bool   `envconfig:"DISABLE_AUTH" default:"false"`
+	FailFast    bool   `envconfig:"FAIL_FAST" default:"false"`
+	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true"`
+	OSCARPort   int    `envconfig:"OSCAR_PORT" default:"5190"`
+	LogLevel    string `envconfig:"LOG_LEVEL" default:"info"`
+}
+
+func Address(host string, port int) string {
+	return fmt.Sprintf("%s:%d", host, port)
+}

+ 311 - 0
server/connection.go

@@ -0,0 +1,311 @@
+package server
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"log/slog"
+	"net"
+	"os"
+
+	"github.com/google/uuid"
+	"github.com/mkaminski/goaim/oscar"
+)
+
+var (
+	CapChat, _ = uuid.MustParse("748F2420-6287-11D1-8222-444553540000").MarshalBinary()
+)
+
+var (
+	ErrUnsupportedFoodGroup = errors.New("unimplemented food group, your client version may be unsupported")
+	ErrUnsupportedSubGroup  = errors.New("unimplemented subgroup, your client version may be unsupported")
+)
+
+type IncomingMessage struct {
+	flap oscar.FlapFrame
+	snac oscar.SnacFrame
+	buf  io.Reader
+}
+
+type XMessage struct {
+	snacFrame oscar.SnacFrame
+	snacOut   any
+}
+
+func readIncomingRequests(ctx context.Context, logger *slog.Logger, rw io.Reader, msgCh chan IncomingMessage, errCh chan error) {
+	defer close(msgCh)
+	defer close(errCh)
+
+	for {
+		flap := oscar.FlapFrame{}
+		if err := oscar.Unmarshal(&flap, rw); err != nil {
+			errCh <- err
+			return
+		}
+
+		switch flap.FrameType {
+		case oscar.FlapFrameSignon:
+			errCh <- errors.New("shouldn't get FlapFrameSignon")
+			return
+		case oscar.FlapFrameData:
+			b := make([]byte, flap.PayloadLength)
+			if _, err := rw.Read(b); err != nil {
+				errCh <- err
+				return
+			}
+
+			snac := oscar.SnacFrame{}
+			buf := bytes.NewBuffer(b)
+			if err := oscar.Unmarshal(&snac, buf); err != nil {
+				errCh <- err
+				return
+			}
+
+			msgCh <- IncomingMessage{
+				flap: flap,
+				snac: snac,
+				buf:  buf,
+			}
+		case oscar.FlapFrameError:
+			errCh <- fmt.Errorf("got FlapFrameError: %v", flap)
+			return
+		case oscar.FlapFrameSignoff:
+			errCh <- ErrSignedOff
+			return
+		case oscar.FlapFrameKeepAlive:
+			logger.DebugContext(ctx, "keepalive heartbeat")
+		default:
+			errCh <- fmt.Errorf("unknown frame type: %v", flap)
+			return
+		}
+	}
+}
+
+func Signout(ctx context.Context, logger *slog.Logger, sess *Session, sm SessionManager, fm *FeedbagStore) {
+	if err := BroadcastDeparture(ctx, sess, sm, fm); err != nil {
+		logger.ErrorContext(ctx, "error notifying departure", "err", err.Error())
+	}
+	sm.Remove(sess)
+}
+
+func ReadBos(ctx context.Context, cfg Config, sess *Session, seq uint32, sm SessionManager, fm *FeedbagStore, cr *ChatRegistry, rwc io.ReadWriter, room ChatRoom, router Router, logger *slog.Logger) {
+	if err := router.WriteOServiceHostOnline(rwc, &seq); err != nil {
+		logger.ErrorContext(ctx, "error WriteOServiceHostOnline")
+	}
+
+	// buffered so that the go routine has room to exit
+	msgCh := make(chan IncomingMessage, 1)
+	errCh := make(chan error, 1)
+	go readIncomingRequests(ctx, logger, rwc, msgCh, errCh)
+
+	rl := RouteLogger{
+		Logger: logger,
+	}
+
+	for {
+		select {
+		case m := <-msgCh:
+			if err := router.routeIncomingRequests(ctx, cfg, sm, sess, fm, cr, rwc, &seq, m.snac, m.buf, room); err != nil {
+				if errors.Is(err, ErrUnsupportedSubGroup) || errors.Is(err, ErrUnsupportedFoodGroup) {
+					if err1 := sendInvalidSNACErr(m.snac, rwc, &seq); err1 != nil {
+						err = errors.Join(err1, err)
+					}
+					if cfg.FailFast {
+						panic(err.Error())
+					}
+				}
+				logRequestError(ctx, logger, m.snac, err)
+				return
+			}
+		case m := <-sess.RecvMessage():
+			if err := writeOutSNAC(oscar.SnacFrame{}, m.snacFrame, m.snacOut, &seq, rwc); err != nil {
+				logRequestError(ctx, logger, m.snacFrame, err)
+				return
+			}
+			rl.logRequest(ctx, m.snacFrame, m.snacOut)
+		case <-sess.Closed():
+			if err := gracefulDisconnect(seq, rwc); err != nil {
+				logger.ErrorContext(ctx, "unable to gracefully disconnect user", "err", err)
+			}
+			return
+		case err := <-errCh:
+			switch {
+			case errors.Is(io.EOF, err):
+				fallthrough
+			case errors.Is(ErrSignedOff, err):
+				logger.InfoContext(ctx, "client signed off")
+			default:
+				logger.ErrorContext(ctx, "client disconnected with error", "err", err)
+			}
+			return
+		}
+	}
+}
+
+func logRequestError(ctx context.Context, logger *slog.Logger, inFrame oscar.SnacFrame, err error) {
+	logger.LogAttrs(ctx, slog.LevelError, "client disconnected with error",
+		slog.Group("request",
+			slog.String("food_group", oscar.FoodGroupStr(inFrame.FoodGroup)),
+			slog.String("sub_group", oscar.SubGroupStr(inFrame.FoodGroup, inFrame.SubGroup)),
+		),
+		slog.String("err", err.Error()),
+	)
+}
+
+func gracefulDisconnect(seq uint32, rwc io.ReadWriter) error {
+	return oscar.Marshal(oscar.FlapFrame{
+		StartMarker: 42,
+		FrameType:   oscar.FlapFrameSignoff,
+		Sequence:    uint16(seq),
+	}, rwc)
+}
+
+func HandleChatConnection(ctx context.Context, cfg Config, fm *FeedbagStore, cr *ChatRegistry, conn net.Conn, router Router, logger *slog.Logger) {
+	cookie, seq, err := VerifyChatLogin(conn)
+	if err != nil {
+		logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
+		return
+	}
+
+	room, err := cr.Retrieve(string(cookie.Cookie))
+	if err != nil {
+		logger.ErrorContext(ctx, "unable to find chat room", "err", err.Error())
+		return
+	}
+
+	chatSess, found := room.Retrieve(cookie.SessID)
+	if !found {
+		logger.ErrorContext(ctx, "unable to find user for session", "sessID", cookie.SessID)
+		return
+	}
+
+	defer chatSess.Close()
+	go func() {
+		<-chatSess.Closed()
+		AlertUserLeft(ctx, chatSess, room)
+		room.Remove(chatSess)
+		cr.MaybeRemoveRoom(room.Cookie)
+		conn.Close()
+	}()
+
+	ctx = context.WithValue(ctx, "screenName", chatSess.ScreenName)
+
+	ReadBos(ctx, cfg, chatSess, seq, room.SessionManager, fm, cr, conn, room, router, logger)
+}
+
+func HandleAuthConnection(cfg Config, sm *InMemorySessionManager, fm *FeedbagStore, conn net.Conn) {
+	defer conn.Close()
+	seq := uint32(100)
+	_, err := SendAndReceiveSignonFrame(conn, &seq)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	err = ReceiveAndSendAuthChallenge(cfg, fm, conn, conn, &seq, uuid.New)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	err = ReceiveAndSendBUCPLoginRequest(cfg, sm, fm, conn, conn, &seq, uuid.New)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+}
+
+func HandleBOSConnection(ctx context.Context, cfg Config, sm *InMemorySessionManager, fm *FeedbagStore, cr *ChatRegistry, conn net.Conn, router Router, logger *slog.Logger) {
+	sess, seq, err := VerifyLogin(sm, conn)
+	if err != nil {
+		logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
+		return
+	}
+
+	defer sess.Close()
+	defer conn.Close()
+
+	go func() {
+		<-sess.Closed()
+		Signout(ctx, logger, sess, sm, fm)
+	}()
+
+	ctx = context.WithValue(ctx, "screenName", sess.ScreenName)
+
+	ReadBos(ctx, cfg, sess, seq, sm, fm, cr, conn, ChatRoom{}, router, logger)
+}
+
+func ListenChat(cfg Config, fm *FeedbagStore, cr *ChatRegistry, logger *slog.Logger) {
+	addr := Address("", cfg.ChatPort)
+	listener, err := net.Listen("tcp", addr)
+	if err != nil {
+		logger.Error("unable to bind chat server address", "err", err.Error())
+		os.Exit(1)
+	}
+	defer listener.Close()
+
+	logger.Info("starting service", "addr", addr)
+
+	router := NewRouterForChat(logger)
+	for {
+		conn, err := listener.Accept()
+		if err != nil {
+			log.Println(err)
+			continue
+		}
+		ctx := context.Background()
+		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
+		logger.DebugContext(ctx, "accepted connection")
+		go HandleChatConnection(ctx, cfg, fm, cr, conn, router, logger)
+	}
+}
+
+func ListenBOS(cfg Config, sm *InMemorySessionManager, fm *FeedbagStore, cr *ChatRegistry, logger *slog.Logger) {
+	addr := Address("", cfg.BOSPort)
+	listener, err := net.Listen("tcp", addr)
+	if err != nil {
+		logger.Error("unable to bind BOS server address", "err", err.Error())
+		os.Exit(1)
+	}
+	defer listener.Close()
+
+	logger.Info("starting service", "addr", addr)
+
+	router := NewRouter(logger)
+	for {
+		conn, err := listener.Accept()
+		if err != nil {
+			log.Println(err)
+			continue
+		}
+		ctx := context.Background()
+		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
+		logger.DebugContext(ctx, "accepted connection")
+		go HandleBOSConnection(ctx, cfg, sm, fm, cr, conn, router, logger)
+	}
+}
+
+func ListenBUCPLogin(cfg Config, err error, logger *slog.Logger, sm *InMemorySessionManager, fm *FeedbagStore) {
+	addr := Address("", cfg.OSCARPort)
+	listener, err := net.Listen("tcp", addr)
+	if err != nil {
+		logger.Error("unable to bind OSCAR server address", "err", err.Error())
+		os.Exit(1)
+	}
+	defer listener.Close()
+
+	logger.Info("starting OSCAR server", "addr", addr)
+
+	for {
+		conn, err := listener.Accept()
+		if err != nil {
+			log.Println(err)
+			continue
+		}
+
+		go HandleAuthConnection(cfg, sm, fm, conn)
+	}
+}

+ 1 - 1
server/feedbag.go

@@ -244,7 +244,7 @@ func (s FeedbagService) InsertItemHandler(ctx context.Context, sm SessionManager
 					SubGroup:  oscar.FeedbagErr,
 					SubGroup:  oscar.FeedbagErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotSupportedByHost,
+					Code: oscar.ErrorCodeNotSupportedByHost,
 				},
 				},
 			}, nil
 			}, nil
 		}
 		}

+ 1 - 1
server/feedbag_test.go

@@ -518,7 +518,7 @@ func TestInsertItemHandler(t *testing.T) {
 					SubGroup:  oscar.FeedbagErr,
 					SubGroup:  oscar.FeedbagErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotSupportedByHost,
+					Code: oscar.ErrorCodeNotSupportedByHost,
 				},
 				},
 			},
 			},
 		},
 		},

+ 5 - 5
server/icbm.go

@@ -111,9 +111,9 @@ func (s ICBMService) ChannelMsgToHostHandler(ctx context.Context, sm SessionMana
 	}
 	}
 
 
 	if blocked != BlockedNo {
 	if blocked != BlockedNo {
-		code := ErrorCodeNotLoggedOn
+		code := oscar.ErrorCodeNotLoggedOn
 		if blocked == BlockedA {
 		if blocked == BlockedA {
-			code = ErrorCodeInLocalPermitDeny
+			code = oscar.ErrorCodeInLocalPermitDeny
 		}
 		}
 		return &XMessage{
 		return &XMessage{
 			snacFrame: oscar.SnacFrame{
 			snacFrame: oscar.SnacFrame{
@@ -135,7 +135,7 @@ func (s ICBMService) ChannelMsgToHostHandler(ctx context.Context, sm SessionMana
 				SubGroup:  oscar.ICBMErr,
 				SubGroup:  oscar.ICBMErr,
 			},
 			},
 			snacOut: oscar.SnacError{
 			snacOut: oscar.SnacError{
-				Code: ErrorCodeNotLoggedOn,
+				Code: oscar.ErrorCodeNotLoggedOn,
 			},
 			},
 		}, nil
 		}, nil
 	case err != nil:
 	case err != nil:
@@ -225,7 +225,7 @@ func (s ICBMService) EvilRequestHandler(ctx context.Context, sm SessionManager,
 				SubGroup:  oscar.ICBMErr,
 				SubGroup:  oscar.ICBMErr,
 			},
 			},
 			snacOut: oscar.SnacError{
 			snacOut: oscar.SnacError{
-				Code: ErrorCodeNotSupportedByHost,
+				Code: oscar.ErrorCodeNotSupportedByHost,
 			},
 			},
 		}, nil
 		}, nil
 	}
 	}
@@ -241,7 +241,7 @@ func (s ICBMService) EvilRequestHandler(ctx context.Context, sm SessionManager,
 				SubGroup:  oscar.ICBMErr,
 				SubGroup:  oscar.ICBMErr,
 			},
 			},
 			snacOut: oscar.SnacError{
 			snacOut: oscar.SnacError{
-				Code: ErrorCodeNotLoggedOn,
+				Code: oscar.ErrorCodeNotLoggedOn,
 			},
 			},
 		}, nil
 		}, nil
 	}
 	}

+ 6 - 6
server/icbm_test.go

@@ -151,7 +151,7 @@ func TestSendAndReceiveChannelMsgTohost(t *testing.T) {
 					SubGroup:  oscar.ICBMErr,
 					SubGroup:  oscar.ICBMErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeInLocalPermitDeny,
+					Code: oscar.ErrorCodeInLocalPermitDeny,
 				},
 				},
 			},
 			},
 		},
 		},
@@ -183,7 +183,7 @@ func TestSendAndReceiveChannelMsgTohost(t *testing.T) {
 					SubGroup:  oscar.ICBMErr,
 					SubGroup:  oscar.ICBMErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotLoggedOn,
+					Code: oscar.ErrorCodeNotLoggedOn,
 				},
 				},
 			},
 			},
 		},
 		},
@@ -216,7 +216,7 @@ func TestSendAndReceiveChannelMsgTohost(t *testing.T) {
 					SubGroup:  oscar.ICBMErr,
 					SubGroup:  oscar.ICBMErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotLoggedOn,
+					Code: oscar.ErrorCodeNotLoggedOn,
 				},
 				},
 			},
 			},
 		},
 		},
@@ -471,7 +471,7 @@ func TestSendAndReceiveEvilRequest(t *testing.T) {
 					SubGroup:  oscar.ICBMErr,
 					SubGroup:  oscar.ICBMErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotLoggedOn,
+					Code: oscar.ErrorCodeNotLoggedOn,
 				},
 				},
 			},
 			},
 		},
 		},
@@ -493,7 +493,7 @@ func TestSendAndReceiveEvilRequest(t *testing.T) {
 					SubGroup:  oscar.ICBMErr,
 					SubGroup:  oscar.ICBMErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotLoggedOn,
+					Code: oscar.ErrorCodeNotLoggedOn,
 				},
 				},
 			},
 			},
 		},
 		},
@@ -513,7 +513,7 @@ func TestSendAndReceiveEvilRequest(t *testing.T) {
 					SubGroup:  oscar.ICBMErr,
 					SubGroup:  oscar.ICBMErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotSupportedByHost,
+					Code: oscar.ErrorCodeNotSupportedByHost,
 				},
 				},
 			},
 			},
 		},
 		},

+ 2 - 2
server/locate.go

@@ -133,7 +133,7 @@ func (s LocateService) UserInfoQuery2Handler(ctx context.Context, sess *Session,
 				SubGroup:  oscar.LocateErr,
 				SubGroup:  oscar.LocateErr,
 			},
 			},
 			snacOut: oscar.SnacError{
 			snacOut: oscar.SnacError{
-				Code: ErrorCodeNotLoggedOn,
+				Code: oscar.ErrorCodeNotLoggedOn,
 			},
 			},
 		}, nil
 		}, nil
 	}
 	}
@@ -147,7 +147,7 @@ func (s LocateService) UserInfoQuery2Handler(ctx context.Context, sess *Session,
 				SubGroup:  oscar.LocateErr,
 				SubGroup:  oscar.LocateErr,
 			},
 			},
 			snacOut: oscar.SnacError{
 			snacOut: oscar.SnacError{
-				Code: ErrorCodeNotLoggedOn,
+				Code: oscar.ErrorCodeNotLoggedOn,
 			},
 			},
 		}, nil
 		}, nil
 	case err != nil:
 	case err != nil:

+ 2 - 2
server/locate_test.go

@@ -229,7 +229,7 @@ func TestSendAndReceiveUserInfoQuery2(t *testing.T) {
 					SubGroup:  oscar.LocateErr,
 					SubGroup:  oscar.LocateErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotLoggedOn,
+					Code: oscar.ErrorCodeNotLoggedOn,
 				},
 				},
 			},
 			},
 		},
 		},
@@ -256,7 +256,7 @@ func TestSendAndReceiveUserInfoQuery2(t *testing.T) {
 					SubGroup:  oscar.LocateErr,
 					SubGroup:  oscar.LocateErr,
 				},
 				},
 				snacOut: oscar.SnacError{
 				snacOut: oscar.SnacError{
-					Code: ErrorCodeNotLoggedOn,
+					Code: oscar.ErrorCodeNotLoggedOn,
 				},
 				},
 			},
 			},
 		},
 		},

+ 0 - 399
server/protocol.go

@@ -1,399 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"github.com/google/uuid"
-	"github.com/mkaminski/goaim/oscar"
-	"io"
-	"log/slog"
-)
-
-const (
-	ErrorCodeInvalidSnac          uint16 = 0x01
-	ErrorCodeRateToHost           uint16 = 0x02
-	ErrorCodeRateToClient         uint16 = 0x03
-	ErrorCodeNotLoggedOn          uint16 = 0x04
-	ErrorCodeServiceUnavailable   uint16 = 0x05
-	ErrorCodeServiceNotDefined    uint16 = 0x06
-	ErrorCodeObsoleteSnac         uint16 = 0x07
-	ErrorCodeNotSupportedByHost   uint16 = 0x08
-	ErrorCodeNotSupportedByClient uint16 = 0x09
-	ErrorCodeRefusedByClient      uint16 = 0x0A
-	ErrorCodeReplyTooBig          uint16 = 0x0B
-	ErrorCodeResponsesLost        uint16 = 0x0C
-	ErrorCodeRequestDenied        uint16 = 0x0D
-	ErrorCodeBustedSnacPayload    uint16 = 0x0E
-	ErrorCodeInsufficientRights   uint16 = 0x0F
-	ErrorCodeInLocalPermitDeny    uint16 = 0x10
-	ErrorCodeTooEvilSender        uint16 = 0x11
-	ErrorCodeTooEvilReceiver      uint16 = 0x12
-	ErrorCodeUserTempUnavail      uint16 = 0x13
-	ErrorCodeNoMatch              uint16 = 0x14
-	ErrorCodeListOverflow         uint16 = 0x15
-	ErrorCodeRequestAmbigous      uint16 = 0x16
-	ErrorCodeQueueFull            uint16 = 0x17
-	ErrorCodeNotWhileOnAol        uint16 = 0x18
-	ErrorCodeQueryFail            uint16 = 0x19
-	ErrorCodeTimeout              uint16 = 0x1A
-	ErrorCodeErrorText            uint16 = 0x1B
-	ErrorCodeGeneralFailure       uint16 = 0x1C
-	ErrorCodeProgress             uint16 = 0x1D
-	ErrorCodeInFreeArea           uint16 = 0x1E
-	ErrorCodeRestrictedByPc       uint16 = 0x1F
-	ErrorCodeRemoteRestrictedByPc uint16 = 0x20
-)
-
-const (
-	ErrorTagsFailUrl        = 0x04
-	ErrorTagsErrorSubcode   = 0x08
-	ErrorTagsErrorText      = 0x1B
-	ErrorTagsErrorInfoClsid = 0x29
-	ErrorTagsErrorInfoData  = 0x2A
-)
-
-var (
-	CapChat, _ = uuid.MustParse("748F2420-6287-11D1-8222-444553540000").MarshalBinary()
-)
-
-var (
-	ErrUnsupportedFoodGroup = errors.New("unimplemented food group, your client version may be unsupported")
-	ErrUnsupportedSubGroup  = errors.New("unimplemented subgroup, your client version may be unsupported")
-)
-
-type Config struct {
-	BOSPort     int    `envconfig:"BOS_PORT" default:"5191"`
-	ChatPort    int    `envconfig:"CHAT_PORT" default:"5192"`
-	DBPath      string `envconfig:"DB_PATH" required:"true"`
-	DisableAuth bool   `envconfig:"DISABLE_AUTH" default:"false"`
-	FailFast    bool   `envconfig:"FAIL_FAST" default:"false"`
-	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true"`
-	OSCARPort   int    `envconfig:"OSCAR_PORT" default:"5190"`
-	LogLevel    string `envconfig:"LOG_LEVEL" default:"info"`
-}
-
-func Address(host string, port int) string {
-	return fmt.Sprintf("%s:%d", host, port)
-}
-
-func SendAndReceiveSignonFrame(rw io.ReadWriter, sequence *uint32) (oscar.FlapSignonFrame, error) {
-	flapFrameOut := oscar.FlapFrame{
-		StartMarker:   42,
-		FrameType:     oscar.FlapFrameSignon,
-		Sequence:      uint16(*sequence),
-		PayloadLength: 4, // size of FlapSignonFrame
-	}
-	if err := oscar.Marshal(flapFrameOut, rw); err != nil {
-		return oscar.FlapSignonFrame{}, err
-	}
-	flapSignonFrameOut := oscar.FlapSignonFrame{
-		FlapVersion: 1,
-	}
-	if err := oscar.Marshal(flapSignonFrameOut, rw); err != nil {
-		return oscar.FlapSignonFrame{}, err
-	}
-
-	// receive
-	flapFrameIn := oscar.FlapFrame{}
-	if err := oscar.Unmarshal(&flapFrameIn, rw); err != nil {
-		return oscar.FlapSignonFrame{}, err
-	}
-	b := make([]byte, flapFrameIn.PayloadLength)
-	if _, err := rw.Read(b); err != nil {
-		return oscar.FlapSignonFrame{}, err
-	}
-	flapSignonFrameIn := oscar.FlapSignonFrame{}
-	if err := oscar.Unmarshal(&flapSignonFrameIn, bytes.NewBuffer(b)); err != nil {
-		return oscar.FlapSignonFrame{}, err
-	}
-
-	*sequence++
-
-	return flapSignonFrameIn, nil
-}
-
-func VerifyLogin(sm SessionManager, rw io.ReadWriter) (*Session, uint32, error) {
-	seq := uint32(100)
-
-	flap, err := SendAndReceiveSignonFrame(rw, &seq)
-	if err != nil {
-		return nil, 0, err
-	}
-
-	var ok bool
-	ID, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return nil, 0, errors.New("unable to get session ID from payload")
-	}
-
-	sess, ok := sm.Retrieve(string(ID))
-	if !ok {
-		return nil, 0, fmt.Errorf("unable to find session by ID %s", ID)
-	}
-
-	return sess, seq, nil
-}
-
-func VerifyChatLogin(rw io.ReadWriter) (*ChatCookie, uint32, error) {
-	seq := uint32(100)
-
-	flap, err := SendAndReceiveSignonFrame(rw, &seq)
-	if err != nil {
-		return nil, 0, err
-	}
-
-	var ok bool
-	buf, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return nil, 0, errors.New("unable to get session ID from payload")
-	}
-
-	cookie := ChatCookie{}
-	err = oscar.Unmarshal(&cookie, bytes.NewBuffer(buf))
-
-	return &cookie, seq, err
-}
-
-type IncomingMessage struct {
-	flap oscar.FlapFrame
-	snac oscar.SnacFrame
-	buf  io.Reader
-}
-
-type XMessage struct {
-	snacFrame oscar.SnacFrame
-	snacOut   any
-}
-
-func sendInvalidSNACErr(snac oscar.SnacFrame, w io.Writer, sequence *uint32) error {
-	snacFrameOut := oscar.SnacFrame{
-		FoodGroup: snac.FoodGroup,
-		SubGroup:  0x01, // error subgroup for all SNACs
-	}
-	snacPayloadOut := oscar.SnacError{
-		Code: ErrorCodeInvalidSnac,
-	}
-	return writeOutSNAC(snac, snacFrameOut, snacPayloadOut, sequence, w)
-}
-
-func readIncomingRequests(ctx context.Context, logger *slog.Logger, rw io.Reader, msgCh chan IncomingMessage, errCh chan error) {
-	defer close(msgCh)
-	defer close(errCh)
-
-	for {
-		flap := oscar.FlapFrame{}
-		if err := oscar.Unmarshal(&flap, rw); err != nil {
-			errCh <- err
-			return
-		}
-
-		switch flap.FrameType {
-		case oscar.FlapFrameSignon:
-			errCh <- errors.New("shouldn't get FlapFrameSignon")
-			return
-		case oscar.FlapFrameData:
-			b := make([]byte, flap.PayloadLength)
-			if _, err := rw.Read(b); err != nil {
-				errCh <- err
-				return
-			}
-
-			snac := oscar.SnacFrame{}
-			buf := bytes.NewBuffer(b)
-			if err := oscar.Unmarshal(&snac, buf); err != nil {
-				errCh <- err
-				return
-			}
-
-			msgCh <- IncomingMessage{
-				flap: flap,
-				snac: snac,
-				buf:  buf,
-			}
-		case oscar.FlapFrameError:
-			errCh <- fmt.Errorf("got FlapFrameError: %v", flap)
-			return
-		case oscar.FlapFrameSignoff:
-			errCh <- ErrSignedOff
-			return
-		case oscar.FlapFrameKeepAlive:
-			logger.DebugContext(ctx, "keepalive heartbeat")
-		default:
-			errCh <- fmt.Errorf("unknown frame type: %v", flap)
-			return
-		}
-	}
-}
-
-func Signout(ctx context.Context, logger *slog.Logger, sess *Session, sm SessionManager, fm *FeedbagStore) {
-	if err := BroadcastDeparture(ctx, sess, sm, fm); err != nil {
-		logger.ErrorContext(ctx, "error notifying departure", "err", err.Error())
-	}
-	sm.Remove(sess)
-}
-
-func ReadBos(ctx context.Context, cfg Config, sess *Session, seq uint32, sm SessionManager, fm *FeedbagStore, cr *ChatRegistry, rwc io.ReadWriter, room ChatRoom, router Router, logger *slog.Logger) {
-	if err := router.WriteOServiceHostOnline(rwc, &seq); err != nil {
-		logger.ErrorContext(ctx, "error WriteOServiceHostOnline")
-	}
-
-	// buffered so that the go routine has room to exit
-	msgCh := make(chan IncomingMessage, 1)
-	errCh := make(chan error, 1)
-	go readIncomingRequests(ctx, logger, rwc, msgCh, errCh)
-
-	rl := RouteLogger{
-		Logger: logger,
-	}
-
-	for {
-		select {
-		case m := <-msgCh:
-			if err := router.routeIncomingRequests(ctx, cfg, sm, sess, fm, cr, rwc, &seq, m.snac, m.buf, room); err != nil {
-				if errors.Is(err, ErrUnsupportedSubGroup) || errors.Is(err, ErrUnsupportedFoodGroup) {
-					if err1 := sendInvalidSNACErr(m.snac, rwc, &seq); err1 != nil {
-						err = errors.Join(err1, err)
-					}
-					if cfg.FailFast {
-						panic(err.Error())
-					}
-				}
-				logRequestError(ctx, logger, m.snac, err)
-				return
-			}
-		case m := <-sess.RecvMessage():
-			if err := writeOutSNAC(oscar.SnacFrame{}, m.snacFrame, m.snacOut, &seq, rwc); err != nil {
-				logRequestError(ctx, logger, m.snacFrame, err)
-				return
-			}
-			rl.logRequest(ctx, m.snacFrame, m.snacOut)
-		case <-sess.Closed():
-			if err := gracefulDisconnect(seq, rwc); err != nil {
-				logger.ErrorContext(ctx, "unable to gracefully disconnect user", "err", err)
-			}
-			return
-		case err := <-errCh:
-			switch {
-			case errors.Is(io.EOF, err):
-				fallthrough
-			case errors.Is(ErrSignedOff, err):
-				logger.InfoContext(ctx, "client signed off")
-			default:
-				logger.ErrorContext(ctx, "client disconnected with error", "err", err)
-			}
-			return
-		}
-	}
-}
-
-func logRequestError(ctx context.Context, logger *slog.Logger, inFrame oscar.SnacFrame, err error) {
-	logger.LogAttrs(ctx, slog.LevelError, "client disconnected with error",
-		slog.Group("request",
-			slog.String("food_group", oscar.FoodGroupStr(inFrame.FoodGroup)),
-			slog.String("sub_group", oscar.SubGroupStr(inFrame.FoodGroup, inFrame.SubGroup)),
-		),
-		slog.String("err", err.Error()),
-	)
-}
-
-func gracefulDisconnect(seq uint32, rwc io.ReadWriter) error {
-	return oscar.Marshal(oscar.FlapFrame{
-		StartMarker: 42,
-		FrameType:   oscar.FlapFrameSignoff,
-		Sequence:    uint16(seq),
-	}, rwc)
-}
-
-func NewRouter(logger *slog.Logger) Router {
-	return Router{
-		AlertRouter:    NewAlertRouter(logger),
-		BuddyRouter:    NewBuddyRouter(logger),
-		ChatNavRouter:  NewChatNavRouter(logger),
-		ChatRouter:     NewChatRouter(logger),
-		FeedbagRouter:  NewFeedbagRouter(logger),
-		ICBMRouter:     NewICBMRouter(logger),
-		LocateRouter:   NewLocateRouter(logger),
-		OServiceRouter: NewOServiceRouter(logger),
-	}
-}
-
-func NewRouterForChat(logger *slog.Logger) Router {
-	r := NewRouter(logger)
-	r.OServiceRouter = NewOServiceRouterForChat(logger)
-	return r
-}
-
-type Router struct {
-	AlertRouter
-	BuddyRouter
-	ChatNavRouter
-	ChatRouter
-	FeedbagRouter
-	ICBMRouter
-	LocateRouter
-	OServiceRouter
-}
-
-func (rt *Router) routeIncomingRequests(ctx context.Context, cfg Config, sm SessionManager, sess *Session, fm *FeedbagStore, cr *ChatRegistry, rw io.ReadWriter, sequence *uint32, snac oscar.SnacFrame, buf io.Reader, room ChatRoom) error {
-	switch snac.FoodGroup {
-	case oscar.OSERVICE:
-		return rt.RouteOService(ctx, cfg, cr, sm, fm, sess, room, snac, buf, rw, sequence)
-	case oscar.LOCATE:
-		return rt.RouteLocate(ctx, sess, sm, fm, snac, buf, rw, sequence)
-	case oscar.BUDDY:
-		return rt.RouteBuddy(ctx, snac, buf, rw, sequence)
-	case oscar.ICBM:
-		return rt.RouteICBM(ctx, sm, fm, sess, snac, buf, rw, sequence)
-	case oscar.CHAT_NAV:
-		return rt.RouteChatNav(ctx, sess, cr, snac, buf, rw, sequence)
-	case oscar.FEEDBAG:
-		return rt.RouteFeedbag(ctx, sm, sess, fm, snac, buf, rw, sequence)
-	case oscar.BUCP:
-		return routeBUCP(ctx)
-	case oscar.CHAT:
-		return rt.RouteChat(ctx, sess, sm, snac, buf, rw, sequence)
-	case oscar.ALERT:
-		return rt.RouteAlert(ctx, snac)
-	default:
-		return ErrUnsupportedFoodGroup
-	}
-}
-
-func writeOutSNAC(originsnac oscar.SnacFrame, snacFrame oscar.SnacFrame, snacOut any, sequence *uint32, w io.Writer) error {
-	if originsnac.RequestID != 0 {
-		snacFrame.RequestID = originsnac.RequestID
-	}
-
-	snacBuf := &bytes.Buffer{}
-	if err := oscar.Marshal(snacFrame, snacBuf); err != nil {
-		return err
-	}
-	if err := oscar.Marshal(snacOut, snacBuf); err != nil {
-		return err
-	}
-
-	flap := oscar.FlapFrame{
-		StartMarker:   42,
-		FrameType:     oscar.FlapFrameData,
-		Sequence:      uint16(*sequence),
-		PayloadLength: uint16(snacBuf.Len()),
-	}
-
-	if err := oscar.Marshal(flap, w); err != nil {
-		return err
-	}
-
-	expectLen := snacBuf.Len()
-	c, err := w.Write(snacBuf.Bytes())
-	if err != nil {
-		return err
-	}
-	if c != expectLen {
-		panic("did not write the expected # of bytes")
-	}
-
-	*sequence++
-	return nil
-}

+ 112 - 0
server/router.go

@@ -0,0 +1,112 @@
+package server
+
+import (
+	"bytes"
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"io"
+	"log/slog"
+)
+
+func NewRouter(logger *slog.Logger) Router {
+	return Router{
+		AlertRouter:    NewAlertRouter(logger),
+		BuddyRouter:    NewBuddyRouter(logger),
+		ChatNavRouter:  NewChatNavRouter(logger),
+		ChatRouter:     NewChatRouter(logger),
+		FeedbagRouter:  NewFeedbagRouter(logger),
+		ICBMRouter:     NewICBMRouter(logger),
+		LocateRouter:   NewLocateRouter(logger),
+		OServiceRouter: NewOServiceRouter(logger),
+	}
+}
+
+func NewRouterForChat(logger *slog.Logger) Router {
+	r := NewRouter(logger)
+	r.OServiceRouter = NewOServiceRouterForChat(logger)
+	return r
+}
+
+type Router struct {
+	AlertRouter
+	BuddyRouter
+	ChatNavRouter
+	ChatRouter
+	FeedbagRouter
+	ICBMRouter
+	LocateRouter
+	OServiceRouter
+}
+
+func (rt *Router) routeIncomingRequests(ctx context.Context, cfg Config, sm SessionManager, sess *Session, fm *FeedbagStore, cr *ChatRegistry, rw io.ReadWriter, sequence *uint32, snac oscar.SnacFrame, buf io.Reader, room ChatRoom) error {
+	switch snac.FoodGroup {
+	case oscar.OSERVICE:
+		return rt.RouteOService(ctx, cfg, cr, sm, fm, sess, room, snac, buf, rw, sequence)
+	case oscar.LOCATE:
+		return rt.RouteLocate(ctx, sess, sm, fm, snac, buf, rw, sequence)
+	case oscar.BUDDY:
+		return rt.RouteBuddy(ctx, snac, buf, rw, sequence)
+	case oscar.ICBM:
+		return rt.RouteICBM(ctx, sm, fm, sess, snac, buf, rw, sequence)
+	case oscar.CHAT_NAV:
+		return rt.RouteChatNav(ctx, sess, cr, snac, buf, rw, sequence)
+	case oscar.FEEDBAG:
+		return rt.RouteFeedbag(ctx, sm, sess, fm, snac, buf, rw, sequence)
+	case oscar.BUCP:
+		return routeBUCP(ctx)
+	case oscar.CHAT:
+		return rt.RouteChat(ctx, sess, sm, snac, buf, rw, sequence)
+	case oscar.ALERT:
+		return rt.RouteAlert(ctx, snac)
+	default:
+		return ErrUnsupportedFoodGroup
+	}
+}
+
+func writeOutSNAC(originsnac oscar.SnacFrame, snacFrame oscar.SnacFrame, snacOut any, sequence *uint32, w io.Writer) error {
+	if originsnac.RequestID != 0 {
+		snacFrame.RequestID = originsnac.RequestID
+	}
+
+	snacBuf := &bytes.Buffer{}
+	if err := oscar.Marshal(snacFrame, snacBuf); err != nil {
+		return err
+	}
+	if err := oscar.Marshal(snacOut, snacBuf); err != nil {
+		return err
+	}
+
+	flap := oscar.FlapFrame{
+		StartMarker:   42,
+		FrameType:     oscar.FlapFrameData,
+		Sequence:      uint16(*sequence),
+		PayloadLength: uint16(snacBuf.Len()),
+	}
+
+	if err := oscar.Marshal(flap, w); err != nil {
+		return err
+	}
+
+	expectLen := snacBuf.Len()
+	c, err := w.Write(snacBuf.Bytes())
+	if err != nil {
+		return err
+	}
+	if c != expectLen {
+		panic("did not write the expected # of bytes")
+	}
+
+	*sequence++
+	return nil
+}
+
+func sendInvalidSNACErr(snac oscar.SnacFrame, w io.Writer, sequence *uint32) error {
+	snacFrameOut := oscar.SnacFrame{
+		FoodGroup: snac.FoodGroup,
+		SubGroup:  0x01, // error subgroup for all SNACs
+	}
+	snacPayloadOut := oscar.SnacError{
+		Code: oscar.ErrorCodeInvalidSnac,
+	}
+	return writeOutSNAC(snac, snacFrameOut, snacPayloadOut, sequence, w)
+}