Преглед изворни кода

split BOS listeners into SSL and non-SSL

- BUCP auth sends the correct advertised hostname depending on SSL
or non-SSL
- startOscarSession now correctly sends the correct hostname as well

Functionally, this makes AIM pro work (which does SSL over BUCP)
Mike пре 1 месец
родитељ
комит
02161147b0
41 измењених фајлова са 998 додато и 821 уклоњено
  1. 0 6
      .mockery.yaml
  2. 6 2
      cmd/server/factory.go
  3. 106 12
      config/config.go
  4. 152 69
      config/config_test.go
  5. 24 2
      config/ssl/settings.env
  6. 1 1
      config/ssl/stunnel.conf
  7. 19 17
      foodgroup/auth.go
  8. 123 77
      foodgroup/auth_test.go
  9. 10 18
      foodgroup/oservice.go
  10. 87 63
      foodgroup/oservice_test.go
  11. 1 1
      server/icq_legacy/handler.go
  12. 16 15
      server/icq_legacy/mock_auth_service_test.go
  13. 2 1
      server/icq_legacy/property_test.go
  14. 2 1
      server/icq_legacy/service.go
  15. 7 6
      server/icq_legacy/service_test.go
  16. 15 10
      server/kerberos/kerberos.go
  17. 71 24
      server/kerberos/kerberos_test.go
  18. 16 15
      server/kerberos/mock_auth_test.go
  19. 4 4
      server/oscar/handler.go
  20. 67 67
      server/oscar/handler_test.go
  21. 46 45
      server/oscar/mock_auth_test.go
  22. 15 15
      server/oscar/mock_oservice_service_test.go
  23. 42 55
      server/oscar/server.go
  24. 33 30
      server/oscar/server_test.go
  25. 4 4
      server/oscar/types.go
  26. 3 3
      server/toc/cmd_client.go
  27. 4 4
      server/toc/cmd_client_test.go
  28. 31 30
      server/toc/mock_auth_service_test.go
  29. 15 15
      server/toc/mock_oservice_service_test.go
  30. 6 6
      server/toc/server.go
  31. 3 3
      server/toc/types.go
  32. 2 1
      server/webapi/handler.go
  33. 2 1
      server/webapi/handlers/auth.go
  34. 13 12
      server/webapi/handlers/auth_test.go
  35. 6 5
      server/webapi/handlers/login_psp_test.go
  36. 20 21
      server/webapi/handlers/oscar_bridge.go
  37. 17 12
      server/webapi/handlers/oscar_bridge_test.go
  38. 3 2
      server/webapi/handlers/session.go
  39. 0 135
      server/webapi/oscar_config.go
  40. 1 1
      server/webapi/server.go
  41. 3 10
      server/webapi/types.go

+ 0 - 6
.mockery.yaml

@@ -107,9 +107,6 @@ packages:
       UserManager:
         config:
           filename: "mock_user_manager_test.go"
-      LinkedAccountManager:
-        config:
-          filename: "mock_linked_account_manager_test.go"
   github.com/mk6i/open-oscar-server/foodgroup:
     interfaces:
       AccountManager:
@@ -172,9 +169,6 @@ packages:
       UserManager:
         config:
           filename: "mock_user_manager_manager_test.go"
-      LinkedAccountManager:
-        config:
-          filename: "mock_linked_account_manager_test.go"
   github.com/mk6i/open-oscar-server/server/toc:
     interfaces:
       AdminService:

+ 6 - 2
cmd/server/factory.go

