소스 검색

implement SNAC class rate limiting

wip

so far

wip

rate limit works

icbm rate limiting works

replenish rate limits over time

subscribe to rate param changes

so far

only clear alert when clear threshold is met

correctly replenish rate limits

works so far

simplify and unit test rate replenishment

fix client disconnect

wrap rate group info with iterator

more iterator stuff

refactoring

dependency injection for rate limit params

fixing java client

add rate limiter at server level

fix npe

bring rate limits into line with iserverd values

store client foodgroup version in session

add ip-based rate limiting to auth server

rate limit auth

broadcast rate limit class changes

broadcat rate limit classes upon subscription (if needed)

rate limit TOC client requests

fix TOC tests

fix more tests

fix remaining unit tests

fix connection tests

document and test wire package

test session EvaluateRateLimit, ObserveRateChanges

more session unit testing

test OServiceService.RateLimitUpdates

unit test RateLimitUpdates

unit test RateParamsSubAddA

more testing

more testing

more testing
Mike 1 년 전
부모
커밋
5c000238ab
97개의 변경된 파일2673개의 추가작업 그리고 2092개의 파일을 삭제
  1. 3 0
      .mockery.yaml
  2. 82 26
      cmd/server/factory.go
  3. 8 0
      foodgroup/auth.go
  4. 6 6
      foodgroup/auth_test.go
  5. 6 0
      foodgroup/helpers_test.go
  6. 3 0
      foodgroup/icbm.go
  7. 2 2
      foodgroup/icbm_test.go
  8. 1 1
      foodgroup/mock_account_manager_test.go
  9. 1 1
      foodgroup/mock_buddy_broadcaster_test.go
  10. 1 1
      foodgroup/mock_buddy_icon_manager_test.go
  11. 1 1
      foodgroup/mock_chat_message_relayer_test.go
  12. 1 1
      foodgroup/mock_chat_room_registry_test.go
  13. 1 1
      foodgroup/mock_chat_session_registry_test.go
  14. 1 1
      foodgroup/mock_client_side_buddy_list_manager_test.go
  15. 1 1
      foodgroup/mock_cookie_baker_test.go
  16. 1 1
      foodgroup/mock_feedbag_manager_test.go
  17. 1 1
      foodgroup/mock_icq_user_finder_test.go
  18. 1 1
      foodgroup/mock_icq_user_updater_test.go
  19. 1 1
      foodgroup/mock_message_relayer_test.go
  20. 1 1
      foodgroup/mock_offline_message_manager_test.go
  21. 1 1
      foodgroup/mock_profile_manager_test.go
  22. 1 1
      foodgroup/mock_relationship_fetcher_test.go
  23. 1 1
      foodgroup/mock_session_registry_test.go
  24. 1 1
      foodgroup/mock_session_retriever_test.go
  25. 1 1
      foodgroup/mock_user_manager_manager_test.go
  26. 250 418
      foodgroup/oservice.go
  27. 253 952
      foodgroup/oservice_test.go
  28. 3 1
      go.mod
  29. 4 0
      go.sum
  30. 1 1
      server/http/mock_account_manager_test.go
  31. 1 1
      server/http/mock_buddy_icon_retriever_test.go
  32. 1 1
      server/http/mock_chat_room_creator_test.go
  33. 1 1
      server/http/mock_chat_room_retriever_test.go
  34. 1 1
      server/http/mock_chat_session_retriever_test.go
  35. 1 1
      server/http/mock_directory_manager_test.go
  36. 1 1
      server/http/mock_feedbag_retriever_test.go
  37. 1 1
      server/http/mock_message_relayer_test.go
  38. 1 1
      server/http/mock_profile_retriever_test.go
  39. 1 1
      server/http/mock_session_retriever_test.go
  40. 1 1
      server/http/mock_user_manager_test.go
  41. 36 2
      server/oscar/admin.go
  42. 109 3
      server/oscar/auth.go
  43. 33 16
      server/oscar/auth_test.go
  44. 8 1
      server/oscar/bos.go
  45. 3 1
      server/oscar/chat.go
  46. 24 1
      server/oscar/connection.go
  47. 60 5
      server/oscar/connection_test.go
  48. 1 1
      server/oscar/handler/mock_admin_service_test.go
  49. 1 1
      server/oscar/handler/mock_bart_service_test.go
  50. 1 1
      server/oscar/handler/mock_buddy_service_test.go
  51. 1 1
      server/oscar/handler/mock_chat_nav_service_test.go
  52. 1 1
      server/oscar/handler/mock_chat_service_test.go
  53. 1 1
      server/oscar/handler/mock_feedbag_service_test.go
  54. 1 1
      server/oscar/handler/mock_icbm_service_test.go
  55. 1 1
      server/oscar/handler/mock_icq_service_test.go
  56. 1 1
      server/oscar/handler/mock_locate_service_test.go
  57. 1 1
      server/oscar/handler/mock_odir_service_test.go
  58. 22 20
      server/oscar/handler/mock_oservice_service_test.go
  59. 1 1
      server/oscar/handler/mock_permit_deny_service_test.go
  60. 1 1
      server/oscar/handler/mock_response_writer_test.go
  61. 1 1
      server/oscar/handler/mock_user_lookup_service_test.go
  62. 7 7
      server/oscar/handler/oservice.go
  63. 10 11
      server/oscar/handler/oservice_test.go
  64. 0 174
      server/oscar/mock_BuddyListRegistry.go
  65. 0 68
      server/oscar/mock_ChatSessionManager.go
  66. 0 84
      server/oscar/mock_DepartureNotifier.go
  67. 0 91
      server/oscar/mock_HandlerFunc.go
  68. 0 93
      server/oscar/mock_SessionManager.go
  69. 1 1
      server/oscar/mock_auth_test.go
  70. 1 1
      server/oscar/mock_handler_test.go
  71. 1 1
      server/oscar/mock_online_notifier_test.go
  72. 91 0
      server/oscar/mock_rate_limit_updater_test.go
  73. 140 0
      server/toc/cmd_client.go
  74. 21 2
      server/toc/cmd_client_test.go
  75. 3 2
      server/toc/cmd_server.go
  76. 48 0
      server/toc/helpers_test.go
  77. 21 3
      server/toc/http.go
  78. 9 4
      server/toc/http_test.go
  79. 1 1
      server/toc/mock_admin_service_test.go
  80. 1 1
      server/toc/mock_auth_service_test.go
  81. 1 1
      server/toc/mock_buddy_list_registry_test.go
  82. 1 1
      server/toc/mock_buddy_service_test.go
  83. 1 1
      server/toc/mock_chat_nav_service_test.go
  84. 1 1
      server/toc/mock_chat_service_test.go
  85. 1 1
      server/toc/mock_cookie_baker_test.go
  86. 1 1
      server/toc/mock_dir_search_service_test.go
  87. 1 1
      server/toc/mock_icbm_service_test.go
  88. 1 1
      server/toc/mock_locate_service_test.go
  89. 1 1
      server/toc/mock_oservice_service_test.go
  90. 1 1
      server/toc/mock_permit_deny_service_test.go
  91. 1 1
      server/toc/mock_toc_config_store_test.go
  92. 49 3
      server/toc/server.go
  93. 191 21
      state/session.go
  94. 223 0
      state/session_test.go
  95. 529 0
      wire/rate_limit.go
  96. 293 0
      wire/rate_limit_test.go
  97. 65 17
      wire/snacs.go

+ 3 - 0
.mockery.yaml

@@ -23,6 +23,9 @@ packages:
       UserManager:
       UserManager:
         config:
         config:
           filename: "mock_user_manager_test.go"
           filename: "mock_user_manager_test.go"
+      RateLimitUpdater:
+        config:
+          filename: "mock_rate_limit_updater_test.go"
   github.com/mk6i/retro-aim-server/server/http:
   github.com/mk6i/retro-aim-server/server/http:
     interfaces:
     interfaces:
       AccountManager:
       AccountManager:

+ 82 - 26
cmd/server/factory.go

@@ -5,8 +5,10 @@ import (
 	"fmt"
 	"fmt"
 	"log/slog"
 	"log/slog"
 	"net"
 	"net"
+	"time"
 
 
 	"github.com/kelseyhightower/envconfig"
 	"github.com/kelseyhightower/envconfig"
+	"golang.org/x/time/rate"
 
 
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/foodgroup"
 	"github.com/mk6i/retro-aim-server/foodgroup"
@@ -16,6 +18,7 @@ import (
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/server/toc"
 	"github.com/mk6i/retro-aim-server/server/toc"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
 )
 )
 
 
 // Container groups together common dependencies.
 // Container groups together common dependencies.
@@ -25,6 +28,8 @@ type Container struct {
 	hmacCookieBaker        state.HMACCookieBaker
 	hmacCookieBaker        state.HMACCookieBaker
 	inMemorySessionManager *state.InMemorySessionManager
 	inMemorySessionManager *state.InMemorySessionManager
 	logger                 *slog.Logger
 	logger                 *slog.Logger
+	rateLimitClasses       wire.RateLimitClasses
+	snacRateLimits         wire.SNACRateLimits
 	sqLiteUserStore        *state.SQLiteUserStore
 	sqLiteUserStore        *state.SQLiteUserStore
 }
 }
 
 
@@ -56,7 +61,8 @@ func MakeCommonDeps() (Container, error) {
 	c.logger = middleware.NewLogger(c.cfg)
 	c.logger = middleware.NewLogger(c.cfg)
 	c.inMemorySessionManager = state.NewInMemorySessionManager(c.logger)
 	c.inMemorySessionManager = state.NewInMemorySessionManager(c.logger)
 	c.chatSessionManager = state.NewInMemoryChatSessionManager(c.logger)
 	c.chatSessionManager = state.NewInMemoryChatSessionManager(c.logger)
-
+	c.rateLimitClasses = wire.DefaultRateLimitClasses()
+	c.snacRateLimits = wire.DefaultSNACRateLimits()
 	return c, nil
 	return c, nil
 }
 }
 
 
@@ -81,6 +87,7 @@ func Admin(deps Container) oscar.AdminServer {
 		deps.chatSessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 		deps.inMemorySessionManager,
+		deps.rateLimitClasses,
 	)
 	)
 	oServiceService := foodgroup.NewOServiceServiceForAdmin(
 	oServiceService := foodgroup.NewOServiceServiceForAdmin(
 		deps.cfg,
 		deps.cfg,
@@ -89,6 +96,8 @@ func Admin(deps Container) oscar.AdminServer {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.rateLimitClasses,
+		deps.snacRateLimits,
 	)
 	)
 
 
 	return oscar.AdminServer{
 	return oscar.AdminServer{
@@ -98,9 +107,11 @@ func Admin(deps Container) oscar.AdminServer {
 			AdminHandler:    handler.NewAdminHandler(logger, adminService),
 			AdminHandler:    handler.NewAdminHandler(logger, adminService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 		}),
 		}),
-		Logger:         logger,
-		OnlineNotifier: oServiceService,
-		ListenAddr:     net.JoinHostPort("", deps.cfg.AdminPort),
+		Logger:           logger,
+		OnlineNotifier:   oServiceService,
+		ListenAddr:       net.JoinHostPort("", deps.cfg.AdminPort),
+		RateLimitUpdater: oServiceService,
+		SNACRateLimits:   deps.snacRateLimits,
 	}
 	}
 }
 }
 
 
@@ -118,6 +129,7 @@ func Alert(deps Container) oscar.BOSServer {
 		deps.chatSessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		nil,
 		nil,
+		deps.rateLimitClasses,
 	)
 	)
 	oServiceService := foodgroup.NewOServiceServiceForAlert(
 	oServiceService := foodgroup.NewOServiceServiceForAlert(
 		deps.cfg,
 		deps.cfg,
@@ -126,6 +138,8 @@ func Alert(deps Container) oscar.BOSServer {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		sessionManager,
 		sessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.rateLimitClasses,
+		deps.snacRateLimits,
 	)
 	)
 
 
 	return oscar.BOSServer{
 	return oscar.BOSServer{
@@ -135,9 +149,11 @@ func Alert(deps Container) oscar.BOSServer {
 			AlertHandler:    handler.NewAlertHandler(logger),
 			AlertHandler:    handler.NewAlertHandler(logger),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 		}),
 		}),
-		Logger:         logger,
-		OnlineNotifier: oServiceService,
-		ListenAddr:     net.JoinHostPort("", deps.cfg.AlertPort),
+		Logger:           logger,
+		OnlineNotifier:   oServiceService,
+		ListenAddr:       net.JoinHostPort("", deps.cfg.AlertPort),
+		RateLimitUpdater: oServiceService,
+		SNACRateLimits:   deps.snacRateLimits,
 	}
 	}
 }
 }
 
 
@@ -154,12 +170,14 @@ func Auth(deps Container) oscar.AuthServer {
 		deps.chatSessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		nil,
 		nil,
+		deps.rateLimitClasses,
 	)
 	)
 
 
 	return oscar.AuthServer{
 	return oscar.AuthServer{
-		AuthService: authHandler,
-		Config:      deps.cfg,
-		Logger:      logger,
+		AuthService:   authHandler,
+		Config:        deps.cfg,
+		Logger:        logger,
+		IPRateLimiter: oscar.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 	}
 	}
 }
 }
 
 
@@ -184,6 +202,7 @@ func BART(deps Container) oscar.BOSServer {
 		deps.chatSessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		nil,
 		nil,
+		deps.rateLimitClasses,
 	)
 	)
 	oServiceService := foodgroup.NewOServiceServiceForBART(
 	oServiceService := foodgroup.NewOServiceServiceForBART(
 		deps.cfg,
 		deps.cfg,
@@ -192,6 +211,8 @@ func BART(deps Container) oscar.BOSServer {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		sessionManager,
 		sessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.rateLimitClasses,
+		deps.snacRateLimits,
 	)
 	)
 
 
 	return oscar.BOSServer{
 	return oscar.BOSServer{
@@ -201,9 +222,11 @@ func BART(deps Container) oscar.BOSServer {
 			BARTHandler:     handler.NewBARTHandler(logger, bartService),
 			BARTHandler:     handler.NewBARTHandler(logger, bartService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 		}),
 		}),
-		ListenAddr:     net.JoinHostPort("", deps.cfg.BARTPort),
-		Logger:         logger,
-		OnlineNotifier: oServiceService,
+		ListenAddr:       net.JoinHostPort("", deps.cfg.BARTPort),
+		Logger:           logger,
+		OnlineNotifier:   oServiceService,
+		RateLimitUpdater: oServiceService,
+		SNACRateLimits:   deps.snacRateLimits,
 	}
 	}
 }
 }
 
 
@@ -220,6 +243,7 @@ func BOS(deps Container) oscar.BOSServer {
 		deps.chatSessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		nil,
 		nil,
+		deps.rateLimitClasses,
 	)
 	)
 	bartService := foodgroup.NewBARTService(
 	bartService := foodgroup.NewBARTService(
 		logger,
 		logger,
@@ -257,6 +281,7 @@ func BOS(deps Container) oscar.BOSServer {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 		deps.inMemorySessionManager,
+		deps.snacRateLimits,
 	)
 	)
 	icqService := foodgroup.NewICQService(deps.inMemorySessionManager, deps.sqLiteUserStore, deps.sqLiteUserStore,
 	icqService := foodgroup.NewICQService(deps.inMemorySessionManager, deps.sqLiteUserStore, deps.sqLiteUserStore,
 		logger, deps.inMemorySessionManager, deps.sqLiteUserStore)
 		logger, deps.inMemorySessionManager, deps.sqLiteUserStore)
@@ -276,6 +301,8 @@ func BOS(deps Container) oscar.BOSServer {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.rateLimitClasses,
+		deps.snacRateLimits,
 	)
 	)
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
 
 
@@ -298,9 +325,11 @@ func BOS(deps Container) oscar.BOSServer {
 			PermitDenyHandler: handler.NewPermitDenyHandler(logger, permitDenyService),
 			PermitDenyHandler: handler.NewPermitDenyHandler(logger, permitDenyService),
 			UserLookupHandler: handler.NewUserLookupHandler(logger, userLookupService),
 			UserLookupHandler: handler.NewUserLookupHandler(logger, userLookupService),
 		}),
 		}),
-		Logger:         logger,
-		OnlineNotifier: oServiceService,
-		ListenAddr:     net.JoinHostPort("", deps.cfg.BOSPort),
+		Logger:           logger,
+		OnlineNotifier:   oServiceService,
+		ListenAddr:       net.JoinHostPort("", deps.cfg.BOSPort),
+		RateLimitUpdater: oServiceService,
+		SNACRateLimits:   deps.snacRateLimits,
 	}
 	}
 }
 }
 
 
@@ -318,6 +347,7 @@ func Chat(deps Container) oscar.ChatServer {
 		deps.chatSessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		nil,
 		nil,
+		deps.rateLimitClasses,
 	)
 	)
 	chatService := foodgroup.NewChatService(deps.chatSessionManager)
 	chatService := foodgroup.NewChatService(deps.chatSessionManager)
 	oServiceService := foodgroup.NewOServiceServiceForChat(
 	oServiceService := foodgroup.NewOServiceServiceForChat(
@@ -329,6 +359,8 @@ func Chat(deps Container) oscar.ChatServer {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		sessionManager,
 		sessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.rateLimitClasses,
+		deps.snacRateLimits,
 	)
 	)
 
 
 	return oscar.ChatServer{
 	return oscar.ChatServer{
@@ -338,8 +370,10 @@ func Chat(deps Container) oscar.ChatServer {
 			ChatHandler:     handler.NewChatHandler(logger, chatService),
 			ChatHandler:     handler.NewChatHandler(logger, chatService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 		}),
 		}),
-		Logger:         logger,
-		OnlineNotifier: oServiceService,
+		Logger:           logger,
+		OnlineNotifier:   oServiceService,
+		SNACRateLimits:   deps.snacRateLimits,
+		RateLimitUpdater: oServiceService,
 	}
 	}
 }
 }
 
 
@@ -357,6 +391,7 @@ func ChatNav(deps Container) oscar.BOSServer {
 		deps.chatSessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		nil,
 		nil,
+		deps.rateLimitClasses,
 	)
 	)
 	chatNavService := foodgroup.NewChatNavService(logger, deps.sqLiteUserStore)
 	chatNavService := foodgroup.NewChatNavService(logger, deps.sqLiteUserStore)
 	oServiceService := foodgroup.NewOServiceServiceForChatNav(
 	oServiceService := foodgroup.NewOServiceServiceForChatNav(
@@ -366,6 +401,8 @@ func ChatNav(deps Container) oscar.BOSServer {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		sessionManager,
 		sessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.rateLimitClasses,
+		deps.snacRateLimits,
 	)
 	)
 
 
 	return oscar.BOSServer{
 	return oscar.BOSServer{
@@ -375,9 +412,11 @@ func ChatNav(deps Container) oscar.BOSServer {
 			ChatNavHandler:  handler.NewChatNavHandler(chatNavService, logger),
 			ChatNavHandler:  handler.NewChatNavHandler(chatNavService, logger),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 		}),
 		}),
-		Logger:         logger,
-		OnlineNotifier: oServiceService,
-		ListenAddr:     net.JoinHostPort("", deps.cfg.ChatNavPort),
+		Logger:           logger,
+		OnlineNotifier:   oServiceService,
+		ListenAddr:       net.JoinHostPort("", deps.cfg.ChatNavPort),
+		RateLimitUpdater: oServiceService,
+		SNACRateLimits:   deps.snacRateLimits,
 	}
 	}
 }
 }
 
 
@@ -407,8 +446,14 @@ func ODir(deps Container) oscar.BOSServer {
 		deps.chatSessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		nil,
 		nil,
+		deps.rateLimitClasses,
+	)
+	oServiceService := foodgroup.NewOServiceServiceForODir(
+		deps.cfg,
+		logger,
+		deps.rateLimitClasses,
+		deps.snacRateLimits,
 	)
 	)
-	oServiceService := foodgroup.NewOServiceServiceForODir(deps.cfg, logger)
 	oDirService := foodgroup.NewODirService(logger, deps.sqLiteUserStore)
 	oDirService := foodgroup.NewODirService(logger, deps.sqLiteUserStore)
 
 
 	return oscar.BOSServer{
 	return oscar.BOSServer{
@@ -418,9 +463,11 @@ func ODir(deps Container) oscar.BOSServer {
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
 			ODirHandler:     handler.NewODirHandler(logger, oDirService),
 			ODirHandler:     handler.NewODirHandler(logger, oDirService),
 		}),
 		}),
-		Logger:         logger,
-		OnlineNotifier: oServiceService,
-		ListenAddr:     net.JoinHostPort("", deps.cfg.ODirPort),
+		Logger:           logger,
+		OnlineNotifier:   oServiceService,
+		ListenAddr:       net.JoinHostPort("", deps.cfg.ODirPort),
+		RateLimitUpdater: oServiceService,
+		SNACRateLimits:   deps.snacRateLimits,
 	}
 	}
 }
 }
 
 
@@ -449,6 +496,7 @@ func TOC(deps Container) toc.Server {
 				deps.chatSessionManager,
 				deps.chatSessionManager,
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
 				nil,
 				nil,
+				deps.rateLimitClasses,
 			),
 			),
 			BuddyListRegistry: deps.sqLiteUserStore,
 			BuddyListRegistry: deps.sqLiteUserStore,
 			BuddyService: foodgroup.NewBuddyService(
 			BuddyService: foodgroup.NewBuddyService(
@@ -466,6 +514,7 @@ func TOC(deps Container) toc.Server {
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
 				deps.inMemorySessionManager,
 				deps.inMemorySessionManager,
+				deps.snacRateLimits,
 			),
 			),
 			LocateService: foodgroup.NewLocateService(
 			LocateService: foodgroup.NewLocateService(
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
@@ -484,6 +533,8 @@ func TOC(deps Container) toc.Server {
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
 				deps.inMemorySessionManager,
 				deps.inMemorySessionManager,
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
+				deps.rateLimitClasses,
+				deps.snacRateLimits,
 			),
 			),
 			PermitDenyService: foodgroup.NewPermitDenyService(
 			PermitDenyService: foodgroup.NewPermitDenyService(
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
@@ -503,8 +554,13 @@ func TOC(deps Container) toc.Server {
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
 				sessionManager,
 				sessionManager,
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
+				deps.rateLimitClasses,
+				deps.snacRateLimits,
 			),
 			),
-			ChatNavService: foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
+			ChatNavService:    foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
+			SNACRateLimits:    deps.snacRateLimits,
+			HTTPIPRateLimiter: toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		},
 		},
+		LoginIPRateLimiter: toc.NewIPRateLimiter(rate.Every(10*time.Minute), 10, 20*time.Minute),
 	}
 	}
 }
 }

+ 8 - 0
foodgroup/auth.go

@@ -27,6 +27,7 @@ func NewAuthService(
 	chatMessageRelayer ChatMessageRelayer,
 	chatMessageRelayer ChatMessageRelayer,
 	accountManager AccountManager,
 	accountManager AccountManager,
 	adminServerSessionRetriever SessionRetriever,
 	adminServerSessionRetriever SessionRetriever,
+	classes wire.RateLimitClasses,
 ) *AuthService {
 ) *AuthService {
 	return &AuthService{
 	return &AuthService{
 		chatSessionRegistry: chatSessionRegistry,
 		chatSessionRegistry: chatSessionRegistry,
@@ -38,6 +39,7 @@ func NewAuthService(
 		accountManager:      accountManager,
 		accountManager:      accountManager,
 		// hack - adminServerSessionRetriever is just used for admin server
 		// hack - adminServerSessionRetriever is just used for admin server
 		adminServerSessionRetriever: adminServerSessionRetriever,
 		adminServerSessionRetriever: adminServerSessionRetriever,
+		rateLimitClasses:            classes,
 	}
 	}
 }
 }
 
 
@@ -53,6 +55,7 @@ type AuthService struct {
 	userManager                 UserManager
 	userManager                 UserManager
 	accountManager              AccountManager
 	accountManager              AccountManager
 	adminServerSessionRetriever SessionRetriever
 	adminServerSessionRetriever SessionRetriever
+	rateLimitClasses            wire.RateLimitClasses
 }
 }
 
 
 // RegisterChatSession adds a user to a chat room. The authCookie param is an
 // RegisterChatSession adds a user to a chat room. The authCookie param is an
@@ -75,6 +78,9 @@ func (s AuthService) RegisterChatSession(ctx context.Context, authCookie []byte)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("AddSession: %w", err)
 		return nil, fmt.Errorf("AddSession: %w", err)
 	}
 	}
+
+	sess.SetRateClasses(time.Now(), s.rateLimitClasses)
+
 	return sess, err
 	return sess, err
 }
 }
 
 
@@ -120,6 +126,8 @@ func (s AuthService) RegisterBOSSession(ctx context.Context, authCookie []byte)
 		sess.SetUserInfoFlag(wire.OServiceUserFlagUnconfirmed)
 		sess.SetUserInfoFlag(wire.OServiceUserFlagUnconfirmed)
 	}
 	}
 
 
+	sess.SetRateClasses(time.Now(), s.rateLimitClasses)
+
 	// set string containing OSCAR client name and version
 	// set string containing OSCAR client name and version
 	sess.SetClientID(c.ClientID)
 	sess.SetClientID(c.ClientID)
 
 

+ 6 - 6
foodgroup/auth_test.go

@@ -1313,7 +1313,7 @@ func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) {
 		Crack(authCookie).
 		Crack(authCookie).
 		Return(chatCookieBuf.Bytes(), nil)
 		Return(chatCookieBuf.Bytes(), nil)
 
 
-	svc := NewAuthService(config.Config{}, nil, chatSessionRegistry, nil, cookieBaker, nil, nil, nil)
+	svc := NewAuthService(config.Config{}, nil, chatSessionRegistry, nil, cookieBaker, nil, nil, nil, wire.DefaultRateLimitClasses())
 
 
 	have, err := svc.RegisterChatSession(context.Background(), authCookie)
 	have, err := svc.RegisterChatSession(context.Background(), authCookie)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1469,7 +1469,7 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 					Return(params.confirmStatus, nil)
 					Return(params.confirmStatus, nil)
 			}
 			}
 
 
