Просмотр исходного кода

feat: commit various improvements to the icq

feat: another batch of improvements for icq
siohaza 5 месяцев назад
Родитель
Сommit
1c84b132ff
43 измененных файлов с 1482 добавлено и 148 удалено
  1. 4 1
      cmd/server/factory.go
  2. 85 14
      foodgroup/auth.go
  3. 25 9
      foodgroup/auth_test.go
  4. 9 0
      foodgroup/helpers_test.go
  5. 54 0
      foodgroup/icbm.go
  6. 83 0
      foodgroup/icbm_test.go
  7. 9 1
      foodgroup/icq.go
  8. 23 2
      foodgroup/icq_test.go
  9. 3 6
      foodgroup/locate.go
  10. 63 0
      foodgroup/mock_icq_user_updater_test.go
  11. 13 0
      foodgroup/oservice.go
  12. 21 0
      foodgroup/oservice_test.go
  13. 3 0
      foodgroup/types.go
  14. 4 3
      go.mod
  15. 10 10
      go.sum
  16. 2 2
      server/kerberos/kerberos.go
  17. 1 1
      server/kerberos/kerberos_test.go
  18. 18 12
      server/kerberos/mock_auth_test.go
  19. 34 0
      server/oscar/handler.go
  20. 54 36
      server/oscar/mock_auth_test.go
  21. 61 2
      server/oscar/mock_oservice_service_test.go
  22. 25 9
      server/oscar/server.go
  23. 40 2
      server/oscar/server_test.go
  24. 4 3
      server/oscar/types.go
  25. 1 1
      server/toc/cmd_client.go
  26. 1 1
      server/toc/cmd_client_test.go
  27. 36 24
      server/toc/mock_auth_service_test.go
  28. 2 2
      server/toc/types.go
  29. 1 1
      server/webapi/handlers/session.go
  30. 2 2
      server/webapi/types.go
  31. 0 0
      state/migrations/0027_icq_permissions_columns.down.sql
  32. 4 0
      state/migrations/0027_icq_permissions_columns.up.sql
  33. 21 0
      state/session.go
  34. 4 0
      state/user.go
  35. 32 0
      state/user_store.go
  36. 4 3
      wire/decode.go
  37. 19 1
      wire/decode_test.go
  38. 5 0
      wire/frames.go
  39. 169 0
      wire/snacs.go
  40. 2 0
      wire/snacs_string.go
  41. 130 0
      wire/snacs_test.go
  42. 172 0
      wire/xtraz.go
  43. 229 0
      wire/xtraz_test.go

+ 4 - 1
cmd/server/factory.go

@@ -236,6 +236,7 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		deps.rateLimitClasses,
+		logger,
 	)
 	bartService := foodgroup.NewBARTService(
 		logger,
@@ -338,7 +339,7 @@ func OSCAR(deps Container) *oscar.Server {
 // KerberosAPI creates an HTTP server for the Kerberos server.
 func KerberosAPI(deps Container) *kerberos.Server {
 	logger := deps.logger.With("svc", "Kerberos")
-	authService := foodgroup.NewAuthService(deps.cfg, deps.inMemorySessionManager, deps.inMemorySessionManager, deps.chatSessionManager, deps.sqLiteUserStore, deps.hmacCookieBaker, deps.chatSessionManager, deps.sqLiteUserStore, deps.sqLiteUserStore, deps.rateLimitClasses)
+	authService := foodgroup.NewAuthService(deps.cfg, deps.inMemorySessionManager, deps.inMemorySessionManager, deps.chatSessionManager, deps.sqLiteUserStore, deps.hmacCookieBaker, deps.chatSessionManager, deps.sqLiteUserStore, deps.sqLiteUserStore, deps.rateLimitClasses, logger)
 	return kerberos.NewKerberosServer(deps.Listeners, logger, authService)
 }
 
@@ -397,6 +398,7 @@ func TOC(deps Container) *toc.Server {
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
 				deps.rateLimitClasses,
+				logger,
 			),
 			BuddyListRegistry: deps.sqLiteUserStore,
 			BuddyService: foodgroup.NewBuddyService(
@@ -496,6 +498,7 @@ func WebAPI(deps Container) *webapi.Server {
 			deps.sqLiteUserStore,
 			deps.sqLiteUserStore,
 			deps.rateLimitClasses,
+			logger,
 		),
 		BuddyListRegistry: deps.sqLiteUserStore,
 		BuddyService: foodgroup.NewBuddyService(

+ 85 - 14
foodgroup/auth.go

@@ -5,6 +5,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"log/slog"
 	"strconv"
 	"strings"
 	"time"
@@ -32,6 +33,7 @@ func NewAuthService(
 	accountManager AccountManager,
 	bartItemManager BARTItemManager,
 	classes wire.RateLimitClasses,
+	logger *slog.Logger,
 ) *AuthService {
 	return &AuthService{
 		chatSessionRegistry:        chatSessionRegistry,
@@ -46,6 +48,7 @@ func NewAuthService(
 		rateLimitClasses:           classes,
 		timeNow:                    time.Now,
 		maxConcurrentLoginsPerUser: MaxConcurrentLoginsPerUser,
+		logger:                     logger,
 	}
 }
 
@@ -57,6 +60,7 @@ type AuthService struct {
 	chatSessionRegistry        ChatSessionRegistry
 	config                     config.Config
 	cookieBaker                CookieBaker
+	logger                     *slog.Logger
 	sessionManager             SessionRegistry
 	sessionRetriever           SessionRetriever
 	userManager                UserManager
@@ -209,13 +213,19 @@ func (s AuthService) BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x
 
 	screenName, exists := inBody.String(wire.LoginTLVTagsScreenName)
 	if !exists {
+		s.logger.Debug("BUCPChallenge: screen name TLV not found in request")
 		return wire.SNACMessage{}, errors.New("screen name doesn't exist in tlv")
 	}
 
+	s.logger.Debug("BUCPChallenge: received challenge request",
+		"screen_name", screenName,
+		"is_uin", state.DisplayScreenName(screenName).IsUIN())
+
 	var authKey string
 
 	user, err := s.userManager.User(ctx, state.NewIdentScreenName(screenName))
 	if err != nil {
+		s.logger.Error("BUCPChallenge: user lookup failed", "screen_name", screenName, "err", err.Error())
 		return wire.SNACMessage{}, err
 	}
 
@@ -223,11 +233,19 @@ func (s AuthService) BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x
 	case user != nil:
 		// user lookup succeeded
 		authKey = user.AuthKey
+		s.logger.Debug("BUCPChallenge: user found, returning auth key",
+			"screen_name", screenName,
+			"auth_key_len", len(authKey))
 	case s.config.DisableAuth:
 		// can't find user, generate stub auth key
 		authKey = newUUID().String()
+		s.logger.Debug("BUCPChallenge: user not found, auth disabled, generating stub auth key",
+			"screen_name", screenName)
 	default:
 		// can't find user, return login error
+		s.logger.Debug("BUCPChallenge: user not found, returning error",
+			"screen_name", screenName,
+			"error_code", wire.LoginErrInvalidUsernameOrPassword)
 		return wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.BUCP,
@@ -264,9 +282,9 @@ func (s AuthService) BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x
 // (wire.LoginTLVTagsReconnectHere) and an authorization cookie
 // (wire.LoginTLVTagsAuthorizationCookie). Else, an error code is set
 // (wire.LoginTLVTagsErrorSubcode).
-func (s AuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
+func (s AuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error) {
 
-	block, err := s.login(ctx, inBody.TLVList, newUserFn, advertisedHost)
+	block, err := s.login(ctx, inBody.TLVList, newUserFn, advertisedHost, advertisedHostSSL)
 	if err != nil {
 		return wire.SNACMessage{}, err
 	}
@@ -292,8 +310,8 @@ func (s AuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_B
 // (wire.LoginTLVTagsReconnectHere) and an authorization cookie
 // (wire.LoginTLVTagsAuthorizationCookie). Else, an error code is set
 // (wire.LoginTLVTagsErrorSubcode).
-func (s AuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error) {
-	return s.login(ctx, inFrame.TLVList, newUserFn, advertisedHost)
+func (s AuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error) {
+	return s.login(ctx, inFrame.TLVList, newUserFn, advertisedHost, advertisedHostSSL)
 }
 
 // KerberosLogin handles AIM-style Kerberos authentication for AIM 6.0+.
@@ -304,7 +322,7 @@ func (s AuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame
 //
 // Several values in the response are poorly understood but necessary for proper
 // processing on the client side.
-func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
+func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error) {
 
 	b, ok := inBody.TicketRequestMetadata.Bytes(wire.KerberosTLVTicketRequest)
 	if !ok {
@@ -327,7 +345,7 @@ func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_
 		list = append(list, wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, info.Password))
 	}
 
-	result, err := s.login(ctx, list, newUserFn, "") //todo
+	result, err := s.login(ctx, list, newUserFn, advertisedHost, advertisedHostSSL)
 	if err != nil {
 		return wire.SNACMessage{}, fmt.Errorf("login: %w", err)
 	}
@@ -462,59 +480,99 @@ func (l *loginProperties) fromTLV(list wire.TLVList) error {
 
 // login validates a user's credentials and creates their session. it returns
 // metadata used in both BUCP and FLAP authentication responses.
-func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error) {
+func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error) {
 
 	props := loginProperties{}
 	if err := props.fromTLV(tlv); err != nil {
+		s.logger.Debug("login: failed to parse TLVs", "err", err.Error())
 		return wire.TLVRestBlock{}, err
 	}
 
+	s.logger.Debug("login: parsed login properties",
+		"screen_name", props.screenName,
+		"client_id", props.clientID,
+		"is_bucp", props.isBUCPAuth,
+		"is_flap", props.isFLAPAuth,
+		"is_flap_java", props.isFLAPJavaAuth,
+		"is_toc", props.isTOCAuth,
+		"is_kerberos_plaintext", props.isKerberosPlaintextAuth,
+		"is_kerberos_roasted", props.isKerberosRoastedAuth,
+		"password_hash_len", len(props.passwordHash),
+		"roasted_pass_len", len(props.roastedPass))
+
 	user, err := s.userManager.User(ctx, props.screenName.IdentScreenName())
 	if err != nil {
+		s.logger.Error("login: user lookup failed", "screen_name", props.screenName, "err", err.Error())
 		return wire.TLVRestBlock{}, err
 	}
 
 	if user == nil {
+		s.logger.Debug("login: user not found", "screen_name", props.screenName)
 		// user not found
 		if s.config.DisableAuth {
 			// auth disabled, create the user
-			return s.createUser(ctx, props, newUserFn, advertisedHost)
+			s.logger.Debug("login: auth disabled, creating user", "screen_name", props.screenName)
+			return s.createUser(ctx, props, newUserFn, advertisedHost, advertisedHostSSL)
 		}
 		// auth enabled, return separate login errors for ICQ and AIM
 		loginErr := wire.LoginErrInvalidUsernameOrPassword
 		if props.screenName.IsUIN() {
 			loginErr = wire.LoginErrICQUserErr
 		}
+		s.logger.Debug("login: returning user not found error",
+			"screen_name", props.screenName,
+			"error_code", loginErr)
 		return loginFailureResponse(props, loginErr), nil
 	}
 
+	s.logger.Debug("login: user found", "screen_name", props.screenName, "is_icq", user.IsICQ)
+
 	// check if suspended status should prevent login
 	if user.SuspendedStatus > 0x0 {
+		s.logger.Debug("login: user suspended",
+			"screen_name", props.screenName,
+			"suspended_status", user.SuspendedStatus)
 		return loginFailureResponse(props, user.SuspendedStatus), nil
 	}
 
 	if s.config.DisableAuth {
 		// user exists, but don't validate
-		return s.loginSuccessResponse(props, advertisedHost)
+		s.logger.Debug("login: auth disabled, skipping password validation", "screen_name", props.screenName)
+		return s.loginSuccessResponse(props, advertisedHost, advertisedHostSSL)
 	}
 
 	var loginOK bool
+	var authMethod string
 	switch {
 	case props.isBUCPAuth:
+		authMethod = "BUCP"
 		loginOK = user.ValidateHash(props.passwordHash)
 	case props.isFLAPAuth:
+		authMethod = "FLAP"
 		loginOK = user.ValidateRoastedPass(props.roastedPass)
 	case props.isFLAPJavaAuth:
+		authMethod = "FLAP_Java"
 		loginOK = user.ValidateRoastedJavaPass(props.roastedPass)
 	case props.isTOCAuth:
+		authMethod = "TOC"
 		loginOK = user.ValidateRoastedTOCPass(props.roastedPass)
 	case props.isKerberosPlaintextAuth:
+		authMethod = "Kerberos_Plaintext"
 		loginOK = user.ValidatePlaintextPass(props.plaintextPassword)
 	case props.isKerberosRoastedAuth:
+		authMethod = "Kerberos_Roasted"
 		loginOK = user.ValidateRoastedKerberosPass(props.roastedPass)
 	}
 
+	s.logger.Debug("login: password validation result",
+		"screen_name", props.screenName,
+		"auth_method", authMethod,
+		"login_ok", loginOK)
+
 	if !loginOK {
+		s.logger.Debug("login: password validation failed",
+			"screen_name", props.screenName,
+			"auth_method", authMethod)
 		return loginFailureResponse(props, wire.LoginErrInvalidPassword), nil
 	}
 
@@ -522,14 +580,18 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func
 	if props.multiConnFlag == uint8(wire.MultiConnFlagsRecentClient) {
 		sess := s.sessionRetriever.RetrieveSession(props.screenName.IdentScreenName())
 		if sess != nil && sess.InstanceCount() >= s.maxConcurrentLoginsPerUser {
+			s.logger.Debug("login: too many concurrent sessions",
+				"screen_name", props.screenName,
+				"instance_count", sess.InstanceCount())
 			return loginFailureResponse(props, wire.LoginErrRateLimitExceeded), nil
 		}
 	}
 
-	return s.loginSuccessResponse(props, advertisedHost)
+	s.logger.Debug("login: login successful", "screen_name", props.screenName)
+	return s.loginSuccessResponse(props, advertisedHost, advertisedHostSSL)
 }
 
-func (s AuthService) createUser(ctx context.Context, props loginProperties, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error) {
+func (s AuthService) createUser(ctx context.Context, props loginProperties, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error) {
 
 	var err error
 	if props.screenName.IsUIN() {
@@ -559,10 +621,10 @@ func (s AuthService) createUser(ctx context.Context, props loginProperties, newU
 		return wire.TLVRestBlock{}, err
 	}
 
-	return s.loginSuccessResponse(props, advertisedHost)
+	return s.loginSuccessResponse(props, advertisedHost, advertisedHostSSL)
 }
 
-func (s AuthService) loginSuccessResponse(props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) {
+func (s AuthService) loginSuccessResponse(props loginProperties, advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error) {
 	loginCookie := state.ServerCookie{
 		Service:       wire.BOS,
 		ScreenName:    props.screenName,
@@ -582,11 +644,20 @@ func (s AuthService) loginSuccessResponse(props loginProperties, advertisedHost
 		return wire.TLVRestBlock{}, fmt.Errorf("failed to issue auth cookie: %w", err)
 	}
 
+	reconnectHost := advertisedHost
+	sslState := wire.OServiceServiceResponseSSLStateNotUsed
+
+	s.logger.Debug("loginSuccessResponse: returning login response",
+		"screen_name", props.screenName,
+		"reconnect_host", reconnectHost,
+		"ssl_state", sslState)
+
 	return wire.TLVRestBlock{
 		TLVList: []wire.TLV{
 			wire.NewTLVBE(wire.LoginTLVTagsScreenName, props.screenName),
-			wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, advertisedHost),
+			wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, reconnectHost),
 			wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, cookie),
+			wire.NewTLVBE(wire.OServiceTLVTagsSSLState, sslState),
 		},
 	}, nil
 }

+ 25 - 9
foodgroup/auth_test.go

@@ -5,6 +5,7 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"log/slog"
 	"testing"
 	"time"
 
@@ -103,6 +104,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
 					},
 				},
@@ -173,6 +175,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
 					},
 				},
@@ -284,6 +287,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
 					},
 				},
@@ -493,6 +497,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
 					},
 				},
@@ -629,6 +634,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
 					},
 				},
@@ -703,6 +709,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
 					},
 				},
