From 4912c3a7c20d765692993f6caaa98a94f5ae28cb Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:55:05 -0700 Subject: Add Reloader interface to remove type assertions from reload path main.go's reloadFunc reached through the server's interface fields with concrete type assertions (srv.MessageBoard.(*mobius.FlatNews), etc.) to trigger SIGHUP/API reloads, leaking storage implementation details past the manager interfaces. Storage backends now implement a one-method Reloader interface, with compile-time assertions for FlatNews, BanFile, ThreadedNewsYAML, and Agreement. BanFile.Load and ThreadedNewsYAML.Load are renamed Reload for a uniform method set, matching FlatNews's existing convention of using Reload for the initial load as well. main.go registers each backend in a named reloader list as it is constructed, and reloadFunc iterates the list. The banner reload is a ReloaderFunc that also performs the initial load, and the Redis-backed ban list simply registers no reloader, replacing the old type-switch special case. --- cmd/mobius-hotline-server/main.go | 70 +++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 33 deletions(-) (limited to 'cmd/mobius-hotline-server') diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index 117cb2a..ba7924d 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -109,11 +109,17 @@ func main() { os.Exit(1) } - srv.MessageBoard, err = mobius.NewFlatNews(path.Join(*configDir, "MessageBoard.txt")) + // reloaders collects the storage backends whose state is reloaded on SIGHUP or via the + // reload API endpoint. + var reloaders []namedReloader + + messageBoard, err := mobius.NewFlatNews(path.Join(*configDir, "MessageBoard.txt")) if err != nil { slogger.Error("Error loading message board", "err", err) os.Exit(1) } + srv.MessageBoard = messageBoard + reloaders = append(reloaders, namedReloader{"message board", messageBoard}) // Initialize ban list - use Redis if configured, otherwise use file-based storage if *redisAddr != "" { @@ -133,18 +139,23 @@ func main() { srv.BanList = mobius.NewRedisBanMgr(redisClient, slogger) slogger.Debug("Using Redis for ban management", "addr", *redisAddr) } else { - srv.BanList, err = mobius.NewBanFile(path.Join(*configDir, "Banlist.yaml")) + banFile, err := mobius.NewBanFile(path.Join(*configDir, "Banlist.yaml")) if err != nil { slogger.Error("Error loading ban list", "err", err) os.Exit(1) } + srv.BanList = banFile + // The Redis-backed ban list needs no reload, so only the file-backed one registers. + reloaders = append(reloaders, namedReloader{"ban list", banFile}) } - srv.ThreadedNewsMgr, err = mobius.NewThreadedNewsYAML(path.Join(*configDir, "ThreadedNews.yaml")) + threadedNews, err := mobius.NewThreadedNewsYAML(path.Join(*configDir, "ThreadedNews.yaml")) if err != nil { slogger.Error("Error loading news", "err", err) os.Exit(1) } + srv.ThreadedNewsMgr = threadedNews + reloaders = append(reloaders, namedReloader{"threaded news", threadedNews}) srv.AccountManager, err = mobius.NewYAMLAccountManager(path.Join(*configDir, "Users/")) if err != nil { @@ -152,48 +163,35 @@ func main() { os.Exit(1) } - srv.Agreement, err = mobius.NewAgreement(*configDir, "\r") + agreement, err := mobius.NewAgreement(*configDir, "\r") if err != nil { slogger.Error("Error loading agreement", "err", err) os.Exit(1) } + srv.Agreement = agreement + reloaders = append(reloaders, namedReloader{"agreement", agreement}) - bannerPath := path.Join(*configDir, config.BannerFile) - banner, err := os.ReadFile(bannerPath) - if err != nil { + // On reload failure, the previous banner is kept because SetBanner is only called on success. + reloadBanner := mobius.ReloaderFunc(func() error { + banner, err := os.ReadFile(path.Join(*configDir, config.BannerFile)) + if err != nil { + return err + } + srv.SetBanner(banner) + return nil + }) + if err := reloadBanner.Reload(); err != nil { slogger.Error("Error loading banner", "err", err) os.Exit(1) } - srv.SetBanner(banner) + reloaders = append(reloaders, namedReloader{"banner", reloadBanner}) reloadFunc := func() { - if err := srv.MessageBoard.(*mobius.FlatNews).Reload(); err != nil { - slogger.Error("Error reloading news", "err", err) - } - - // Only reload ban list if using file-based storage (Redis doesn't need reload) - if banFile, ok := srv.BanList.(*mobius.BanFile); ok { - if err := banFile.Load(); err != nil { - slogger.Error("Error reloading ban list", "err", err) + for _, item := range reloaders { + if err := item.reloader.Reload(); err != nil { + slogger.Error("Error reloading "+item.name, "err", err) } } - - if err := srv.ThreadedNewsMgr.(*mobius.ThreadedNewsYAML).Load(); err != nil { - slogger.Error("Error reloading threaded news list", "err", err) - } - - if err := srv.Agreement.(*mobius.Agreement).Reload(); err != nil { - slogger.Error("Error reloading agreement", "err", err) - } - - // Let's try to reload the banner. On failure, keep serving the previous banner. - bannerPath := path.Join(*configDir, config.BannerFile) - banner, err := os.ReadFile(bannerPath) - if err != nil { - slogger.Error("Error reloading banner", "err", err) - } else { - srv.SetBanner(banner) - } } if *apiAddr != "" { @@ -248,6 +246,12 @@ func main() { slogger.Info("Server shut down") } +// namedReloader pairs a Reloader with a human-readable name for reload error logging. +type namedReloader struct { + name string + reloader mobius.Reloader +} + // findConfigPath searches for an existing config directory from the predefined search order. // Returns the first directory that exists, or falls back to "config" as the default. func findConfigPath() string { -- cgit