-			svc := NewAuthService(config.Config{}, sessionRegistry, nil, userManager, cookieBaker, nil, accountManager, nil)
+			svc := NewAuthService(config.Config{}, sessionRegistry, nil, userManager, cookieBaker, nil, accountManager, nil, wire.DefaultRateLimitClasses())
 
 
 			have, err := svc.RegisterBOSSession(context.Background(), tc.cookie)
 			have, err := svc.RegisterBOSSession(context.Background(), tc.cookie)
 			assert.NoError(t, err)
 			assert.NoError(t, err)
@@ -1507,7 +1507,7 @@ func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
 		User(matchContext(), sess.IdentScreenName()).
 		User(matchContext(), sess.IdentScreenName()).
 		Return(&state.User{IdentScreenName: sess.IdentScreenName()}, nil)
 		Return(&state.User{IdentScreenName: sess.IdentScreenName()}, nil)
 
 
-	svc := NewAuthService(config.Config{}, nil, nil, userManager, cookieBaker, nil, nil, sessionRetriever)
+	svc := NewAuthService(config.Config{}, nil, nil, userManager, cookieBaker, nil, nil, sessionRetriever, wire.DefaultRateLimitClasses())
 
 
 	have, err := svc.RetrieveBOSSession(context.Background(), authCookie)
 	have, err := svc.RetrieveBOSSession(context.Background(), authCookie)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1540,7 +1540,7 @@ func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
 		User(matchContext(), sess.IdentScreenName()).
 		User(matchContext(), sess.IdentScreenName()).
 		Return(&state.User{IdentScreenName: sess.IdentScreenName()}, nil)
 		Return(&state.User{IdentScreenName: sess.IdentScreenName()}, nil)
 
 
-	svc := NewAuthService(config.Config{}, nil, nil, userManager, cookieBaker, nil, nil, sessionRetriever)
+	svc := NewAuthService(config.Config{}, nil, nil, userManager, cookieBaker, nil, nil, sessionRetriever, wire.DefaultRateLimitClasses())
 
 
 	have, err := svc.RetrieveBOSSession(context.Background(), authCookie)
 	have, err := svc.RetrieveBOSSession(context.Background(), authCookie)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1633,7 +1633,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 					RemoveSession(matchSession(params.screenName))
 					RemoveSession(matchSession(params.screenName))
 			}
 			}
 
 
-			svc := NewAuthService(config.Config{}, nil, sessionManager, nil, nil, chatMessageRelayer, nil, nil)
+			svc := NewAuthService(config.Config{}, nil, sessionManager, nil, nil, chatMessageRelayer, nil, nil, wire.DefaultRateLimitClasses())
 			svc.SignoutChat(context.Background(), tt.userSession)
 			svc.SignoutChat(context.Background(), tt.userSession)
 		})
 		})
 	}
 	}
@@ -1678,7 +1678,7 @@ func TestAuthService_Signout(t *testing.T) {
 			for _, params := range tt.mockParams.removeSessionParams {
 			for _, params := range tt.mockParams.removeSessionParams {
 				sessionManager.EXPECT().RemoveSession(matchSession(params.screenName))
 				sessionManager.EXPECT().RemoveSession(matchSession(params.screenName))
 			}
 			}
-			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil, nil)
+			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses())
 
 
 			svc.Signout(context.Background(), tt.userSession)
 			svc.Signout(context.Background(), tt.userSession)
 		})
 		})

+ 6 - 0
foodgroup/helpers_test.go

@@ -762,6 +762,12 @@ func sessOptUIN(UIN uint32) func(session *state.Session) {
 	}
 	}
 }
 }
 
 
+func sessOptFoodGroupVersions(versions [wire.MDir + 1]uint16) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetFoodGroupVersions(versions)
+	}
+}
+
 // sessClientID sets the client ID
 // sessClientID sets the client ID
 func sessClientID(clientID string) func(session *state.Session) {
 func sessClientID(clientID string) func(session *state.Session) {
 	return func(session *state.Session) {
 	return func(session *state.Session) {

+ 3 - 0
foodgroup/icbm.go

@@ -22,6 +22,7 @@ func NewICBMService(
 	offlineMessageSaver OfflineMessageManager,
 	offlineMessageSaver OfflineMessageManager,
 	relationshipFetcher RelationshipFetcher,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	sessionRetriever SessionRetriever,
+	snacRateLimits wire.SNACRateLimits,
 ) *ICBMService {
 ) *ICBMService {
 	return &ICBMService{
 	return &ICBMService{
 		relationshipFetcher: relationshipFetcher,
 		relationshipFetcher: relationshipFetcher,
@@ -30,6 +31,7 @@ func NewICBMService(
 		offlineMessageSaver: offlineMessageSaver,
 		offlineMessageSaver: offlineMessageSaver,
 		timeNow:             time.Now,
 		timeNow:             time.Now,
 		sessionRetriever:    sessionRetriever,
 		sessionRetriever:    sessionRetriever,
+		snacRateLimits:      snacRateLimits,
 	}
 	}
 }
 }
 
 
@@ -43,6 +45,7 @@ type ICBMService struct {
 	offlineMessageSaver OfflineMessageManager
 	offlineMessageSaver OfflineMessageManager
 	timeNow             func() time.Time
 	timeNow             func() time.Time
 	sessionRetriever    SessionRetriever
 	sessionRetriever    SessionRetriever
+	snacRateLimits      wire.SNACRateLimits
 }
 }
 
 
 // ParameterQuery returns ICBM service parameters.
 // ParameterQuery returns ICBM service parameters.

+ 2 - 2
foodgroup/icbm_test.go

@@ -1276,7 +1276,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 }
 }
 
 
 func TestICBMService_ParameterQuery(t *testing.T) {
 func TestICBMService_ParameterQuery(t *testing.T) {
-	svc := NewICBMService(nil, nil, nil, nil, nil)
+	svc := NewICBMService(nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits())
 
 
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
 	want := wire.SNACMessage{
@@ -1328,7 +1328,7 @@ func TestICBMService_ClientErr(t *testing.T) {
 	messageRelayer.EXPECT().
 	messageRelayer.EXPECT().
 		RelayToScreenName(mock.Anything, state.NewIdentScreenName("recipientScreenName"), expect)
 		RelayToScreenName(mock.Anything, state.NewIdentScreenName("recipientScreenName"), expect)
 
 
-	svc := NewICBMService(nil, messageRelayer, nil, nil, nil)
+	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, wire.DefaultSNACRateLimits())
 
 
 	err := svc.ClientErr(context.Background(), sess, wire.SNACFrame{RequestID: 1234}, inBody)
 	err := svc.ClientErr(context.Background(), sess, wire.SNACFrame{RequestID: 1234}, inBody)
 	assert.NoError(t, err)
 	assert.NoError(t, err)

+ 1 - 1
foodgroup/mock_account_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_buddy_broadcaster_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_buddy_icon_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_chat_message_relayer_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_chat_room_registry_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_chat_session_registry_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_client_side_buddy_list_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_cookie_baker_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_feedbag_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_icq_user_finder_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_icq_user_updater_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_message_relayer_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_offline_message_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_profile_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_relationship_fetcher_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_session_registry_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_session_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 1 - 1
foodgroup/mock_user_manager_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package foodgroup
 package foodgroup
 
 

+ 250 - 418
foodgroup/oservice.go

@@ -7,7 +7,6 @@ import (
 	"fmt"
 	"fmt"
 	"log/slog"
 	"log/slog"
 	"net"
 	"net"
-	"strings"
 	"time"
 	"time"
 
 
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/config"
@@ -22,6 +21,9 @@ type OServiceService struct {
 	cfg              config.Config
 	cfg              config.Config
 	logger           *slog.Logger
 	logger           *slog.Logger
 	foodGroups       []uint16
 	foodGroups       []uint16
+	rateLimitClasses wire.RateLimitClasses
+	snacRateLimits   wire.SNACRateLimits
+	timeNow          func() time.Time
 }
 }
 
 
 // ClientVersions informs the server what food group versions the client
 // ClientVersions informs the server what food group versions the client
@@ -32,7 +34,31 @@ type OServiceService struct {
 // implicitly accommodates any food group version for Windows AIM clients 5.x.
 // implicitly accommodates any food group version for Windows AIM clients 5.x.
 // It returns SNAC wire.OServiceHostVersions containing the server's supported
 // It returns SNAC wire.OServiceHostVersions containing the server's supported
 // food group versions.
 // food group versions.
-func (s OServiceService) ClientVersions(_ context.Context, frame wire.SNACFrame, inBody wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage {
+// todo this documentation
+func (s OServiceService) ClientVersions(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage {
+	var versions [wire.MDir + 1]uint16
+
+	if len(bodyIn.Versions)%2 != 0 {
+		s.logger.ErrorContext(ctx, "got uneven food group length")
+		return wire.SNACMessage{}
+	}
+
+	for i := 0; i < len(bodyIn.Versions); i += 2 {
+		fg := bodyIn.Versions[i]
+		if fg < wire.OService || fg > wire.MDir {
+			s.logger.ErrorContext(ctx, "invalid food group ID", "id", fg)
+			continue
+		}
+		ver := bodyIn.Versions[i+1]
+		if ver < 1 {
+			s.logger.ErrorContext(ctx, "invalid food group version", "version", ver)
+			continue
+		}
+		versions[fg] = ver
+	}
+
+	sess.SetFoodGroupVersions(versions)
+
 	return wire.SNACMessage{
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.OService,
 			FoodGroup: wire.OService,
@@ -40,419 +66,11 @@ func (s OServiceService) ClientVersions(_ context.Context, frame wire.SNACFrame,
 			RequestID: frame.RequestID,
 			RequestID: frame.RequestID,
 		},
 		},
 		Body: wire.SNAC_0x01_0x18_OServiceHostVersions{
 		Body: wire.SNAC_0x01_0x18_OServiceHostVersions{
-			Versions: inBody.Versions,
+			Versions: bodyIn.Versions,
 		},
 		},
 	}
 	}
 }
 }
 
 
-// rateLimitSNACV1 is the rate params reply sent to AIM 1.x clients that does
-// not contain LastTime and CurrentState fields.
-var rateLimitSNACV1 = wire.SNAC_0x01_0x07_OServiceRateParamsReply{
-	RateClasses: []struct {
-		ID              uint16
-		WindowSize      uint32
-		ClearLevel      uint32
-		AlertLevel      uint32
-		LimitLevel      uint32
-		DisconnectLevel uint32
-		CurrentLevel    uint32
-		MaxLevel        uint32
-		V2Params        *struct {
-			LastTime     uint32
-			CurrentState uint8
-		} `oscar:"optional"`
-	}{
-		{
-			ID:              0x01,
-			WindowSize:      0x0050,
-			ClearLevel:      0x09C4,
-			AlertLevel:      0x07D0,
-			LimitLevel:      0x05DC,
-			DisconnectLevel: 0x0320,
-			CurrentLevel:    0x0D69,
-			MaxLevel:        0x1770,
-			V2Params:        nil,
-		},
-	},
-	RateGroups: []struct {
-		ID    uint16
-		Pairs []struct {
-			FoodGroup uint16
-			SubGroup  uint16
-		} `oscar:"count_prefix=uint16"`
-	}{
-		{
-			ID: 1,
-			Pairs: []struct {
-				FoodGroup uint16
-				SubGroup  uint16
-			}{},
-		},
-	},
-}
-
-// rateLimitSNACV2 is the rate params reply sent to non-AIM 1.x clients.
-var rateLimitSNACV2 = wire.SNAC_0x01_0x07_OServiceRateParamsReply{
-	RateClasses: []struct {
-		ID              uint16
-		WindowSize      uint32
-		ClearLevel      uint32
-		AlertLevel      uint32
-		LimitLevel      uint32
-		DisconnectLevel uint32
-		CurrentLevel    uint32
-		MaxLevel        uint32
-		V2Params        *struct {
-			LastTime     uint32
-			CurrentState uint8
-		} `oscar:"optional"`
-	}{
-		{
-			ID:              0x01,
-			WindowSize:      0x0050,
-			ClearLevel:      0x09C4,
-			AlertLevel:      0x07D0,
-			LimitLevel:      0x05DC,
-			DisconnectLevel: 0x0320,
-			CurrentLevel:    0x0D69,
-			MaxLevel:        0x1770,
-			V2Params: &struct {
-				LastTime     uint32
-				CurrentState uint8
-			}{
-				LastTime:     0x0000,
-				CurrentState: 0x0,
-			},
-		},
-	},
-	RateGroups: []struct {
-		ID    uint16
-		Pairs []struct {
-			FoodGroup uint16
-			SubGroup  uint16
-		} `oscar:"count_prefix=uint16"`
-	}{
-		{
-			ID: 1,
-			Pairs: []struct {
-				FoodGroup uint16
-				SubGroup  uint16
-			}{},
-		},
-	},
-}
-
-// populate the rate limit SNAC with a rule for each subgroup
-func init() {
-	foodGroupToSubgroup := map[uint16][]uint16{
-		wire.OService: {
-			wire.OServiceErr,
-			wire.OServiceClientOnline,
-			wire.OServiceHostOnline,
-			wire.OServiceServiceRequest,
-			wire.OServiceServiceResponse,
-			wire.OServiceRateParamsQuery,
-			wire.OServiceRateParamsReply,
-			wire.OServiceRateParamsSubAdd,
-			wire.OServiceRateDelParamSub,
-			wire.OServiceRateParamChange,
-			wire.OServicePauseReq,
-			wire.OServicePauseAck,
-			wire.OServiceResume,
-			wire.OServiceUserInfoQuery,
-			wire.OServiceUserInfoUpdate,
-			wire.OServiceEvilNotification,
-			wire.OServiceIdleNotification,
-			wire.OServiceMigrateGroups,
-			wire.OServiceMotd,
-			wire.OServiceSetPrivacyFlags,
-			wire.OServiceWellKnownUrls,
-			wire.OServiceNoop,
-			wire.OServiceClientVersions,
-			wire.OServiceHostVersions,
-			wire.OServiceMaxConfigQuery,
-			wire.OServiceMaxConfigReply,
-			wire.OServiceStoreConfig,
-			wire.OServiceConfigQuery,
-			wire.OServiceConfigReply,
-			wire.OServiceSetUserInfoFields,
-			wire.OServiceProbeReq,
-			wire.OServiceProbeAck,
-			wire.OServiceBartReply,
-			wire.OServiceBartQuery2,
-			wire.OServiceBartReply2,
-		},
-		wire.Locate: {
-			wire.LocateErr,
-			wire.LocateRightsQuery,
-			wire.LocateRightsReply,
-			wire.LocateSetInfo,
-			wire.LocateUserInfoQuery,
-			wire.LocateUserInfoReply,
-			wire.LocateWatcherSubRequest,
-			wire.LocateWatcherNotification,
-			wire.LocateSetDirInfo,
-			wire.LocateSetDirReply,
-			wire.LocateGetDirInfo,
-			wire.LocateGetDirReply,
-			wire.LocateGroupCapabilityQuery,
-			wire.LocateGroupCapabilityReply,
-			wire.LocateSetKeywordInfo,
-			wire.LocateSetKeywordReply,
-			wire.LocateGetKeywordInfo,
-			wire.LocateGetKeywordReply,
-			wire.LocateFindListByEmail,
-			wire.LocateFindListReply,
-			wire.LocateUserInfoQuery2,
-		},
-		wire.Buddy: {
-			wire.BuddyErr,
-			wire.BuddyRightsQuery,
-			wire.BuddyRightsReply,
-			wire.BuddyAddBuddies,
-			wire.BuddyDelBuddies,
-			wire.BuddyWatcherListQuery,
-			wire.BuddyWatcherListResponse,
-			wire.BuddyWatcherSubRequest,
-			wire.BuddyWatcherNotification,
-			wire.BuddyRejectNotification,
-			wire.BuddyArrived,
-			wire.BuddyDeparted,
-			wire.BuddyAddTempBuddies,
-			wire.BuddyDelTempBuddies,
-		},
-		wire.ICBM: {
-			wire.ICBMErr,
-			wire.ICBMAddParameters,
-			wire.ICBMDelParameters,
-			wire.ICBMParameterQuery,
-			wire.ICBMParameterReply,
-			wire.ICBMChannelMsgToHost,
-			wire.ICBMChannelMsgToClient,
-			wire.ICBMEvilRequest,
-			wire.ICBMEvilReply,
-			wire.ICBMMissedCalls,
-			wire.ICBMClientErr,
-			wire.ICBMHostAck,
-			wire.ICBMSinStored,
-			wire.ICBMSinListQuery,
-			wire.ICBMSinListReply,
-			wire.ICBMSinRetrieve,
-			wire.ICBMSinDelete,
-			wire.ICBMNotifyRequest,
-			wire.ICBMNotifyReply,
-			wire.ICBMClientEvent,
-			wire.ICBMSinReply,
-		},
-		wire.Invite: {
-			wire.InviteRequestQuery,
-		},
-		wire.ChatNav: {
-			wire.ChatNavErr,
-			wire.ChatNavRequestChatRights,
-			wire.ChatNavRequestExchangeInfo,
-			wire.ChatNavRequestRoomInfo,
-			wire.ChatNavRequestMoreRoomInfo,
-			wire.ChatNavRequestOccupantList,
-			wire.ChatNavSearchForRoom,
-			wire.ChatNavCreateRoom,
-			wire.ChatNavNavInfo,
-		},
-		wire.Chat: {
-			wire.ChatErr,
-			wire.ChatRoomInfoUpdate,
-			wire.ChatUsersJoined,
-			wire.ChatUsersLeft,
-			wire.ChatChannelMsgToHost,
-			wire.ChatChannelMsgToClient,
-			wire.ChatEvilRequest,
-			wire.ChatEvilReply,
-			wire.ChatClientErr,
-			wire.ChatPauseRoomReq,
-			wire.ChatPauseRoomAck,
-			wire.ChatResumeRoom,
-			wire.ChatShowMyRow,
-			wire.ChatShowRowByUsername,
-			wire.ChatShowRowByNumber,
-			wire.ChatShowRowByName,
-			wire.ChatRowInfo,
-			wire.ChatListRows,
-			wire.ChatRowListInfo,
-			wire.ChatMoreRows,
-			wire.ChatMoveToRow,
-			wire.ChatToggleChat,
-			wire.ChatSendQuestion,
-			wire.ChatSendComment,
-			wire.ChatTallyVote,
-			wire.ChatAcceptBid,
-			wire.ChatSendInvite,
-			wire.ChatDeclineInvite,
-			wire.ChatAcceptInvite,
-			wire.ChatNotifyMessage,
-			wire.ChatGotoRow,
-			wire.ChatStageUserJoin,
-			wire.ChatStageUserLeft,
-			wire.ChatUnnamedSnac22,
-			wire.ChatClose,
-			wire.ChatUserBan,
-			wire.ChatUserUnban,
-			wire.ChatJoined,
-			wire.ChatUnnamedSnac27,
-			wire.ChatUnnamedSnac28,
-			wire.ChatUnnamedSnac29,
-			wire.ChatRoomInfoOwner,
-		},
-		wire.BART: {
-			wire.BARTErr,
-			wire.BARTUploadQuery,
-			wire.BARTUploadReply,
-			wire.BARTDownloadQuery,
-			wire.BARTDownloadReply,
-			wire.BARTDownload2Query,
-			wire.BARTDownload2Reply,
-		},
-		wire.Feedbag: {
-			wire.FeedbagErr,
-			wire.FeedbagRightsQuery,
-			wire.FeedbagRightsReply,
-			wire.FeedbagQuery,
-			wire.FeedbagQueryIfModified,
-			wire.FeedbagReply,
-			wire.FeedbagUse,
-			wire.FeedbagInsertItem,
-			wire.FeedbagUpdateItem,
-			wire.FeedbagDeleteItem,
-			wire.FeedbagInsertClass,
-			wire.FeedbagUpdateClass,
-			wire.FeedbagDeleteClass,
-			wire.FeedbagStatus,
-			wire.FeedbagReplyNotModified,
-			wire.FeedbagDeleteUser,
-			wire.FeedbagStartCluster,
-			wire.FeedbagEndCluster,
-			wire.FeedbagAuthorizeBuddy,
-			wire.FeedbagPreAuthorizeBuddy,
-			wire.FeedbagPreAuthorizedBuddy,
-			wire.FeedbagRemoveMe,
-			wire.FeedbagRemoveMe2,
-			wire.FeedbagRequestAuthorizeToHost,
-			wire.FeedbagRequestAuthorizeToClient,
-			wire.FeedbagRespondAuthorizeToHost,
-			wire.FeedbagRespondAuthorizeToClient,
-			wire.FeedbagBuddyAdded,
-			wire.FeedbagRequestAuthorizeToBadog,
-			wire.FeedbagRespondAuthorizeToBadog,
-			wire.FeedbagBuddyAddedToBadog,
-			wire.FeedbagTestSnac,
-			wire.FeedbagForwardMsg,
-			wire.FeedbagIsAuthRequiredQuery,
-			wire.FeedbagIsAuthRequiredReply,
-			wire.FeedbagRecentBuddyUpdate,
-		},
-		wire.BUCP: {
-			wire.BUCPErr,
-			wire.BUCPLoginRequest,
-			wire.BUCPLoginResponse,
-			wire.BUCPRegisterRequest,
-			wire.BUCPChallengeRequest,
-			wire.BUCPChallengeResponse,
-			wire.BUCPAsasnRequest,
-			wire.BUCPSecuridRequest,
-			wire.BUCPRegistrationImageRequest,
-		},
-		wire.Alert: {
-			wire.AlertErr,
-			wire.AlertSetAlertRequest,
-			wire.AlertSetAlertReply,
-			wire.AlertGetSubsRequest,
-			wire.AlertGetSubsResponse,
-			wire.AlertNotifyCapabilities,
-			wire.AlertNotify,
-			wire.AlertGetRuleRequest,
-			wire.AlertGetRuleReply,
-			wire.AlertGetFeedRequest,
-			wire.AlertGetFeedReply,
-			wire.AlertRefreshFeed,
-			wire.AlertEvent,
-			wire.AlertQogSnac,
-			wire.AlertRefreshFeedStock,
-			wire.AlertNotifyTransport,
-			wire.AlertSetAlertRequestV2,
-			wire.AlertSetAlertReplyV2,
-			wire.AlertTransitReply,
-			wire.AlertNotifyAck,
-			wire.AlertNotifyDisplayCapabilities,
-			wire.AlertUserOnline,
-		},
-		wire.ICQ: {
-			wire.ICQErr,
-			wire.ICQDBQuery,
-			wire.ICQDBReply,
-		},
-		wire.PermitDeny: {
-			wire.PermitDenyErr,
-			wire.PermitDenyRightsQuery,
-			wire.PermitDenyRightsReply,
-			wire.PermitDenySetGroupPermitMask,
-			wire.PermitDenyAddPermListEntries,
-			wire.PermitDenyDelPermListEntries,
-			wire.PermitDenyAddDenyListEntries,
-			wire.PermitDenyDelDenyListEntries,
-			wire.PermitDenyBosErr,
-			wire.PermitDenyAddTempPermitListEntries,
-			wire.PermitDenyDelTempPermitListEntries,
-		},
-		wire.ODir: {
-			wire.ODirErr,
-			wire.ODirInfoQuery,
-			wire.ODirInfoReply,
-			wire.ODirKeywordListQuery,
-			wire.ODirKeywordListReply,
-		},
-		wire.UserLookup: {
-			wire.UserLookupFindByEmail,
-		},
-	}
-
-	for _, foodGroup := range []uint16{
-		wire.OService,
-		wire.Locate,
-		wire.Buddy,
-		wire.ICBM,
-		wire.Invite,
-		wire.ChatNav,
-		wire.Chat,
-		wire.BART,
-		wire.Feedbag,
-		wire.BUCP,
-		wire.Alert,
-		wire.ICQ,
-		wire.PermitDeny,
-		wire.ODir,
-		wire.UserLookup,
-	} {
-		subGroups := foodGroupToSubgroup[foodGroup]
-		for _, subGroup := range subGroups {
-			// build response for AIM 1.x clients
-			rateLimitSNACV1.RateGroups[0].Pairs = append(rateLimitSNACV1.RateGroups[0].Pairs, struct {
-				FoodGroup uint16
-				SubGroup  uint16
-			}{
-				FoodGroup: foodGroup,
-				SubGroup:  subGroup,
-			})
-			// build response for all other clients
-			rateLimitSNACV2.RateGroups[0].Pairs = append(rateLimitSNACV2.RateGroups[0].Pairs, struct {
-				FoodGroup uint16
-				SubGroup  uint16
-			}{
-				FoodGroup: foodGroup,
-				SubGroup:  subGroup,
-			})
-		}
-	}
-}
-
 // RateParamsQuery returns SNAC rate limits. It returns SNAC
 // RateParamsQuery returns SNAC rate limits. It returns SNAC
 // wire.OServiceRateParamsReply containing rate limits for all food groups
 // wire.OServiceRateParamsReply containing rate limits for all food groups
 // supported by this server.
 // supported by this server.
@@ -473,10 +91,85 @@ func init() {
 // exist in this response. When support for a new food group is added to the
 // exist in this response. When support for a new food group is added to the
 // server, update this function accordingly.
 // server, update this function accordingly.
 func (s OServiceService) RateParamsQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) wire.SNACMessage {
 func (s OServiceService) RateParamsQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) wire.SNACMessage {
-	limits := rateLimitSNACV2
-	if strings.Contains(sess.ClientID(), "AOL Instant Messenger (TM), version 1.") {
-		limits = rateLimitSNACV1
+	// not contain LastTime and CurrentStatus fields.
+	var limits = wire.SNAC_0x01_0x07_OServiceRateParamsReply{
+		RateClasses: []wire.RateParamsSNAC{},
+		RateGroups: []struct {
+			ID    uint16
+			Pairs []struct {
+				FoodGroup uint16
+				SubGroup  uint16
+			} `oscar:"count_prefix=uint16"`
+		}{
+			{
+				ID: 1,
+				Pairs: []struct {
+					FoodGroup uint16
+					SubGroup  uint16
+				}{},
+			},
+			{
+				ID: 2,
+				Pairs: []struct {
+					FoodGroup uint16
+					SubGroup  uint16
+				}{},
+			},
+			{
+				ID: 3,
+				Pairs: []struct {
+					FoodGroup uint16
+					SubGroup  uint16
+				}{},
+			},
+			{
+				ID: 4,
+				Pairs: []struct {
+					FoodGroup uint16
+					SubGroup  uint16
+				}{},
+			},
+			{
+				ID: 5,
+				Pairs: []struct {
+					FoodGroup uint16
+					SubGroup  uint16
+				}{},
+			},
+		},
 	}
 	}
+
+	for _, class := range s.rateLimitClasses.All() {
+		str := wire.RateParamsSNAC{
+			ID:              uint16(class.ID),
+			WindowSize:      uint32(class.WindowSize),
+			ClearLevel:      uint32(class.ClearLevel),
+			AlertLevel:      uint32(class.AlertLevel),
+			LimitLevel:      uint32(class.LimitLevel),
+			DisconnectLevel: uint32(class.DisconnectLevel),
+			CurrentLevel:    uint32(class.MaxLevel),
+			MaxLevel:        uint32(class.MaxLevel),
+		}
+		if sess.FoodGroupVersions()[wire.OService] > 1 {
+			str.V2Params = &struct {
+				LastTime      uint32
+				DroppingSNACs uint8
+			}{
+				LastTime: uint32(s.timeNow().Add(-time.Second).Unix()),
+			}
+		}
+		limits.RateClasses = append(limits.RateClasses, str)
+	}
+
+	for snacClass := range s.snacRateLimits.All() {
+		classID := int(snacClass.RateLimitClass) - 1
+		limits.RateGroups[classID].Pairs = append(limits.RateGroups[classID].Pairs,
+			struct {
+				FoodGroup uint16
+				SubGroup  uint16
+			}{FoodGroup: snacClass.FoodGroup, SubGroup: snacClass.SubGroup})
+	}
+
 	return wire.SNACMessage{
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.OService,
 			FoodGroup: wire.OService,
@@ -561,9 +254,27 @@ func (s OServiceService) SetPrivacyFlags(ctx context.Context, bodyIn wire.SNAC_0
 	}
 	}
 }
 }
 
 
-// RateParamsSubAdd exists to capture the SNAC input in unit tests to
-// verify it's correctly unmarshalled.
-func (s OServiceService) RateParamsSubAdd(context.Context, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd) {
+// RateParamsSubAdd subscribes to rate parameter changes. AOL's OSCAR spec says
+// that notifications will be queued after calling this method. I don't see the
+// point of doing that since all clients appear to call RateParamsQuery at
+// sign-on for all rate classes.
+func (s OServiceService) RateParamsSubAdd(ctx context.Context, sess *state.Session, snac wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd) {
+	ids := make([]wire.RateLimitClassID, 0, len(snac.ClassIDs))
+
+	for _, id := range snac.ClassIDs {
+		if id < 1 || id > 5 {
+			s.logger.DebugContext(ctx, "snac class ID out of range")
+			continue
+		}
+		ids = append(ids, wire.RateLimitClassID(id))
+	}
+
+	if len(ids) == 0 {
+		return
+	}
+
+	s.logger.DebugContext(ctx, "subscribing to rate limit updates", "classes", ids)
+	sess.SubscribeRateLimits(ids)
 }
 }
 
 
 func (s OServiceService) ServiceRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error) {
 func (s OServiceService) ServiceRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error) {
@@ -604,6 +315,89 @@ func (s OServiceService) HostOnline() wire.SNACMessage {
 	}
 	}
 }
 }
 
 
+// RateLimitUpdates produces update messages reflecting any recent changes in
+// rate limit class params or rate limit states for the current session.
+// Changes are reported relative to the previous invocation for this session.
+// Only newly observed transitions or updated rate parameters will be included.
+func (s OServiceService) RateLimitUpdates(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage {
+	msgs := make([]wire.SNACMessage, 0, 5)
+	classDelta, stateDelta := sess.ObserveRateChanges(now)
+
+	for _, curRate := range classDelta {
+		s.logger.DebugContext(ctx, "rate limit class changed", "class", curRate.ID)
+		msgs = append(msgs, buildRateLimitUpdate(1, curRate, sess, now))
+	}
+
+	for _, curRate := range stateDelta {
+		s.logger.DebugContext(ctx, "rate limit state changed",
+			"class", curRate.ID,
+			"state", curRate.CurrentStatus)
+		var code uint16
+		switch curRate.CurrentStatus {
+		case wire.RateLimitStatusLimited:
+			code = 3
+		case wire.RateLimitStatusAlert:
+			code = 2
+		case wire.RateLimitStatusClear:
+			code = 4
+		case wire.RateLimitStatusDisconnect:
+			s.logger.DebugContext(ctx, "rate limit status disconnected, no point in returning status update")
+			continue
+		}
+
+		msgs = append(msgs, buildRateLimitUpdate(code, curRate, sess, now))
+	}
+
+	return msgs
+}
+
+// buildRateLimitUpdate constructs a SNAC message notifying the client of a rate limit
+// threshold update or a change in rate limiting status for a specific class.
+//
+// The message format varies depending on the client's supported protocol version.
+// If OService version 2 or higher is supported, additional metadata such as
+// time since last status change and whether SNACs are currently being dropped
+// will be included.
+func buildRateLimitUpdate(code uint16, curRate state.RateClassState, sess *state.Session, now time.Time) wire.SNACMessage {
+	var droppingSNACs uint8
+	if curRate.CurrentStatus == wire.RateLimitStatusLimited {
+		droppingSNACs = 1
+	}
+
+	rate := wire.RateParamsSNAC{
+		ID:              uint16(curRate.ID),
+		WindowSize:      uint32(curRate.WindowSize),
+		ClearLevel:      uint32(curRate.ClearLevel),
+		AlertLevel:      uint32(curRate.AlertLevel),
+		LimitLevel:      uint32(curRate.LimitLevel),
+		DisconnectLevel: uint32(curRate.DisconnectLevel),
+		CurrentLevel:    uint32(curRate.CurrentLevel),
+		MaxLevel:        uint32(curRate.MaxLevel),
+	}
+
+	if sess.FoodGroupVersions()[wire.OService] > 1 {
+		rate.V2Params = &struct {
+			LastTime      uint32
+			DroppingSNACs uint8
+		}{
+			LastTime:      uint32(max(0, now.Unix()-curRate.LastTime.Unix())),
+			DroppingSNACs: droppingSNACs,
+		}
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceRateParamChange,
+			RequestID: wire.ReqIDFromServer,
+		},
+		Body: wire.SNAC_0x01_0x0A_OServiceRateParamsChange{
+			Code: code,
+			Rate: rate,
+		},
+	}
+}
+
 // NewOServiceServiceForBOS creates a new instance of OServiceServiceForBOS.
 // NewOServiceServiceForBOS creates a new instance of OServiceServiceForBOS.
 func NewOServiceServiceForBOS(
 func NewOServiceServiceForBOS(
 	cfg config.Config,
 	cfg config.Config,
@@ -614,6 +408,8 @@ func NewOServiceServiceForBOS(
 	relationshipFetcher RelationshipFetcher,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	sessionRetriever SessionRetriever,
 	buddyIconManager BuddyIconManager,
 	buddyIconManager BuddyIconManager,
+	rateLimitClasses wire.RateLimitClasses,
+	snacRateLimits wire.SNACRateLimits,
 ) *OServiceServiceForBOS {
 ) *OServiceServiceForBOS {
 	return &OServiceServiceForBOS{
 	return &OServiceServiceForBOS{
 		chatRoomManager: chatRoomManager,
 		chatRoomManager: chatRoomManager,
@@ -638,6 +434,9 @@ func NewOServiceServiceForBOS(
 				wire.Popup,
 				wire.Popup,
 				wire.Stats,
 				wire.Stats,
 			},
 			},
+			rateLimitClasses: rateLimitClasses,
+			snacRateLimits:   snacRateLimits,
+			timeNow:          time.Now,
 		},
 		},
 	}
 	}
 }
 }
