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

Add admin service and screenname formatting

Adds an additional service, ADMIN, to handle administrative functions such as
formatting screenname, updating email address, etc.

This initial pass implements screenname formatting and stubs out a few additional
functions to be implemented later.
Josh Knight пре 2 година
родитељ
комит
ca64417168

+ 20 - 0
cmd/server/main.go

@@ -140,6 +140,26 @@ func main() {
 		}.Start()
 		wg.Done()
 	}(logger)
+	go func(logger *slog.Logger) {
+		logger = logger.With("svc", "ADMIN")
+		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
+		adminService := foodgroup.NewAdminService(sessionManager, feedbagStore, buddyService)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		oServiceService := foodgroup.NewOServiceServiceForAdmin(cfg, logger, buddyService)
+
+		oscar.AdminServer{
+			AuthService: authService,
+			Config:      cfg,
+			Handler: handler.NewAdminRouter(handler.Handlers{
+				AdminHandler:    handler.NewAdminHandler(logger, adminService),
+				OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
+			}),
+			Logger:         logger,
+			OnlineNotifier: oServiceService,
+			ListenAddr:     net.JoinHostPort("", cfg.AdminPort),
+		}.Start()
+		wg.Done()
+	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "BART")
 		sessionManager := state.NewInMemorySessionManager(logger)

+ 2 - 1
config/config.go

@@ -11,9 +11,10 @@ type Config struct {
 	BOSPort     string `envconfig:"BOS_PORT" required:"true" val:"5191" description:"The port that the BOS service binds to."`
 	ChatNavPort string `envconfig:"CHAT_NAV_PORT" required:"true" val:"5193" description:"The port that the chat nav service binds to."`
 	ChatPort    string `envconfig:"CHAT_PORT" required:"true" val:"5192" description:"The port that the chat service binds to."`
+	AdminPort   string `envconfig:"ADMIN_PORT" required:"true" val:"5196" description:"The port that the admin service binds to."`
 	DBPath      string `envconfig:"DB_PATH" required:"true" val:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
 	DisableAuth bool   `envconfig:"DISABLE_AUTH" required:"true" val:"true" description:"Disable password check and auto-create new users at login time. Useful for quickly creating new accounts during development without having to register new users via the management API."`
 	FailFast    bool   `envconfig:"FAIL_FAST" required:"true" val:"false" description:"Crash the server in case it encounters a client message type it doesn't recognize. This makes failures obvious for debugging purposes."`
 	LogLevel    string `envconfig:"LOG_LEVEL" required:"true" val:"info" description:"Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 'error'."`
-	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true" val:"127.0.0.1" description:"The hostname that AIM clients connect to in order to reach OSCAR services (auth, BOS, BUCP, etc). Make sure the hostname is reachable by all clients. For local development, the default loopback address should work provided the server and AIM client(s) are running on the same machine. For LAN-only clients, a private IP address (e.g. 192.168..) or hostname should suffice. For clients connecting over the Internet, specify your public IP address and ensure that TCP ports 5190-5194 are open on your firewall."`
+	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true" val:"127.0.0.1" description:"The hostname that AIM clients connect to in order to reach OSCAR services (auth, BOS, BUCP, etc). Make sure the hostname is reachable by all clients. For local development, the default loopback address should work provided the server and AIM client(s) are running on the same machine. For LAN-only clients, a private IP address (e.g. 192.168..) or hostname should suffice. For clients connecting over the Internet, specify your public IP address and ensure that TCP ports 5190-5196 are open on your firewall."`
 }

+ 2 - 0
config/ras.service

@@ -5,8 +5,10 @@ Description=Retro AIM Server
 Type=simple
 User=ras
 Group=ras
+Environment="ADMIN_PORT=5196"
 Environment="ALERT_PORT=5194"
 Environment="AUTH_PORT=5190"
+Environment="BART_PORT=5195"
 Environment="BOS_PORT=5191"
 Environment="CHAT_NAV_PORT=5193"
 Environment="CHAT_PORT=5192"

+ 4 - 1
config/settings.bat

@@ -22,6 +22,9 @@ set CHAT_NAV_PORT=5193
 rem The port that the chat service binds to.
 set CHAT_PORT=5192
 
