]> git.r.bdr.sh - rbdr/mobius/blobdiff - hotline/server.go
Fix overwrite of user info due to buffer re-use
[rbdr/mobius] / hotline / server.go
index e685afdbe7a76dee8e4b173d9702649800320f84..bd15a2ecea697b8ed88a2e34c29e870b6866e034 100644 (file)
@@ -18,7 +18,6 @@ import (
        "net"
        "os"
        "path/filepath"
-       "runtime/debug"
        "strings"
        "sync"
        "time"
@@ -34,38 +33,52 @@ type requestCtx struct {
        name       string
 }
 
-const (
-       userIdleSeconds        = 300 // time in seconds before an inactive user is marked idle
-       idleCheckInterval      = 10  // time in seconds to check for idle users
-       trackerUpdateFrequency = 300 // time in seconds between tracker re-registration
-)
-
 var nostalgiaVersion = []byte{0, 0, 2, 0x2c} // version ID used by the Nostalgia client
+var frogblastVersion = []byte{0, 0, 0, 0xb9} // version ID used by the Frogblast 1.2.4 client
 
 type Server struct {
-       Port         int
-       Accounts     map[string]*Account
-       Agreement    []byte
-       Clients      map[uint16]*ClientConn
-       ThreadedNews *ThreadedNews
-
+       Port          int
+       Accounts      map[string]*Account
+       Agreement     []byte
+       Clients       map[uint16]*ClientConn
        fileTransfers map[[4]byte]*FileTransfer
 
-       Config        *Config
-       ConfigDir     string
-       Logger        *zap.SugaredLogger
-       PrivateChats  map[uint32]*PrivateChat
+       Config    *Config
+       ConfigDir string
+       Logger    *zap.SugaredLogger
+
+       PrivateChatsMu sync.Mutex
+       PrivateChats   map[uint32]*PrivateChat
+
        NextGuestID   *uint16
        TrackerPassID [4]byte
-       Stats         *Stats
+
+       StatsMu sync.Mutex
+       Stats   *Stats
 
        FS FileStore // Storage backend to use for File storage
 
        outbox chan Transaction
        mux    sync.Mutex
 
+       threadedNewsMux sync.Mutex
+       ThreadedNews    *ThreadedNews
+
        flatNewsMux sync.Mutex
        FlatNews    []byte
+
+       banListMU sync.Mutex
+       banList   map[string]*time.Time
+}
+
+func (s *Server) CurrentStats() Stats {
+       s.StatsMu.Lock()
+       defer s.StatsMu.Unlock()
+
+       stats := s.Stats
+       stats.CurrentlyConnected = len(s.Clients)
+
+       return *stats
 }
 
 type PrivateChat struct {
@@ -133,7 +146,6 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error
 }
 
 func (s *Server) sendTransaction(t Transaction) error {
-       requestNum := binary.BigEndian.Uint16(t.Type)
        clientID, err := byteToInt(*t.clientID)
        if err != nil {
                return err
@@ -141,31 +153,21 @@ func (s *Server) sendTransaction(t Transaction) error {
 
        s.mux.Lock()
        client := s.Clients[uint16(clientID)]
-       s.mux.Unlock()
        if client == nil {
                return fmt.Errorf("invalid client id %v", *t.clientID)
        }
-       userName := string(client.UserName)
-       login := client.Account.Login
 
-       handler := TransactionHandlers[requestNum]
+       s.mux.Unlock()
 
        b, err := t.MarshalBinary()
        if err != nil {
                return err
        }
-       var n int
-       if n, err = client.Connection.Write(b); err != nil {
+
+       if _, err := client.Connection.Write(b); err != nil {
                return err
        }
-       s.Logger.Debugw("Sent Transaction",
-               "name", userName,
-               "login", login,
-               "IsReply", t.IsReply,
-               "type", handler.Name,
-               "sentBytes", n,
-               "remoteAddr", client.RemoteAddr,
-       )
+
        return nil
 }
 
@@ -193,8 +195,10 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
                })
 
                go func() {
+                       s.Logger.Infow("Connection established", "RemoteAddr", conn.RemoteAddr())
+
+                       defer conn.Close()
                        if err := s.handleNewConnection(connCtx, conn, conn.RemoteAddr().String()); err != nil {
-                               s.Logger.Infow("New client connection established", "RemoteAddr", conn.RemoteAddr())
                                if err == io.EOF {
                                        s.Logger.Infow("Client disconnected", "RemoteAddr", conn.RemoteAddr())
                                } else {
@@ -222,9 +226,10 @@ func NewServer(configDir string, netPort int, logger *zap.SugaredLogger, FS File
                Logger:        logger,
                NextGuestID:   new(uint16),
                outbox:        make(chan Transaction),
-               Stats:         &Stats{StartTime: time.Now()},
+               Stats:         &Stats{Since: time.Now()},
                ThreadedNews:  &ThreadedNews{},
                FS:            FS,
+               banList:       make(map[string]*time.Time),
        }
 
        var err error
@@ -243,6 +248,9 @@ func NewServer(configDir string, netPort int, logger *zap.SugaredLogger, FS File
                return nil, err
        }
 
+       // try to load the ban list, but ignore errors as this file may not be present or may be empty
+       _ = server.loadBanList(filepath.Join(configDir, "Banlist.yaml"))
+
        if err := server.loadThreadedNews(filepath.Join(configDir, "ThreadedNews.yaml")); err != nil {
                return nil, err
        }
@@ -279,7 +287,7 @@ func NewServer(configDir string, netPort int, logger *zap.SugaredLogger, FS File
                                        if err := register(t, tr); err != nil {
                                                server.Logger.Errorw("unable to register with tracker %v", "error", err)
                                        }
-                                       server.Logger.Infow("Sent Tracker registration", "data", tr)
+                                       server.Logger.Debugw("Sent Tracker registration", "addr", t)
                                }
 
                                time.Sleep(trackerUpdateFrequency * time.Second)
@@ -310,16 +318,16 @@ func (s *Server) keepaliveHandler() {
                        if c.IdleTime > userIdleSeconds && !c.Idle {
                                c.Idle = true
 
-                               flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*c.Flags)))
+                               flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(c.Flags)))
                                flagBitmap.SetBit(flagBitmap, userFlagAway, 1)
-                               binary.BigEndian.PutUint16(*c.Flags, uint16(flagBitmap.Int64()))
+                               binary.BigEndian.PutUint16(c.Flags, uint16(flagBitmap.Int64()))
 
                                c.sendAll(
                                        tranNotifyChangeUser,
                                        NewField(fieldUserID, *c.ID),
-                                       NewField(fieldUserFlags, *c.Flags),
+                                       NewField(fieldUserFlags, c.Flags),
                                        NewField(fieldUserName, c.UserName),
-                                       NewField(fieldUserIconID, *c.Icon),
+                                       NewField(fieldUserIconID, c.Icon),
                                )
                        }
                }