@@ -875,6 +674,8 @@ func NewOServiceServiceForChat(
 	relationshipFetcher RelationshipFetcher,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	sessionRetriever SessionRetriever,
 	buddyIconManager BuddyIconManager,
 	buddyIconManager BuddyIconManager,
+	rateLimitClasses wire.RateLimitClasses,
+	snacRateLimits wire.SNACRateLimits,
 ) *OServiceServiceForChat {
 ) *OServiceServiceForChat {
 	return &OServiceServiceForChat{
 	return &OServiceServiceForChat{
 		OServiceService: OServiceService{
 		OServiceService: OServiceService{
@@ -885,6 +686,9 @@ func NewOServiceServiceForChat(
 				wire.OService,
 				wire.OService,
 				wire.Chat,
 				wire.Chat,
 			},
 			},
+			rateLimitClasses: rateLimitClasses,
+			snacRateLimits:   snacRateLimits,
+			timeNow:          time.Now,
 		},
 		},
 		chatRoomManager:    chatRoomManager,
 		chatRoomManager:    chatRoomManager,
 		chatMessageRelayer: chatMessageRelayer,
 		chatMessageRelayer: chatMessageRelayer,
@@ -929,6 +733,8 @@ func NewOServiceServiceForChatNav(
 	relationshipFetcher RelationshipFetcher,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	sessionRetriever SessionRetriever,
 	buddyIconManager BuddyIconManager,
 	buddyIconManager BuddyIconManager,
+	rateLimitClasses wire.RateLimitClasses,
+	snacRateLimits wire.SNACRateLimits,
 ) *OServiceService {
 ) *OServiceService {
 	return &OServiceService{
 	return &OServiceService{
 		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
@@ -938,6 +744,9 @@ func NewOServiceServiceForChatNav(
 			wire.ChatNav,
 			wire.ChatNav,
 			wire.OService,
 			wire.OService,
 		},
 		},
+		rateLimitClasses: rateLimitClasses,
+		snacRateLimits:   snacRateLimits,
+		timeNow:          time.Now,
 	}
 	}
 }
 }
 
 
@@ -950,6 +759,8 @@ func NewOServiceServiceForAlert(
 	relationshipFetcher RelationshipFetcher,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	sessionRetriever SessionRetriever,
 	buddyIconManager BuddyIconManager,
 	buddyIconManager BuddyIconManager,
+	rateLimitClasses wire.RateLimitClasses,
+	snacRateLimits wire.SNACRateLimits,
 ) *OServiceService {
 ) *OServiceService {
 	return &OServiceService{
 	return &OServiceService{
 		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
@@ -959,12 +770,20 @@ func NewOServiceServiceForAlert(
 			wire.Alert,
 			wire.Alert,
 			wire.OService,
 			wire.OService,
 		},
 		},
+		rateLimitClasses: rateLimitClasses,
+		snacRateLimits:   snacRateLimits,
+		timeNow:          time.Now,
 	}
 	}
 }
 }
 
 
 // NewOServiceServiceForODir creates a new instance of OServiceService for the
 // NewOServiceServiceForODir creates a new instance of OServiceService for the
 // ODir server.
 // ODir server.
-func NewOServiceServiceForODir(cfg config.Config, logger *slog.Logger) *OServiceService {
+func NewOServiceServiceForODir(
+	cfg config.Config,
+	logger *slog.Logger,
+	rateLimitClasses wire.RateLimitClasses,
+	snacRateLimits wire.SNACRateLimits,
+) *OServiceService {
 	return &OServiceService{
 	return &OServiceService{
 		cfg:    cfg,
 		cfg:    cfg,
 		logger: logger,
 		logger: logger,
@@ -972,6 +791,9 @@ func NewOServiceServiceForODir(cfg config.Config, logger *slog.Logger) *OService
 			wire.ODir,
 			wire.ODir,
 			wire.OService,
 			wire.OService,
 		},
 		},
+		rateLimitClasses: rateLimitClasses,
+		snacRateLimits:   snacRateLimits,
+		timeNow:          time.Now,
 	}
 	}
 }
 }
 
 
@@ -983,6 +805,8 @@ func NewOServiceServiceForAdmin(
 	relationshipFetcher RelationshipFetcher,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	sessionRetriever SessionRetriever,
 	buddyIconManager BuddyIconManager,
 	buddyIconManager BuddyIconManager,
+	rateLimitClasses wire.RateLimitClasses,
+	snacRateLimits wire.SNACRateLimits,
 ) *OServiceService {
 ) *OServiceService {
 	return &OServiceService{
 	return &OServiceService{
 		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
@@ -992,6 +816,9 @@ func NewOServiceServiceForAdmin(
 			wire.OService,
 			wire.OService,
 			wire.Admin,
 			wire.Admin,
 		},
 		},
+		rateLimitClasses: rateLimitClasses,
+		snacRateLimits:   snacRateLimits,
+		timeNow:          time.Now,
 	}
 	}
 }
 }
 
 
@@ -1004,6 +831,8 @@ func NewOServiceServiceForBART(
 	relationshipFetcher RelationshipFetcher,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	sessionRetriever SessionRetriever,
 	buddyIconManager BuddyIconManager,
 	buddyIconManager BuddyIconManager,
+	rateLimitClasses wire.RateLimitClasses,
+	snacRateLimits wire.SNACRateLimits,
 ) *OServiceService {
 ) *OServiceService {
 	return &OServiceService{
 	return &OServiceService{
 		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
@@ -1013,5 +842,8 @@ func NewOServiceServiceForBART(
 			wire.BART,
 			wire.BART,
 			wire.OService,
 			wire.OService,
 		},
 		},
+		rateLimitClasses: rateLimitClasses,
+		snacRateLimits:   snacRateLimits,
+		timeNow:          time.Now,
 	}
 	}
 }
 }

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 253 - 952
foodgroup/oservice_test.go


+ 3 - 1
go.mod

@@ -1,6 +1,6 @@
 module github.com/mk6i/retro-aim-server
 module github.com/mk6i/retro-aim-server
 
 
-go 1.23
+go 1.24.2
 
 
 require (
 require (
 	github.com/golang-migrate/migrate/v4 v4.18.2
 	github.com/golang-migrate/migrate/v4 v4.18.2
@@ -8,9 +8,11 @@ require (
 	github.com/joho/godotenv v1.5.1
 	github.com/joho/godotenv v1.5.1
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/mitchellh/go-wordwrap v1.0.1
 	github.com/mitchellh/go-wordwrap v1.0.1
+	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/stretchr/testify v1.10.0
 	github.com/stretchr/testify v1.10.0
 	golang.org/x/net v0.34.0
 	golang.org/x/net v0.34.0
 	golang.org/x/sync v0.10.0
 	golang.org/x/sync v0.10.0
+	golang.org/x/time v0.11.0
 	modernc.org/sqlite v1.34.5
 	modernc.org/sqlite v1.34.5
 )
 )
 
 

+ 4 - 0
go.sum

@@ -23,6 +23,8 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ
 github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
 github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
+github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -44,6 +46,8 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
 golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
 golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
 golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
 golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

+ 1 - 1
server/http/mock_account_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_buddy_icon_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_chat_room_creator_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_chat_room_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_chat_session_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_directory_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_feedbag_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_message_relayer_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_profile_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_session_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 1 - 1
server/http/mock_user_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package http
 package http
 
 

+ 36 - 2
server/oscar/admin.go

@@ -9,6 +9,7 @@ import (
 	"log/slog"
 	"log/slog"
 	"net"
 	"net"
 	"sync"
 	"sync"
+	"time"
 
 
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
@@ -25,6 +26,8 @@ type AdminServer struct {
 	Logger     *slog.Logger
 	Logger     *slog.Logger
 	OnlineNotifier
 	OnlineNotifier
 	config.Config
 	config.Config
+	RateLimitUpdater
+	wire.SNACRateLimits
 }
 }
 
 
 // Start starts a TCP server and listens for connections. The initial
 // Start starts a TCP server and listens for connections. The initial
@@ -109,10 +112,20 @@ func (rt AdminServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteC
 		return err
 		return err
 	}
 	}
 
 
-	return dispatchIncomingMessagesSimple(ctx, sess, flapc, rwc, rt.Logger, rt.Handler)
+	return dispatchIncomingMessagesSimple(ctx, sess, flapc, rwc, rt.Logger, rt.Handler, rt.RateLimitUpdater, rt.SNACRateLimits)
 }
 }
 
 
-func dispatchIncomingMessagesSimple(ctx context.Context, sess *state.Session, flapc *wire.FlapClient, r io.Reader, logger *slog.Logger, router Handler) error {
+func dispatchIncomingMessagesSimple(
+	ctx context.Context,
+	sess *state.Session,
+	flapc *wire.FlapClient,
+	r io.Reader,
+	logger *slog.Logger,
+	router Handler,
+	rateLimitUpdater RateLimitUpdater,
+	snacRateLimits wire.SNACRateLimits,
+) error {
+
 	defer func() {
 	defer func() {
 		logger.InfoContext(ctx, "user disconnected")
 		logger.InfoContext(ctx, "user disconnected")
 	}()
 	}()
@@ -150,6 +163,19 @@ func dispatchIncomingMessagesSimple(ctx context.Context, sess *state.Session, fl
 				if err := wire.UnmarshalBE(&inFrame, flapBuf); err != nil {
 				if err := wire.UnmarshalBE(&inFrame, flapBuf); err != nil {
 					return err
 					return err
 				}
 				}
+
+				rateClassID, ok := snacRateLimits.RateClassLookup(inFrame.FoodGroup, inFrame.SubGroup)
+				if ok {
+					if status := sess.EvaluateRateLimit(time.Now(), rateClassID); status == wire.RateLimitStatusLimited {
+						logger.DebugContext(ctx, "rate limit exceeded, dropping SNAC",
+							"foodgroup", wire.FoodGroupName(inFrame.FoodGroup),
+							"subgroup", wire.SubGroupName(inFrame.FoodGroup, inFrame.SubGroup))
+						break
+					}
+				} else {
+					logger.ErrorContext(ctx, "rate limit not found, allowing request through")
+				}
+
 				// route a client request to the appropriate service handler. the
 				// route a client request to the appropriate service handler. the
 				// handler may write a response to the client connection.
 				// handler may write a response to the client connection.
 				if err := router.Handle(ctx, sess, inFrame, flapBuf, flapc); err != nil {
 				if err := router.Handle(ctx, sess, inFrame, flapBuf, flapc); err != nil {
@@ -174,6 +200,14 @@ func dispatchIncomingMessagesSimple(ctx context.Context, sess *state.Session, fl
 			default:
 			default:
 				return fmt.Errorf("got unknown FLAP frame type. flap: %v", flap)
 				return fmt.Errorf("got unknown FLAP frame type. flap: %v", flap)
 			}
 			}
+		case <-time.After(1 * time.Second):
+			msgs := rateLimitUpdater.RateLimitUpdates(ctx, sess, time.Now())
+			for _, rate := range msgs {
+				if err := flapc.SendSNAC(rate.Frame, rate.Body); err != nil {
+					middleware.LogRequestError(ctx, logger, rate.Frame, err)
+					return err
+				}
+			}
 		case <-ctx.Done():
 		case <-ctx.Done():
 			// application is shutting down
 			// application is shutting down
 			if err := flapc.Disconnect(); err != nil {
 			if err := flapc.Disconnect(); err != nil {

+ 109 - 3
server/oscar/auth.go

@@ -9,12 +9,15 @@ import (
 	"log/slog"
 	"log/slog"
 	"net"
 	"net"
 	"sync"
 	"sync"
+	"time"
 
 
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/mk6i/retro-aim-server/wire"
 
 
 	"github.com/google/uuid"
 	"github.com/google/uuid"
+	"github.com/patrickmn/go-cache"
+	"golang.org/x/time/rate"
 )
 )
 
 
 type AuthService interface {
 type AuthService interface {
@@ -28,12 +31,69 @@ type AuthService interface {
 	SignoutChat(ctx context.Context, sess *state.Session)
 	SignoutChat(ctx context.Context, sess *state.Session)
 }
 }
 
 
+// IPRateLimiter enforces a per-IP rate limit using a token bucket algorithm.
+// It caches individual rate limiters by IP address and supports tagging requests
+// as originating from the BUCP or FLAP auth.
+//
+// The limiter uses an in-memory cache with TTL expiration, so rate limits reset
+// after the TTL if no activity is observed for a given IP.
+type IPRateLimiter struct {
+	cache *cache.Cache // In-memory cache mapping IPs to rate limiters with optional BUCP tag
+	rate  rate.Limit   // Requests allowed per second
+	burst int          // Maximum burst size allowed
+}
+
+type rateLimitEntry struct {
+	isBUCP  bool
+	limiter *rate.Limiter
+}
+
+// NewIPRateLimiter initializes a new IPRateLimiter with the specified rate,
+// burst size, and TTL for each IP's limiter. Entries expire after 2×TTL.
+func NewIPRateLimiter(rate rate.Limit, burst int, ttl time.Duration) *IPRateLimiter {
+	return &IPRateLimiter{
+		cache: cache.New(ttl, 2*ttl),
+		rate:  rate,
+		burst: burst,
+	}
+}
+
+// SetBUCP marks the rate limiter for the given IP as originating from BUCP auth
+// (default FLAP auth).
+func (l *IPRateLimiter) SetBUCP(ip string) {
+	limiter, found := l.cache.Get(ip)
+	if !found {
+		limiter = &rateLimitEntry{
+			isBUCP:  true,
+			limiter: rate.NewLimiter(l.rate, l.burst),
+		}
+		l.cache.Set(ip, limiter, cache.DefaultExpiration)
+	}
+	limiter.(*rateLimitEntry).isBUCP = true
+}
+
+// Allow checks if a request from the given IP is allowed under its rate limit.
+// It returns whether the request is allowed and whether the connection uses
+// BUCP auth.
+func (l *IPRateLimiter) Allow(ip string) (allowed bool, isBUCP bool) {
+	limiter, found := l.cache.Get(ip)
+	if !found {
+		limiter = &rateLimitEntry{
+			limiter: rate.NewLimiter(l.rate, l.burst),
+		}
+		l.cache.Set(ip, limiter, cache.DefaultExpiration)
+	}
+	entry := limiter.(*rateLimitEntry)
+	return entry.limiter.Allow(), entry.isBUCP
+}
+
 // AuthServer is an authentication server for both FLAP (AIM v1.0-3.0) and BUCP
 // AuthServer is an authentication server for both FLAP (AIM v1.0-3.0) and BUCP
 // (AIM v3.5-5.9) authentication flows.
 // (AIM v3.5-5.9) authentication flows.
 type AuthServer struct {
 type AuthServer struct {
 	AuthService
 	AuthService
 	config.Config
 	config.Config
 	Logger *slog.Logger
 	Logger *slog.Logger
+	*IPRateLimiter
 }
 }
 
 
 // Start starts the authentication server and listens for new connections.
 // Start starts the authentication server and listens for new connections.
@@ -82,13 +142,49 @@ func (rt AuthServer) Start(ctx context.Context) error {
 	return nil
 	return nil
 }
 }
 
 
-func (rt AuthServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
-	defer rwc.Close()
+func (rt AuthServer) handleNewConnection(ctx context.Context, conn net.Conn) error {
+	defer conn.Close()
+
+	flapc := wire.NewFlapClient(100, conn, conn)
+
+	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, isBUCP := rt.Allow(ip); !ok {
+		rt.Logger.Error("user rate limited at login", "remote", ip)
+		tlv := wire.TLVRestBlock{
+			TLVList: []wire.TLV{
+				wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrRateLimitExceeded),
+			},
+		}
+		// gives wrong response if you quickly switch between BUCP/FLAP clients
+		if isBUCP {
+			return flapc.SendSNAC(
+				wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
+				},
+				wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: tlv,
+				},
+			)
+		} else {
+			return flapc.SendSignoffFrame(tlv)
+		}
+	}
+
+	// auth must complete within the next 30 seconds
+	if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
+		return fmt.Errorf("failed to set deadline: %w", err)
+	}
 
 
-	flapc := wire.NewFlapClient(100, rwc, rwc)
 	if err := flapc.SendSignonFrame(nil); err != nil {
 	if err := flapc.SendSignonFrame(nil); err != nil {
 		return err
 		return err
 	}
 	}
+
 	signonFrame, err := flapc.ReceiveSignonFrame()
 	signonFrame, err := flapc.ReceiveSignonFrame()
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -103,6 +199,8 @@ func (rt AuthServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCl
 		return rt.processFLAPAuth(ctx, signonFrame, flapc)
 		return rt.processFLAPAuth(ctx, signonFrame, flapc)
 	}
 	}
 
 
+	rt.SetBUCP(ip)
+
 	return rt.processBUCPAuth(ctx, flapc)
 	return rt.processBUCPAuth(ctx, flapc)
 }
 }
 
 