@@ -7,6 +7,7 @@ import (
 	"log/slog"
 	"math/rand"
 	"os"
+	"slices"
 	"strings"
 	"time"
 
@@ -39,7 +40,7 @@ type Container struct {
 	snacRateLimits         wire.SNACRateLimits
 	sqLiteUserStore        *state.SQLiteUserStore
 	webAPISessionManager   *state.WebAPISessionManager
-	Listeners              []config.Listener
+	Listeners              []config.ListenerGroup
 	feedbagSvc             *foodgroup.FeedbagService
 	icqService             *foodgroup.ICQService
 }
@@ -596,7 +597,10 @@ func WebAPI(deps Container) *webapi.Server {
 		// Phase 2 additions
 		BuddyBroadcaster: oscarBuddyBroadcaster,
 		// Phase 4 additions for OSCAR Bridge
-		OSCARConfig: webapi.NewOSCARConfigAdapter(deps.cfg),
+		// listener groups come back in map order, so pin the web API to one
+		BOSListener: slices.MinFunc(deps.Listeners, func(a, b config.ListenerGroup) int {
+			return strings.Compare(a.Name, b.Name)
+		}),
 		// Phase 5 additions for buddy list and messaging
 		BuddyListManager:   buddyListManager,
 		ChatSessionManager: deps.chatSessionManager,

+ 106 - 12
config/config.go

@@ -32,19 +32,72 @@ type Build struct {
 	Date    string `json:"date"`
 }
 
-type Listener struct {
+// ListenerGroup is a set of related BOS endpoints: one plaintext, and
+// optionally one for SSL clients. Both listen in plaintext — a load balancer
+// terminates TLS and forwards decrypted traffic to the SSL endpoint. Pairing
+// them lets a redirect hand a client the sibling endpoint's advertised host,
+// so a session can upgrade to SSL or downgrade to plaintext on reconnect.
+type ListenerGroup struct {
+	// Name is the URI scheme the group was parsed from, e.g. "LOCAL".
+	Name                   string
 	BOSListenAddress       string
+	BOSListenAddressSSL    string
 	BOSAdvertisedHostPlain string
 	BOSAdvertisedHostSSL   string
 	KerberosListenAddress  string
-	HasSSL                 bool
+}
+
+// HasSSL reports whether clients can reach this group over SSL. Config
+// validation guarantees such a group also has an SSL listen address.
+func (g ListenerGroup) HasSSL() bool {
+	return g.BOSAdvertisedHostSSL != ""
+}
+
+// PlainEndpoint returns the group's plaintext BOS socket.
+func (g ListenerGroup) PlainEndpoint() Endpoint {
+	return Endpoint{Group: g, ListenAddress: g.BOSListenAddress}
+}
+
+// SSLEndpoint returns the socket that receives decrypted traffic from the
+// group's SSL terminator. ok is false when SSL is not enabled for the group.
+func (g ListenerGroup) SSLEndpoint() (ep Endpoint, ok bool) {
+	if !g.HasSSL() {
+		return Endpoint{}, false
+	}
+	return Endpoint{Group: g, ListenAddress: g.BOSListenAddressSSL, IsSSL: true}, true
+}
+
+// Endpoints returns every BOS socket the group binds.
+func (g ListenerGroup) Endpoints() []Endpoint {
+	eps := []Endpoint{g.PlainEndpoint()}
+	if ssl, ok := g.SSLEndpoint(); ok {
+		eps = append(eps, ssl)
+	}
+	return eps
+}
+
+// Endpoint is a single BOS socket. IsSSL means traffic arrives from an SSL
+// terminator, so clients that connect here stay on the SSL path.
+type Endpoint struct {
+	Group         ListenerGroup
+	ListenAddress string
+	IsSSL         bool
+}
+
+// AdvertisedHost returns the BOS host clients on this endpoint reconnect to.
+func (e Endpoint) AdvertisedHost() string {
+	if e.IsSSL {
+		return e.Group.BOSAdvertisedHostSSL
+	}
+	return e.Group.BOSAdvertisedHostPlain
 }
 
 //go:generate go run ../cmd/config_generator unix settings.env ssl
 type Config struct {
 	BOSListeners            []string `envconfig:"OSCAR_LISTENERS" required:"true" basic:"LOCAL://0.0.0.0:5190" ssl:"LOCAL://0.0.0.0:5190" description:"Network listeners for core OSCAR services. For multi-homed servers, allows users to connect from multiple networks. For example, you can allow both LAN and Internet clients to connect to the same server using different connection settings.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Listener names are user-defined\n\t- Each listener needs a listener in OSCAR_ADVERTISED_LISTENERS_PLAIN\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5190\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5190,LAN://192.168.1.10:5191"`
-	BOSAdvertisedHostsPlain []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_PLAIN" required:"true" basic:"LOCAL://127.0.0.1:5190" ssl:"LOCAL://127.0.0.1:5190" description:"Hostnames published by the server that clients connect to for accessing various OSCAR services. These hostnames are NOT the bind addresses. For multi-homed use servers, allows clients to connect using separate hostnames per network.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Each listener config must correspond to a config in OSCAR_LISTENERS\n\t- Clients MUST be able to connect to these hostnames\n\nExamples:\n\t// Local LAN config, server behind NAT\n\tLAN://192.168.1.10:5190\n\t// Separate Internet and LAN config\n\tWAN://aim.example.com:5190,LAN://192.168.1.10:5191"`
-	BOSAdvertisedHostsSSL   []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_SSL" required:"false" basic:"" ssl:"LOCAL://ras.dev:5193" description:"Same as OSCAR_ADVERTISED_LISTENERS_PLAIN, except the hostname is for the server that terminates SSL."`
+	BOSAdvertisedHostsPlain []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_PLAIN" required:"true" basic:"LOCAL://127.0.0.1:5190" ssl:"LOCAL://ras.dev:5190" description:"Hostnames published by the server that clients connect to for accessing various OSCAR services. These hostnames are NOT the bind addresses. For multi-homed use servers, allows clients to connect using separate hostnames per network.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Each listener config must correspond to a config in OSCAR_LISTENERS\n\t- Clients MUST be able to connect to these hostnames\n\nExamples:\n\t// Local LAN config, server behind NAT\n\tLAN://192.168.1.10:5190\n\t// Separate Internet and LAN config\n\tWAN://aim.example.com:5190,LAN://192.168.1.10:5191"`
+	BOSListenersSSL         []string `envconfig:"OSCAR_LISTENERS_SSL" required:"false" basic:"" ssl:"LOCAL://0.0.0.0:5191" description:"Network listeners for core OSCAR services that receive decrypted traffic from an SSL terminator such as stunnel. Clients that connect through these listeners are redirected to the hostnames in OSCAR_ADVERTISED_LISTENERS_SSL, keeping them on the SSL path for the rest of the session.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Each listener needs a listener in OSCAR_LISTENERS and OSCAR_ADVERTISED_LISTENERS_SSL\n\t- A listener without a matching OSCAR_ADVERTISED_LISTENERS_SSL entry is not started\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5191\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5191,LAN://192.168.1.10:5192"`
+	BOSAdvertisedHostsSSL   []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_SSL" required:"false" basic:"" ssl:"LOCAL://ras.dev:5193" description:"Same as OSCAR_ADVERTISED_LISTENERS_PLAIN, except the hostname is for the server that terminates SSL. Each listener defined here must have a matching listener in OSCAR_LISTENERS_SSL for the terminator to forward decrypted traffic to."`
 	KerberosListeners       []string `envconfig:"KERBEROS_LISTENERS" required:"false" basic:"" ssl:"LOCAL://0.0.0.0:1088" description:"Network listeners for Kerberos authentication. See OSCAR_LISTENERS doc for more details.\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:1088\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:1088,LAN://192.168.1.10:1087"`
 	TOCListeners            []string `envconfig:"TOC_LISTENERS" required:"true" basic:"0.0.0.0:9898" ssl:"0.0.0.0:9898" description:"Network listeners for TOC protocol service.\n\nFormat: Comma-separated list of hostname:port pairs.\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:9898\n\t// Multiple listeners\n\t0.0.0.0:9898,192.168.1.10:9899"`
 	APIListener             string   `envconfig:"API_LISTENER" required:"true" basic:"127.0.0.1:8080" ssl:"127.0.0.1:8080" description:"Network listener for management API binds to. Only 1 listener can be specified. (Default 127.0.0.1 restricts to same machine only)."`
@@ -108,7 +161,7 @@ func (c *ICQLegacyConfig) DirectConnectionEnabled(version int) bool {
 	return false
 }
 
-func (c *Config) ParseListenersCfg() ([]Listener, error) {
+func (c *Config) ParseListenersCfg() ([]ListenerGroup, error) {
 	// Helper function to parse and validate a single URI
 	parseURI := func(uriStr string) (*url.URL, error) {
 		uriStr = strings.TrimSpace(uriStr)
@@ -132,7 +185,7 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 		return u, nil
 	}
 
-	m := make(map[string]*Listener)
+	m := make(map[string]*ListenerGroup)
 
 	// Parse BOS listeners
 	for _, uriStr := range c.BOSListeners {
@@ -145,7 +198,7 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 		}
 
 		if _, ok := m[u.Scheme]; !ok {
-			m[u.Scheme] = &Listener{}
+			m[u.Scheme] = &ListenerGroup{}
 		}
 		if m[u.Scheme].BOSListenAddress != "" {
 			return nil, errDuplicateListener
@@ -153,6 +206,25 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 		m[u.Scheme].BOSListenAddress = net.JoinHostPort(u.Hostname(), u.Port())
 	}
 
+	// Parse SSL BOS listeners
+	for _, uriStr := range c.BOSListenersSSL {
+		u, err := parseURI(uriStr)
+		if err != nil {
+			return nil, err
+		}
+		if u == nil {
+			continue
+		}
+
+		if _, ok := m[u.Scheme]; !ok {
+			m[u.Scheme] = &ListenerGroup{}
+		}
+		if m[u.Scheme].BOSListenAddressSSL != "" {
+			return nil, errDuplicateListener
+		}
+		m[u.Scheme].BOSListenAddressSSL = net.JoinHostPort(u.Hostname(), u.Port())
+	}
+
 	// Parse plaintext BOS advertised listeners
 	for _, uriStr := range c.BOSAdvertisedHostsPlain {
 		u, err := parseURI(uriStr)
@@ -164,7 +236,7 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 		}
 
 		if _, ok := m[u.Scheme]; !ok {
-			m[u.Scheme] = &Listener{}
+			m[u.Scheme] = &ListenerGroup{}
 		}
 		if m[u.Scheme].BOSAdvertisedHostPlain != "" {
 			return nil, errDuplicateListener
@@ -183,12 +255,11 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 		}
 
 		if _, ok := m[u.Scheme]; !ok {
-			m[u.Scheme] = &Listener{}
+			m[u.Scheme] = &ListenerGroup{}
 		}
 		if m[u.Scheme].BOSAdvertisedHostSSL != "" {
 			return nil, errDuplicateListener
 		}
-		m[u.Scheme].HasSSL = true
 		m[u.Scheme].BOSAdvertisedHostSSL = net.JoinHostPort(u.Hostname(), u.Port())
 	}
 
@@ -203,7 +274,7 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 		}
 
 		if _, ok := m[u.Scheme]; !ok {
-			m[u.Scheme] = &Listener{}
+			m[u.Scheme] = &ListenerGroup{}
 		}
 		if m[u.Scheme].KerberosListenAddress != "" {
 			return nil, errDuplicateListener
@@ -211,7 +282,7 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 		m[u.Scheme].KerberosListenAddress = net.JoinHostPort(u.Hostname(), u.Port())
 	}
 
-	ret := make([]Listener, 0, len(m))
+	ret := make([]ListenerGroup, 0, len(m))
 
 	for k, v := range m {
 		switch {
@@ -219,7 +290,10 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 			return nil, fmt.Errorf("missing BOS advertise address for listener `%s://`", k)
 		case v.BOSListenAddress == "":
 			return nil, fmt.Errorf("missing BOS listen address for listener `%s://`", k)
+		case v.HasSSL() && v.BOSListenAddressSSL == "":
+			return nil, fmt.Errorf("missing SSL BOS listen address for listener `%s://`", k)
 		}
+		v.Name = k
 		ret = append(ret, *v)
 	}
 
@@ -227,6 +301,26 @@ func (c *Config) ParseListenersCfg() ([]Listener, error) {
 		return nil, errNoBOSListeners
 	}
 
+	// Catch sockets that collide across lists or groups, which would otherwise
+	// surface at bind time as a bare "address already in use".
+	seen := make(map[string]string, len(ret)*3)
+	for _, l := range ret {
+		for _, socket := range []struct{ envVar, addr string }{
+			{"OSCAR_LISTENERS", l.BOSListenAddress},
+			{"OSCAR_LISTENERS_SSL", l.BOSListenAddressSSL},
+			{"KERBEROS_LISTENERS", l.KerberosListenAddress},
+		} {
+			if socket.addr == "" {
+				continue
+			}
+			src := fmt.Sprintf("%s `%s://`", socket.envVar, l.Name)
+			if prev, ok := seen[socket.addr]; ok {
+				return nil, fmt.Errorf("listen address %s is configured for both %s and %s", socket.addr, prev, src)
+			}
+			seen[socket.addr] = src
+		}
+	}
+
 	return ret, nil
 }
 

+ 152 - 69
config/config_test.go

@@ -1,6 +1,9 @@
 package config
 
 import (
+	"reflect"
+	"slices"
+	"strings"
 	"testing"
 )
 
@@ -11,9 +14,10 @@ func TestParseListenersCfg(t *testing.T) {
 		bosAdvertisedListeners []string
 		bosAdvertisedHostsSSL  []string
 		kerberosListeners      []string
-		want                   []Listener
+		want                   []ListenerGroup
 		wantErr                bool
 		errContains            string
+		bosListenersSSL        []string
 	}{
 		{
 			name:                   "valid single listener with kerberos",
@@ -21,8 +25,9 @@ func TestParseListenersCfg(t *testing.T) {
 			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
 			bosAdvertisedHostsSSL:  []string{},
 			kerberosListeners:      []string{"LOCAL://0.0.0.0:1088"},
-			want: []Listener{
+			want: []ListenerGroup{
 				{
+					Name:                   "local",
 					BOSListenAddress:       "0.0.0.0:5190",
 					BOSAdvertisedHostPlain: "127.0.0.1:5190",
 					KerberosListenAddress:  "0.0.0.0:1088",
@@ -36,8 +41,9 @@ func TestParseListenersCfg(t *testing.T) {
 			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
 			bosAdvertisedHostsSSL:  []string{},
 			kerberosListeners:      []string{},
-			want: []Listener{
+			want: []ListenerGroup{
 				{
+					Name:                   "local",
 					BOSListenAddress:       "0.0.0.0:5190",
 					BOSAdvertisedHostPlain: "127.0.0.1:5190",
 					KerberosListenAddress:  "",
@@ -51,13 +57,15 @@ func TestParseListenersCfg(t *testing.T) {
 			bosAdvertisedListeners: []string{"LAN://192.168.1.10:5190", "WAN://example.com:5191"},
 			bosAdvertisedHostsSSL:  []string{},
 			kerberosListeners:      []string{"LAN://192.168.1.10:1088"},
-			want: []Listener{
+			want: []ListenerGroup{
 				{
+					Name:                   "lan",
 					BOSListenAddress:       "192.168.1.10:5190",
 					BOSAdvertisedHostPlain: "192.168.1.10:5190",
 					KerberosListenAddress:  "192.168.1.10:1088",
 				},
 				{
+					Name:                   "wan",
 					BOSListenAddress:       "0.0.0.0:5191",
 					BOSAdvertisedHostPlain: "example.com:5191",
 					KerberosListenAddress:  "",
@@ -181,27 +189,27 @@ func TestParseListenersCfg(t *testing.T) {
 			bosAdvertisedListeners: []string{"DOCKER://172.17.0.1:5192", "LAN://192.168.1.10:5190", "WAN://example.com:5191"},
 			bosAdvertisedHostsSSL:  []string{},
 			kerberosListeners:      []string{"WAN://0.0.0.0:1089", "LAN://192.168.1.10:1088"},
-			want: []Listener{
+			want: []ListenerGroup{
 				{
+					Name:                   "docker",
+					BOSListenAddress:       "172.17.0.1:5192",
+					BOSAdvertisedHostPlain: "172.17.0.1:5192",
+					BOSAdvertisedHostSSL:   "",
+					KerberosListenAddress:  "",
+				},
+				{
+					Name:                   "lan",
 					BOSListenAddress:       "192.168.1.10:5190",
 					BOSAdvertisedHostPlain: "192.168.1.10:5190",
 					BOSAdvertisedHostSSL:   "",
 					KerberosListenAddress:  "192.168.1.10:1088",
-					HasSSL:                 false,
 				},
 				{
+					Name:                   "wan",
 					BOSListenAddress:       "0.0.0.0:5191",
 					BOSAdvertisedHostPlain: "example.com:5191",
 					BOSAdvertisedHostSSL:   "",
 					KerberosListenAddress:  "0.0.0.0:1089",
-					HasSSL:                 false,
-				},
-				{
-					BOSListenAddress:       "172.17.0.1:5192",
-					BOSAdvertisedHostPlain: "172.17.0.1:5192",
-					BOSAdvertisedHostSSL:   "",
-					KerberosListenAddress:  "",
-					HasSSL:                 false,
 				},
 			},
 			wantErr: false,
@@ -239,16 +247,18 @@ func TestParseListenersCfg(t *testing.T) {
 		{
 			name:                   "valid single listener with SSL",
 			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
+			bosListenersSSL:        []string{"LOCAL://0.0.0.0:5191"},
 			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
 			bosAdvertisedHostsSSL:  []string{"LOCAL://127.0.0.1:5193"},
 			kerberosListeners:      []string{},
-			want: []Listener{
+			want: []ListenerGroup{
 				{
+					Name:                   "local",
 					BOSListenAddress:       "0.0.0.0:5190",
+					BOSListenAddressSSL:    "0.0.0.0:5191",
 					BOSAdvertisedHostPlain: "127.0.0.1:5190",
 					BOSAdvertisedHostSSL:   "127.0.0.1:5193",
 					KerberosListenAddress:  "",
-					HasSSL:                 true,
 				},
 			},
 			wantErr: false,
@@ -256,27 +266,130 @@ func TestParseListenersCfg(t *testing.T) {
 		{
 			name:                   "valid multiple listeners with mixed SSL",
 			bosListeners:           []string{"LAN://192.168.1.10:5190", "WAN://0.0.0.0:5191"},
+			bosListenersSSL:        []string{"LAN://192.168.1.10:5195"},
 			bosAdvertisedListeners: []string{"LAN://192.168.1.10:5190", "WAN://example.com:5191"},
 			bosAdvertisedHostsSSL:  []string{"LAN://192.168.1.10:5193"},
 			kerberosListeners:      []string{},
-			want: []Listener{
+			want: []ListenerGroup{
 				{
+					Name:                   "lan",
 					BOSListenAddress:       "192.168.1.10:5190",
+					BOSListenAddressSSL:    "192.168.1.10:5195",
 					BOSAdvertisedHostPlain: "192.168.1.10:5190",
 					BOSAdvertisedHostSSL:   "192.168.1.10:5193",
 					KerberosListenAddress:  "",
-					HasSSL:                 true,
 				},
 				{
+					Name:                   "wan",
 					BOSListenAddress:       "0.0.0.0:5191",
 					BOSAdvertisedHostPlain: "example.com:5191",
 					BOSAdvertisedHostSSL:   "",
 					KerberosListenAddress:  "",
-					HasSSL:                 false,
 				},
 			},
 			wantErr: false,
 		},
+		{
+			name:                   "advertised SSL host without an SSL listen address",
+			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
+			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
+			bosAdvertisedHostsSSL:  []string{"LOCAL://127.0.0.1:5193"},
+			kerberosListeners:      []string{},
+			want:                   nil,
+			wantErr:                true,
+			errContains:            "missing SSL BOS listen address for listener `local://`",
+		},
+		{
+			name:                   "SSL listen address without advertised SSL host",
+			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
+			bosListenersSSL:        []string{"LOCAL://0.0.0.0:5191"},
+			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
+			bosAdvertisedHostsSSL:  []string{},
+			kerberosListeners:      []string{},
+			want: []ListenerGroup{
+				{
+					Name:                   "local",
+					BOSListenAddress:       "0.0.0.0:5190",
+					BOSListenAddressSSL:    "0.0.0.0:5191",
+					BOSAdvertisedHostPlain: "127.0.0.1:5190",
+					BOSAdvertisedHostSSL:   "",
+					KerberosListenAddress:  "",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name:                   "SSL listener with kerberos",
+			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
+			bosListenersSSL:        []string{"LOCAL://0.0.0.0:5191"},
+			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
+			bosAdvertisedHostsSSL:  []string{"LOCAL://127.0.0.1:5193"},
+			kerberosListeners:      []string{"LOCAL://0.0.0.0:1088"},
+			want: []ListenerGroup{
+				{
+					Name:                   "local",
+					BOSListenAddress:       "0.0.0.0:5190",
+					BOSListenAddressSSL:    "0.0.0.0:5191",
+					BOSAdvertisedHostPlain: "127.0.0.1:5190",
+					BOSAdvertisedHostSSL:   "127.0.0.1:5193",
+					KerberosListenAddress:  "0.0.0.0:1088",
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name:                   "duplicate SSL BOS listen address",
+			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
+			bosListenersSSL:        []string{"LOCAL://0.0.0.0:5191", "LOCAL://0.0.0.0:5192"},
+			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
+			bosAdvertisedHostsSSL:  []string{"LOCAL://127.0.0.1:5193"},
+			kerberosListeners:      []string{},
+			want:                   nil,
+			wantErr:                true,
+			errContains:            "duplicate listener definition",
+		},
+		{
+			name:                   "plaintext and SSL BOS listeners share an address",
+			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
+			bosListenersSSL:        []string{"LOCAL://0.0.0.0:5190"},
+			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
+			bosAdvertisedHostsSSL:  []string{"LOCAL://127.0.0.1:5193"},
+			kerberosListeners:      []string{},
+			want:                   nil,
+			wantErr:                true,
+			errContains:            "listen address 0.0.0.0:5190 is configured for both OSCAR_LISTENERS `local://` and OSCAR_LISTENERS_SSL `local://`",
+		},
+		{
+			name:                   "BOS listeners in different groups share an address",
+			bosListeners:           []string{"LAN://0.0.0.0:5190", "WAN://0.0.0.0:5190"},
+			bosAdvertisedListeners: []string{"LAN://192.168.1.10:5190", "WAN://example.com:5190"},
+			bosAdvertisedHostsSSL:  []string{},
+			kerberosListeners:      []string{},
+			want:                   nil,
+			wantErr:                true,
+			errContains:            "listen address 0.0.0.0:5190 is configured for both OSCAR_LISTENERS `lan://` and OSCAR_LISTENERS `wan://`",
+		},
+		{
+			name:                   "BOS and kerberos listeners share an address",
+			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
+			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
+			bosAdvertisedHostsSSL:  []string{},
+			kerberosListeners:      []string{"LOCAL://0.0.0.0:5190"},
+			want:                   nil,
+			wantErr:                true,
+			errContains:            "listen address 0.0.0.0:5190 is configured for both OSCAR_LISTENERS `local://` and KERBEROS_LISTENERS `local://`",
+		},
+		{
+			name:                   "SSL BOS listener missing port",
+			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
+			bosListenersSSL:        []string{"LOCAL://0.0.0.0"},
+			bosAdvertisedListeners: []string{"LOCAL://127.0.0.1:5190"},
+			bosAdvertisedHostsSSL:  []string{"LOCAL://127.0.0.1:5193"},
+			kerberosListeners:      []string{},
+			want:                   nil,
+			wantErr:                true,
+			errContains:            "missing port",
+		},
 		{
 			name:                   "SSL host without corresponding BOS listener",
 			bosListeners:           []string{"LOCAL://0.0.0.0:5190"},
@@ -330,30 +443,33 @@ func TestParseListenersCfg(t *testing.T) {
 		{
 			name:                   "complex multi-listener setup with SSL",
 			bosListeners:           []string{"LAN://192.168.1.10:5190", "WAN://0.0.0.0:5191", "DOCKER://172.17.0.1:5192"},
+			bosListenersSSL:        []string{"LAN://192.168.1.10:5195", "WAN://0.0.0.0:5196"},
 			bosAdvertisedListeners: []string{"DOCKER://172.17.0.1:5192", "LAN://192.168.1.10:5190", "WAN://example.com:5191"},
 			bosAdvertisedHostsSSL:  []string{"LAN://192.168.1.10:5193", "WAN://ssl.example.com:5194"},
 			kerberosListeners:      []string{"WAN://0.0.0.0:1089", "LAN://192.168.1.10:1088"},
-			want: []Listener{
+			want: []ListenerGroup{
+				{
+					Name:                   "docker",
+					BOSListenAddress:       "172.17.0.1:5192",
+					BOSAdvertisedHostPlain: "172.17.0.1:5192",
+					BOSAdvertisedHostSSL:   "",
+					KerberosListenAddress:  "",
+				},
 				{
+					Name:                   "lan",
 					BOSListenAddress:       "192.168.1.10:5190",
+					BOSListenAddressSSL:    "192.168.1.10:5195",
 					BOSAdvertisedHostPlain: "192.168.1.10:5190",
 					BOSAdvertisedHostSSL:   "192.168.1.10:5193",
 					KerberosListenAddress:  "192.168.1.10:1088",
-					HasSSL:                 true,
 				},
 				{
+					Name:                   "wan",
 					BOSListenAddress:       "0.0.0.0:5191",
+					BOSListenAddressSSL:    "0.0.0.0:5196",
 					BOSAdvertisedHostPlain: "example.com:5191",
 					BOSAdvertisedHostSSL:   "ssl.example.com:5194",
 					KerberosListenAddress:  "0.0.0.0:1089",
-					HasSSL:                 true,
-				},
-				{
-					BOSListenAddress:       "172.17.0.1:5192",
-					BOSAdvertisedHostPlain: "172.17.0.1:5192",
-					BOSAdvertisedHostSSL:   "",
-					KerberosListenAddress:  "",
-					HasSSL:                 false,
 				},
 			},
 			wantErr: false,
@@ -364,6 +480,7 @@ func TestParseListenersCfg(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			config := &Config{
 				BOSListeners:            tt.bosListeners,
+				BOSListenersSSL:         tt.bosListenersSSL,
 				BOSAdvertisedHostsPlain: tt.bosAdvertisedListeners,
 				BOSAdvertisedHostsSSL:   tt.bosAdvertisedHostsSSL,
 				KerberosListeners:       tt.kerberosListeners,
@@ -386,47 +503,13 @@ func TestParseListenersCfg(t *testing.T) {
 				return
 			}
 
-			if len(got) != len(tt.want) {
-				t.Errorf("ParseListenersCfg() returned %d listeners, want %d", len(got), len(tt.want))
-				return
-			}
-
-			// Create maps for easier comparison
-			gotMap := make(map[string]Listener)
-			wantMap := make(map[string]Listener)
-
-			for _, l := range got {
-				key := l.BOSListenAddress + "|" + l.BOSAdvertisedHostPlain
-				gotMap[key] = l
-			}
-
-			for _, l := range tt.want {
-				key := l.BOSListenAddress + "|" + l.BOSAdvertisedHostPlain
-				wantMap[key] = l
-			}
+			// groups come back in map order; want is listed by name
+			slices.SortFunc(got, func(a, b ListenerGroup) int {
+				return strings.Compare(a.Name, b.Name)
+			})
 
-			for key, wantListener := range wantMap {
-				gotListener, exists := gotMap[key]
-				if !exists {
-					t.Errorf("ParseListenersCfg() missing listener with key %s", key)
-					continue
-				}
-
-				if gotListener.BOSListenAddress != wantListener.BOSListenAddress {
-					t.Errorf("ParseListenersCfg() BOSListenAddress = %v, want %v", gotListener.BOSListenAddress, wantListener.BOSListenAddress)
-				}
-				if gotListener.BOSAdvertisedHostPlain != wantListener.BOSAdvertisedHostPlain {
-					t.Errorf("ParseListenersCfg() BOSAdvertisedHostPlain = %v, want %v", gotListener.BOSAdvertisedHostPlain, wantListener.BOSAdvertisedHostPlain)
-				}
-				if gotListener.BOSAdvertisedHostSSL != wantListener.BOSAdvertisedHostSSL {
-					t.Errorf("ParseListenersCfg() BOSAdvertisedHostSSL = %v, want %v", gotListener.BOSAdvertisedHostSSL, wantListener.BOSAdvertisedHostSSL)
-				}
-				if gotListener.HasSSL != wantListener.HasSSL {
-					t.Errorf("ParseListenersCfg() HasSSL = %v, want %v", gotListener.HasSSL, wantListener.HasSSL)
-				}
-				if gotListener.KerberosListenAddress != wantListener.KerberosListenAddress {
-					t.Errorf("ParseListenersCfg() KerberosListenAddress = %v, want %v", gotListener.KerberosListenAddress, wantListener.KerberosListenAddress)
-				}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("ParseListenersCfg() = %+v, want %+v", got, tt.want)
 			}
 		})
 	}

+ 24 - 2
config/ssl/settings.env

@@ -31,10 +31,32 @@ export OSCAR_LISTENERS=LOCAL://0.0.0.0:5190
 # 	LAN://192.168.1.10:5190
 # 	// Separate Internet and LAN config
 # 	WAN://aim.example.com:5190,LAN://192.168.1.10:5191
-export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://127.0.0.1:5190
+export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://ras.dev:5190
+
+# Network listeners for core OSCAR services that receive decrypted traffic from
+# an SSL terminator such as stunnel. Clients that connect through these
+# listeners are redirected to the hostnames in OSCAR_ADVERTISED_LISTENERS_SSL,
+# keeping them on the SSL path for the rest of the session.
+# 
+# Format:
+# 	- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]
+# 	- Listener names and ports must be unique
+# 	- Each listener needs a listener in OSCAR_LISTENERS and
+# OSCAR_ADVERTISED_LISTENERS_SSL
+# 	- A listener without a matching OSCAR_ADVERTISED_LISTENERS_SSL entry is not
+# started
+# 
+# Examples:
+# 	// Listen on all interfaces
+# 	LAN://0.0.0.0:5191
+# 	// Separate Internet and LAN config
+# 	WAN://142.250.176.206:5191,LAN://192.168.1.10:5192
+export OSCAR_LISTENERS_SSL=LOCAL://0.0.0.0:5191
 
 # Same as OSCAR_ADVERTISED_LISTENERS_PLAIN, except the hostname is for the
-# server that terminates SSL.
+# server that terminates SSL. Each listener defined here must have a matching
+# listener in OSCAR_LISTENERS_SSL for the terminator to forward decrypted
+# traffic to.
 export OSCAR_ADVERTISED_LISTENERS_SSL=LOCAL://ras.dev:5193
 
 # Network listeners for Kerberos authentication. See OSCAR_LISTENERS doc for

+ 1 - 1
config/ssl/stunnel.conf

@@ -16,5 +16,5 @@ options = NO_SSLv3
 options = NO_TLSv1_1
 ciphers = ALL
 accept = 5193
-connect = open-oscar-server:5190
+connect = open-oscar-server:5191
 cert = /etc/stunnel/certs/server.pem

+ 19 - 17
foodgroup/auth.go

@@ -291,9 +291,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, advertisedHost string) (wire.SNACMessage, error) {
+func (s AuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error) {
 
-	block, err := s.login(ctx, inBody.TLVList, advertisedHost)
+	block, err := s.login(ctx, inBody.TLVList, endpointCfg)
 	if err != nil {
 		return wire.SNACMessage{}, err
 	}
@@ -319,8 +319,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, advertisedHost string) (wire.TLVRestBlock, error) {
-	return s.login(ctx, inFrame.TLVList, advertisedHost)
+func (s AuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
+	return s.login(ctx, inFrame.TLVList, endpointCfg)
 }
 
 // KerberosLogin handles AIM-style Kerberos authentication for AIM 6.0+.
@@ -331,7 +331,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, advertisedHost string) (wire.SNACMessage, error) {
+func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error) {
 
 	b, ok := inBody.TicketRequestMetadata.Bytes(wire.KerberosTLVTicketRequest)
 	if !ok {
@@ -353,7 +353,7 @@ func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_
 	} else {
 		list = append(list, wire.NewTLVBE(wire.LoginTLVTagsPlaintextKerberosPassword, info.Password))
 	}
-	result, err := s.login(ctx, list, advertisedHost)
+	result, err := s.login(ctx, list, endpointCfg)
 	if err != nil {
 		return wire.SNACMessage{}, fmt.Errorf("login: %w", err)
 	}
@@ -403,7 +403,7 @@ func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_
 								Unknown: 1,
 								ConnectionInfo: wire.TLVBlock{
 									TLVList: wire.TLVList{
-										wire.NewTLVBE(wire.KerberosTLVHostname, advertisedHost),
+										wire.NewTLVBE(wire.KerberosTLVHostname, endpointCfg.AdvertisedHost()),
 										wire.NewTLVBE(wire.KerberosTLVCookie, cookie),
 										wire.NewTLVBE(wire.KerberosTLVConnSettings, wire.KerberosConnUseSSL),
 									},
@@ -492,7 +492,7 @@ 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, advertisedHost string) (wire.TLVRestBlock, error) {
+func (s AuthService) login(ctx context.Context, tlv wire.TLVList, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 
 	props := loginProperties{}
 	if err := props.fromTLV(tlv); err != nil {
@@ -525,7 +525,7 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, advertisedHost
 		if s.config.DisableAuth {
 			// auth disabled, create the user
 			s.logger.Debug("login: auth disabled, creating user", "screen_name", props.screenName)
-			return s.createUser(ctx, props, advertisedHost)
+			return s.createUser(ctx, props, endpointCfg)
 		}
 		// auth enabled, return separate login errors for ICQ and AIM
 		loginErr := wire.LoginErrInvalidUsernameOrPassword
@@ -551,7 +551,7 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, advertisedHost
 	if s.config.DisableAuth {
 		// user exists, but don't validate
 		s.logger.Debug("login: auth disabled, skipping password validation", "screen_name", props.screenName)
-		return s.loginSuccessResponse(ctx, props, advertisedHost)
+		return s.loginSuccessResponse(ctx, props, endpointCfg)
 	}
 
 	var loginOK bool
@@ -604,10 +604,10 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, advertisedHost
 	}
 
 	s.logger.Debug("login: login successful", "screen_name", props.screenName)
-	return s.loginSuccessResponse(ctx, props, advertisedHost)
+	return s.loginSuccessResponse(ctx, props, endpointCfg)
 }
 
-func (s AuthService) createUser(ctx context.Context, props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) {
+func (s AuthService) createUser(ctx context.Context, props loginProperties, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 	err := s.createAccount(ctx, props.screenName, "welcome1")
 	if err != nil {
 		switch {
@@ -620,10 +620,10 @@ func (s AuthService) createUser(ctx context.Context, props loginProperties, adve
 		}
 	}
 
-	return s.loginSuccessResponse(ctx, props, advertisedHost)
+	return s.loginSuccessResponse(ctx, props, endpointCfg)
 }
 
-func (s AuthService) loginSuccessResponse(ctx context.Context, props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) {
+func (s AuthService) loginSuccessResponse(ctx context.Context, props loginProperties, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 	loginCookie := state.ServerCookie{
 		Service:       wire.BOS,
 		ScreenName:    props.screenName,
@@ -643,17 +643,19 @@ func (s AuthService) loginSuccessResponse(ctx context.Context, props loginProper
 		return wire.TLVRestBlock{}, fmt.Errorf("failed to issue auth cookie: %w", err)
 	}
 
-	reconnectHost := advertisedHost
 	sslState := wire.OServiceServiceResponseSSLStateNotUsed
+	if endpointCfg.IsSSL {
+		sslState = wire.OServiceServiceResponseSSLStateResume
+	}
 
 	s.logger.Debug("loginSuccessResponse: returning login response",
 		"screen_name", props.screenName,
-		"reconnect_host", reconnectHost,
+		"reconnect_host", endpointCfg.AdvertisedHost(),
 		"ssl_state", sslState)
 
 	loginTLVTags := wire.TLVList{
 		wire.NewTLVBE(wire.LoginTLVTagsScreenName, props.screenName),
-		wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, reconnectHost),
+		wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, endpointCfg.AdvertisedHost()),
 		wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, cookie),
 		wire.NewTLVBE(wire.OServiceTLVTagsSSLState, sslState),
 	}

+ 123 - 77
foodgroup/auth_test.go

@@ -30,8 +30,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
-		// advertisedHost is the BOS host the client will connect to upon successful login
-		advertisedHost string
+		// endpointCfg is the listener the client authenticated through
+		endpointCfg config.Endpoint
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the SNAC sent from the client to the server
@@ -49,8 +49,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 		maxConcurrentLoginsPerUser int
 	}{
 		{
-			name:           "AIM account exists, correct password, login OK, no concurrent logins",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, correct password, login OK, no concurrent logins",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -113,8 +113,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			maxConcurrentLoginsPerUser: 2,
 		},
 		{
-			name:           "AIM account exists, correct password, login OK, concurrent logins under limit",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, correct password, login OK, concurrent logins under limit",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -185,8 +185,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 		},
 
 		{
-			name:           "login fails when concurrent login limit is reached",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "login fails when concurrent login limit is reached",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -240,8 +240,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			maxConcurrentLoginsPerUser: 2,
 		},
 		{
-			name:           "ICQ account exists, correct password, login OK",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "ICQ account exists, correct password, login OK",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -295,8 +295,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account exists, incorrect password, login fails",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, incorrect password, login fails",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -331,8 +331,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account doesn't exist, login fails",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account doesn't exist, login fails",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -367,8 +367,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account is suspended",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account is suspended",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -405,8 +405,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "ICQ account doesn't exist, login fails",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "ICQ account doesn't exist, login fails",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -441,8 +441,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "account doesn't exist, authentication is disabled, account is created, login succeeds",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "account doesn't exist, authentication is disabled, account is created, login succeeds",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			cfg: config.Config{
 				DisableAuth: true,
 			},
@@ -502,8 +502,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account doesn't exist, authentication is disabled, screen name has bad format, login fails",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account doesn't exist, authentication is disabled, screen name has bad format, login fails",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			cfg: config.Config{
 				DisableAuth: true,
 			},
@@ -546,8 +546,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "ICQ account doesn't exist, authentication is disabled, UIN has bad format, login fails",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "ICQ account doesn't exist, authentication is disabled, UIN has bad format, login fails",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			cfg: config.Config{
 				DisableAuth: true,
 			},
@@ -590,8 +590,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "account exists, password is invalid, authentication is disabled, login succeeds",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "account exists, password is invalid, authentication is disabled, login succeeds",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			cfg: config.Config{
 				DisableAuth: true,
 			},
@@ -668,8 +668,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			wantErr: io.EOF,
 		},
 		{
-			name:           "login with TOC client - success",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "login with TOC client - success",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -721,8 +721,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account exists, correct password, linked accounts in response",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, correct password, linked accounts in response",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -783,8 +783,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "feedbag error during login, returns error",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "feedbag error during login, returns error",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -829,8 +829,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			wantErr: io.EOF,
 		},
 		{
-			name:           "login with TOC client - failed",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "login with TOC client - failed",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -906,7 +906,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 				createAccount:              tc.createAccount,
 				logger:                     slog.Default(),
 			}
-			outputSNAC, err := svc.BUCPLogin(context.Background(), tc.inputSNAC, tc.advertisedHost)
+			outputSNAC, err := svc.BUCPLogin(context.Background(), tc.inputSNAC, tc.endpointCfg)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -924,8 +924,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
-		// advertisedHost is the BOS host the client will connect to upon successful login
-		advertisedHost string
+		// endpointCfg is the listener the client authenticated through
+		endpointCfg config.Endpoint
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the authentication FLAP frame sent from the client to the server
@@ -941,8 +941,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 		wantErr error
 	}{
 		{
-			name:           "AIM account exists, correct password, login OK",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, correct password, login OK",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -986,8 +986,59 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "ICQ account exists, correct password, login OK",
-			advertisedHost: "127.0.0.1:5190",
+			name: "AIM account exists, correct password, login OK via SSL listener",
+			endpointCfg: config.Endpoint{
+				Group: config.ListenerGroup{
+					BOSAdvertisedHostPlain: "127.0.0.1:5190",
+					BOSAdvertisedHostSSL:   "ras.dev:5193",
+				},
+				IsSSL: true,
+			},
+			inputSNAC: wire.FLAPSignonFrame{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.LoginTLVTagsRoastedPassword, wire.RoastOSCARPassword([]byte("the_password"))),
+						wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.IdentScreenName,
+							result:     &user,
+						},
+					},
+				},
+				cookieBakerParams: cookieBakerParams{
+					cookieIssueParams: cookieIssueParams{
+						{
+							dataIn: func() []byte {
+								loginCookie := state.ServerCookie{
+									ScreenName: user.DisplayScreenName,
+								}
+								buf := &bytes.Buffer{}
+								assert.NoError(t, wire.MarshalBE(loginCookie, buf))
+								return buf.Bytes()
+							}(),
+							cookieOut: []byte("the-cookie"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
+					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "ras.dev:5193"),
+					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+					wire.NewTLVBE(wire.OServiceTLVTagsSSLState, wire.OServiceServiceResponseSSLStateResume),
+				},
+			},
+		},
+		{
+			name:        "ICQ account exists, correct password, login OK",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1033,8 +1084,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account exists, incorrect password, login fails",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, incorrect password, login fails",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1061,8 +1112,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account doesn't exist, login fails",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account doesn't exist, login fails",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1089,8 +1140,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "ICQ account doesn't exist, login fails",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "ICQ account doesn't exist, login fails",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1118,8 +1169,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "account doesn't exist, authentication is disabled, account is created, login succeeds",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "account doesn't exist, authentication is disabled, account is created, login succeeds",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			cfg: config.Config{
 				DisableAuth: true,
 			},
@@ -1171,8 +1222,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "account exists, password is invalid, authentication is disabled, login succeeds",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "account exists, password is invalid, authentication is disabled, login succeeds",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			cfg: config.Config{
 				DisableAuth: true,
 			},
@@ -1219,8 +1270,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "feedbag error during login, returns error",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "feedbag error during login, returns error",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1287,8 +1338,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			wantErr: io.EOF,
 		},
 		{
-			name:           "login with AIM 1.1.19 for Java - success",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "login with AIM 1.1.19 for Java - success",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1334,8 +1385,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "login with AIM 1.1.19 for Java - failed",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "login with AIM 1.1.19 for Java - failed",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}},
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1393,7 +1444,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 				createAccount:  tc.createAccount,
 				logger:         slog.Default(),
 			}
-			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.advertisedHost)
+			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.endpointCfg)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -1411,8 +1462,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
-		// advertisedHost is the BOS host the client will connect to upon successful login
-		advertisedHost string
+		// endpointCfg is the SSL listener the client authenticated through
+		endpointCfg config.Endpoint
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the kerberos SNAC sent from the client to the server
@@ -1430,8 +1481,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 		timeNow func() time.Time
 	}{
 		{
-			name:           "AIM account exists, correct password, login OK",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, correct password, login OK",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostSSL: "127.0.0.1:5190"}, IsSSL: true},
 			timeNow: func() time.Time {
 				return time.Unix(1000, 0)
 			},
@@ -1526,8 +1577,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account exists, incorrect password, login failed",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, incorrect password, login failed",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostSSL: "127.0.0.1:5190"}, IsSSL: true},
 			timeNow: func() time.Time {
 				return time.Unix(1000, 0)
 			},
@@ -1566,8 +1617,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account exists, correct roasted password, login OK",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, correct roasted password, login OK",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostSSL: "127.0.0.1:5190"}, IsSSL: true},
 			timeNow: func() time.Time {
 				return time.Unix(1000, 0)
 			},
@@ -1663,8 +1714,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 			},
 		},
 		{
-			name:           "AIM account exists, incorrect roasted password, login failed",
-			advertisedHost: "127.0.0.1:5190",
+			name:        "AIM account exists, incorrect roasted password, login failed",
+			endpointCfg: config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostSSL: "127.0.0.1:5190"}, IsSSL: true},
 			timeNow: func() time.Time {
 				return time.Unix(1000, 0)
 			},
@@ -1738,7 +1789,7 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 				createAccount:              tc.createAccount,
 				logger:                     slog.Default(),
 			}
-			outputSNAC, err := svc.KerberosLogin(context.Background(), tc.inputSNAC, tc.advertisedHost)
+			outputSNAC, err := svc.KerberosLogin(context.Background(), tc.inputSNAC, tc.endpointCfg)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -1750,8 +1801,6 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
-		// advertisedHost is the BOS host the client will connect to upon successful login
-		advertisedHost string
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the SNAC sent from the client to the server
@@ -1765,8 +1814,7 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 		wantErr error
 	}{
 		{
-			name:           "login with valid username, expect OK login response",
-			advertisedHost: "127.0.0.1:5190",
+			name: "login with valid username, expect OK login response",
 			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1798,8 +1846,7 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "login with invalid username, expect OK login response (Cfg.DisableAuth=true)",
-			advertisedHost: "127.0.0.1:5190",
+			name: "login with invalid username, expect OK login response (Cfg.DisableAuth=true)",
 			cfg: config.Config{
 				DisableAuth: true,
 			},
@@ -1831,8 +1878,7 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 			},
 		},
 		{
-			name:           "login with invalid username, expect failed login response (Cfg.DisableAuth=false)",
-			advertisedHost: "127.0.0.1:5190",
+			name: "login with invalid username, expect failed login response (Cfg.DisableAuth=false)",
 			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{

+ 10 - 18
foodgroup/oservice.go

@@ -588,7 +588,7 @@ func buildRateLimitUpdate(code uint16, curRate state.RateClassState, instance *s
 
 // ServiceRequest handles service discovery, providing a host name and metadata
 // for connecting to the food group service specified in inFrame.
-func (s OServiceService) ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error) {
+func (s OServiceService) ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error) {
 	if service != wire.BOS {
 		return wire.SNACMessage{
 			Frame: wire.SNACFrame{
@@ -602,20 +602,6 @@ func (s OServiceService) ServiceRequest(ctx context.Context, service uint16, ins
 		}, nil
 	}
 
-	if inBody.HasTag(wire.OserviceTLVTagsSSLUseSSL) && !listener.HasSSL {
-		s.logger.DebugContext(ctx, "service request for SSL but the listener doesn't support SSL")
-		return wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.OService,
-				SubGroup:  wire.OServiceErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: wire.SNACError{
-				Code: wire.ErrorCodeGeneralFailure,
-			},
-		}, nil
-	}
-
 	fnIssueCookie := func(val any) ([]byte, error) {
 		buf := &bytes.Buffer{}
 		if err := wire.MarshalBE(val, buf); err != nil {
@@ -702,12 +688,18 @@ func (s OServiceService) ServiceRequest(ctx context.Context, service uint16, ins
 		}, nil
 	}
 
-	host := listener.BOSAdvertisedHostPlain
+	host := listenerGroup.BOSAdvertisedHostPlain
 	stateCode := wire.OServiceServiceResponseSSLStateNotUsed
 
 	if inBody.HasTag(wire.OserviceTLVTagsSSLUseSSL) {
-		host = listener.BOSAdvertisedHostSSL
-		stateCode = wire.OServiceServiceResponseSSLStateResume
+		if listenerGroup.HasSSL() {
+			host = listenerGroup.BOSAdvertisedHostSSL
+			stateCode = wire.OServiceServiceResponseSSLStateResume
+		} else {
+			// redirect to the plaintext host and let the client decide whether
+			// to downgrade or give up
+			s.logger.DebugContext(ctx, "service request for SSL but the listener doesn't support SSL")
+		}
 	}
 
 	return wire.SNACMessage{

+ 87 - 63
foodgroup/oservice_test.go

@@ -27,7 +27,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 		// service is the OSCAR service type
 		service uint16
 		// listener is the connection listener
-		listener config.Listener
+		listenerGroup config.ListenerGroup
 		// instance is the session of the user requesting the chat service
 		// info
 		instance *state.SessionInstance
@@ -43,10 +43,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 		expectErr error
 	}{
 		{
-			name:     "request info for connecting to admin svc, return admin svc connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to admin svc, return admin svc connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -92,10 +92,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to alert svc, return alert svc connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to alert svc, return alert svc connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -141,10 +141,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to BART service, return BART connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to BART service, return BART connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -190,10 +190,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to chat nav, return chat nav connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to chat nav, return chat nav connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -239,10 +239,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to chat room, return chat service and chat room metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to chat room, return chat service and chat room metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -307,10 +307,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			}(),
 		},
 		{
-			name:     "request info for connecting to BART service, return BART connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to BART service, return BART connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -356,10 +356,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to non-existent chat room, return ErrChatRoomNotFound",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to non-existent chat room, return ErrChatRoomNotFound",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -436,10 +436,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to admin svc with SSL, return admin svc SSL connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235", HasSSL: true},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to admin svc with SSL, return admin svc SSL connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -490,10 +490,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to alert svc with SSL, return alert svc SSL connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235", HasSSL: true},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to alert svc with SSL, return alert svc SSL connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -544,10 +544,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to BART service with SSL, return BART SSL connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235", HasSSL: true},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to BART service with SSL, return BART SSL connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -598,10 +598,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to chat nav with SSL, return chat nav SSL connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235", HasSSL: true},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to chat nav with SSL, return chat nav SSL connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -652,10 +652,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request info for connecting to chat room with SSL, return chat service SSL connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235", HasSSL: true},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to chat room with SSL, return chat service SSL connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -721,10 +721,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			}(),
 		},
 		{
-			name:     "request info for connecting to ODir service with SSL, return ODir SSL connection metadata",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235", HasSSL: true},
-			instance: newTestInstance("me"),
+			name:          "request info for connecting to ODir service with SSL, return ODir SSL connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234", BOSAdvertisedHostSSL: "127.0.0.1:1235"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -775,10 +775,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name:     "request SSL service but listener doesn't support SSL, return error",
-			service:  wire.BOS,
-			listener: config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234", HasSSL: false},
-			instance: newTestInstance("me"),
+			name:          "request SSL service but listener doesn't support SSL, return plaintext connection metadata",
+			service:       wire.BOS,
+			listenerGroup: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"},
+			instance:      newTestInstance("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -795,14 +795,38 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.OService,
-					SubGroup:  wire.OServiceErr,
+					SubGroup:  wire.OServiceServiceResponse,
 					RequestID: 1234,
 				},
-				Body: wire.SNACError{
-					Code: wire.ErrorCodeGeneralFailure,
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.Admin),
+							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					cookieIssueParams: cookieIssueParams{
+						{
+							dataIn: []byte{
+								0x00, 0x07, // admin service
+								0x02, 'm', 'e',
+								0x0,  // no client ID
+								0x0,  // no chat cookie
+								0x0,  // multi conn flag
+								0x0,  // kerberos flag
+								0x01, // session num
+							},
+							cookieOut: []byte("the-cookie"),
+						},
+					},
 				},
 			},
-			mockParams: mockParams{},
 		},
 	}
 
@@ -831,7 +855,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, nil, nil, nil)
 
 			outputSNAC, err := svc.ServiceRequest(context.Background(), tc.service, tc.instance, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest), tc.listener)
+				tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest), tc.listenerGroup)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return
@@ -1008,7 +1032,7 @@ func TestOServiceService_ServiceRequest_LinkedAccountSignon(t *testing.T) {
 			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, nil, nil, nil, nil,
 				wire.DefaultSNACRateLimits(), nil, nil, nil, feedbagManager)
 
-			listener := config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:5190"}
+			listener := config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:5190"}
 
 			outputSNAC, err := svc.ServiceRequest(context.Background(), wire.BOS, instance,
 				wire.SNACFrame{RequestID: 1234}, tc.inputBody, listener)

+ 1 - 1
server/icq_legacy/handler.go

@@ -284,7 +284,7 @@ type BaseHandler struct {
 
 // AuthService provides OSCAR authentication and BOS session registration.
 type AuthService interface {
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, cfg func(*state.Session)) (*state.SessionInstance, error)
 }

+ 16 - 15
server/icq_legacy/mock_auth_service_test.go

@@ -7,6 +7,7 @@ package icq_legacy
 import (
 	"context"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 	mock "github.com/stretchr/testify/mock"
@@ -100,8 +101,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, advertisedHost string) (wire.TLVRestBlock, error) {
-	ret := _mock.Called(ctx, inFrame, advertisedHost)
+func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
+	ret := _mock.Called(ctx, inFrame, endpointCfg)
 
 	if len(ret) == 0 {
 		panic("no return value specified for FLAPLogin")
@@ -109,16 +110,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, string) (wire.TLVRestBlock, error)); ok {
-		return returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) (wire.TLVRestBlock, error)); ok {
+		return returnFunc(ctx, inFrame, endpointCfg)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, string) wire.TLVRestBlock); ok {
-		r0 = returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) wire.TLVRestBlock); ok {
+		r0 = returnFunc(ctx, inFrame, endpointCfg)
 	} else {
 		r0 = ret.Get(0).(wire.TLVRestBlock)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, string) error); ok {
-		r1 = returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) error); ok {
+		r1 = returnFunc(ctx, inFrame, endpointCfg)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -133,12 +134,12 @@ type mockAuthService_FLAPLogin_Call struct {
 // FLAPLogin is a helper method to define mock.On call
 //   - ctx context.Context
 //   - inFrame wire.FLAPSignonFrame
-//   - advertisedHost string
-func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, advertisedHost interface{}) *mockAuthService_FLAPLogin_Call {
-	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, advertisedHost)}
+//   - endpointCfg config.Endpoint
+func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, endpointCfg interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, endpointCfg)}
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -148,9 +149,9 @@ func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFr
 		if args[1] != nil {
 			arg1 = args[1].(wire.FLAPSignonFrame)
 		}
-		var arg2 string
+		var arg2 config.Endpoint
 		if args[2] != nil {
-			arg2 = args[2].(string)
+			arg2 = args[2].(config.Endpoint)
 		}
 		run(
 			arg0,
@@ -166,7 +167,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, advertisedHost string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 2 - 1
server/icq_legacy/property_test.go

@@ -7,6 +7,7 @@ import (
 	"testing"
 	"testing/quick"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 	"github.com/stretchr/testify/mock"
@@ -55,7 +56,7 @@ func TestProperty_ServiceBehavioralEquivalence(t *testing.T) {
 		password := string([]byte{'a' + passByte%26})
 
 		authSvc := newMockAuthService(t)
-		authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+		authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, config.Endpoint{}).
 			Return(wire.TLVRestBlock{
 				TLVList: []wire.TLV{
 					wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrICQUserErr),

+ 2 - 1
server/icq_legacy/service.go

@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -197,7 +198,7 @@ func (s *ICQLegacyService) legacyFLAPLogin(ctx context.Context, uin uint32, pass
 	signonFrame := wire.FLAPSignonFrame{}
 	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, screenName))
 	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, password))
-	return s.authService.FLAPLogin(ctx, signonFrame, "")
+	return s.authService.FLAPLogin(ctx, signonFrame, config.Endpoint{})
 }
 
 // ProcessContactList processes a contact list and returns online status for each contact.

+ 7 - 6
server/icq_legacy/service_test.go

@@ -7,6 +7,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 	"github.com/stretchr/testify/assert"
@@ -40,7 +41,7 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				Version:  ICQLegacyVersionV5,
 			},
 			setupAuth: func(authSvc *mockAuthService) {
-				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, config.Endpoint{}).
 					Return(successBlock, nil)
 				authSvc.EXPECT().CrackCookie(authCookie).
 					Return(serverCookie, nil)
@@ -60,7 +61,7 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				Version:  ICQLegacyVersionV5,
 			},
 			setupAuth: func(authSvc *mockAuthService) {
-				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, config.Endpoint{}).
 					Return(wire.TLVRestBlock{
 						TLVList: []wire.TLV{
 							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrInvalidPassword),
@@ -80,7 +81,7 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				Version:  ICQLegacyVersionV5,
 			},
 			setupAuth: func(authSvc *mockAuthService) {
-				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, config.Endpoint{}).
 					Return(wire.TLVRestBlock{
 						TLVList: []wire.TLV{
 							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrICQUserErr),
@@ -111,7 +112,7 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				Version:  ICQLegacyVersionV5,
 			},
 			setupAuth: func(authSvc *mockAuthService) {
-				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, config.Endpoint{}).
 					Return(wire.TLVRestBlock{
 						TLVList: []wire.TLV{
 							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrInvalidPassword),
@@ -1001,14 +1002,14 @@ func TestICQLegacyService_DeleteUser(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			authSvc := newMockAuthService(t)
 			if tc.wantErr {
-				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, config.Endpoint{}).
 					Return(wire.TLVRestBlock{
 						TLVList: []wire.TLV{
 							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrInvalidPassword),
 						},
 					}, nil)
 			} else {
-				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, config.Endpoint{}).
 					Return(wire.TLVRestBlock{}, nil)
 			}
 

+ 15 - 10
server/kerberos/kerberos.go

@@ -16,25 +16,30 @@ import (
 )
 
 type AuthService interface {
-	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, advertisedHost string) (wire.SNACMessage, error)
+	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error)
 }
 
-func NewKerberosServer(listeners []config.Listener, logger *slog.Logger, authService AuthService) *Server {
-	servers := make([]*http.Server, 0, len(listeners))
+func NewKerberosServer(groups []config.ListenerGroup, logger *slog.Logger, authService AuthService) *Server {
+	servers := make([]*http.Server, 0, len(groups))
 
-	for _, l := range listeners {
-		if l.KerberosListenAddress == "" {
+	for _, group := range groups {
+		if group.KerberosListenAddress == "" {
+			continue
+		}
+		// only support SSL for now
+		endpoint, ok := group.SSLEndpoint()
+		if !ok {
 			continue
 		}
 
 		mux := http.NewServeMux()
 
 		mux.HandleFunc("POST /", func(writer http.ResponseWriter, request *http.Request) {
-			postHandler(writer, request, authService, logger, l.BOSAdvertisedHostSSL)
+			postHandler(writer, request, authService, logger, endpoint)
 		})
 
 		servers = append(servers, &http.Server{
-			Addr:    l.KerberosListenAddress,
+			Addr:    group.KerberosListenAddress,
 			Handler: mux,
 		})
 	}
@@ -87,7 +92,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
 }
 
 // postHandler handles AIM-style Kerberos authentication for AIM 6.0+.
-func postHandler(w http.ResponseWriter, r *http.Request, authService AuthService, logger *slog.Logger, listenAddress string) {
+func postHandler(w http.ResponseWriter, r *http.Request, authService AuthService, logger *slog.Logger, endpointCfg config.Endpoint) {
 	b, err := io.ReadAll(r.Body)
 	if err != nil {
 		http.Error(w, "unable to read HTTP body", http.StatusBadRequest)
@@ -111,7 +116,7 @@ func postHandler(w http.ResponseWriter, r *http.Request, authService AuthService
 		return
 	}
 
-	response, err := authService.KerberosLogin(r.Context(), body, listenAddress)
+	response, err := authService.KerberosLogin(r.Context(), body, endpointCfg)
 	if err != nil {
 		logger.Error("authService.KerberosLogin", "err", err.Error())
 		http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -121,7 +126,7 @@ func postHandler(w http.ResponseWriter, r *http.Request, authService AuthService
 	logger = logger.With("ip", r.RemoteAddr)
 	switch v := response.Body.(type) {
 	case wire.SNAC_0x050C_0x0003_KerberosLoginSuccessResponse:
-		logger.InfoContext(r.Context(), "successful kerberos login", "screen_name", v.ClientPrincipal, "redirect_to", listenAddress)
+		logger.InfoContext(r.Context(), "successful kerberos login", "screen_name", v.ClientPrincipal, "redirect_to", endpointCfg.AdvertisedHost())
 	case wire.SNAC_0x050C_0x0004_KerberosLoginErrResponse:
 		logger.InfoContext(r.Context(), "failed kerberos login", "screen_name", v.ScreenName)
 	}

+ 71 - 24
server/kerberos/kerberos_test.go

@@ -22,7 +22,7 @@ import (
 func TestKerberosLoginHandler(t *testing.T) {
 	tests := []struct {
 		name               string
-		listeners          []config.Listener
+		listeners          []config.ListenerGroup
 		request            wire.SNACMessage
 		response           wire.SNACMessage
 		responseErr        error
@@ -32,10 +32,10 @@ func TestKerberosLoginHandler(t *testing.T) {
 	}{
 		{
 			name: "successful login with single listener",
-			listeners: []config.Listener{
+			listeners: []config.ListenerGroup{
 				{
-					KerberosListenAddress:  ":1088",
-					BOSAdvertisedHostPlain: "localhost:5190",
+					KerberosListenAddress: ":1088",
+					BOSAdvertisedHostSSL:  "localhost:5190",
 				},
 			},
 			request: wire.SNACMessage{
@@ -62,14 +62,14 @@ func TestKerberosLoginHandler(t *testing.T) {
 		},
 		{
 			name: "successful login with multiple listeners",
-			listeners: []config.Listener{
+			listeners: []config.ListenerGroup{
 				{
-					KerberosListenAddress:  ":1088",
-					BOSAdvertisedHostPlain: "localhost:5190",
+					KerberosListenAddress: ":1088",
+					BOSAdvertisedHostSSL:  "localhost:5190",
 				},
 				{
-					KerberosListenAddress:  ":1089",
-					BOSAdvertisedHostPlain: "localhost:5191",
+					KerberosListenAddress: ":1089",
+					BOSAdvertisedHostSSL:  "localhost:5191",
 				},
 			},
 			request: wire.SNACMessage{
@@ -96,18 +96,18 @@ func TestKerberosLoginHandler(t *testing.T) {
 		},
 		{
 			name: "successful login with three listeners",
-			listeners: []config.Listener{
+			listeners: []config.ListenerGroup{
 				{
-					KerberosListenAddress:  ":1088",
-					BOSAdvertisedHostPlain: "localhost:5190",
+					KerberosListenAddress: ":1088",
+					BOSAdvertisedHostSSL:  "localhost:5190",
 				},
 				{
-					KerberosListenAddress:  ":1089",
-					BOSAdvertisedHostPlain: "localhost:5191",
+					KerberosListenAddress: ":1089",
+					BOSAdvertisedHostSSL:  "localhost:5191",
 				},
 				{
-					KerberosListenAddress:  ":1090",
-					BOSAdvertisedHostPlain: "localhost:5192",
+					KerberosListenAddress: ":1090",
+					BOSAdvertisedHostSSL:  "localhost:5192",
 				},
 			},
 			request: wire.SNACMessage{
@@ -134,9 +134,9 @@ func TestKerberosLoginHandler(t *testing.T) {
 		},
 		{
 			name: "no kerberos listeners defined - server exits cleanly",
-			listeners: []config.Listener{
+			listeners: []config.ListenerGroup{
 				{
-					BOSAdvertisedHostPlain: "localhost:5192",
+					BOSAdvertisedHostSSL: "localhost:5192",
 				},
 			},
 			request:            wire.SNACMessage{},
@@ -148,10 +148,10 @@ func TestKerberosLoginHandler(t *testing.T) {
 		},
 		{
 			name: "invalid request SNAC type",
-			listeners: []config.Listener{
+			listeners: []config.ListenerGroup{
 				{
-					KerberosListenAddress:  ":1088",
-					BOSAdvertisedHostPlain: "localhost:5190",
+					KerberosListenAddress: ":1088",
+					BOSAdvertisedHostSSL:  "localhost:5190",
 				},
 			},
 			request: wire.SNACMessage{
@@ -169,10 +169,10 @@ func TestKerberosLoginHandler(t *testing.T) {
 		},
 		{
 			name: "login runtime error",
-			listeners: []config.Listener{
+			listeners: []config.ListenerGroup{
 				{
-					KerberosListenAddress:  ":1088",
-					BOSAdvertisedHostPlain: "localhost:5190",
+					KerberosListenAddress: ":1088",
+					BOSAdvertisedHostSSL:  "localhost:5190",
 				},
 			},
 			request: wire.SNACMessage{
@@ -274,3 +274,50 @@ func TestKerberosLoginHandler(t *testing.T) {
 		})
 	}
 }
+
+func TestNewKerberosServer_ServesSSLGroupsOnly(t *testing.T) {
+	tests := []struct {
+		name      string
+		listeners []config.ListenerGroup
+		wantAddrs []string
+	}{
+		{
+			name: "SSL group with a kerberos address binds its port once",
+			listeners: []config.ListenerGroup{
+				{
+					KerberosListenAddress: "127.0.0.1:1088",
+					BOSAdvertisedHostSSL:  "localhost:5193",
+				},
+			},
+			wantAddrs: []string{"127.0.0.1:1088"},
+		},
+		{
+			name: "plaintext-only group is not served",
+			listeners: []config.ListenerGroup{
+				{KerberosListenAddress: "127.0.0.1:1088"},
+			},
+			wantAddrs: []string{},
+		},
+		{
+			name: "SSL group without a kerberos address is skipped rather than bound to :80",
+			listeners: []config.ListenerGroup{
+				{KerberosListenAddress: "", BOSAdvertisedHostSSL: "localhost:5193"},
+				{KerberosListenAddress: "127.0.0.1:1089", BOSAdvertisedHostSSL: "localhost:5193"},
+			},
+			wantAddrs: []string{"127.0.0.1:1089"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srv := NewKerberosServer(tt.listeners, slog.Default(), newMockAuthService(t))
+
+			haveAddrs := make([]string, 0, len(srv.servers))
+			for _, s := range srv.servers {
+				haveAddrs = append(haveAddrs, s.Addr)
+			}
+
+			assert.Equal(t, tt.wantAddrs, haveAddrs)
+		})
+	}
+}

+ 16 - 15
server/kerberos/mock_auth_test.go

@@ -7,6 +7,7 @@ package kerberos
 import (
 	"context"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/wire"
 	mock "github.com/stretchr/testify/mock"
 )
@@ -39,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, advertisedHost string) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, inBody, advertisedHost)
+func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inBody, endpointCfg)
 
 	if len(ret) == 0 {
 		panic("no return value specified for KerberosLogin")
@@ -48,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, string) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, config.Endpoint) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inBody, endpointCfg)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, string) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, config.Endpoint) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inBody, endpointCfg)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, string) error); ok {
-		r1 = returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, config.Endpoint) error); ok {
+		r1 = returnFunc(ctx, inBody, endpointCfg)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -72,12 +73,12 @@ type mockAuthService_KerberosLogin_Call struct {
 // KerberosLogin is a helper method to define mock.On call
 //   - ctx context.Context
 //   - inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest
-//   - advertisedHost string
-func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, advertisedHost interface{}) *mockAuthService_KerberosLogin_Call {
-	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, advertisedHost)}
+//   - endpointCfg config.Endpoint
+func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, endpointCfg interface{}) *mockAuthService_KerberosLogin_Call {
+	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, endpointCfg)}
 }
 
-func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, advertisedHost string)) *mockAuthService_KerberosLogin_Call {
+func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, endpointCfg config.Endpoint)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -87,9 +88,9 @@ func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context,
 		if args[1] != nil {
 			arg1 = args[1].(wire.SNAC_0x050C_0x0002_KerberosLoginRequest)
 		}
-		var arg2 string
+		var arg2 config.Endpoint
 		if args[2] != nil {
-			arg2 = args[2].(string)
+			arg2 = args[2].(config.Endpoint)
 		}
 		run(
 			arg0,
@@ -105,7 +106,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, 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, endpointCfg config.Endpoint) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 4 - 4
server/oscar/handler.go

@@ -893,12 +893,12 @@ func (rt Handler) OServiceSetPrivacyFlags(ctx context.Context, instance *state.S
 	return nil
 }
 
-func (rt Handler) OServiceServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
+func (rt Handler) OServiceServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, endpointCfg config.Endpoint) error {
 	inBody := wire.SNAC_0x01_0x04_OServiceServiceRequest{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 		return err
 	}
-	outSNAC, err := rt.ServiceRequest(ctx, service, instance, inFrame, inBody, listener)
+	outSNAC, err := rt.ServiceRequest(ctx, service, instance, inFrame, inBody, endpointCfg.Group)
 	if err != nil {
 		return err
 	}
@@ -1047,7 +1047,7 @@ func (rt Handler) UserLookupFindByEmail(ctx context.Context, _ *state.SessionIns
 // its group and subGroup identifiers found in the SNAC frame. It returns an
 // ErrRouteNotFound error if no matching handler is found for the group:subGroup
 // pair in the request.
-func (rt Handler) Handle(ctx context.Context, server uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
+func (rt Handler) Handle(ctx context.Context, server uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, endpointCfg config.Endpoint) error {
 	switch inFrame.FoodGroup {
 	case wire.Admin:
 		switch inFrame.SubGroup {
@@ -1198,7 +1198,7 @@ func (rt Handler) Handle(ctx context.Context, server uint16, instance *state.Ses
 		case wire.OServiceRateParamsSubAdd:
 			return rt.OServiceRateParamsSubAdd(ctx, instance, inFrame, r, rw)
 		case wire.OServiceServiceRequest:
-			return rt.OServiceServiceRequest(ctx, server, instance, inFrame, r, rw, listener)
+			return rt.OServiceServiceRequest(ctx, server, instance, inFrame, r, rw, endpointCfg)
 		case wire.OServiceSetPrivacyFlags:
 			return rt.OServiceSetPrivacyFlags(ctx, instance, inFrame, r, rw)
 		case wire.OServiceSetUserInfoFields:

+ 67 - 67
server/oscar/handler_test.go

@@ -83,7 +83,7 @@ func TestHandler_AdminConfirmRequest(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -183,7 +183,7 @@ func TestHandler_AdminInfoQuery_RegistrationStatus(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -283,7 +283,7 @@ func TestHandler_AdminInfoChangeRequest_ScreenNameFormatted(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -328,7 +328,7 @@ func TestHandler_AlertNotifyCapabilities(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -373,7 +373,7 @@ func TestHandler_AlertNotifyDisplayCapabilities(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -450,7 +450,7 @@ func TestHandler_BARTDownloadQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -531,7 +531,7 @@ func TestHandler_BARTDownload2Query(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -614,7 +614,7 @@ func TestHandler_BARTUploadQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -698,7 +698,7 @@ func TestHandler_BuddyRightsQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -770,7 +770,7 @@ func TestHandler_BuddyAddBuddies(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -842,7 +842,7 @@ func TestHandler_BuddyDelBuddies(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -914,7 +914,7 @@ func TestHandler_BuddyAddTempBuddies(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -986,7 +986,7 @@ func TestHandler_BuddyDelTempBuddies(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1069,7 +1069,7 @@ func TestHandler_ChatNavCreateRoom(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1119,7 +1119,7 @@ func TestHandler_ChatNavCreateRoom_ReadErr(t *testing.T) {
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, ss, config.Listener{}))
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, ss, config.Endpoint{}))
 }
 
 func TestHandler_ChatNavRequestChatRights(t *testing.T) {
@@ -1178,7 +1178,7 @@ func TestHandler_ChatNavRequestChatRights(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1259,7 +1259,7 @@ func TestHandler_ChatNavRequestRoomInfo(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1340,7 +1340,7 @@ func TestHandler_ChatNavRequestExchangeInfo(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1458,7 +1458,7 @@ func TestHandler_ChatChannelMsgToHost(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1583,7 +1583,7 @@ func TestHandler_FeedbagDeleteItem(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1631,7 +1631,7 @@ func TestHandler_FeedbagEndCluster(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1756,7 +1756,7 @@ func TestHandler_FeedbagInsertItem(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1860,7 +1860,7 @@ func TestHandler_FeedbagQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -1943,7 +1943,7 @@ func TestHandler_FeedbagQueryIfModified(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2036,7 +2036,7 @@ func TestHandler_FeedbagRightsQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2093,7 +2093,7 @@ func TestHandler_FeedbagStartCluster(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2218,7 +2218,7 @@ func TestHandler_FeedbagUpdateItem(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2273,7 +2273,7 @@ func TestHandler_FeedbagUse(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2312,7 +2312,7 @@ func TestHandler_FeedbagRespondAuthorizeToHost(t *testing.T) {
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, responseWriter, config.Listener{}))
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, responseWriter, config.Endpoint{}))
 }
 
 func TestHandler_FeedbagPreAuthorizeBuddy(t *testing.T) {
@@ -2343,7 +2343,7 @@ func TestHandler_FeedbagPreAuthorizeBuddy(t *testing.T) {
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{}))
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{}))
 }
 
 func TestHandler_ICBMAddParameters(t *testing.T) {
@@ -2382,7 +2382,7 @@ func TestHandler_ICBMAddParameters(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2465,7 +2465,7 @@ func TestHandler_ICBMChannelMsgToHost(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2525,7 +2525,7 @@ func TestHandler_ICBMClientErr(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2585,7 +2585,7 @@ func TestHandler_ICBMClientEvent(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2668,7 +2668,7 @@ func TestHandler_ICBMEvilRequest(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2738,7 +2738,7 @@ func TestHandler_ICBMOfflineRetrieve(t *testing.T) {
 
 			buf := &bytes.Buffer{}
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -2806,7 +2806,7 @@ func TestHandler_ICBMParameterQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -3860,7 +3860,7 @@ func TestHandler_ICQDBQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(tt.reqParams.inBody, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, tt.reqParams.instance, frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, tt.reqParams.instance, frame, buf, nil, config.Endpoint{})
 			assert.ErrorIs(t, err, tt.reqParams.wantErr)
 		})
 	}
@@ -4014,7 +4014,7 @@ func TestHandler_ODirInfoQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4109,7 +4109,7 @@ func TestHandler_ODirKeywordListQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4185,7 +4185,7 @@ func TestHandler_OServiceServiceClientOnline(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4252,7 +4252,7 @@ func TestHandler_OServiceServiceServiceRequest(t *testing.T) {
 
 			svc := newMockOServiceService(t)
 			svc.EXPECT().
-				ServiceRequest(mock.Anything, wire.BOS, mock.Anything, input.Frame, input.Body, config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"}).
+				ServiceRequest(mock.Anything, wire.BOS, mock.Anything, input.Frame, input.Body, config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"}).
 				Return(output, tt.serviceError)
 
 			h := Handler{
@@ -4272,7 +4272,7 @@ func TestHandler_OServiceServiceServiceRequest(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:1234"})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "127.0.0.1:1234"}})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4332,7 +4332,7 @@ func TestHandler_OServiceServiceIdleNotification(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4436,7 +4436,7 @@ func TestHandler_OServiceServiceClientVersions(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.Background(), wire.BOS, instance, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.Background(), wire.BOS, instance, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4514,7 +4514,7 @@ func TestHandler_OServiceServiceRateParamsQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4564,7 +4564,7 @@ func TestHandler_OServiceServiceRateParamsSubAdd(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.Background(), wire.BOS, instance, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.Background(), wire.BOS, instance, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4662,7 +4662,7 @@ func TestHandler_OServiceServiceSetUserInfoFields(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4733,7 +4733,7 @@ func TestHandler_OServiceServiceUserInfoQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4775,7 +4775,7 @@ func TestHandler_OServiceServiceNoop(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4825,7 +4825,7 @@ func TestHandler_OServiceServiceSetPrivacyFlags(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4897,7 +4897,7 @@ func TestHandler_PermitDenyRightsQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -4974,7 +4974,7 @@ func TestHandler_PermitDenyAddDenyListEntries(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5051,7 +5051,7 @@ func TestHandler_PermitDenyDelDenyListEntries(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5128,7 +5128,7 @@ func TestHandler_PermitDenyAddPermListEntries(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5205,7 +5205,7 @@ func TestHandler_PermitDenyDelPermListEntries(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5258,7 +5258,7 @@ func TestHandler_PermitDenySetGroupPermitMask(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, nil, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5345,7 +5345,7 @@ func TestUserLookupHandler_FindByEmail(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5428,7 +5428,7 @@ func TestHandler_LocateGetDirInfo(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5500,7 +5500,7 @@ func TestHandler_LocateRightsQuery(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5604,7 +5604,7 @@ func TestHandler_LocateSetDirInfo(t *testing.T) {
 			buf := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{})
 			if tt.expectedError != nil {
 				assert.ErrorIs(t, err, tt.expectedError)
 			} else {
@@ -5649,7 +5649,7 @@ func TestHandler_LocateSetInfo(t *testing.T) {
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{}))
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{}))
 }
 
 func TestHandler_LocateSetKeywordInfo(t *testing.T) {
@@ -5699,7 +5699,7 @@ func TestHandler_LocateSetKeywordInfo(t *testing.T) {
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{}))
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{}))
 }
 
 func TestHandler_LocateUserInfoQuery(t *testing.T) {
@@ -5752,7 +5752,7 @@ func TestHandler_LocateUserInfoQuery(t *testing.T) {
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{}))
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{}))
 }
 
 func TestHandler_LocateUserInfoQuery2(t *testing.T) {
@@ -5805,7 +5805,7 @@ func TestHandler_LocateUserInfoQuery2(t *testing.T) {
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{}))
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Endpoint{}))
 }
 
 func TestHandler_StatsReportEvents(t *testing.T) {
@@ -5844,7 +5844,7 @@ func TestHandler_StatsReportEvents(t *testing.T) {
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 
-	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, config.Listener{}))
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, config.Endpoint{}))
 }
 
 func TestHandler_RouteNotFound(t *testing.T) {
@@ -5862,5 +5862,5 @@ func TestHandler_RouteNotFound(t *testing.T) {
 		},
 	}
 
-	assert.ErrorIs(t, ErrRouteNotFound, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, nil, nil, config.Listener{}))
+	assert.ErrorIs(t, ErrRouteNotFound, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, nil, nil, config.Endpoint{}))
 }

+ 46 - 45
server/oscar/mock_auth_test.go

@@ -8,6 +8,7 @@ import (
 	"context"
 
 	"github.com/google/uuid"
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 	mock "github.com/stretchr/testify/mock"
@@ -113,8 +114,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, advertisedHost string) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, inBody, advertisedHost)
+func (_mock *mockAuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inBody, endpointCfg)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BUCPLogin")
@@ -122,16 +123,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, string) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, config.Endpoint) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inBody, endpointCfg)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, string) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, config.Endpoint) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inBody, endpointCfg)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, string) error); ok {
-		r1 = returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, config.Endpoint) error); ok {
+		r1 = returnFunc(ctx, inBody, endpointCfg)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -146,12 +147,12 @@ type mockAuthService_BUCPLogin_Call struct {
 // BUCPLogin is a helper method to define mock.On call
 //   - ctx context.Context
 //   - inBody wire.SNAC_0x17_0x02_BUCPLoginRequest
-//   - advertisedHost string
-func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, inBody interface{}, advertisedHost interface{}) *mockAuthService_BUCPLogin_Call {
-	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, inBody, advertisedHost)}
+//   - endpointCfg config.Endpoint
+func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, inBody interface{}, endpointCfg interface{}) *mockAuthService_BUCPLogin_Call {
+	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, inBody, endpointCfg)}
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string)) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -161,9 +162,9 @@ func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBo
 		if args[1] != nil {
 			arg1 = args[1].(wire.SNAC_0x17_0x02_BUCPLoginRequest)
 		}
-		var arg2 string
+		var arg2 config.Endpoint
 		if args[2] != nil {
-			arg2 = args[2].(string)
+			arg2 = args[2].(config.Endpoint)
 		}
 		run(
 			arg0,
@@ -179,7 +180,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, 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, endpointCfg config.Endpoint) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -245,8 +246,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, advertisedHost string) (wire.TLVRestBlock, error) {
-	ret := _mock.Called(ctx, inFrame, advertisedHost)
+func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
+	ret := _mock.Called(ctx, inFrame, endpointCfg)
 
 	if len(ret) == 0 {
 		panic("no return value specified for FLAPLogin")
@@ -254,16 +255,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, string) (wire.TLVRestBlock, error)); ok {
-		return returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) (wire.TLVRestBlock, error)); ok {
+		return returnFunc(ctx, inFrame, endpointCfg)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, string) wire.TLVRestBlock); ok {
-		r0 = returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) wire.TLVRestBlock); ok {
+		r0 = returnFunc(ctx, inFrame, endpointCfg)
 	} else {
 		r0 = ret.Get(0).(wire.TLVRestBlock)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, string) error); ok {
-		r1 = returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) error); ok {
+		r1 = returnFunc(ctx, inFrame, endpointCfg)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -278,12 +279,12 @@ type mockAuthService_FLAPLogin_Call struct {
 // FLAPLogin is a helper method to define mock.On call
 //   - ctx context.Context
 //   - inFrame wire.FLAPSignonFrame
-//   - advertisedHost string
-func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, advertisedHost interface{}) *mockAuthService_FLAPLogin_Call {
-	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, advertisedHost)}
+//   - endpointCfg config.Endpoint
+func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, endpointCfg interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, endpointCfg)}
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -293,9 +294,9 @@ func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFr
 		if args[1] != nil {
 			arg1 = args[1].(wire.FLAPSignonFrame)
 		}
-		var arg2 string
+		var arg2 config.Endpoint
 		if args[2] != nil {
-			arg2 = args[2].(string)
+			arg2 = args[2].(config.Endpoint)
 		}
 		run(
 			arg0,
@@ -311,14 +312,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, advertisedHost string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (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, advertisedHost string) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, inBody, advertisedHost)
+func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inBody, endpointCfg)
 
 	if len(ret) == 0 {
 		panic("no return value specified for KerberosLogin")
@@ -326,16 +327,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, string) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, config.Endpoint) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inBody, endpointCfg)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, string) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, config.Endpoint) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inBody, endpointCfg)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, string) error); ok {
-		r1 = returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, config.Endpoint) error); ok {
+		r1 = returnFunc(ctx, inBody, endpointCfg)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -350,12 +351,12 @@ type mockAuthService_KerberosLogin_Call struct {
 // KerberosLogin is a helper method to define mock.On call
 //   - ctx context.Context
 //   - inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest
-//   - advertisedHost string
-func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, advertisedHost interface{}) *mockAuthService_KerberosLogin_Call {
-	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, advertisedHost)}
+//   - endpointCfg config.Endpoint
+func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, endpointCfg interface{}) *mockAuthService_KerberosLogin_Call {
+	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, endpointCfg)}
 }
 
