A settings update replaced the in-memory configuration but nothing told the code that derives state from it. The logger is the first such consumer: its debug flag is captured once at startup by postLog.SetDebugMode, so toggling system.debugMode at runtime changed what config.IsDebugMode() reported while DEBUG lines stayed filtered by the value from boot. Add a hook registry to the config package. OnReload registers a callback and every writer of config.json runs the registered hooks with the configuration now in effect. It lives in config so packages that already depend on it (the logger, and later the controller manager) can react without config importing them back, which would be an import cycle. Hooks run after settingsLock is released, not inside it. They will rebuild controllers once those support reloading, which can wait on a network call, and settingsLock is also held by the node tracker's background save — running a hook under the lock would stall node registration behind a settings edit. runSettingsUpdate now owns that lock/notify sequence for all four writer paths (UpdateSettings and the three webhook endpoint helpers). A panicking hook is logged and skipped: by then the new configuration is on disk and published, so reporting the write as failed would be a lie, and the hooks registered after the broken one still need to run.
360 lines
10 KiB
Go
360 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"nukumizu-backend/config"
|
|
"nukumizu-backend/database"
|
|
"nukumizu-backend/global"
|
|
"nukumizu-backend/internal/controller"
|
|
"nukumizu-backend/internal/controller/pipes"
|
|
"nukumizu-backend/internal/controller/pipes/qq_napcat"
|
|
"nukumizu-backend/internal/controller/pipes/telegram"
|
|
"nukumizu-backend/internal/komari"
|
|
"nukumizu-backend/internal/node"
|
|
"nukumizu-backend/postLog"
|
|
"nukumizu-backend/utils"
|
|
)
|
|
|
|
// CommitHash and BuildTime are set at build time using -ldflags.
|
|
var (
|
|
CommitHash string
|
|
BuildTime string
|
|
)
|
|
|
|
func main() {
|
|
// Set global software info.
|
|
global.SoftwareInfo.CommitHash = CommitHash
|
|
global.SoftwareInfo.BuildTime = BuildTime
|
|
|
|
// Log startup banner.
|
|
postLog.Info(fmt.Sprintf("%s Ver.%s.%d.%s.%s Developed by %s at %s", global.SoftwareInfo.Name, global.SoftwareInfo.Version, global.SoftwareInfo.BuildVer, global.SoftwareInfo.BuildType, global.SoftwareInfo.CommitHash, global.SoftwareInfo.Developer, global.SoftwareInfo.BuildTime))
|
|
|
|
// Load configuration.
|
|
cfg, err := config.LoadGlobalConfig(global.ConfigPath.Global)
|
|
if err != nil {
|
|
log.Fatalf("Failed to load global config: %v", err)
|
|
}
|
|
|
|
_, err = config.LoadBotUserConfig(global.ConfigPath.BotUserConfig)
|
|
if err != nil {
|
|
log.Fatalf("Failed to load bot user config: %v", err)
|
|
}
|
|
|
|
// Load the node registry config. Unlike the other files it is optional:
|
|
// missing or empty bot_node_config.json simply means every node keeps its
|
|
// default enableStatusNotify (true).
|
|
if err := config.LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
|
log.Fatalf("Failed to load bot node config: %v", err)
|
|
}
|
|
|
|
// Initialize logging.
|
|
postLog.SetDebugMode(cfg.System.DebugMode)
|
|
postLog.InitLogBroadcaster()
|
|
|
|
// The logger's debug flag is a process-wide setting rather than something
|
|
// read at every log call, so a settings update has to push the new value
|
|
// into it. This is the first link in the reload hook chain; the controllers
|
|
// join it as they gain reload support.
|
|
config.OnReload(func(updated *config.Config) {
|
|
postLog.SetDebugMode(updated.System.DebugMode)
|
|
})
|
|
|
|
dbPath := cfg.DBPath
|
|
|
|
if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil {
|
|
postLog.Fatal("Failed to initialize logs database: " + err.Error())
|
|
}
|
|
|
|
// Initialize database.
|
|
if err := database.InitUserDB(fmt.Sprintf("%s/user.db", dbPath)); err != nil {
|
|
postLog.Fatal("Failed to initialize user database: " + err.Error())
|
|
}
|
|
defer database.CloseUserDB()
|
|
|
|
// Initialize node tracker.
|
|
node.InitTracker()
|
|
|
|
// Initialize controller manager.
|
|
controller.InitManager()
|
|
|
|
// Initialize rate limiter.
|
|
utils.InitRateLimiter(100, time.Minute)
|
|
|
|
// Start token cleaner.
|
|
utils.StartTokenCleaner()
|
|
|
|
postLog.Info("Starting Nukumizu server...")
|
|
|
|
// --- Login to Komari ---
|
|
komari.InitClient(cfg.Komari.DashboardURL)
|
|
|
|
if err := komari.LoginAndStart(); err != nil {
|
|
postLog.Fatal("Failed to login to Komari: " + err.Error())
|
|
return
|
|
}
|
|
|
|
// --- Start Komari WebSocket connection ---
|
|
komari.InitWSClient(cfg.Komari.DashboardURL, 5)
|
|
wsClient := komari.GetWSClient()
|
|
if wsClient != nil {
|
|
wsClient.SetOnReconnectFail(func() {
|
|
postLog.Error("Komari WebSocket reconnection exhausted")
|
|
controller.GetManager().NotifyAllAdmins("Komari WebSocket connection lost after 5 retry attempts")
|
|
})
|
|
|
|
if err := wsClient.Connect(); err != nil {
|
|
postLog.Error("Failed to connect Komari WebSocket: " + err.Error())
|
|
}
|
|
}
|
|
|
|
// --- Initialize controllers ---
|
|
initControllers()
|
|
|
|
// Send the bot initialization message to all enabled bot controllers
|
|
// (QQ_NapCat, Telegram), then the server list right after it once the
|
|
// initial status refresh has completed so the list reflects the real
|
|
// online/offline state.
|
|
if mgr := controller.GetManager(); mgr != nil {
|
|
mgr.ShowBotInitMessage()
|
|
|
|
if tracker := node.GetTracker(); tracker != nil {
|
|
tracker.OnBootstrapDone(func() {
|
|
if m := controller.GetManager(); m != nil {
|
|
m.ShowBotServerList()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Start background tasks ---
|
|
startBackgroundTasks(cfg)
|
|
|
|
// --- Setup HTTP router ---
|
|
mux := SetupRouter()
|
|
|
|
// Apply middleware chain.
|
|
handler := utils.RateLimitMiddleware(mux)
|
|
handler = utils.CORSMiddleware(handler)
|
|
handler = utils.XSSProtectionMiddleware(handler)
|
|
|
|
addr := fmt.Sprintf("%s:%s", cfg.System.ListenAddr, cfg.System.ListenPort)
|
|
postLog.Info(fmt.Sprintf("Server listening on %s", addr))
|
|
|
|
// Start HTTP server in a goroutine.
|
|
go func() {
|
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
|
postLog.Fatal("Server error: " + err.Error())
|
|
}
|
|
}()
|
|
|
|
// --- Start the incoming webhook listener ---
|
|
startWebhookServer(cfg)
|
|
|
|
// --- Graceful shutdown ---
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
<-quit
|
|
|
|
postLog.Info("Shutting down Nukumizu server...")
|
|
|
|
// Stop WebSocket client.
|
|
if wsClient != nil {
|
|
wsClient.Stop()
|
|
}
|
|
|
|
// Stop all controllers.
|
|
mgr := controller.GetManager()
|
|
if mgr != nil {
|
|
mgr.StopAll()
|
|
}
|
|
|
|
postLog.Info("Server stopped")
|
|
}
|
|
|
|
// startWebhookServer serves the incoming webhook API on its own listener. The
|
|
// API is not exposed on the main listener: external applications post alerts to
|
|
// this port only, so its rate limiter and CORS policy are configured
|
|
// independently. A failure to bind it is logged rather than fatal — the rest of
|
|
// the program (bots, status monitoring) keeps running without it.
|
|
func startWebhookServer(cfg *config.Config) {
|
|
if !cfg.Webhook.Enabled {
|
|
postLog.Warning("Incoming webhook API is disabled")
|
|
return
|
|
}
|
|
|
|
handler := utils.RateLimitMiddleware(SetupWebhookRouter())
|
|
handler = utils.CORSMiddleware(handler)
|
|
|
|
addr := fmt.Sprintf("%s:%s", cfg.Webhook.ListenAddr, cfg.Webhook.ListenPort)
|
|
postLog.Info(fmt.Sprintf("Webhook API listening on %s", addr))
|
|
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Webhook server panic: %v", r))
|
|
}
|
|
}()
|
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
|
postLog.Error("Webhook server error: " + err.Error())
|
|
}
|
|
}()
|
|
}
|
|
|
|
// initControllers initializes and starts all configured controllers.
|
|
func initControllers() {
|
|
cfg := config.Current()
|
|
mgr := controller.GetManager()
|
|
if mgr == nil {
|
|
return
|
|
}
|
|
|
|
// QQ (Napcat) controller.
|
|
qqCtrl := qq_napcat.NewQQController(cfg.ControllerMethod.QQ)
|
|
mgr.Register(qqCtrl)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("QQ controller panic: %v", r))
|
|
}
|
|
}()
|
|
if err := qqCtrl.Start(); err != nil {
|
|
postLog.Error("Failed to start QQ controller: " + err.Error())
|
|
}
|
|
}()
|
|
|
|
// Telegram controller.
|
|
tgCtrl := telegram.NewTelegramController(cfg.ControllerMethod.Telegram)
|
|
mgr.Register(tgCtrl)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Telegram controller panic: %v", r))
|
|
}
|
|
}()
|
|
if err := tgCtrl.Start(); err != nil {
|
|
postLog.Error("Failed to start Telegram controller: " + err.Error())
|
|
}
|
|
}()
|
|
|
|
// Email controller (status-only).
|
|
emailCtrl := pipes.NewEmailController(cfg.ControllerMethod.Email)
|
|
mgr.Register(emailCtrl)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Email controller panic: %v", r))
|
|
}
|
|
}()
|
|
if err := emailCtrl.Start(); err != nil {
|
|
postLog.Error("Failed to start Email controller: " + err.Error())
|
|
}
|
|
}()
|
|
|
|
// Ntfy controller (status-only).
|
|
ntfyCtrl := pipes.NewNtfyController(cfg.ControllerMethod.Ntfy)
|
|
mgr.Register(ntfyCtrl)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Ntfy controller panic: %v", r))
|
|
}
|
|
}()
|
|
if err := ntfyCtrl.Start(); err != nil {
|
|
postLog.Error("Failed to start Ntfy controller: " + err.Error())
|
|
}
|
|
}()
|
|
|
|
// Webhook controller (status-only).
|
|
webhookCtrl := pipes.NewWebhookController(cfg.ControllerMethod.Webhook)
|
|
mgr.Register(webhookCtrl)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Webhook controller panic: %v", r))
|
|
}
|
|
}()
|
|
if err := webhookCtrl.Start(); err != nil {
|
|
postLog.Error("Failed to start Webhook controller: " + err.Error())
|
|
}
|
|
}()
|
|
}
|
|
|
|
// startBackgroundTasks starts periodic background goroutines.
|
|
func startBackgroundTasks(_ *config.Config) {
|
|
// Refresh node list every 5 minutes.
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Node refresh panic: %v", r))
|
|
}
|
|
}()
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
client := komari.GetClient()
|
|
if client == nil {
|
|
continue
|
|
}
|
|
nodes, err := client.FetchNodes()
|
|
if err != nil {
|
|
postLog.Warning("Failed to refresh node list: " + err.Error())
|
|
continue
|
|
}
|
|
tracker := node.GetTracker()
|
|
if tracker != nil {
|
|
tracker.UpdateNodeList(komari.BuildNodeListData(nodes))
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Re-login to Komari every 12 hours.
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Komari re-login panic: %v", r))
|
|
}
|
|
}()
|
|
ticker := time.NewTicker(12 * time.Hour)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
postLog.Info("Performing scheduled Komari re-login...")
|
|
if err := komari.LoginAndStart(); err != nil {
|
|
postLog.Error("Komari re-login failed: " + err.Error())
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Wire node status change notifications to all controllers.
|
|
tracker := node.GetTracker()
|
|
if tracker != nil {
|
|
tracker.OnStatusChange(func(change node.StatusChange) {
|
|
mgr := controller.GetManager()
|
|
if mgr != nil {
|
|
mgr.NotifyStatusChange(change)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Safety net for the initial refresh gate: if some node never reports a
|
|
// status, force-complete the initial refresh after a grace period so status
|
|
// notifications cannot stay blocked forever.
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Bootstrap timeout panic: %v", r))
|
|
}
|
|
}()
|
|
time.Sleep(2 * time.Minute) // Add 2 minutes grace period to allow nodes to report status.
|
|
if tracker := node.GetTracker(); tracker != nil {
|
|
tracker.CompleteBootstrap()
|
|
}
|
|
}()
|
|
}
|