diff options
| -rw-r--r-- | hotline/server.go | 173 |
1 files changed, 120 insertions, 53 deletions
diff --git a/hotline/server.go b/hotline/server.go index a521a6b..fb75039 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -8,8 +8,6 @@ import ( "encoding/binary" "errors" "fmt" - "golang.org/x/text/encoding/charmap" - "golang.org/x/time/rate" "io" "log" "log/slog" @@ -18,6 +16,10 @@ import ( "strings" "sync" "time" + + "github.com/redis/go-redis/v9" + "golang.org/x/text/encoding/charmap" + "golang.org/x/time/rate" ) type contextKey string @@ -64,6 +66,8 @@ type Server struct { BanList BanMgr MessageBoard io.ReadWriteSeeker + + Redis *redis.Client } type Option = func(s *Server) @@ -269,33 +273,57 @@ func (s *Server) registerWithTrackers(ctx context.Context) { s.Logger.Info("Tracker registration enabled", "trackers", s.Config.Trackers) } + // Do the first registration immediately + if s.Config.EnableTrackerRegistration { + for _, t := range s.Config.Trackers { + tr := &TrackerRegistration{ + UserCount: len(s.ClientMgr.List()), + PassID: s.TrackerPassID, + Name: s.Config.Name, + Description: s.Config.Description, + } + binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) + + splitAddr := strings.Split(":", t) + if len(splitAddr) == 3 { + tr.Password = splitAddr[2] + } + + if err := register(&RealDialer{}, t, tr); err != nil { + s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) + } + } + } + + ticker := time.NewTicker(trackerUpdateFrequency * time.Second) + defer ticker.Stop() + for { - if s.Config.EnableTrackerRegistration { - for _, t := range s.Config.Trackers { - tr := &TrackerRegistration{ - UserCount: len(s.ClientMgr.List()), - PassID: s.TrackerPassID, - Name: s.Config.Name, - Description: s.Config.Description, - } - binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) + select { + case <-ctx.Done(): + return + case <-ticker.C: + if s.Config.EnableTrackerRegistration { + for _, t := range s.Config.Trackers { + tr := &TrackerRegistration{ + UserCount: len(s.ClientMgr.List()), + PassID: s.TrackerPassID, + Name: s.Config.Name, + Description: s.Config.Description, + } + binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) - // Check the tracker string for a password. This is janky but avoids a breaking change to the Config - // Trackers field. - splitAddr := strings.Split(":", t) - if len(splitAddr) == 3 { - tr.Password = splitAddr[2] - } + splitAddr := strings.Split(":", t) + if len(splitAddr) == 3 { + tr.Password = splitAddr[2] + } - if err := register(&RealDialer{}, t, tr); err != nil { - s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) + if err := register(&RealDialer{}, t, tr); err != nil { + s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) + } } } } - // Using time.Ticker with for/select would be more idiomatic, but it's super annoying that it doesn't tick on - // first pass. Revist, maybe. - // https://github.com/golang/go/issues/17601 - time.Sleep(trackerUpdateFrequency * time.Second) } } @@ -372,24 +400,6 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser return fmt.Errorf("perform handshake: %w", err) } - // Check if remoteAddr is present in the ban list - ipAddr := strings.Split(remoteAddr, ":")[0] - if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned { - // permaban - if banUntil == nil { - sendBanMessage(rwc, "You are permanently banned on this server") - s.Logger.Debug("Disconnecting permanently banned IP", "remoteAddr", ipAddr) - return nil - } - - // temporary ban - if time.Now().Before(*banUntil) { - sendBanMessage(rwc, "You are temporarily banned on this server") - s.Logger.Debug("Disconnecting temporarily banned IP", "remoteAddr", ipAddr) - return nil - } - } - // Create a new scanner for parsing incoming bytes into transaction tokens scanner := bufio.NewScanner(rwc) scanner.Split(transactionScanner) @@ -406,17 +416,66 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser return fmt.Errorf("error writing login transaction: %w", err) } - c := s.NewClientConn(rwc, remoteAddr) - defer c.Disconnect() - - encodedPassword := clientLogin.GetField(FieldUserPassword).Data - c.Version = clientLogin.GetField(FieldVersion).Data - login := clientLogin.GetField(FieldUserLogin).DecodeObfuscatedString() if login == "" { login = GuestAccount } + // Check if remoteAddr is present in the ban list, we do this after we have the login name + ipAddr := strings.Split(remoteAddr, ":")[0] + if s.Redis != nil { + // Redis-based ban check + bannedUser, _ := s.Redis.SIsMember(ctx, "mobius:banned:users", login).Result() + bannedIP, _ := s.Redis.SIsMember(ctx, "mobius:banned:ips", ipAddr).Result() + if bannedUser { + s.Redis.SAdd(ctx, "mobius:banned:ips", ipAddr) + sendBanMessage(rwc, "You are banned on this server") + s.Logger.Debug("Disconnecting banned user", "login", login, "ip", ipAddr) + return nil + } + if bannedIP { + sendBanMessage(rwc, "You are banned on this server") + s.Logger.Debug("Disconnecting banned IP", "ip", ipAddr) + return nil + } + } else { + // Fallback to in-memory ban list + if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned { + // permaban + if banUntil == nil { + sendBanMessage(rwc, "You are permanently banned on this server") + s.Logger.Debug("Disconnecting permanently banned IP", "remoteAddr", ipAddr) + return nil + } + // temporary ban + if time.Now().Before(*banUntil) { + sendBanMessage(rwc, "You are temporarily banned on this server") + s.Logger.Debug("Disconnecting temporarily banned IP", "remoteAddr", ipAddr) + return nil + } + } + } + + c := s.NewClientConn(rwc, remoteAddr) + // Add the client to the list of connected clients + if s.Redis != nil { + s.Redis.SAdd(context.Background(), "mobius:online", login+"::"+ipAddr) + } + + // Remove the client from the list of connected clients when they disconnect + defer func() { + if s.Redis != nil { + s.Redis.SRem(context.Background(), "mobius:online", login+"::"+ipAddr) + if len(c.UserName) != 0 { + s.Redis.SRem(context.Background(), "mobius:online", login+":"+string(c.UserName)+":"+ipAddr) + } + } + c.Disconnect() + }() + + encodedPassword := clientLogin.GetField(FieldUserPassword).Data + c.Version = clientLogin.GetField(FieldVersion).Data + c.Logger = s.Logger.With("ip", ipAddr, "login", login) // If authentication fails, send error reply and close connection @@ -486,6 +545,14 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser c.Logger = c.Logger.With("name", string(c.UserName)) c.Logger.Info("Login successful") + // Update the Redis set with the new information + if s.Redis != nil && len(c.UserName) != 0 { + // Remove old entry (login::ip) + s.Redis.SRem(context.Background(), "mobius:online", login+"::"+ipAddr) + // Add new entry with login, nickname, ip + s.Redis.SAdd(context.Background(), "mobius:online", login+":"+string(c.UserName)+":"+ipAddr) + } + // Notify other clients on the server that the new user has logged in. For 1.5+ clients we don't have this // information yet, so we do it in TranAgreed instead for _, t := range c.NotifyOthers( @@ -605,12 +672,12 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro var transferSizeValue uint32 switch len(fileTransfer.TransferSize) { - case 2: // 16-bit - transferSizeValue = uint32(binary.BigEndian.Uint16(fileTransfer.TransferSize)) - case 4: // 32-bit - transferSizeValue = binary.BigEndian.Uint32(fileTransfer.TransferSize) - default: - rLogger.Warn("Unexpected TransferSize length: %d bytes", len(fileTransfer.TransferSize)) + case 2: // 16-bit + transferSizeValue = uint32(binary.BigEndian.Uint16(fileTransfer.TransferSize)) + case 4: // 32-bit + transferSizeValue = binary.BigEndian.Uint32(fileTransfer.TransferSize) + default: + rLogger.Warn("Unexpected TransferSize length", "bytes", len(fileTransfer.TransferSize)) } rLogger.Info( |