-func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, advertisedHost string)) *mockAuthService_KerberosLogin_Call {
+func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, endpointCfg config.Endpoint)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -365,9 +366,9 @@ func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context,
 		if args[1] != nil {
 			arg1 = args[1].(wire.SNAC_0x050C_0x0002_KerberosLoginRequest)
 		}
-		var arg2 string
+		var arg2 config.Endpoint
 		if args[2] != nil {
-			arg2 = args[2].(string)
+			arg2 = args[2].(config.Endpoint)
 		}
 		run(
 			arg0,
@@ -383,7 +384,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, 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, endpointCfg config.Endpoint) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 15 - 15
server/oscar/mock_oservice_service_test.go

@@ -467,8 +467,8 @@ func (_c *mockOServiceService_RateParamsSubAdd_Call) RunAndReturn(run func(ctx c
 }
 
 // ServiceRequest provides a mock function for the type mockOServiceService
-func (_mock *mockOServiceService) ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, service, instance, inFrame, inBody, listener)
+func (_mock *mockOServiceService) ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, service, instance, inFrame, inBody, listenerGroup)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ServiceRequest")
@@ -476,16 +476,16 @@ func (_mock *mockOServiceService) ServiceRequest(ctx context.Context, service ui
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.Listener) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, service, instance, inFrame, inBody, listener)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.ListenerGroup) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, service, instance, inFrame, inBody, listenerGroup)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.Listener) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, service, instance, inFrame, inBody, listener)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.ListenerGroup) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, service, instance, inFrame, inBody, listenerGroup)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.Listener) error); ok {
-		r1 = returnFunc(ctx, service, instance, inFrame, inBody, listener)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.ListenerGroup) error); ok {
+		r1 = returnFunc(ctx, service, instance, inFrame, inBody, listenerGroup)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -503,12 +503,12 @@ type mockOServiceService_ServiceRequest_Call struct {
 //   - instance *state.SessionInstance
 //   - inFrame wire.SNACFrame
 //   - inBody wire.SNAC_0x01_0x04_OServiceServiceRequest
-//   - listener config.Listener
-func (_e *mockOServiceService_Expecter) ServiceRequest(ctx interface{}, service interface{}, instance interface{}, inFrame interface{}, inBody interface{}, listener interface{}) *mockOServiceService_ServiceRequest_Call {
-	return &mockOServiceService_ServiceRequest_Call{Call: _e.mock.On("ServiceRequest", ctx, service, instance, inFrame, inBody, listener)}
+//   - listenerGroup config.ListenerGroup
+func (_e *mockOServiceService_Expecter) ServiceRequest(ctx interface{}, service interface{}, instance interface{}, inFrame interface{}, inBody interface{}, listenerGroup interface{}) *mockOServiceService_ServiceRequest_Call {
+	return &mockOServiceService_ServiceRequest_Call{Call: _e.mock.On("ServiceRequest", ctx, service, instance, inFrame, inBody, listenerGroup)}
 }
 
-func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener)) *mockOServiceService_ServiceRequest_Call {
+func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup)) *mockOServiceService_ServiceRequest_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -530,9 +530,9 @@ func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Cont
 		if args[4] != nil {
 			arg4 = args[4].(wire.SNAC_0x01_0x04_OServiceServiceRequest)
 		}
