Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,33 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
// PROTECTED ROUTES //
//////////////////////
// ORDER IS IMPORTANT: all routes applied AFTER the security middleware will require authn
protectedAPIRouter := apiRouter.Group("/")
if cfg.Security != nil {
if err := cfg.Security.RegisterHandlers(app); err != nil {
panic(err)
}
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
panic(err)
if cfg.Security.IsGlobal() {
// Global auth: protect all API routes with middleware
protectedAPIRouter := apiRouter.Group("/")
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
panic(err)
}
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
protectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
protectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
} else {
// Endpoint-level auth: routes are accessible but data is filtered
unprotectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
unprotectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
unprotectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
}
} else {
// No security: all routes accessible
unprotectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
unprotectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
unprotectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
}
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
protectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
protectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
return app
}
5 changes: 3 additions & 2 deletions api/badge.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ func HealthBadge(c *fiber.Ctx) error {
return c.Status(400).SendString("invalid key encoding")
}
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
status, err := store.Get().GetEndpointStatusByKey(key, true, pagingConfig.WithResults(1, 1))

if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
Expand Down Expand Up @@ -148,7 +149,7 @@ func HealthBadgeShields(c *fiber.Ctx) error {
return c.Status(400).SendString("invalid key encoding")
}
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
status, err := store.Get().GetEndpointStatusByKey(key, true, pagingConfig.WithResults(1, 1))
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
Expand Down
6 changes: 6 additions & 0 deletions api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,21 @@ type ConfigHandler struct {

func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
hasOIDC := false
hasBasic := false
authLevel := ""
isAuthenticated := true // Default to true if no security config is set
if handler.securityConfig != nil {
hasOIDC = handler.securityConfig.OIDC != nil
hasBasic = handler.securityConfig.Basic != nil
authLevel = handler.securityConfig.Level
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
}

// Prepare response with announcements
response := map[string]interface{}{
"oidc": hasOIDC,
"basic": hasBasic,
"authLevel": authLevel,
"authenticated": isAuthenticated,
}
// Add announcements if available, otherwise use empty slice
Expand Down
14 changes: 10 additions & 4 deletions api/endpoint_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ import (
// Due to how intensive this operation can be on the storage, this function leverages a cache.
func EndpointStatuses(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
// If no security is configured, user is considered authenticated (full access)
// If security is configured at endpoint level, check authentication status
userAuthenticated := cfg.Security == nil || cfg.Security.IsAuthenticated(c)
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
// Include authentication status in cache key to separate public/private cached results
cacheKey := fmt.Sprintf("endpoint-status-%d-%d-auth-%t", page, pageSize, userAuthenticated)
value, exists := cache.Get(cacheKey)
var data []byte
if !exists {
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
endpointStatuses, err := store.Get().GetAllEndpointStatuses(!userAuthenticated, paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil {
logr.Errorf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
return c.Status(500).SendString(err.Error())
Expand All @@ -42,7 +47,7 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
logr.Errorf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(cacheKey, data, cacheTTL)
} else {
data = value.([]byte)
}
Expand Down Expand Up @@ -86,13 +91,14 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
// EndpointStatus retrieves a single endpoint.Status by group and endpoint name
func EndpointStatus(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
userAuthenticated := cfg.Security == nil || cfg.Security.IsAuthenticated(c)
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
logr.Errorf("[api.EndpointStatus] Failed to decode key: %s", err.Error())
return c.Status(400).SendString("invalid key encoding")
}
endpointStatus, err := store.Get().GetEndpointStatusByKey(key, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
endpointStatus, err := store.Get().GetEndpointStatusByKey(key, !userAuthenticated, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
Expand Down
20 changes: 15 additions & 5 deletions api/suite_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import (
// SuiteStatuses handles requests to retrieve all suite statuses
func SuiteStatuses(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
// Check authentication status - if no security or authenticated, user can see all
// If security exists at endpoint level and user is not authenticated, only show public
userAuthenticated := cfg.Security == nil || cfg.Security.IsAuthenticated(c)
page, pageSize := extractPageAndPageSizeFromRequest(c, 100)
params := paging.NewSuiteStatusParams().WithPagination(page, pageSize)
suiteStatuses, err := store.Get().GetAllSuiteStatuses(params)
suiteStatuses, err := store.Get().GetAllSuiteStatuses(!userAuthenticated, params)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Failed to retrieve suite statuses: %v", err),
Expand All @@ -25,7 +28,10 @@ func SuiteStatuses(cfg *config.Config) fiber.Handler {
if len(suiteStatuses) == 0 {
for _, s := range cfg.Suites {
if s.IsEnabled() {
suiteStatuses = append(suiteStatuses, suite.NewStatus(s))
// Only include suites that match the visibility filter
if userAuthenticated || s.Public {
suiteStatuses = append(suiteStatuses, suite.NewStatus(s))
}
}
}
}
Expand All @@ -36,16 +42,20 @@ func SuiteStatuses(cfg *config.Config) fiber.Handler {
// SuiteStatus handles requests to retrieve a single suite's status
func SuiteStatus(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
userAuthenticated := cfg.Security == nil || cfg.Security.IsAuthenticated(c)
page, pageSize := extractPageAndPageSizeFromRequest(c, 100)
key := c.Params("key")
params := paging.NewSuiteStatusParams().WithPagination(page, pageSize)
status, err := store.Get().GetSuiteStatusByKey(key, params)
status, err := store.Get().GetSuiteStatusByKey(key, !userAuthenticated, params)
if err != nil || status == nil {
// Try to find the suite in config
for _, s := range cfg.Suites {
if s.Key() == key {
status = suite.NewStatus(s)
break
// Check visibility - only return if user is authenticated or suite is public
if userAuthenticated || s.Public {
status = suite.NewStatus(s)
break
}
}
}
if status == nil {
Expand Down
5 changes: 3 additions & 2 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import (
"github.com/TwiN/gatus/v5/config/tunneling"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -1864,8 +1865,8 @@ endpoints:
}
if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash {
t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordBcryptHashBase64Encoded)
}
}
}}


func TestParseAndValidateConfigBytesWithLiteralDollarSign(t *testing.T) {
os.Setenv("GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign", "whatever")
Expand Down
5 changes: 4 additions & 1 deletion config/endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ type Endpoint struct {
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
LastReminderSent time.Time `yaml:"-"`

// Public mark an endpoint as public, unauthenticated users will be able to see them
Public bool `yaml:"public,omitempty"`

///////////////////////
// SUITE-ONLY FIELDS //
///////////////////////
Expand Down Expand Up @@ -288,7 +291,7 @@ func (e *Endpoint) EvaluateHealth() *Result {

// EvaluateHealthWithContext sends a request to the endpoint's URL with context support and evaluates the conditions
func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
result := &Result{Success: true, Errors: []string{}}
result := &Result{Success: true, Errors: []string{}, Public: e.Public}
// Preprocess the endpoint with context if provided
processedEndpoint := e
if context != nil {
Expand Down
3 changes: 3 additions & 0 deletions config/endpoint/external_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ type ExternalEndpoint struct {

// NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"`

// Public mark an endpoint as public, unauthenticated users will be able to see them
Public bool `yaml:"public,omitempty"`
}

// ValidateAndSetDefaults validates the ExternalEndpoint and sets the default values
Expand Down
3 changes: 3 additions & 0 deletions config/endpoint/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ type Result struct {
// Name of the endpoint (ONLY USED FOR SUITES)
// Group is not needed because it's inherited from the suite
Name string `json:"name,omitempty"`

// Public mark an endpoint as public, unauthenticated users will be able to see them
Public bool
}

// AddError adds an error to the result's list of errors.
Expand Down
22 changes: 21 additions & 1 deletion config/endpoint/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ type Status struct {
// Events is a list of events
Events []*Event `json:"events,omitempty"`

// Public mark an endpoint as public, unauthenticated users will be able to see them
Public bool `yaml:"public,omitempty"`

// Uptime information on the endpoint's uptime
//
// Used by the memory store.
Expand All @@ -29,13 +32,30 @@ type Status struct {
}

// NewStatus creates a new Status
func NewStatus(group, name string) *Status {
func NewStatus(group, name string, public bool) *Status {
return &Status{
Name: name,
Group: group,
Key: key.ConvertGroupAndNameToKey(group, name),
Results: make([]*Result, 0),
Events: make([]*Event, 0),
Public: public,
Uptime: NewUptime(),
}
}


// EndpointStatusVisibility is a DTO used to manage an EndpointStatus visibility
type EndpointStatusVisibility struct {
// Key of the Endpoint
Key string
// Public mark an endpoint as public, unauthenticated users will be able to see them
Public bool
}

func NewEndpointStatusVisibility(key string, public bool) *EndpointStatusVisibility {
return &EndpointStatusVisibility{
Key: key,
Public: public,
}
}
3 changes: 3 additions & 0 deletions config/suite/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type Result struct {

// Errors contains any suite-level errors
Errors []string `json:"errors,omitempty"`

// Public marks a suite as public, unauthenticated users will be able to see them
Public bool `json:"public,omitempty"`
}

// AddError adds an error to the suite result
Expand Down
4 changes: 4 additions & 0 deletions config/suite/suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ type Suite struct {

// Endpoints in the suite (executed sequentially)
Endpoints []*endpoint.Endpoint `yaml:"endpoints"`

// Public marks a suite as public, unauthenticated users will be able to see them
Public bool `yaml:"public,omitempty"`
}

// IsEnabled returns whether the suite is enabled
Expand Down Expand Up @@ -124,6 +127,7 @@ func (s *Suite) Execute() *Result {
Success: true,
Timestamp: start,
EndpointResults: make([]*endpoint.Result, 0, len(s.Endpoints)),
Public: s.Public,
}
// Set up timeout for the entire suite execution
timeoutChan := time.After(s.Timeout)
Expand Down
19 changes: 19 additions & 0 deletions config/suite/suite_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type Status struct {

// Results is the list of suite execution results
Results []*Result `json:"results"`

// Public marks a suite as public, unauthenticated users will be able to see them
Public bool `json:"public,omitempty"`
}

// NewStatus creates a new Status for a given Suite
Expand All @@ -22,5 +25,21 @@ func NewStatus(s *Suite) *Status {
Group: s.Group,
Key: s.Key(),
Results: []*Result{},
Public: s.Public,
}
}

// SuiteStatusVisibility is a DTO used to manage a SuiteStatus visibility
type SuiteStatusVisibility struct {
// Key of the Suite
Key string
// Public marks a suite as public, unauthenticated users will be able to see them
Public bool
}

func NewSuiteStatusVisibility(key string, public bool) *SuiteStatusVisibility {
return &SuiteStatusVisibility{
Key: key,
Public: public,
}
}
27 changes: 24 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"time"

"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/controller"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
Expand Down Expand Up @@ -115,23 +117,42 @@ func initializeStorage(cfg *config.Config) {
}
// Remove all EndpointStatus that represent endpoints which no longer exist in the configuration
var keys []string
var visibility []*endpoint.EndpointStatusVisibility
for _, ep := range cfg.Endpoints {
keys = append(keys, ep.Key())
key := ep.Key()
keys = append(keys, key)
visibility = append(visibility, endpoint.NewEndpointStatusVisibility(key, ep.Public))
}
for _, ee := range cfg.ExternalEndpoints {
keys = append(keys, ee.Key())
key := ee.Key()
keys = append(keys, key)
visibility = append(visibility, endpoint.NewEndpointStatusVisibility(key, ee.Public))
}
// Also add endpoints that are part of suites
for _, suite := range cfg.Suites {
for _, ep := range suite.Endpoints {
keys = append(keys, ep.Key())
key := ep.Key()
keys = append(keys, key)
visibility = append(visibility, endpoint.NewEndpointStatusVisibility(key, ep.Public))
}
}
logr.Infof("[main.initializeStorage] Total endpoint keys to preserve: %d", len(keys))
numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys)
if numberOfEndpointStatusesDeleted > 0 {
logr.Infof("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted)
}
// Update endpoint status visibility
logr.Infof("[main.initializeStorage] Total endpoint to update visibility: %d", len(visibility))
store.Get().UpdateEndpointStatusVisibility(visibility)

// Update suite status visibility
var suiteVisibility []*suite.SuiteStatusVisibility
for _, s := range cfg.Suites {
suiteVisibility = append(suiteVisibility, suite.NewSuiteStatusVisibility(s.Key(), s.Public))
}
logr.Infof("[main.initializeStorage] Total suites to update visibility: %d", len(suiteVisibility))
store.Get().UpdateSuiteStatusVisibility(suiteVisibility)

// Clean up the triggered alerts from the storage provider and load valid triggered endpoint alerts
numberOfPersistedTriggeredAlertsLoaded := 0
for _, ep := range cfg.Endpoints {
Expand Down
Loading