diff options
Diffstat (limited to 'hotline/server.go')
| -rw-r--r-- | hotline/server.go | 277 |
1 files changed, 209 insertions, 68 deletions
diff --git a/hotline/server.go b/hotline/server.go index a521a6b..9b74d99 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -5,11 +5,10 @@ import ( "bytes" "context" "crypto/rand" + "crypto/tls" "encoding/binary" "errors" "fmt" - "golang.org/x/text/encoding/charmap" - "golang.org/x/time/rate" "io" "log" "log/slog" @@ -18,6 +17,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 @@ -38,7 +41,8 @@ type Server struct { NetInterface string Port int - rateLimiters map[string]*rate.Limiter + rateLimiters map[string]*rate.Limiter + rateLimitersMu sync.Mutex handlers map[TranType]HandlerFunc @@ -64,6 +68,14 @@ type Server struct { BanList BanMgr MessageBoard io.ReadWriteSeeker + + Redis *redis.Client + + // TrackerRegistrar handles tracker registration (injectable for testing) + TrackerRegistrar TrackerRegistrar + + TLSConfig *tls.Config + TLSPort int } type Option = func(s *Server) @@ -94,19 +106,35 @@ func WithInterface(netInterface string) func(s *Server) { } } +// WithTrackerRegistrar optionally sets a custom tracker registrar (useful for testing). +func WithTrackerRegistrar(registrar TrackerRegistrar) func(s *Server) { + return func(s *Server) { + s.TrackerRegistrar = registrar + } +} + +// WithTLS optionally enables TLS support on the specified port. +func WithTLS(tlsConfig *tls.Config, port int) func(s *Server) { + return func(s *Server) { + s.TLSConfig = tlsConfig + s.TLSPort = port + } +} + type ServerConfig struct { } func NewServer(options ...Option) (*Server, error) { server := Server{ - handlers: make(map[TranType]HandlerFunc), - outbox: make(chan Transaction), - rateLimiters: make(map[string]*rate.Limiter), - FS: &OSFileStore{}, - ChatMgr: NewMemChatManager(), - ClientMgr: NewMemClientMgr(), - FileTransferMgr: NewMemFileTransferMgr(), - Stats: NewStats(), + handlers: make(map[TranType]HandlerFunc), + outbox: make(chan Transaction), + rateLimiters: make(map[string]*rate.Limiter), + FS: &OSFileStore{}, + ChatMgr: NewMemChatManager(), + ClientMgr: NewMemClientMgr(), + FileTransferMgr: NewMemFileTransferMgr(), + Stats: NewStats(), + TrackerRegistrar: NewRealTrackerRegistrar(), } for _, opt := range options { @@ -153,6 +181,28 @@ func (s *Server) ListenAndServe(ctx context.Context) error { log.Fatal(s.ServeFileTransfers(ctx, ln)) }() + if s.TLSConfig != nil { + wg.Add(1) + go func() { + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", s.NetInterface, s.TLSPort)) + if err != nil { + log.Fatal(err) + } + + log.Fatal(s.ServeWithTLS(ctx, ln)) + }() + + wg.Add(1) + go func() { + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", s.NetInterface, s.TLSPort+1)) + if err != nil { + log.Fatal(err) + } + + log.Fatal(s.ServeFileTransfersWithTLS(ctx, ln)) + }() + } + wg.Wait() return nil @@ -180,6 +230,14 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error } } +func (s *Server) ServeWithTLS(ctx context.Context, ln net.Listener) error { + return s.Serve(ctx, tls.NewListener(ln, s.TLSConfig)) +} + +func (s *Server) ServeFileTransfersWithTLS(ctx context.Context, ln net.Listener) error { + return s.ServeFileTransfers(ctx, tls.NewListener(ln, s.TLSConfig)) +} + func (s *Server) sendTransaction(t Transaction) error { client := s.ClientMgr.Get(t.ClientID) @@ -224,26 +282,28 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { } go func() { - ipAddr := strings.Split(conn.RemoteAddr().(*net.TCPAddr).String(), ":")[0] + ipAddr, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{ remoteAddr: conn.RemoteAddr().String(), }) s.Logger.Info("Connection established", "ip", ipAddr) - defer conn.Close() + defer func() { _ = conn.Close() }() // Check if we have an existing rate limit for the IP and create one if we do not. + s.rateLimitersMu.Lock() rl, ok := s.rateLimiters[ipAddr] if !ok { rl = rate.NewLimiter(perIPRateLimit, 1) s.rateLimiters[ipAddr] = rl } + s.rateLimitersMu.Unlock() // Check if the rate limit is exceeded and close the connection if so. if !rl.Allow() { s.Logger.Info("Rate limit exceeded", "RemoteAddr", conn.RemoteAddr()) - conn.Close() + _ = conn.Close() return } @@ -262,6 +322,62 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { // time in seconds between tracker re-registration const trackerUpdateFrequency = 300 +// TrackerRegistrar interface for tracker registration operations +type TrackerRegistrar interface { + Register(tracker string, registration *TrackerRegistration) error +} + +// RealTrackerRegistrar implements TrackerRegistrar using the real network operations +type RealTrackerRegistrar struct { + dialer Dialer +} + +func NewRealTrackerRegistrar() *RealTrackerRegistrar { + return &RealTrackerRegistrar{ + dialer: &RealDialer{}, + } +} + +func (r *RealTrackerRegistrar) Register(tracker string, registration *TrackerRegistration) error { + return register(r.dialer, tracker, registration) +} + +// parseTrackerPassword extracts the password from a tracker address in format "host:port:password" +// Returns empty string if no password is present or if the format is invalid +// For addresses with more than 3 parts (like passwords containing colons), everything after the second colon is treated as the password +func parseTrackerPassword(trackerAddr string) string { + splitAddr := strings.Split(trackerAddr, ":") + if len(splitAddr) >= 3 { + // Join everything from the third part onwards (index 2+) to handle passwords with colons + return strings.Join(splitAddr[2:], ":") + } + return "" +} + +// registerWithAllTrackers performs tracker registration for all configured trackers +func (s *Server) registerWithAllTrackers() { + if !s.Config.EnableTrackerRegistration { + return + } + + 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)) + binary.BigEndian.PutUint16(tr.TLSPort[:], uint16(s.TLSPort)) + + tr.Password = parseTrackerPassword(t) + + if err := s.TrackerRegistrar.Register(t, tr); err != nil { + s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) + } + } +} + // registerWithTrackers runs every trackerUpdateFrequency seconds to update the server's tracker entry on all configured // trackers. func (s *Server) registerWithTrackers(ctx context.Context) { @@ -269,33 +385,19 @@ func (s *Server) registerWithTrackers(ctx context.Context) { s.Logger.Info("Tracker registration enabled", "trackers", s.Config.Trackers) } - 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)) + // Do the first registration immediately + s.registerWithAllTrackers() - // 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] - } + ticker := time.NewTicker(trackerUpdateFrequency * time.Second) + defer ticker.Stop() - if err := register(&RealDialer{}, t, tr); err != nil { - s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) - } - } + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.registerWithAllTrackers() } - // 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 +474,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 +490,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, _, _ := net.SplitHostPort(remoteAddr) + 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 +619,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 +746,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( |