-		var arg5 config.Listener
+		var arg5 config.ListenerGroup
 		if args[5] != nil {
-			arg5 = args[5].(config.Listener)
+			arg5 = args[5].(config.ListenerGroup)
 		}
 		run(
 			arg0,
@@ -551,7 +551,7 @@ func (_c *mockOServiceService_ServiceRequest_Call) Return(sNACMessage wire.SNACM
 	return _c
 }
 
-func (_c *mockOServiceService_ServiceRequest_Call) RunAndReturn(run func(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error)) *mockOServiceService_ServiceRequest_Call {
+func (_c *mockOServiceService_ServiceRequest_Call) RunAndReturn(run func(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error)) *mockOServiceService_ServiceRequest_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 42 - 55
server/oscar/server.go

@@ -29,11 +29,11 @@ func NewServer(
 	departureNotifier DepartureNotifier,
 	logger *slog.Logger,
 	onlineNotifier OnlineNotifier,
-	SNACHandler func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error,
+	SNACHandler func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, endpointCfg config.Endpoint) error,
 	rateLimitUpdater RateLimitUpdater,
 	limits wire.SNACRateLimits,
 	limiter *IPRateLimiter,
-	listenerCfg []config.Listener,
+	listenerGroups []config.ListenerGroup,
 	recalcWarning func(ctx context.Context, instance *state.SessionInstance) error,
 	lowerWarnLevel func(ctx context.Context, instance *state.SessionInstance),
 ) *Server {
@@ -58,7 +58,7 @@ func NewServer(
 		closed:         make(chan struct{}),
 		conns:          make(map[net.Conn]struct{}),
 		handler:        oscarSvc.routeConnection,
-		listenerCfg:    listenerCfg,
+		listenerGroups: listenerGroups,
 		logger:         logger,
 		shutdownCancel: cancel,
 		shutdownCtx:    ctx,
@@ -68,8 +68,8 @@ func NewServer(
 type Server struct {
 	logger *slog.Logger
 
-	listenerCfg []config.Listener
-	listeners   []net.Listener
+	listenerGroups []config.ListenerGroup
+	listeners      []net.Listener
 
 	connMu sync.Mutex
 	conns  map[net.Conn]struct{}
@@ -81,30 +81,29 @@ type Server struct {
 	shutdownCancel context.CancelFunc
 	closed         chan struct{}
 
-	handler func(ctx context.Context, conn net.Conn, listener config.Listener) error
+	handler func(ctx context.Context, conn net.Conn, endpointCfg config.Endpoint) error
 }
 
 func (s *Server) ListenAndServe() error {
-	for _, listenCfg := range s.listenerCfg {
-		ln, err := net.Listen("tcp", listenCfg.BOSListenAddress)
-		if err != nil {
-			s.cleanupListeners()
-			s.shutdownCancel()
-			return fmt.Errorf("failed to listen on %s: %w", listenCfg.BOSListenAddress, err)
-		}
+	for _, group := range s.listenerGroups {
+		for _, endpoint := range group.Endpoints() {
+			ln, err := net.Listen("tcp", endpoint.ListenAddress)
+			if err != nil {
+				s.cleanupListeners()
+				s.shutdownCancel()
+				return fmt.Errorf("failed to listen on %s: %w", endpoint.ListenAddress, err)
+			}
 
-		args := []any{
-			"listen_address", listenCfg.BOSListenAddress,
-			"advertised_host_plain", listenCfg.BOSAdvertisedHostPlain,
-		}
-		if listenCfg.HasSSL {
-			args = append(args, "advertised_host_ssl", listenCfg.BOSAdvertisedHostSSL)
-		}
-		s.logger.Info("starting server", args...)
+			s.logger.Info("starting server",
+				"listener", group.Name,
+				"listen_address", endpoint.ListenAddress,
+				"advertised_host", endpoint.AdvertisedHost(),
+				"ssl", endpoint.IsSSL)
 
-		s.listeners = append(s.listeners, ln)
-		s.listenWg.Add(1)
-		go s.acceptLoop(ln, listenCfg)
+			s.listeners = append(s.listeners, ln)
+			s.listenWg.Add(1)
+			go s.acceptLoop(ln, endpoint)
+		}
 	}
 
 	<-s.closed // block until Shutdown is called
@@ -136,7 +135,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
 	return nil
 }
 
-func (s *Server) acceptLoop(ln net.Listener, listener config.Listener) {
+func (s *Server) acceptLoop(ln net.Listener, endpointCfg config.Endpoint) {
 	defer s.listenWg.Done()
 
 	for {
@@ -155,11 +154,11 @@ func (s *Server) acceptLoop(ln net.Listener, listener config.Listener) {
 		s.connMu.Unlock()
 
 		s.connWg.Add(1)
-		go s.handleConnection(s.shutdownCtx, conn, listener)
+		go s.handleConnection(s.shutdownCtx, conn, endpointCfg)
 	}
 }
 
-func (s *Server) handleConnection(ctx context.Context, conn net.Conn, listener config.Listener) {
+func (s *Server) handleConnection(ctx context.Context, conn net.Conn, endpointCfg config.Endpoint) {
 	defer func() {
 		// untrack connections
 		s.connMu.Lock()
@@ -170,7 +169,7 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, listener c
 		s.connWg.Done()
 	}()
 	ctx = middleware.WithIP(ctx, conn.RemoteAddr().String())
-	if err := s.handler(ctx, conn, listener); err != nil {
+	if err := s.handler(ctx, conn, endpointCfg); err != nil {
 		s.logger.InfoContext(ctx, "user session failed", "err", err.Error())
 	}
 }
@@ -189,7 +188,7 @@ type oscarServer struct {
 	departureNotifier  DepartureNotifier
 	logger             *slog.Logger
 	onlineNotifier     OnlineNotifier
-	snacHandler        func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error
+	snacHandler        func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, endpointCfg config.Endpoint) error
 	rateLimitUpdater   RateLimitUpdater
 	rateLimits         wire.SNACRateLimits
 	ipRateLimiter      *IPRateLimiter
@@ -197,7 +196,7 @@ type oscarServer struct {
 	lowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
 }
 
-func (s oscarServer) routeConnection(ctx context.Context, conn net.Conn, listener config.Listener) error {
+func (s oscarServer) routeConnection(ctx context.Context, conn net.Conn, endpointCfg config.Endpoint) error {
 	ip, _, err := net.SplitHostPort(conn.RemoteAddr().String())
 	if err != nil {
 		s.logger.Error("failed to parse remote address", "err", err.Error())
@@ -216,10 +215,10 @@ func (s oscarServer) routeConnection(ctx context.Context, conn net.Conn, listene
 	}
 
 	if flap.HasTag(wire.OServiceTLVTagsLoginCookie) {
-		return s.connectToOSCARService(ctx, flap, flapc, conn, listener)
+		return s.connectToOSCARService(ctx, flap, flapc, conn, endpointCfg)
 	}
 
-	return s.authenticate(ctx, flap, ip, conn, flapc, listener.BOSAdvertisedHostPlain)
+	return s.authenticate(ctx, flap, ip, conn, flapc, endpointCfg)
 }
 
 func (s oscarServer) connectToOSCARService(
@@ -227,7 +226,7 @@ func (s oscarServer) connectToOSCARService(
 	flap wire.FLAPSignonFrame,
 	flapc *wire.FlapClient,
 	conn net.Conn,
-	listener config.Listener,
+	endpointCfg config.Endpoint,
 ) error {
 	authCookie, ok := flap.Bytes(wire.OServiceTLVTagsLoginCookie)
 	if !ok {
@@ -381,7 +380,7 @@ func (s oscarServer) connectToOSCARService(
 		return err
 	}
 
-	return s.dispatchIncomingMessages(ctx, cookie.Service, instance, flapc, conn, listener)
+	return s.dispatchIncomingMessages(ctx, cookie.Service, instance, flapc, conn, endpointCfg)
 }
 
 func shuttingDown(ctx context.Context) bool {
@@ -412,14 +411,7 @@ func (s oscarServer) receiveSessMessages(ctx context.Context, instance *state.Se
 	}
 }
 
-func (s oscarServer) authenticate(
-	ctx context.Context,
-	flap wire.FLAPSignonFrame,
-	ip string,
-	conn net.Conn,
-	flapc *wire.FlapClient,
-	advertisedHost string,
-) error {
+func (s oscarServer) authenticate(ctx context.Context, flap wire.FLAPSignonFrame, ip string, conn net.Conn, flapc *wire.FlapClient, endpointCfg config.Endpoint) error {
 	if ok, isBUCP := s.ipRateLimiter.Allow(ip); !ok {
 		s.logger.InfoContext(ctx, "user rate limited at login, dropping connection")
 		tlv := wire.TLVRestBlock{
@@ -454,28 +446,23 @@ 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, endpointCfg)
 	}
 
 	s.ipRateLimiter.SetBUCP(ip)
 
-	return s.processBUCPAuth(ctx, flapc, advertisedHost)
+	return s.processBUCPAuth(ctx, flapc, endpointCfg)
 }
 
-func (s oscarServer) processFLAPAuth(
-	ctx context.Context,
-	signonFrame wire.FLAPSignonFrame,
-	flapc *wire.FlapClient,
-	advertisedHost string,
-) error {
-	tlv, err := s.authService.FLAPLogin(ctx, signonFrame, advertisedHost)
+func (s oscarServer) processFLAPAuth(ctx context.Context, signonFrame wire.FLAPSignonFrame, flapc *wire.FlapClient, endpointCfg config.Endpoint) error {
+	tlv, err := s.authService.FLAPLogin(ctx, signonFrame, endpointCfg)
 	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, endpointCfg config.Endpoint) error {
 	frames := 0
 
 	for {
@@ -527,7 +514,7 @@ func (s oscarServer) processBUCPAuth(ctx context.Context, flapc *wire.FlapClient
 				if err := wire.UnmarshalBE(&loginRequest, buf); err != nil {
 					return err
 				}
-				outSNAC, err := s.authService.BUCPLogin(ctx, loginRequest, advertisedHost)
+				outSNAC, err := s.authService.BUCPLogin(ctx, loginRequest, endpointCfg)
 				if err != nil {
 					return err
 				}
@@ -577,7 +564,7 @@ func (s oscarServer) dispatchIncomingMessages(
 	instance *state.SessionInstance,
 	flapc *wire.FlapClient,
 	r io.ReadCloser,
-	listener config.Listener,
+	endpointCfg config.Endpoint,
 ) error {
 	defer func() {
 		s.logger.InfoContext(ctx, "user disconnected")
@@ -631,7 +618,7 @@ func (s oscarServer) dispatchIncomingMessages(
 
 				// route a client request to the appropriate service handler. the
 				// handler may write a response to the client connection.
-				if err := s.snacHandler(ctx, fg, instance, inFrame, flapBuf, flapc, listener); err != nil {
+				if err := s.snacHandler(ctx, fg, instance, inFrame, flapBuf, flapc, endpointCfg); err != nil {
 					middleware.LogRequestError(ctx, s.logger, inFrame, err)
 					if errors.Is(err, ErrRouteNotFound) {
 						if err1 := sendInvalidSNACErr(inFrame, flapc); err1 != nil {

+ 33 - 30
server/oscar/server_test.go

@@ -48,21 +48,24 @@ func TestServer_ListenAndServeAndShutdown(t *testing.T) {
 
 	var msgWg sync.WaitGroup
 
-	cfg := []config.Listener{
+	// the second group terminates SSL, so it binds :15001 and :15002
+	groups := []config.ListenerGroup{
 		{
 			BOSListenAddress:       ":15000",
 			BOSAdvertisedHostPlain: "localhost",
 		},
 		{
 			BOSListenAddress:       ":15001",
+			BOSListenAddressSSL:    ":15002",
 			BOSAdvertisedHostPlain: "localhost",
+			BOSAdvertisedHostSSL:   "localhost",
 		},
-		{
-			BOSListenAddress:       ":15002",
-			BOSAdvertisedHostPlain: "localhost",
-		},
 	}
-	responses := []string{"hello1", "hello2", "hello2"}
+	var endpoints []config.Endpoint
+	for _, g := range groups {
+		endpoints = append(endpoints, g.Endpoints()...)
+	}
+	responses := []string{"hello1", "hello2", "hello3"}
 
 	server := NewServer(
 		nil,
@@ -75,12 +78,12 @@ func TestServer_ListenAndServeAndShutdown(t *testing.T) {
 		nil,
 		wire.DefaultSNACRateLimits(),
 		nil,
-		cfg,
+		groups,
 		func(ctx context.Context, instance *state.SessionInstance) error { return nil },
 		func(ctx context.Context, instance *state.SessionInstance) {},
 	)
 
-	server.handler = func(ctx context.Context, conn net.Conn, listener config.Listener) error {
+	server.handler = func(ctx context.Context, conn net.Conn, endpointCfg config.Endpoint) error {
 		go func() {
 			<-ctx.Done()
 			_ = conn.Close()
@@ -108,12 +111,12 @@ func TestServer_ListenAndServeAndShutdown(t *testing.T) {
 	}()
 
 	// Wait for server to be ready by checking if ports are listening
-	for i := 0; i < len(cfg); i++ {
+	for i := 0; i < len(endpoints); i++ {
 		maxRetries := 10
 		backoff := 5 * time.Millisecond
 
 		for attempt := 0; attempt < maxRetries; attempt++ {
-			conn, err := net.Dial("tcp", "localhost"+cfg[i].BOSListenAddress)
+			conn, err := net.Dial("tcp", "localhost"+endpoints[i].ListenAddress)
 			if err == nil {
 				_ = conn.Close()
 				break
@@ -126,10 +129,10 @@ func TestServer_ListenAndServeAndShutdown(t *testing.T) {
 		}
 	}
 
-	for i := 0; i < len(cfg); i++ {
+	for i := 0; i < len(endpoints); i++ {
 		msgWg.Add(1)
 		// Connect and send message
-		conn, err := net.Dial("tcp", "localhost"+cfg[i].BOSListenAddress)
+		conn, err := net.Dial("tcp", "localhost"+endpoints[i].ListenAddress)
 		assert.NoError(t, err)
 
 		_, err = conn.Write([]byte(responses[i] + "\n"))
@@ -245,7 +248,7 @@ func TestOscarServer_RouteConnection_Auth_BUCP(t *testing.T) {
 			Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{},
 		}, nil)
 	authService.EXPECT().
-		BUCPLogin(matchContext(), mock.Anything, "localhost:5190").
+		BUCPLogin(matchContext(), mock.Anything, config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "localhost:5190"}}).
 		Return(wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.BUCP,
@@ -260,7 +263,7 @@ func TestOscarServer_RouteConnection_Auth_BUCP(t *testing.T) {
 		logger:           slog.Default(),
 		ipRateLimiter:    NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 	}
-	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Listener{BOSAdvertisedHostPlain: "localhost:5190"}))
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "localhost:5190"}}))
 
 	wg.Wait()
 }
@@ -321,7 +324,7 @@ func TestOscarServer_RouteConnection_Auth_FLAP(t *testing.T) {
 
 	authService := newMockAuthService(t)
 	authService.EXPECT().
-		FLAPLogin(matchContext(), mock.Anything, "localhost:5190").
+		FLAPLogin(matchContext(), mock.Anything, config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "localhost:5190"}}).
 		Return(wire.TLVRestBlock{
 			TLVList: []wire.TLV{
 				wire.NewTLVBE(wire.LoginTLVTagsScreenName, "testuser"),
@@ -336,7 +339,7 @@ func TestOscarServer_RouteConnection_Auth_FLAP(t *testing.T) {
 		logger:           slog.Default(),
 		ipRateLimiter:    NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 	}
-	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Listener{BOSAdvertisedHostPlain: "localhost:5190"}))
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Endpoint{Group: config.ListenerGroup{BOSAdvertisedHostPlain: "localhost:5190"}}))
 
 	wg.Wait()
 }
@@ -441,7 +444,7 @@ func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 		RemoveUserFromAllChats(mock.Anything)
 
 	wg.Add(2)
-	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
+	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, endpointCfg config.Endpoint) error {
 		defer wg.Done()
 		assert.NoError(t, clientConn.Close())
 		return nil
@@ -463,7 +466,7 @@ func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 			defer wg.Done()
 		},
 	}
-	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Listener{}))
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Endpoint{}))
 
 	wg.Wait()
 }
@@ -555,7 +558,7 @@ func TestOscarServer_RouteConnection_BOS_MultiSessionSignoff(t *testing.T) {
 	chatSessionManager := newMockChatSessionManager(t)
 
 	wg.Add(2)
-	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
+	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, endpointCfg config.Endpoint) error {
 		defer wg.Done()
 		assert.NoError(t, clientConn.Close())
 		return nil
@@ -577,7 +580,7 @@ func TestOscarServer_RouteConnection_BOS_MultiSessionSignoff(t *testing.T) {
 			defer wg.Done()
 		},
 	}
-	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Listener{}))
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Endpoint{}))
 
 	wg.Wait()
 }
@@ -640,7 +643,7 @@ func TestOscarServer_RouteConnection_BOS_MaxConcurrentSessionsReached(t *testing
 		authService:      authService,
 		logger:           slog.Default(),
 	}
-	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Listener{}))
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Endpoint{}))
 
 	wg.Wait()
 }
@@ -736,7 +739,7 @@ func TestOscarServer_RouteConnection_Chat(t *testing.T) {
 	chatSessionManager := newMockChatSessionManager(t)
 
 	wg.Add(1)
-	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
+	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, endpointCfg config.Endpoint) error {
 		defer wg.Done()
 		assert.NoError(t, clientConn.Close())
 		return nil
@@ -752,7 +755,7 @@ func TestOscarServer_RouteConnection_Chat(t *testing.T) {
 		chatSessionManager: chatSessionManager,
 		departureNotifier:  departureNotifier,
 	}
-	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Listener{}))
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Endpoint{}))
 
 	wg.Wait()
 
@@ -839,7 +842,7 @@ func TestOscarServer_RouteConnection_Admin(t *testing.T) {
 	chatSessionManager := newMockChatSessionManager(t)
 
 	wg.Add(1)
-	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
+	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, endpointCfg config.Endpoint) error {
 		defer wg.Done()
 		assert.NoError(t, clientConn.Close())
 		return nil
@@ -855,7 +858,7 @@ func TestOscarServer_RouteConnection_Admin(t *testing.T) {
 		chatSessionManager: chatSessionManager,
 		departureNotifier:  departureNotifier,
 	}
-	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Listener{}))
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Endpoint{}))
 
 	wg.Wait()
 }
@@ -877,7 +880,7 @@ func Test_oscarServer_dispatchIncomingMessages_shutdownSignoff(t *testing.T) {
 		instance := state.NewSession().AddInstance()
 		instance.SetMultiConnFlag(wire.MultiConnFlagsRecentClient)
 		flapc := wire.NewFlapClient(0, serverConn, serverConn)
-		err := srv.dispatchIncomingMessages(ctx, wire.BOS, instance, flapc, serverConn, config.Listener{})
+		err := srv.dispatchIncomingMessages(ctx, wire.BOS, instance, flapc, serverConn, config.Endpoint{})
 		assert.NoError(t, err)
 	}()
 
@@ -907,7 +910,7 @@ func Test_oscarServer_dispatchIncomingMessages_disconnect_old_client(t *testing.
 			logger:           slog.Default(),
 		}
 		flapc := wire.NewFlapClient(0, serverConn, serverConn)
-		err := srv.dispatchIncomingMessages(ctx, wire.BOS, instance, flapc, serverConn, config.Listener{})
+		err := srv.dispatchIncomingMessages(ctx, wire.BOS, instance, flapc, serverConn, config.Endpoint{})
 		assert.NoError(t, err)
 	}()
 
