| package hub
|
|
|
| import (
|
| "context"
|
| "net/http"
|
| "regexp"
|
| "strings"
|
| "time"
|
|
|
| "github.com/blang/semver"
|
| "github.com/google/uuid"
|
| "github.com/henrygd/beszel"
|
| "github.com/henrygd/beszel/internal/alerts"
|
| "github.com/henrygd/beszel/internal/ghupdate"
|
| "github.com/henrygd/beszel/internal/hub/config"
|
| "github.com/henrygd/beszel/internal/hub/systems"
|
| "github.com/henrygd/beszel/internal/hub/utils"
|
| "github.com/pocketbase/dbx"
|
| "github.com/pocketbase/pocketbase/apis"
|
| "github.com/pocketbase/pocketbase/core"
|
| )
|
|
|
|
|
| type UpdateInfo struct {
|
| lastCheck time.Time
|
| Version string `json:"v"`
|
| Url string `json:"url"`
|
| }
|
|
|
| var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
|
|
|
|
| var requireAdminRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
|
| return e.Auth.GetString("role") == "admin"
|
| })
|
|
|
|
|
| var excludeReadOnlyRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
|
| return e.Auth.GetString("role") != "readonly"
|
| })
|
|
|
|
|
|
|
| func customAuthMiddleware(fn func(*core.RequestEvent) bool) func(*core.RequestEvent) error {
|
| return func(e *core.RequestEvent) error {
|
| if e.Auth == nil {
|
| return e.UnauthorizedError("The request requires valid record authorization token.", nil)
|
| }
|
| if !fn(e) {
|
| return e.ForbiddenError("The authorized record is not allowed to perform this action.", nil)
|
| }
|
| return e.Next()
|
| }
|
| }
|
|
|
|
|
| func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
|
|
| authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
|
| if e.Auth != nil || email == "" {
|
| return e.Next()
|
| }
|
| isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
| e.Auth, err = e.App.FindAuthRecordByEmail("users", email)
|
| if err != nil || !isAuthRefresh {
|
| return e.Next()
|
| }
|
|
|
| token, _ := e.Auth.NewAuthToken()
|
| e.Request.Header.Set("Authorization", token)
|
| return e.Next()
|
| }
|
|
|
| if autoLogin, _ := utils.GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
| se.Router.BindFunc(func(e *core.RequestEvent) error {
|
| return authorizeRequestWithEmail(e, autoLogin)
|
| })
|
| }
|
|
|
| if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
| se.Router.BindFunc(func(e *core.RequestEvent) error {
|
| return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
| })
|
| }
|
| }
|
|
|
|
|
| func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|
|
| apiAuth := se.Router.Group("/api/beszel")
|
| apiAuth.Bind(apis.RequireAuth())
|
|
|
| apiNoAuth := se.Router.Group("/api/beszel")
|
|
|
|
|
| if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
|
| apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
|
| }
|
|
|
| apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
|
| total, err := e.App.CountRecords("users")
|
| return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
| })
|
|
|
| apiAuth.GET("/info", h.getInfo)
|
| apiAuth.GET("/getkey", h.getInfo)
|
|
|
| if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
| var updateInfo UpdateInfo
|
| apiAuth.GET("/update", updateInfo.getUpdate)
|
| }
|
|
|
| apiAuth.POST("/test-notification", h.SendTestNotification)
|
|
|
| apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole)
|
| apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole)
|
|
|
| apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole)
|
|
|
| apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
|
|
| apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole)
|
|
|
| apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
| apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
|
|
| apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
|
|
|
| apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
|
|
| if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
|
|
| apiAuth.GET("/containers/logs", h.getContainerLogs)
|
|
|
| apiAuth.GET("/containers/info", h.getContainerInfo)
|
| }
|
| return nil
|
| }
|
|
|
|
|
| func (h *Hub) getInfo(e *core.RequestEvent) error {
|
| type infoResponse struct {
|
| Key string `json:"key"`
|
| Version string `json:"v"`
|
| CheckUpdate bool `json:"cu"`
|
| }
|
| info := infoResponse{
|
| Key: h.pubKey,
|
| Version: beszel.Version,
|
| }
|
| if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
| info.CheckUpdate = true
|
| }
|
| return e.JSON(http.StatusOK, info)
|
| }
|
|
|
|
|
| func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error {
|
| if time.Since(info.lastCheck) < 6*time.Hour {
|
| return e.JSON(http.StatusOK, info)
|
| }
|
| info.lastCheck = time.Now()
|
| latestRelease, err := ghupdate.FetchLatestRelease(context.Background(), http.DefaultClient, "")
|
| if err != nil {
|
| return err
|
| }
|
| currentVersion, err := semver.Parse(strings.TrimPrefix(beszel.Version, "v"))
|
| if err != nil {
|
| return err
|
| }
|
| latestVersion, err := semver.Parse(strings.TrimPrefix(latestRelease.Tag, "v"))
|
| if err != nil {
|
| return err
|
| }
|
| if latestVersion.GT(currentVersion) {
|
| info.Version = strings.TrimPrefix(latestRelease.Tag, "v")
|
| info.Url = latestRelease.Url
|
| }
|
| return e.JSON(http.StatusOK, info)
|
| }
|
|
|
|
|
| func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
| if e.Auth.IsSuperuser() {
|
| return e.ForbiddenError("Superusers cannot use universal tokens", nil)
|
| }
|
|
|
| tokenMap := universalTokenMap.GetMap()
|
| userID := e.Auth.Id
|
| query := e.Request.URL.Query()
|
| token := query.Get("token")
|
| enable := query.Get("enable")
|
| permanent := query.Get("permanent")
|
|
|
|
|
| deletePermanent := func() error {
|
| rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
|
| if err != nil {
|
| return nil
|
| }
|
| return h.Delete(rec)
|
| }
|
|
|
|
|
| upsertPermanent := func(token string) error {
|
| rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
|
| if err == nil {
|
| rec.Set("token", token)
|
| return h.Save(rec)
|
| }
|
|
|
| col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
|
| if err != nil {
|
| return err
|
| }
|
| newRec := core.NewRecord(col)
|
| newRec.Set("user", userID)
|
| newRec.Set("token", token)
|
| return h.Save(newRec)
|
| }
|
|
|
|
|
| if enable == "0" {
|
| tokenMap.RemovebyValue(userID)
|
| _ = deletePermanent()
|
| return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
|
| }
|
|
|
|
|
| if enable == "1" {
|
| if token == "" {
|
| token = uuid.New().String()
|
| }
|
|
|
| if permanent == "1" {
|
|
|
| tokenMap.RemovebyValue(userID)
|
| if err := upsertPermanent(token); err != nil {
|
| return err
|
| }
|
| return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
|
| }
|
|
|
|
|
| _ = deletePermanent()
|
| tokenMap.Set(token, userID, time.Hour)
|
| return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
|
| }
|
|
|
|
|
|
|
| if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
|
| dbToken := rec.GetString("token")
|
|
|
| if token == "" || token == dbToken {
|
| return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
|
| }
|
|
|
| return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
|
| }
|
|
|
|
|
| if token == "" {
|
|
|
| if token, _, ok := tokenMap.GetByValue(userID); ok {
|
| return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
|
| }
|
|
|
| token = uuid.New().String()
|
| }
|
|
|
|
|
| activeUser, ok := tokenMap.GetOk(token)
|
| active := ok && activeUser == userID
|
| response := map[string]any{"token": token, "active": active, "permanent": false}
|
| return e.JSON(http.StatusOK, response)
|
| }
|
|
|
|
|
| func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
| if h.hb == nil {
|
| return e.JSON(http.StatusOK, map[string]any{
|
| "enabled": false,
|
| "msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring",
|
| })
|
| }
|
| cfg := h.hb.GetConfig()
|
| return e.JSON(http.StatusOK, map[string]any{
|
| "enabled": true,
|
| "url": cfg.URL,
|
| "interval": cfg.Interval,
|
| "method": cfg.Method,
|
| })
|
| }
|
|
|
|
|
| func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
|
| if h.hb == nil {
|
| return e.JSON(http.StatusOK, map[string]any{
|
| "err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
|
| })
|
| }
|
| if err := h.hb.Send(); err != nil {
|
| return e.JSON(http.StatusOK, map[string]any{"err": err.Error()})
|
| }
|
| return e.JSON(http.StatusOK, map[string]any{"err": false})
|
| }
|
|
|
|
|
| func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error {
|
| systemID := e.Request.URL.Query().Get("system")
|
| containerID := e.Request.URL.Query().Get("container")
|
|
|
| if systemID == "" || containerID == "" || !containerIDPattern.MatchString(containerID) {
|
| return e.BadRequestError("Invalid system or container parameter", nil)
|
| }
|
|
|
| system, err := h.sm.GetSystem(systemID)
|
| if err != nil || !system.HasUser(e.App, e.Auth) {
|
| return e.NotFoundError("", nil)
|
| }
|
|
|
| data, err := fetchFunc(system, containerID)
|
| if err != nil {
|
| return e.InternalServerError("", err)
|
| }
|
|
|
| return e.JSON(http.StatusOK, map[string]string{responseKey: data})
|
| }
|
|
|
|
|
| func (h *Hub) getContainerLogs(e *core.RequestEvent) error {
|
| return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
|
| return system.FetchContainerLogsFromAgent(containerID)
|
| }, "logs")
|
| }
|
|
|
| func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
|
| return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
|
| return system.FetchContainerInfoFromAgent(containerID)
|
| }, "info")
|
| }
|
|
|
|
|
| func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
| query := e.Request.URL.Query()
|
| systemID := query.Get("system")
|
| serviceName := query.Get("service")
|
|
|
| if systemID == "" || serviceName == "" {
|
| return e.BadRequestError("Invalid system or service parameter", nil)
|
| }
|
| system, err := h.sm.GetSystem(systemID)
|
| if err != nil || !system.HasUser(e.App, e.Auth) {
|
| return e.NotFoundError("", nil)
|
| }
|
|
|
| _, err = e.App.FindFirstRecordByFilter("systemd_services", "system = {:system} && name = {:name}", dbx.Params{
|
| "system": systemID,
|
| "name": serviceName,
|
| })
|
| if err != nil {
|
| return e.NotFoundError("", err)
|
| }
|
| details, err := system.FetchSystemdInfoFromAgent(serviceName)
|
| if err != nil {
|
| return e.InternalServerError("", err)
|
| }
|
| e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
| return e.JSON(http.StatusOK, map[string]any{"details": details})
|
| }
|
|
|
|
|
|
|
| func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
| systemID := e.Request.URL.Query().Get("system")
|
| if systemID == "" {
|
| return e.BadRequestError("Invalid system parameter", nil)
|
| }
|
|
|
| system, err := h.sm.GetSystem(systemID)
|
| if err != nil || !system.HasUser(e.App, e.Auth) {
|
| return e.NotFoundError("", nil)
|
| }
|
|
|
| if err := system.FetchAndSaveSmartDevices(); err != nil {
|
| return e.InternalServerError("", err)
|
| }
|
|
|
| return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
| }
|
|
|