@@ -779,8 +786,9 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 				userManager:                userManager,
 				sessionRetriever:           sessionRetriever,
 				maxConcurrentLoginsPerUser: 2,
+				logger:                     slog.Default(),
 			}
-			outputSNAC, err := svc.BUCPLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost)
+			outputSNAC, err := svc.BUCPLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost, "")
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -855,6 +863,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+					wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 				},
 			},
 		},
@@ -901,6 +910,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+					wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 				},
 			},
 		},
@@ -1041,6 +1051,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+					wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 				},
 			},
 		},
@@ -1091,6 +1102,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+					wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 				},
 			},
 		},
@@ -1159,6 +1171,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
 					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+					wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 				},
 			},
 		},
@@ -1216,8 +1229,9 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 				config:      tc.cfg,
 				cookieBaker: cookieBaker,
 				userManager: userManager,
+				logger:      slog.Default(),
 			}
-			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost)
+			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost, "")
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -1556,8 +1570,9 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 				sessionRetriever:           sessionRetriever,
 				timeNow:                    tc.timeNow,
 				maxConcurrentLoginsPerUser: 2,
+				logger:                     slog.Default(),
 			}
-			outputSNAC, err := svc.KerberosLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost)
+			outputSNAC, err := svc.KerberosLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost, "")
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -1717,6 +1732,7 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 			svc := AuthService{
 				config:      tc.cfg,
 				userManager: userManager,
+				logger:      slog.Default(),
 			}
 			fnNewUUID := func() uuid.UUID {
 				return sessUUID
@@ -1744,7 +1760,7 @@ func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) {
 	chatCookieBuf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(serverCookie, chatCookieBuf))
 
-	svc := NewAuthService(config.Config{}, nil, nil, chatSessionRegistry, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses())
+	svc := NewAuthService(config.Config{}, nil, nil, chatSessionRegistry, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), slog.Default())
 
 	have, err := svc.RegisterChatSession(context.Background(), serverCookie)
 	assert.NoError(t, err)
@@ -1954,7 +1970,7 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 					Return(params.result, params.err)
 			}
 
-			svc := NewAuthService(config.Config{}, sessionRegistry, nil, nil, userManager, nil, nil, accountManager, bartItemManager, wire.DefaultRateLimitClasses())
+			svc := NewAuthService(config.Config{}, sessionRegistry, nil, nil, userManager, nil, nil, accountManager, bartItemManager, wire.DefaultRateLimitClasses(), slog.Default())
 
 			have, err := svc.RegisterBOSSession(context.Background(), tc.cookie)
 			assert.NoError(t, err)
@@ -1985,7 +2001,7 @@ func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
 		User(matchContext(), instance.IdentScreenName()).
 		Return(&state.User{IdentScreenName: instance.IdentScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, nil, wire.DefaultRateLimitClasses())
+	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), slog.Default())
 
 	have, err := svc.RetrieveBOSSession(context.Background(), aimAuthCookie)
 	assert.NoError(t, err)
@@ -2010,7 +2026,7 @@ func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
 		User(matchContext(), instance.IdentScreenName()).
 		Return(&state.User{IdentScreenName: instance.IdentScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, nil, wire.DefaultRateLimitClasses())
+	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), slog.Default())
 
 	have, err := svc.RetrieveBOSSession(context.Background(), aimAuthCookie)
 	assert.NoError(t, err)
@@ -2103,7 +2119,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 					RemoveSession(matchSession(params.screenName))
 			}
 
-			svc := NewAuthService(config.Config{}, nil, nil, sessionManager, nil, nil, chatMessageRelayer, nil, nil, wire.DefaultRateLimitClasses())
+			svc := NewAuthService(config.Config{}, nil, nil, sessionManager, nil, nil, chatMessageRelayer, nil, nil, wire.DefaultRateLimitClasses(), slog.Default())
 			svc.SignoutChat(context.Background(), tt.instance)
 		})
 	}
@@ -2148,7 +2164,7 @@ func TestAuthService_Signout(t *testing.T) {
 			for _, params := range tt.mockParams.removeSessionParams {
 				sessionManager.EXPECT().RemoveSession(matchSession(params.screenName))
 			}
-			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses())
+			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), slog.Default())
 
 			svc.Signout(context.Background(), tt.instance)
 		})

+ 9 - 0
foodgroup/helpers_test.go

@@ -172,6 +172,7 @@ type icqUserUpdaterParams struct {
 	setBasicInfoParams
 	setInterestsParams
 	setMoreInfoParams
+	setPermissionsParams
 	setUserNotesParams
 	setWorkInfoParams
 }
@@ -224,6 +225,14 @@ type setMoreInfoParams []struct {
 	err  error
 }
 
+// setPermissionsParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetPermissions call site
+type setPermissionsParams []struct {
+	name state.IdentScreenName
+	data state.ICQPermissions
+	err  error
+}
+
 // bartItemManagerParams is a helper struct that contains mock parameters for
 // BARTItemManager methods
 type bartItemManagerParams struct {

+ 54 - 0
foodgroup/icbm.go

@@ -148,6 +148,15 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, instance *state.Sessi
 				return nil, fmt.Errorf("addExternalIP: %w", err)
 			}
 		}
+		// Strip HTML from message if recipient doesn't support XHTML
+		if (clientIM.ChannelID == wire.ICBMChannelIM || clientIM.ChannelID == wire.ICBMChannelMIME) &&
+			tlv.Tag == wire.ICBMTLVAOLIMData {
+			if !recipSess.HasCap(wire.CapXHTMLIM) {
+				if transformedTLV, err := stripHTMLFromICBMTLV(tlv); err == nil {
+					tlv = transformedTLV
+				}
+			}
+		}
 		clientIM.Append(tlv)
 	}
 
@@ -298,6 +307,51 @@ func addExternalIP(instance *state.SessionInstance, tlv wire.TLV) (wire.TLV, err
 	return tlv, nil
 }
 
+// stripHTMLFromICBMTLV transforms an ICBMTLVAOLIMData TLV by stripping HTML
+// from the message text for clients that don't support XHTML.
+func stripHTMLFromICBMTLV(tlv wire.TLV) (wire.TLV, error) {
+	var frags []wire.ICBMCh1Fragment
+	if err := wire.UnmarshalBE(&frags, bytes.NewBuffer(tlv.Value)); err != nil {
+		return tlv, fmt.Errorf("unmarshal ICBM fragments: %w", err)
+	}
+
+	modified := false
+	for i, frag := range frags {
+		if frag.ID == 1 { // 1 = message text
+			msg := wire.ICBMCh1Message{}
+			if err := wire.UnmarshalBE(&msg, bytes.NewBuffer(frag.Payload)); err != nil {
+				continue
+			}
+
+			// Strip HTML from message text
+			strippedText := wire.StripHTML(msg.Text)
+			if !bytes.Equal(strippedText, msg.Text) {
+				msg.Text = strippedText
+
+				// Remarshal the message
+				msgBuf := bytes.Buffer{}
+				if err := wire.MarshalBE(msg, &msgBuf); err != nil {
+					continue
+				}
+				frags[i].Payload = msgBuf.Bytes()
+				modified = true
+			}
+		}
+	}
+
+	if !modified {
+		return tlv, nil
+	}
+
+	// Remarshal the fragments
+	newValue, err := wire.MarshalICBMFragmentList(frags)
+	if err != nil {
+		return tlv, err
+	}
+
+	return wire.NewTLVBE(tlv.Tag, newValue), nil
+}
+
 // ClientEvent relays SNAC wire.ICBMClientEvent typing events from the
 // sender to the recipient.
 func (s ICBMService) ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {

+ 83 - 0
foodgroup/icbm_test.go

@@ -1070,6 +1070,89 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 			},
 			expectOutput: nil,
 		},