@@ -937,7 +940,7 @@ func Test_oscarServer_dispatchIncomingMessages_disconnect_new_client(t *testing.
 			logger:           slog.Default(),
 		}
 		flapc := wire.NewFlapClient(0, serverConn, serverConn)
-		err := srv.dispatchIncomingMessages(ctx, wire.BOS, instance, flapc, serverConn, config.Listener{})
+		err := srv.dispatchIncomingMessages(ctx, wire.BOS, instance, flapc, serverConn, config.Endpoint{})
 		assert.NoError(t, err)
 	}()
 
@@ -1042,7 +1045,7 @@ func Test_oscarServer_receiveSessMessages_BOS_integration(t *testing.T) {
 
 	// Run the server handler in background so we can drive the session
 	doneServer := make(chan error, 1)
-	go func() { doneServer <- server.routeConnection(context.Background(), clientFake, config.Listener{}) }()
+	go func() { doneServer <- server.routeConnection(context.Background(), clientFake, config.Endpoint{}) }()
 
 	// Wait for HostOnline to be received so session is ready
 	select {
@@ -1178,7 +1181,7 @@ func Test_oscarServer_receiveSessMessages_Chat_integration(t *testing.T) {
 
 	// Run the server handler in background so we can drive the session
 	doneServer := make(chan error, 1)
-	go func() { doneServer <- server.routeConnection(context.Background(), clientFake, config.Listener{}) }()
+	go func() { doneServer <- server.routeConnection(context.Background(), clientFake, config.Endpoint{}) }()
 
 	// Wait for HostOnline to be received so session is ready
 	select {

+ 4 - 4
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, advertisedHost string) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
-	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, advertisedHost string) (wire.SNACMessage, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
+	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, sessCfg func(sess *state.Session)) (*state.SessionInstance, error)
 	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, sessCfg func(sess *state.Session)) (*state.SessionInstance, error)
 	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
@@ -161,7 +161,7 @@ type OServiceService interface {
 	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)
+	ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error)
 	SetPrivacyFlags(ctx context.Context, inBody wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags)
 	SetUserInfoFields(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error)
 	UserInfoQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) wire.SNACMessage