+rem The port that the admin service binds to.
+set ADMIN_PORT=5196
+
 rem The path to the SQLite database file. The file and DB schema are
 rem auto-created if they doesn't exist.
 set DB_PATH=oscar.sqlite
@@ -45,6 +48,6 @@ rem For local development, the default loopback address should work provided the
 rem server and AIM client(s) are running on the same machine. For LAN-only
 rem clients, a private IP address (e.g. 192.168..) or hostname should suffice.
 rem For clients connecting over the Internet, specify your public IP address and
-rem ensure that TCP ports 5190-5194 are open on your firewall.
+rem ensure that TCP ports 5190-5196 are open on your firewall.
 set OSCAR_HOST=127.0.0.1
 

+ 4 - 1
config/settings.env

@@ -22,6 +22,9 @@ export CHAT_NAV_PORT=5193
 # The port that the chat service binds to.
 export CHAT_PORT=5192
 
+# The port that the admin service binds to.
+export ADMIN_PORT=5196
+
 # The path to the SQLite database file. The file and DB schema are auto-created
 # if they doesn't exist.
 export DB_PATH=oscar.sqlite
@@ -45,6 +48,6 @@ export LOG_LEVEL=info
 # server and AIM client(s) are running on the same machine. For LAN-only
 # clients, a private IP address (e.g. 192.168..) or hostname should suffice. For
 # clients connecting over the Internet, specify your public IP address and
-# ensure that TCP ports 5190-5194 are open on your firewall.
+# ensure that TCP ports 5190-5196 are open on your firewall.
 export OSCAR_HOST=127.0.0.1
 

+ 160 - 0
foodgroup/admin.go

