From 3f9221420a57b0b7534a0877925e363855274b1a Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Wed, 5 Feb 2025 15:06:22 +0100 Subject: Account for 16 vs 32 bit integers in folder upload --- hotline/server.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) (limited to 'hotline/server.go') diff --git a/hotline/server.go b/hotline/server.go index ed3c041..a521a6b 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -603,10 +603,20 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro s.Stats.Decrement(StatUploadsInProgress) }() + 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)) + } + rLogger.Info( "Folder upload started", "dstPath", fullPath, - "TransferSize", binary.BigEndian.Uint32(fileTransfer.TransferSize), + "TransferSize", transferSizeValue, "FolderItemCount", fileTransfer.FolderItemCount, ) -- cgit From 24d753e4ecb06c467784732d7159b0db1b48aa9a Mon Sep 17 00:00:00 2001 From: Theo Knez <27211475+Knezzen@users.noreply.github.com> Date: Sat, 10 May 2025 20:36:09 +0200 Subject: Update server.go --- hotline/server.go | 173 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 120 insertions(+), 53 deletions(-) (limited to 'hotline/server.go') 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( -- cgit From d2e125bd255e807aa4d374b23eb9c2bb02fe63a3 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Sun, 29 Jun 2025 17:49:35 -0700 Subject: Fix IPv6 address parsing in IP extraction Replace string splitting with net.SplitHostPort to properly handle both IPv4 and IPv6 addresses. Fixes issue where IPv6 addresses like [::1]:8080 were incorrectly parsed as "[" instead of "::1". --- hotline/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'hotline/server.go') diff --git a/hotline/server.go b/hotline/server.go index fb75039..98b6132 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -228,7 +228,7 @@ 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(), @@ -422,7 +422,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser } // Check if remoteAddr is present in the ban list, we do this after we have the login name - ipAddr := strings.Split(remoteAddr, ":")[0] + ipAddr, _, _ := net.SplitHostPort(remoteAddr) if s.Redis != nil { // Redis-based ban check bannedUser, _ := s.Redis.SIsMember(ctx, "mobius:banned:users", login).Result() -- cgit From ee6629ad78ac62fa14371ea5ddb7474c1fe9c979 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Fri, 4 Jul 2025 17:24:02 -0700 Subject: Fix file handle close warnings by ignoring return values Updated all file close operations to use anonymous functions that ignore return values to satisfy golangci-lint errcheck warnings. --- cmd/mobius-hotline-server/main.go | 4 ++-- cmd/mobius-hotline-server/main_test.go | 2 +- hotline/files_test.go | 2 +- hotline/server.go | 4 ++-- hotline/tracker.go | 2 +- internal/mobius/account_manager.go | 4 ++-- internal/mobius/api.go | 12 ++++++------ internal/mobius/ban.go | 2 +- internal/mobius/ban_test.go | 2 +- internal/mobius/threaded_news.go | 2 +- internal/mobius/threaded_news_test.go | 4 ++-- 11 files changed, 20 insertions(+), 20 deletions(-) (limited to 'hotline/server.go') diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index 1ba11ae..aa41279 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -246,13 +246,13 @@ func copyFile(src, dst string) error { if err != nil { return fmt.Errorf("failed to open source file: %w", err) } - defer srcFile.Close() + defer func() { _ = srcFile.Close() }() dstFile, err := os.Create(dst) if err != nil { return fmt.Errorf("failed to create destination file: %w", err) } - defer dstFile.Close() + defer func() { _ = dstFile.Close() }() if _, err := io.Copy(dstFile, srcFile); err != nil { return fmt.Errorf("failed to copy file contents: %w", err) diff --git a/cmd/mobius-hotline-server/main_test.go b/cmd/mobius-hotline-server/main_test.go index ed63065..dec2ebf 100644 --- a/cmd/mobius-hotline-server/main_test.go +++ b/cmd/mobius-hotline-server/main_test.go @@ -166,7 +166,7 @@ func TestFindConfigPath(t *testing.T) { tmpDir := t.TempDir() originalDir, err := os.Getwd() require.NoError(t, err) - defer os.Chdir(originalDir) + defer func() { _ = os.Chdir(originalDir) }() err = os.Chdir(tmpDir) require.NoError(t, err) diff --git a/hotline/files_test.go b/hotline/files_test.go index 0a7eb7b..9bed670 100644 --- a/hotline/files_test.go +++ b/hotline/files_test.go @@ -148,7 +148,7 @@ func TestCalcItemCount(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tempDir) + defer func() { _ = os.RemoveAll(tempDir) }() // Create the test directory structure if err := createTestDirStructure(tempDir, tt.structure); err != nil { diff --git a/hotline/server.go b/hotline/server.go index 98b6132..19c7acc 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -235,7 +235,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { }) 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. rl, ok := s.rateLimiters[ipAddr] @@ -247,7 +247,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { // 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 } diff --git a/hotline/tracker.go b/hotline/tracker.go index 52963bf..edd973d 100644 --- a/hotline/tracker.go +++ b/hotline/tracker.go @@ -69,7 +69,7 @@ func register(dialer Dialer, tracker string, tr io.Reader) error { if err != nil { return fmt.Errorf("failed to dial tracker: %v", err) } - defer conn.Close() + defer func() { _ = conn.Close() }() if _, err := io.Copy(conn, tr); err != nil { return fmt.Errorf("failed to write to connection: %w", err) diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go index 8859c58..d9169c9 100644 --- a/internal/mobius/account_manager.go +++ b/internal/mobius/account_manager.go @@ -18,7 +18,7 @@ func loadFromYAMLFile(path string, data interface{}) error { if err != nil { return err } - defer fh.Close() + defer func() { _ = fh.Close() }() decoder := yaml.NewDecoder(fh) return decoder.Decode(data) @@ -90,7 +90,7 @@ func (am *YAMLAccountManager) Create(account hotline.Account) error { if err != nil { return fmt.Errorf("create account file: %w", err) } - defer file.Close() + defer func() { _ = file.Close() }() b, err := yaml.Marshal(account) if err != nil { diff --git a/internal/mobius/api.go b/internal/mobius/api.go index 0a20d01..f912f60 100644 --- a/internal/mobius/api.go +++ b/internal/mobius/api.go @@ -125,7 +125,7 @@ func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) { } } - json.NewEncoder(w).Encode(users) + _ = json.NewEncoder(w).Encode(users) } type BanRequest struct { @@ -169,7 +169,7 @@ func (srv *APIServer) BanHandler(w http.ResponseWriter, r *http.Request) { } } - w.Write([]byte(`{"msg":"banned"}`)) + _, _ = w.Write([]byte(`{"msg":"banned"}`)) } func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) { @@ -198,7 +198,7 @@ func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) { // TODO: Fallback } - w.Write([]byte(`{"msg":"unbanned"}`)) + _, _ = w.Write([]byte(`{"msg":"unbanned"}`)) } func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Request) { @@ -208,7 +208,7 @@ func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Reques http.Error(w, "failed to fetch banned IPs", http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(ips) + _ = json.NewEncoder(w).Encode(ips) } else { // TODO: Fallback } @@ -221,7 +221,7 @@ func (srv *APIServer) ListBannedUsernamesHandler(w http.ResponseWriter, r *http. http.Error(w, "failed to fetch banned usernames", http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(users) + _ = json.NewEncoder(w).Encode(users) } else { // TODO: Fallback } @@ -234,7 +234,7 @@ func (srv *APIServer) ListBannedNicknamesHandler(w http.ResponseWriter, r *http. http.Error(w, "failed to fetch banned nicknames", http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(nicks) + _ = json.NewEncoder(w).Encode(nicks) } else { // TODO: Fallback } diff --git a/internal/mobius/ban.go b/internal/mobius/ban.go index 781052b..b4fde95 100644 --- a/internal/mobius/ban.go +++ b/internal/mobius/ban.go @@ -43,7 +43,7 @@ func (bf *BanFile) Load() error { if err != nil { return fmt.Errorf("open file: %v", err) } - defer fh.Close() + defer func() { _ = fh.Close() }() err = yaml.NewDecoder(fh).Decode(&bf.banList) if err != nil { diff --git a/internal/mobius/ban_test.go b/internal/mobius/ban_test.go index 1bf68a4..9f1f5d8 100644 --- a/internal/mobius/ban_test.go +++ b/internal/mobius/ban_test.go @@ -52,7 +52,7 @@ func TestAdd(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } - defer os.RemoveAll(tmpDir) // Clean up the temporary directory. + defer func() { _ = os.RemoveAll(tmpDir) }() // Clean up the temporary directory. // Path to the temporary ban file. tmpFilePath := path.Join(tmpDir, "banfile.yaml") diff --git a/internal/mobius/threaded_news.go b/internal/mobius/threaded_news.go index 67e8282..c7daea4 100644 --- a/internal/mobius/threaded_news.go +++ b/internal/mobius/threaded_news.go @@ -218,7 +218,7 @@ func (n *ThreadedNewsYAML) Load() error { if err != nil { return err } - defer fh.Close() + defer func() { _ = fh.Close() }() n.ThreadedNews = hotline.ThreadedNews{} diff --git a/internal/mobius/threaded_news_test.go b/internal/mobius/threaded_news_test.go index 7f5cdaa..2ff8a84 100644 --- a/internal/mobius/threaded_news_test.go +++ b/internal/mobius/threaded_news_test.go @@ -52,7 +52,7 @@ func TestLoadFromYAMLFile(t *testing.T) { if tt.content != "" { err := os.WriteFile(tt.fileName, []byte(tt.content), 0644) assert.NoError(t, err) - defer os.Remove(tt.fileName) // Cleanup the file after the test + defer func() { _ = os.Remove(tt.fileName) }() // Cleanup the file after the test } var data TestData @@ -161,7 +161,7 @@ func TestThreadedNewsYAML_CreateGrouping(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } - defer os.RemoveAll(tmpDir) // Clean up the temporary directory. + defer func() { _ = os.RemoveAll(tmpDir) }() // Clean up the temporary directory. // Path to the temporary ban file. tmpFilePath := path.Join(tmpDir, "ThreadedNews.yaml") -- cgit From cab4e2192d5b39e51933ced3dd569de9fc182a04 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Sat, 5 Jul 2025 16:02:33 -0700 Subject: Fix string splitting bug and add comprehensive tracker registration tests - Fix critical bug in tracker address parsing: strings.Split(t, ":") instead of strings.Split(":", t) - Add dependency injection for TrackerRegistrar to improve testability - Extract registerWithAllTrackers() helper function to eliminate code duplication - Add comprehensive test suite covering: * Unit tests for parseTrackerPassword with edge cases and special characters * Integration tests for registerWithTrackers with mocking * Context cancellation and graceful shutdown testing * Error handling for network failures and malformed addresses * Edge cases like zero ports, long names, and empty configurations - Improve password parsing to handle colons in passwords correctly - Add MockTrackerRegistrar for isolated testing without network dependencies The string splitting bug would have prevented tracker registration from working with password-protected trackers. All tests pass and verify the fix works correctly. --- hotline/server.go | 124 +++++++---- hotline/server_test.go | 583 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 659 insertions(+), 48 deletions(-) (limited to 'hotline/server.go') diff --git a/hotline/server.go b/hotline/server.go index 19c7acc..0395d7b 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -68,6 +68,9 @@ type Server struct { MessageBoard io.ReadWriteSeeker Redis *redis.Client + + // TrackerRegistrar handles tracker registration (injectable for testing) + TrackerRegistrar TrackerRegistrar } type Option = func(s *Server) @@ -98,19 +101,27 @@ 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 + } +} + 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 { @@ -266,6 +277,61 @@ 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)) + + 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) { @@ -274,26 +340,7 @@ func (s *Server) registerWithTrackers(ctx context.Context) { } // 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) - } - } - } + s.registerWithAllTrackers() ticker := time.NewTicker(trackerUpdateFrequency * time.Second) defer ticker.Stop() @@ -303,26 +350,7 @@ func (s *Server) registerWithTrackers(ctx context.Context) { 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)) - - 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) - } - } - } + s.registerWithAllTrackers() } } } diff --git a/hotline/server_test.go b/hotline/server_test.go index d0e2372..574aae7 100644 --- a/hotline/server_test.go +++ b/hotline/server_test.go @@ -3,12 +3,15 @@ package hotline import ( "bytes" "context" + "encoding/binary" "fmt" "github.com/stretchr/testify/assert" "io" "log/slog" "os" + "strings" "testing" + "time" ) type mockReadWriter struct { @@ -181,3 +184,583 @@ func TestServer_handleFileTransfer(t *testing.T) { }) } } + +func TestParseTrackerPassword(t *testing.T) { + tests := []struct { + name string + trackerAddr string + wantPassword string + }{ + { + name: "tracker address with password", + trackerAddr: "tracker.example.com:5500:mypassword", + wantPassword: "mypassword", + }, + { + name: "tracker address without password", + trackerAddr: "tracker.example.com:5500", + wantPassword: "", + }, + { + name: "tracker address with empty password", + trackerAddr: "tracker.example.com:5500:", + wantPassword: "", + }, + { + name: "tracker address with password containing special characters", + trackerAddr: "tracker.example.com:5500:pass@word#123", + wantPassword: "pass@word#123", + }, + { + name: "tracker address with password containing colons", + trackerAddr: "tracker.example.com:5500:pass:word:123", + wantPassword: "pass:word:123", + }, + { + name: "IPv4 address with password", + trackerAddr: "192.168.1.100:5500:secret", + wantPassword: "secret", + }, + { + name: "IPv4 address without password", + trackerAddr: "192.168.1.100:5500", + wantPassword: "", + }, + { + name: "malformed address - no port", + trackerAddr: "tracker.example.com", + wantPassword: "", + }, + { + name: "malformed address - empty string", + trackerAddr: "", + wantPassword: "", + }, + { + name: "malformed address - only colons", + trackerAddr: ":::", + wantPassword: ":", + }, + { + name: "IPv6 address handling (edge case - not properly supported)", + trackerAddr: "[::1]:5500:password", + wantPassword: "1]:5500:password", // IPv6 addresses aren't properly handled by simple colon splitting + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTrackerPassword(tt.trackerAddr) + assert.Equal(t, tt.wantPassword, got) + }) + } +} + +// MockTrackerRegistrar is a mock implementation of TrackerRegistrar for testing +type MockTrackerRegistrar struct { + RegisterCalls []RegisterCall + RegisterFunc func(tracker string, registration *TrackerRegistration) error +} + +type RegisterCall struct { + Tracker string + Registration *TrackerRegistration +} + +func (m *MockTrackerRegistrar) Register(tracker string, registration *TrackerRegistration) error { + // Record the call + m.RegisterCalls = append(m.RegisterCalls, RegisterCall{ + Tracker: tracker, + Registration: registration, + }) + + // Use custom function if provided, otherwise return nil (success) + if m.RegisterFunc != nil { + return m.RegisterFunc(tracker, registration) + } + return nil +} + +func (m *MockTrackerRegistrar) Reset() { + m.RegisterCalls = nil + m.RegisterFunc = nil +} + +func TestServer_registerWithTrackers(t *testing.T) { + tests := []struct { + name string + config Config + wantImmediateRegistration bool + wantTrackerCalls []string + mockRegisterFunc func(tracker string, registration *TrackerRegistration) error + expectError bool + }{ + { + name: "disabled tracker registration", + config: Config{ + EnableTrackerRegistration: false, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + wantImmediateRegistration: false, + wantTrackerCalls: []string{}, + }, + { + name: "enabled tracker registration with multiple trackers", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + { + name: "enabled tracker registration with empty tracker list", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{}, + }, + { + name: "tracker registration with network errors", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{"tracker1.example.com:5500"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return assert.AnError // Simulate network error + }, + expectError: false, // Errors are logged but don't stop the function + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock registrar + mockRegistrar := &MockTrackerRegistrar{ + RegisterFunc: tt.mockRegisterFunc, + } + + // Create server with mock registrar + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + // Create a context that we can cancel + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start the registerWithTrackers function in a goroutine + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Give it a moment to do the immediate registration + time.Sleep(100 * time.Millisecond) + + // Cancel the context to stop the goroutine + cancel() + + // Wait for the goroutine to finish (should be quick after cancellation) + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify the calls made to the mock registrar + assert.Len(t, mockRegistrar.RegisterCalls, len(tt.wantTrackerCalls)) + + for i, expectedTracker := range tt.wantTrackerCalls { + if i < len(mockRegistrar.RegisterCalls) { + call := mockRegistrar.RegisterCalls[i] + assert.Equal(t, expectedTracker, call.Tracker) + assert.Equal(t, tt.config.Name, call.Registration.Name) + assert.Equal(t, tt.config.Description, call.Registration.Description) + assert.Equal(t, parseTrackerPassword(expectedTracker), call.Registration.Password) + } + } + }) + } +} + +func TestServer_registerWithTrackers_ContextCancellation(t *testing.T) { + tests := []struct { + name string + cancelAfter time.Duration + expectedCalls int // Number of expected registration calls before cancellation + trackerCount int + }{ + { + name: "immediate cancellation", + cancelAfter: 10 * time.Millisecond, + expectedCalls: 2, // Should complete immediate registration + trackerCount: 2, + }, + { + name: "cancellation after first ticker", + cancelAfter: 100 * time.Millisecond, + expectedCalls: 2, // Should only do immediate registration within 100ms + trackerCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + config := Config{ + EnableTrackerRegistration: true, + Trackers: make([]string, tt.trackerCount), + Name: "Test Server", + Description: "Test Description", + } + + // Fill trackers array + for i := 0; i < tt.trackerCount; i++ { + config.Trackers[i] = fmt.Sprintf("tracker%d.example.com:5500", i+1) + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Wait for the specified time then cancel + time.Sleep(tt.cancelAfter) + cancel() + + // Wait for graceful shutdown + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify that the function respects context cancellation + assert.Equal(t, tt.expectedCalls, len(mockRegistrar.RegisterCalls)) + }) + } +} + +func TestServer_registerWithTrackers_PeriodicRegistration(t *testing.T) { + t.Skip("Skipping timing-sensitive test - would take 5+ minutes to run reliably") + + // This test would verify that periodic re-registration happens every trackerUpdateFrequency seconds + // but it's impractical to run in normal test suites due to the 300-second interval + + mockRegistrar := &MockTrackerRegistrar{} + config := Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Wait for timeout or completion + <-ctx.Done() + + // Should have done immediate registration only (1 call) in 10 seconds + // since trackerUpdateFrequency is 300 seconds + assert.Equal(t, 1, len(mockRegistrar.RegisterCalls)) +} + +func TestServer_registerWithTrackers_ErrorHandling(t *testing.T) { + tests := []struct { + name string + trackers []string + mockRegisterFunc func(tracker string, registration *TrackerRegistration) error + expectPanic bool + }{ + { + name: "handles network errors gracefully", + trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + if tracker == "tracker1.example.com:5500" { + return fmt.Errorf("network error: connection refused") + } + return nil // Second tracker succeeds + }, + expectPanic: false, + }, + { + name: "handles all trackers failing", + trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return fmt.Errorf("network error") + }, + expectPanic: false, + }, + { + name: "handles empty tracker addresses", + trackers: []string{"", "valid.tracker.com:5500", ""}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + if tracker == "" { + return fmt.Errorf("invalid tracker address") + } + return nil + }, + expectPanic: false, + }, + { + name: "handles malformed tracker addresses", + trackers: []string{"invalid-address", "another:invalid", "valid.tracker.com:5500:password"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return nil // Accept all for this test + }, + expectPanic: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{ + RegisterFunc: tt.mockRegisterFunc, + } + + config := Config{ + EnableTrackerRegistration: true, + Trackers: tt.trackers, + Name: "Test Server", + Description: "Test Description", + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if tt.expectPanic { + assert.Panics(t, func() { + server.registerWithTrackers(ctx) + }) + return + } + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Give it time to process + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + // Success - function completed without panicking + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify all trackers were attempted + assert.Equal(t, len(tt.trackers), len(mockRegistrar.RegisterCalls)) + }) + } +} + +func TestServer_registerWithTrackers_EdgeCases(t *testing.T) { + tests := []struct { + name string + config Config + expectedCalls int + validateResult func(t *testing.T, calls []RegisterCall) + }{ + { + name: "server with zero port", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, uint16(0), binary.BigEndian.Uint16(calls[0].Registration.Port[:])) + }, + }, + { + name: "server with very long name and description", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: strings.Repeat("A", 255), // Max uint8 length + Description: strings.Repeat("B", 255), + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, strings.Repeat("A", 255), calls[0].Registration.Name) + assert.Equal(t, strings.Repeat("B", 255), calls[0].Registration.Description) + }, + }, + { + name: "empty server name and description", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: "", + Description: "", + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, "", calls[0].Registration.Name) + assert.Equal(t, "", calls[0].Registration.Description) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + assert.Equal(t, tt.expectedCalls, len(mockRegistrar.RegisterCalls)) + if tt.validateResult != nil { + tt.validateResult(t, mockRegistrar.RegisterCalls) + } + }) + } +} + +func TestServer_registerWithAllTrackers(t *testing.T) { + tests := []struct { + name string + config Config + expectRegistrationAttempt bool + expectedTrackerCalls []string + }{ + { + name: "disabled tracker registration", + config: Config{ + EnableTrackerRegistration: false, + Trackers: []string{"tracker1.example.com:5500"}, + }, + expectRegistrationAttempt: false, + expectedTrackerCalls: []string{}, + }, + { + name: "enabled tracker registration with multiple trackers", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + Name: "Test Server", + Description: "Test Description", + }, + expectRegistrationAttempt: true, + expectedTrackerCalls: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + { + name: "enabled tracker registration with empty tracker list", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{}, + Name: "Test Server", + Description: "Test Description", + }, + expectRegistrationAttempt: true, + expectedTrackerCalls: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + // Call the extracted function directly + server.registerWithAllTrackers() + + // Verify the expected number of calls + assert.Equal(t, len(tt.expectedTrackerCalls), len(mockRegistrar.RegisterCalls)) + + // Verify each call + for i, expectedTracker := range tt.expectedTrackerCalls { + if i < len(mockRegistrar.RegisterCalls) { + call := mockRegistrar.RegisterCalls[i] + assert.Equal(t, expectedTracker, call.Tracker) + assert.Equal(t, tt.config.Name, call.Registration.Name) + assert.Equal(t, tt.config.Description, call.Registration.Description) + assert.Equal(t, parseTrackerPassword(expectedTracker), call.Registration.Password) + } + } + }) + } +} -- cgit From 357baa94bf9b1d4b623f8a9c04b9d591e9f60406 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:42:32 -0800 Subject: Add optional TLS support for encrypted client connections - Add TLSConfig and TLSPort fields to Server struct - Add WithTLS option function for configuration - Add ServeWithTLS and ServeFileTransfersWithTLS methods - Update ListenAndServe to start TLS listeners when configured - Add -tls-cert, -tls-key, -tls-port command-line flags - Fix data race in rateLimiters map access with mutex - Add TLS documentation with certificate generation instructions --- cmd/mobius-hotline-server/main.go | 26 +++++++++- docs/tls.md | 101 ++++++++++++++++++++++++++++++++++++++ hotline/server.go | 47 +++++++++++++++++- 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 docs/tls.md (limited to 'hotline/server.go') diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index aa41279..cc24f5f 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/tls" "embed" "flag" "fmt" @@ -44,6 +45,9 @@ func main() { logLevel := flag.String("log-level", "info", "Log level") logFile := flag.String("log-file", "", "Path to log file") init := flag.Bool("init", false, "Populate the config dir with default configuration") + tlsCert := flag.String("tls-cert", "", "Path to TLS certificate file") + tlsKey := flag.String("tls-key", "", "Path to TLS key file") + tlsPort := flag.Int("tls-port", 5600, "Base TLS port. TLS file transfer port is base + 1.") flag.Parse() @@ -78,12 +82,27 @@ func main() { os.Exit(1) } - srv, err := hotline.NewServer( + var tlsConfig *tls.Config + if *tlsCert != "" && *tlsKey != "" { + cert, err := tls.LoadX509KeyPair(*tlsCert, *tlsKey) + if err != nil { + slogger.Error("Error loading TLS certificate", "err", err) + os.Exit(1) + } + tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + } + + opts := []hotline.Option{ hotline.WithInterface(*netInterface), hotline.WithLogger(slogger), hotline.WithPort(*basePort), hotline.WithConfig(*config), - ) + } + if tlsConfig != nil { + opts = append(opts, hotline.WithTLS(tlsConfig, *tlsPort)) + } + + srv, err := hotline.NewServer(opts...) if err != nil { slogger.Error("Error starting server", "err", err) os.Exit(1) @@ -174,6 +193,9 @@ func main() { }() slogger.Info("Hotline server started", "version", version, "config", *configDir) + if tlsConfig != nil { + slogger.Info("TLS enabled", "port", *tlsPort, "fileTransferPort", *tlsPort+1) + } // Assign functions to handle specific Hotline transaction types mobius.RegisterHandlers(srv) diff --git a/docs/tls.md b/docs/tls.md new file mode 100644 index 0000000..6cb2fd5 --- /dev/null +++ b/docs/tls.md @@ -0,0 +1,101 @@ +# TLS Support + +Mobius supports TLS (Transport Layer Security) for encrypted connections between clients and the server. When enabled, TLS runs on separate ports alongside the standard unencrypted ports, allowing both secure and legacy client connections simultaneously. + +## Ports + +| Service | Standard Port | TLS Port (default) | +|---------------|---------------|-------------------| +| Hotline | 5500 | 5600 | +| File Transfer | 5501 | 5601 | + +## Generating Certificates + +### Self-Signed Certificate (Testing/Private Use) + +For testing or private servers, you can generate a self-signed certificate using OpenSSL: + +```bash +openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes -subj "/CN=localhost" +``` + +This creates: +- `server.key` - Private key file +- `server.crt` - Certificate file + +For a certificate that includes your server's hostname or IP address: + +```bash +openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes \ + -subj "/CN=your-hostname.example.com" \ + -addext "subjectAltName=DNS:your-hostname.example.com,IP:192.168.1.100" +``` + +### Let's Encrypt (Production) + +For production servers with a public domain name, use [Let's Encrypt](https://letsencrypt.org/) with certbot: + +```bash +certbot certonly --standalone -d your-hostname.example.com +``` + +The certificates are typically stored at: +- `/etc/letsencrypt/live/your-hostname.example.com/fullchain.pem` +- `/etc/letsencrypt/live/your-hostname.example.com/privkey.pem` + +## Command-Line Options + +| Flag | Description | Default | +|------|-------------|---------| +| `-tls-cert` | Path to TLS certificate file | (none) | +| `-tls-key` | Path to TLS private key file | (none) | +| `-tls-port` | Base TLS port (file transfer uses base + 1) | 5600 | + +TLS is enabled when both `-tls-cert` and `-tls-key` are provided. + +## Usage Examples + +### Basic TLS Setup + +```bash +mobius-hotline-server -tls-cert server.crt -tls-key server.key +``` + +### Custom TLS Port + +```bash +mobius-hotline-server -tls-cert server.crt -tls-key server.key -tls-port 5700 +``` + +### Full Example with All Options + +```bash +mobius-hotline-server \ + -config /path/to/config \ + -bind 5500 \ + -tls-cert /etc/letsencrypt/live/example.com/fullchain.pem \ + -tls-key /etc/letsencrypt/live/example.com/privkey.pem \ + -tls-port 5600 +``` + +## Verifying TLS is Working + +When TLS is enabled, you'll see a log message at startup: + +``` +TLS enabled port=5600 fileTransferPort=5601 +``` + +You can verify the TLS connection using OpenSSL: + +```bash +openssl s_client -connect localhost:5600 +``` + +## Client Configuration + +Clients connecting via TLS must: +1. Connect to the TLS port (default 5600) instead of the standard port (5500) +2. Support TLS connections (client-dependent) + +Note: Self-signed certificates may require clients to accept or trust the certificate manually. diff --git a/hotline/server.go b/hotline/server.go index 0395d7b..2c25e93 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/tls" "encoding/binary" "errors" "fmt" @@ -40,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 @@ -71,6 +73,9 @@ type Server struct { // TrackerRegistrar handles tracker registration (injectable for testing) TrackerRegistrar TrackerRegistrar + + TLSConfig *tls.Config + TLSPort int } type Option = func(s *Server) @@ -108,6 +113,14 @@ func WithTrackerRegistrar(registrar TrackerRegistrar) func(s *Server) { } } +// 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 { } @@ -168,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 @@ -195,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) @@ -249,11 +292,13 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { 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() { -- cgit From 8ddb9bb228389b198a76d6df21de005da4fad66b Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:30:30 -0800 Subject: Add TLS port field to tracker registration protocol Repurpose the previously unused 2-byte field in the tracker protocol to advertise the server's TLS port. This allows clients to discover which servers support TLS connections. Note: currently only the official, original, 1990's Tracker software sends this server provided 2-byte field to tracker clients. I'm adding this to Mobius in the hope that the modern day trackers might update their behavior to match the original software, which would put these unused 2 bytes to a useful purpose. --- hotline/server.go | 1 + hotline/tracker.go | 9 ++--- hotline/tracker_test.go | 90 ++++++++++++++++++++++++------------------------- 3 files changed, 51 insertions(+), 49 deletions(-) (limited to 'hotline/server.go') diff --git a/hotline/server.go b/hotline/server.go index 2c25e93..9b74d99 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -368,6 +368,7 @@ func (s *Server) registerWithAllTrackers() { Description: s.Config.Description, } binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) + binary.BigEndian.PutUint16(tr.TLSPort[:], uint16(s.TLSPort)) tr.Password = parseTrackerPassword(t) diff --git a/hotline/tracker.go b/hotline/tracker.go index bfba69d..ee4fc05 100644 --- a/hotline/tracker.go +++ b/hotline/tracker.go @@ -15,6 +15,7 @@ import ( type TrackerRegistration struct { Port [2]byte // Server's listening TCP port number UserCount int // Number of users connected to this particular server + TLSPort [2]byte // TLSPort PassID [4]byte // Random number generated by the server Name string // Server Name Description string // Description of the server @@ -32,7 +33,7 @@ func (tr *TrackerRegistration) Read(p []byte) (int, error) { []byte{0x00, 0x01}, // Magic number, always 1 tr.Port[:], userCount, - []byte{0x00, 0x00}, // Magic number, always 0 + tr.TLSPort[:], tr.PassID[:], []byte{uint8(len(tr.Name))}, []byte(tr.Name), @@ -114,7 +115,7 @@ type ServerRecord struct { IPAddr [4]byte Port [2]byte NumUsers [2]byte // Number of users connected to this particular server - Unused [2]byte + TLSPort [2]byte NameSize byte // Length of Name string Name []byte // Server Name DescriptionSize byte @@ -182,7 +183,7 @@ func GetListing(conn io.ReadWriteCloser) ([]ServerRecord, error) { func readServerRecord(reader *bufio.Reader) (ServerRecord, error) { var srv ServerRecord - // Read fixed header: IP (4) + Port (2) + NumUsers (2) + Unused (2) = 10 bytes + // Read fixed header: IP (4) + Port (2) + NumUsers (2) + TLSPort (2) = 10 bytes header := make([]byte, 10) if _, err := io.ReadFull(reader, header); err != nil { return srv, fmt.Errorf("failed to read server header: %w", err) @@ -191,7 +192,7 @@ func readServerRecord(reader *bufio.Reader) (ServerRecord, error) { copy(srv.IPAddr[:], header[0:4]) copy(srv.Port[:], header[4:6]) copy(srv.NumUsers[:], header[6:8]) - copy(srv.Unused[:], header[8:10]) + copy(srv.TLSPort[:], header[8:10]) // Read name size nameSizeByte, err := reader.ReadByte() diff --git a/hotline/tracker_test.go b/hotline/tracker_test.go index 8295582..242deb1 100644 --- a/hotline/tracker_test.go +++ b/hotline/tracker_test.go @@ -104,7 +104,7 @@ func TestGetListing(t *testing.T) { 192, 168, 1, 1, // IP address 0x1F, 0x90, // Port 8080 0x00, 0x10, // NumUsers 16 - 0x00, 0x00, // Unused + 0x00, 0x00, // TLSPort 0x04, // NameSize 'S', 'e', 'r', 'v', // Name 0x0B, // DescriptionSize @@ -113,7 +113,7 @@ func TestGetListing(t *testing.T) { 10, 0, 0, 1, // IP address 0x1F, 0x91, // Port 8081 0x00, 0x05, // NumUsers 5 - 0x00, 0x00, // Unused + 0x00, 0x00, // TLSPort 0x04, // NameSize 'S', 'e', 'r', 'v', // Name 0x0B, // DescriptionSize @@ -127,7 +127,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 1}, Port: [2]byte{0x1F, 0x90}, NumUsers: [2]byte{0x00, 0x10}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 4, Name: []byte("Serv"), DescriptionSize: 11, @@ -137,7 +137,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{10, 0, 0, 1}, Port: [2]byte{0x1F, 0x91}, NumUsers: [2]byte{0x00, 0x05}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 4, Name: []byte("Serv"), DescriptionSize: 11, @@ -217,19 +217,19 @@ func TestGetListing(t *testing.T) { 192, 168, 1, 1, // IP address 0x1F, 0x90, // Port 8080 0x00, 0x0A, // NumUsers 10 - 0x00, 0x00, // Unused - 0x07, // NameSize + 0x00, 0x00, // TLSPort + 0x07, // NameSize 'S', 'e', 'r', 'v', 'e', 'r', '1', // Name - 0x0C, // DescriptionSize + 0x0C, // DescriptionSize 'F', 'i', 'r', 's', 't', ' ', 'b', 'a', 't', 'c', 'h', '1', // Description // ServerRecord 2 192, 168, 1, 2, // IP address 0x1F, 0x91, // Port 8081 0x00, 0x14, // NumUsers 20 - 0x00, 0x00, // Unused - 0x07, // NameSize + 0x00, 0x00, // TLSPort + 0x07, // NameSize 'S', 'e', 'r', 'v', 'e', 'r', '2', // Name - 0x0C, // DescriptionSize + 0x0C, // DescriptionSize 'F', 'i', 'r', 's', 't', ' ', 'b', 'a', 't', 'c', 'h', '2', // Description // Second ServerInfoHeader (next batch) 0x00, 0x01, // MsgType (1) @@ -240,10 +240,10 @@ func TestGetListing(t *testing.T) { 192, 168, 1, 3, // IP address 0x1F, 0x92, // Port 8082 0x00, 0x1E, // NumUsers 30 - 0x00, 0x00, // Unused - 0x07, // NameSize + 0x00, 0x00, // TLSPort + 0x07, // NameSize 'S', 'e', 'r', 'v', 'e', 'r', '3', // Name - 0x0D, // DescriptionSize + 0x0D, // DescriptionSize 'S', 'e', 'c', 'o', 'n', 'd', ' ', 'b', 'a', 't', 'c', 'h', '1', // Description }), writeBuffer: &bytes.Buffer{}, @@ -254,7 +254,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 1}, Port: [2]byte{0x1F, 0x90}, NumUsers: [2]byte{0x00, 0x0A}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 7, Name: []byte("Server1"), DescriptionSize: 12, @@ -264,7 +264,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 2}, Port: [2]byte{0x1F, 0x91}, NumUsers: [2]byte{0x00, 0x14}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 7, Name: []byte("Server2"), DescriptionSize: 12, @@ -274,7 +274,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 3}, Port: [2]byte{0x1F, 0x92}, NumUsers: [2]byte{0x00, 0x1E}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 7, Name: []byte("Server3"), DescriptionSize: 13, @@ -298,20 +298,20 @@ func TestGetListing(t *testing.T) { 192, 168, 1, 1, // IP 0x15, 0x7c, // Port 5500 0x00, 0x01, // NumUsers 1 - 0x00, 0x00, // Unused - 0x01, // NameSize - 'A', // Name - 0x01, // DescriptionSize - '1', // Description + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'A', // Name + 0x01, // DescriptionSize + '1', // Description // ServerRecord 2 192, 168, 1, 2, // IP 0x15, 0x7c, // Port 5500 0x00, 0x02, // NumUsers 2 - 0x00, 0x00, // Unused - 0x01, // NameSize - 'B', // Name - 0x01, // DescriptionSize - '2', // Description + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'B', // Name + 0x01, // DescriptionSize + '2', // Description // Second ServerInfoHeader 0x00, 0x01, // MsgType (1) 0x00, 0x0A, // MsgDataSize @@ -321,11 +321,11 @@ func TestGetListing(t *testing.T) { 192, 168, 1, 3, // IP 0x15, 0x7c, // Port 5500 0x00, 0x03, // NumUsers 3 - 0x00, 0x00, // Unused - 0x01, // NameSize - 'C', // Name - 0x01, // DescriptionSize - '3', // Description + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'C', // Name + 0x01, // DescriptionSize + '3', // Description // Third ServerInfoHeader 0x00, 0x01, // MsgType (1) 0x00, 0x0A, // MsgDataSize @@ -335,11 +335,11 @@ func TestGetListing(t *testing.T) { 192, 168, 1, 4, // IP 0x15, 0x7c, // Port 5500 0x00, 0x04, // NumUsers 4 - 0x00, 0x00, // Unused - 0x01, // NameSize - 'D', // Name - 0x01, // DescriptionSize - '4', // Description + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'D', // Name + 0x01, // DescriptionSize + '4', // Description }), writeBuffer: &bytes.Buffer{}, }, @@ -349,7 +349,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 1}, Port: [2]byte{0x15, 0x7c}, NumUsers: [2]byte{0x00, 0x01}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 1, Name: []byte("A"), DescriptionSize: 1, @@ -359,7 +359,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 2}, Port: [2]byte{0x15, 0x7c}, NumUsers: [2]byte{0x00, 0x02}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 1, Name: []byte("B"), DescriptionSize: 1, @@ -369,7 +369,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 3}, Port: [2]byte{0x15, 0x7c}, NumUsers: [2]byte{0x00, 0x03}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 1, Name: []byte("C"), DescriptionSize: 1, @@ -379,7 +379,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 4}, Port: [2]byte{0x15, 0x7c}, NumUsers: [2]byte{0x00, 0x04}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 1, Name: []byte("D"), DescriptionSize: 1, @@ -403,11 +403,11 @@ func TestGetListing(t *testing.T) { 192, 168, 1, 1, // IP 0x15, 0x7c, // Port 5500 0x00, 0x01, // NumUsers 1 - 0x00, 0x00, // Unused - 0x01, // NameSize - 'A', // Name - 0x01, // DescriptionSize - '1', // Description + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'A', // Name + 0x01, // DescriptionSize + '1', // Description // Incomplete second ServerInfoHeader 0x00, 0x01, // MsgType only }), -- cgit