+ 3 - 3
server/toc/cmd_client.go

@@ -503,7 +503,7 @@ func (s OSCARProxy) ChatAccept(
 			},
 		},
 	}
-	svcReqReply, err := s.OServiceService.ServiceRequest(ctx, wire.BOS, me, wire.SNACFrame{}, svcReqSNAC, config.Listener{})
+	svcReqReply, err := s.OServiceService.ServiceRequest(ctx, wire.BOS, me, wire.SNACFrame{}, svcReqSNAC, config.ListenerGroup{})
 	if err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ServiceRequest: %w", err))
 	}
@@ -699,7 +699,7 @@ func (s OSCARProxy) ChatJoin(
 			},
 		},
 	}
-	svcReqReply, err := s.OServiceService.ServiceRequest(ctx, wire.BOS, me, wire.SNACFrame{}, svcReqSNAC, config.Listener{})
+	svcReqReply, err := s.OServiceService.ServiceRequest(ctx, wire.BOS, me, wire.SNACFrame{}, svcReqSNAC, config.ListenerGroup{})
 	if err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ServiceRequest: %w", err))
 	}
@@ -2330,7 +2330,7 @@ func (s OSCARProxy) Signon(ctx context.Context, args []byte, recalcWarning func(
 		signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsMultiConnFlags, wire.MultiConnFlagsRecentClient))
 	}
 