+		{
+			name:     "relay Xtraz XStatus message",
+			instance: newTestInstance("sender-screen-name", sessOptWarning(10)),
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     newTestInstance("recipient-screen-name", sessOptWarning(20), sessOptSignonComplete).Session(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameActiveOnlyParams: relayToScreenNameActiveOnlyParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelRendezvous,
+									TLVUserInfo: newTestInstance("sender-screen-name", sessOptWarning(10)).Session().TLVUserInfo(),
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+												Type:       wire.ICBMRdvMessagePropose,
+												Capability: wire.CapXtrazScript,
+												TLVRestBlock: wire.TLVRestBlock{
+													TLVList: wire.TLVList{
+														wire.NewTLVBE(0x2711, []byte("xtraz-xml-payload")),
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelRendezvous,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+								Type:       wire.ICBMRdvMessagePropose,
+								Capability: wire.CapXtrazScript,
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(0x2711, []byte("xtraz-xml-payload")),
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
 		{
 			name:     "transmit message to recipient with all sessions inactive - use RelayToScreenName",
 			instance: newTestInstance("sender-screen-name", sessOptWarning(10)),

+ 9 - 1
foodgroup/icq.go

@@ -560,7 +560,15 @@ func (s ICQService) SetMoreInfo(ctx context.Context, instance *state.SessionInst
 }
 
 func (s ICQService) SetPermissions(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error {
-	s.logger.Debug("setting permissions is not yet supported")
+	u := state.ICQPermissions{
+		AuthRequired: inBody.Authorization == 1,
+		WebAware:     inBody.WebAware == 1,
+	}
+
+	if err := s.userUpdater.SetPermissions(ctx, instance.IdentScreenName(), u); err != nil {
+		return err
+	}
+
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetPermissions)
 }
 

+ 23 - 2
foodgroup/icq_test.go

@@ -2548,8 +2548,22 @@ func TestICQService_SetPermissions(t *testing.T) {
 			name:     "happy path",
 			seq:      1,
 			instance: newTestInstance("100003", sessOptUIN(100003)),
-			req:      wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{},
+			req: wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{
+				Authorization: 1,
+				WebAware:      1,
+			},
 			mockParams: mockParams{
+				icqUserUpdaterParams: icqUserUpdaterParams{
+					setPermissionsParams: setPermissionsParams{
+						{
+							name: state.NewIdentScreenName("100003"),
+							data: state.ICQPermissions{
+								AuthRequired: true,
+								WebAware:     true,
+							},
+						},
+					},
+				},
 				messageRelayerParams: messageRelayerParams{
 					relayToScreenNameParams: relayToScreenNameParams{
 						{
@@ -2585,13 +2599,20 @@ func TestICQService_SetPermissions(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			userUpdater := newMockICQUserUpdater(t)
+			for _, params := range tt.mockParams.setPermissionsParams {
+				userUpdater.EXPECT().
+					SetPermissions(matchContext(), params.name, params.data).
+					Return(params.err)
+			}
+
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tt.mockParams.relayToScreenNameParams {
 				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
 			}
 
 			s := ICQService{
-				logger:         slog.Default(),
+				userUpdater:    userUpdater,
 				messageRelayer: messageRelayer,
 			}
 			err := s.SetPermissions(context.Background(), tt.instance, tt.req, tt.seq)

+ 3 - 6
foodgroup/locate.go

@@ -13,12 +13,9 @@ import (
 // omitCaps is the map of to filter out of the client's capability list
 // because they are not currently supported by the server.
 var omitCaps = map[[16]byte]bool{
-	// 0946134a-4c7f-11d1-8222-444553540000 (games)
-	{9, 70, 19, 74, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0}: true,
-	// 0946134d-4c7f-11d1-8222-444553540000 (ICQ inter-op)
-	{9, 70, 19, 77, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0}: true,
-	// 09461341-4c7f-11d1-8222-444553540000 (voice chat)
-	{9, 70, 19, 65, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0}: true,
+	wire.CapGames:      true, // games
+	wire.CapSupportICQ: true, // ICQ inter-op
+	wire.CapVoiceChat:  true, // voice chat
 }
 
 // NewLocateService creates a new instance of LocateService.

+ 63 - 0
foodgroup/mock_icq_user_updater_test.go

@@ -290,6 +290,69 @@ func (_c *mockICQUserUpdater_SetMoreInfo_Call) RunAndReturn(run func(ctx context
 	return _c
 }
 
+// SetPermissions provides a mock function for the type mockICQUserUpdater
+func (_mock *mockICQUserUpdater) SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error {
+	ret := _mock.Called(ctx, name, data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetPermissions")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.ICQPermissions) error); ok {
+		r0 = returnFunc(ctx, name, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockICQUserUpdater_SetPermissions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPermissions'
+type mockICQUserUpdater_SetPermissions_Call struct {
+	*mock.Call
+}
+
+// SetPermissions is a helper method to define mock.On call
+//   - ctx context.Context
+//   - name state.IdentScreenName
+//   - data state.ICQPermissions
+func (_e *mockICQUserUpdater_Expecter) SetPermissions(ctx interface{}, name interface{}, data interface{}) *mockICQUserUpdater_SetPermissions_Call {
+	return &mockICQUserUpdater_SetPermissions_Call{Call: _e.mock.On("SetPermissions", ctx, name, data)}
+}
+
+func (_c *mockICQUserUpdater_SetPermissions_Call) Run(run func(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions)) *mockICQUserUpdater_SetPermissions_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		var arg2 state.ICQPermissions
+		if args[2] != nil {
+			arg2 = args[2].(state.ICQPermissions)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetPermissions_Call) Return(err error) *mockICQUserUpdater_SetPermissions_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetPermissions_Call) RunAndReturn(run func(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error) *mockICQUserUpdater_SetPermissions_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // SetUserNotes provides a mock function for the type mockICQUserUpdater
 func (_mock *mockICQUserUpdater) SetUserNotes(ctx context.Context, name state.IdentScreenName, data state.ICQUserNotes) error {
 	ret := _mock.Called(ctx, name, data)

+ 13 - 0
foodgroup/oservice.go

@@ -306,6 +306,19 @@ func (s OServiceService) SetPrivacyFlags(ctx context.Context, inBody wire.SNAC_0
 	}
 }
 
+// ProbeReq responds to client probe requests. Some ICQ clients send probe
+// requests to test server connectivity before authenticating. This returns a
+// simple ProbeAck to indicate the server is responsive.
+func (s OServiceService) ProbeReq(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceProbeAck,
+			RequestID: inFrame.RequestID,
+		},
+	}
+}
+
 // 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

+ 21 - 0
foodgroup/oservice_test.go

@@ -2024,6 +2024,27 @@ func TestOServiceService_UserInfoQuery(t *testing.T) {
 	}
 }
 
+func TestOServiceService_ProbeReq(t *testing.T) {
+	svc := NewOServiceService(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), nil, nil, nil)
+
+	inFrame := wire.SNACFrame{
+		FoodGroup: wire.OService,
+		SubGroup:  wire.OServiceProbeReq,
+		RequestID: 12345,
+	}
+
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceProbeAck,
+			RequestID: 12345,
+		},
+	}
+
+	have := svc.ProbeReq(context.Background(), inFrame)
+	assert.Equal(t, want, have)
+}
+
 func TestOServiceService_IdleNotification(t *testing.T) {
 	tests := []struct {
 		name     string

+ 3 - 0
foodgroup/types.go

@@ -279,6 +279,9 @@ type ICQUserUpdater interface {
 
 	// SetWorkInfo updates the user's professional or employment-related details.
 	SetWorkInfo(ctx context.Context, name state.IdentScreenName, data state.ICQWorkInfo) error
+
+	// SetPermissions updates the user's privacy and permission settings.
+	SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error
 }
 
 // MessageRelayer defines methods for delivering SNAC messages to one or more

+ 4 - 3
go.mod

@@ -11,8 +11,8 @@ require (
 	github.com/mitchellh/go-wordwrap v1.0.1
 	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/stretchr/testify v1.11.1
-	golang.org/x/net v0.47.0
-	golang.org/x/sync v0.18.0
+	golang.org/x/net v0.48.0
+	golang.org/x/sync v0.19.0
 	golang.org/x/time v0.14.0
 	modernc.org/sqlite v1.40.1
 )
@@ -26,7 +26,8 @@ require (
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/stretchr/objx v0.5.3 // indirect
 	golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
-	golang.org/x/sys v0.38.0 // indirect
+	golang.org/x/sys v0.39.0 // indirect
+	golang.org/x/tools v0.40.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	modernc.org/libc v1.67.1 // indirect
 	modernc.org/mathutil v1.7.1 // indirect

+ 10 - 10
go.sum

@@ -34,19 +34,19 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
 golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
-golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
-golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
+golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
 golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
-golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
-golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
+golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

+ 2 - 2
server/kerberos/kerberos.go

@@ -17,7 +17,7 @@ import (
 )
 
 type AuthService interface {
-	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)
 }
 
 func NewKerberosServer(listeners []config.Listener, logger *slog.Logger, authService AuthService) *Server {
@@ -112,7 +112,7 @@ func postHandler(w http.ResponseWriter, r *http.Request, authService AuthService
 		return
 	}
 
-	response, err := authService.KerberosLogin(r.Context(), body, state.NewStubUser, listenAddress)
+	response, err := authService.KerberosLogin(r.Context(), body, state.NewStubUser, "", listenAddress)
 	if err != nil {
 		logger.Error("authService.KerberosLogin", "err", err.Error())
 		http.Error(w, "internal server error", http.StatusInternalServerError)

+ 1 - 1
server/kerberos/kerberos_test.go

@@ -201,7 +201,7 @@ func TestKerberosLoginHandler(t *testing.T) {
 				mockAuth := newMockAuthService(t)
 				if tt.expectLogin {
 					mockAuth.EXPECT().
-						KerberosLogin(mock.Anything, tt.request.Body, mock.Anything, mock.Anything).
+						KerberosLogin(mock.Anything, tt.request.Body, mock.Anything, mock.Anything, mock.Anything).
 						Return(tt.response, tt.responseErr)
 				}
 				srv = NewKerberosServer(tt.listeners, log, mockAuth)

+ 18 - 12
server/kerberos/mock_auth_test.go

@@ -40,8 +40,8 @@ func (_m *mockAuthService) EXPECT() *mockAuthService_Expecter {
 }
 
 // KerberosLogin provides a mock function for the type mockAuthService
-func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost)
+func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 
 	if len(ret) == 0 {
 		panic("no return value specified for KerberosLogin")
@@ -49,16 +49,16 @@ func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNA
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) error); ok {
-		r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) error); ok {
+		r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -75,11 +75,12 @@ type mockAuthService_KerberosLogin_Call struct {
 //   - inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest
 //   - newUserFn func(screenName state.DisplayScreenName) (state.User, error)
 //   - advertisedHost string
-func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_KerberosLogin_Call {
-	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn, advertisedHost)}
+//   - advertisedHostSSL string
+func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}, advertisedHostSSL interface{}) *mockAuthService_KerberosLogin_Call {
+	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)}
 }
 
-func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_KerberosLogin_Call {
+func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -97,11 +98,16 @@ func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context,
 		if args[3] != nil {
 			arg3 = args[3].(string)
 		}
+		var arg4 string
+		if args[4] != nil {
+			arg4 = args[4].(string)
+		}
 		run(
 			arg0,
 			arg1,
 			arg2,
 			arg3,
+			arg4,
 		)
 	})
 	return _c
@@ -112,7 +118,7 @@ func (_c *mockAuthService_KerberosLogin_Call) Return(sNACMessage wire.SNACMessag
 	return _c
 }
 
-func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
+func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 34 - 0
server/oscar/handler.go

@@ -94,6 +94,21 @@ func (rt Handler) AlertNotifyDisplayCapabilities(ctx context.Context, _ *state.S
 	return nil
 }
 
+func (rt Handler) InviteRequest(ctx context.Context, _ *state.SessionInstance, inFrame wire.SNACFrame, _ io.Reader, _ ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
+func (rt Handler) PluginRequest(ctx context.Context, _ *state.SessionInstance, inFrame wire.SNACFrame, _ io.Reader, _ ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
+func (rt Handler) MDirRequest(ctx context.Context, _ *state.SessionInstance, inFrame wire.SNACFrame, _ io.Reader, _ ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
 func (rt Handler) BARTUploadQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
 	inBody := wire.SNAC_0x10_0x02_BARTUploadQuery{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
@@ -627,6 +642,8 @@ func (rt Handler) ICQDBQuery(ctx context.Context, instance *state.SessionInstanc
 			wire.ICQDBQueryMetaReqStat0ad7,
 			wire.ICQDBQueryMetaReqStat0758:
 			rt.Logger.Debug("got a request for stats, not doing anything right now")
+		case wire.ICQDBQueryMetaReqDirectoryQuery, wire.ICQDBQueryMetaReqDirectoryUpdate:
+			rt.Logger.Debug("got a directory query/update request, not implemented yet")
 		default:
 			return fmt.Errorf("%w: %X", errUnknownICQMetaReqSubType, icqMD.Optional.ReqSubType)
 		}
@@ -767,6 +784,12 @@ func (rt Handler) OServiceUserInfoQuery(ctx context.Context, instance *state.Ses
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 }
 
+func (rt Handler) OServiceProbeReq(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC := rt.OServiceService.ProbeReq(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
 func (rt Handler) OServiceIdleNotification(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
 	inBody := wire.SNAC_0x01_0x11_OServiceIdleNotification{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
@@ -1053,6 +1076,11 @@ func (rt Handler) Handle(ctx context.Context, server uint16, instance *state.Ses
 		case wire.FeedbagUse:
 			return rt.FeedbagUse(ctx, instance, inFrame, r, rw)
 		}
+	case wire.Invite:
+		switch inFrame.SubGroup {
+		case wire.InviteRequestQuery, wire.InviteRequestReply:
+			return rt.InviteRequest(ctx, instance, inFrame, r, rw)
+		}
 	case wire.ICQ:
 		switch inFrame.SubGroup {
 		case wire.ICQDBQuery:
@@ -1092,6 +1120,8 @@ func (rt Handler) Handle(ctx context.Context, server uint16, instance *state.Ses
 		case wire.LocateUserInfoQuery2:
 			return rt.LocateUserInfoQuery2(ctx, instance, inFrame, r, rw)
 		}
+	case wire.MDir:
+		return rt.MDirRequest(ctx, instance, inFrame, r, rw)
 	case wire.ODir:
 		switch inFrame.SubGroup {
 		case wire.ODirInfoQuery:
@@ -1121,6 +1151,8 @@ func (rt Handler) Handle(ctx context.Context, server uint16, instance *state.Ses
 			return rt.OServiceSetUserInfoFields(ctx, instance, inFrame, r, rw)
 		case wire.OServiceUserInfoQuery:
 			return rt.OServiceUserInfoQuery(ctx, instance, inFrame, r, rw)
+		case wire.OServiceProbeReq:
+			return rt.OServiceProbeReq(ctx, instance, inFrame, r, rw)
 		}
 	case wire.PermitDeny:
 		switch inFrame.SubGroup {
@@ -1137,6 +1169,8 @@ func (rt Handler) Handle(ctx context.Context, server uint16, instance *state.Ses
 		case wire.PermitDenySetGroupPermitMask:
 			return rt.PermitDenySetGroupPermitMask(ctx, instance, inFrame, r, rw)
 		}
+	case wire.Plugin:
+		return rt.PluginRequest(ctx, instance, inFrame, r, rw)
 	case wire.Stats:
 		switch inFrame.SubGroup {
 		case wire.StatsReportEvents:

+ 54 - 36
server/oscar/mock_auth_test.go

@@ -113,8 +113,8 @@ func (_c *mockAuthService_BUCPChallenge_Call) RunAndReturn(run func(ctx context.
 }
 
 // BUCPLogin provides a mock function for the type mockAuthService
-func (_mock *mockAuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost)
+func (_mock *mockAuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BUCPLogin")
@@ -122,16 +122,16 @@ func (_mock *mockAuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) error); ok {
-		r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) error); ok {
+		r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -148,11 +148,12 @@ type mockAuthService_BUCPLogin_Call struct {
 //   - inBody wire.SNAC_0x17_0x02_BUCPLoginRequest
 //   - newUserFn func(screenName state.DisplayScreenName) (state.User, error)
 //   - advertisedHost string
-func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_BUCPLogin_Call {
-	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, inBody, newUserFn, advertisedHost)}
+//   - advertisedHostSSL string
+func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}, advertisedHostSSL interface{}) *mockAuthService_BUCPLogin_Call {
+	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)}
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -170,11 +171,16 @@ func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBo
 		if args[3] != nil {
 			arg3 = args[3].(string)
 		}
+		var arg4 string
+		if args[4] != nil {
+			arg4 = args[4].(string)
+		}
 		run(
 			arg0,
 			arg1,
 			arg2,
 			arg3,
+			arg4,
 		)
 	})
 	return _c
@@ -185,7 +191,7 @@ func (_c *mockAuthService_BUCPLogin_Call) Return(sNACMessage wire.SNACMessage, e
 	return _c
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -251,8 +257,8 @@ func (_c *mockAuthService_CrackCookie_Call) RunAndReturn(run func(authCookie []b
 }
 
 // FLAPLogin provides a mock function for the type mockAuthService
-func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error) {
-	ret := _mock.Called(ctx, inFrame, newUserFn, advertisedHost)
+func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error) {
+	ret := _mock.Called(ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)
 
 	if len(ret) == 0 {
 		panic("no return value specified for FLAPLogin")
@@ -260,16 +266,16 @@ func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSi
 
 	var r0 wire.TLVRestBlock
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string) (wire.TLVRestBlock, error)); ok {
-		return returnFunc(ctx, inFrame, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string, string) (wire.TLVRestBlock, error)); ok {
+		return returnFunc(ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string) wire.TLVRestBlock); ok {
-		r0 = returnFunc(ctx, inFrame, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string, string) wire.TLVRestBlock); ok {
+		r0 = returnFunc(ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r0 = ret.Get(0).(wire.TLVRestBlock)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string) error); ok {
-		r1 = returnFunc(ctx, inFrame, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string, string) error); ok {
+		r1 = returnFunc(ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -286,11 +292,12 @@ type mockAuthService_FLAPLogin_Call struct {
 //   - inFrame wire.FLAPSignonFrame
 //   - newUserFn func(screenName state.DisplayScreenName) (state.User, error)
 //   - advertisedHost string
-func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_FLAPLogin_Call {
-	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, newUserFn, advertisedHost)}
+//   - advertisedHostSSL string
+func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, newUserFn interface{}, advertisedHost interface{}, advertisedHostSSL interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)}
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -308,11 +315,16 @@ func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFr
 		if args[3] != nil {
 			arg3 = args[3].(string)
 		}
+		var arg4 string
+		if args[4] != nil {
+			arg4 = args[4].(string)
+		}
 		run(
 			arg0,
 			arg1,
 			arg2,
 			arg3,
+			arg4,
 		)
 	})
 	return _c
@@ -323,14 +335,14 @@ func (_c *mockAuthService_FLAPLogin_Call) Return(tLVRestBlock wire.TLVRestBlock,
 	return _c
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
 // KerberosLogin provides a mock function for the type mockAuthService
-func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost)
+func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 
 	if len(ret) == 0 {
 		panic("no return value specified for KerberosLogin")
@@ -338,16 +350,16 @@ func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNA
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) error); ok {
-		r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) error); ok {
+		r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -364,11 +376,12 @@ type mockAuthService_KerberosLogin_Call struct {
 //   - inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest
 //   - newUserFn func(screenName state.DisplayScreenName) (state.User, error)
 //   - advertisedHost string
-func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_KerberosLogin_Call {
-	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn, advertisedHost)}
+//   - advertisedHostSSL string
+func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}, advertisedHostSSL interface{}) *mockAuthService_KerberosLogin_Call {
+	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)}
 }
 
-func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_KerberosLogin_Call {
+func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -386,11 +399,16 @@ func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context,
 		if args[3] != nil {
 			arg3 = args[3].(string)
 		}
+		var arg4 string
+		if args[4] != nil {
+			arg4 = args[4].(string)
+		}
 		run(
 			arg0,
 			arg1,
 			arg2,
 			arg3,
+			arg4,
 		)
 	})
 	return _c
@@ -401,7 +419,7 @@ func (_c *mockAuthService_KerberosLogin_Call) Return(sNACMessage wire.SNACMessag
 	return _c
 }
 
-func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
+func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 61 - 2
server/oscar/mock_oservice_service_test.go

@@ -120,8 +120,10 @@ func (_mock *mockOServiceService) ClientVersions(ctx context.Context, instance *
 	var r0 []wire.SNACMessage
 	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x17_OServiceClientVersions) []wire.SNACMessage); ok {
 		r0 = returnFunc(ctx, instance, inFrame, inBody)
-	} else if ret.Get(0) != nil {
-		r0 = ret.Get(0).([]wire.SNACMessage)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]wire.SNACMessage)
+		}
 	}
 	return r0
 }
@@ -292,6 +294,63 @@ func (_c *mockOServiceService_IdleNotification_Call) RunAndReturn(run func(ctx c
 	return _c
 }
 
+// ProbeReq provides a mock function for the type mockOServiceService
+func (_mock *mockOServiceService) ProbeReq(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _mock.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ProbeReq")
+	}
+
+	var r0 wire.SNACMessage
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	return r0
+}
+
+// mockOServiceService_ProbeReq_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ProbeReq'
+type mockOServiceService_ProbeReq_Call struct {
+	*mock.Call
+}
+
+// ProbeReq is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockOServiceService_Expecter) ProbeReq(ctx interface{}, inFrame interface{}) *mockOServiceService_ProbeReq_Call {
+	return &mockOServiceService_ProbeReq_Call{Call: _e.mock.On("ProbeReq", ctx, inFrame)}
+}
+
+func (_c *mockOServiceService_ProbeReq_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockOServiceService_ProbeReq_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 wire.SNACFrame
+		if args[1] != nil {
+			arg1 = args[1].(wire.SNACFrame)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockOServiceService_ProbeReq_Call) Return(sNACMessage wire.SNACMessage) *mockOServiceService_ProbeReq_Call {
+	_c.Call.Return(sNACMessage)
+	return _c
+}
+
+func (_c *mockOServiceService_ProbeReq_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage) *mockOServiceService_ProbeReq_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // RateParamsQuery provides a mock function for the type mockOServiceService
 func (_mock *mockOServiceService) RateParamsQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) wire.SNACMessage {
 	ret := _mock.Called(ctx, instance, inFrame)

+ 25 - 9
server/oscar/server.go

@@ -204,9 +204,15 @@ func (s oscarServer) routeConnection(ctx context.Context, conn net.Conn, listene
 		return err
 	}
 
-	flapc := wire.NewFlapClient(100, conn, conn)
+	flapc := wire.NewFlapClient(0, conn, conn)
 
-	if err := flapc.SendSignonFrame(nil); err != nil {
+	// send flap signon with server capabilities
+	signonTLVs := []wire.TLV{
+		wire.NewTLVBE(wire.LoginTLVTagsMaxSendSize, wire.FLAPMaxDataSize),
+		wire.NewTLVBE(wire.LoginTLVTagsMaxRecvSize, wire.FLAPMaxDataSize),
+		wire.NewTLVBE(wire.LoginTLVTagsUseBigTime, []byte{}), // empty TLV indicates support
+	}
+	if err := flapc.SendSignonFrame(signonTLVs); err != nil {
 		return err
 	}
 	flap, err := flapc.ReceiveSignonFrame()
@@ -218,7 +224,7 @@ func (s oscarServer) routeConnection(ctx context.Context, conn net.Conn, listene
 		return s.connectToOSCARService(ctx, flap, flapc, conn, listener)
 	}
 
-	return s.authenticate(ctx, flap, ip, conn, flapc, listener.BOSAdvertisedHostPlain)
+	return s.authenticate(ctx, flap, ip, conn, flapc, listener.BOSAdvertisedHostPlain, listener.BOSAdvertisedHostSSL)
 }
 
 func (s oscarServer) connectToOSCARService(
@@ -401,6 +407,7 @@ func (s oscarServer) authenticate(
 	conn net.Conn,
 	flapc *wire.FlapClient,
 	advertisedHost string,
+	advertisedHostSSL string,
 ) error {
 	if ok, isBUCP := s.Allow(ip); !ok {
 		s.Logger.Error("user rate limited at login", "remote", ip)
@@ -436,12 +443,12 @@ func (s oscarServer) authenticate(
 	// indicator of FLAP-auth because older ICQ clients appear to omit the
 	// roasted password TLV when the password is not stored client-side.
 	if _, hasScreenName := flap.Uint16BE(wire.LoginTLVTagsScreenName); hasScreenName {
-		return s.processFLAPAuth(ctx, flap, flapc, advertisedHost)
+		return s.processFLAPAuth(ctx, flap, flapc, advertisedHost, advertisedHostSSL)
 	}
 
 	s.SetBUCP(ip)
 
-	return s.processBUCPAuth(ctx, flapc, advertisedHost)
+	return s.processBUCPAuth(ctx, flapc, advertisedHost, advertisedHostSSL)
 }
 
 func (s oscarServer) processFLAPAuth(
@@ -449,15 +456,16 @@ func (s oscarServer) processFLAPAuth(
 	signonFrame wire.FLAPSignonFrame,
 	flapc *wire.FlapClient,
 	advertisedHost string,
+	advertisedHostSSL string,
 ) error {
-	tlv, err := s.AuthService.FLAPLogin(ctx, signonFrame, state.NewStubUser, advertisedHost)
+	tlv, err := s.AuthService.FLAPLogin(ctx, signonFrame, state.NewStubUser, advertisedHost, advertisedHostSSL)
 	if err != nil {
 		return err
 	}
 	return flapc.NewSignoff(tlv)
 }
 
-func (s oscarServer) processBUCPAuth(ctx context.Context, flapc *wire.FlapClient, advertisedHost string) error {
+func (s oscarServer) processBUCPAuth(ctx context.Context, flapc *wire.FlapClient, advertisedHost string, advertisedHostSSL string) error {
 	frames := 0
 
 	for {
@@ -508,12 +516,20 @@ func (s oscarServer) processBUCPAuth(ctx context.Context, flapc *wire.FlapClient
 				if err := wire.UnmarshalBE(&loginRequest, buf); err != nil {
 					return err
 				}
-				outSNAC, err := s.BUCPLogin(ctx, loginRequest, state.NewStubUser, advertisedHost)
+				outSNAC, err := s.BUCPLogin(ctx, loginRequest, state.NewStubUser, advertisedHost, advertisedHostSSL)
 				if err != nil {
 					return err
 				}
 
-				return flapc.SendSNAC(outSNAC.Frame, outSNAC.Body)
+				loginResp := outSNAC.Body.(wire.SNAC_0x17_0x03_BUCPLoginResponse)
+
+				// Clients expect login response as SNAC on FLAP
+				// channel 2 followed by a FLAP signoff frame to properly close the auth
+				// connection
+				if err := flapc.SendSNAC(outSNAC.Frame, loginResp); err != nil {
+					return err
+				}
+				return flapc.NewSignoff(loginResp.TLVRestBlock)
 			default:
 				s.Logger.Debug("unexpected SNAC received during login",
 					"foodgroup", wire.FoodGroupName(fr.FoodGroup),

+ 40 - 2
server/oscar/server_test.go

@@ -202,6 +202,11 @@ func TestOscarServer_RouteConnection_Auth_BUCP(t *testing.T) {
 		frame = wire.SNACFrame{}
 		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x03_BUCPLoginResponse{}))
 		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPLoginResponse}, frame)
+
+		// < receive FLAPSignoffFrame (server sends this after SNAC for Kopete compatibility)
+		flap = wire.FLAPFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flap, clientConn))
+		assert.Equal(t, wire.FLAPFrameSignoff, flap.FrameType)
 	}()
 
 	wg := &sync.WaitGroup{}
@@ -217,7 +222,7 @@ func TestOscarServer_RouteConnection_Auth_BUCP(t *testing.T) {
 			Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{},
 		}, nil)
 	authService.EXPECT().
-		BUCPLogin(matchContext(), mock.Anything, mock.Anything, "localhost:5190").
+		BUCPLogin(matchContext(), mock.Anything, mock.Anything, "localhost:5190", "").
 		Return(wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.BUCP,
@@ -292,7 +297,7 @@ func TestOscarServer_RouteConnection_Auth_FLAP(t *testing.T) {
 
 	authService := newMockAuthService(t)
 	authService.EXPECT().
-		FLAPLogin(matchContext(), mock.Anything, mock.Anything, "localhost:5190").
+		FLAPLogin(matchContext(), mock.Anything, mock.Anything, "localhost:5190", "").
 		Return(wire.TLVRestBlock{
 			TLVList: []wire.TLV{
 				wire.NewTLVBE(wire.LoginTLVTagsScreenName, "testuser"),
@@ -311,6 +316,39 @@ func TestOscarServer_RouteConnection_Auth_FLAP(t *testing.T) {
 	wg.Wait()
 }
 
+func TestOscarServer_RouteConnection_ConnectionProbe(t *testing.T) {
+	serverConn, clientConn := net.Pipe()
+	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
+	assert.NoError(t, err)
+
+	clientFake := fakeConn{
+		Conn:   serverConn,
+		local:  addr,
+		remote: addr,
+	}
+
+	go func() {
+		// < receive FLAPSignonFrame from server
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flap, clientConn))
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
+
+		// Immediately close connection without sending anything back
+		// This simulates connection probes that some ICQ clients do
+		_ = clientConn.Close()
+	}()
+
+	rt := oscarServer{
+		Logger:        slog.Default(),
+		IPRateLimiter: NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
+	}
+
+	// routeConnection should return nil (not an error) for connection probes
+	err = rt.routeConnection(context.Background(), clientFake, config.Listener{BOSAdvertisedHostPlain: "localhost:5190"})
+	assert.NoError(t, err, "connection probe should not be treated as an error")
+}
+
 func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 	instance := state.NewSession().AddInstance()
 

+ 4 - 3
server/oscar/types.go

@@ -47,10 +47,10 @@ type RateLimitUpdater interface {
 
 type AuthService interface {
 	BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
-	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error)
-	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error)
+	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
 	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
 	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
@@ -153,6 +153,7 @@ type OServiceService interface {
 	ClientVersions(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x17_OServiceClientVersions) []wire.SNACMessage
 	HostOnline(service uint16) wire.SNACMessage
 	IdleNotification(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x11_OServiceIdleNotification) error
+	ProbeReq(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
 	RateParamsQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) wire.SNACMessage
 	RateParamsSubAdd(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
 	ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error)

+ 1 - 1
server/toc/cmd_client.go

@@ -1674,7 +1674,7 @@ func (s OSCARProxy) Signon(ctx context.Context, args []byte) (*state.SessionInst
 	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, userName))
 	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, passwordHash))
 
-	block, err := s.AuthService.FLAPLogin(ctx, signonFrame, state.NewStubUser, "")
+	block, err := s.AuthService.FLAPLogin(ctx, signonFrame, state.NewStubUser, "", "")
 	if err != nil {
 		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("AuthService.FLAPLogin: %w", err))}
 	}

+ 1 - 1
server/toc/cmd_client_test.go

@@ -4163,7 +4163,7 @@ func TestOSCARProxy_Signon(t *testing.T) {
 			authSvc := newMockAuthService(t)
 			for _, params := range tc.mockParams.flapLoginParams {
 				authSvc.EXPECT().
-					FLAPLogin(matchContext(), params.frame, mock.Anything, "").
+					FLAPLogin(matchContext(), params.frame, mock.Anything, "", "").
 					Return(params.tlv, params.err)
 			}
 			for _, params := range tc.mockParams.crackCookieParams {

+ 36 - 24
server/toc/mock_auth_service_test.go

@@ -113,8 +113,8 @@ func (_c *mockAuthService_BUCPChallenge_Call) RunAndReturn(run func(ctx context.
 }
 
 // BUCPLogin provides a mock function for the type mockAuthService
-func (_mock *mockAuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost)
+func (_mock *mockAuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BUCPLogin")
@@ -122,16 +122,16 @@ func (_mock *mockAuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) error); ok {
-		r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string, string) error); ok {
+		r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -148,11 +148,12 @@ type mockAuthService_BUCPLogin_Call struct {
 //   - inBody wire.SNAC_0x17_0x02_BUCPLoginRequest
 //   - newUserFn func(screenName state.DisplayScreenName) (state.User, error)
 //   - advertisedHost string
-func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_BUCPLogin_Call {
-	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, inBody, newUserFn, advertisedHost)}
+//   - advertisedHostSSL string
+func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}, advertisedHostSSL interface{}) *mockAuthService_BUCPLogin_Call {
+	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, inBody, newUserFn, advertisedHost, advertisedHostSSL)}
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -170,11 +171,16 @@ func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBo
 		if args[3] != nil {
 			arg3 = args[3].(string)
 		}
+		var arg4 string
+		if args[4] != nil {
+			arg4 = args[4].(string)
+		}
 		run(
 			arg0,
 			arg1,
 			arg2,
 			arg3,
+			arg4,
 		)
 	})
 	return _c
@@ -185,7 +191,7 @@ func (_c *mockAuthService_BUCPLogin_Call) Return(sNACMessage wire.SNACMessage, e
 	return _c
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -251,8 +257,8 @@ func (_c *mockAuthService_CrackCookie_Call) RunAndReturn(run func(authCookie []b
 }
 
 // FLAPLogin provides a mock function for the type mockAuthService
-func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error) {
-	ret := _mock.Called(ctx, inFrame, newUserFn, advertisedHost)
+func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error) {
+	ret := _mock.Called(ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)
 
 	if len(ret) == 0 {
 		panic("no return value specified for FLAPLogin")
@@ -260,16 +266,16 @@ func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSi
 
 	var r0 wire.TLVRestBlock
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string) (wire.TLVRestBlock, error)); ok {
-		return returnFunc(ctx, inFrame, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string, string) (wire.TLVRestBlock, error)); ok {
+		return returnFunc(ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string) wire.TLVRestBlock); ok {
-		r0 = returnFunc(ctx, inFrame, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string, string) wire.TLVRestBlock); ok {
+		r0 = returnFunc(ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r0 = ret.Get(0).(wire.TLVRestBlock)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string) error); ok {
-		r1 = returnFunc(ctx, inFrame, newUserFn, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, func(screenName state.DisplayScreenName) (state.User, error), string, string) error); ok {
+		r1 = returnFunc(ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -286,11 +292,12 @@ type mockAuthService_FLAPLogin_Call struct {
 //   - inFrame wire.FLAPSignonFrame
 //   - newUserFn func(screenName state.DisplayScreenName) (state.User, error)
 //   - advertisedHost string
-func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_FLAPLogin_Call {
-	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, newUserFn, advertisedHost)}
+//   - advertisedHostSSL string
+func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, newUserFn interface{}, advertisedHost interface{}, advertisedHostSSL interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, newUserFn, advertisedHost, advertisedHostSSL)}
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -308,11 +315,16 @@ func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFr
 		if args[3] != nil {
 			arg3 = args[3].(string)
 		}
+		var arg4 string
+		if args[4] != nil {
+			arg4 = args[4].(string)
+		}
 		run(
 			arg0,
 			arg1,
 			arg2,
 			arg3,
+			arg4,
 		)
 	})
 	return _c
@@ -323,7 +335,7 @@ func (_c *mockAuthService_FLAPLogin_Call) Return(tLVRestBlock wire.TLVRestBlock,
 	return _c
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 2 - 2
server/toc/types.go

@@ -44,9 +44,9 @@ type OServiceService interface {
 
 type AuthService interface {
 	BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
-	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
 	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
 	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)

+ 1 - 1
server/webapi/handlers/session.go

@@ -33,7 +33,7 @@ type SessionHandler struct {
 // AuthService defines methods needed for authentication.
 type AuthService interface {
 	BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
-	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
 }
 

+ 2 - 2
server/webapi/types.go

@@ -45,9 +45,9 @@ type OServiceService interface {
 
 type AuthService interface {
 	BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
-	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.SNACMessage, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string, advertisedHostSSL string) (wire.TLVRestBlock, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
 	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
 	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)

+ 0 - 0
state/migrations/0027_icq_permissions_columns.down.sql


+ 4 - 0
state/migrations/0027_icq_permissions_columns.up.sql

@@ -0,0 +1,4 @@
+ALTER TABLE users
+    ADD COLUMN icq_permissions_webAware BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE users
+    ADD COLUMN icq_permissions_allowSpam BOOLEAN NOT NULL DEFAULT false;

+ 21 - 0
state/session.go

@@ -2,6 +2,7 @@ package state
 
 import (
 	"net/netip"
+	"slices"
 	"sync"
 	"time"
 
@@ -656,9 +657,29 @@ func (s *Session) Caps() [][16]byte {
 		ret = append(ret, c)
 	}
 
+	// Sort capabilities to ensure deterministic order
+	slices.SortFunc(ret, func(a, b [16]byte) int {
+		for i := 0; i < 16; i++ {
+			if a[i] != b[i] {
+				return int(a[i]) - int(b[i])
+			}
+		}
+		return 0
+	})
+
 	return ret
 }
 
+// HasCap returns true if any instance in the session has the given capability UUID.
+func (s *Session) HasCap(cap [16]byte) bool {
+	for _, c := range s.Caps() {
+		if c == cap {
+			return true
+		}
+	}
+	return false
+}
+
 // MemberSince reports when the user became a member.
 func (s *Session) MemberSince() time.Time {
 	s.mutex.RLock()

+ 4 - 0
state/user.go

@@ -398,6 +398,10 @@ type ICQPermissions struct {
 	// AuthRequired indicates where users must ask this permission to add them
 	// to their contact list.
 	AuthRequired bool
+	// WebAware indicates whether the user's online status is visible via web.
+	WebAware bool
+	// AllowSpam indicates whether to allow messages from users not on the contact list.
+	AllowSpam bool
 }
 
 // Age returns the user's age relative to their birthday and timeNow.

+ 32 - 0
state/user_store.go

@@ -394,6 +394,8 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			icq_moreInfo_lang3,
 			icq_notes,
 			icq_permissions_authRequired,
+			icq_permissions_webAware,
+			icq_permissions_allowSpam,
 			icq_workInfo_address,
 			icq_workInfo_city,
 			icq_workInfo_company,
@@ -491,6 +493,8 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			&u.ICQMoreInfo.Lang3,
 			&u.ICQNotes.Notes,
 			&u.ICQPermissions.AuthRequired,
+			&u.ICQPermissions.WebAware,
+			&u.ICQPermissions.AllowSpam,
 			&u.ICQWorkInfo.Address,
 			&u.ICQWorkInfo.City,
 			&u.ICQWorkInfo.Company,
@@ -1413,6 +1417,34 @@ func (f SQLiteUserStore) SetWorkInfo(ctx context.Context, name IdentScreenName,
 	return nil
 }
 
+func (f SQLiteUserStore) SetPermissions(ctx context.Context, name IdentScreenName, data ICQPermissions) error {
+	q := `
+		UPDATE users SET
+			icq_permissions_authRequired = ?,
+			icq_permissions_webAware = ?,
+			icq_permissions_allowSpam = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.ExecContext(ctx,
+		q,
+		data.AuthRequired,
+		data.WebAware,
+		data.AllowSpam,
+		name.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}
+
 func (f SQLiteUserStore) SetMoreInfo(ctx context.Context, name IdentScreenName, data ICQMoreInfo) error {
 	q := `
 		UPDATE users SET 

+ 4 - 3
wire/decode.go

@@ -173,10 +173,11 @@ func unmarshalString(v reflect.Value, oscTag oscarTag, r io.Reader, order binary
 			return err
 		}
 		if oscTag.nullTerminated {
-			if buf[len(buf)-1] != 0x00 {
-				return errNotNullTerminated
+			// search for null within string and truncate there if found
+			// needed for icq 6 login to be working
+			if nullPos := bytes.IndexByte(buf, 0x00); nullPos != -1 {
+				buf = buf[0:nullPos]
 			}
-			buf = buf[0 : len(buf)-1] // remove null terminator
 		}
 	}
 

+ 19 - 1
wire/decode_test.go

@@ -163,11 +163,29 @@ func TestUnmarshal(t *testing.T) {
 			prototype: &struct {
 				Val string `oscar:"len_prefix=uint16,nullterm"`
 			}{},
-			wantErr: errNotNullTerminated,
+			want: &struct {
+				Val string `oscar:"len_prefix=uint16,nullterm"`
+			}{
+				Val: "test-value",
+			},
 			given: append(
 				[]byte{0x0, 0xa}, /* len prefix */
 				[]byte{0x74, 0x65, 0x73, 0x74, 0x2d, 0x76, 0x61, 0x6c, 0x75, 0x65}...), /* str val */
 		},
+		{
+			name: "null-terminated string16 with null in middle",
+			prototype: &struct {
+				Val string `oscar:"len_prefix=uint16,nullterm"`
+			}{},
+			want: &struct {
+				Val string `oscar:"len_prefix=uint16,nullterm"`
+			}{
+				Val: "test",
+			},
+			given: append(
+				[]byte{0x0, 0xf}, /* len prefix = 15 bytes */
+				[]byte{0x74, 0x65, 0x73, 0x74, 0x00, 0x6d, 0x6f, 0x72, 0x65, 0x2d, 0x64, 0x61, 0x74, 0x61, 0x21}...), /* "test\0more-data!" */
+		},
 		{
 			name: "string16 read error",
 			prototype: &struct {

+ 5 - 0
wire/frames.go

@@ -20,6 +20,11 @@ const (
 	FLAPFrameKeepAlive uint8 = 0x05
 )
 
+const (
+	// FLAPMaxDataSize is the maximum size of a FLAP packet's data (excluding header).
+	FLAPMaxDataSize uint32 = 0xFFF9
+)
+
 type FLAPFrame struct {
 	StartMarker uint8
 	FrameType   uint8

+ 169 - 0
wire/snacs.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"errors"
 	"fmt"
+	"strings"
 
 	"github.com/google/uuid"
 )
@@ -99,9 +100,13 @@ const (
 	LoginTLVTagsErrorSubcode            uint16 = 0x08
 	LoginTLVTagsPasswordHash            uint16 = 0x25
 	LoginTLVTagsMultiConnFlags          uint16 = 0x4A
+	LoginTLVTagsMaxSendSize             uint16 = 0x8001
+	LoginTLVTagsMaxRecvSize             uint16 = 0x8003
+	LoginTLVTagsHostSuffix              uint16 = 0x8004
 	LoginTLVTagsRoastedKerberosPassword uint16 = 0x1335
 	LoginTLVTagsRoastedTOCPassword      uint16 = 0x1337
 	LoginTLVTagsPlaintextPassword       uint16 = 0x1338
+	LoginTLVTagsUseBigTime              uint16 = 0x2038
 )
 
 const (
@@ -137,6 +142,82 @@ var (
 	CapChat = uuid.MustParse("748F2420-6287-11D1-8222-444553540000")
 	// CapFileTransfer is the UUID that represents an OSCAR client's ability to send files
 	CapFileTransfer = uuid.MustParse("09461343-4C7F-11D1-8222-444553540000")
+
+	//
+	// Short Caps (0946XXYY-4C7F-11D1-8222-444553540000 format)
+	//
+
+	// CapShortCaps indicates the client supports short capability format
+	CapShortCaps = uuid.MustParse("09460000-4C7F-11D1-8222-444553540000")
+	// CapSecureIM indicates the client supports SECURE_IM encryption
+	CapSecureIM = uuid.MustParse("09460001-4C7F-11D1-8222-444553540000")
+	// CapXHTMLIM indicates the client supports XHTML profile and IMs instead of AOLRTF
+	CapXHTMLIM = uuid.MustParse("09460002-4C7F-11D1-8222-444553540000")
+	// CapRTCVideo indicates the client supports SIP/RTP video
+	CapRTCVideo = uuid.MustParse("09460101-4C7F-11D1-8222-444553540000")
+	// CapHasCamera indicates the client has a camera
+	CapHasCamera = uuid.MustParse("09460102-4C7F-11D1-8222-444553540000")
+	// CapHasMicrophone indicates the client has a microphone
+	CapHasMicrophone = uuid.MustParse("09460103-4C7F-11D1-8222-444553540000")
+	// CapRTCAudio indicates the client supports RTC audio
+	CapRTCAudio = uuid.MustParse("09460104-4C7F-11D1-8222-444553540000")
+	// CapHostStatusTextAware indicates the client supports new status message features
+	CapHostStatusTextAware = uuid.MustParse("0946010A-4C7F-11D1-8222-444553540000")
+	// CapRTIM indicates the client supports "see as I type" IMs
+	CapRTIM = uuid.MustParse("0946010B-4C7F-11D1-8222-444553540000")
+	// CapSmartCaps indicates the client only asserts caps for services it is participating in
+	CapSmartCaps = uuid.MustParse("094601FF-4C7F-11D1-8222-444553540000")
+	// CapVoiceChat indicates the client supports voice chat (AIM)
+	CapVoiceChat = uuid.MustParse("09461341-4C7F-11D1-8222-444553540000")
+	// CapDirectPlay indicates the client supports direct play service (AIM)
+	CapDirectPlay = uuid.MustParse("09461342-4C7F-11D1-8222-444553540000")
+	// CapRouteFinder indicates the client supports route finder (ICQ)
+	CapRouteFinder = uuid.MustParse("09461344-4C7F-11D1-8222-444553540000")
+	// CapDirectICBM indicates the client supports P2P IMs
+	CapDirectICBM = uuid.MustParse("09461345-4C7F-11D1-8222-444553540000")
+	// CapAvatarService indicates the client supports avatar service (AIM)
+	CapAvatarService = uuid.MustParse("09461346-4C7F-11D1-8222-444553540000")
+	// CapStocksAddins indicates the client supports stocks/add-ins (AIM)
+	CapStocksAddins = uuid.MustParse("09461347-4C7F-11D1-8222-444553540000")
+	// CapFileSharing indicates the client supports file sharing
+	CapFileSharing = uuid.MustParse("09461348-4C7F-11D1-8222-444553540000")
+	// CapICQCh2Extended indicates the client supports channel 2 extended TLV(0x2711) messages (ICQ)
+	CapICQCh2Extended = uuid.MustParse("09461349-4C7F-11D1-8222-444553540000")
+	// CapGames indicates the client supports games (AIM)
+	CapGames = uuid.MustParse("0946134A-4C7F-11D1-8222-444553540000")
+	// CapBuddyListTransfer indicates the client supports buddy lists transfer (AIM)
+	CapBuddyListTransfer = uuid.MustParse("0946134B-4C7F-11D1-8222-444553540000")
+	// CapSupportICQ indicates the client supports talking to ICQ users
+	CapSupportICQ = uuid.MustParse("0946134D-4C7F-11D1-8222-444553540000")
+	// CapUTF8Messages indicates the client supports UTF-8 messages (AIM)
+	CapUTF8Messages = uuid.MustParse("0946134E-4C7F-11D1-8222-444553540000")
+
+	//
+	// Full UUIDs (non-short format)
+	//
+
+	// CapRTFMessages indicates the client supports RTF messages (ICQ)
+	CapRTFMessages = uuid.MustParse("97B12751-243C-4334-AD22-D6ABF73F1492")
+	// CapTrillianSecureIM indicates the client supports Trillian SecureIM messages
+	CapTrillianSecureIM = uuid.MustParse("F2E7C7F4-FEAD-4DFB-B235-36798BDF0000")
+	// CapXtrazScript is the UUID for the ICQ Xtraz/Tzer.
+	// Xtraz enables extended status (XStatus), greeting cards, and chat invitations.
+	CapXtrazScript = uuid.MustParse("3B60B3EF-D82A-6C45-A4E0-9C5A5E67E865")
+
+	//
+	// Unknown/Legacy UUIDs
+	//
+
+	// CapUnknownICQ2001_2002 is an unknown capability seen in ICQ 2001/2002
+	CapUnknownICQ2001_2002 = uuid.MustParse("A0E93F37-4C7F-11D1-8222-444553540000")
+	// CapUnknownICQ2002 is an unknown capability seen in ICQ 2002
+	CapUnknownICQ2002 = uuid.MustParse("10CF40D1-4C7F-11D1-8222-444553540000")
+	// CapUnknownICQ2001 is an unknown capability seen in ICQ 2001
+	CapUnknownICQ2001 = uuid.MustParse("2E7A6475-FADF-4DC8-886F-EA3595FDB6DF")
+	// CapUnknownICQLite is an unknown capability seen in ICQLite/ICQ2Go
+	CapUnknownICQLite = uuid.MustParse("563FC809-0B6F-41BD-9F79-422609DFA2F3")
+	// CapGamesAlt is an alternate games capability with different endianness (AIM)
+	CapGamesAlt = uuid.MustParse("0946134A-4C7F-11D1-2282-444553540000")
 )
 
 //
@@ -839,6 +920,62 @@ func UnmarshalICBMMessageText(b []byte) (string, error) {
 	return "", errors.New("unable to find message fragment")
 }
 
+// MarshalICBMFragmentList serializes an ICBM fragment list for an ICBM channel 1
+// message. This is the inverse of UnmarshalICBMMessageText's internal unmarshaling.
+func MarshalICBMFragmentList(frags []ICBMCh1Fragment) ([]byte, error) {
+	buf := bytes.Buffer{}
+	if err := MarshalBE(frags, &buf); err != nil {
+		return nil, fmt.Errorf("unable to marshal ICBM fragments: %w", err)
+	}
+	return buf.Bytes(), nil
+}
+
+// StripHTML removes HTML tags and converts HTML entities to plain text.
+func StripHTML(text []byte) []byte {
+	if len(text) == 0 {
+		return text
+	}
+
+	s := string(text)
+
+	// Convert <BR> tags to newlines (case-insensitive)
+	s = strings.ReplaceAll(s, "<BR>", "\n")
+	s = strings.ReplaceAll(s, "<br>", "\n")
+	s = strings.ReplaceAll(s, "<br/>", "\n")
+	s = strings.ReplaceAll(s, "<br />", "\n")
+
+	// Strip all HTML tags
+	var result strings.Builder
+	result.Grow(len(s))
+	inTag := false
+
+	for i := 0; i < len(s); i++ {
+		if s[i] == '<' {
+			inTag = true
+			continue
+		}
+		if s[i] == '>' {
+			inTag = false
+			continue
+		}
+		if !inTag {
+			result.WriteByte(s[i])
+		}
+	}
+
+	s = result.String()
+
+	// Convert common HTML entities
+	s = strings.ReplaceAll(s, "&lt;", "<")
+	s = strings.ReplaceAll(s, "&gt;", ">")
+	s = strings.ReplaceAll(s, "&amp;", "&")
+	s = strings.ReplaceAll(s, "&quot;", "\"")
+	s = strings.ReplaceAll(s, "&nbsp;", " ")
+	s = strings.ReplaceAll(s, "&apos;", "'")
+
+	return []byte(s)
+}
+
 type SNAC_0x04_0x08_ICBMEvilRequest struct {
 	SendAs     uint16
 	ScreenName string `oscar:"len_prefix=uint8"`
@@ -1775,6 +1912,36 @@ const (
 	ICQTLVTagsOriginallyFromState       uint16 = 0x032A // User originally from state
 	ICQTLVTagsOriginallyFromCountryCode uint16 = 0x0334 // User originally from country (code)
 
+	ICQDirTLVTagsPrivacyToken   uint16 = 0x003C // Privacy token (16 bytes)
+	ICQDirTLVTagsVerifiedEmail  uint16 = 0x0050 // Verified email address
+	ICQDirTLVTagsPendingEmail   uint16 = 0x0055 // Pending email address
+	ICQDirTLVTagsFirstName      uint16 = 0x0064 // First name
+	ICQDirTLVTagsLastName       uint16 = 0x006E // Last name
+	ICQDirTLVTagsNickname       uint16 = 0x0078 // Nickname
+	ICQDirTLVTagsGender         uint16 = 0x0082 // Gender (1=Female, 2=Male)
+	ICQDirTLVTagsEmailAddresses uint16 = 0x008C // Email addresses (record list)
+	ICQDirTLVTagsHomeAddress    uint16 = 0x0096 // Home address
+	ICQDirTLVTagsOriginAddress  uint16 = 0x00A0 // Origin/birth address
+	ICQDirTLVTagsLanguage1      uint16 = 0x00AA // Primary language
+	ICQDirTLVTagsLanguage2      uint16 = 0x00B4 // Secondary language
+	ICQDirTLVTagsLanguage3      uint16 = 0x00BE // Tertiary language
+	ICQDirTLVTagsPhoneNumbers   uint16 = 0x00C8 // Phone numbers (record list)
+	ICQDirTLVTagsHomepage       uint16 = 0x00FA // Homepage URL
+	ICQDirTLVTagsEducation      uint16 = 0x010E // Education info
+	ICQDirTLVTagsCompanyInfo    uint16 = 0x0118 // Company/work info
+	ICQDirTLVTagsInterests      uint16 = 0x0122 // Interests
+	ICQDirTLVTagsMaritalStatus  uint16 = 0x012C // Marital status
+	ICQDirTLVTagsTimezone       uint16 = 0x017C // Timezone
+	ICQDirTLVTagsAboutBio       uint16 = 0x0186 // About/biography text
+	ICQDirTLVTagsAuthRequired   uint16 = 0x019A // Authorization required flag
+	ICQDirTLVTagsBirthDate      uint16 = 0x01A4 // Birth date
+	ICQDirTLVTagsCodePage       uint16 = 0x01C2 // Code page
+	ICQDirTLVTagsMetadataTime   uint16 = 0x01CC // Metadata update time
+	ICQDirTLVTagsAllowSpam      uint16 = 0x01EA // Allow spam/messages from non-contacts flag
+	ICQDirTLVTagsPrivacyLevel   uint16 = 0x01F9 // Privacy level
+	ICQDirTLVTagsWebAware       uint16 = 0x0212 // Web aware flag (online visibility via web)
+	ICQDirTLVTagsStatusNote     uint16 = 0x0226 // Status note/message
+
 	ICQStatusCodeOK   uint8 = 0x0A
 	ICQStatusCodeFail uint8 = 0x32
 	ICQStatusCodeErr  uint8 = 0x14
@@ -1823,6 +1990,8 @@ const (
 	ICQDBQueryMetaReqStat0acd          uint16 = 0x0ACD
 	ICQDBQueryMetaReqStat0ad2          uint16 = 0x0AD2
 	ICQDBQueryMetaReqStat0ad7          uint16 = 0x0AD7
+	ICQDBQueryMetaReqDirectoryQuery    uint16 = 0x0FA0
+	ICQDBQueryMetaReqDirectoryUpdate   uint16 = 0x0FD2
 
 	ICQDBQueryMetaReplySetBasicInfo    uint16 = 0x0064
 	ICQDBQueryMetaReplySetWorkInfo     uint16 = 0x006E

+ 2 - 0
wire/snacs_string.go

@@ -370,6 +370,8 @@ var icqDBQueryMeta = map[uint16]string{
 	ICQDBQueryMetaReqStat0ad2:          "ICQDBQueryMetaReqStat0ad2",
 	ICQDBQueryMetaReqStat0ad7:          "ICQDBQueryMetaReqStat0ad7",
 	ICQDBQueryMetaReqStat0758:          "ICQDBQueryMetaReqStat0758",
+	ICQDBQueryMetaReqDirectoryQuery:    "ICQDBQueryMetaReqDirectoryQuery",
+	ICQDBQueryMetaReqDirectoryUpdate:   "ICQDBQueryMetaReqDirectoryUpdate",
 	ICQDBQueryMetaReplySetBasicInfo:    "ICQDBQueryMetaReplySetBasicInfo",
 	ICQDBQueryMetaReplySetWorkInfo:     "ICQDBQueryMetaReplySetWorkInfo",
 	ICQDBQueryMetaReplySetMoreInfo:     "ICQDBQueryMetaReplySetMoreInfo",

+ 130 - 0
wire/snacs_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"testing"
 
+	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -147,3 +148,132 @@ func TestUnmarshalChatMessageText(t *testing.T) {
 		})
 	}
 }
+
+func TestCapabilityUUIDs(t *testing.T) {
+	tests := []struct {
+		name     string
+		cap      uuid.UUID
+		expected string
+	}{
+		// Original capabilities
+		{"CapChat", CapChat, "748f2420-6287-11d1-8222-444553540000"},
+		{"CapFileTransfer", CapFileTransfer, "09461343-4c7f-11d1-8222-444553540000"},
+
+		// Short caps
+		{"CapShortCaps", CapShortCaps, "09460000-4c7f-11d1-8222-444553540000"},
+		{"CapSecureIM", CapSecureIM, "09460001-4c7f-11d1-8222-444553540000"},
+		{"CapXHTMLIM", CapXHTMLIM, "09460002-4c7f-11d1-8222-444553540000"},
+		{"CapRTCVideo", CapRTCVideo, "09460101-4c7f-11d1-8222-444553540000"},
+		{"CapHasCamera", CapHasCamera, "09460102-4c7f-11d1-8222-444553540000"},
+		{"CapHasMicrophone", CapHasMicrophone, "09460103-4c7f-11d1-8222-444553540000"},
+		{"CapRTCAudio", CapRTCAudio, "09460104-4c7f-11d1-8222-444553540000"},
+		{"CapHostStatusTextAware", CapHostStatusTextAware, "0946010a-4c7f-11d1-8222-444553540000"},
+		{"CapRTIM", CapRTIM, "0946010b-4c7f-11d1-8222-444553540000"},
+		{"CapSmartCaps", CapSmartCaps, "094601ff-4c7f-11d1-8222-444553540000"},
+		{"CapVoiceChat", CapVoiceChat, "09461341-4c7f-11d1-8222-444553540000"},
+		{"CapDirectPlay", CapDirectPlay, "09461342-4c7f-11d1-8222-444553540000"},
+		{"CapRouteFinder", CapRouteFinder, "09461344-4c7f-11d1-8222-444553540000"},
+		{"CapDirectICBM", CapDirectICBM, "09461345-4c7f-11d1-8222-444553540000"},
+		{"CapAvatarService", CapAvatarService, "09461346-4c7f-11d1-8222-444553540000"},
+		{"CapStocksAddins", CapStocksAddins, "09461347-4c7f-11d1-8222-444553540000"},
+		{"CapFileSharing", CapFileSharing, "09461348-4c7f-11d1-8222-444553540000"},
+		{"CapICQCh2Extended", CapICQCh2Extended, "09461349-4c7f-11d1-8222-444553540000"},
+		{"CapGames", CapGames, "0946134a-4c7f-11d1-8222-444553540000"},
+		{"CapBuddyListTransfer", CapBuddyListTransfer, "0946134b-4c7f-11d1-8222-444553540000"},
+		{"CapSupportICQ", CapSupportICQ, "0946134d-4c7f-11d1-8222-444553540000"},
+		{"CapUTF8Messages", CapUTF8Messages, "0946134e-4c7f-11d1-8222-444553540000"},
+
+		// Full UUIDs
+		{"CapRTFMessages", CapRTFMessages, "97b12751-243c-4334-ad22-d6abf73f1492"},
+		{"CapTrillianSecureIM", CapTrillianSecureIM, "f2e7c7f4-fead-4dfb-b235-36798bdf0000"},
+		{"CapXtrazScript", CapXtrazScript, "3b60b3ef-d82a-6c45-a4e0-9c5a5e67e865"},
+
+		// Unknown/Legacy
+		{"CapUnknownICQ2001_2002", CapUnknownICQ2001_2002, "a0e93f37-4c7f-11d1-8222-444553540000"},
+		{"CapUnknownICQ2002", CapUnknownICQ2002, "10cf40d1-4c7f-11d1-8222-444553540000"},
+		{"CapUnknownICQ2001", CapUnknownICQ2001, "2e7a6475-fadf-4dc8-886f-ea3595fdb6df"},
+		{"CapUnknownICQLite", CapUnknownICQLite, "563fc809-0b6f-41bd-9f79-422609dfa2f3"},
+		{"CapGamesAlt", CapGamesAlt, "0946134a-4c7f-11d1-2282-444553540000"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, tt.cap.String())
+		})
+	}
+}
+
+func TestStripHTML(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    []byte
+		expected []byte
+	}{
+		{
+			name:     "simple HTML tags",
+			input:    []byte("<HTML><BODY>Hello World</BODY></HTML>"),
+			expected: []byte("Hello World"),
+		},
+		{
+			name:     "BR tag to newline",
+			input:    []byte("Line 1<BR>Line 2"),
+			expected: []byte("Line 1\nLine 2"),
+		},
+		{
+			name:     "lowercase br tag",
+			input:    []byte("Line 1<br>Line 2"),
+			expected: []byte("Line 1\nLine 2"),
+		},
+		{
+			name:     "self-closing br tag",
+			input:    []byte("Line 1<br/>Line 2"),
+			expected: []byte("Line 1\nLine 2"),
+		},
+		{
+			name:     "HTML entities",
+			input:    []byte("&lt;test&gt; &amp; &quot;quoted&quot;"),
+			expected: []byte("<test> & \"quoted\""),
+		},
+		{
+			name:     "FONT tags with attributes",
+			input:    []byte(`<FONT FACE="Arial" SIZE="3">Text</FONT>`),
+			expected: []byte("Text"),
+		},
+		{
+			name:     "mixed formatting",
+			input:    []byte("<HTML><BODY><B>Bold</B> <I>Italic</I></BODY></HTML>"),
+			expected: []byte("Bold Italic"),
+		},
+		{
+			name:     "plain text unchanged",
+			input:    []byte("Hello World"),
+			expected: []byte("Hello World"),
+		},
+		{
+			name:     "empty input",
+			input:    []byte(""),
+			expected: []byte(""),
+		},
+		{
+			name:     "nbsp entity",
+			input:    []byte("Hello&nbsp;World"),
+			expected: []byte("Hello World"),
+		},
+		{
+			name:     "realistic HTML message",
+			input:    []byte(`<HTML><BODY dir="ltr"><FONT color="#000000" size="2" face="Arial">Hello</FONT></BODY></HTML>`),
+			expected: []byte("Hello"),
+		},
+		{
+			name:     "apos entity",
+			input:    []byte("it&apos;s working"),
+			expected: []byte("it's working"),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := StripHTML(tt.input)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}

+ 172 - 0
wire/xtraz.go

@@ -0,0 +1,172 @@
+package wire
+
+import (
+	"encoding/xml"
+	"errors"
+	"html"
+	"strconv"
+	"strings"
+)
+
+const (
+	XtrazFuncInvitation uint16 = 0x0001 // chat invitation
+	XtrazFuncData       uint16 = 0x0002 // greeting cards, custom data
+	XtrazFuncUserRemove uint16 = 0x0004 // user removal notification
+	XtrazFuncNotify     uint16 = 0x0008 // XStatus notifications
+)
+
+const (
+	XStatusAngry       uint8 = 1
+	XStatusDuck        uint8 = 2
+	XStatusTired       uint8 = 3
+	XStatusParty       uint8 = 4
+	XStatusBeer        uint8 = 5
+	XStatusThinking    uint8 = 6
+	XStatusEating      uint8 = 7
+	XStatusTV          uint8 = 8
+	XStatusFriends     uint8 = 9
+	XStatusCoffee      uint8 = 10
+	XStatusMusic       uint8 = 11
+	XStatusBusiness    uint8 = 12
+	XStatusCamera      uint8 = 13
+	XStatusFunny       uint8 = 14
+	XStatusPhone       uint8 = 15
+	XStatusGames       uint8 = 16
+	XStatusCollege     uint8 = 17
+	XStatusShopping    uint8 = 18
+	XStatusSick        uint8 = 19
+	XStatusSleeping    uint8 = 20
+	XStatusSurfing     uint8 = 21
+	XStatusInternet    uint8 = 22
+	XStatusEngineering uint8 = 23
+	XStatusTyping      uint8 = 24
+	XStatusPPC         uint8 = 25
+	XStatusMobile      uint8 = 26
+	XStatusLove        uint8 = 27
+	XStatusSearching   uint8 = 28
+	XStatusEvil        uint8 = 29
+	XStatusDepression  uint8 = 30
+	XStatusParty2      uint8 = 31
+	XStatusCoffee2     uint8 = 32
+)
+
+// UnmangleXtrazXML decodes the HTML entity encoded XML used in Xtraz messages.
+// Xtraz uses HTML entity encoding for transport: &lt; &gt; &amp; &quot;
+func UnmangleXtrazXML(mangled string) string {
+	return html.UnescapeString(mangled)
+}
+
+// MangleXtrazXML encodes XML for Xtraz transport using HTML entities.
+func MangleXtrazXML(plain string) string {
+	return html.EscapeString(plain)
+}
+
+// XtrazNotifyRequest represents a parsed Xtraz notification request (<N> type).
+type XtrazNotifyRequest struct {
+	PluginID  string
+	ServiceID string
+	RequestID string
+	TransID   string // transaction ID
+	SenderID  string // sender's UIN
+}
+
+// XtrazNotifyResponse represents a parsed Xtraz notification response (<NR> type).
+type XtrazNotifyResponse struct {
+	UIN     string
+	Index   uint8
+	Title   string
+	Message string
+}
+
+// xmlNotifyRequest is the internal XML structure for parsing <N> requests.
+type xmlNotifyRequest struct {
+	XMLName xml.Name `xml:"N"`
+	Query   struct {
+		PluginID string `xml:"PluginID"`
+	} `xml:"QUERY"`
+	Notify struct {
+		Srv struct {
+			ID  string `xml:"id"`
+			Req struct {
+				ID       string `xml:"id"`
+				Trans    string `xml:"trans"`
+				SenderID string `xml:"senderId"`
+			} `xml:"req"`
+		} `xml:"srv"`
+	} `xml:"NOTIFY"`
+}
+
+// xmlNotifyResponseRoot is the internal XML structure for the <Root> element in responses.
+type xmlNotifyResponseRoot struct {
+	UIN   string `xml:"uin"`
+	Index uint8  `xml:"index"`
+	Title string `xml:"title"`
+	Desc  string `xml:"desc"`
+}
+
+// ParseXtrazNotifyRequest parses an Xtraz notification request from XML.
+// The input is expected to be unmangled.
+func ParseXtrazNotifyRequest(xmlData []byte) (*XtrazNotifyRequest, error) {
+	var req xmlNotifyRequest
+	if err := xml.Unmarshal(xmlData, &req); err != nil {
+		return nil, err
+	}
+	return &XtrazNotifyRequest{
+		PluginID:  req.Query.PluginID,
+		ServiceID: req.Notify.Srv.ID,
+		RequestID: req.Notify.Srv.Req.ID,
+		TransID:   req.Notify.Srv.Req.Trans,
+		SenderID:  req.Notify.Srv.Req.SenderID,
+	}, nil
+}
+
+// ErrXtrazRootNotFound is returned when the Root element is not found in an Xtraz response.
+var ErrXtrazRootNotFound = errors.New("xtraz: Root element not found in response")
+
+// ParseXtrazNotifyResponse parses an Xtraz notification response from XML.
+// The input should be unmangled.
+func ParseXtrazNotifyResponse(xmlData []byte) (*XtrazNotifyResponse, error) {
+	// The response XML has a nested structure, we need to extract the Root element
+	xmlStr := string(xmlData)
+
+	rootStart := strings.Index(xmlStr, "<Root>")
+	rootEnd := strings.Index(xmlStr, "</Root>")
+	if rootStart == -1 || rootEnd == -1 {
+		return nil, ErrXtrazRootNotFound
+	}
+
+	rootXML := xmlStr[rootStart : rootEnd+len("</Root>")]
+
+	var root xmlNotifyResponseRoot
+	if err := xml.Unmarshal([]byte(rootXML), &root); err != nil {
+		return nil, err
+	}
+
+	return &XtrazNotifyResponse{
+		UIN:     root.UIN,
+		Index:   root.Index,
+		Title:   root.Title,
+		Message: root.Desc,
+	}, nil
+}
+
+// BuildXtrazNotifyResponse builds an Xtraz notification response XML string.
+func BuildXtrazNotifyResponse(uin string, index uint8, title, message string) string {
+	xmlStr := `<NR><RES><ret event="OnRemoteNotification"><srv><id></id>` +
+		`<val srv_id="cAwaySrv"><Root><CASXtraSetAwayMessage></CASXtraSetAwayMessage>` +
+		`<uin>` + uin + `</uin>` +
+		`<index>` + strconv.Itoa(int(index)) + `</index>` +
+		`<title>` + MangleXtrazXML(title) + `</title>` +
+		`<desc>` + MangleXtrazXML(message) + `</desc>` +
+		`</Root></val></srv></ret></RES></NR>`
+	return MangleXtrazXML(xmlStr)
+}
+
+// BuildXtrazNotifyRequest builds an Xtraz notification request XML string.
+func BuildXtrazNotifyRequest(senderUIN string) string {
+	xml := `<N><QUERY><PluginID>srvMng</PluginID></QUERY>` +
+		`<NOTIFY><srv><id>cAwaySrv</id>` +
+		`<req><id>AwayStat</id><trans>1</trans>` +
+		`<senderId>` + senderUIN + `</senderId></req></srv></NOTIFY></N>`
+	return MangleXtrazXML(xml)
+}

+ 229 - 0
wire/xtraz_test.go

@@ -0,0 +1,229 @@
+package wire
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestUnmangleXtrazXML(t *testing.T) {
+	tests := []struct {
+		name    string
+		mangled string
+		want    string
+	}{
+		{
+			name:    "unmangle basic entities",
+			mangled: "&lt;N&gt;&lt;QUERY&gt;&lt;/QUERY&gt;&lt;/N&gt;",
+			want:    "<N><QUERY></QUERY></N>",
+		},
+		{
+			name:    "unmangle all entities",
+			mangled: "&lt;tag attr=&quot;value&quot;&gt;text &amp; more&lt;/tag&gt;",
+			want:    `<tag attr="value">text & more</tag>`,
+		},
+		{
+			name:    "plain text unchanged",
+			mangled: "plain text",
+			want:    "plain text",
+		},
+		{
+			name:    "empty string",
+			mangled: "",
+			want:    "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := UnmangleXtrazXML(tt.mangled)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestMangleXtrazXML(t *testing.T) {
+	tests := []struct {
+		name  string
+		plain string
+		want  string
+	}{
+		{
+			name:  "mangle basic XML",
+			plain: "<N><QUERY></QUERY></N>",
+			want:  "&lt;N&gt;&lt;QUERY&gt;&lt;/QUERY&gt;&lt;/N&gt;",
+		},
+		{
+			name:  "mangle special chars",
+			plain: `<tag attr="value">text & more</tag>`,
+			want:  "&lt;tag attr=&#34;value&#34;&gt;text &amp; more&lt;/tag&gt;",
+		},
+		{
+			name:  "plain text unchanged",
+			plain: "plain text",
+			want:  "plain text",
+		},
+		{
+			name:  "empty string",
+			plain: "",
+			want:  "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := MangleXtrazXML(tt.plain)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestParseXtrazNotifyRequest(t *testing.T) {
+	tests := []struct {
+		name    string
+		xml     string
+		want    *XtrazNotifyRequest
+		wantErr bool
+	}{
+		{
+			name: "parse valid XStatus request",
+			xml: `<N><QUERY><PluginID>srvMng</PluginID></QUERY>` +
+				`<NOTIFY><srv><id>cAwaySrv</id>` +
+				`<req><id>AwayStat</id><trans>1</trans><senderId>123456</senderId></req>` +
+				`</srv></NOTIFY></N>`,
+			want: &XtrazNotifyRequest{
+				PluginID:  "srvMng",
+				ServiceID: "cAwaySrv",
+				RequestID: "AwayStat",
+				TransID:   "1",
+				SenderID:  "123456",
+			},
+			wantErr: false,
+		},
+		{
+			name:    "parse invalid XML",
+			xml:     "<invalid>",
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name:    "parse empty XML",
+			xml:     "",
+			want:    nil,
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ParseXtrazNotifyRequest([]byte(tt.xml))
+			if tt.wantErr {
+				assert.Error(t, err)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestParseXtrazNotifyResponse(t *testing.T) {
+	tests := []struct {
+		name    string
+		xml     string
+		want    *XtrazNotifyResponse
+		wantErr bool
+	}{
+		{
+			name: "parse valid XStatus response",
+			xml: `<NR><RES><ret event="OnRemoteNotification"><srv><id></id>` +
+				`<val srv_id="cAwaySrv"><Root><CASXtraSetAwayMessage></CASXtraSetAwayMessage>` +
+				`<uin>123456</uin><index>5</index><title>Having a beer</title>` +
+				`<desc>Cheers!</desc></Root></val></srv></ret></RES></NR>`,
+			want: &XtrazNotifyResponse{
+				UIN:     "123456",
+				Index:   5,
+				Title:   "Having a beer",
+				Message: "Cheers!",
+			},
+			wantErr: false,
+		},
+		{
+			name:    "parse XML without Root element",
+			xml:     "<NR><RES></RES></NR>",
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name:    "parse empty XML",
+			xml:     "",
+			want:    nil,
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ParseXtrazNotifyResponse([]byte(tt.xml))
+			if tt.wantErr {
+				assert.Error(t, err)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestBuildXtrazNotifyRequest(t *testing.T) {
+	senderUIN := "123456"
+	result := BuildXtrazNotifyRequest(senderUIN)
+
+	// Unmangle and verify the structure
+	unmangled := UnmangleXtrazXML(result)
+	assert.Contains(t, unmangled, "<N>")
+	assert.Contains(t, unmangled, "<PluginID>srvMng</PluginID>")
+	assert.Contains(t, unmangled, "<senderId>123456</senderId>")
+	assert.Contains(t, unmangled, "<id>AwayStat</id>")
+	assert.Contains(t, unmangled, "<id>cAwaySrv</id>")
+
+	// Verify it can be parsed back
+	parsed, err := ParseXtrazNotifyRequest([]byte(unmangled))
+	assert.NoError(t, err)
+	assert.Equal(t, "srvMng", parsed.PluginID)
+	assert.Equal(t, "cAwaySrv", parsed.ServiceID)
+	assert.Equal(t, "AwayStat", parsed.RequestID)
+	assert.Equal(t, senderUIN, parsed.SenderID)
+}
+
+func TestBuildXtrazNotifyResponse(t *testing.T) {
+	uin := "123456"
+	index := uint8(5)
+	title := "Having a beer"
+	message := "Cheers!"
+
+	result := BuildXtrazNotifyResponse(uin, index, title, message)
+
+	// Unmangle and verify the structure
+	unmangled := UnmangleXtrazXML(result)
+	assert.Contains(t, unmangled, "<NR>")
+	assert.Contains(t, unmangled, "<uin>123456</uin>")
+	assert.Contains(t, unmangled, "<index>5</index>")
+
+	// Verify it can be parsed back
+	parsed, err := ParseXtrazNotifyResponse([]byte(unmangled))
+	assert.NoError(t, err)
+	assert.Equal(t, uin, parsed.UIN)
+	assert.Equal(t, index, parsed.Index)
+}
+
+func TestXtrazCapabilityGUID(t *testing.T) {
+	// Verify the GUID matches the expected GUID
+	expected := "3b60b3ef-d82a-6c45-a4e0-9c5a5e67e865"
+	assert.Equal(t, expected, CapXtrazScript.String())
+}
+
+func TestXStatusConstants(t *testing.T) {
+	assert.Equal(t, uint8(1), XStatusAngry)
+	assert.Equal(t, uint8(32), XStatusCoffee2)
+}