@@ -115,12 +213,20 @@ func (rt AuthServer) processFLAPAuth(ctx context.Context, signonFrame wire.FLAPS
 }
 }
 
 
 func (rt AuthServer) processBUCPAuth(ctx context.Context, flapc *wire.FlapClient) error {
 func (rt AuthServer) processBUCPAuth(ctx context.Context, flapc *wire.FlapClient) error {
+	frames := 0
+
 	for {
 	for {
 		frame, err := flapc.ReceiveFLAP()
 		frame, err := flapc.ReceiveFLAP()
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
 
 
+		if frames > 10 {
+			// a lot of frames received, the client is misbehaving
+			return fmt.Errorf("too many auth flap packets received")
+		}
+		frames++
+
 		switch frame.FrameType {
 		switch frame.FrameType {
 		case wire.FLAPFrameSignoff:
 		case wire.FLAPFrameSignoff:
 			rt.Logger.Debug("signed off mid-login")
 			rt.Logger.Debug("signed off mid-login")

+ 33 - 16
server/oscar/auth_test.go

@@ -3,24 +3,45 @@ package oscar
 import (
 import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
-	"io"
 	"log/slog"
 	"log/slog"
+	"net"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/mk6i/retro-aim-server/wire"
-
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/mock"
+	"golang.org/x/time/rate"
 )
 )
 
 
+type fakeConn struct {
+	net.Conn // embed the real connection
+	local    net.Addr
+	remote   net.Addr
+}
+
+func (f fakeConn) RemoteAddr() net.Addr { return f.remote }
+
 func TestBUCPAuthService_handleNewConnection(t *testing.T) {
 func TestBUCPAuthService_handleNewConnection(t *testing.T) {
-	clientReader, serverWriter := io.Pipe()
-	serverReader, clientWriter := io.Pipe()
+	serverConn, clientConn := net.Pipe()
+
+	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
+	if err != nil {
+		panic(err)
+	}
+
+	clientFake := fakeConn{
+		Conn:   clientConn,
+		local:  addr,
+		remote: addr,
+	}
 
 
 	go func() {
 	go func() {
+		defer serverConn.Close()
+
 		// < receive FLAPSignonFrame
 		// < receive FLAPSignonFrame
 		flap := wire.FLAPFrame{}
 		flap := wire.FLAPFrame{}
-		assert.NoError(t, wire.UnmarshalBE(&flap, serverReader))
+		assert.NoError(t, wire.UnmarshalBE(&flap, serverConn))
 		flapSignonFrame := wire.FLAPSignonFrame{}
 		flapSignonFrame := wire.FLAPSignonFrame{}
 		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
 		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
 
 
@@ -35,10 +56,10 @@ func TestBUCPAuthService_handleNewConnection(t *testing.T) {
 			FrameType:   wire.FLAPFrameSignon,
 			FrameType:   wire.FLAPFrameSignon,
 			Payload:     buf.Bytes(),
 			Payload:     buf.Bytes(),
 		}
 		}
-		assert.NoError(t, wire.MarshalBE(flap, serverWriter))
+		assert.NoError(t, wire.MarshalBE(flap, serverConn))
 
 
 		// > send SNAC_0x17_0x06_BUCPChallengeRequest
 		// > send SNAC_0x17_0x06_BUCPChallengeRequest
-		flapc := wire.NewFlapClient(0, serverReader, serverWriter)
+		flapc := wire.NewFlapClient(0, serverConn, serverConn)
 		frame := wire.SNACFrame{
 		frame := wire.SNACFrame{
 			FoodGroup: wire.BUCP,
 			FoodGroup: wire.BUCP,
 			SubGroup:  wire.BUCPChallengeRequest,
 			SubGroup:  wire.BUCPChallengeRequest,
@@ -65,8 +86,6 @@ func TestBUCPAuthService_handleNewConnection(t *testing.T) {
 		frame = wire.SNACFrame{}
 		frame = wire.SNACFrame{}
 		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x03_BUCPLoginResponse{}))
 		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x03_BUCPLoginResponse{}))
 		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPLoginResponse}, frame)
 		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPLoginResponse}, frame)
-
-		assert.NoError(t, serverWriter.Close())
 	}()
 	}()
 
 
 	authService := newMockAuthService(t)
 	authService := newMockAuthService(t)
@@ -90,12 +109,10 @@ func TestBUCPAuthService_handleNewConnection(t *testing.T) {
 		}, nil)
 		}, nil)
 
 
 	rt := AuthServer{
 	rt := AuthServer{
-		AuthService: authService,
-		Logger:      slog.Default(),
-	}
-	rwc := pipeRWC{
-		PipeReader: clientReader,
-		PipeWriter: clientWriter,
+		AuthService:   authService,
+		Logger:        slog.Default(),
+		IPRateLimiter: NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 	}
 	}
-	assert.NoError(t, rt.handleNewConnection(context.Background(), rwc))
+
+	assert.NoError(t, rt.handleNewConnection(context.Background(), clientFake))
 }
 }

+ 8 - 1
server/oscar/bos.go

@@ -44,6 +44,11 @@ type ChatSessionManager interface {
 	RemoveUserFromAllChats(user state.IdentScreenName)
 	RemoveUserFromAllChats(user state.IdentScreenName)
 }
 }
 
 
+// RateLimitUpdater provides rate limit updates for subscribed rate limit classes.
+type RateLimitUpdater interface {
+	RateLimitUpdates(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage
+}
+
 // BOSServer provides client connection lifecycle management for the BOS
 // BOSServer provides client connection lifecycle management for the BOS
 // service.
 // service.
 type BOSServer struct {
 type BOSServer struct {
@@ -56,6 +61,8 @@ type BOSServer struct {
 	OnlineNotifier
 	OnlineNotifier
 	config.Config
 	config.Config
 	ChatSessionManager *state.InMemoryChatSessionManager
 	ChatSessionManager *state.InMemoryChatSessionManager
+	RateLimitUpdater
+	wire.SNACRateLimits
 }
 }
 
 
 // Start starts a TCP server and listens for connections. The initial
 // Start starts a TCP server and listens for connections. The initial
@@ -203,5 +210,5 @@ func (rt BOSServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteClo
 		sess.SetRemoteAddr(&ip)
 		sess.SetRemoteAddr(&ip)
 	}
 	}
 
 
-	return dispatchIncomingMessages(ctx, sess, flapc, rwc, rt.Logger, rt.Handler)
+	return dispatchIncomingMessages(ctx, sess, flapc, rwc, rt.Logger, rt.Handler, rt.RateLimitUpdater, rt.SNACRateLimits)
 }
 }

+ 3 - 1
server/oscar/chat.go

@@ -22,6 +22,8 @@ type ChatServer struct {
 	Logger *slog.Logger
 	Logger *slog.Logger
 	OnlineNotifier
 	OnlineNotifier
 	config.Config
 	config.Config
+	RateLimitUpdater
+	wire.SNACRateLimits
 }
 }
 
 
 // Start creates a TCP server that implements that chat flow.
 // Start creates a TCP server that implements that chat flow.
@@ -108,5 +110,5 @@ func (rt ChatServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCl
 	}
 	}
 
 
 	ctx = context.WithValue(ctx, "screenName", chatSess.IdentScreenName())
 	ctx = context.WithValue(ctx, "screenName", chatSess.IdentScreenName())
-	return dispatchIncomingMessages(ctx, chatSess, flapc, rwc, rt.Logger, rt.Handler)
+	return dispatchIncomingMessages(ctx, chatSess, flapc, rwc, rt.Logger, rt.Handler, rt.RateLimitUpdater, rt.SNACRateLimits)
 }
 }

+ 24 - 1
server/oscar/connection.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"log/slog"
 	"log/slog"
+	"time"
 
 
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/state"
@@ -33,7 +34,8 @@ func sendInvalidSNACErr(frameIn wire.SNACFrame, rw ResponseWriter) error {
 // or when the session closes.
 // or when the session closes.
 //
 //
 // todo: this method has too many params and should be folded into a new type
 // todo: this method has too many params and should be folded into a new type
-func dispatchIncomingMessages(ctx context.Context, sess *state.Session, flapc *wire.FlapClient, r io.Reader, logger *slog.Logger, router Handler) error {
+func dispatchIncomingMessages(ctx context.Context, sess *state.Session, flapc *wire.FlapClient, r io.Reader, logger *slog.Logger, router Handler, rateLimitUpdater RateLimitUpdater, snacRateLimits wire.SNACRateLimits) error {
+
 	defer func() {
 	defer func() {
 		logger.InfoContext(ctx, "user disconnected")
 		logger.InfoContext(ctx, "user disconnected")
 	}()
 	}()
@@ -71,6 +73,19 @@ func dispatchIncomingMessages(ctx context.Context, sess *state.Session, flapc *w
 				if err := wire.UnmarshalBE(&inFrame, flapBuf); err != nil {
 				if err := wire.UnmarshalBE(&inFrame, flapBuf); err != nil {
 					return err
 					return err
 				}
 				}
+
+				rateClassID, ok := snacRateLimits.RateClassLookup(inFrame.FoodGroup, inFrame.SubGroup)
+				if ok {
+					if status := sess.EvaluateRateLimit(time.Now(), rateClassID); status == wire.RateLimitStatusLimited {
+						logger.DebugContext(ctx, "rate limit exceeded, dropping SNAC",
+							"foodgroup", wire.FoodGroupName(inFrame.FoodGroup),
+							"subgroup", wire.SubGroupName(inFrame.FoodGroup, inFrame.SubGroup))
+						break
+					}
+				} else {
+					logger.ErrorContext(ctx, "rate limit not found, allowing request through")
+				}
+
 				// route a client request to the appropriate service handler. the
 				// route a client request to the appropriate service handler. the
 				// handler may write a response to the client connection.
 				// handler may write a response to the client connection.
 				if err := router.Handle(ctx, sess, inFrame, flapBuf, flapc); err != nil {
 				if err := router.Handle(ctx, sess, inFrame, flapBuf, flapc); err != nil {
@@ -102,6 +117,14 @@ func dispatchIncomingMessages(ctx context.Context, sess *state.Session, flapc *w
 				return err
 				return err
 			}
 			}
 			middleware.LogRequest(ctx, logger, m.Frame, m.Body)
 			middleware.LogRequest(ctx, logger, m.Frame, m.Body)
+		case <-time.After(1 * time.Second):
+			msgs := rateLimitUpdater.RateLimitUpdates(ctx, sess, time.Now())
+			for _, rate := range msgs {
+				if err := flapc.SendSNAC(rate.Frame, rate.Body); err != nil {
+					middleware.LogRequestError(ctx, logger, rate.Frame, err)
+					return err
+				}
+			}
 		case <-sess.Closed():
 		case <-sess.Closed():
 			block := wire.TLVRestBlock{}
 			block := wire.TLVRestBlock{}
 			// error code indicating user signed in a different location
 			// error code indicating user signed in a different location

+ 60 - 5
server/oscar/connection_test.go

@@ -7,12 +7,12 @@ import (
 	"log/slog"
 	"log/slog"
 	"sync"
 	"sync"
 	"testing"
 	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
+	"time"
 
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/mk6i/retro-aim-server/wire"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 )
 )
 
 
 func TestHandleChatConnection_MessageRelay(t *testing.T) {
 func TestHandleChatConnection_MessageRelay(t *testing.T) {
@@ -25,7 +25,8 @@ func TestHandleChatConnection_MessageRelay(t *testing.T) {
 	clientReader, serverWriter := io.Pipe()
 	clientReader, serverWriter := io.Pipe()
 	go func() {
 	go func() {
 		flapc := wire.NewFlapClient(0, nil, serverWriter)
 		flapc := wire.NewFlapClient(0, nil, serverWriter)
-		err := dispatchIncomingMessages(context.Background(), sess, flapc, serverReader, slog.Default(), nil)
+		rateLimitUpdater := newMockRateLimitUpdater(t)
+		err := dispatchIncomingMessages(context.Background(), sess, flapc, serverReader, slog.Default(), nil, rateLimitUpdater, wire.DefaultSNACRateLimits())
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 	}()
 	}()
 
 
@@ -91,6 +92,7 @@ func TestHandleChatConnection_ClientRequest(t *testing.T) {
 	sessionManager := state.NewInMemorySessionManager(slog.Default())
 	sessionManager := state.NewInMemorySessionManager(slog.Default())
 	// add session so that the function can terminate upon closure
 	// add session so that the function can terminate upon closure
 	sess, _ := sessionManager.AddSession(nil, "bob")
 	sess, _ := sessionManager.AddSession(nil, "bob")
+	sess.SetRateClasses(time.Now(), defaultRateLimitClasses())
 
 
 	inboundMsgs := []wire.SNACMessage{
 	inboundMsgs := []wire.SNACMessage{
 		{
 		{
@@ -144,7 +146,8 @@ func TestHandleChatConnection_ClientRequest(t *testing.T) {
 	clientReader, serverWriter := io.Pipe()
 	clientReader, serverWriter := io.Pipe()
 	go func() {
 	go func() {
 		flapc := wire.NewFlapClient(0, nil, serverWriter)
 		flapc := wire.NewFlapClient(0, nil, serverWriter)
-		assert.NoError(t, dispatchIncomingMessages(context.Background(), sess, flapc, serverReader, slog.Default(), router))
+		rateLimitUpdater := newMockRateLimitUpdater(t)
+		assert.NoError(t, dispatchIncomingMessages(context.Background(), sess, flapc, serverReader, slog.Default(), router, rateLimitUpdater, wire.DefaultSNACRateLimits()))
 	}()
 	}()
 
 
 	// send client messages
 	// send client messages
@@ -165,3 +168,55 @@ func TestHandleChatConnection_ClientRequest(t *testing.T) {
 	assert.NoError(t, wire.UnmarshalBE(&flap, clientReader))
 	assert.NoError(t, wire.UnmarshalBE(&flap, clientReader))
 	assert.Equal(t, wire.FLAPFrameSignoff, flap.FrameType)
 	assert.Equal(t, wire.FLAPFrameSignoff, flap.FrameType)
 }
 }
+
+func defaultRateLimitClasses() wire.RateLimitClasses {
+	return wire.NewRateLimitClasses(
+		[5]wire.RateClass{
+			{
+				ID:              1,
+				WindowSize:      80,
+				ClearLevel:      2500,
+				AlertLevel:      2000,
+				LimitLevel:      1500,
+				DisconnectLevel: 800,
+				MaxLevel:        6000,
+			},
+			{
+				ID:              2,
+				WindowSize:      80,
+				ClearLevel:      3000,
+				AlertLevel:      2000,
+				LimitLevel:      1500,
+				DisconnectLevel: 1000,
+				MaxLevel:        6000,
+			},
+			{
+				ID:              3,
+				WindowSize:      20,
+				ClearLevel:      5100,
+				AlertLevel:      5000,
+				LimitLevel:      4000,
+				DisconnectLevel: 3000,
+				MaxLevel:        6000,
+			},
+			{
+				ID:              4,
+				WindowSize:      20,
+				ClearLevel:      5500,
+				AlertLevel:      5300,
+				LimitLevel:      4200,
+				DisconnectLevel: 3000,
+				MaxLevel:        8000,
+			},
+			{
+				ID:              5,
+				WindowSize:      10,
+				ClearLevel:      5500,
+				AlertLevel:      5300,
+				LimitLevel:      4200,
+				DisconnectLevel: 3000,
+				MaxLevel:        8000,
+			},
+		},
+	)
+}

+ 1 - 1
server/oscar/handler/mock_admin_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_bart_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_buddy_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_chat_nav_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_chat_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_feedbag_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_icbm_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_icq_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_locate_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_odir_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 22 - 20
server/oscar/handler/mock_oservice_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 
@@ -72,17 +72,17 @@ func (_c *mockOServiceService_ClientOnline_Call) RunAndReturn(run func(context.C
 	return _c
 	return _c
 }
 }
 
 
-// ClientVersions provides a mock function with given fields: ctx, frame, bodyIn
-func (_m *mockOServiceService) ClientVersions(ctx context.Context, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage {
-	ret := _m.Called(ctx, frame, bodyIn)
+// ClientVersions provides a mock function with given fields: ctx, sess, frame, bodyIn
+func (_m *mockOServiceService) ClientVersions(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage {
+	ret := _m.Called(ctx, sess, frame, bodyIn)
 
 
 	if len(ret) == 0 {
 	if len(ret) == 0 {
 		panic("no return value specified for ClientVersions")
 		panic("no return value specified for ClientVersions")
 	}
 	}
 
 
 	var r0 wire.SNACMessage
 	var r0 wire.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage); ok {
-		r0 = rf(ctx, frame, bodyIn)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, frame, bodyIn)
 	} else {
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 	}
@@ -97,15 +97,16 @@ type mockOServiceService_ClientVersions_Call struct {
 
 
 // ClientVersions is a helper method to define mock.On call
 // ClientVersions is a helper method to define mock.On call
 //   - ctx context.Context
 //   - ctx context.Context
+//   - sess *state.Session
 //   - frame wire.SNACFrame
 //   - frame wire.SNACFrame
 //   - bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions
 //   - bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions
-func (_e *mockOServiceService_Expecter) ClientVersions(ctx interface{}, frame interface{}, bodyIn interface{}) *mockOServiceService_ClientVersions_Call {
-	return &mockOServiceService_ClientVersions_Call{Call: _e.mock.On("ClientVersions", ctx, frame, bodyIn)}
+func (_e *mockOServiceService_Expecter) ClientVersions(ctx interface{}, sess interface{}, frame interface{}, bodyIn interface{}) *mockOServiceService_ClientVersions_Call {
+	return &mockOServiceService_ClientVersions_Call{Call: _e.mock.On("ClientVersions", ctx, sess, frame, bodyIn)}
 }
 }
 
 
-func (_c *mockOServiceService_ClientVersions_Call) Run(run func(ctx context.Context, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions)) *mockOServiceService_ClientVersions_Call {
+func (_c *mockOServiceService_ClientVersions_Call) Run(run func(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions)) *mockOServiceService_ClientVersions_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x01_0x17_OServiceClientVersions))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x01_0x17_OServiceClientVersions))
 	})
 	})
 	return _c
 	return _c
 }
 }
@@ -115,7 +116,7 @@ func (_c *mockOServiceService_ClientVersions_Call) Return(_a0 wire.SNACMessage)
 	return _c
 	return _c
 }
 }
 
 
-func (_c *mockOServiceService_ClientVersions_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage) *mockOServiceService_ClientVersions_Call {
+func (_c *mockOServiceService_ClientVersions_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage) *mockOServiceService_ClientVersions_Call {
 	_c.Call.Return(run)
 	_c.Call.Return(run)
 	return _c
 	return _c
 }
 }
@@ -261,9 +262,9 @@ func (_c *mockOServiceService_RateParamsQuery_Call) RunAndReturn(run func(contex
 	return _c
 	return _c
 }
 }
 
 
-// RateParamsSubAdd provides a mock function with given fields: _a0, _a1
-func (_m *mockOServiceService) RateParamsSubAdd(_a0 context.Context, _a1 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd) {
-	_m.Called(_a0, _a1)
+// RateParamsSubAdd provides a mock function with given fields: _a0, _a1, _a2
+func (_m *mockOServiceService) RateParamsSubAdd(_a0 context.Context, _a1 *state.Session, _a2 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd) {
+	_m.Called(_a0, _a1, _a2)
 }
 }
 
 
 // mockOServiceService_RateParamsSubAdd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RateParamsSubAdd'
 // mockOServiceService_RateParamsSubAdd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RateParamsSubAdd'
@@ -273,14 +274,15 @@ type mockOServiceService_RateParamsSubAdd_Call struct {
 
 
 // RateParamsSubAdd is a helper method to define mock.On call
 // RateParamsSubAdd is a helper method to define mock.On call
 //   - _a0 context.Context
 //   - _a0 context.Context
-//   - _a1 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd
-func (_e *mockOServiceService_Expecter) RateParamsSubAdd(_a0 interface{}, _a1 interface{}) *mockOServiceService_RateParamsSubAdd_Call {
-	return &mockOServiceService_RateParamsSubAdd_Call{Call: _e.mock.On("RateParamsSubAdd", _a0, _a1)}
+//   - _a1 *state.Session
+//   - _a2 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd
+func (_e *mockOServiceService_Expecter) RateParamsSubAdd(_a0 interface{}, _a1 interface{}, _a2 interface{}) *mockOServiceService_RateParamsSubAdd_Call {
+	return &mockOServiceService_RateParamsSubAdd_Call{Call: _e.mock.On("RateParamsSubAdd", _a0, _a1, _a2)}
 }
 }
 
 
-func (_c *mockOServiceService_RateParamsSubAdd_Call) Run(run func(_a0 context.Context, _a1 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)) *mockOServiceService_RateParamsSubAdd_Call {
+func (_c *mockOServiceService_RateParamsSubAdd_Call) Run(run func(_a0 context.Context, _a1 *state.Session, _a2 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)) *mockOServiceService_RateParamsSubAdd_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd))
 	})
 	})
 	return _c
 	return _c
 }
 }
@@ -290,7 +292,7 @@ func (_c *mockOServiceService_RateParamsSubAdd_Call) Return() *mockOServiceServi
 	return _c
 	return _c
 }
 }
 
 
-func (_c *mockOServiceService_RateParamsSubAdd_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)) *mockOServiceService_RateParamsSubAdd_Call {
+func (_c *mockOServiceService_RateParamsSubAdd_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)) *mockOServiceService_RateParamsSubAdd_Call {
 	_c.Run(run)
 	_c.Run(run)
 	return _c
 	return _c
 }
 }

+ 1 - 1
server/oscar/handler/mock_permit_deny_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_response_writer_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 1 - 1
server/oscar/handler/mock_user_lookup_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package handler
 package handler
 
 

+ 7 - 7
server/oscar/handler/oservice.go

@@ -14,11 +14,11 @@ import (
 
 
 type OServiceService interface {
 type OServiceService interface {
 	ClientOnline(ctx context.Context, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error
 	ClientOnline(ctx context.Context, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error
-	ClientVersions(ctx context.Context, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage
+	ClientVersions(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage
 	HostOnline() wire.SNACMessage
 	HostOnline() wire.SNACMessage
 	IdleNotification(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification) error
 	IdleNotification(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification) error
 	RateParamsQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame) wire.SNACMessage
 	RateParamsQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame) wire.SNACMessage
-	RateParamsSubAdd(context.Context, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
+	RateParamsSubAdd(context.Context, *state.Session, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
 	ServiceRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error)
 	ServiceRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error)
 	SetPrivacyFlags(ctx context.Context, bodyIn wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags)
 	SetPrivacyFlags(ctx context.Context, bodyIn wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags)
 	SetUserInfoFields(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error)
 	SetUserInfoFields(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error)
@@ -45,14 +45,14 @@ func (h OServiceHandler) RateParamsQuery(ctx context.Context, sess *state.Sessio
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 }
 }
 
 
-func (h OServiceHandler) RateParamsSubAdd(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
+func (h OServiceHandler) RateParamsSubAdd(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
 	inBody := wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd{}
 	inBody := wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 		return err
 		return err
 	}
 	}
-	h.OServiceService.RateParamsSubAdd(ctx, inBody)
+	h.OServiceService.RateParamsSubAdd(ctx, sess, inBody)
 	h.LogRequest(ctx, inFrame, inBody)
 	h.LogRequest(ctx, inFrame, inBody)
-	return wire.UnmarshalBE(&inBody, r)
+	return nil
 }
 }
 
 
 func (h OServiceHandler) UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
 func (h OServiceHandler) UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
@@ -70,12 +70,12 @@ func (h OServiceHandler) IdleNotification(ctx context.Context, sess *state.Sessi
 	return h.OServiceService.IdleNotification(ctx, sess, inBody)
 	return h.OServiceService.IdleNotification(ctx, sess, inBody)
 }
 }
 
 
-func (h OServiceHandler) ClientVersions(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+func (h OServiceHandler) ClientVersions(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
 	inBody := wire.SNAC_0x01_0x17_OServiceClientVersions{}
 	inBody := wire.SNAC_0x01_0x17_OServiceClientVersions{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 		return err
 		return err
 	}
 	}
-	outSNAC := h.OServiceService.ClientVersions(ctx, inFrame, inBody)
+	outSNAC := h.OServiceService.ClientVersions(ctx, sess, inFrame, inBody)
 	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
 	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 }
 }

+ 10 - 11
server/oscar/handler/oservice_test.go

@@ -2,10 +2,12 @@ package handler
 
 
 import (
 import (
 	"bytes"
 	"bytes"
+	"context"
 	"log/slog"
 	"log/slog"
 	"testing"
 	"testing"
 
 
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/mk6i/retro-aim-server/wire"
 
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
@@ -179,9 +181,10 @@ func TestOServiceHandler_ClientVersions(t *testing.T) {
 		},
 		},
 	}
 	}
 
 
+	sess := state.NewSession()
 	svc := newMockOServiceService(t)
 	svc := newMockOServiceService(t)
 	svc.EXPECT().
 	svc.EXPECT().
-		ClientVersions(mock.Anything, input.Frame, input.Body).
+		ClientVersions(mock.Anything, sess, input.Frame, input.Body).
 		Return(output)
 		Return(output)
 
 
 	h := OServiceHandler{
 	h := OServiceHandler{
@@ -199,7 +202,7 @@ func TestOServiceHandler_ClientVersions(t *testing.T) {
 	buf := &bytes.Buffer{}
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
 
-	assert.NoError(t, h.ClientVersions(nil, nil, input.Frame, buf, responseWriter))
+	assert.NoError(t, h.ClientVersions(context.Background(), sess, input.Frame, buf, responseWriter))
 }
 }
 
 
 func TestOServiceHandler_RateParamsQuery(t *testing.T) {
 func TestOServiceHandler_RateParamsQuery(t *testing.T) {
@@ -260,17 +263,15 @@ func TestOServiceHandler_RateParamsSubAdd(t *testing.T) {
 			SubGroup:  wire.OServiceRateParamsSubAdd,
 			SubGroup:  wire.OServiceRateParamsSubAdd,
 		},
 		},
 		Body: wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd{
 		Body: wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x01, []byte{1, 2, 3, 4}),
-				},
-			},
+			ClassIDs: []uint16{1, 2, 3, 4},
 		},
 		},
 	}
 	}
 
 
+	sess := state.NewSession()
+
 	svc := newMockOServiceService(t)
 	svc := newMockOServiceService(t)
 	svc.EXPECT().
 	svc.EXPECT().
-		RateParamsSubAdd(mock.Anything, input.Body)
+		RateParamsSubAdd(mock.Anything, sess, input.Body)
 
 
 	h := OServiceHandler{
 	h := OServiceHandler{
 		OServiceService: svc,
 		OServiceService: svc,
@@ -279,12 +280,10 @@ func TestOServiceHandler_RateParamsSubAdd(t *testing.T) {
 		},
 		},
 	}
 	}
 
 