@@ -0,0 +1,160 @@
+package foodgroup
+
+import (
+	"context"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// NewAdminService creates an instance of AdminService.
+func NewAdminService(
+	sessionManager SessionManager,
+	accountManager AccountManager,
+	buddyUpdateBroadcaster buddyBroadcaster,
+) *AdminService {
+	return &AdminService{
+		sessionManager:         sessionManager,
+		accountManager:         accountManager,
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
+	}
+}
+
+// AdminService provides functionality for the Admin food group.
+// The Admin food group is used for client control of passwords, screen name formatting,
+// email address, and account confirmation.
+type AdminService struct {
+	sessionManager         SessionManager
+	accountManager         AccountManager
+	buddyUpdateBroadcaster buddyBroadcaster
+}
+
+// ConfirmRequest returns the ScreenName account status. It returns SNAC
+// wire.AdminConfirmReply. The values in the return SNAC are
+// flag, URL, length
+func (s AdminService) ConfirmRequest(_ context.Context, frame wire.SNACFrame) (wire.SNACMessage, error) {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Admin,
+			SubGroup:  wire.AdminAcctConfirmReply,
+			RequestID: frame.RequestID,
+		},
+		Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
+			Status: wire.AdminAcctConfirmStatusEmailSent, // todo: get from session/db
+		},
+	}, nil
+}
+
+// InfoQuery returns the requested information about the account
+func (s AdminService) InfoQuery(_ context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x02_AdminInfoQuery) (wire.SNACMessage, error) {
+	var getAdminInfoReply = func(tag uint16, val any) wire.SNACMessage {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Admin,
+				SubGroup:  wire.AdminInfoReply,
+				RequestID: frame.RequestID,
+			},
+			Body: wire.SNAC_0x07_0x03_AdminInfoReply{
+				Permissions: wire.AdminInfoPermissionsReadWrite, // todo: what does this actually control?
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(tag, val),
+					},
+				},
+			},
+		}
+	}
+
+	// wire.AdminTLVRegistrationStatus is used in the AIM Preferences > Privacy panel to control
+	// Allow users who know my e-mail address to find...
+	//	o Nothing about me - wire.AdminInfoRegStatusNoDisclosure
+	//	o Only that I have an account - wire.AdminInfoRegStatusLimitDisclosure
+	//	o My screen name - wire.AdminInfoRegStatusFullDisclosure
+	if _, hasRegStatus := body.TLVRestBlock.Slice(wire.AdminTLVRegistrationStatus); hasRegStatus {
+		return getAdminInfoReply(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusFullDisclosure), nil // todo: get from session/db
+
+	} else if _, hasEmail := body.TLVRestBlock.Slice(wire.AdminTLVEmailAddress); hasEmail {
+		return getAdminInfoReply(wire.AdminTLVEmailAddress, sess.IdentScreenName().String()+"@aol.com"), nil // todo: get from session/db
+
+	} else if _, hasNickName := body.TLVRestBlock.Slice(wire.AdminTLVScreenNameFormatted); hasNickName {
+		return getAdminInfoReply(wire.AdminTLVScreenNameFormatted, sess.DisplayScreenName().String()), nil
+
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Admin,
+			SubGroup:  wire.AdminErr,
+			RequestID: frame.RequestID,
+		},
+		Body: wire.SNACError{
+			Code: wire.ErrorCodeNotSupportedByHost,
+		},
+	}, nil
+}
+
+func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error) {
+	var replyMessage = func(tag uint16, val any) wire.SNACMessage {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Admin,
+				SubGroup:  wire.AdminInfoChangeReply,
+				RequestID: frame.RequestID,
+			},
+			Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+				Permissions: wire.AdminInfoPermissionsReadWrite,
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(tag, val),
+					},
+				},
+			},
+		}
+	}
+
+	var validateProposedName = func(name state.DisplayScreenName) (ok bool, errorCode uint16) {
+		if len(name) > 16 {
+			// proposed name is too long
+			// todo: 16 should be defined elsewhere
+			return false, wire.AdminInfoErrorInvalidNickNameLength
+		} else if name.IdentScreenName() != sess.IdentScreenName() {
+			// proposed name does not match session name (e.g. malicious client)
+			return false, wire.AdminInfoErrorInvalidNickName
+		}
+		return true, 0
+	}
+
+	if sn, hasScreenNameFormatted := body.TLVRestBlock.Slice(wire.AdminTLVScreenNameFormatted); hasScreenNameFormatted {
+		proposedName := state.DisplayScreenName(sn)
+		if ok, errorCode := validateProposedName(proposedName); !ok {
+			return wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminErr,
+					RequestID: frame.RequestID,
+				},
+				Body: wire.SNACError{
+					Code: errorCode,
+				},
+			}, nil
+		}
+		if err := s.accountManager.UpdateDisplayScreenName(proposedName); err != nil {
+			return wire.SNACMessage{}, err
+		}
+		sess.SetDisplayScreenName(proposedName)
+		if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+			return wire.SNACMessage{}, err
+		}
+		return replyMessage(wire.AdminTLVScreenNameFormatted, proposedName.String()), nil
+	}
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Admin,
+			SubGroup:  wire.AdminErr,
+			RequestID: frame.RequestID,
+		},
+		Body: wire.SNACError{
+			Code: wire.ErrorCodeNotSupportedByHost,
+		},
+	}, nil
+}

+ 18 - 0
foodgroup/auth.go

@@ -90,6 +90,24 @@ func (s AuthService) RegisterBOSSession(authCookie []byte) (*state.Session, erro
 	return s.sessionManager.AddSession(u.DisplayScreenName), nil
 }
 
+// RetrieveBOSSession returns a user's existing session
+func (s AuthService) RetrieveBOSSession(authCookie []byte) (*state.Session, error) {
+	screenName, err := s.cookieBaker.Crack(authCookie)
+	if err != nil {
+		return nil, err
+	}
+
+	u, err := s.userManager.User(state.NewIdentScreenName(string(screenName)))
+	if err != nil {
+		return nil, fmt.Errorf("failed to retrieve user: %w", err)
+	}
+	if u == nil {
+		return nil, fmt.Errorf("user not found")
+	}
+
+	return s.sessionManager.RetrieveSession(u.IdentScreenName), nil
+}
+
 // Signout removes this user's session and notifies users who have this user on
 // their buddy list about this user's departure.
 func (s AuthService) Signout(ctx context.Context, sess *state.Session) error {

+ 36 - 0
foodgroup/oservice.go

@@ -561,6 +561,29 @@ type chatLoginCookie struct {
 // for connecting to the food group service specified in inFrame.
 func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error) {
 	switch inBody.FoodGroup {
+	case wire.Admin:
+		cookie, err := s.cookieIssuer.Issue([]byte(sess.IdentScreenName().String()))
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceServiceResponse,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.OServiceTLVTagsReconnectHere, net.JoinHostPort(s.cfg.OSCARHost, s.cfg.AdminPort)),
+						wire.NewTLV(wire.OServiceTLVTagsLoginCookie, cookie),
+						wire.NewTLV(wire.OServiceTLVTagsGroupID, wire.Admin),
+						wire.NewTLV(wire.OServiceTLVTagsSSLCertName, ""),
+						wire.NewTLV(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+					},
+				},
+			},
+		}, nil
 	case wire.Alert:
 		cookie, err := s.cookieIssuer.Issue([]byte(sess.IdentScreenName().String()))
 		if err != nil {
@@ -805,6 +828,19 @@ func NewOServiceServiceForAlert(
 	}
 }
 
