aboutsummaryrefslogtreecommitdiff
path: root/hotline/server.go
diff options
context:
space:
mode:
Diffstat (limited to 'hotline/server.go')
-rw-r--r--hotline/server.go277
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(