-	block, err := s.AuthService.FLAPLogin(ctx, signonFrame, "")
+	block, err := s.AuthService.FLAPLogin(ctx, signonFrame, config.Endpoint{})
 	if err != nil {
 		return nil, s.runtimeErr(ctx, fmt.Errorf("AuthService.FLAPLogin: %w", err))
 	}

+ 4 - 4
server/toc/cmd_client_test.go

@@ -32,7 +32,7 @@ func (nopOServiceService) IdleNotification(context.Context, *state.SessionInstan
 
 func (nopOServiceService) MonitorRateLimits(context.Context, *state.Session) {}
 
-func (nopOServiceService) ServiceRequest(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.Listener) (wire.SNACMessage, error) {
+func (nopOServiceService) ServiceRequest(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.ListenerGroup) (wire.SNACMessage, error) {
 	return wire.SNACMessage{}, nil
 }
 
@@ -957,7 +957,7 @@ func TestOSCARProxy_RecvClientCmd_ChatAccept(t *testing.T) {
 			oServiceSvc := newMockOServiceService(t)
 			for _, params := range tc.mockParams.serviceRequestParams {
 				oServiceSvc.EXPECT().
-					ServiceRequest(ctx, wire.BOS, matchSession(params.me), wire.SNACFrame{}, params.bodyIn, config.Listener{}).
+					ServiceRequest(ctx, wire.BOS, matchSession(params.me), wire.SNACFrame{}, params.bodyIn, config.ListenerGroup{}).
 					Return(params.msg, params.err)
 			}
 			for _, params := range tc.mockParams.clientOnlineParams {
@@ -1489,7 +1489,7 @@ func TestOSCARProxy_RecvClientCmd_ChatJoin(t *testing.T) {
 			bosOServiceSvc := newMockOServiceService(t)
 			for _, params := range tc.mockParams.serviceRequestParams {
 				bosOServiceSvc.EXPECT().
-					ServiceRequest(ctx, wire.BOS, matchSession(params.me), wire.SNACFrame{}, params.bodyIn, config.Listener{}).
+					ServiceRequest(ctx, wire.BOS, matchSession(params.me), wire.SNACFrame{}, params.bodyIn, config.ListenerGroup{}).
 					Return(params.msg, params.err)
 			}
 			for _, params := range tc.mockParams.clientOnlineParams {
@@ -6946,7 +6946,7 @@ func TestOSCARProxy_Signon(t *testing.T) {
 			authSvc := newMockAuthService(t)
 			for _, params := range tc.mockParams.flapLoginParams {
 				authSvc.EXPECT().
-					FLAPLogin(matchContext(), params.frame, "").
+					FLAPLogin(matchContext(), params.frame, config.Endpoint{}).
 					Return(params.tlv, params.err)
 			}
 			for _, params := range tc.mockParams.crackCookieParams {

+ 31 - 30
server/toc/mock_auth_service_test.go

@@ -8,6 +8,7 @@ import (
 	"context"
 
 	"github.com/google/uuid"
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 	mock "github.com/stretchr/testify/mock"
@@ -113,8 +114,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, advertisedHost string) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, inBody, advertisedHost)
+func (_mock *mockAuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inBody, endpointCfg)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BUCPLogin")
@@ -122,16 +123,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, string) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, config.Endpoint) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inBody, endpointCfg)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, string) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, config.Endpoint) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inBody, endpointCfg)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, string) error); ok {
-		r1 = returnFunc(ctx, inBody, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, config.Endpoint) error); ok {
+		r1 = returnFunc(ctx, inBody, endpointCfg)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -146,12 +147,12 @@ type mockAuthService_BUCPLogin_Call struct {
 // BUCPLogin is a helper method to define mock.On call
 //   - ctx context.Context
 //   - inBody wire.SNAC_0x17_0x02_BUCPLoginRequest
-//   - advertisedHost string
-func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, inBody interface{}, advertisedHost interface{}) *mockAuthService_BUCPLogin_Call {
-	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, inBody, advertisedHost)}
+//   - endpointCfg config.Endpoint
+func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, inBody interface{}, endpointCfg interface{}) *mockAuthService_BUCPLogin_Call {
+	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, inBody, endpointCfg)}
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string)) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -161,9 +162,9 @@ func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, inBo
 		if args[1] != nil {
 			arg1 = args[1].(wire.SNAC_0x17_0x02_BUCPLoginRequest)
 		}
-		var arg2 string
+		var arg2 config.Endpoint
 		if args[2] != nil {
-			arg2 = args[2].(string)
+			arg2 = args[2].(config.Endpoint)
 		}
 		run(
 			arg0,
@@ -179,7 +180,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, 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, endpointCfg config.Endpoint) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -245,8 +246,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, advertisedHost string) (wire.TLVRestBlock, error) {
-	ret := _mock.Called(ctx, inFrame, advertisedHost)
+func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
+	ret := _mock.Called(ctx, inFrame, endpointCfg)
 
 	if len(ret) == 0 {
 		panic("no return value specified for FLAPLogin")
@@ -254,16 +255,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, string) (wire.TLVRestBlock, error)); ok {
-		return returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) (wire.TLVRestBlock, error)); ok {
+		return returnFunc(ctx, inFrame, endpointCfg)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, string) wire.TLVRestBlock); ok {
-		r0 = returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) wire.TLVRestBlock); ok {
+		r0 = returnFunc(ctx, inFrame, endpointCfg)
 	} else {
 		r0 = ret.Get(0).(wire.TLVRestBlock)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, string) error); ok {
-		r1 = returnFunc(ctx, inFrame, advertisedHost)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, config.Endpoint) error); ok {
+		r1 = returnFunc(ctx, inFrame, endpointCfg)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -278,12 +279,12 @@ type mockAuthService_FLAPLogin_Call struct {
 // FLAPLogin is a helper method to define mock.On call
 //   - ctx context.Context
 //   - inFrame wire.FLAPSignonFrame
-//   - advertisedHost string
-func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, advertisedHost interface{}) *mockAuthService_FLAPLogin_Call {
-	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, advertisedHost)}
+//   - endpointCfg config.Endpoint
+func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, endpointCfg interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, endpointCfg)}
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -293,9 +294,9 @@ func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFr
 		if args[1] != nil {
 			arg1 = args[1].(wire.FLAPSignonFrame)
 		}
-		var arg2 string
+		var arg2 config.Endpoint
 		if args[2] != nil {
-			arg2 = args[2].(string)
+			arg2 = args[2].(config.Endpoint)
 		}
 		run(
 			arg0,
@@ -311,7 +312,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, advertisedHost string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 15 - 15
server/toc/mock_oservice_service_test.go

@@ -219,8 +219,8 @@ func (_c *mockOServiceService_MonitorRateLimits_Call) RunAndReturn(run func(ctx
 }
 
 // ServiceRequest provides a mock function for the type mockOServiceService
-func (_mock *mockOServiceService) ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error) {
-	ret := _mock.Called(ctx, service, instance, inFrame, inBody, listener)
+func (_mock *mockOServiceService) ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, service, instance, inFrame, inBody, listenerGroup)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ServiceRequest")
@@ -228,16 +228,16 @@ func (_mock *mockOServiceService) ServiceRequest(ctx context.Context, service ui
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.Listener) (wire.SNACMessage, error)); ok {
-		return returnFunc(ctx, service, instance, inFrame, inBody, listener)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.ListenerGroup) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, service, instance, inFrame, inBody, listenerGroup)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.Listener) wire.SNACMessage); ok {
-		r0 = returnFunc(ctx, service, instance, inFrame, inBody, listener)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.ListenerGroup) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, service, instance, inFrame, inBody, listenerGroup)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.Listener) error); ok {
-		r1 = returnFunc(ctx, service, instance, inFrame, inBody, listener)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, uint16, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, config.ListenerGroup) error); ok {
+		r1 = returnFunc(ctx, service, instance, inFrame, inBody, listenerGroup)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -255,12 +255,12 @@ type mockOServiceService_ServiceRequest_Call struct {
 //   - instance *state.SessionInstance
 //   - inFrame wire.SNACFrame
 //   - inBody wire.SNAC_0x01_0x04_OServiceServiceRequest
-//   - listener config.Listener
-func (_e *mockOServiceService_Expecter) ServiceRequest(ctx interface{}, service interface{}, instance interface{}, inFrame interface{}, inBody interface{}, listener interface{}) *mockOServiceService_ServiceRequest_Call {
-	return &mockOServiceService_ServiceRequest_Call{Call: _e.mock.On("ServiceRequest", ctx, service, instance, inFrame, inBody, listener)}
+//   - listenerGroup config.ListenerGroup
+func (_e *mockOServiceService_Expecter) ServiceRequest(ctx interface{}, service interface{}, instance interface{}, inFrame interface{}, inBody interface{}, listenerGroup interface{}) *mockOServiceService_ServiceRequest_Call {
+	return &mockOServiceService_ServiceRequest_Call{Call: _e.mock.On("ServiceRequest", ctx, service, instance, inFrame, inBody, listenerGroup)}
 }
 
-func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener)) *mockOServiceService_ServiceRequest_Call {
+func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup)) *mockOServiceService_ServiceRequest_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -282,9 +282,9 @@ func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Cont
 		if args[4] != nil {
 			arg4 = args[4].(wire.SNAC_0x01_0x04_OServiceServiceRequest)
 		}