@@ -327,15 +335,31 @@ func (s *Server) keepaliveHandler() {
        }
 }
 
+func (s *Server) writeBanList() error {
+       s.banListMU.Lock()
+       defer s.banListMU.Unlock()
+
+       out, err := yaml.Marshal(s.banList)
+       if err != nil {
+               return err
+       }
+       err = ioutil.WriteFile(
+               filepath.Join(s.ConfigDir, "Banlist.yaml"),
+               out,
+               0666,
+       )
+       return err
+}
+
 func (s *Server) writeThreadedNews() error {
-       s.mux.Lock()
-       defer s.mux.Unlock()
+       s.threadedNewsMux.Lock()
+       defer s.threadedNewsMux.Unlock()
 
        out, err := yaml.Marshal(s.ThreadedNews)
        if err != nil {
                return err
        }
-       err = ioutil.WriteFile(
+       err = s.FS.WriteFile(
                filepath.Join(s.ConfigDir, "ThreadedNews.yaml"),
                out,
                0666,
@@ -349,12 +373,12 @@ func (s *Server) NewClientConn(conn io.ReadWriteCloser, remoteAddr string) *Clie
 
        clientConn := &ClientConn{
                ID:         &[]byte{0, 0},
-               Icon:       &[]byte{0, 0},
-               Flags:      &[]byte{0, 0},
+               Icon:       []byte{0, 0},
+               Flags:      []byte{0, 0},
                UserName:   []byte{},
                Connection: conn,
                Server:     s,
-               Version:    &[]byte{},
+               Version:    []byte{},
                AutoReply:  []byte{},
                transfers:  map[int]map[[4]byte]*FileTransfer{},
                Agreed:     false,
@@ -378,7 +402,7 @@ func (s *Server) NewClientConn(conn io.ReadWriteCloser, remoteAddr string) *Clie
 }
 
 // NewUser creates a new user account entry in the server map and config file
-func (s *Server) NewUser(login, name, password string, access []byte) error {
+func (s *Server) NewUser(login, name, password string, access accessBitmap) error {
        s.mux.Lock()
        defer s.mux.Unlock()
 
@@ -386,7 +410,7 @@ func (s *Server) NewUser(login, name, password string, access []byte) error {
                Login:    login,
                Name:     name,
                Password: hashAndSalt([]byte(password)),
-               Access:   &access,
+               Access:   access,
        }
        out, err := yaml.Marshal(&account)
        if err != nil {
@@ -397,7 +421,7 @@ func (s *Server) NewUser(login, name, password string, access []byte) error {
        return s.FS.WriteFile(filepath.Join(s.ConfigDir, "Users", login+".yaml"), out, 0666)
 }
 
-func (s *Server) UpdateUser(login, newLogin, name, password string, access []byte) error {
+func (s *Server) UpdateUser(login, newLogin, name, password string, access accessBitmap) error {
        s.mux.Lock()
        defer s.mux.Unlock()
 
@@ -412,7 +436,7 @@ func (s *Server) UpdateUser(login, newLogin, name, password string, access []byt
        }
 
        account := s.Accounts[newLogin]
-       account.Access = &access
+       account.Access = access
        account.Name = name
        account.Password = password
 
@@ -449,8 +473,8 @@ func (s *Server) connectedUsers() []Field {
                }
                user := User{
                        ID:    *c.ID,
-                       Icon:  *c.Icon,
-                       Flags: *c.Flags,
+                       Icon:  c.Icon,
+                       Flags: c.Flags,
                        Name:  string(c.UserName),
                }
                connectedUsers = append(connectedUsers, NewField(fieldUsernameWithInfo, user.Payload()))
@@ -458,6 +482,16 @@ func (s *Server) connectedUsers() []Field {
        return connectedUsers
 }
 
+func (s *Server) loadBanList(path string) error {
+       fh, err := os.Open(path)
+       if err != nil {
+               return err
+       }
+       decoder := yaml.NewDecoder(fh)
+
+       return decoder.Decode(s.banList)
+}
+
 // loadThreadedNews loads the threaded news data from disk
 func (s *Server) loadThreadedNews(threadedNewsPath string) error {
        fh, err := os.Open(threadedNewsPath)
@@ -517,48 +551,61 @@ func (s *Server) loadConfig(path string) error {
        return nil
 }
 
-const (
-       minTransactionLen = 22 // minimum length of any transaction
-)
-
-// dontPanic recovers and logs panics instead of crashing
-// TODO: remove this after known issues are fixed
-func dontPanic(logger *zap.SugaredLogger) {
-       if r := recover(); r != nil {
-               fmt.Println("stacktrace from panic: \n" + string(debug.Stack()))
-               logger.Errorw("PANIC", "err", r, "trace", string(debug.Stack()))
-       }
-}
-
 // handleNewConnection takes a new net.Conn and performs the initial login sequence
-func (s *Server) handleNewConnection(ctx context.Context, conn io.ReadWriteCloser, remoteAddr string) error {
+func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser, remoteAddr string) error {
        defer dontPanic(s.Logger)
 
-       if err := Handshake(conn); err != nil {
+       if err := Handshake(rwc); err != nil {
                return err
        }
 
-       buf := make([]byte, 1024)
-       // TODO: fix potential short read with io.ReadFull
-       readLen, err := conn.Read(buf)
-       if readLen < minTransactionLen {
-               return err
-       }
-       if err != nil {
-               return err
-       }
+       // Create a new scanner for parsing incoming bytes into transaction tokens
+       scanner := bufio.NewScanner(rwc)
+       scanner.Split(transactionScanner)
 
-       clientLogin, _, err := ReadTransaction(buf[:readLen])
-       if err != nil {
+       scanner.Scan()
+
+       // Make a new []byte slice and copy the scanner bytes to it.  This is critical to avoid a data race as the
+       // scanner re-uses the buffer for subsequent scans.
+       buf := make([]byte, len(scanner.Bytes()))
+       copy(buf, scanner.Bytes())
+
+       var clientLogin Transaction
+       if _, err := clientLogin.Write(buf); err != nil {
                return err
        }
 
-       c := s.NewClientConn(conn, remoteAddr)
+       c := s.NewClientConn(rwc, remoteAddr)
+
+       // check if remoteAddr is present in the ban list
+       if banUntil, ok := s.banList[strings.Split(remoteAddr, ":")[0]]; ok {
+               // permaban
+               if banUntil == nil {
+                       s.outbox <- *NewTransaction(
+                               tranServerMsg,
+                               c.ID,
+                               NewField(fieldData, []byte("You are permanently banned on this server")),
+                               NewField(fieldChatOptions, []byte{0, 0}),
+                       )
+                       time.Sleep(1 * time.Second)
+                       return nil
+               } else if time.Now().Before(*banUntil) {
+                       s.outbox <- *NewTransaction(
+                               tranServerMsg,
+                               c.ID,
+                               NewField(fieldData, []byte("You are temporarily banned on this server")),
+                               NewField(fieldChatOptions, []byte{0, 0}),
+                       )
+                       time.Sleep(1 * time.Second)
+                       return nil
+               }
+
+       }
        defer c.Disconnect()
 
        encodedLogin := clientLogin.GetField(fieldUserLogin).Data
        encodedPassword := clientLogin.GetField(fieldUserPassword).Data
-       *c.Version = clientLogin.GetField(fieldVersion).Data
+       c.Version = clientLogin.GetField(fieldVersion).Data
 
        var login string
        for _, char := range encodedLogin {
@@ -568,109 +615,115 @@ func (s *Server) handleNewConnection(ctx context.Context, conn io.ReadWriteClose
                login = GuestAccount
        }
 
+       c.logger = s.Logger.With("remoteAddr", remoteAddr, "login", login)
+
        // If authentication fails, send error reply and close connection
        if !c.Authenticate(login, encodedPassword) {
-               t := c.NewErrReply(clientLogin, "Incorrect login.")
+               t := c.NewErrReply(&clientLogin, "Incorrect login.")
                b, err := t.MarshalBinary()
                if err != nil {
                        return err
                }
-               if _, err := conn.Write(b); err != nil {
+               if _, err := rwc.Write(b); err != nil {
                        return err
                }
-               return fmt.Errorf("incorrect login")
-       }
 
-       if clientLogin.GetField(fieldUserName).Data != nil {
-               c.UserName = clientLogin.GetField(fieldUserName).Data
+               c.logger.Infow("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version))
+
+               return nil
        }
 
        if clientLogin.GetField(fieldUserIconID).Data != nil {
-               *c.Icon = clientLogin.GetField(fieldUserIconID).Data
+               c.Icon = clientLogin.GetField(fieldUserIconID).Data
        }
 
        c.Account = c.Server.Accounts[login]
 
-       if c.Authorize(accessDisconUser) {
-               *c.Flags = []byte{0, 2}
+       if clientLogin.GetField(fieldUserName).Data != nil {
+               if c.Authorize(accessAnyName) {
+                       c.UserName = clientLogin.GetField(fieldUserName).Data
+               } else {
+                       c.UserName = []byte(c.Account.Name)
+               }
        }
 
-       c.logger = s.Logger.With("remoteAddr", remoteAddr, "login", login)
-
-       c.logger.Infow("Client connection received", "version", fmt.Sprintf("%x", *c.Version))
+       if c.Authorize(accessDisconUser) {
+               c.Flags = []byte{0, 2}
+       }
 
-       s.outbox <- c.NewReply(clientLogin,
+       s.outbox <- c.NewReply(&clientLogin,
                NewField(fieldVersion, []byte{0x00, 0xbe}),
                NewField(fieldCommunityBannerID, []byte{0, 0}),
                NewField(fieldServerName, []byte(s.Config.Name)),
        )
 
        // Send user access privs so client UI knows how to behave
-       c.Server.outbox <- *NewTransaction(tranUserAccess, c.ID, NewField(fieldUserAccess, *c.Account.Access))
-
-       // Show agreement to client
-       c.Server.outbox <- *NewTransaction(tranShowAgreement, c.ID, NewField(fieldData, s.Agreement))
+       c.Server.outbox <- *NewTransaction(tranUserAccess, c.ID, NewField(fieldUserAccess, c.Account.Access[:]))
+
+       // Accounts with accessNoAgreement do not receive the server agreement on login.  The behavior is different between
+       // client versions.  For 1.2.3 client, we do not send tranShowAgreement.  For other client versions, we send
+       // tranShowAgreement but with the NoServerAgreement field set to 1.
+       if c.Authorize(accessNoAgreement) {
+               // If client version is nil, then the client uses the 1.2.3 login behavior
+               if c.Version != nil {
+                       c.Server.outbox <- *NewTransaction(tranShowAgreement, c.ID, NewField(fieldNoServerAgreement, []byte{1}))
+               }
+       } else {
+               c.Server.outbox <- *NewTransaction(tranShowAgreement, c.ID, NewField(fieldData, s.Agreement))
+       }
 
        // Used simplified hotline v1.2.3 login flow for clients that do not send login info in tranAgreed
-       if *c.Version == nil || bytes.Equal(*c.Version, nostalgiaVersion) {
+       if c.Version == nil || bytes.Equal(c.Version, nostalgiaVersion) || bytes.Equal(c.Version, frogblastVersion) {
                c.Agreed = true
                c.logger = c.logger.With("name", string(c.UserName))
+               c.logger.Infow("Login successful", "clientVersion", fmt.Sprintf("%v", func() int { i, _ := byteToInt(c.Version); return i }()))
 
                for _, t := range c.notifyOthers(
                        *NewTransaction(
                                tranNotifyChangeUser, nil,
                                NewField(fieldUserName, c.UserName),
                                NewField(fieldUserID, *c.ID),
-                               NewField(fieldUserIconID, *c.Icon),
-                               NewField(fieldUserFlags, *c.Flags),
+                               NewField(fieldUserIconID, c.Icon),
+                               NewField(fieldUserFlags, c.Flags),
                        ),
                ) {
                        c.Server.outbox <- t
                }
        }
 
-       c.Server.Stats.LoginCount += 1
+       c.Server.Stats.ConnectionCounter += 1
+       if len(s.Clients) > c.Server.Stats.ConnectionPeak {
+               c.Server.Stats.ConnectionPeak = len(s.Clients)
+       }
 
-       const readBuffSize = 1024000 // 1KB - TODO: what should this be?
-       tranBuff := make([]byte, 0)
-       tReadlen := 0
-       // Infinite loop where take action on incoming client requests until the connection is closed
-       for {
-               buf = make([]byte, readBuffSize)
-               tranBuff = tranBuff[tReadlen:]
+       // Scan for new transactions and handle them as they come in.
+       for scanner.Scan() {
+               // Make a new []byte slice and copy the scanner bytes to it.  This is critical to avoid a data race as the
+               // scanner re-uses the buffer for subsequent scans.
+               buf := make([]byte, len(scanner.Bytes()))
+               copy(buf, scanner.Bytes())
 
-               readLen, err := c.Connection.Read(buf)
-               if err != nil {
+               var t Transaction
+               if _, err := t.Write(buf); err != nil {
                        return err
                }
-               tranBuff = append(tranBuff, buf[:readLen]...)
 
-               // We may have read multiple requests worth of bytes from Connection.Read.  readTransactions splits them
-               // into a slice of transactions
-               var transactions []Transaction
-               if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
+               if err := c.handleTransaction(t); err != nil {
                        c.logger.Errorw("Error handling transaction", "err", err)
                }
-
-               // iterate over all the transactions that were parsed from the byte slice and handle them
-               for _, t := range transactions {
-                       if err := c.handleTransaction(&t); err != nil {
-                               c.logger.Errorw("Error handling transaction", "err", err)
-                       }
-               }
        }
+       return nil
 }
 
 func (s *Server) NewPrivateChat(cc *ClientConn) []byte {
-       s.mux.Lock()
-       defer s.mux.Unlock()
+       s.PrivateChatsMu.Lock()
+       defer s.PrivateChatsMu.Unlock()
 
        randID := make([]byte, 4)
        rand.Read(randID)
        data := binary.BigEndian.Uint32(randID[:])
 
        s.PrivateChats[data] = &PrivateChat{
-               Subject:    "",
                ClientConn: make(map[uint16]*ClientConn),
        }
        s.PrivateChats[data].ClientConn[cc.uint16ID()] = cc
@@ -701,6 +754,10 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
                delete(s.fileTransfers, t.ReferenceNumber)
                s.mux.Unlock()
 
+               // Wait a few seconds before closing the connection: this is a workaround for problems
+               // observed with Windows clients where the client must initiate close of the TCP connection before
+               // the server does.  This is gross and seems unnecessary.  TODO: Revisit?
+               time.Sleep(3 * time.Second)
        }()
 
        s.mux.Lock()
@@ -730,11 +787,14 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
        switch fileTransfer.Type {
        case bannerDownload:
                if err := s.bannerDownload(rwc); err != nil {
-                       panic(err)
                        return err
                }
        case FileDownload:
                s.Stats.DownloadCounter += 1
+               s.Stats.DownloadsInProgress += 1
+               defer func() {
+                       s.Stats.DownloadsInProgress -= 1
+               }()
 
                var dataOffset int64
                if fileTransfer.fileResumeData != nil {
@@ -789,6 +849,8 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
 
        case FileUpload:
                s.Stats.UploadCounter += 1
+               s.Stats.UploadsInProgress += 1
+               defer func() { s.Stats.UploadsInProgress -= 1 }()
 
                var file *os.File
 
@@ -845,7 +907,12 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
                }
 
                rLogger.Infow("File upload complete", "dstFile", fullPath)
+
        case FolderDownload:
+               s.Stats.DownloadCounter += 1
+               s.Stats.DownloadsInProgress += 1
+               defer func() { s.Stats.DownloadsInProgress -= 1 }()
+
                // Folder Download flow:
                // 1. Get filePath from the transfer
                // 2. Iterate over files
@@ -1009,6 +1076,9 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
                }
 
        case FolderUpload:
+               s.Stats.UploadCounter += 1
+               s.Stats.UploadsInProgress += 1
+               defer func() { s.Stats.UploadsInProgress -= 1 }()
                rLogger.Infow(
                        "Folder upload started",
                        "dstPath", fullPath,