-	responseWriter := newMockResponseWriter(t)
-
 	buf := &bytes.Buffer{}
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
 
-	assert.NoError(t, h.RateParamsSubAdd(nil, nil, input.Frame, buf, responseWriter))
+	assert.NoError(t, h.RateParamsSubAdd(context.Background(), sess, input.Frame, buf, nil))
 }
 }
 
 
 func TestOServiceHandler_SetUserInfoFields(t *testing.T) {
 func TestOServiceHandler_SetUserInfoFields(t *testing.T) {

+ 0 - 174
server/oscar/mock_BuddyListRegistry.go

@@ -1,174 +0,0 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
-
-package oscar
-
-import (
-	"context"
-
-	state "github.com/mk6i/retro-aim-server/state"
-	mock "github.com/stretchr/testify/mock"
-)
-
-// mockBuddyListRegistry is an autogenerated mock type for the BuddyListRegistry type
-type mockBuddyListRegistry struct {
-	mock.Mock
-}
-
-type mockBuddyListRegistry_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockBuddyListRegistry) EXPECT() *mockBuddyListRegistry_Expecter {
-	return &mockBuddyListRegistry_Expecter{mock: &_m.Mock}
-}
-
-// ClearBuddyListRegistry provides a mock function with no fields
-func (_m *mockBuddyListRegistry) ClearBuddyListRegistry(context.Context) error {
-	ret := _m.Called()
-
-	if len(ret) == 0 {
-		panic("no return value specified for ClearBuddyListRegistry")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func() error); ok {
-		r0 = rf()
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockBuddyListRegistry_ClearBuddyListRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearBuddyListRegistry'
-type mockBuddyListRegistry_ClearBuddyListRegistry_Call struct {
-	*mock.Call
-}
-
-// ClearBuddyListRegistry is a helper method to define mock.On call
-func (_e *mockBuddyListRegistry_Expecter) ClearBuddyListRegistry() *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
-	return &mockBuddyListRegistry_ClearBuddyListRegistry_Call{Call: _e.mock.On("ClearBuddyListRegistry")}
-}
-
-func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) Run(run func()) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run()
-	})
-	return _c
-}
-
-func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) Return(_a0 error) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) RunAndReturn(run func() error) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// RegisterBuddyList provides a mock function with given fields: user
-func (_m *mockBuddyListRegistry) RegisterBuddyList(ctx context.Context, user state.IdentScreenName) error {
-	ret := _m.Called(user)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RegisterBuddyList")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(state.IdentScreenName) error); ok {
-		r0 = rf(user)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockBuddyListRegistry_RegisterBuddyList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterBuddyList'
-type mockBuddyListRegistry_RegisterBuddyList_Call struct {
-	*mock.Call
-}
-
-// RegisterBuddyList is a helper method to define mock.On call
-//   - user state.IdentScreenName
-func (_e *mockBuddyListRegistry_Expecter) RegisterBuddyList(user interface{}) *mockBuddyListRegistry_RegisterBuddyList_Call {
-	return &mockBuddyListRegistry_RegisterBuddyList_Call{Call: _e.mock.On("RegisterBuddyList", user)}
-}
-
-func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) Run(run func(user state.IdentScreenName)) *mockBuddyListRegistry_RegisterBuddyList_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(state.IdentScreenName))
-	})
-	return _c
-}
-
-func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) Return(_a0 error) *mockBuddyListRegistry_RegisterBuddyList_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) RunAndReturn(run func(state.IdentScreenName) error) *mockBuddyListRegistry_RegisterBuddyList_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// UnregisterBuddyList provides a mock function with given fields: user
-func (_m *mockBuddyListRegistry) UnregisterBuddyList(ctx context.Context, user state.IdentScreenName) error {
-	ret := _m.Called(user)
-
-	if len(ret) == 0 {
-		panic("no return value specified for UnregisterBuddyList")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(state.IdentScreenName) error); ok {
-		r0 = rf(user)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockBuddyListRegistry_UnregisterBuddyList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnregisterBuddyList'
-type mockBuddyListRegistry_UnregisterBuddyList_Call struct {
-	*mock.Call
-}
-
-// UnregisterBuddyList is a helper method to define mock.On call
-//   - user state.IdentScreenName
-func (_e *mockBuddyListRegistry_Expecter) UnregisterBuddyList(user interface{}) *mockBuddyListRegistry_UnregisterBuddyList_Call {
-	return &mockBuddyListRegistry_UnregisterBuddyList_Call{Call: _e.mock.On("UnregisterBuddyList", user)}
-}
-
-func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) Run(run func(user state.IdentScreenName)) *mockBuddyListRegistry_UnregisterBuddyList_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(state.IdentScreenName))
-	})
-	return _c
-}
-
-func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) Return(_a0 error) *mockBuddyListRegistry_UnregisterBuddyList_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) RunAndReturn(run func(state.IdentScreenName) error) *mockBuddyListRegistry_UnregisterBuddyList_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockBuddyListRegistry creates a new instance of mockBuddyListRegistry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func newMockBuddyListRegistry(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockBuddyListRegistry {
-	mock := &mockBuddyListRegistry{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 68
server/oscar/mock_ChatSessionManager.go

@@ -1,68 +0,0 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
-
-package oscar
-
-import (
-	state "github.com/mk6i/retro-aim-server/state"
-	mock "github.com/stretchr/testify/mock"
-)
-
-// mockChatSessionManager is an autogenerated mock type for the ChatSessionManager type
-type mockChatSessionManager struct {
-	mock.Mock
-}
-
-type mockChatSessionManager_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockChatSessionManager) EXPECT() *mockChatSessionManager_Expecter {
-	return &mockChatSessionManager_Expecter{mock: &_m.Mock}
-}
-
-// RemoveUserFromAllChats provides a mock function with given fields: user
-func (_m *mockChatSessionManager) RemoveUserFromAllChats(user state.IdentScreenName) {
-	_m.Called(user)
-}
-
-// mockChatSessionManager_RemoveUserFromAllChats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveUserFromAllChats'
-type mockChatSessionManager_RemoveUserFromAllChats_Call struct {
-	*mock.Call
-}
-
-// RemoveUserFromAllChats is a helper method to define mock.On call
-//   - user state.IdentScreenName
-func (_e *mockChatSessionManager_Expecter) RemoveUserFromAllChats(user interface{}) *mockChatSessionManager_RemoveUserFromAllChats_Call {
-	return &mockChatSessionManager_RemoveUserFromAllChats_Call{Call: _e.mock.On("RemoveUserFromAllChats", user)}
-}
-
-func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) Run(run func(user state.IdentScreenName)) *mockChatSessionManager_RemoveUserFromAllChats_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(state.IdentScreenName))
-	})
-	return _c
-}
-
-func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) Return() *mockChatSessionManager_RemoveUserFromAllChats_Call {
-	_c.Call.Return()
-	return _c
-}
-
-func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) RunAndReturn(run func(state.IdentScreenName)) *mockChatSessionManager_RemoveUserFromAllChats_Call {
-	_c.Run(run)
-	return _c
-}
-
-// newMockChatSessionManager creates a new instance of mockChatSessionManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func newMockChatSessionManager(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockChatSessionManager {
-	mock := &mockChatSessionManager{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 84
server/oscar/mock_DepartureNotifier.go

@@ -1,84 +0,0 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
-
-package oscar
-
-import (
-	context "context"
-
-	state "github.com/mk6i/retro-aim-server/state"
-	mock "github.com/stretchr/testify/mock"
-)
-
-// mockDepartureNotifier is an autogenerated mock type for the DepartureNotifier type
-type mockDepartureNotifier struct {
-	mock.Mock
-}
-
-type mockDepartureNotifier_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockDepartureNotifier) EXPECT() *mockDepartureNotifier_Expecter {
-	return &mockDepartureNotifier_Expecter{mock: &_m.Mock}
-}
-
-// BroadcastBuddyDeparted provides a mock function with given fields: ctx, sess
-func (_m *mockDepartureNotifier) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
-	ret := _m.Called(ctx, sess)
-
-	if len(ret) == 0 {
-		panic("no return value specified for BroadcastBuddyDeparted")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
-		r0 = rf(ctx, sess)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockDepartureNotifier_BroadcastBuddyDeparted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyDeparted'
-type mockDepartureNotifier_BroadcastBuddyDeparted_Call struct {
-	*mock.Call
-}
-
-// BroadcastBuddyDeparted is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockDepartureNotifier_Expecter) BroadcastBuddyDeparted(ctx interface{}, sess interface{}) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
-	return &mockDepartureNotifier_BroadcastBuddyDeparted_Call{Call: _e.mock.On("BroadcastBuddyDeparted", ctx, sess)}
-}
-
-func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
-	})
-	return _c
-}
-
-func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) Return(_a0 error) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockDepartureNotifier creates a new instance of mockDepartureNotifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func newMockDepartureNotifier(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockDepartureNotifier {
-	mock := &mockDepartureNotifier{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 91
server/oscar/mock_HandlerFunc.go

@@ -1,91 +0,0 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
-
-package oscar
-
-import (
-	context "context"
-	io "io"
-
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-
-	wire "github.com/mk6i/retro-aim-server/wire"
-)
-
-// mockHandlerFunc is an autogenerated mock type for the HandlerFunc type
-type mockHandlerFunc struct {
-	mock.Mock
-}
-
-type mockHandlerFunc_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockHandlerFunc) EXPECT() *mockHandlerFunc_Expecter {
-	return &mockHandlerFunc_Expecter{mock: &_m.Mock}
-}
-
-// Execute provides a mock function with given fields: ctx, sess, inFrame, r, rw
-func (_m *mockHandlerFunc) Execute(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
-	ret := _m.Called(ctx, sess, inFrame, r, rw)
-
-	if len(ret) == 0 {
-		panic("no return value specified for Execute")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, io.Reader, ResponseWriter) error); ok {
-		r0 = rf(ctx, sess, inFrame, r, rw)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockHandlerFunc_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
-type mockHandlerFunc_Execute_Call struct {
-	*mock.Call
-}
-
-// Execute is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame wire.SNACFrame
-//   - r io.Reader
-//   - rw ResponseWriter
-func (_e *mockHandlerFunc_Expecter) Execute(ctx interface{}, sess interface{}, inFrame interface{}, r interface{}, rw interface{}) *mockHandlerFunc_Execute_Call {
-	return &mockHandlerFunc_Execute_Call{Call: _e.mock.On("Execute", ctx, sess, inFrame, r, rw)}
-}
-
-func (_c *mockHandlerFunc_Execute_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter)) *mockHandlerFunc_Execute_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(io.Reader), args[4].(ResponseWriter))
-	})
-	return _c
-}
-
-func (_c *mockHandlerFunc_Execute_Call) Return(_a0 error) *mockHandlerFunc_Execute_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockHandlerFunc_Execute_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, io.Reader, ResponseWriter) error) *mockHandlerFunc_Execute_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockHandlerFunc creates a new instance of mockHandlerFunc. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func newMockHandlerFunc(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockHandlerFunc {
-	mock := &mockHandlerFunc{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 93
server/oscar/mock_SessionManager.go

@@ -1,93 +0,0 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
-
-package oscar
-
-import (
-	state "github.com/mk6i/retro-aim-server/state"
-	mock "github.com/stretchr/testify/mock"
-)
-
-// mockSessionManager is an autogenerated mock type for the SessionManager type
-type mockSessionManager struct {
-	mock.Mock
-}
-
-type mockSessionManager_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockSessionManager) EXPECT() *mockSessionManager_Expecter {
-	return &mockSessionManager_Expecter{mock: &_m.Mock}
-}
-
-// RetrieveBOSSession provides a mock function with given fields: authCookie
-func (_m *mockSessionManager) RetrieveBOSSession(authCookie []byte) (*state.Session, error) {
-	ret := _m.Called(authCookie)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RetrieveBOSSession")
-	}
-
-	var r0 *state.Session
-	var r1 error
-	if rf, ok := ret.Get(0).(func([]byte) (*state.Session, error)); ok {
-		return rf(authCookie)
-	}
-	if rf, ok := ret.Get(0).(func([]byte) *state.Session); ok {
-		r0 = rf(authCookie)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*state.Session)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func([]byte) error); ok {
-		r1 = rf(authCookie)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockSessionManager_RetrieveBOSSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveBOSSession'
-type mockSessionManager_RetrieveBOSSession_Call struct {
-	*mock.Call
-}
-
-// RetrieveBOSSession is a helper method to define mock.On call
-//   - authCookie []byte
-func (_e *mockSessionManager_Expecter) RetrieveBOSSession(authCookie interface{}) *mockSessionManager_RetrieveBOSSession_Call {
-	return &mockSessionManager_RetrieveBOSSession_Call{Call: _e.mock.On("RetrieveBOSSession", authCookie)}
-}
-
-func (_c *mockSessionManager_RetrieveBOSSession_Call) Run(run func(authCookie []byte)) *mockSessionManager_RetrieveBOSSession_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].([]byte))
-	})
-	return _c
-}
-
-func (_c *mockSessionManager_RetrieveBOSSession_Call) Return(_a0 *state.Session, _a1 error) *mockSessionManager_RetrieveBOSSession_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockSessionManager_RetrieveBOSSession_Call) RunAndReturn(run func([]byte) (*state.Session, error)) *mockSessionManager_RetrieveBOSSession_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockSessionManager creates a new instance of mockSessionManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func newMockSessionManager(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockSessionManager {
-	mock := &mockSessionManager{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 1 - 1
server/oscar/mock_auth_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package oscar
 package oscar
 
 

+ 1 - 1
server/oscar/mock_handler_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package oscar
 package oscar
 
 

+ 1 - 1
server/oscar/mock_online_notifier_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package oscar
 package oscar
 
 

+ 91 - 0
server/oscar/mock_rate_limit_updater_test.go

@@ -0,0 +1,91 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package oscar
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	time "time"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockRateLimitUpdater is an autogenerated mock type for the RateLimitUpdater type
+type mockRateLimitUpdater struct {
+	mock.Mock
+}
+
+type mockRateLimitUpdater_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockRateLimitUpdater) EXPECT() *mockRateLimitUpdater_Expecter {
+	return &mockRateLimitUpdater_Expecter{mock: &_m.Mock}
+}
+
+// RateLimitUpdates provides a mock function with given fields: ctx, sess, now
+func (_m *mockRateLimitUpdater) RateLimitUpdates(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage {
+	ret := _m.Called(ctx, sess, now)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RateLimitUpdates")
+	}
+
+	var r0 []wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, time.Time) []wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, now)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]wire.SNACMessage)
+		}
+	}
+
+	return r0
+}
+
+// mockRateLimitUpdater_RateLimitUpdates_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RateLimitUpdates'
+type mockRateLimitUpdater_RateLimitUpdates_Call struct {
+	*mock.Call
+}
+
+// RateLimitUpdates is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - now time.Time
+func (_e *mockRateLimitUpdater_Expecter) RateLimitUpdates(ctx interface{}, sess interface{}, now interface{}) *mockRateLimitUpdater_RateLimitUpdates_Call {
+	return &mockRateLimitUpdater_RateLimitUpdates_Call{Call: _e.mock.On("RateLimitUpdates", ctx, sess, now)}
+}
+
+func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) Run(run func(ctx context.Context, sess *state.Session, now time.Time)) *mockRateLimitUpdater_RateLimitUpdates_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(time.Time))
+	})
+	return _c
+}
+
+func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) Return(_a0 []wire.SNACMessage) *mockRateLimitUpdater_RateLimitUpdates_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) RunAndReturn(run func(context.Context, *state.Session, time.Time) []wire.SNACMessage) *mockRateLimitUpdater_RateLimitUpdates_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockRateLimitUpdater creates a new instance of mockRateLimitUpdater. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockRateLimitUpdater(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockRateLimitUpdater {
+	mock := &mockRateLimitUpdater{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 140 - 0
server/toc/cmd_client.go

@@ -13,6 +13,7 @@ import (
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
+	"time"
 
 
 	"github.com/google/uuid"
 	"github.com/google/uuid"
 
 
@@ -128,6 +129,8 @@ type OSCARProxy struct {
 	OServiceServiceChat OServiceService
 	OServiceServiceChat OServiceService
 	PermitDenyService   PermitDenyService
 	PermitDenyService   PermitDenyService
 	TOCConfigStore      TOCConfigStore
 	TOCConfigStore      TOCConfigStore
+	SNACRateLimits      wire.SNACRateLimits
+	HTTPIPRateLimiter   *IPRateLimiter
 }
 }
 
 
 // RecvClientCmd processes a client TOC command and returns a server reply.
 // RecvClientCmd processes a client TOC command and returns a server reply.
@@ -246,6 +249,10 @@ func (s OSCARProxy) RecvClientCmd(
 //
 //
 // Command syntax: toc_add_buddy <Buddy User 1> [<Buddy User2> [<Buddy User 3> [...]]]
 // Command syntax: toc_add_buddy <Buddy User 1> [<Buddy User2> [<Buddy User 3> [...]]]
 func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.Session, args []byte) string {
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.Buddy, wire.BuddyAddBuddies); isLimited {
+		return msg
+	}
+
 	users, err := parseArgs(args)
 	users, err := parseArgs(args)
 	if err != nil {
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
@@ -276,6 +283,10 @@ func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.Session, args []byte
 //
 //
 // Command syntax: toc_add_permit [ <User 1> [<User 2> [...]]]
 // Command syntax: toc_add_permit [ <User 1> [<User 2> [...]]]
 func (s OSCARProxy) AddPermit(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) AddPermit(ctx context.Context, me *state.Session, args []byte) string {
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.PermitDeny, wire.PermitDenyAddDenyListEntries); isLimited {
+		return msg
+	}
+
 	users, err := parseArgs(args)
 	users, err := parseArgs(args)
 	if err != nil {
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
@@ -305,6 +316,10 @@ func (s OSCARProxy) AddPermit(ctx context.Context, me *state.Session, args []byt
 //
 //
 // Command syntax: toc_add_deny [ <User 1> [<User 2> [...]]]
 // Command syntax: toc_add_deny [ <User 1> [<User 2> [...]]]
 func (s OSCARProxy) AddDeny(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) AddDeny(ctx context.Context, me *state.Session, args []byte) string {
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.PermitDeny, wire.PermitDenyAddDenyListEntries); isLimited {
+		return msg
+	}
+
 	users, err := parseArgs(args)
 	users, err := parseArgs(args)
 	if err != nil {
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
@@ -332,6 +347,10 @@ func (s OSCARProxy) AddDeny(ctx context.Context, me *state.Session, args []byte)
 //
 //
 // Command syntax: toc_change_passwd <existing_passwd> <new_passwd>
 // Command syntax: toc_change_passwd <existing_passwd> <new_passwd>
 func (s OSCARProxy) ChangePassword(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) ChangePassword(ctx context.Context, me *state.Session, args []byte) string {
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.Admin, wire.AdminInfoChangeRequest); isLimited {
+		return msg
+	}
+
 	var oldPass, newPass string
 	var oldPass, newPass string
 
 
 	if _, err := parseArgs(args, &oldPass, &newPass); err != nil {
 	if _, err := parseArgs(args, &oldPass, &newPass); err != nil {
@@ -388,6 +407,7 @@ func (s OSCARProxy) ChatAccept(
 	chatRegistry *ChatRegistry,
 	chatRegistry *ChatRegistry,
 	args []byte,
 	args []byte,
 ) (int, string) {
 ) (int, string) {
+
 	var chatIDStr string
 	var chatIDStr string
 
 
 	if _, err := parseArgs(args, &chatIDStr); err != nil {
 	if _, err := parseArgs(args, &chatIDStr); err != nil {
@@ -403,6 +423,10 @@ func (s OSCARProxy) ChatAccept(
 		return 0, s.runtimeErr(ctx, fmt.Errorf("chatRegistry.LookupRoom: no chat found for ID %d", chatID))
 		return 0, s.runtimeErr(ctx, fmt.Errorf("chatRegistry.LookupRoom: no chat found for ID %d", chatID))
 	}
 	}
 
 
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.ChatNav, wire.ChatNavRequestRoomInfo); isLimited {
+		return 0, msg
+	}
+
 	reqRoomSNAC := wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
 	reqRoomSNAC := wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
 		Cookie:         chatInfo.Cookie,
 		Cookie:         chatInfo.Cookie,
 		Exchange:       chatInfo.Exchange,
 		Exchange:       chatInfo.Exchange,
@@ -435,6 +459,10 @@ func (s OSCARProxy) ChatAccept(
 		return 0, s.runtimeErr(ctx, errors.New("roomInfo.Bytes: missing wire.ChatRoomTLVRoomName"))
 		return 0, s.runtimeErr(ctx, errors.New("roomInfo.Bytes: missing wire.ChatRoomTLVRoomName"))
 	}
 	}
 
 
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.OService, wire.OServiceServiceRequest); isLimited {
+		return 0, msg
+	}
+
 	svcReqSNAC := wire.SNAC_0x01_0x04_OServiceServiceRequest{
 	svcReqSNAC := wire.SNAC_0x01_0x04_OServiceServiceRequest{
 		FoodGroup: wire.Chat,
 		FoodGroup: wire.Chat,
 		TLVRestBlock: wire.TLVRestBlock{
 		TLVRestBlock: wire.TLVRestBlock{
@@ -468,6 +496,10 @@ func (s OSCARProxy) ChatAccept(
 		return 0, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterChatSession: %w", err))
 		return 0, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterChatSession: %w", err))
 	}
 	}
 
 
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.OService, wire.OServiceClientOnline); isLimited {
+		return 0, msg
+	}
+
 	if err := s.OServiceServiceChat.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
 	if err := s.OServiceServiceChat.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceChat.ClientOnline: %w", err))
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceChat.ClientOnline: %w", err))
 	}
 	}
@@ -505,6 +537,10 @@ func (s OSCARProxy) ChatInvite(ctx context.Context, me *state.Session, chatRegis
 	}
 	}
 
 
 	for _, guest := range users {
 	for _, guest := range users {
+		if msg, isLimited := s.checkRateLimit(ctx, me, wire.ICBM, wire.ICBMChannelMsgToHost); isLimited {
+			return msg
+		}
+
 		snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 		snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 			ChannelID:  wire.ICBMChannelRendezvous,
 			ChannelID:  wire.ICBMChannelRendezvous,
 			ScreenName: guest,
 			ScreenName: guest,
@@ -568,6 +604,10 @@ func (s OSCARProxy) ChatJoin(
 		return 0, s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
 		return 0, s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
 	}
 	}
 
 
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.Chat, wire.ChatRoomInfoUpdate); isLimited {
+		return 0, msg
+	}
+
 	mkRoomReq := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
 	mkRoomReq := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
 		Exchange: uint16(exchange),
 		Exchange: uint16(exchange),
 		Cookie:   "create",
 		Cookie:   "create",
@@ -599,6 +639,10 @@ func (s OSCARProxy) ChatJoin(
 		return 0, s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
 		return 0, s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
 	}
 	}
 
 
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.OService, wire.OServiceServiceRequest); isLimited {
+		return 0, msg
+	}
+
 	svcReqSNAC := wire.SNAC_0x01_0x04_OServiceServiceRequest{
 	svcReqSNAC := wire.SNAC_0x01_0x04_OServiceServiceRequest{
 		FoodGroup: wire.Chat,
 		FoodGroup: wire.Chat,
 		TLVRestBlock: wire.TLVRestBlock{
 		TLVRestBlock: wire.TLVRestBlock{
@@ -632,6 +676,10 @@ func (s OSCARProxy) ChatJoin(
 		return 0, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterChatSession: %w", err))
 		return 0, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterChatSession: %w", err))
 	}
 	}
 
 
+	if msg, isLimited := s.checkRateLimit(ctx, me, wire.OService, wire.OServiceClientOnline); isLimited {
+		return 0, msg
+	}
+
 	if err := s.OServiceServiceChat.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
 	if err := s.OServiceServiceChat.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceChat.ClientOnline: %w", err))
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceChat.ClientOnline: %w", err))
 	}
 	}
@@ -708,6 +756,10 @@ func (s OSCARProxy) ChatSend(ctx context.Context, chatRegistry *ChatRegistry, ar
 		return s.runtimeErr(ctx, fmt.Errorf("chatRegistry.RetrieveSess: session for chat ID `%d` not found", chatID))
 		return s.runtimeErr(ctx, fmt.Errorf("chatRegistry.RetrieveSess: session for chat ID `%d` not found", chatID))
 	}
 	}
 
 
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Chat, wire.ChatChannelMsgToHost); isLimited {
+		return errMsg
+	}
+
 	block := wire.TLVRestBlock{}
 	block := wire.TLVRestBlock{}
 	// the order of these TLVs matters for AIM 2.x. if out of order, screen
 	// the order of these TLVs matters for AIM 2.x. if out of order, screen
 	// names do not appear with each chat message.
 	// names do not appear with each chat message.
@@ -789,6 +841,10 @@ func (s OSCARProxy) ChatWhisper(ctx context.Context, chatRegistry *ChatRegistry,
 		return s.runtimeErr(ctx, fmt.Errorf("chatRegistry.RetrieveSess: session for chat ID `%d` not found", chatID))
 		return s.runtimeErr(ctx, fmt.Errorf("chatRegistry.RetrieveSess: session for chat ID `%d` not found", chatID))
 	}
 	}
 
 
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Chat, wire.ChatChannelMsgToHost); isLimited {
+		return errMsg
+	}
+
 	block := wire.TLVRestBlock{}
 	block := wire.TLVRestBlock{}
 	block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, me.TLVUserInfo()))
 	block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, me.TLVUserInfo()))
 	block.Append(wire.NewTLVBE(wire.ChatTLVWhisperToUser, recip))
 	block.Append(wire.NewTLVBE(wire.ChatTLVWhisperToUser, recip))
