mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
Initial commit: Beszel fork with Domain Locker integration
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
package monitors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// APIHandler handles monitor API endpoints
|
||||
type APIHandler struct {
|
||||
app core.App
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
// NewAPIHandler creates a new monitor API handler
|
||||
func NewAPIHandler(app core.App, scheduler *Scheduler) *APIHandler {
|
||||
return &APIHandler{
|
||||
app: app,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers monitor API routes
|
||||
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
|
||||
api := se.Router.Group("/api/beszel/monitors")
|
||||
|
||||
// Require auth for all routes
|
||||
api.BindFunc(func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return e.UnauthorizedError("Authentication required", nil)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
// CRUD endpoints
|
||||
api.GET("", h.listMonitors)
|
||||
api.POST("", h.createMonitor)
|
||||
api.GET("/:id", h.getMonitor)
|
||||
api.PATCH("/:id", h.updateMonitor)
|
||||
api.DELETE("/:id", h.deleteMonitor)
|
||||
|
||||
// Action endpoints
|
||||
api.POST("/:id/check", h.manualCheck)
|
||||
api.POST("/:id/pause", h.pauseMonitor)
|
||||
api.POST("/:id/resume", h.resumeMonitor)
|
||||
api.GET("/:id/stats", h.getStats)
|
||||
api.GET("/:id/heartbeats", h.getHeartbeats)
|
||||
}
|
||||
|
||||
// MonitorResponse represents a monitor in API responses
|
||||
type MonitorResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Interval int `json:"interval"`
|
||||
Timeout int `json:"timeout"`
|
||||
Retries int `json:"retries"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
Description string `json:"description,omitempty"`
|
||||
LastCheck *time.Time `json:"last_check,omitempty"`
|
||||
UptimeStats map[string]float64 `json:"uptime_stats,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
JSONQuery string `json:"json_query,omitempty"`
|
||||
ExpectedValue string `json:"expected_value,omitempty"`
|
||||
InvertKeyword bool `json:"invert_keyword"`
|
||||
DNSResolveServer string `json:"dns_resolve_server,omitempty"`
|
||||
DNSResolverMode string `json:"dns_resolver_mode,omitempty"`
|
||||
CertExpiryNotification bool `json:"cert_expiry_notification"`
|
||||
CertExpiryDays int `json:"cert_expiry_days,omitempty"`
|
||||
IgnoreTLSError bool `json:"ignore_tls_error"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// CreateMonitorRequest represents a request to create a monitor
|
||||
type CreateMonitorRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Headers string `json:"headers,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Interval int `json:"interval"`
|
||||
Timeout int `json:"timeout"`
|
||||
Retries int `json:"retries,omitempty"`
|
||||
RetryInterval int `json:"retry_interval,omitempty"`
|
||||
MaxRedirects int `json:"max_redirects,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
JSONQuery string `json:"json_query,omitempty"`
|
||||
ExpectedValue string `json:"expected_value,omitempty"`
|
||||
InvertKeyword bool `json:"invert_keyword,omitempty"`
|
||||
DNSResolveServer string `json:"dns_resolve_server,omitempty"`
|
||||
DNSResolverMode string `json:"dns_resolver_mode,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CertExpiryNotification bool `json:"cert_expiry_notification,omitempty"`
|
||||
CertExpiryDays int `json:"cert_expiry_days,omitempty"`
|
||||
IgnoreTLSError bool `json:"ignore_tls_error,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateMonitorRequest represents a request to update a monitor
|
||||
type UpdateMonitorRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Port *int `json:"port,omitempty"`
|
||||
Method *string `json:"method,omitempty"`
|
||||
Headers *string `json:"headers,omitempty"`
|
||||
Body *string `json:"body,omitempty"`
|
||||
Interval *int `json:"interval,omitempty"`
|
||||
Timeout *int `json:"timeout,omitempty"`
|
||||
Retries *int `json:"retries,omitempty"`
|
||||
RetryInterval *int `json:"retry_interval,omitempty"`
|
||||
MaxRedirects *int `json:"max_redirects,omitempty"`
|
||||
Keyword *string `json:"keyword,omitempty"`
|
||||
JSONQuery *string `json:"json_query,omitempty"`
|
||||
ExpectedValue *string `json:"expected_value,omitempty"`
|
||||
InvertKeyword *bool `json:"invert_keyword,omitempty"`
|
||||
DNSResolveServer *string `json:"dns_resolve_server,omitempty"`
|
||||
DNSResolverMode *string `json:"dns_resolver_mode,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CertExpiryNotification *bool `json:"cert_expiry_notification,omitempty"`
|
||||
CertExpiryDays *int `json:"cert_expiry_days,omitempty"`
|
||||
IgnoreTLSError *bool `json:"ignore_tls_error,omitempty"`
|
||||
}
|
||||
|
||||
// listMonitors returns all monitors for the authenticated user
|
||||
func (h *APIHandler) listMonitors(e *core.RequestEvent) error {
|
||||
userID := e.Auth.Id
|
||||
|
||||
records, err := h.app.FindRecordsByFilter(
|
||||
"monitors",
|
||||
"user = {:userId}",
|
||||
"-created",
|
||||
0,
|
||||
0,
|
||||
map[string]any{"userId": userID},
|
||||
)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to fetch monitors", err)
|
||||
}
|
||||
|
||||
monitors := make([]MonitorResponse, 0, len(records))
|
||||
for _, record := range records {
|
||||
monitors = append(monitors, recordToResponse(record))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||
"monitors": monitors,
|
||||
})
|
||||
}
|
||||
|
||||
// getMonitor returns a single monitor by ID
|
||||
func (h *APIHandler) getMonitor(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
if id == "" {
|
||||
return e.BadRequestError("Monitor ID is required", nil)
|
||||
}
|
||||
|
||||
record, err := h.app.FindRecordById("monitors", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Monitor not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if record.GetString("user") != e.Auth.Id {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, recordToResponse(record))
|
||||
}
|
||||
|
||||
// createMonitor creates a new monitor
|
||||
func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
|
||||
var req CreateMonitorRequest
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("Invalid request body", err)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Name == "" || req.Type == "" {
|
||||
return e.BadRequestError("Name and type are required", nil)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.Interval == 0 {
|
||||
req.Interval = 60
|
||||
}
|
||||
if req.Timeout == 0 {
|
||||
req.Timeout = 30
|
||||
}
|
||||
if req.Retries == 0 {
|
||||
req.Retries = 1
|
||||
}
|
||||
|
||||
// Get collection
|
||||
collection, err := h.app.FindCollectionByNameOrId("monitors")
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to get collection", err)
|
||||
}
|
||||
|
||||
// Create record
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("name", req.Name)
|
||||
record.Set("type", req.Type)
|
||||
record.Set("url", req.URL)
|
||||
record.Set("hostname", req.Hostname)
|
||||
record.Set("port", req.Port)
|
||||
record.Set("method", req.Method)
|
||||
record.Set("headers", req.Headers)
|
||||
record.Set("body", req.Body)
|
||||
record.Set("interval", req.Interval)
|
||||
record.Set("timeout", req.Timeout)
|
||||
record.Set("retries", req.Retries)
|
||||
record.Set("retry_interval", req.RetryInterval)
|
||||
record.Set("max_redirects", req.MaxRedirects)
|
||||
record.Set("keyword", req.Keyword)
|
||||
record.Set("json_query", req.JSONQuery)
|
||||
record.Set("expected_value", req.ExpectedValue)
|
||||
record.Set("invert_keyword", req.InvertKeyword)
|
||||
record.Set("dns_resolve_server", req.DNSResolveServer)
|
||||
record.Set("dns_resolver_mode", req.DNSResolverMode)
|
||||
record.Set("status", string(monitor.StatusPending))
|
||||
record.Set("active", true)
|
||||
record.Set("user", e.Auth.Id)
|
||||
record.Set("description", req.Description)
|
||||
record.Set("tags", req.Tags)
|
||||
record.Set("cert_expiry_notification", req.CertExpiryNotification)
|
||||
record.Set("cert_expiry_days", req.CertExpiryDays)
|
||||
record.Set("ignore_tls_error", req.IgnoreTLSError)
|
||||
record.Set("uptime_stats", map[string]float64{})
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("Failed to create monitor", err)
|
||||
}
|
||||
|
||||
// Add to scheduler
|
||||
h.scheduler.AddMonitor(record)
|
||||
|
||||
return e.JSON(http.StatusCreated, recordToResponse(record))
|
||||
}
|
||||
|
||||
// updateMonitor updates an existing monitor
|
||||
func (h *APIHandler) updateMonitor(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
if id == "" {
|
||||
return e.BadRequestError("Monitor ID is required", nil)
|
||||
}
|
||||
|
||||
record, err := h.app.FindRecordById("monitors", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Monitor not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if record.GetString("user") != e.Auth.Id {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
var req UpdateMonitorRequest
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("Invalid request body", err)
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Name != nil {
|
||||
record.Set("name", *req.Name)
|
||||
}
|
||||
if req.URL != nil {
|
||||
record.Set("url", *req.URL)
|
||||
}
|
||||
if req.Hostname != nil {
|
||||
record.Set("hostname", *req.Hostname)
|
||||
}
|
||||
if req.Port != nil {
|
||||
record.Set("port", *req.Port)
|
||||
}
|
||||
if req.Method != nil {
|
||||
record.Set("method", *req.Method)
|
||||
}
|
||||
if req.Headers != nil {
|
||||
record.Set("headers", *req.Headers)
|
||||
}
|
||||
if req.Body != nil {
|
||||
record.Set("body", *req.Body)
|
||||
}
|
||||
if req.Interval != nil {
|
||||
record.Set("interval", *req.Interval)
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
record.Set("timeout", *req.Timeout)
|
||||
}
|
||||
if req.Retries != nil {
|
||||
record.Set("retries", *req.Retries)
|
||||
}
|
||||
if req.RetryInterval != nil {
|
||||
record.Set("retry_interval", *req.RetryInterval)
|
||||
}
|
||||
if req.MaxRedirects != nil {
|
||||
record.Set("max_redirects", *req.MaxRedirects)
|
||||
}
|
||||
if req.Keyword != nil {
|
||||
record.Set("keyword", *req.Keyword)
|
||||
}
|
||||
if req.JSONQuery != nil {
|
||||
record.Set("json_query", *req.JSONQuery)
|
||||
}
|
||||
if req.ExpectedValue != nil {
|
||||
record.Set("expected_value", *req.ExpectedValue)
|
||||
}
|
||||
if req.InvertKeyword != nil {
|
||||
record.Set("invert_keyword", *req.InvertKeyword)
|
||||
}
|
||||
if req.DNSResolveServer != nil {
|
||||
record.Set("dns_resolve_server", *req.DNSResolveServer)
|
||||
}
|
||||
if req.DNSResolverMode != nil {
|
||||
record.Set("dns_resolver_mode", *req.DNSResolverMode)
|
||||
}
|
||||
if req.Active != nil {
|
||||
record.Set("active", *req.Active)
|
||||
}
|
||||
if req.Description != nil {
|
||||
record.Set("description", *req.Description)
|
||||
}
|
||||
if req.Tags != nil {
|
||||
record.Set("tags", req.Tags)
|
||||
}
|
||||
if req.CertExpiryNotification != nil {
|
||||
record.Set("cert_expiry_notification", *req.CertExpiryNotification)
|
||||
}
|
||||
if req.CertExpiryDays != nil {
|
||||
record.Set("cert_expiry_days", *req.CertExpiryDays)
|
||||
}
|
||||
if req.IgnoreTLSError != nil {
|
||||
record.Set("ignore_tls_error", *req.IgnoreTLSError)
|
||||
}
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("Failed to update monitor", err)
|
||||
}
|
||||
|
||||
// Update scheduler
|
||||
h.scheduler.UpdateMonitor(record)
|
||||
|
||||
return e.JSON(http.StatusOK, recordToResponse(record))
|
||||
}
|
||||
|
||||
// deleteMonitor deletes a monitor
|
||||
func (h *APIHandler) deleteMonitor(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
if id == "" {
|
||||
return e.BadRequestError("Monitor ID is required", nil)
|
||||
}
|
||||
|
||||
record, err := h.app.FindRecordById("monitors", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Monitor not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if record.GetString("user") != e.Auth.Id {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
// Remove from scheduler first
|
||||
h.scheduler.RemoveMonitor(id)
|
||||
|
||||
if err := h.app.Delete(record); err != nil {
|
||||
return e.InternalServerError("Failed to delete monitor", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"message": "Monitor deleted"})
|
||||
}
|
||||
|
||||
// manualCheck runs a manual check for a monitor
|
||||
func (h *APIHandler) manualCheck(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
if id == "" {
|
||||
return e.BadRequestError("Monitor ID is required", nil)
|
||||
}
|
||||
|
||||
record, err := h.app.FindRecordById("monitors", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Monitor not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if record.GetString("user") != e.Auth.Id {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
result, err := h.scheduler.RunManualCheck(id)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Check failed", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": result.Status,
|
||||
"ping": result.Ping,
|
||||
"msg": result.Msg,
|
||||
})
|
||||
}
|
||||
|
||||
// pauseMonitor pauses a monitor
|
||||
func (h *APIHandler) pauseMonitor(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
if id == "" {
|
||||
return e.BadRequestError("Monitor ID is required", nil)
|
||||
}
|
||||
|
||||
record, err := h.app.FindRecordById("monitors", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Monitor not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if record.GetString("user") != e.Auth.Id {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
record.Set("active", false)
|
||||
record.Set("status", string(monitor.StatusPaused))
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("Failed to pause monitor", err)
|
||||
}
|
||||
|
||||
h.scheduler.UpdateMonitor(record)
|
||||
|
||||
return e.JSON(http.StatusOK, recordToResponse(record))
|
||||
}
|
||||
|
||||
// resumeMonitor resumes a paused monitor
|
||||
func (h *APIHandler) resumeMonitor(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
if id == "" {
|
||||
return e.BadRequestError("Monitor ID is required", nil)
|
||||
}
|
||||
|
||||
record, err := h.app.FindRecordById("monitors", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Monitor not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if record.GetString("user") != e.Auth.Id {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
record.Set("active", true)
|
||||
record.Set("status", string(monitor.StatusPending))
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("Failed to resume monitor", err)
|
||||
}
|
||||
|
||||
h.scheduler.UpdateMonitor(record)
|
||||
|
||||
return e.JSON(http.StatusOK, recordToResponse(record))
|
||||
}
|
||||
|
||||
// getStats returns uptime statistics for a monitor
|
||||
func (h *APIHandler) getStats(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
if id == "" {
|
||||
return e.BadRequestError("Monitor ID is required", nil)
|
||||
}
|
||||
|
||||
record, err := h.app.FindRecordById("monitors", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Monitor not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if record.GetString("user") != e.Auth.Id {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
stats24h, _ := h.scheduler.GetUptimeStats(id, 24)
|
||||
stats7d, _ := h.scheduler.GetUptimeStats(id, 168)
|
||||
stats30d, _ := h.scheduler.GetUptimeStats(id, 720)
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||
"uptime_24h": stats24h,
|
||||
"uptime_7d": stats7d,
|
||||
"uptime_30d": stats30d,
|
||||
})
|
||||
}
|
||||
|
||||
// getHeartbeats returns recent heartbeats for a monitor
|
||||
func (h *APIHandler) getHeartbeats(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
if id == "" {
|
||||
return e.BadRequestError("Monitor ID is required", nil)
|
||||
}
|
||||
|
||||
record, err := h.app.FindRecordById("monitors", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Monitor not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if record.GetString("user") != e.Auth.Id {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
// Get limit from query, default 100
|
||||
limit := 100
|
||||
|
||||
records, err := h.app.FindRecordsByFilter(
|
||||
"monitor_heartbeats",
|
||||
"monitor = {:monitorId}",
|
||||
"-time",
|
||||
0,
|
||||
limit,
|
||||
map[string]any{"monitorId": id},
|
||||
)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to fetch heartbeats", err)
|
||||
}
|
||||
|
||||
heartbeats := make([]map[string]interface{}, 0, len(records))
|
||||
for _, hb := range records {
|
||||
heartbeats = append(heartbeats, map[string]interface{}{
|
||||
"id": hb.Id,
|
||||
"status": hb.GetString("status"),
|
||||
"ping": hb.GetInt("ping"),
|
||||
"msg": hb.GetString("msg"),
|
||||
"cert_expiry": hb.GetInt("cert_expiry"),
|
||||
"cert_valid": hb.GetBool("cert_valid"),
|
||||
"time": hb.Get("time"),
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||
"heartbeats": heartbeats,
|
||||
})
|
||||
}
|
||||
|
||||
// recordToResponse converts a PocketBase record to MonitorResponse
|
||||
func recordToResponse(record *core.Record) MonitorResponse {
|
||||
resp := MonitorResponse{
|
||||
ID: record.Id,
|
||||
Name: record.GetString("name"),
|
||||
Type: record.GetString("type"),
|
||||
URL: record.GetString("url"),
|
||||
Hostname: record.GetString("hostname"),
|
||||
Port: record.GetInt("port"),
|
||||
Method: record.GetString("method"),
|
||||
Interval: record.GetInt("interval"),
|
||||
Timeout: record.GetInt("timeout"),
|
||||
Retries: record.GetInt("retries"),
|
||||
Status: record.GetString("status"),
|
||||
Active: record.GetBool("active"),
|
||||
Description: record.GetString("description"),
|
||||
Keyword: record.GetString("keyword"),
|
||||
JSONQuery: record.GetString("json_query"),
|
||||
ExpectedValue: record.GetString("expected_value"),
|
||||
InvertKeyword: record.GetBool("invert_keyword"),
|
||||
DNSResolveServer: record.GetString("dns_resolve_server"),
|
||||
DNSResolverMode: record.GetString("dns_resolver_mode"),
|
||||
CertExpiryNotification: record.GetBool("cert_expiry_notification"),
|
||||
CertExpiryDays: record.GetInt("cert_expiry_days"),
|
||||
IgnoreTLSError: record.GetBool("ignore_tls_error"),
|
||||
Created: record.GetDateTime("created").Time(),
|
||||
Updated: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
|
||||
// Handle last_check
|
||||
if lc := record.Get("last_check"); lc != nil {
|
||||
if t, ok := lc.(time.Time); ok {
|
||||
resp.LastCheck = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uptime_stats
|
||||
if stats := record.Get("uptime_stats"); stats != nil {
|
||||
if s, ok := stats.(map[string]float64); ok {
|
||||
resp.UptimeStats = s
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if tags := record.Get("tags"); tags != nil {
|
||||
if t, ok := tags.([]string); ok {
|
||||
resp.Tags = t
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
)
|
||||
|
||||
// Checker defines the interface for monitor check implementations
|
||||
type Checker interface {
|
||||
Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult
|
||||
}
|
||||
|
||||
// CheckerRegistry holds all monitor type checkers
|
||||
type CheckerRegistry struct {
|
||||
checkers map[string]Checker
|
||||
}
|
||||
|
||||
// NewCheckerRegistry creates a new registry with all checkers registered
|
||||
func NewCheckerRegistry() *CheckerRegistry {
|
||||
registry := &CheckerRegistry{
|
||||
checkers: make(map[string]Checker),
|
||||
}
|
||||
|
||||
// Register all checkers
|
||||
registry.Register(monitor.TypeHTTP, &HTTPChecker{})
|
||||
registry.Register(monitor.TypeHTTPS, &HTTPChecker{IsHTTPS: true})
|
||||
registry.Register(monitor.TypeTCP, &TCPChecker{})
|
||||
registry.Register(monitor.TypePing, &PingChecker{})
|
||||
registry.Register(monitor.TypeDNS, &DNSChecker{})
|
||||
registry.Register(monitor.TypeKeyword, &KeywordChecker{})
|
||||
registry.Register(monitor.TypeJSONQuery, &JSONQueryChecker{})
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// Register adds a checker for a monitor type
|
||||
func (r *CheckerRegistry) Register(monitorType string, checker Checker) {
|
||||
r.checkers[monitorType] = checker
|
||||
}
|
||||
|
||||
// Get returns the checker for a monitor type
|
||||
func (r *CheckerRegistry) Get(monitorType string) (Checker, bool) {
|
||||
checker, ok := r.checkers[monitorType]
|
||||
return checker, ok
|
||||
}
|
||||
|
||||
// HTTPChecker performs HTTP/HTTPS checks
|
||||
type HTTPChecker struct {
|
||||
IsHTTPS bool
|
||||
}
|
||||
|
||||
// Check performs an HTTP/HTTPS check
|
||||
func (c *HTTPChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
start := time.Now()
|
||||
|
||||
// Parse URL
|
||||
checkURL := m.URL
|
||||
if checkURL == "" {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Msg: "URL is empty",
|
||||
Error: fmt.Errorf("URL is empty"),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure URL has scheme
|
||||
if !strings.HasPrefix(checkURL, "http://") && !strings.HasPrefix(checkURL, "https://") {
|
||||
if c.IsHTTPS {
|
||||
checkURL = "https://" + checkURL
|
||||
} else {
|
||||
checkURL = "http://" + checkURL
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
method := m.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, checkURL, strings.NewReader(m.Body))
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Msg: fmt.Sprintf("Failed to create request: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Add headers
|
||||
if m.Headers != "" {
|
||||
var headers map[string]string
|
||||
if err := json.Unmarshal([]byte(m.Headers), &headers); err == nil {
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create client with timeout and TLS config
|
||||
timeout := time.Duration(m.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
maxRedirects := m.MaxRedirects
|
||||
if maxRedirects == 0 {
|
||||
maxRedirects = 10
|
||||
}
|
||||
if len(via) >= maxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Configure TLS
|
||||
if c.IsHTTPS {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: m.IgnoreTLSError,
|
||||
}
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Msg: fmt.Sprintf("Request failed: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
ping := int(elapsed.Milliseconds())
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Ping: ping,
|
||||
Msg: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
// Check certificate if HTTPS and cert expiry notification enabled
|
||||
var certExpiry int
|
||||
var certValid bool
|
||||
if c.IsHTTPS && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||
cert := resp.TLS.PeerCertificates[0]
|
||||
certValid = true
|
||||
certExpiry = int(time.Until(cert.NotAfter).Hours() / 24)
|
||||
}
|
||||
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusUp,
|
||||
Ping: ping,
|
||||
Msg: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
CertExpiry: certExpiry,
|
||||
CertValid: certValid,
|
||||
}
|
||||
}
|
||||
|
||||
// TCPChecker performs TCP port checks
|
||||
type TCPChecker struct{}
|
||||
|
||||
// Check performs a TCP port check
|
||||
func (c *TCPChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
start := time.Now()
|
||||
|
||||
hostname := m.Hostname
|
||||
if hostname == "" {
|
||||
hostname = m.URL
|
||||
}
|
||||
|
||||
port := m.Port
|
||||
if port == 0 {
|
||||
port = 80
|
||||
}
|
||||
|
||||
address := fmt.Sprintf("%s:%d", hostname, port)
|
||||
|
||||
// Create dialer with timeout
|
||||
timeout := time.Duration(m.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Msg: fmt.Sprintf("Connection failed: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
ping := int(elapsed.Milliseconds())
|
||||
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusUp,
|
||||
Ping: ping,
|
||||
Msg: fmt.Sprintf("Connected in %dms", ping),
|
||||
}
|
||||
}
|
||||
|
||||
// PingChecker performs ICMP ping checks
|
||||
type PingChecker struct{}
|
||||
|
||||
// Check performs an ICMP ping check
|
||||
func (c *PingChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
start := time.Now()
|
||||
|
||||
hostname := m.Hostname
|
||||
if hostname == "" {
|
||||
hostname = m.URL
|
||||
}
|
||||
|
||||
// Parse hostname to remove any scheme
|
||||
hostname = strings.TrimPrefix(hostname, "http://")
|
||||
hostname = strings.TrimPrefix(hostname, "https://")
|
||||
hostname = strings.TrimSuffix(hostname, "/")
|
||||
|
||||
// Resolve the address
|
||||
resolver := &net.Resolver{}
|
||||
addrs, err := resolver.LookupHost(ctx, hostname)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Msg: fmt.Sprintf("DNS lookup failed: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
if len(addrs) == 0 {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Msg: "No IP addresses found",
|
||||
Error: fmt.Errorf("no IP addresses found"),
|
||||
}
|
||||
}
|
||||
|
||||
// Try to connect to port 7 (echo) or just check if host is reachable
|
||||
// Since raw ICMP requires root, we'll do a TCP connection to a common port
|
||||
address := net.JoinHostPort(addrs[0], "80")
|
||||
|
||||
timeout := time.Duration(m.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
// Try port 443
|
||||
address = net.JoinHostPort(addrs[0], "443")
|
||||
conn, err = net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Msg: fmt.Sprintf("Host unreachable: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
ping := int(elapsed.Milliseconds())
|
||||
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusUp,
|
||||
Ping: ping,
|
||||
Msg: fmt.Sprintf("Ping: %dms", ping),
|
||||
}
|
||||
}
|
||||
|
||||
// DNSChecker performs DNS resolution checks
|
||||
type DNSChecker struct{}
|
||||
|
||||
// Check performs a DNS resolution check
|
||||
func (c *DNSChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
start := time.Now()
|
||||
|
||||
hostname := m.Hostname
|
||||
if hostname == "" {
|
||||
hostname = m.URL
|
||||
}
|
||||
|
||||
// Remove scheme if present
|
||||
hostname = strings.TrimPrefix(hostname, "http://")
|
||||
hostname = strings.TrimPrefix(hostname, "https://")
|
||||
hostname = strings.TrimSuffix(hostname, "/")
|
||||
|
||||
// Use custom DNS server if specified
|
||||
resolver := &net.Resolver{}
|
||||
if m.DNSResolveServer != "" {
|
||||
resolver = &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
d := net.Dialer{}
|
||||
return d.DialContext(ctx, network, m.DNSResolveServer+":53")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
var results []string
|
||||
|
||||
// Perform DNS lookup based on record type
|
||||
recordType := m.DNSResolverMode
|
||||
if recordType == "" {
|
||||
recordType = "A"
|
||||
}
|
||||
|
||||
switch recordType {
|
||||
case "A", "AAAA":
|
||||
results, err = resolver.LookupHost(ctx, hostname)
|
||||
case "CNAME":
|
||||
var cname string
|
||||
cname, err = resolver.LookupCNAME(ctx, hostname)
|
||||
if err == nil && cname != "" {
|
||||
results = []string{cname}
|
||||
}
|
||||
case "MX":
|
||||
var mxRecords []*net.MX
|
||||
mxRecords, err = resolver.LookupMX(ctx, hostname)
|
||||
if err == nil {
|
||||
for _, mx := range mxRecords {
|
||||
results = append(results, fmt.Sprintf("%s (priority: %d)", mx.Host, mx.Pref))
|
||||
}
|
||||
}
|
||||
case "NS":
|
||||
var nsRecords []*net.NS
|
||||
nsRecords, err = resolver.LookupNS(ctx, hostname)
|
||||
if err == nil {
|
||||
for _, ns := range nsRecords {
|
||||
results = append(results, ns.Host)
|
||||
}
|
||||
}
|
||||
case "TXT":
|
||||
results, err = resolver.LookupTXT(ctx, hostname)
|
||||
case "SRV":
|
||||
// SRV requires service and protocol
|
||||
_, srvRecords, err := resolver.LookupSRV(ctx, "", "", hostname)
|
||||
if err == nil {
|
||||
for _, srv := range srvRecords {
|
||||
results = append(results, fmt.Sprintf("%s:%d (priority: %d, weight: %d)",
|
||||
srv.Target, srv.Port, srv.Priority, srv.Weight))
|
||||
}
|
||||
}
|
||||
default:
|
||||
results, err = resolver.LookupHost(ctx, hostname)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
ping := int(elapsed.Milliseconds())
|
||||
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Msg: fmt.Sprintf("DNS lookup failed: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusUp,
|
||||
Ping: ping,
|
||||
Msg: fmt.Sprintf("Resolved %d records in %dms", len(results), ping),
|
||||
}
|
||||
}
|
||||
|
||||
// KeywordChecker performs HTTP checks with keyword validation
|
||||
type KeywordChecker struct{}
|
||||
|
||||
// Check performs an HTTP check with keyword validation
|
||||
func (c *KeywordChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
// First do HTTP check
|
||||
httpChecker := &HTTPChecker{}
|
||||
result := httpChecker.Check(ctx, m)
|
||||
|
||||
if result.Status != monitor.StatusUp {
|
||||
return result
|
||||
}
|
||||
|
||||
// Now we need to fetch the body and check for keyword
|
||||
// Re-fetch the body since we closed it in HTTPChecker
|
||||
checkURL := m.URL
|
||||
if !strings.HasPrefix(checkURL, "http://") && !strings.HasPrefix(checkURL, "https://") {
|
||||
checkURL = "https://" + checkURL
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", checkURL, nil)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(m.Timeout) * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Ping: result.Ping,
|
||||
Msg: fmt.Sprintf("Failed to read body: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
keyword := m.Keyword
|
||||
found := strings.Contains(bodyStr, keyword)
|
||||
|
||||
// Handle invert keyword option
|
||||
if m.InvertKeyword {
|
||||
found = !found
|
||||
}
|
||||
|
||||
if !found {
|
||||
status := "not found"
|
||||
if m.InvertKeyword {
|
||||
status = "found (inverted)"
|
||||
}
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Ping: result.Ping,
|
||||
Msg: fmt.Sprintf("Keyword '%s' %s", keyword, status),
|
||||
Error: fmt.Errorf("keyword check failed"),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// JSONQueryChecker performs HTTP checks with JSON path validation
|
||||
type JSONQueryChecker struct{}
|
||||
|
||||
// Check performs an HTTP check with JSON path validation
|
||||
func (c *JSONQueryChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
// First do HTTP check
|
||||
httpChecker := &HTTPChecker{}
|
||||
result := httpChecker.Check(ctx, m)
|
||||
|
||||
if result.Status != monitor.StatusUp {
|
||||
return result
|
||||
}
|
||||
|
||||
// Re-fetch the body for JSON parsing
|
||||
checkURL := m.URL
|
||||
if !strings.HasPrefix(checkURL, "http://") && !strings.HasPrefix(checkURL, "https://") {
|
||||
checkURL = "https://" + checkURL
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", checkURL, nil)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(m.Timeout) * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Ping: result.Ping,
|
||||
Msg: fmt.Sprintf("Failed to read body: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Ping: result.Ping,
|
||||
Msg: fmt.Sprintf("Invalid JSON: %v", err),
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Simple path evaluation (supports dot notation like "data.status")
|
||||
path := m.JSONQuery
|
||||
expectedValue := m.ExpectedValue
|
||||
|
||||
value := evaluateJSONPath(data, path)
|
||||
|
||||
if expectedValue != "" && value != expectedValue {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusDown,
|
||||
Ping: result.Ping,
|
||||
Msg: fmt.Sprintf("Expected '%s' but got '%s'", expectedValue, value),
|
||||
Error: fmt.Errorf("JSON value mismatch"),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// evaluateJSONPath extracts a value from JSON using dot notation path
|
||||
func evaluateJSONPath(data interface{}, path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(path, ".")
|
||||
current := data
|
||||
|
||||
for _, part := range parts {
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
if val, ok := v[part]; ok {
|
||||
current = val
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
case []interface{}:
|
||||
// Try to parse as index
|
||||
if idx, err := strconv.Atoi(part); err == nil && idx >= 0 && idx < len(v) {
|
||||
current = v[idx]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Convert result to string
|
||||
switch v := current.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
case nil:
|
||||
return "null"
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// URLValidator validates URL format
|
||||
func URLValidator(urlStr string) error {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("missing host in URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsValidStatusCode checks if HTTP status code is valid for UP status
|
||||
func IsValidStatusCode(code int, validCodes []int) bool {
|
||||
if len(validCodes) == 0 {
|
||||
return code >= 200 && code < 300
|
||||
}
|
||||
for _, validCode := range validCodes {
|
||||
if code == validCode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractDomain extracts domain from URL or hostname
|
||||
func ExtractDomain(urlStr string) string {
|
||||
if urlStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to parse as URL first
|
||||
if u, err := url.Parse(urlStr); err == nil && u.Host != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// Remove scheme if present
|
||||
domain := urlStr
|
||||
domain = strings.TrimPrefix(domain, "http://")
|
||||
domain = strings.TrimPrefix(domain, "https://")
|
||||
domain = strings.TrimSuffix(domain, "/")
|
||||
|
||||
// Remove port if present
|
||||
if idx := strings.LastIndex(domain, ":"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
// ValidateRegex validates a regex pattern
|
||||
func ValidateRegex(pattern string) error {
|
||||
_, err := regexp.Compile(pattern)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package monitors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/henrygd/beszel/internal/hub/monitors/checks"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
)
|
||||
|
||||
// Scheduler manages the periodic execution of monitor checks
|
||||
type Scheduler struct {
|
||||
app core.App
|
||||
registry *checks.CheckerRegistry
|
||||
monitors *store.Store[string, *ScheduledMonitor]
|
||||
ticker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// ScheduledMonitor wraps a monitor with scheduling info
|
||||
type ScheduledMonitor struct {
|
||||
Monitor *monitor.Monitor
|
||||
NextCheck time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewScheduler creates a new monitor scheduler
|
||||
func NewScheduler(app core.App) *Scheduler {
|
||||
return &Scheduler{
|
||||
app: app,
|
||||
registry: checks.NewCheckerRegistry(),
|
||||
monitors: store.New(map[string]*ScheduledMonitor{}),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the scheduler loop
|
||||
func (s *Scheduler) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("scheduler already running")
|
||||
}
|
||||
|
||||
// Load active monitors from database
|
||||
if err := s.loadMonitors(); err != nil {
|
||||
return fmt.Errorf("failed to load monitors: %w", err)
|
||||
}
|
||||
|
||||
// Start the ticker (minimum 20 second resolution)
|
||||
s.ticker = time.NewTicker(20 * time.Second)
|
||||
s.running = true
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.run()
|
||||
|
||||
log.Println("[monitor-scheduler] Started")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop halts the scheduler
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
s.ticker.Stop()
|
||||
close(s.stopChan)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.wg.Wait()
|
||||
log.Println("[monitor-scheduler] Stopped")
|
||||
}
|
||||
|
||||
// run is the main scheduler loop
|
||||
func (s *Scheduler) run() {
|
||||
defer s.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ticker.C:
|
||||
s.checkMonitors()
|
||||
case <-s.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkMonitors checks all due monitors
|
||||
func (s *Scheduler) checkMonitors() {
|
||||
now := time.Now()
|
||||
|
||||
allMonitors := s.monitors.GetAll()
|
||||
for _, sm := range allMonitors {
|
||||
sm.mu.Lock()
|
||||
|
||||
// Skip if monitor is paused or not active
|
||||
if !sm.Monitor.Active || sm.Monitor.Status == monitor.StatusPaused {
|
||||
sm.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's time to run
|
||||
if now.Before(sm.NextCheck) {
|
||||
sm.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Schedule the next check
|
||||
interval := time.Duration(sm.Monitor.Interval) * time.Second
|
||||
if interval < 20*time.Second {
|
||||
interval = 20 * time.Second
|
||||
}
|
||||
sm.NextCheck = now.Add(interval)
|
||||
sm.mu.Unlock()
|
||||
|
||||
// Run check in background
|
||||
s.wg.Add(1)
|
||||
go func(m *monitor.Monitor) {
|
||||
defer s.wg.Done()
|
||||
s.runCheck(m)
|
||||
}(sm.Monitor)
|
||||
}
|
||||
}
|
||||
|
||||
// runCheck executes a single monitor check
|
||||
func (s *Scheduler) runCheck(m *monitor.Monitor) {
|
||||
// Get the appropriate checker
|
||||
checker, ok := s.registry.Get(m.Type)
|
||||
if !ok {
|
||||
log.Printf("[monitor-scheduler] No checker found for type: %s", m.Type)
|
||||
return
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
timeout := time.Duration(m.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Execute check
|
||||
result := checker.Check(ctx, m)
|
||||
|
||||
// Handle retries
|
||||
if result.Status == monitor.StatusDown && m.Retries > 0 {
|
||||
retryInterval := time.Duration(m.RetryInterval) * time.Second
|
||||
if retryInterval == 0 {
|
||||
retryInterval = time.Second
|
||||
}
|
||||
|
||||
for i := 0; i < m.Retries && result.Status == monitor.StatusDown; i++ {
|
||||
time.Sleep(retryInterval)
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||
result = checker.Check(ctx, m)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Save heartbeat and update monitor status
|
||||
if err := s.saveResult(m, result); err != nil {
|
||||
log.Printf("[monitor-scheduler] Failed to save result: %v", err)
|
||||
}
|
||||
|
||||
// Log result
|
||||
if result.Status == monitor.StatusUp {
|
||||
log.Printf("[monitor-scheduler] Check UP: %s (ping: %dms)", m.Name, result.Ping)
|
||||
} else {
|
||||
log.Printf("[monitor-scheduler] Check DOWN: %s - %s", m.Name, result.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
// saveResult saves the check result to the database
|
||||
func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult) error {
|
||||
// Update monitor record
|
||||
record, err := s.app.FindRecordById("monitors", m.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find monitor: %w", err)
|
||||
}
|
||||
|
||||
// Update status
|
||||
record.Set("status", string(result.Status))
|
||||
record.Set("last_check", time.Now())
|
||||
|
||||
// Calculate uptime stats (simplified - in production would aggregate from heartbeats)
|
||||
if m.UptimeStats == nil {
|
||||
m.UptimeStats = make(map[string]float64)
|
||||
}
|
||||
|
||||
// Simple rolling uptime calculation (can be improved)
|
||||
if result.Status == monitor.StatusUp {
|
||||
m.UptimeStats["total"] = m.UptimeStats["total"] + 1
|
||||
m.UptimeStats["up"] = m.UptimeStats["up"] + 1
|
||||
} else {
|
||||
m.UptimeStats["total"] = m.UptimeStats["total"] + 1
|
||||
m.UptimeStats["down"] = m.UptimeStats["down"] + 1
|
||||
}
|
||||
|
||||
if total := m.UptimeStats["total"]; total > 0 {
|
||||
m.UptimeStats["uptime_24h"] = (m.UptimeStats["up"] / total) * 100
|
||||
}
|
||||
|
||||
record.Set("uptime_stats", m.UptimeStats)
|
||||
|
||||
if err := s.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to update monitor: %w", err)
|
||||
}
|
||||
|
||||
// Create heartbeat record
|
||||
hbCollection, err := s.app.FindCollectionByNameOrId("monitor_heartbeats")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find heartbeats collection: %w", err)
|
||||
}
|
||||
|
||||
hbRecord := core.NewRecord(hbCollection)
|
||||
hbRecord.Set("monitor", m.ID)
|
||||
hbRecord.Set("status", string(result.Status))
|
||||
hbRecord.Set("ping", result.Ping)
|
||||
hbRecord.Set("msg", result.Msg)
|
||||
hbRecord.Set("cert_expiry", result.CertExpiry)
|
||||
hbRecord.Set("cert_valid", result.CertValid)
|
||||
hbRecord.Set("time", time.Now())
|
||||
|
||||
if err := s.app.Save(hbRecord); err != nil {
|
||||
return fmt.Errorf("failed to save heartbeat: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadMonitors loads active monitors from the database
|
||||
func (s *Scheduler) loadMonitors() error {
|
||||
records, err := s.app.FindRecordsByFilter("monitors", "active = true", "-created", 0, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query monitors: %w", err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
m := recordToMonitor(record)
|
||||
s.monitors.Set(m.ID, &ScheduledMonitor{
|
||||
Monitor: m,
|
||||
NextCheck: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("[monitor-scheduler] Loaded %d monitors", len(records))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMonitor adds a new monitor to the scheduler
|
||||
func (s *Scheduler) AddMonitor(record *core.Record) {
|
||||
m := recordToMonitor(record)
|
||||
|
||||
s.monitors.Set(m.ID, &ScheduledMonitor{
|
||||
Monitor: m,
|
||||
NextCheck: time.Now(),
|
||||
})
|
||||
|
||||
log.Printf("[monitor-scheduler] Added monitor: %s (%s)", m.Name, m.Type)
|
||||
}
|
||||
|
||||
// UpdateMonitor updates a monitor in the scheduler
|
||||
func (s *Scheduler) UpdateMonitor(record *core.Record) {
|
||||
m := recordToMonitor(record)
|
||||
|
||||
// Get existing scheduled monitor to preserve next check time if appropriate
|
||||
if sm, ok := s.monitors.GetOk(m.ID); ok {
|
||||
sm.mu.Lock()
|
||||
sm.Monitor = m
|
||||
sm.mu.Unlock()
|
||||
} else {
|
||||
s.monitors.Set(m.ID, &ScheduledMonitor{
|
||||
Monitor: m,
|
||||
NextCheck: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("[monitor-scheduler] Updated monitor: %s", m.Name)
|
||||
}
|
||||
|
||||
// RemoveMonitor removes a monitor from the scheduler
|
||||
func (s *Scheduler) RemoveMonitor(monitorID string) {
|
||||
s.monitors.Remove(monitorID)
|
||||
log.Printf("[monitor-scheduler] Removed monitor: %s", monitorID)
|
||||
}
|
||||
|
||||
// RunManualCheck runs a manual check for a monitor
|
||||
func (s *Scheduler) RunManualCheck(monitorID string) (*monitor.CheckResult, error) {
|
||||
// Get monitor from database
|
||||
record, err := s.app.FindRecordById("monitors", monitorID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("monitor not found: %w", err)
|
||||
}
|
||||
|
||||
m := recordToMonitor(record)
|
||||
|
||||
// Get checker
|
||||
checker, ok := s.registry.Get(m.Type)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no checker for type: %s", m.Type)
|
||||
}
|
||||
|
||||
// Run check
|
||||
timeout := time.Duration(m.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result := checker.Check(ctx, m)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetUptimeStats calculates uptime statistics for a monitor
|
||||
func (s *Scheduler) GetUptimeStats(monitorID string, hours int) (*monitor.UptimeStats, error) {
|
||||
// Query heartbeats from the last N hours
|
||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
"monitor_heartbeats",
|
||||
"monitor = {:monitorId} && time >= {:since}",
|
||||
"-time",
|
||||
0,
|
||||
0,
|
||||
map[string]any{
|
||||
"monitorId": monitorID,
|
||||
"since": since.Format("2006-01-02 15:04:05"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query heartbeats: %w", err)
|
||||
}
|
||||
|
||||
stats := &monitor.UptimeStats{}
|
||||
|
||||
for _, record := range records {
|
||||
stats.Total++
|
||||
status := record.GetString("status")
|
||||
if status == string(monitor.StatusUp) {
|
||||
stats.Up++
|
||||
} else if status == string(monitor.StatusDown) {
|
||||
stats.Down++
|
||||
}
|
||||
}
|
||||
|
||||
if stats.Total > 0 {
|
||||
uptime := float64(stats.Up) / float64(stats.Total) * 100
|
||||
switch hours {
|
||||
case 24:
|
||||
stats.Uptime24h = uptime
|
||||
case 168: // 7 days
|
||||
stats.Uptime7d = uptime
|
||||
case 720: // 30 days
|
||||
stats.Uptime30d = uptime
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// recordToMonitor converts a PocketBase record to a Monitor struct
|
||||
func recordToMonitor(record *core.Record) *monitor.Monitor {
|
||||
m := &monitor.Monitor{
|
||||
ID: record.Id,
|
||||
Name: record.GetString("name"),
|
||||
Type: record.GetString("type"),
|
||||
URL: record.GetString("url"),
|
||||
Hostname: record.GetString("hostname"),
|
||||
Port: record.GetInt("port"),
|
||||
Method: record.GetString("method"),
|
||||
Headers: record.GetString("headers"),
|
||||
Body: record.GetString("body"),
|
||||
Interval: record.GetInt("interval"),
|
||||
Timeout: record.GetInt("timeout"),
|
||||
Retries: record.GetInt("retries"),
|
||||
RetryInterval: record.GetInt("retry_interval"),
|
||||
MaxRedirects: record.GetInt("max_redirects"),
|
||||
Keyword: record.GetString("keyword"),
|
||||
JSONQuery: record.GetString("json_query"),
|
||||
ExpectedValue: record.GetString("expected_value"),
|
||||
InvertKeyword: record.GetBool("invert_keyword"),
|
||||
DNSResolveServer: record.GetString("dns_resolve_server"),
|
||||
DNSResolverMode: record.GetString("dns_resolver_mode"),
|
||||
Status: monitor.Status(record.GetString("status")),
|
||||
Active: record.GetBool("active"),
|
||||
UserID: record.GetString("user"),
|
||||
Description: record.GetString("description"),
|
||||
CertExpiryNotification: record.GetBool("cert_expiry_notification"),
|
||||
CertExpiryDays: record.GetInt("cert_expiry_days"),
|
||||
IgnoreTLSError: record.GetBool("ignore_tls_error"),
|
||||
}
|
||||
|
||||
// Parse JSON fields
|
||||
if tagsData := record.Get("tags"); tagsData != nil {
|
||||
if tags, ok := tagsData.([]string); ok {
|
||||
m.Tags = tags
|
||||
}
|
||||
}
|
||||
|
||||
if statsData := record.Get("uptime_stats"); statsData != nil {
|
||||
if stats, ok := statsData.(map[string]float64); ok {
|
||||
m.UptimeStats = stats
|
||||
}
|
||||
}
|
||||
|
||||
if lastCheck := record.Get("last_check"); lastCheck != nil {
|
||||
if t, ok := lastCheck.(time.Time); ok {
|
||||
m.LastCheck = t
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// CleanupOldHeartbeats removes heartbeats older than retention period
|
||||
func (s *Scheduler) CleanupOldHeartbeats(retentionDays int) error {
|
||||
cutoff := time.Now().AddDate(0, 0, -retentionDays)
|
||||
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
"monitor_heartbeats",
|
||||
"time < {:cutoff}",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
map[string]any{
|
||||
"cutoff": cutoff.Format("2006-01-02 15:04:05"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find old heartbeats: %w", err)
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, record := range records {
|
||||
if err := s.app.Delete(record); err == nil {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[monitor-scheduler] Cleaned up %d old heartbeats", deleted)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user