+// NewOServiceServiceForAdmin creates a new instance of OServiceService for Admin server.
+func NewOServiceServiceForAdmin(cfg config.Config, logger *slog.Logger, buddyUpdateBroadcaster buddyBroadcaster) *OServiceService {
+	return &OServiceService{
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
+		cfg:                    cfg,
+		logger:                 logger,
+		foodGroups: []uint16{
+			wire.OService,
+			wire.Admin,
+		},
+	}
+}
+
 // NewOServiceServiceForBART creates a new instance of OServiceService for the
 // BART server.
 func NewOServiceServiceForBART(

+ 4 - 0
foodgroup/types.go

@@ -161,3 +161,7 @@ type buddyBroadcaster interface {
 	UnicastBuddyArrived(ctx context.Context, from *state.Session, to *state.Session) error
 	UnicastBuddyDeparted(ctx context.Context, from *state.Session, to *state.Session)
 }
+
+type AccountManager interface {
+	UpdateDisplayScreenName(displayScreenName state.DisplayScreenName) error
+}

+ 154 - 0
server/oscar/admin.go

@@ -0,0 +1,154 @@
+package oscar
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"os"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// AdminServer provides client connection lifecycle management for the BOS
+// service.
+type AdminServer struct {
+	AuthService
+	Handler
+	ListenAddr string
+	Logger     *slog.Logger
+	OnlineNotifier
+	config.Config
+}
+
+// Start starts a TCP server and listens for connections. The initial
+// authentication handshake sequences are handled by this method. The remaining
+// requests are relayed to BOSRouter.
+func (rt AdminServer) Start() {
+	listener, err := net.Listen("tcp", rt.ListenAddr)
+	if err != nil {
+		rt.Logger.Error("unable to bind server address", "host", rt.ListenAddr, "err", err.Error())
+		os.Exit(1)
+	}
+	defer listener.Close()
+
+	rt.Logger.Info("starting server", "listen_host", rt.ListenAddr, "oscar_host", rt.Config.OSCARHost)
+
+	for {
+		conn, err := listener.Accept()
+		if err != nil {
+			rt.Logger.Error(err.Error())
+			continue
+		}
+		ctx := context.Background()
+		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
+		rt.Logger.DebugContext(ctx, "accepted connection")
+		go func() {
+			if err := rt.handleNewConnection(ctx, conn); err != nil {
+				rt.Logger.Info("user session failed", "err", err.Error())
+			}
+		}()
+	}
+}
+
+func (rt AdminServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
+	flapc := wire.NewFlapClient(100, rwc, rwc)
+
+	if err := flapc.SendSignonFrame(nil); err != nil {
+		return err
+	}
+	flap, err := flapc.ReceiveSignonFrame()
+	if err != nil {
+		return err
+	}
+
+	authCookie, ok := flap.Slice(wire.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return errors.New("unable to get session id from payload")
+	}
+
+	sess, err := rt.RetrieveBOSSession(authCookie)
+	if sess == nil {
+		return errors.New("session not found")
+	}
+
+	defer func() {
+		sess.Close()
+		rwc.Close()
+		if err := rt.Signout(ctx, sess); err != nil {
+			rt.Logger.ErrorContext(ctx, "error notifying departure", "err", err.Error())
+		}
+	}()
+
+	ctx = context.WithValue(ctx, "screenName", sess.IdentScreenName())
+
+	msg := rt.OnlineNotifier.HostOnline()
+	if err := flapc.SendSNAC(msg.Frame, msg.Body); err != nil {
+		return err
+	}
+
+	return dispatchIncomingMessagesSimple(ctx, sess, flapc, rwc, rt.Logger, rt.Handler, rt.Config)
+}
+
+func dispatchIncomingMessagesSimple(ctx context.Context, sess *state.Session, flapc *wire.FlapClient, r io.Reader, logger *slog.Logger, router Handler, config config.Config) error {
+	// buffered so that the go routine has room to exit
+	msgCh := make(chan incomingMessage, 1)
+	readErrCh := make(chan error, 1)
+	go consumeFLAPFrames(r, msgCh, readErrCh)
+
+	defer func() {
+		logger.InfoContext(ctx, "user disconnected")
+	}()
+
+	for {
+		select {
+		case m, ok := <-msgCh:
+			if !ok {
+				return nil
+			}
+			switch m.flap.FrameType {
+			case wire.FLAPFrameData:
+				inFrame := wire.SNACFrame{}
+				if err := wire.Unmarshal(&inFrame, m.payload); err != nil {
+					return err
+				}
+				// route a client request to the appropriate service handler. the
+				// handler may write a response to the client connection.
+				if err := router.Handle(ctx, sess, inFrame, m.payload, flapc); err != nil {
+					middleware.LogRequestError(ctx, logger, inFrame, err)
+					if errors.Is(err, ErrRouteNotFound) {
+						if err1 := sendInvalidSNACErr(inFrame, flapc); err1 != nil {
+							return errors.Join(err1, err)
+						}
+						if config.FailFast {
+							panic(err.Error())
+						}
+						break
+					}
+					return err
+				}
+			case wire.FLAPFrameSignon:
+				return fmt.Errorf("shouldn't get FLAPFrameSignon. flap: %v", m.flap)
+			case wire.FLAPFrameError:
+				return fmt.Errorf("got FLAPFrameError. flap: %v", m.flap)
+			case wire.FLAPFrameSignoff:
+				logger.InfoContext(ctx, "got FLAPFrameSignoff", "flap", m.flap)
+				return nil
+			case wire.FLAPFrameKeepAlive:
+				logger.DebugContext(ctx, "keepalive heartbeat")
+			default:
+				return fmt.Errorf("got unknown FLAP frame type. flap: %v", m.flap)
+			}
+		case err := <-readErrCh:
+			if !errors.Is(io.EOF, err) {
+				logger.ErrorContext(ctx, "client disconnected with error", "err", err)
+			}
+			return nil
+		}
+	}
+}

+ 1 - 0
server/oscar/auth.go

@@ -19,6 +19,7 @@ type AuthService interface {
 	BUCPLogin(bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)
 	FLAPLogin(frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)
 	RegisterBOSSession(authCookie []byte) (*state.Session, error)
+	RetrieveBOSSession(authCookie []byte) (*state.Session, error)
 	RegisterChatSession(authCookie []byte) (*state.Session, error)
 	Signout(ctx context.Context, sess *state.Session) error
 	SignoutChat(ctx context.Context, sess *state.Session)

+ 68 - 0
server/oscar/handler/admin.go

@@ -0,0 +1,68 @@
+package handler
+
+import (
+	"context"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func NewAdminHandler(logger *slog.Logger, adminService AdminService) AdminHandler {
+	return AdminHandler{
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+		AdminService: adminService,
+	}
+}
+
+type AdminHandler struct {
+	AdminService
+	OServiceService
+	middleware.RouteLogger
+}
+
+type AdminService interface {
+	ConfirmRequest(_ context.Context, frame wire.SNACFrame) (wire.SNACMessage, error)
+	InfoQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x02_AdminInfoQuery) (wire.SNACMessage, error)
+	InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)
+}
+
+func (rt AdminHandler) ConfirmRequest(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
+	outSNAC, err := rt.AdminService.ConfirmRequest(ctx, inFrame)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt AdminHandler) InfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x07_0x02_AdminInfoQuery{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.AdminService.InfoQuery(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt AdminHandler) InfoChangeRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x07_0x04_AdminInfoChangeRequest{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.AdminService.InfoChangeRequest(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 16 - 0
server/oscar/handler/routes.go

@@ -10,6 +10,7 @@ import (
 // specific handler responsible for a distinct aspect of the OSCAR service,
 // such as managing buddy lists, chat sessions, and user alerts.
 type Handlers struct {
+	AdminHandler
 	AlertHandler
 	BARTHandler
 	BuddyHandler
@@ -150,3 +151,18 @@ func NewBARTRouter(h Handlers) oscar.Router {
 
 	return router
 }
+
+func NewAdminRouter(h Handlers) oscar.Router {
+	router := oscar.NewRouter()
+
+	router.Register(wire.OService, wire.OServiceClientOnline, h.OServiceHandler.ClientOnline)
+	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
+	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
+	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
+
+	router.Register(wire.Admin, wire.AdminAcctConfirmRequest, h.AdminHandler.ConfirmRequest)
+	router.Register(wire.Admin, wire.AdminInfoQuery, h.AdminHandler.InfoQuery)
+	router.Register(wire.Admin, wire.AdminInfoChangeRequest, h.AdminHandler.InfoChangeRequest)
+
+	return router
+}

+ 11 - 0
state/user_store.go

@@ -582,3 +582,14 @@ func (f SQLiteUserStore) AllChatRooms(exchange uint16) ([]ChatRoom, error) {
 
 	return users, nil
 }
+
+// UpdateDisplayScreenName updates the user's DisplayScreenName
+func (f SQLiteUserStore) UpdateDisplayScreenName(displayScreenName DisplayScreenName) error {
+	q := `
+		UPDATE users
+		SET displayScreenName = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.Exec(q, displayScreenName.String(), displayScreenName.IdentScreenName().String())
+	return err
+}

+ 105 - 0
wire/snacs.go

@@ -633,6 +633,111 @@ type SNAC_0x04_0x14_ICBMClientEvent struct {
 	Event      uint16
 }
 
+//
+// 0x07: Admin
+//
+
+const (
+	AdminErr                uint16 = 0x0001
+	AdminInfoQuery          uint16 = 0x0002
+	AdminInfoReply          uint16 = 0x0003
+	AdminInfoChangeRequest  uint16 = 0x0004
+	AdminInfoChangeReply    uint16 = 0x0005
+	AdminAcctConfirmRequest uint16 = 0x0006
+	AdminAcctConfirmReply   uint16 = 0x0007
+	AdminAcctDeleteRequest  uint16 = 0x0008
+	AdminAcctDeleteReply    uint16 = 0x0009
+
+	AdminInfoErrorValidateNickName              uint16 = 0x0001
+	AdminInfoErrorValidatePassword              uint16 = 0x0002
+	AdminInfoErrorValidateEmail                 uint16 = 0x0003
+	AdminInfoErrorServiceTempUnavailable        uint16 = 0x0004
+	AdminInfoErrorFieldChangeTempUnavailable    uint16 = 0x0005
+	AdminInfoErrorInvalidNickName               uint16 = 0x0006
+	AdminInfoErrorInvalidPassword               uint16 = 0x0007
+	AdminInfoErrorInvalidEmail                  uint16 = 0x0008
+	AdminInfoErrorInvalidRegistrationPreference uint16 = 0x0009
+	AdminInfoErrorInvalidOldPassword            uint16 = 0x000A
+	AdminInfoErrorInvalidNickNameLength         uint16 = 0x000B
+	AdminInfoErrorInvalidPasswordLength         uint16 = 0x000C
+	AdminInfoErrorInvalidEmailLength            uint16 = 0x000D
+	AdminInfoErrorInvalidOldPasswordLength      uint16 = 0x000E
+	AdminInfoErrorNeedOldPassword               uint16 = 0x000F
+	AdminInfoErrorReadOnlyField                 uint16 = 0x0010
+	AdminInfoErrorWriteOnlyField                uint16 = 0x0011
+	AdminInfoErrorUnsupportedType               uint16 = 0x0012
+	AdminInfoErrorAllOtherErrors                uint16 = 0x0013
+	AdminInfoErrorBadSnac                       uint16 = 0x0014
+	AdminInfoErrorInvalidAccount                uint16 = 0x0015
+	AdminInfoErrorDeletedAccount                uint16 = 0x0016
+	AdminInfoErrorExpiredAccount                uint16 = 0x0017
+	AdminInfoErrorNoDatabaseAccess              uint16 = 0x0018
+	AdminInfoErrorInvalidDatabaseFields         uint16 = 0x0019
+	AdminInfoErrorBadDatabaseStatus             uint16 = 0x001A
+	AdminInfoErrorMigrationCancel               uint16 = 0x001B
+	AdminInfoErrorInternalError                 uint16 = 0x001C
+	AdminInfoErrorPendingRequest                uint16 = 0x001D
+	AdminInfoErrorNotDTStatus                   uint16 = 0x001E
+	AdminInfoErrorOutstandingConfirm            uint16 = 0x001F
+	AdminInfoErrorNoEmailAddress                uint16 = 0x0020
+	AdminInfoErrorOverLimit                     uint16 = 0x0021
+	AdminInfoErrorEmailHostFail                 uint16 = 0x0022
+	AdminInfoErrorDNSFail                       uint16 = 0x0023
+
+	AdminInfoRegStatusNoDisclosure    uint16 = 0x01
+	AdminInfoRegStatusLimitDisclosure uint16 = 0x02
+	AdminInfoRegStatusFullDisclosure  uint16 = 0x03
+
+	AdminInfoPermissionsReadOnly1 uint16 = 0x01
+	AdminInfoPermissionsReadOnly2 uint16 = 0x02
+	AdminInfoPermissionsReadWrite uint16 = 0x03
+
+	AdminAcctConfirmStatusEmailSent        uint16 = 0x00
+	AdminAcctConfirmStatusAlreadyConfirmed uint16 = 0x1E
+	AdminAcctConfirmStatusServerError      uint16 = 0x23
+
+	AdminTLVScreenNameFormatted uint16 = 0x01
+	AdminTLVNewPassword         uint16 = 0x02
+	AdminTLVUrl                 uint16 = 0x04
+	AdminTLVErrorCode           uint16 = 0x08
+	AdminTLVEmailAddress        uint16 = 0x11
+	AdminTLVOldPassword         uint16 = 0x12
+	AdminTLVRegistrationStatus  uint16 = 0x13
+)
+
+// Used when client wants to get its account information
+// - AdminTLVScreenNameFormatted
+// - AdminTLVEmailAddress
+// - AdminTLVRegistrationStatus
+type SNAC_0x07_0x02_AdminInfoQuery struct {
+	TLVRestBlock
+}
+
+type SNAC_0x07_0x03_AdminInfoReply struct {
+	Permissions uint16
+	TLVBlock
+}
+
+// AdminTLVScreenNameFormatted - change screenname formatting
+// AdminTLVEmailAddress - change account email
+// AdminTLVRegistrationStatus - change registration status
+// AdminTLVNewPassword, AdminTLVOldPassword - change password
+type SNAC_0x07_0x04_AdminInfoChangeRequest struct {
+	TLVRestBlock
+}
+
+type SNAC_0x07_0x05_AdminChangeReply struct {
+	Permissions uint16
+	TLVBlock
+}
+
+type SNAC_0x07_0x06_AdminConfirmRequest struct{}
+
+type SNAC_0x07_0x07_AdminConfirmReply struct {
+	Status uint16
+	TLV
+}
+
 //
 // 0x09: PermitDeny
 //

+ 11 - 0
wire/snacs_string.go

@@ -276,6 +276,17 @@ var subGroupName = map[uint16]map[uint16]string{
 		PermitDenyAddTempPermitListEntries: "PermitDenyAddTempPermitListEntries",
 		PermitDenyDelTempPermitListEntries: "PermitDenyDelTempPermitListEntries",
 	},
+	Admin: {
+		AdminErr:                "AdminErr",
+		AdminInfoQuery:          "AdminInfoQuery",
+		AdminInfoReply:          "AdminInfoReply",
+		AdminInfoChangeRequest:  "AdminInfoChangeRequest",
+		AdminInfoChangeReply:    "AdminInfoChangeReply",
+		AdminAcctConfirmRequest: "AdminAcctConfirmRequest",
+		AdminAcctConfirmReply:   "AdminAcctConfirmReply",
+		AdminAcctDeleteRequest:  "AdminAcctDeleteRequest",
+		AdminAcctDeleteReply:    "AdminAcctDeleteReply",
+	},
 }
 
 // SubGroupName gets the string name of a subgroup within a food group. It