@@ -820,6 +876,10 @@ func (s OSCARProxy) ChatWhisper(ctx context.Context, chatRegistry *ChatRegistry,
 //
 //
 // Command syntax: toc_evil <User> <norm|anon>
 // Command syntax: toc_evil <User> <norm|anon>
 func (s OSCARProxy) Evil(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) Evil(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.ICBM, wire.ICBMEvilRequest); isLimited {
+		return errMsg
+	}
+
 	var user, scope string
 	var user, scope string
 
 
 	if _, err := parseArgs(args, &user, &scope); err != nil {
 	if _, err := parseArgs(args, &user, &scope); err != nil {
@@ -865,6 +925,10 @@ func (s OSCARProxy) Evil(ctx context.Context, me *state.Session, args []byte) st
 //
 //
 // Command syntax: toc_format_nickname <new_format>
 // Command syntax: toc_format_nickname <new_format>
 func (s OSCARProxy) FormatNickname(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) FormatNickname(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Admin, wire.AdminInfoChangeRequest); isLimited {
+		return errMsg
+	}
+
 	var newFormat string
 	var newFormat string
 
 
 	if _, err := parseArgs(args, &newFormat); err != nil {
 	if _, err := parseArgs(args, &newFormat); err != nil {
@@ -922,6 +986,10 @@ func (s OSCARProxy) FormatNickname(ctx context.Context, me *state.Session, args
 //
 //
 // Command syntax: toc_dir_search <info information>
 // Command syntax: toc_dir_search <info information>
 func (s OSCARProxy) GetDirSearchURL(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) GetDirSearchURL(ctx context.Context, me *state.Session, args []byte) string {
+	if status := me.EvaluateRateLimit(time.Now(), 1); status == wire.RateLimitStatusLimited {
+		return rateLimitExceededErr
+	}
+
 	var info string
 	var info string
 
 
 	if _, err := parseArgs(args, &info); err != nil {
 	if _, err := parseArgs(args, &info); err != nil {
@@ -976,6 +1044,10 @@ func (s OSCARProxy) GetDirSearchURL(ctx context.Context, me *state.Session, args
 //
 //
 // Command syntax: toc_get_dir <username>
 // Command syntax: toc_get_dir <username>
 func (s OSCARProxy) GetDirURL(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) GetDirURL(ctx context.Context, me *state.Session, args []byte) string {
+	if status := me.EvaluateRateLimit(time.Now(), 1); status == wire.RateLimitStatusLimited {
+		return rateLimitExceededErr
+	}
+
 	var user string
 	var user string
 
 
 	if _, err := parseArgs(args, &user); err != nil {
 	if _, err := parseArgs(args, &user); err != nil {
@@ -1002,6 +1074,10 @@ func (s OSCARProxy) GetDirURL(ctx context.Context, me *state.Session, args []byt
 //
 //
 // Command syntax: toc_get_info <username>
 // Command syntax: toc_get_info <username>
 func (s OSCARProxy) GetInfoURL(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) GetInfoURL(ctx context.Context, me *state.Session, args []byte) string {
+	if status := me.EvaluateRateLimit(time.Now(), 1); status == wire.RateLimitStatusLimited {
+		return rateLimitExceededErr
+	}
+
 	var user string
 	var user string
 
 
 	if _, err := parseArgs(args, &user); err != nil {
 	if _, err := parseArgs(args, &user); err != nil {
@@ -1031,6 +1107,10 @@ func (s OSCARProxy) GetInfoURL(ctx context.Context, me *state.Session, args []by
 //
 //
 // Command syntax: toc_get_status <screenname>
 // Command syntax: toc_get_status <screenname>
 func (s OSCARProxy) GetStatus(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) GetStatus(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateUserInfoQuery); isLimited {
+		return errMsg
+	}
+
 	var them string
 	var them string
 
 
 	if _, err := parseArgs(args, &them); err != nil {
 	if _, err := parseArgs(args, &them); err != nil {
@@ -1076,6 +1156,9 @@ func (s OSCARProxy) GetStatus(ctx context.Context, me *state.Session, args []byt
 //
 //
 // Command syntax: toc_init_done
 // Command syntax: toc_init_done
 func (s OSCARProxy) InitDone(ctx context.Context, sess *state.Session) string {
 func (s OSCARProxy) InitDone(ctx context.Context, sess *state.Session) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, sess, wire.OService, wire.OServiceClientOnline); isLimited {
+		return errMsg
+	}
 	if err := s.OServiceServiceBOS.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, sess); err != nil {
 	if err := s.OServiceServiceBOS.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, sess); err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ClientOnliney: %w", err))
 		return s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ClientOnliney: %w", err))
 	}
 	}
@@ -1090,6 +1173,10 @@ func (s OSCARProxy) InitDone(ctx context.Context, sess *state.Session) string {
 //
 //
 // Command syntax: toc_remove_buddy <Buddy User 1> [<Buddy User2> [<Buddy User 3> [...]]]
 // Command syntax: toc_remove_buddy <Buddy User 1> [<Buddy User2> [<Buddy User 3> [...]]]
 func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Buddy, wire.BuddyDelBuddies); isLimited {
+		return errMsg
+	}
+
 	users, err := parseArgs(args)
 	users, err := parseArgs(args)
 	if err != nil {
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
@@ -1121,6 +1208,10 @@ func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.Session, args []b
 //
 //
 // Command syntax: toc_rvous_accept <nick> <cookie> <service>
 // Command syntax: toc_rvous_accept <nick> <cookie> <service>
 func (s OSCARProxy) RvousAccept(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) RvousAccept(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.ICBM, wire.ICBMChannelMsgToHost); isLimited {
+		return errMsg
+	}
+
 	var nick, cookie, service string
 	var nick, cookie, service string
 
 
 	if _, err := parseArgs(args, &nick, &cookie, &service); err != nil {
 	if _, err := parseArgs(args, &nick, &cookie, &service); err != nil {
@@ -1174,6 +1265,10 @@ func (s OSCARProxy) RvousAccept(ctx context.Context, me *state.Session, args []b
 //
 //
 // Command syntax: toc_rvous_cancel <nick> <cookie> <service>
 // Command syntax: toc_rvous_cancel <nick> <cookie> <service>
 func (s OSCARProxy) RvousCancel(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) RvousCancel(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.ICBM, wire.ICBMChannelMsgToHost); isLimited {
+		return errMsg
+	}
+
 	var nick, cookie, service string
 	var nick, cookie, service string
 
 
 	if _, err := parseArgs(args, &nick, &cookie, &service); err != nil {
 	if _, err := parseArgs(args, &nick, &cookie, &service); err != nil {
@@ -1229,6 +1324,10 @@ func (s OSCARProxy) RvousCancel(ctx context.Context, me *state.Session, args []b
 //
 //
 // Command syntax: toc_send_im <Destination User> <Message> [auto]
 // Command syntax: toc_send_im <Destination User> <Message> [auto]
 func (s OSCARProxy) SendIM(ctx context.Context, sender *state.Session, args []byte) string {
 func (s OSCARProxy) SendIM(ctx context.Context, sender *state.Session, args []byte) string {
+	if msg, isLimited := s.checkRateLimit(ctx, sender, wire.ICBM, wire.ICBMChannelMsgToHost); isLimited {
+		return msg
+	}
+
 	var recip, msg string
 	var recip, msg string
 
 
 	autoReply, err := parseArgs(args, &recip, &msg)
 	autoReply, err := parseArgs(args, &recip, &msg)
@@ -1277,6 +1376,10 @@ func (s OSCARProxy) SendIM(ctx context.Context, sender *state.Session, args []by
 //
 //
 // Command syntax: toc_set_away [<away message>]
 // Command syntax: toc_set_away [<away message>]
 func (s OSCARProxy) SetAway(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) SetAway(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetInfo); isLimited {
+		return errMsg
+	}
+
 	maybeMsg, err := parseArgs(args)
 	maybeMsg, err := parseArgs(args)
 	if err != nil {
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
@@ -1315,6 +1418,10 @@ func (s OSCARProxy) SetAway(ctx context.Context, me *state.Session, args []byte)
 //
 //
 // Command syntax: toc_set_caps [ <Capability 1> [<Capability 2> [...]]]
 // Command syntax: toc_set_caps [ <Capability 1> [<Capability 2> [...]]]
 func (s OSCARProxy) SetCaps(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) SetCaps(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetInfo); isLimited {
+		return errMsg
+	}
+
 	params, err := parseArgs(args)
 	params, err := parseArgs(args)
 	if err != nil {
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
 		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
@@ -1373,6 +1480,10 @@ func (s OSCARProxy) SetCaps(ctx context.Context, me *state.Session, args []byte)
 //
 //
 // Command syntax: toc_set_config <Config Info>
 // Command syntax: toc_set_config <Config Info>
 func (s OSCARProxy) SetConfig(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) SetConfig(ctx context.Context, me *state.Session, args []byte) string {
+	if status := me.EvaluateRateLimit(time.Now(), 1); status == wire.RateLimitStatusLimited {
+		return rateLimitExceededErr
+	}
+
 	// most TOC clients don't quote the config info argument, despite what the
 	// most TOC clients don't quote the config info argument, despite what the
 	// documentation specifies. this makes the argument payload incompatible
 	// documentation specifies. this makes the argument payload incompatible
 	// for CSV parsing. since this command takes a single argument, we can get
 	// for CSV parsing. since this command takes a single argument, we can get
@@ -1408,6 +1519,10 @@ func (s OSCARProxy) SetConfig(ctx context.Context, me *state.Session, args []byt
 //
 //
 // Command syntax: toc_set_dir <info information>
 // Command syntax: toc_set_dir <info information>
 func (s OSCARProxy) SetDir(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) SetDir(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetDirInfo); isLimited {
+		return errMsg
+	}
+
 	var info string
 	var info string
 
 
 	if _, err := parseArgs(args, &info); err != nil {
 	if _, err := parseArgs(args, &info); err != nil {
@@ -1457,6 +1572,10 @@ func (s OSCARProxy) SetDir(ctx context.Context, me *state.Session, args []byte)
 //
 //
 // Command syntax: toc_set_idle <idle secs>
 // Command syntax: toc_set_idle <idle secs>
 func (s OSCARProxy) SetIdle(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) SetIdle(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.OService, wire.OServiceIdleNotification); isLimited {
+		return errMsg
+	}
+
 	var idleTimeStr string
 	var idleTimeStr string
 
 
 	if _, err := parseArgs(args, &idleTimeStr); err != nil {
 	if _, err := parseArgs(args, &idleTimeStr); err != nil {
@@ -1486,6 +1605,10 @@ func (s OSCARProxy) SetIdle(ctx context.Context, me *state.Session, args []byte)
 //
 //
 // Command syntax: toc_set_info <info information>
 // Command syntax: toc_set_info <info information>
 func (s OSCARProxy) SetInfo(ctx context.Context, me *state.Session, args []byte) string {
 func (s OSCARProxy) SetInfo(ctx context.Context, me *state.Session, args []byte) string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetInfo); isLimited {
+		return errMsg
+	}
+
 	var info string
 	var info string
 
 
 	if _, err := parseArgs(args, &info); err != nil {
 	if _, err := parseArgs(args, &info); err != nil {
@@ -1650,6 +1773,23 @@ func (s OSCARProxy) runtimeErr(ctx context.Context, err error) string {
 	return cmdInternalSvcErr
 	return cmdInternalSvcErr
 }
 }
 
 
+func (s OSCARProxy) checkRateLimit(ctx context.Context, sender *state.Session, foodGroup uint16, subGroup uint16) (string, bool) {
+	rateClassID, ok := s.SNACRateLimits.RateClassLookup(foodGroup, subGroup)
+	if !ok {
+		s.Logger.ErrorContext(ctx, "rate limit not found, allowing request through")
+		return "", false
+	}
+
+	if status := sender.EvaluateRateLimit(time.Now(), rateClassID); status == wire.RateLimitStatusLimited {
+		s.Logger.DebugContext(ctx, "(toc) rate limit exceeded, dropping SNAC",
+			"foodgroup", wire.FoodGroupName(foodGroup),
+			"subgroup", wire.SubGroupName(foodGroup, subGroup))
+		return rateLimitExceededErr, true
+	}
+
+	return "", false
+}
+
 // unescape removes escaping from the following TOC characters: \ { } ( ) [ ] $ "
 // unescape removes escaping from the following TOC characters: \ { } ( ) [ ] $ "
 func unescape(encoded string) string {
 func unescape(encoded string) string {
 	if !strings.ContainsRune(encoded, '\\') {
 	if !strings.ContainsRune(encoded, '\\') {

+ 21 - 2
server/toc/cmd_client_test.go

@@ -508,6 +508,7 @@ func TestOSCARProxy_RecvClientCmd_FormatNickname(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_format_nickname`),
 			givenCmd: []byte(`toc_format_nickname`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -954,6 +955,7 @@ func TestOSCARProxy_RecvClientCmd_ChatInvite(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_chat_invite`),
 			givenCmd: []byte(`toc_chat_invite`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -1281,6 +1283,7 @@ func TestOSCARProxy_RecvClientCmd_ChatLeave(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_chat_leave`),
 			givenCmd: []byte(`toc_chat_leave`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -1497,6 +1500,7 @@ func TestOSCARProxy_RecvClientCmd_ChatSend(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_chat_send`),
 			givenCmd: []byte(`toc_chat_send`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -1623,6 +1627,7 @@ func TestOSCARProxy_RecvClientCmd_ChatWhisper(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_chat_whisper`),
 			givenCmd: []byte(`toc_chat_whisper`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -1777,6 +1782,7 @@ func TestOSCARProxy_RecvClientCmd_Evil(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_evil`),
 			givenCmd: []byte(`toc_evil`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -2005,6 +2011,7 @@ func TestOSCARProxy_RecvClientCmd_ChangePassword(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_change_passwd`),
 			givenCmd: []byte(`toc_change_passwd`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -2102,6 +2109,7 @@ func TestOSCARProxy_RecvClientCmd_GetDirSearchURL(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_dir_search`),
 			givenCmd: []byte(`toc_dir_search`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -2119,8 +2127,9 @@ func TestOSCARProxy_RecvClientCmd_GetDirSearchURL(t *testing.T) {
 			}
 			}
 
 
 			svc := OSCARProxy{
 			svc := OSCARProxy{
-				Logger:      slog.Default(),
-				CookieBaker: cookieBaker,
+				Logger:         slog.Default(),
+				CookieBaker:    cookieBaker,
+				SNACRateLimits: wire.DefaultSNACRateLimits(),
 			}
 			}
 			msg := svc.RecvClientCmd(ctx, tc.me, nil, tc.givenCmd, nil, nil)
 			msg := svc.RecvClientCmd(ctx, tc.me, nil, tc.givenCmd, nil, nil)
 
 
@@ -2177,6 +2186,7 @@ func TestOSCARProxy_RecvClientCmd_GetDirURL(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_get_dir`),
 			givenCmd: []byte(`toc_get_dir`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -2252,6 +2262,7 @@ func TestOSCARProxy_RecvClientCmd_GetInfoURL(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_get_info`),
 			givenCmd: []byte(`toc_get_info`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -2413,6 +2424,7 @@ func TestOSCARProxy_RecvClientCmd_GetStatus(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_get_status`),
 			givenCmd: []byte(`toc_get_status`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -2681,6 +2693,7 @@ func TestOSCARProxy_RecvClientCmd_RvousAccept(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_rvous_accept`),
 			givenCmd: []byte(`toc_rvous_accept`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -2790,6 +2803,7 @@ func TestOSCARProxy_RecvClientCmd_RvousCancel(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_rvous_cancel`),
 			givenCmd: []byte(`toc_rvous_cancel`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -2950,6 +2964,7 @@ func TestOSCARProxy_RecvClientCmd_SendIM(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_send_im`),
 			givenCmd: []byte(`toc_send_im`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -3291,6 +3306,7 @@ func TestOSCARProxy_RecvClientCmd_SetConfig(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_set_config`),
 			givenCmd: []byte(`toc_set_config`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -3469,6 +3485,7 @@ func TestOSCARProxy_RecvClientCmd_SetDir(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_set_dir`),
 			givenCmd: []byte(`toc_set_dir`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -3553,6 +3570,7 @@ func TestOSCARProxy_RecvClientCmd_SetIdle(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_set_idle`),
 			givenCmd: []byte(`toc_set_idle`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},
@@ -3640,6 +3658,7 @@ func TestOSCARProxy_RecvClientCmd_SetInfo(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "bad command",
 			name:     "bad command",
+			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_set_info`),
 			givenCmd: []byte(`toc_set_info`),
 			wantMsg:  cmdInternalSvcErr,
 			wantMsg:  cmdInternalSvcErr,
 		},
 		},

+ 3 - 2
server/toc/cmd_server.go

@@ -16,8 +16,9 @@ import (
 )
 )
 
 
 var (
 var (
-	cmdInternalSvcErr = "ERROR:989:internal server error"
-	errDisconnect     = errors.New("got booted by another session")
+	cmdInternalSvcErr    = "ERROR:989:internal server error"
+	rateLimitExceededErr = "ERROR:903"
+	errDisconnect        = errors.New("got booted by another session")
 )
 )
 
 
 // RecvBOS routes incoming SNAC messages from the BOS server to their
 // RecvBOS routes incoming SNAC messages from the BOS server to their

+ 48 - 0
server/toc/helpers_test.go

@@ -2,6 +2,7 @@ package toc
 
 
 import (
 import (
 	"context"
 	"context"
+	"time"
 
 
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/mock"
 
 
@@ -286,6 +287,53 @@ func newTestSession(screenName state.DisplayScreenName, options ...func(session
 	s := state.NewSession()
 	s := state.NewSession()
 	s.SetIdentScreenName(screenName.IdentScreenName())
 	s.SetIdentScreenName(screenName.IdentScreenName())
 	s.SetDisplayScreenName(screenName)
 	s.SetDisplayScreenName(screenName)
+	s.SetRateClasses(time.Now(), wire.NewRateLimitClasses([5]wire.RateClass{
+		{
+			ID:              1,
+			WindowSize:      80,
+			ClearLevel:      2500,
+			AlertLevel:      2000,
+			LimitLevel:      1500,
+			DisconnectLevel: 800,
+			MaxLevel:        6000,
+		},
+		{
+			ID:              2,
+			WindowSize:      80,
+			ClearLevel:      3000,
+			AlertLevel:      2000,
+			LimitLevel:      1500,
+			DisconnectLevel: 1000,
+			MaxLevel:        6000,
+		},
+		{
+			ID:              3,
+			WindowSize:      20,
+			ClearLevel:      5100,
+			AlertLevel:      5000,
+			LimitLevel:      4000,
+			DisconnectLevel: 3000,
+			MaxLevel:        6000,
+		},
+		{
+			ID:              4,
+			WindowSize:      20,
+			ClearLevel:      5500,
+			AlertLevel:      5300,
+			LimitLevel:      4200,
+			DisconnectLevel: 3000,
+			MaxLevel:        8000,
+		},
+		{
+			ID:              5,
+			WindowSize:      10,
+			ClearLevel:      5500,
+			AlertLevel:      5300,
+			LimitLevel:      4200,
+			DisconnectLevel: 3000,
+			MaxLevel:        8000,
+		},
+	}))
 	for _, op := range options {
 	for _, op := range options {
 		op(s)
 		op(s)
 	}
 	}

+ 21 - 3
server/toc/http.go

@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"fmt"
 	"html/template"
 	"html/template"
 	"io"
 	"io"
+	"net"
 	"net/http"
 	"net/http"
 
 
 	"golang.org/x/net/html"
 	"golang.org/x/net/html"
@@ -69,12 +70,29 @@ func init() {
 // NewServeMux creates and returns an HTTP mux that serves all TOC routes.
 // NewServeMux creates and returns an HTTP mux that serves all TOC routes.
 func (s OSCARProxy) NewServeMux() http.Handler {
 func (s OSCARProxy) NewServeMux() http.Handler {
 	mux := http.NewServeMux()
 	mux := http.NewServeMux()
-	mux.Handle("GET /info", s.AuthMiddleware(http.HandlerFunc(s.ProfileHandler)))
-	mux.Handle("GET /dir_info", s.AuthMiddleware(http.HandlerFunc(s.DirInfoHandler)))
-	mux.Handle("GET /dir_search", s.AuthMiddleware(http.HandlerFunc(s.DirSearchHandler)))
+	mux.Handle("GET /info", s.RateLimiterMiddleware(s.AuthMiddleware(http.HandlerFunc(s.ProfileHandler))))
+	mux.Handle("GET /dir_info", s.RateLimiterMiddleware(s.AuthMiddleware(http.HandlerFunc(s.DirInfoHandler))))
+	mux.Handle("GET /dir_search", s.RateLimiterMiddleware(s.AuthMiddleware(http.HandlerFunc(s.DirSearchHandler))))
 	return mux
 	return mux
 }
 }
 
 
+func (s OSCARProxy) RateLimiterMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ip, _, err := net.SplitHostPort(r.RemoteAddr)
+		if err != nil {
+			http.Error(w, "internal server error", http.StatusInternalServerError)
+			s.Logger.Error("failed to parse remote address", "err", err.Error())
+			return
+		}
+
+		if !s.HTTPIPRateLimiter.Allow(ip) {
+			http.Error(w, "rate limited", http.StatusTooManyRequests)
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
 // AuthMiddleware is an HTTP middleware that enforces authentication using an
 // AuthMiddleware is an HTTP middleware that enforces authentication using an
 // authorization cookie provided as a query parameter. It validates and decrypts
 // authorization cookie provided as a query parameter. It validates and decrypts
 // the cookie before allowing the request to proceed.
 // the cookie before allowing the request to proceed.

+ 9 - 4
server/toc/http_test.go

@@ -7,9 +7,11 @@ import (
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/mock"
+	"golang.org/x/time/rate"
 
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -622,13 +624,16 @@ func TestOSCARProxy_NewServeMux(t *testing.T) {
 			}
 			}
 
 
 			svc := OSCARProxy{
 			svc := OSCARProxy{
-				CookieBaker:      cookieBaker,
-				DirSearchService: dirSearchSvc,
-				LocateService:    locateSvc,
-				Logger:           slog.Default(),
+				CookieBaker:       cookieBaker,
+				DirSearchService:  dirSearchSvc,
+				LocateService:     locateSvc,
+				Logger:            slog.Default(),
+				HTTPIPRateLimiter: NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
+				SNACRateLimits:    wire.DefaultSNACRateLimits(),
 			}
 			}
 
 
 			req, err := http.NewRequest(http.MethodGet, tc.path, nil)
 			req, err := http.NewRequest(http.MethodGet, tc.path, nil)
+			req.RemoteAddr = "127.0.0.1:1234"
 			if err != nil {
 			if err != nil {
 				t.Fatalf("Failed to create request: %v", err)
 				t.Fatalf("Failed to create request: %v", err)
 			}
 			}

+ 1 - 1
server/toc/mock_admin_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_auth_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_buddy_list_registry_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_buddy_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_chat_nav_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_chat_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_cookie_baker_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_dir_search_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_icbm_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_locate_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_oservice_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_permit_deny_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 1 - 1
server/toc/mock_toc_config_store_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.52.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 
 package toc
 package toc
 
 

+ 49 - 3
server/toc/server.go

@@ -15,7 +15,9 @@ import (
 	"syscall"
 	"syscall"
 	"time"
 	"time"
 
 
+	"github.com/patrickmn/go-cache"
 	"golang.org/x/sync/errgroup"
 	"golang.org/x/sync/errgroup"
+	"golang.org/x/time/rate"
 
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -88,13 +90,44 @@ func (l *channelListener) Addr() net.Addr {
 	return nil
 	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
 // Server implements a TOC protocol server that multiplexes TOC/HTTP and
 // TOC/FLAP requests. It acts as a gateway, forwarding all TOC requests
 // TOC/FLAP requests. It acts as a gateway, forwarding all TOC requests
 // to the OSCAR server for processing.
 // to the OSCAR server for processing.
 type Server struct {
 type Server struct {
-	BOSProxy   OSCARProxy
-	ListenAddr string
-	Logger     *slog.Logger
+	BOSProxy           OSCARProxy
+	ListenAddr         string
+	Logger             *slog.Logger
+	LoginIPRateLimiter *IPRateLimiter
 }
 }
 
 
 func (rt Server) Start(ctx context.Context) error {
 func (rt Server) Start(ctx context.Context) error {
@@ -206,6 +239,19 @@ func (rt Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
 		return err
 		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)
 	sessBOS, err := rt.login(ctx, clientFlap)
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("rt.login: %w", err)
 		return fmt.Errorf("rt.login: %w", err)

+ 191 - 21
state/session.go

@@ -11,6 +11,31 @@ import (
 // SessSendStatus is the result of sending a message to a user.
 // SessSendStatus is the result of sending a message to a user.
 type SessSendStatus int
 type SessSendStatus int
 
 
+// RateClassState tracks the rate limiting state for a specific rate class
+// within a user's session.
+//
+// It embeds the static wire.RateClass configuration and maintains dynamic,
+// per-session state used to evaluate rate limits in real time.
+type RateClassState struct {
+	// static rate limit configuration for this class
+	wire.RateClass
+	// CurrentLevel is the current exponential moving average for this rate
+	// class.
+	CurrentLevel int32
+	// LastTime represents the last time a SNAC message was sent for this rate
+	// class.
+	LastTime time.Time
+	// CurrentStatus is the last recorded rate limit status for this rate class.
+	CurrentStatus wire.RateLimitStatus
+	// Subscribed indicates whether the user wants to receive rate limit
+	// parameter updates for this rate class.
+	Subscribed bool
+	// LimitedNow indicates whether the user is currently rate limited for this
+	// rate class; the user is blocked from sending SNACs in this rate class
+	// until the clear threshold is met.
+	LimitedNow bool
+}
+
 const (
 const (
 	// SessSendOK indicates message was sent to recipient
 	// SessSendOK indicates message was sent to recipient
 	SessSendOK SessSendStatus = iota
 	SessSendOK SessSendStatus = iota
@@ -24,40 +49,96 @@ const (
 // Session represents a user's current session. Unless stated otherwise, all
 // Session represents a user's current session. Unless stated otherwise, all
 // methods may be safely accessed by multiple goroutines.
 // methods may be safely accessed by multiple goroutines.
 type Session struct {
 type Session struct {
-	awayMessage       string
-	caps              [][16]byte
-	chatRoomCookie    string
-	closed            bool
-	displayScreenName DisplayScreenName
-	identScreenName   IdentScreenName
-	idle              bool
-	idleTime          time.Time
-	msgCh             chan wire.SNACMessage
-	mutex             sync.RWMutex
-	nowFn             func() time.Time
-	signonComplete    bool
-	signonTime        time.Time
-	stopCh            chan struct{}
-	uin               uint32
-	warning           uint16
-	userInfoBitmask   uint16
-	userStatusBitmask uint32
-	clientID          string
-	remoteAddr        *netip.AddrPort
+	awayMessage        string
+	caps               [][16]byte
+	chatRoomCookie     string
+	closed             bool
+	displayScreenName  DisplayScreenName
+	identScreenName    IdentScreenName
+	idle               bool
+	idleTime           time.Time
+	msgCh              chan wire.SNACMessage
+	mutex              sync.RWMutex
+	nowFn              func() time.Time
+	signonComplete     bool
+	signonTime         time.Time
+	stopCh             chan struct{}
+	uin                uint32
+	warning            uint16
+	userInfoBitmask    uint16
+	userStatusBitmask  uint32
+	clientID           string
+	remoteAddr         *netip.AddrPort
+	lastObservedStates [5]RateClassState
+	rateByClassID      [5]RateClassState
+	foodGroupVersions  [wire.MDir + 1]uint16
 }
 }
 
 
 // NewSession returns a new instance of Session. By default, the user may have
 // NewSession returns a new instance of Session. By default, the user may have
 // up to 1000 pending messages before blocking.
 // up to 1000 pending messages before blocking.
 func NewSession() *Session {
 func NewSession() *Session {
+	now := time.Now()
 	return &Session{
 	return &Session{
 		msgCh:             make(chan wire.SNACMessage, 1000),
 		msgCh:             make(chan wire.SNACMessage, 1000),
 		nowFn:             time.Now,
 		nowFn:             time.Now,
 		stopCh:            make(chan struct{}),
 		stopCh:            make(chan struct{}),
-		signonTime:        time.Now(),
+		signonTime:        now,
 		caps:              make([][16]byte, 0),
 		caps:              make([][16]byte, 0),
 		userInfoBitmask:   wire.OServiceUserFlagOSCARFree,
 		userInfoBitmask:   wire.OServiceUserFlagOSCARFree,
 		userStatusBitmask: wire.OServiceUserStatusAvailable,
 		userStatusBitmask: wire.OServiceUserStatusAvailable,
+		foodGroupVersions: func() [wire.MDir + 1]uint16 {
+			// initialize default food groups versions to 1.0
+			vals := [wire.MDir + 1]uint16{}
+			vals[wire.OService] = 1
+			vals[wire.Locate] = 1
+			vals[wire.Buddy] = 1
+			vals[wire.ICBM] = 1
+			vals[wire.Advert] = 1
+			vals[wire.Invite] = 1
+			vals[wire.Admin] = 1
+			vals[wire.Popup] = 1
+			vals[wire.PermitDeny] = 1
+			vals[wire.UserLookup] = 1
+			vals[wire.Stats] = 1
+			vals[wire.Translate] = 1
+			vals[wire.ChatNav] = 1
+			vals[wire.Chat] = 1
+			vals[wire.ODir] = 1
+			vals[wire.BART] = 1
+			vals[wire.Feedbag] = 1
+			vals[wire.ICQ] = 1
+			vals[wire.BUCP] = 1
+			vals[wire.Alert] = 1
+			vals[wire.Plugin] = 1
+			vals[wire.UnnamedFG24] = 1
+			vals[wire.MDir] = 1
+			return vals
+		}(),
+	}
+}
+
+func (s *Session) SetRateClasses(now time.Time, classes wire.RateLimitClasses) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	var newStates [5]RateClassState
+	for i, class := range classes.All() {
+		newStates[i] = RateClassState{
+			CurrentLevel:  class.MaxLevel,
+			CurrentStatus: wire.RateLimitStatusClear,
+			LastTime:      now,
+			RateClass:     class,
+			Subscribed:    s.lastObservedStates[i].Subscribed,
+		}
+	}
+
+	if s.lastObservedStates[0].ID == 0 {
+		s.lastObservedStates = newStates
+	} else {
+		s.lastObservedStates = s.rateByClassID
 	}
 	}
+
+	s.rateByClassID = newStates
 }
 }
 
 
 // SetRemoteAddr sets the user's remote IP address
 // SetRemoteAddr sets the user's remote IP address
@@ -353,6 +434,10 @@ func (s *Session) RelayMessage(msg wire.SNACMessage) SessSendStatus {
 func (s *Session) Close() {
 func (s *Session) Close() {
 	s.mutex.Lock()
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	defer s.mutex.Unlock()
+	s.close()
+}
+
+func (s *Session) close() {
 	if s.closed {
 	if s.closed {
 		return
 		return
 	}
 	}
@@ -378,3 +463,88 @@ func (s *Session) ClientID() string {
 	defer s.mutex.RUnlock()
 	defer s.mutex.RUnlock()
 	return s.clientID
 	return s.clientID
 }
 }
+
+// SubscribeRateLimits subscribes the Session to updates for the specified
+// rate limit classes. Future calls to ObserveRateChanges will report changes
+// for these classes.
+func (s *Session) SubscribeRateLimits(classes []wire.RateLimitClassID) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	for _, classID := range classes {
+		s.rateByClassID[classID-1].Subscribed = true
+	}
+}
+
+// ObserveRateChanges updates rate limit states for all known classes and returns
+// any classes and class states that have changed since the previous observation.
+func (s *Session) ObserveRateChanges(now time.Time) (classDelta []RateClassState, stateDelta []RateClassState) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	for i, params := range s.rateByClassID {
+		if !params.Subscribed {
+			continue
+		}
+
+		state, level := wire.CheckRateLimit(params.LastTime, now, params.RateClass, params.CurrentLevel, params.LimitedNow)
+		s.rateByClassID[i].CurrentStatus = state
+
+		// clear limited now flag if passing from limited state to clear state
+		if s.rateByClassID[i].LimitedNow && state == wire.RateLimitStatusClear {
+			s.rateByClassID[i].LimitedNow = false
+			s.rateByClassID[i].CurrentLevel = level
+		}
+
+		// did rate class change?
+		if params.RateClass != s.lastObservedStates[i].RateClass {
+			classDelta = append(classDelta, s.rateByClassID[i])
+		}
+
+		// did rate limit status change?
+		if s.lastObservedStates[i].CurrentStatus != s.rateByClassID[i].CurrentStatus {
+			stateDelta = append(stateDelta, s.rateByClassID[i])
+		}
+
+		// save it for next time
+		s.lastObservedStates[i] = s.rateByClassID[i]
+	}
+
+	return classDelta, stateDelta
+}
+
+// EvaluateRateLimit checks and updates the session’s rate limit state
+// for the given rate class ID. If the rate status reaches 'disconnect',
+// the session is closed.
+func (s *Session) EvaluateRateLimit(now time.Time, rateClassID wire.RateLimitClassID) wire.RateLimitStatus {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	rateClass := &s.rateByClassID[rateClassID-1]
+
+	status, newLevel := wire.CheckRateLimit(rateClass.LastTime, now, rateClass.RateClass, rateClass.CurrentLevel, rateClass.LimitedNow)
+	rateClass.CurrentLevel = newLevel
+	rateClass.CurrentStatus = status
+	rateClass.LastTime = now
+	rateClass.LimitedNow = status == wire.RateLimitStatusLimited
+
+	if status == wire.RateLimitStatusDisconnect {
+		s.close()
+	}
+
+	return status
+}
+
+// SetFoodGroupVersions sets the client's supported food group versions
+func (s *Session) SetFoodGroupVersions(versions [wire.MDir + 1]uint16) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.foodGroupVersions = versions
+}
+
+// FoodGroupVersions retrieves the client's supported food group versions.
+func (s *Session) FoodGroupVersions() [wire.MDir + 1]uint16 {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.foodGroupVersions
+}

+ 223 - 0
state/session_test.go

@@ -1,6 +1,7 @@
 package state
 package state
 
 
 import (
 import (
+	"math"
 	"net/netip"
 	"net/netip"
 	"sync"
 	"sync"
 	"testing"
 	"testing"
@@ -354,3 +355,225 @@ func TestSession_Close(t *testing.T) {
 	s.Close()
 	s.Close()
 	<-s.Closed()
 	<-s.Closed()
 }
 }
+
+func TestSession_EvaluateRateLimit_ObserveRateChanges(t *testing.T) {
+	classParams := [5]wire.RateClass{
+		{
+			ID:              1,
+			WindowSize:      80,
+			ClearLevel:      2500,
+			AlertLevel:      2000,
+			LimitLevel:      1500,
+			DisconnectLevel: 800,
+			MaxLevel:        6000,
+		},
+		{
+			ID:              2,
+			WindowSize:      80,
+			ClearLevel:      3000,
+			AlertLevel:      2000,
+			LimitLevel:      1500,
+			DisconnectLevel: 1000,
+			MaxLevel:        6000,
+		},
+		{
+			ID:              3,
+			WindowSize:      20,
+			ClearLevel:      5100,
+			AlertLevel:      5000,
+			LimitLevel:      4000,
+			DisconnectLevel: 3000,
+			MaxLevel:        6000,
+		},
+		{
+			ID:              4,
+			WindowSize:      20,
+			ClearLevel:      5500,
+			AlertLevel:      5300,
+			LimitLevel:      4200,
+			DisconnectLevel: 3000,
+			MaxLevel:        8000,
+		},
+		{
+			ID:              5,
+			WindowSize:      10,
+			ClearLevel:      5500,
+			AlertLevel:      5300,
+			LimitLevel:      4200,
+			DisconnectLevel: 3000,
+			MaxLevel:        8000,
+		},
+	}
+	rateClasses := wire.NewRateLimitClasses(classParams)
+
+	t.Run("we can action every 5 seconds indefinitely without getting rate limited", func(t *testing.T) {
+		now := time.Now()
+
+		sess := NewSession()
+		sess.SetRateClasses(now, rateClasses)
+
+		rateClass := rateClasses.Get(3)
+		sess.SubscribeRateLimits([]wire.RateLimitClassID{rateClass.ID})
+
+		for i := 0; i < 100; i++ {
+			now = now.Add(5 * time.Second)
+			have := sess.EvaluateRateLimit(now, rateClass.ID)
+			assert.Equal(t, wire.RateLimitStatusClear, have)
+		}
+	})
+
+	t.Run("reach disconnect threshold", func(t *testing.T) {
+		now := time.Now()
+
+		sess := NewSession()
+		sess.SetRateClasses(now, rateClasses)
+
+		rateClass := rateClasses.Get(3)
+		sess.SubscribeRateLimits([]wire.RateLimitClassID{rateClass.ID})
+
+		// record some event in the rate limiter
+		want := []wire.RateLimitStatus{
+			wire.RateLimitStatusClear,
+			wire.RateLimitStatusClear,
+			wire.RateLimitStatusClear,
+			wire.RateLimitStatusClear,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusLimited,
+			wire.RateLimitStatusLimited,
+			wire.RateLimitStatusLimited,
+			wire.RateLimitStatusLimited,
+			wire.RateLimitStatusLimited,
+			wire.RateLimitStatusLimited,
+			wire.RateLimitStatusLimited,
+			wire.RateLimitStatusLimited,
+			wire.RateLimitStatusDisconnect,
+		}
+		for i := 0; i < len(want); i++ {
+			now = now.Add(1 * time.Second)
+			have := sess.EvaluateRateLimit(now, rateClass.ID)
+			assert.Equal(t, want[i], have)
+		}
+
+		select {
+		case <-sess.Closed():
+		default:
+			t.Error("expected session to be closed")
+		}
+	})
+
+	t.Run("reach rate limit threshold, wait for clear threshold", func(t *testing.T) {
+		now := time.Now()
+
+		sess := NewSession()
+		sess.SetRateClasses(now, rateClasses)
+
+		rateClass := rateClasses.Get(3)
+		sess.SubscribeRateLimits([]wire.RateLimitClassID{rateClass.ID})
+
+		// first reach the rate limit threshold
+		want := []wire.RateLimitStatus{
+			wire.RateLimitStatusClear,
+			wire.RateLimitStatusClear,
+			wire.RateLimitStatusClear,
+			wire.RateLimitStatusClear,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusAlert,
+			wire.RateLimitStatusLimited,
+		}
+		for i := 0; i < len(want); i++ {
+			now = now.Add(1 * time.Second)
+			have := sess.EvaluateRateLimit(now, rateClass.ID)
+			assert.Equal(t, want[i], have)
+
+			if i > 0 && want[i-1] != want[i] {
+				classChanges, rateChanges := sess.ObserveRateChanges(now)
+				assert.Empty(t, classChanges)
+				if assert.NotEmpty(t, rateChanges) {
+					rateDelta := rateChanges[0]
+					assert.Equal(t, rateClass, rateDelta.RateClass)
+					assert.Equal(t, want[i], rateDelta.CurrentStatus)
+					assert.True(t, rateDelta.Subscribed)
+					if want[i] == wire.RateLimitStatusLimited {
+						assert.True(t, rateDelta.LimitedNow)
+					}
+				}
+			}
+		}
+
+		// this is a rearranged moving average formula that determines how many
+		// milliseconds it will take to reach the clear threshold
+		timeToRecover := int(math.Ceil((time.Duration(rateClass.ClearLevel*rateClass.WindowSize-sess.rateByClassID[rateClass.ID-1].CurrentLevel*(rateClass.WindowSize-1)) * time.Millisecond).Seconds()))
+		assert.True(t, timeToRecover > 0)
+
+		// indicate the time rate limiting kicked in
+		timeLimited := now
+
+		for i := 0; i < timeToRecover; i++ {
+			now = now.Add(1 * time.Second)
+			classDelta, stateDelta := sess.ObserveRateChanges(now)
+			assert.Empty(t, classDelta)
+
+			if i == timeToRecover-1 {
+				// assert that the clear threshold has been met.
+				assert.ElementsMatch(t, stateDelta, []RateClassState{
+					{
+						RateClass:     rateClass,
+						CurrentLevel:  5140,
+						CurrentStatus: wire.RateLimitStatusClear,
+						LastTime:      timeLimited,
+						Subscribed:    true,
+						LimitedNow:    false,
+					}})
+			} else {
+				// assert that no changed have been observed, it's still rate-limited
+				assert.Nil(t, stateDelta)
+			}
+		}
+	})
+
+	t.Run("observe a rate class change", func(t *testing.T) {
+		now := time.Now()
+
+		sess := NewSession()
+		sess.SetRateClasses(now, rateClasses)
+
+		rateClass := rateClasses.Get(3)
+		sess.SubscribeRateLimits([]wire.RateLimitClassID{rateClass.ID})
+
+		now = now.Add(1 * time.Second)
+		classDelta, stateDelta := sess.ObserveRateChanges(now)
+		assert.Empty(t, classDelta)
+		assert.Empty(t, stateDelta)
+
+		paramsCopy := classParams
+		paramsCopy[rateClass.ID-1].LimitLevel++
+
+		newRateClasses := wire.NewRateLimitClasses(paramsCopy)
+
+		now = now.Add(1 * time.Second)
+		sess.SetRateClasses(now, newRateClasses)
+
+		now = now.Add(1 * time.Second)
+		classDelta, stateDelta = sess.ObserveRateChanges(now)
+		assert.Equal(t, classDelta[0].RateClass, newRateClasses.Get(rateClass.ID))
+		assert.Empty(t, stateDelta)
+	})
+}
+
+func TestSession_SetAndGetFoodGroupVersions(t *testing.T) {
+	versions := [wire.MDir + 1]uint16{}
+	versions[wire.Feedbag] = 1
+	versions[wire.OService] = 2
+
+	s := NewSession()
+	s.SetFoodGroupVersions(versions)
+
+	assert.Equal(t, versions, s.FoodGroupVersions())
+}

+ 529 - 0
wire/rate_limit.go

@@ -0,0 +1,529 @@
+package wire
+
+import (
+	"iter"
+	"time"
+)
+
+type (
+	// RateLimitClassID identifies a rate limit class.
+	RateLimitClassID uint16
+
+	// RateLimitStatus represents a session's current rate limiting state.
+	RateLimitStatus uint8
+)
+
+const (
+	// RateLimitStatusLimited indicates the session is currently rate-limited
+	// and should not send further messages in this class.
+	RateLimitStatusLimited RateLimitStatus = 1
+
+	// RateLimitStatusAlert indicates the session is approaching the rate limit threshold
+	// and may soon be limited if activity continues.
+	RateLimitStatusAlert RateLimitStatus = 2
+
+	// RateLimitStatusClear indicates the session is under the limit and in good standing.
+	RateLimitStatusClear RateLimitStatus = 3
+
+	// RateLimitStatusDisconnect indicates the session has exceeded a critical threshold
+	// and should be forcibly disconnected.
+	RateLimitStatusDisconnect RateLimitStatus = 4
+)
+
+// NewRateLimitClasses creates a new RateLimitClasses instance from a fixed array
+// of 5 RateClass definitions.
+//
+// Each RateClass must have a unique ID from 1 to 5, and the array is expected
+// to be ordered such that classes[ID-1] corresponds to RateClass.ID == ID.
+// No validation is performed on the input.
+func NewRateLimitClasses(classes [5]RateClass) RateLimitClasses {
+	return RateLimitClasses{
+		classes: classes,
+	}
+}
+
+// DefaultRateLimitClasses returns the default SNAC rate limit classes used at
+// one point by the original AIM service, as memorialized by the iserverd
+// project.
+func DefaultRateLimitClasses() RateLimitClasses {
+	return RateLimitClasses{
+		classes: [5]RateClass{
+			{
+				ID:              1,
+				WindowSize:      80,
+				ClearLevel:      2500,
+				AlertLevel:      2000,
+				LimitLevel:      1500,
+				DisconnectLevel: 800,
+				MaxLevel:        6000,
+			},
+			{
+				ID:              2,
+				WindowSize:      80,
+				ClearLevel:      3000,
+				AlertLevel:      2000,
+				LimitLevel:      1500,
+				DisconnectLevel: 1000,
+				MaxLevel:        6000,
+			},
+			{
+				ID:              3,
+				WindowSize:      20,
+				ClearLevel:      5100,
+				AlertLevel:      5000,
+				LimitLevel:      4000,
+				DisconnectLevel: 3000,
+				MaxLevel:        6000,
+			},
+			{
+				ID:              4,
+				WindowSize:      20,
+				ClearLevel:      5500,
+				AlertLevel:      5300,
+				LimitLevel:      4200,
+				DisconnectLevel: 3000,
+				MaxLevel:        8000,
+			},
+			{
+				ID:              5,
+				WindowSize:      10,
+				ClearLevel:      5500,
+				AlertLevel:      5300,
+				LimitLevel:      4200,
+				DisconnectLevel: 3000,
+				MaxLevel:        8000,
+			},
+		},
+	}
+}
+
+// RateLimitClasses stores a fixed set of rate limit class definitions.
+//
+// Each RateClass defines thresholds and behavior for computing moving-average-based
+// rate limits. This struct provides access to individual classes by ID
+// or to the full set.
+type RateLimitClasses struct {
+	classes [5]RateClass // Indexed by class ID - 1
+}
+
+// Get returns the RateClass associated with the given class ID.
+//
+// The class ID must be between 1 and 5 inclusive. Calling Get with an invalid
+// ID will panic.
+func (r RateLimitClasses) Get(ID RateLimitClassID) RateClass {
+	return r.classes[ID-1]
+}
+
+// All returns all defined RateClass entries in order of their class IDs.
+func (r RateLimitClasses) All() [5]RateClass {
+	return r.classes
+}
+
+// DefaultSNACRateLimits returns the default SNAC rate limit mapping used at
+// one point by the original AIM service, as memorialized by the iserverd
+// project.
+func DefaultSNACRateLimits() SNACRateLimits {
+	return SNACRateLimits{
+		lookup: map[uint16]map[uint16]RateLimitClassID{
+			OService: {
+				OServiceErr:               1,
+				OServiceClientOnline:      1,
+				OServiceHostOnline:        1,
+				OServiceServiceRequest:    1,
+				OServiceServiceResponse:   1,
+				OServiceRateParamsQuery:   1,
+				OServiceRateParamsReply:   1,
+				OServiceRateParamsSubAdd:  1,
+				OServiceRateDelParamSub:   1,
+				OServiceRateParamChange:   1,
+				OServicePauseReq:          1,
+				OServicePauseAck:          1,
+				OServiceResume:            1,
+				OServiceUserInfoQuery:     1,
+				OServiceUserInfoUpdate:    1,
+				OServiceEvilNotification:  1,
+				OServiceIdleNotification:  1,
+				OServiceMigrateGroups:     1,
+				OServiceMotd:              1,
+				OServiceSetPrivacyFlags:   1,
+				OServiceWellKnownUrls:     1,
+				OServiceNoop:              1,
+				OServiceClientVersions:    1,
+				OServiceHostVersions:      1,
+				OServiceMaxConfigQuery:    1,
+				OServiceMaxConfigReply:    1,
+				OServiceStoreConfig:       1,
+				OServiceConfigQuery:       1,
+				OServiceConfigReply:       1,
+				OServiceSetUserInfoFields: 1,
+				OServiceProbeReq:          1,
+				OServiceProbeAck:          1,
+				OServiceBartReply:         1,
+				OServiceBartQuery2:        1,
+				OServiceBartReply2:        1,
+			},
+			Locate: {
+				LocateErr:                  1,
+				LocateRightsQuery:          1,
+				LocateRightsReply:          1,
+				LocateSetInfo:              1,
+				LocateUserInfoQuery:        3,
+				LocateUserInfoReply:        1,
+				LocateWatcherSubRequest:    1,
+				LocateWatcherNotification:  1,
+				LocateSetDirInfo:           4,
+				LocateSetDirReply:          1,
+				LocateGetDirInfo:           4,
+				LocateGetDirReply:          1,
+				LocateGroupCapabilityQuery: 1,
+				LocateGroupCapabilityReply: 1,
+				LocateSetKeywordInfo:       1,
+				LocateSetKeywordReply:      1,
+				LocateGetKeywordInfo:       1,
+				LocateGetKeywordReply:      1,
+				LocateFindListByEmail:      1,
+				LocateFindListReply:        1,
+				LocateUserInfoQuery2:       1,
+			},
+			Buddy: {
+				BuddyErr:                 1,
+				BuddyRightsQuery:         1,
+				BuddyRightsReply:         1,
+				BuddyAddBuddies:          2,
+				BuddyDelBuddies:          2,
+				BuddyWatcherListQuery:    1,
+				BuddyWatcherListResponse: 1,
+				BuddyWatcherSubRequest:   1,
+				BuddyWatcherNotification: 1,
+				BuddyRejectNotification:  1,
+				BuddyArrived:             1,
+				BuddyDeparted:            1,
+				BuddyAddTempBuddies:      1,
+				BuddyDelTempBuddies:      1,
+			},
+			ICBM: {
+				ICBMErr:                1,
+				ICBMAddParameters:      1,
+				ICBMDelParameters:      1,
+				ICBMParameterQuery:     1,
+				ICBMParameterReply:     1,
+				ICBMChannelMsgToHost:   3,
+				ICBMChannelMsgToClient: 1,
+				ICBMEvilRequest:        1,
+				ICBMEvilReply:          1,
+				ICBMMissedCalls:        1,
+				ICBMClientErr:          1,
+				ICBMHostAck:            1,
+				ICBMSinStored:          1,
+				ICBMSinListQuery:       1,
+				ICBMSinListReply:       1,
+				ICBMSinRetrieve:        1,
+				ICBMSinDelete:          1,
+				ICBMNotifyRequest:      1,
+				ICBMNotifyReply:        1,
+				ICBMClientEvent:        1,
+			},
+			Advert: {
+				AdvertErr:      1,
+				AdvertAdsQuery: 1,
+				AdvertAdsReply: 1,
+			},
+			Invite: {
+				InviteErr:          1,
+				InviteRequestQuery: 1,
+				InviteRequestReply: 1,
+			},
+			Admin: {
+				AdminErr:                1,
+				AdminInfoQuery:          1,
+				AdminInfoReply:          1,
+				AdminInfoChangeRequest:  1,
+				AdminInfoChangeReply:    1,
+				AdminAcctConfirmRequest: 1,
+				AdminAcctConfirmReply:   1,
+				AdminAcctDeleteRequest:  1,
+				AdminAcctDeleteReply:    1,
+			},
+			Popup: {
+				PopupErr:     1,
+				PopupDisplay: 1,
+			},
+			PermitDeny: {
+				PermitDenyErr:                      1,
+				PermitDenyRightsQuery:              1,
+				PermitDenyRightsReply:              1,
+				PermitDenySetGroupPermitMask:       1,
+				PermitDenyAddPermListEntries:       2,
+				PermitDenyDelPermListEntries:       2,
+				PermitDenyAddDenyListEntries:       2,
+				PermitDenyDelDenyListEntries:       2,
+				PermitDenyBosErr:                   1,
+				PermitDenyAddTempPermitListEntries: 1,
+				PermitDenyDelTempPermitListEntries: 1,
+			},
+			UserLookup: {
+				UserLookupErr:         1,
+				UserLookupFindByEmail: 1,
+				UserLookupFindReply:   1,
+			},
+			Stats: {
+				StatsErr:                  1,
+				StatsSetMinReportInterval: 1,
+				StatsReportEvents:         1,
+				StatsReportAck:            1,
+			},
+			Translate: {
+				TranslateErr:     1,
+				TranslateRequest: 1,
+				TranslateReply:   1,
+			},
+			ChatNav: {
+				ChatNavErr:                 1,
+				ChatNavRequestChatRights:   1,
+				ChatNavRequestExchangeInfo: 1,
+				ChatNavRequestRoomInfo:     1,
+				ChatNavRequestMoreRoomInfo: 1,
+				ChatNavRequestOccupantList: 1,
+				ChatNavSearchForRoom:       1,
+				ChatNavCreateRoom:          1,
+				ChatNavNavInfo:             1,
+			},
+			Chat: {
+				ChatErr:                1,
+				ChatRoomInfoUpdate:     1,
+				ChatUsersJoined:        1,
+				ChatUsersLeft:          1,
+				ChatChannelMsgToHost:   2,
+				ChatChannelMsgToClient: 1,
+				ChatEvilRequest:        1,
+				ChatEvilReply:          1,
+				ChatClientErr:          1,
+			},
+			ODir: {
+				ODirErr:              1,
+				ODirInfoQuery:        1,
+				ODirInfoReply:        1,
+				ODirKeywordListQuery: 1,
+			},
+			BART: {
+				BARTErr:            1,
+				BARTUploadQuery:    1,
+				BARTDownloadQuery:  1,
+				BARTDownload2Query: 1,
+			},
+			Feedbag: {
+				FeedbagErr:                      1,
+				FeedbagRightsQuery:              1,
+				FeedbagRightsReply:              1,
+				FeedbagQuery:                    1,
+				FeedbagQueryIfModified:          1,
+				FeedbagReply:                    1,
+				FeedbagUse:                      1,
+				FeedbagInsertItem:               1,
+				FeedbagUpdateItem:               1,
+				FeedbagDeleteItem:               1,
+				FeedbagInsertClass:              1,
+				FeedbagUpdateClass:              1,
+				FeedbagDeleteClass:              1,
+				FeedbagStatus:                   1,
+				FeedbagReplyNotModified:         1,
+				FeedbagDeleteUser:               1,
+				FeedbagStartCluster:             1,
+				FeedbagEndCluster:               1,
+				FeedbagAuthorizeBuddy:           1,
+				FeedbagPreAuthorizeBuddy:        1,
+				FeedbagPreAuthorizedBuddy:       1,
+				FeedbagRemoveMe:                 1,
+				FeedbagRemoveMe2:                1,
+				FeedbagRequestAuthorizeToHost:   1,
+				FeedbagRequestAuthorizeToClient: 1,
+				FeedbagRespondAuthorizeToHost:   1,
+				FeedbagRespondAuthorizeToClient: 1,
+				FeedbagBuddyAdded:               1,
+				FeedbagRequestAuthorizeToBadog:  1,
+				FeedbagRespondAuthorizeToBadog:  1,
+				FeedbagBuddyAddedToBadog:        1,
+				0x0020:                          1, // unknown
+				FeedbagTestSnac:                 1,
+				FeedbagForwardMsg:               1,
+				FeedbagIsAuthRequiredQuery:      1,
+				FeedbagIsAuthRequiredReply:      1,
+				FeedbagRecentBuddyUpdate:        1,
+				0x0026:                          1, // unknown
+				0x0027:                          1, // unknown
+				0x0028:                          1, // unknown
+			},
+			ICQ: {
+				ICQErr:     1,
+				ICQDBQuery: 1,
+				ICQDBReply: 1,
+			},
+			BUCP: {
+				BUCPErr:                      1,
+				BUCPLoginRequest:             1,
+				BUCPRegisterRequest:          1,
+				BUCPChallengeRequest:         1,
+				BUCPAsasnRequest:             1,
+				BUCPSecuridRequest:           1,
+				BUCPRegistrationImageRequest: 1,
+			},
+			Alert: {
+				AlertErr:                       1,
+				AlertSetAlertRequest:           1,
+				AlertGetSubsRequest:            1,
+				AlertNotifyCapabilities:        1,
+				AlertNotify:                    1,
+				AlertGetRuleRequest:            1,
+				AlertGetFeedRequest:            1,
+				AlertRefreshFeed:               1,
+				AlertEvent:                     1,
+				AlertQogSnac:                   1,
+				AlertRefreshFeedStock:          1,
+				AlertNotifyTransport:           1,
+				AlertSetAlertRequestV2:         1,
+				AlertNotifyAck:                 1,
+				AlertNotifyDisplayCapabilities: 1,
+				AlertUserOnline:                1,
+			},
+		},
+	}
+}
+
+// SNACRateLimits maps SNACs to rate limit classes.
+type SNACRateLimits struct {
+	lookup map[uint16]map[uint16]RateLimitClassID
+}
+
+// All returns an iterator over all SNAC message types and their associated
+// rate limit classes.
+func (rg SNACRateLimits) All() iter.Seq[struct {
+	FoodGroup      uint16
+	SubGroup       uint16
+	RateLimitClass RateLimitClassID
+}] {
+	return func(yield func(struct {
+		FoodGroup      uint16
+		SubGroup       uint16
+		RateLimitClass RateLimitClassID
+	}) bool) {
+		for foodGroup, subGroups := range rg.lookup {
+			for subGroup, classID := range subGroups {
+				match := struct {
+					FoodGroup      uint16
+					SubGroup       uint16
+					RateLimitClass RateLimitClassID
+				}{
+					FoodGroup:      foodGroup,
+					SubGroup:       subGroup,
+					RateLimitClass: classID,
+				}
+				if !yield(match) {
+					return
+				}
+			}
+		}
+	}
+}
+
+// RateClassLookup returns the RateLimitClassID associated with the given SNAC
+// food group and subgroup.
+//
+// If a match is found, it returns the associated rate class ID and true.
+// If not found, it returns false.
+func (rg SNACRateLimits) RateClassLookup(foodGroup uint16, subGroup uint16) (RateLimitClassID, bool) {
+	group, ok := rg.lookup[foodGroup]
+	if !ok {
+		return 0, false
+	}
+	class, ok := group[subGroup]
+	if !ok {
+		return 0, false
+	}
+	return class, true
+}
+
+// RateClass defines the configuration for computing rate-limiting behavior
+// using an exponential moving average over time.
+//
+// Each incoming event contributes a time delta (in ms), and the average inter-event
+// time is calculated over a moving window of the most recent N events (`WindowSize`).
+// The resulting average is compared against threshold levels to determine the
+// current rate status (e.g., limited, alert, clear, or disconnect).
+type RateClass struct {
+	ID              RateLimitClassID // Unique identifier for this rate class.
+	WindowSize      int32            // Number of samples used in the moving average calculation.
+	ClearLevel      int32            // If rate-limited and average exceeds this, rate-limiting is lifted.
+	AlertLevel      int32            // If average is below this, an alert state is triggered.
+	LimitLevel      int32            // If average is below this, rate-limiting is triggered.
+	DisconnectLevel int32            // If average is below this, the session should be disconnected.
+	MaxLevel        int32            // Maximum allowed value for the moving average.
+}
+
+// CheckRateLimit calculates a rate limit status and a new moving average based on
+// the time elapsed between the last event and the current event, a specified rate
+// class, and whether the system is currently limited.
+//
+// Parameters:
+//
+//	lastTime:    The timestamp of the previous event.
+//	currentTime: The current timestamp.
+//	rateClass:   Configuration for rate limiting thresholds and window size.
+//	currentAvg:  The current moving average of the elapsed time between events.
+//	limitedNow:  Indicates if the system is currently under a rate limit.
+//
+// Returns:
+//
+//	status: The new RateLimitStatus, which can be one of:
+//	        - RateLimitStatusDisconnect (when the moving average is smallest)
+//	        - RateLimitStatusLimited
+//	        - RateLimitStatusAlert
+//	        - RateLimitStatusClear (when the moving average is largest)
+//	newAvg:  The updated moving average of the elapsed time.
+//
+// The function updates currentAvg by combining the current interval (the difference
+// between currentTime and lastTime, in milliseconds) with the previous average. If
+// the system was already limited (limitedNow == true), the function checks whether
+// currentAvg has risen above the ClearLevel threshold to move the status back to
+// RateLimitStatusClear. If not, it keeps the status at RateLimitStatusLimited.
+//
+// If the system was not already limited, the updated currentAvg is compared against
+// DisconnectLevel, LimitLevel, and AlertLevel thresholds of the provided RateClass
+// to determine the appropriate rate limit status.
+func CheckRateLimit(
+	lastTime time.Time,
+	currentTime time.Time,
+	rateClass RateClass,
+	currentAvg int32,
+	limitedNow bool,
+) (RateLimitStatus, int32) {
+
+	// calculate the time elapsed in milliseconds since the last event
+	elapsedMs := int32(currentTime.Sub(lastTime).Milliseconds())
+
+	// update the moving average
+	newAvg := (currentAvg*(rateClass.WindowSize-1) + elapsedMs) / rateClass.WindowSize
+
+	// clamp the moving average to the maximum allowable level
+	if newAvg > rateClass.MaxLevel {
+		newAvg = rateClass.MaxLevel
+	}
+
+	var status RateLimitStatus
+
+	switch {
+	case newAvg < rateClass.DisconnectLevel:
+		status = RateLimitStatusDisconnect
+	case limitedNow && newAvg >= rateClass.ClearLevel:
+		status = RateLimitStatusClear
+	case limitedNow:
+		status = RateLimitStatusLimited
+	case newAvg < rateClass.LimitLevel:
+		status = RateLimitStatusLimited
+	case newAvg < rateClass.AlertLevel:
+		status = RateLimitStatusAlert
+	default:
+		status = RateLimitStatusClear
+	}
+
+	return status, newAvg
+}

+ 293 - 0
wire/rate_limit_test.go

@@ -0,0 +1,293 @@
+package wire
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewRateLimitClasses(t *testing.T) {
+	input := [5]RateClass{
+		{ID: 1, WindowSize: 10},
+		{ID: 2, WindowSize: 20},
+		{ID: 3, WindowSize: 30},
+		{ID: 4, WindowSize: 40},
+		{ID: 5, WindowSize: 50},
+	}
+
+	classes := NewRateLimitClasses(input)
+
+	assert.Equal(t, input, classes.All())
+}
+
+func TestCheckRateLimit(t *testing.T) {
+	baseTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
+
+	testCases := []struct {
+		name        string
+		lastTime    time.Time
+		currentTime time.Time
+		rateClass   RateClass
+		currentAvg  int32
+		limitedNow  bool
+		wantStatus  RateLimitStatus
+		wantNewAvg  int32
+	}{
+		{
+			name:        "Already limited, but newAvg >= ClearLevel => Clear",
+			lastTime:    baseTime,
+			currentTime: baseTime.Add(50 * time.Millisecond),
+			rateClass: RateClass{
+				WindowSize:      4,
+				MaxLevel:        1000,
+				ClearLevel:      10,
+				DisconnectLevel: 2,
+				LimitLevel:      5,
+				AlertLevel:      8,
+			},
+			currentAvg: 9,    // currentAvg close to ClearLevel
+			limitedNow: true, // already limited
+			// newAvg = (9*(4-1) + 50) / 4 = (27 + 50) / 4 = 77 / 4 = 19
+			// 19 >= ClearLevel(10) => RateLimitStatusClear
+			wantStatus: RateLimitStatusClear,
+			wantNewAvg: 19,
+		},
+		{
+			name:        "Already limited, but newAvg < ClearLevel => Remain Limited",
+			lastTime:    baseTime,
+			currentTime: baseTime.Add(30 * time.Millisecond),
+			rateClass: RateClass{
+				WindowSize:      4,
+				MaxLevel:        1000,
+				ClearLevel:      50,
+				DisconnectLevel: 10,
+				LimitLevel:      20,
+				AlertLevel:      30,
+			},
+			currentAvg: 10,
+			limitedNow: true,
+			// newAvg = (10*(4-1) + 30) / 4 = (30 + 30) / 4 = 60 / 4 = 15
+			// 15 < ClearLevel(50) => remain limited
+			wantStatus: RateLimitStatusLimited,
+			wantNewAvg: 15,
+		},
+		{
+			name:        "Not Limited Now, New average < DisconnectLevel => Disconnect",
+			lastTime:    baseTime,
+			currentTime: baseTime.Add(10 * time.Millisecond),
+			rateClass: RateClass{
+				WindowSize:      4,
+				MaxLevel:        1000,
+				ClearLevel:      100,
+				DisconnectLevel: 5,
+				LimitLevel:      20,
+				AlertLevel:      40,
+			},
+			currentAvg: 1,
+			limitedNow: false,
+			// newAvg = (1*(4-1) + 10) / 4 = (3 + 10) / 4 = 13 / 4 = 3
+			// 3 < DisconnectLevel(5) => RateLimitStatusDisconnect
+			wantStatus: RateLimitStatusDisconnect,
+			wantNewAvg: 3,
+		},
+		{
+			name:        "Limited Now, New average < DisconnectLevel => Disconnect",
+			lastTime:    baseTime,
+			currentTime: baseTime.Add(10 * time.Millisecond),
+			rateClass: RateClass{
+				WindowSize:      4,
+				MaxLevel:        1000,
+				ClearLevel:      100,
+				DisconnectLevel: 5,
+				LimitLevel:      20,
+				AlertLevel:      40,
+			},
+			currentAvg: 1,
+			limitedNow: true,
+			// newAvg = (1*(4-1) + 10) / 4 = (3 + 10) / 4 = 13 / 4 = 3
+			// 3 < DisconnectLevel(5) => RateLimitStatusDisconnect
+			wantStatus: RateLimitStatusDisconnect,
+			wantNewAvg: 3,
+		},
+		{
+			name:        "New average < LimitLevel => Limited",
+			lastTime:    baseTime,
+			currentTime: baseTime.Add(20 * time.Millisecond),
+			rateClass: RateClass{
+				WindowSize:      4,
+				MaxLevel:        1000,
+				ClearLevel:      100,
+				DisconnectLevel: 5,
+				LimitLevel:      40,
+				AlertLevel:      60,
+			},
+			currentAvg: 10,
+			limitedNow: false,
+			// newAvg = (10*(4-1) + 20) / 4 = (30 + 20) / 4 = 50 / 4 = 12
+			// 12 < LimitLevel(40) => RateLimitStatusLimited
+			wantStatus: RateLimitStatusLimited,
+			wantNewAvg: 12,
+		},
+		{
+			name:        "New average < AlertLevel => Alert",
+			lastTime:    baseTime,
+			currentTime: baseTime.Add(30 * time.Millisecond),
+			rateClass: RateClass{
+				WindowSize:      4,
+				MaxLevel:        1000,
+				ClearLevel:      100,
+				DisconnectLevel: 5,
+				LimitLevel:      20,
+				AlertLevel:      40,
+			},
+			currentAvg: 20,
+			limitedNow: false,
+			// newAvg = (20*(4-1) + 30) / 4 = (60 + 30) / 4 = 90 / 4 = 22
+			// 22 >= 20 => not "Limited"; 22 < 40 => "Alert"
+			wantStatus: RateLimitStatusAlert,
+			wantNewAvg: 22,
+		},
+		{
+			name:        "New average >= AlertLevel => Clear",
+			lastTime:    baseTime,
+			currentTime: baseTime.Add(50 * time.Millisecond),
+			rateClass: RateClass{
+				WindowSize:      4,
+				MaxLevel:        1000,
+				ClearLevel:      100,
+				DisconnectLevel: 5,
+				LimitLevel:      20,
+				AlertLevel:      40,
+			},
+			// Choose 39 so the resulting newAvg is 41, which is >= AlertLevel.
+			currentAvg: 39,
+			limitedNow: false,
+			// newAvg = (39*(4-1) + 50) / 4 = (117 + 50) / 4 = 167 / 4 = 41
+			// 41 >= AlertLevel(40) => RateLimitStatusClear
+			wantStatus: RateLimitStatusClear,
+			wantNewAvg: 41,
+		},
+		{
+			name:        "Clamp newAvg to MaxLevel if exceeded",
+			lastTime:    baseTime,
+			currentTime: baseTime.Add(9999 * time.Millisecond),
+			rateClass: RateClass{
+				WindowSize:      4,
+				MaxLevel:        100,
+				ClearLevel:      80,
+				DisconnectLevel: 20,
+				LimitLevel:      40,
+				AlertLevel:      60,
+			},
+			currentAvg: 95,
+			limitedNow: false,
+			// Without clamping, newAvg would be huge:
+			// newAvg = (95*(4-1) + 9999) / 4 = (285 + 9999)/4 = 10284/4 = 2571
+			// Clamped to 100 => 100 >= AlertLevel(60) => RateLimitStatusClear
+			wantStatus: RateLimitStatusClear,
+			wantNewAvg: 100,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			gotStatus, gotNewAvg := CheckRateLimit(
+				tc.lastTime,
+				tc.currentTime,
+				tc.rateClass,
+				tc.currentAvg,
+				tc.limitedNow,
+			)
+
+			assert.Equal(t, tc.wantStatus, gotStatus)
+			assert.Equal(t, tc.wantNewAvg, gotNewAvg)
+		})
+	}
+}
+
+func TestRateLimitClasses_Get(t *testing.T) {
+	classes := DefaultRateLimitClasses()
+
+	// Test Get() returns correct class for each ID
+	for i := 1; i <= 5; i++ {
+		id := RateLimitClassID(i)
+		class := classes.Get(id)
+		assert.Equal(t, id, class.ID)
+		assert.Equal(t, classes.All()[i-1], class)
+	}
+}
+
+func TestRateLimitClasses_All(t *testing.T) {
+	classes := DefaultRateLimitClasses()
+
+	// Test All() returns exactly 5 classes with correct IDs
+	all := classes.All()
+	assert.Len(t, all, 5)
+	for i, class := range all {
+		expectedID := RateLimitClassID(i + 1)
+		assert.Equal(t, expectedID, class.ID, "class ID mismatch at index %d", i)
+	}
+}
+
+func TestSNACRateLimits_RateClassLookup(t *testing.T) {
+	limits := DefaultSNACRateLimits()
+
+	testCases := []struct {
+		foodGroup uint16
+		subGroup  uint16
+		expected  RateLimitClassID
+		found     bool
+	}{
+		{Chat, ChatUsersJoined, 1, true},
+		{Chat, ChatChannelMsgToHost, 2, true},
+		{0xFFFF, 0x0001, 0, false},
+		{Chat, 0xFFFF, 0, false},
+	}
+
+	for _, tc := range testCases {
+		classID, ok := limits.RateClassLookup(tc.foodGroup, tc.subGroup)
+		assert.Equal(t, tc.found, ok)
+		assert.Equal(t, tc.expected, classID)
+	}
+}
+
+func TestSNACRateLimits_All(t *testing.T) {
+	limits := DefaultSNACRateLimits()
+
+	seen := map[uint16]map[uint16]RateLimitClassID{}
+	for entry := range limits.All() {
+		if _, ok := seen[entry.FoodGroup]; !ok {
+			seen[entry.FoodGroup] = map[uint16]RateLimitClassID{}
+		}
+		seen[entry.FoodGroup][entry.SubGroup] = entry.RateLimitClass
+	}
+
+	// Spot-check a few values
+	require.Contains(t, seen, ICBM)
+	assert.Equal(t, RateLimitClassID(3), seen[ICBM][ICBMChannelMsgToHost])
+	assert.Equal(t, RateLimitClassID(1), seen[ICBM][ICBMChannelMsgToClient])
+
+	require.Contains(t, seen, Locate)
+	assert.Equal(t, RateLimitClassID(4), seen[Locate][LocateSetDirInfo])
+	assert.Equal(t, RateLimitClassID(3), seen[Locate][LocateUserInfoQuery])
+}
+
+func TestSNACRateLimits_All_YieldStopsEarly(t *testing.T) {
+	limits := DefaultSNACRateLimits()
+
+	count := 0
+	limits.All()(func(entry struct {
+		FoodGroup      uint16
+		SubGroup       uint16
+		RateLimitClass RateLimitClassID
+	}) bool {
+		count++
+		// stop iteration after first item to trigger `if !yield(...) { return }`
+		return false
+	})
+
+	// Should only yield one entry
+	assert.Equal(t, 1, count)
+}

+ 65 - 17
wire/snacs.go

@@ -244,6 +244,21 @@ type SNAC_0x01_0x05_OServiceServiceResponse struct {
 	TLVRestBlock
 	TLVRestBlock
 }
 }
 
 
+type RateParamsSNAC struct {
+	ID              uint16
+	WindowSize      uint32
+	ClearLevel      uint32
+	AlertLevel      uint32
+	LimitLevel      uint32
+	DisconnectLevel uint32
+	CurrentLevel    uint32
+	MaxLevel        uint32
+	V2Params        *struct {
+		LastTime      uint32
+		DroppingSNACs uint8
+	} `oscar:"optional"`
+}
+
 //	SNAC_0x01_0x07_OServiceRateParamsReply contains rate limits for rate classes and groups.
 //	SNAC_0x01_0x07_OServiceRateParamsReply contains rate limits for rate classes and groups.
 //
 //
 // Rate Classes:
 // Rate Classes:
@@ -261,28 +276,15 @@ type SNAC_0x01_0x05_OServiceServiceResponse struct {
 //   - MaxLevel: The maximum rate value; if the current value surpasses this,
 //   - MaxLevel: The maximum rate value; if the current value surpasses this,
 //     it should be reset. The upper limit for a rate average.
 //     it should be reset. The upper limit for a rate average.
 //   - LastTime: Time elapsed since the last message was received by the server.
 //   - LastTime: Time elapsed since the last message was received by the server.
-//   - CurrentState: Indicates whether the server is dropping SNACs for this rate class.
+//   - CurrentStatus: Indicates whether the server is dropping SNACs for this rate class.
 //
 //
 // Rate Groups:
 // Rate Groups:
 //   - ID: Unique identifier for the rate group.
 //   - ID: Unique identifier for the rate group.
 //   - Pairs: List of SNAC types associated with the rate group, including FoodGroup
 //   - Pairs: List of SNAC types associated with the rate group, including FoodGroup
 //     (e.g., wire.ICBM) and SubGroup (e.g., wire.ICBMChannelMsgToHost).
 //     (e.g., wire.ICBM) and SubGroup (e.g., wire.ICBMChannelMsgToHost).
 type SNAC_0x01_0x07_OServiceRateParamsReply struct {
 type SNAC_0x01_0x07_OServiceRateParamsReply struct {
-	RateClasses []struct {
-		ID              uint16
-		WindowSize      uint32
-		ClearLevel      uint32
-		AlertLevel      uint32
-		LimitLevel      uint32
-		DisconnectLevel uint32
-		CurrentLevel    uint32
-		MaxLevel        uint32
-		V2Params        *struct {
-			LastTime     uint32
-			CurrentState uint8
-		} `oscar:"optional"`
-	} `oscar:"count_prefix=uint16"`
-	RateGroups []struct {
+	RateClasses []RateParamsSNAC `oscar:"count_prefix=uint16"`
+	RateGroups  []struct {
 		ID    uint16
 		ID    uint16
 		Pairs []struct {
 		Pairs []struct {
 			FoodGroup uint16
 			FoodGroup uint16
@@ -291,8 +293,13 @@ type SNAC_0x01_0x07_OServiceRateParamsReply struct {
 	}
 	}
 }
 }
 
 
+type SNAC_0x01_0x0A_OServiceRateParamsChange struct {
+	Code uint16
+	Rate RateParamsSNAC
+}
+
 type SNAC_0x01_0x08_OServiceRateParamsSubAdd struct {
 type SNAC_0x01_0x08_OServiceRateParamsSubAdd struct {
-	TLVRestBlock
+	ClassIDs []uint16
 }
 }
 
 
 type SNAC_0x01_0x0F_OServiceUserInfoUpdate struct {
 type SNAC_0x01_0x0F_OServiceUserInfoUpdate struct {
@@ -796,6 +803,16 @@ type SNAC_0x04_0x14_ICBMClientEvent struct {
 	Event      uint16
 	Event      uint16
 }
 }
 
 
+//
+// 0x05: Advert
+//
+
+const (
+	AdvertErr      uint16 = 0x0001
+	AdvertAdsQuery uint16 = 0x0002
+	AdvertAdsReply uint16 = 0x0003
+)
+
 //
 //
 // 0x06: Invite
 // 0x06: Invite
 //
 //
@@ -911,6 +928,15 @@ type SNAC_0x07_0x07_AdminConfirmReply struct {
 	TLV
 	TLV
 }
 }
 
 
+//
+// 0x08: Popup
+//
+
+const (
+	PopupErr     uint16 = 0x0001
+	PopupDisplay uint16 = 0x0002
+)
+
 //
 //
 // 0x09: PermitDeny
 // 0x09: PermitDeny
 //
 //
@@ -991,6 +1017,27 @@ type SNAC_0x0A_0x03_UserLookupFindReply struct {
 	TLVRestBlock
 	TLVRestBlock
 }
 }
 
 
+//
+// 0x0B: Stats
+//
+
+const (
+	StatsErr                  uint16 = 0x0001
+	StatsSetMinReportInterval uint16 = 0x0002
+	StatsReportEvents         uint16 = 0x0003
+	StatsReportAck            uint16 = 0x0004
+)
+
+//
+// 0x0C: Translate
+//
+
+const (
+	TranslateErr     uint16 = 0x0001
+	TranslateRequest uint16 = 0x0002
+	TranslateReply   uint16 = 0x0003
+)
+
 //
 //
 // 0x0D: ChatNav
 // 0x0D: ChatNav
 //
 //
@@ -1335,6 +1382,7 @@ type SNAC_0x10_0x05_BARTDownloadReply struct {
 	Data       []byte `oscar:"len_prefix=uint16"`
 	Data       []byte `oscar:"len_prefix=uint16"`
 }
 }
 
 
+//
 // 0x13: Feedbag
 // 0x13: Feedbag
 //
 //
 
 

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.