-		var arg5 config.Listener
+		var arg5 config.ListenerGroup
 		if args[5] != nil {
-			arg5 = args[5].(config.Listener)
+			arg5 = args[5].(config.ListenerGroup)
 		}
 		run(
 			arg0,
@@ -303,7 +303,7 @@ func (_c *mockOServiceService_ServiceRequest_Call) Return(sNACMessage wire.SNACM
 	return _c
 }
 
-func (_c *mockOServiceService_ServiceRequest_Call) RunAndReturn(run func(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error)) *mockOServiceService_ServiceRequest_Call {
+func (_c *mockOServiceService_ServiceRequest_Call) RunAndReturn(run func(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error)) *mockOServiceService_ServiceRequest_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 6 - 6
server/toc/server.go

@@ -130,7 +130,7 @@ func (l *IPRateLimiter) Allow(ip string) (allowed bool) {
 }
 
 func NewServer(
-	listenerCfg []string,
+	endpointCfg []string,
 	logger *slog.Logger,
 	BOSProxy OSCARProxy,
 	ipRateLimiter *IPRateLimiter,
@@ -143,17 +143,17 @@ func NewServer(
 	s := &Server{
 		bosProxy:           BOSProxy,
 		conns:              make(map[net.Conn]struct{}),
-		listenerCfg:        listenerCfg,
+		endpointCfg:        endpointCfg,
 		logger:             logger,
 		loginIPRateLimiter: ipRateLimiter,
 		recalcWarning:      recalcWarning,
 		lowerWarnLevel:     lowerWarnLevel,
-		servers:            make([]*http.Server, 0, len(listenerCfg)),
+		servers:            make([]*http.Server, 0, len(endpointCfg)),
 		shutdownCancel:     cancel,
 		shutdownCtx:        ctx,
 	}
 
-	for range listenerCfg {
+	for range endpointCfg {
 		s.servers = append(s.servers, &http.Server{
 			Handler: BOSProxy.NewServeMux(),
 			BaseContext: func(net.Listener) context.Context {
@@ -175,7 +175,7 @@ type Server struct {
 	recalcWarning      func(ctx context.Context, instance *state.SessionInstance) error
 	lowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
 
-	listenerCfg []string
+	endpointCfg []string
 	listeners   []net.Listener
 	servers     []*http.Server
 
@@ -192,7 +192,7 @@ type Server struct {
 func (s *Server) ListenAndServe() error {
 	g, ctx := errgroup.WithContext(s.shutdownCtx)
 
-	for i, cfg := range s.listenerCfg {
+	for i, cfg := range s.endpointCfg {
 		ln, err := net.Listen("tcp", cfg)
 		if err != nil {
 			s.cleanupListeners()

+ 3 - 3
server/toc/types.go

@@ -42,14 +42,14 @@ type OServiceService interface {
 	ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
 	IdleNotification(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x11_OServiceIdleNotification) error
 	MonitorRateLimits(ctx context.Context, session *state.Session)
-	ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error)
+	ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error)
 }
 
 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, advertisedHost string) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, cfg func(*state.Session)) (*state.SessionInstance, error)
 	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, cfg func(sess *state.Session)) (*state.SessionInstance, error)
 	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)

+ 2 - 1
server/webapi/handler.go

@@ -7,6 +7,7 @@ import (
 	"log/slog"
 	"net/http"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/server/webapi/handlers"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
@@ -22,7 +23,7 @@ type Handler struct {
 	OServiceService    OServiceService
 	SessionRetriever   SessionRetriever
 	BuddyBroadcaster   BuddyBroadcaster
-	OSCARConfig        OSCARConfig
+	BOSListener        config.ListenerGroup
 	BuddyListManager   interface{}
 	RecalcWarning      func(ctx context.Context, instance *state.SessionInstance) error
 	LowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)

+ 2 - 1
server/webapi/handlers/auth.go

@@ -15,6 +15,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -165,7 +166,7 @@ func (h *AuthHandler) authenticateCredentials(ctx context.Context, username, pas
 	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsClientIdentity, clientID))
 	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsMultiConnFlags, wire.MultiConnFlagsRecentClient))
 
-	block, err := h.AuthService.FLAPLogin(ctx, signonFrame, "")
+	block, err := h.AuthService.FLAPLogin(ctx, signonFrame, config.Endpoint{})
 	if err != nil {
 		return nil, fmt.Errorf("FLAPLogin: %w", err)
 	}

+ 13 - 12
server/webapi/handlers/auth_test.go

@@ -13,6 +13,7 @@ import (
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -20,7 +21,7 @@ import (
 // testAuthService implements AuthService for ClientLogin tests (only FLAPLogin and
 // CrackCookie are exercised).
 type testAuthService struct {
-	flapLogin   func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
+	flapLogin   func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
 	crackCookie func(authCookie []byte) (state.ServerCookie, error)
 }
 
@@ -28,7 +29,7 @@ func (t *testAuthService) BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x
 	return wire.SNACMessage{}, nil
 }
 
-func (t *testAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string) (wire.SNACMessage, error) {
+func (t *testAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error) {
 	return wire.SNACMessage{}, nil
 }
 
@@ -58,9 +59,9 @@ func (t *testAuthService) RegisterBOSSession(ctx context.Context, authCookie sta
 	return nil, nil
 }
 
-func (t *testAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+func (t *testAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 	if t.flapLogin != nil {
-		return t.flapLogin(ctx, inFrame, advertisedHost)
+		return t.flapLogin(ctx, inFrame, endpointCfg)
 	}
 	return wire.TLVRestBlock{}, nil
 }
@@ -231,7 +232,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			contentType: "application/json",
 			body:        `{"username":"testuser","password":"testpass","devId":"dev123"}`,
 			auth: &testAuthService{
-				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 					return successfulLoginBlock(), nil
 				},
 			},
@@ -253,7 +254,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			contentType: "application/json; charset=utf-8",
 			body:        `{"username":"testuser","password":"testpass","devId":"dev123"}`,
 			auth: &testAuthService{
-				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 					return successfulLoginBlock(), nil
 				},
 			},
@@ -269,7 +270,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			contentType: "application/x-www-form-urlencoded",
 			body:        "s=testuser&pwd=testpass&devId=dev123",
 			auth: &testAuthService{
-				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 					return successfulLoginBlock(), nil
 				},
 			},
@@ -312,7 +313,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			contentType: "application/json",
 			body:        `{"username":"testuser","password":"wrongpass"}`,
 			auth: &testAuthService{
-				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 					return failedLoginBlock(), nil
 				},
 			},
@@ -329,7 +330,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			contentType: "application/json",
 			body:        `{"username":"testuser","password":"testpass"}`,
 			auth: &testAuthService{
-				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 					return wire.TLVRestBlock{}, errors.New("boom")
 				},
 			},
@@ -356,7 +357,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			contentType: "application/x-www-form-urlencoded",
 			body:        "s=testuser&pwd=wrongpass&f=xml",
 			auth: &testAuthService{
-				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 					return failedLoginBlock(), nil
 				},
 			},
@@ -372,7 +373,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			contentType: "application/json",
 			body:        `{"username":"testuser","password":"testpass"}`,
 			auth: &testAuthService{
-				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 					return blockWithoutCookie(), nil
 				},
 			},
@@ -433,7 +434,7 @@ func TestAuthHandler_ClientLogin_SendsClientIdentity(t *testing.T) {
 			var got wire.FLAPSignonFrame
 			handler := &AuthHandler{
 				AuthService: &testAuthService{
-					flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+					flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 						got = inFrame
 						return successfulLoginBlock(), nil
 					},

+ 6 - 5
server/webapi/handlers/login_psp_test.go

@@ -13,6 +13,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
@@ -54,7 +55,7 @@ func TestAuthHandler_LoginPSP_POST_Success(t *testing.T) {
 	var got wire.FLAPSignonFrame
 	handler := &AuthHandler{
 		AuthService: &testAuthService{
-			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 				got = inFrame
 				return successfulLoginBlock(), nil
 			},
@@ -105,17 +106,17 @@ func TestAuthHandler_LoginPSP_POST_Success(t *testing.T) {
 func TestAuthHandler_LoginPSP_POST_ServiceErrors(t *testing.T) {
 	tests := []struct {
 		name      string
-		flapLogin func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
+		flapLogin func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
 	}{
 		{
 			name: "LoginResponseHasNoCookie",
-			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 				return blockWithoutCookie(), nil
 			},
 		},
 		{
 			name: "AuthServiceUnreachable",
-			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 				return wire.TLVRestBlock{}, errors.New("boom")
 			},
 		},
@@ -148,7 +149,7 @@ func TestAuthHandler_LoginPSP_POST_ServiceErrors(t *testing.T) {
 func TestAuthHandler_LoginPSP_POST_InvalidCredentials(t *testing.T) {
 	handler := &AuthHandler{
 		AuthService: &testAuthService{
-			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
 				return failedLoginBlock(), nil
 			},
 		},

+ 20 - 21
server/webapi/handlers/oscar_bridge.go

@@ -4,9 +4,12 @@ import (
 	"encoding/base64"
 	"encoding/xml"
 	"log/slog"
+	"net"
 	"net/http"
+	"strconv"
 	"strings"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
 	"github.com/mk6i/open-oscar-server/state"
 )
@@ -16,7 +19,7 @@ import (
 // to present.
 type OSCARBridgeHandler struct {
 	OSCARAuthService OSCARAuthService
-	Config           OSCARConfig
+	Listener         config.ListenerGroup
 	Logger           *slog.Logger
 }
 
@@ -25,16 +28,6 @@ type OSCARAuthService interface {
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
 }
 
-// OSCARConfig provides configuration for OSCAR services.
-type OSCARConfig interface {
-	// GetBOSAddress returns the BOS server address for client connections
-	GetBOSAddress() (host string, port int)
-	// GetSSLBOSAddress returns the SSL-enabled BOS server address
-	GetSSLBOSAddress() (host string, port int)
-	// IsSSLAvailable checks if SSL is configured for BOS connections
-	IsSSLAvailable() bool
-}
-
 // StartOSCARSessionResponse represents the response for startOSCARSession endpoint.
 type StartOSCARSessionResponse struct {
 	Response struct {
@@ -116,19 +109,25 @@ func (h *OSCARBridgeHandler) StartOSCARSession(w http.ResponseWriter, r *http.Re
 	// The sign-on cookie then crosses the wire in the clear, so the downgrade is
 	// logged rather than left to be inferred from the absent tlsCertName.
 	useTLS := h.parseBoolParam(params.Get("useTLS"))
-	if useTLS && !h.Config.IsSSLAvailable() {
-		h.Logger.WarnContext(ctx, "TLS requested but no SSL listener is configured, advertising a plaintext BOS host",
-			"screen_name", cookie.ScreenName)
-		useTLS = false
+	endpoint := h.Listener.PlainEndpoint()
+	if useTLS {
+		ssl, ok := h.Listener.SSLEndpoint()
+		if !ok {
+			h.Logger.WarnContext(ctx, "TLS requested but no SSL listener is configured, advertising a plaintext BOS host",
+				"screen_name", cookie.ScreenName)
+			useTLS = false
+		} else {
+			endpoint = ssl
+		}
 	}
 
-	var host string
-	var port int
-	if useTLS {
-		host, port = h.Config.GetSSLBOSAddress()
-	} else {
-		host, port = h.Config.GetBOSAddress()
+	host, portStr, err := net.SplitHostPort(endpoint.AdvertisedHost())
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "unable to split advertised BOS host", "err", err.Error())
+		SendError(w, r, http.StatusInternalServerError, "internal server error")
+		return
 	}
+	port, _ := strconv.Atoi(portStr)
 
 	resp := &StartOSCARSessionResponse{}
 	resp.Response.StatusCode = 200

+ 17 - 12
server/webapi/handlers/oscar_bridge_test.go

@@ -11,21 +11,26 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
 	"github.com/mk6i/open-oscar-server/state"
 )
 
-// testOSCARConfig implements OSCARConfig with fixed addresses.
-type testOSCARConfig struct {
-	sslAvailable bool
+// testListener is a listener group whose SSL half is present only when the
+// test asks for it.
+func testListener(sslAvailable bool) config.ListenerGroup {
+	g := config.ListenerGroup{
+		Name:                   "local",
+		BOSListenAddress:       "0.0.0.0:5190",
+		BOSAdvertisedHostPlain: "bos.example.com:5190",
+	}
+	if sslAvailable {
+		g.BOSListenAddressSSL = "0.0.0.0:5191"
+		g.BOSAdvertisedHostSSL = "ssl.example.com:5193"
+	}
+	return g
 }
 
-func (c testOSCARConfig) GetBOSAddress() (string, int) { return "bos.example.com", 5190 }
-
-func (c testOSCARConfig) GetSSLBOSAddress() (string, int) { return "ssl.example.com", 5193 }
-
-func (c testOSCARConfig) IsSSLAvailable() bool { return c.sslAvailable }
-
 // bridgeRequest builds a startOSCARSession request carrying the API key the
 // middleware would have put on the context.
 func bridgeRequest(query string, apiKey *state.WebAPIKey) *http.Request {
@@ -165,7 +170,7 @@ func TestOSCARBridgeHandler_StartOSCARSession(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			handler := &OSCARBridgeHandler{
 				OSCARAuthService: &testAuthService{crackCookie: crackSignedCookie},
-				Config:           testOSCARConfig{sslAvailable: tt.sslAvailable},
+				Listener:         testListener(tt.sslAvailable),
 				Logger:           slog.Default(),
 			}
 
@@ -195,8 +200,8 @@ func TestOSCARBridgeHandler_StartOSCARSession_ReencodesCookie(t *testing.T) {
 				return state.ServerCookie{ScreenName: "testuser"}, nil
 			},
 		},
-		Config: testOSCARConfig{},
-		Logger: slog.Default(),
+		Listener: testListener(false),
+		Logger:   slog.Default(),
 	}
 
 	rr := httptest.NewRecorder()

+ 3 - 2
server/webapi/handlers/session.go

@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
 	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
@@ -39,10 +40,10 @@ 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, advertisedHost string) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error)
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
 	Signout(ctx context.Context, session *state.Session)
 	SignoutChat(ctx context.Context, sess *state.Session)
 }

+ 0 - 135
server/webapi/oscar_config.go

@@ -1,135 +0,0 @@
-package webapi
-
-import (
-	"net"
-	"strconv"
-	"strings"
-
-	"github.com/mk6i/open-oscar-server/config"
-)
-
-// OSCARConfigAdapter adapts the main server configuration to provide
-// OSCAR-specific configuration for the Web API bridge.
-type OSCARConfigAdapter struct {
-	cfg       config.Config
-	listeners []config.Listener
-}
-
-// NewOSCARConfigAdapter creates a new OSCAR configuration adapter.
-func NewOSCARConfigAdapter(cfg config.Config) *OSCARConfigAdapter {
-	listeners, _ := cfg.ParseListenersCfg()
-	return &OSCARConfigAdapter{
-		cfg:       cfg,
-		listeners: listeners,
-	}
-}
-
-// GetBOSAddress returns the plain (non-SSL) BOS server address for client connections.
-// This parses the configured BOS advertised host to extract the hostname and port.
-func (a *OSCARConfigAdapter) GetBOSAddress() (host string, port int) {
-	// Default to first listener configuration
-	if len(a.listeners) == 0 {
-		return "localhost", 5190 // Default OSCAR port
-	}
-
-	listener := a.listeners[0]
-
-	// Parse the advertised host for plain connections
-	if listener.BOSAdvertisedHostPlain != "" {
-		host, portStr := splitHostPort(listener.BOSAdvertisedHostPlain)
-		if portStr != "" {
-			if p, err := strconv.Atoi(portStr); err == nil {
-				port = p
-			}
-		}
-		if port == 0 {
-			port = 5190 // Default OSCAR port
-		}
-		return host, port
-	}
-
-	// Fall back to parsing the listen address
-	if listener.BOSListenAddress != "" {
-		host, portStr, err := net.SplitHostPort(listener.BOSListenAddress)
-		if err == nil {
-			if host == "" {
-				host = "localhost"
-			}
-			if p, err := strconv.Atoi(portStr); err == nil {
-				port = p
-			}
-		}
-		if port == 0 {
-			port = 5190
-		}
-		return host, port
-	}
-
-	return "localhost", 5190
-}
-
-// GetSSLBOSAddress returns the SSL-enabled BOS server address for client connections.
-func (a *OSCARConfigAdapter) GetSSLBOSAddress() (host string, port int) {
-	// Default to first listener configuration with SSL
-	for _, listener := range a.listeners {
-		if listener.HasSSL && listener.BOSAdvertisedHostSSL != "" {
-			host, portStr := splitHostPort(listener.BOSAdvertisedHostSSL)
-			if portStr != "" {
-				if p, err := strconv.Atoi(portStr); err == nil {
-					port = p
-				}
-			}
-			if port == 0 {
-				port = 5190 // Default OSCAR SSL port (could be different)
-			}
-			return host, port
-		}
-	}
-
-	// Fall back to plain address if no SSL configured
-	return a.GetBOSAddress()
-}
-
-// IsSSLAvailable checks if any listener has SSL configured.
-func (a *OSCARConfigAdapter) IsSSLAvailable() bool {
-	for _, listener := range a.listeners {
-		if listener.HasSSL {
-			return true
-		}
-	}
-	return false
-}
-
-// splitHostPort splits a host:port string, handling IPv6 addresses correctly.
-// Unlike net.SplitHostPort, this doesn't return an error for missing ports.
-func splitHostPort(hostport string) (host string, port string) {
-	// Handle IPv6 addresses
-	if strings.HasPrefix(hostport, "[") {
-		endIdx := strings.LastIndex(hostport, "]")
-		if endIdx != -1 {
-			host = hostport[1:endIdx]
-			if endIdx+1 < len(hostport) && hostport[endIdx+1] == ':' {
-				port = hostport[endIdx+2:]
-			}
-			return
-		}
-	}
-
-	// Handle IPv4 and hostnames
-	lastColon := strings.LastIndex(hostport, ":")
-	if lastColon != -1 {
-		// Check if this might be an IPv6 address without brackets
-		if strings.Count(hostport, ":") > 1 {
-			// Multiple colons, likely IPv6 without port
-			host = hostport
-			return
-		}
-		host = hostport[:lastColon]
-		port = hostport[lastColon+1:]
-		return
-	}
-
-	// No port specified
-	host = hostport
-	return
-}

+ 1 - 1
server/webapi/server.go

@@ -81,7 +81,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 	oscarBridgeHandler := &handlers.OSCARBridgeHandler{
 		OSCARAuthService: handler.AuthService,
-		Config:           handler.OSCARConfig,
+		Listener:         handler.BOSListener,
 		Logger:           logger,
 	}
 

+ 3 - 10
server/webapi/types.go

@@ -24,14 +24,14 @@ type OServiceService interface {
 	IdleNotification(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x11_OServiceIdleNotification) error
 	MonitorRateLimits(ctx context.Context, session *state.Session)
 	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)
+	ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error)
 }
 
 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, advertisedHost string) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, error)
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error)
 	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, cfg func(sess *state.Session)) (*state.SessionInstance, error)
 	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
@@ -96,13 +96,6 @@ type BuddyBroadcaster interface {
 	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
 }
 
-// OSCARConfig provides configuration for OSCAR services.
-type OSCARConfig interface {
-	GetBOSAddress() (host string, port int)
-	GetSSLBOSAddress() (host string, port int)
-	IsSSLAvailable() bool
-}
-
 type ChatSessionManager interface {
 	RemoveUserFromAllChats(user state.IdentScreenName)
 }