// Package hub handles updating systems and serving the web UI. package hub import ( "crypto/ed25519" "encoding/pem" "errors" "fmt" "net/url" "os" "path" "strings" "github.com/henrygd/beszel/internal/alerts" "github.com/henrygd/beszel/internal/hub/badges" "github.com/henrygd/beszel/internal/hub/bulk" "github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/domains" "github.com/henrygd/beszel/internal/hub/export" "github.com/henrygd/beszel/internal/hub/heartbeat" "github.com/henrygd/beszel/internal/hub/incidents" "github.com/henrygd/beszel/internal/hub/maintenance" "github.com/henrygd/beszel/internal/hub/monitors" "github.com/henrygd/beszel/internal/hub/settings" "github.com/henrygd/beszel/internal/hub/statuspages" "github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/hub/utils" "github.com/henrygd/beszel/internal/records" "github.com/henrygd/beszel/internal/users" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" "golang.org/x/crypto/ssh" ) // Hub is the application. It embeds the PocketBase app and keeps references to subcomponents. type Hub struct { core.App *alerts.AlertManager um *users.UserManager rm *records.RecordManager sm *systems.SystemManager monSched *monitors.Scheduler monAPI *monitors.APIHandler domainSched *domains.Scheduler domainAPI *domains.APIHandler exportAPI *export.APIHandler statusPageAPI *statuspages.APIHandler maintenanceAPI *maintenance.APIHandler bulkAPI *bulk.APIHandler incidentAPI *incidents.APIHandler badgeAPI *badges.APIHandler settingsAPI *settings.APIHandler hb *heartbeat.Heartbeat hbStop chan struct{} pubKey string signer ssh.Signer appURL string started bool } // NewHub creates a new Hub instance with default configuration func NewHub(app core.App) *Hub { hub := &Hub{App: app} hub.AlertManager = alerts.NewAlertManager(hub) hub.um = users.NewUserManager(hub) hub.rm = records.NewRecordManager(hub) hub.sm = systems.NewSystemManager(hub) hub.monSched = monitors.NewScheduler(app) hub.monSched.SetAlertCallback(func(userID, title, message, link, linkText string) { hub.AlertManager.SendAlert(alerts.AlertMessageData{ UserID: userID, Title: title, Message: message, Link: link, LinkText: linkText, }) }) hub.monAPI = monitors.NewAPIHandler(app, hub.monSched) hub.domainSched = domains.NewScheduler(app) hub.domainSched.SetAlertCallback(func(userID, title, message, link, linkText string) { hub.AlertManager.SendAlert(alerts.AlertMessageData{ UserID: userID, Title: title, Message: message, Link: link, LinkText: linkText, }) }) hub.domainAPI = domains.NewAPIHandler(app, hub.domainSched) hub.statusPageAPI = statuspages.NewAPIHandler(app) hub.maintenanceAPI = maintenance.NewAPIHandler(app) hub.bulkAPI = bulk.NewAPIHandler(app) hub.incidentAPI = incidents.NewAPIHandler(app) hub.badgeAPI = badges.NewAPIHandler(app) hub.settingsAPI = settings.NewAPIHandler(app) hub.exportAPI = export.NewAPIHandler(app) hub.hb = heartbeat.New(app, utils.GetEnv) if hub.hb != nil { hub.hbStop = make(chan struct{}) } _ = onAfterBootstrapAndMigrations(app, hub.initialize) return hub } // onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied. // This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose. // However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations. // So this ensures that the provided function runs at the right time either way, after DB is ready and migrations are done. func onAfterBootstrapAndMigrations(app core.App, fn func(app core.App) error) error { // pb tests.TestApp is already bootstrapped and doesn't serve if app.IsBootstrapped() { return fn(app) } // Must use OnServe because OnBootstrap appears to run before migrations, even if calling e.Next() before anything else app.OnServe().BindFunc(func(e *core.ServeEvent) error { if err := fn(e.App); err != nil { return err } return e.Next() }) return nil } // StartHub sets up event handlers and starts the PocketBase server func (h *Hub) StartHub() error { h.App.OnServe().BindFunc(func(e *core.ServeEvent) error { // sync systems with config if err := config.SyncSystems(e); err != nil { return err } // register middlewares h.registerMiddlewares(e) // register api routes if err := h.registerApiRoutes(e); err != nil { return err } // register cron jobs if err := h.registerCronJobs(e); err != nil { return err } // start server if err := h.startServer(e); err != nil { return err } // start system updates and background services only once if !h.started { h.started = true // start system updates if err := h.sm.Initialize(); err != nil { return err } // start heartbeat if configured if h.hb != nil { go h.hb.Start(h.hbStop) } // start monitor scheduler if err := h.monSched.Start(); err != nil { return err } // start domain scheduler h.domainSched.Start() // bind monitor lifecycle hooks h.bindMonitorHooks() // bind domain lifecycle hooks h.bindDomainHooks() } // register monitor API routes h.monAPI.RegisterRoutes(e) // register domain API routes h.domainAPI.RegisterRoutes(e) // register status page API routes h.statusPageAPI.RegisterRoutes(e) // register maintenance API routes h.maintenanceAPI.RegisterRoutes(e) // register bulk API routes h.bulkAPI.RegisterRoutes(e) // register incident API routes h.incidentAPI.RegisterRoutes(e) // register badge API routes h.badgeAPI.RegisterRoutes(e) // register settings API routes h.settingsAPI.RegisterRoutes(e) // register export API routes h.exportAPI.RegisterRoutes(e) return e.Next() }) // NOTE: consider moving user initialization into users package // handle default values for user / user_settings creation h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole) h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings) pb, ok := h.App.(*pocketbase.PocketBase) if !ok { return errors.New("not a pocketbase app") } return pb.Start() } // initialize sets up initial configuration (collections, settings, etc.) func (h *Hub) initialize(app core.App) error { // set general settings settings := app.Settings() // batch requests (for alerts) settings.Batch.Enabled = true // set URL if APP_URL env is set if appURL, isSet := utils.GetEnv("APP_URL"); isSet { h.appURL = appURL settings.Meta.AppURL = appURL } if err := app.Save(settings); err != nil { return err } // set auth settings return setCollectionAuthSettings(app) } // registerCronJobs sets up scheduled tasks func (h *Hub) registerCronJobs(_ *core.ServeEvent) error { // delete old system_stats and alerts_history records once every hour h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords) // create longer records every 10 minutes h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords) // cleanup old monitor heartbeats once a day (keep 30 days) h.Cron().MustAdd("cleanup old heartbeats", "0 0 * * *", func() { h.monSched.CleanupOldHeartbeats(30) }) // check domain expiry daily at 1 AM h.Cron().MustAdd("check domains", "0 1 * * *", func() { h.domainSched.CheckAllDomains() }) return nil } // bindMonitorHooks binds event hooks for monitor lifecycle management func (h *Hub) bindMonitorHooks() { // On create - add to scheduler h.OnRecordCreate("monitors").BindFunc(func(e *core.RecordEvent) error { // Only add to scheduler if active if e.Record.GetBool("active") { h.monSched.AddMonitor(e.Record) } return e.Next() }) // On update - update scheduler h.OnRecordAfterUpdateSuccess("monitors").BindFunc(func(e *core.RecordEvent) error { h.monSched.UpdateMonitor(e.Record) return e.Next() }) // On delete - remove from scheduler h.OnRecordAfterDeleteSuccess("monitors").BindFunc(func(e *core.RecordEvent) error { h.monSched.RemoveMonitor(e.Record.Id) return e.Next() }) } // bindDomainHooks binds event hooks for domain lifecycle management func (h *Hub) bindDomainHooks() { // On create - perform initial lookup if active h.OnRecordAfterCreateSuccess("domains").BindFunc(func(e *core.RecordEvent) error { if e.Record.GetBool("active") { h.domainSched.RefreshDomain(e.Record.Id) } return e.Next() }) // On update - refresh if activated h.OnRecordAfterUpdateSuccess("domains").BindFunc(func(e *core.RecordEvent) error { if e.Record.GetBool("active") { h.domainSched.RefreshDomain(e.Record.Id) } return e.Next() }) } // GetSSHKey generates key pair if it doesn't exist and returns signer func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) { if dataDir == "" { dataDir = h.DataDir() } // Only cache the signer when using the default data directory isDefaultDir := dataDir == h.DataDir() if isDefaultDir && h.signer != nil { return h.signer, nil } privateKeyPath := path.Join(dataDir, "id_ed25519") // check if the key pair already exists existingKey, err := os.ReadFile(privateKeyPath) if err == nil { private, err := ssh.ParsePrivateKey(existingKey) if err != nil { return nil, fmt.Errorf("failed to parse private key: %s", err) } pubKeyBytes := ssh.MarshalAuthorizedKey(private.PublicKey()) h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n") if isDefaultDir { h.signer = private } return private, nil } else if !os.IsNotExist(err) { // File exists but couldn't be read for some other reason return nil, fmt.Errorf("failed to read %s: %w", privateKeyPath, err) } // Generate the Ed25519 key pair _, privKey, err := ed25519.GenerateKey(nil) if err != nil { return nil, err } privKeyPem, err := ssh.MarshalPrivateKey(privKey, "") if err != nil { return nil, err } if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(privKeyPem), 0600); err != nil { return nil, fmt.Errorf("failed to write private key to %q: err: %w", privateKeyPath, err) } // These are fine to ignore the errors on, as we've literally just created a crypto.PublicKey | crypto.Signer sshPrivate, _ := ssh.NewSignerFromSigner(privKey) pubKeyBytes := ssh.MarshalAuthorizedKey(sshPrivate.PublicKey()) h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n") if isDefaultDir { h.signer = sshPrivate } h.Logger().Info("ed25519 key pair generated successfully.") h.Logger().Info("Saved to: " + privateKeyPath) return sshPrivate, err } // MakeLink formats a link with the app URL and path segments. // Only path segments should be provided. func (h *Hub) MakeLink(parts ...string) string { base := strings.TrimSuffix(h.Settings().Meta.AppURL, "/") for _, part := range parts { if part == "" { continue } base = fmt.Sprintf("%s/%s", base, url.PathEscape(part)) } return base }