diff --git a/go.mod b/go.mod index 087d6ec..13ce334 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/spf13/pflag v1.0.5 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index a866903..5c8caf9 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= diff --git a/internal/config.go b/internal/config.go index 929e7c0..8ff9ca8 100644 --- a/internal/config.go +++ b/internal/config.go @@ -204,8 +204,8 @@ func initConfig(configFile string) error { HealthCheck: RouteHealthCheck{ Path: "/health/live", HealthyStatuses: []int{200, 404}, - Interval: 30, - Timeout: 10, + Interval: "30s", + Timeout: "10s", }, }, }, diff --git a/internal/handler.go b/internal/handler.go index 1fb556b..ac5b0f5 100644 --- a/internal/handler.go +++ b/internal/handler.go @@ -20,6 +20,7 @@ import ( "encoding/json" "github.com/gorilla/mux" "github.com/jkaninda/goma-gateway/pkg/logger" + "github.com/jkaninda/goma-gateway/util" "net/http" "sync" ) @@ -73,7 +74,13 @@ func (heathRoute HealthCheckRoute) HealthCheckHandler(w http.ResponseWriter, r * go func() { defer wg.Done() if route.HealthCheck.Path != "" { - err := healthCheck(route.Destination+route.HealthCheck.Path, route.HealthCheck.HealthyStatuses) + timeout, _ := util.ParseDuration(route.HealthCheck.Timeout) + health := Health{ + URL: route.Destination + route.HealthCheck.Path, + TimeOut: timeout, + HealthyStatuses: route.HealthCheck.HealthyStatuses, + } + err := health.Check() if err != nil { if heathRoute.DisableRouteHealthCheckError { routes = append(routes, HealthCheckRouteResponse{Name: route.Name, Status: "unhealthy", Error: "Route healthcheck errors disabled"}) diff --git a/internal/healthCheck.go b/internal/healthCheck.go index 518baf0..dca6abf 100644 --- a/internal/healthCheck.go +++ b/internal/healthCheck.go @@ -18,14 +18,23 @@ limitations under the License. import ( "fmt" "github.com/jkaninda/goma-gateway/pkg/logger" + "github.com/jkaninda/goma-gateway/util" + "github.com/robfig/cron/v3" "io" "net/http" "net/url" "slices" + "time" ) -func healthCheck(healthURL string, healthyStatuses []int) error { - healthCheckURL, err := url.Parse(healthURL) +type Health struct { + URL string + TimeOut time.Duration + HealthyStatuses []int +} + +func (health Health) Check() error { + healthCheckURL, err := url.Parse(health.URL) if err != nil { return fmt.Errorf("error parsing HealthCheck URL: %v ", err) } @@ -35,7 +44,7 @@ func healthCheck(healthURL string, healthyStatuses []int) error { return fmt.Errorf("error creating HealthCheck request: %v ", err) } // Perform the request to the route's healthcheck - client := &http.Client{} + client := &http.Client{Timeout: health.TimeOut} healthResp, err := client.Do(healthReq) if err != nil { logger.Error("Error performing HealthCheck request: %v ", err) @@ -46,16 +55,94 @@ func healthCheck(healthURL string, healthyStatuses []int) error { if err != nil { } }(healthResp.Body) - if len(healthyStatuses) > 0 { - if !slices.Contains(healthyStatuses, healthResp.StatusCode) { - logger.Error("Error performing HealthCheck request: %v ", err) + if len(health.HealthyStatuses) > 0 { + if !slices.Contains(health.HealthyStatuses, healthResp.StatusCode) { + logger.Error("Error: health check failed with status code %d", healthResp.StatusCode) return fmt.Errorf("health check failed with status code %v", healthResp.StatusCode) } } else { if healthResp.StatusCode >= 400 { - logger.Debug("Error performing HealthCheck request: %v ", err) + logger.Error("Error: health check failed with status code %d", healthResp.StatusCode) return fmt.Errorf("health check failed with status code %v", healthResp.StatusCode) } } return nil } +func routesHealthCheck(routes []Route) { + for _, route := range routes { + go func() { + if len(route.HealthCheck.Path) > 0 { + interval := "30s" + timeout, _ := util.ParseDuration("") + if len(route.HealthCheck.Interval) > 0 { + interval = route.HealthCheck.Interval + } + expression := fmt.Sprintf("@every %s", interval) + if !util.IsValidCronExpression(expression) { + logger.Error("Health check interval is invalid: %s", interval) + logger.Info("Route health check ignored") + return + } + if len(route.HealthCheck.Timeout) > 0 { + d1, err1 := util.ParseDuration(route.HealthCheck.Timeout) + if err1 != nil { + logger.Error("Health check timeout is invalid: %s", route.HealthCheck.Timeout) + return + } + timeout = d1 + + } + if len(route.Backends) > 0 { + for index, backend := range route.Backends { + err := createCron(fmt.Sprintf("%s [%d]", route.Name, index), expression, backend+route.HealthCheck.Path, timeout, route.HealthCheck.HealthyStatuses) + if err != nil { + logger.Error("Error creating cron expression: %v ", err) + return + } + } + + } else { + err := createCron(route.Name, expression, route.Destination+route.HealthCheck.Path, timeout, route.HealthCheck.HealthyStatuses) + if err != nil { + logger.Error("Error creating cron expression: %v ", err) + return + } + } + + } + }() + + } +} +func createCron(name, expression string, healthURL string, timeout time.Duration, healthyStatuses []int) error { + // Create a new cron instance + c := cron.New() + + _, err := c.AddFunc(expression, func() { + health := Health{ + URL: healthURL, + TimeOut: timeout, + HealthyStatuses: healthyStatuses, + } + err := health.Check() + if err != nil { + logger.Error("Route %s is unhealthy: error %v", name, err.Error()) + return + } + logger.Info("Route %s is healthy", name) + }) + if err != nil { + return err + } + // Start the cron scheduler + c.Start() + defer c.Stop() + select {} +} + +type HealthCheck struct { + url string + interval string + timeout string + healthyStatuses []int +} diff --git a/internal/proxy.go b/internal/proxy.go index ddfbdbd..1d91869 100644 --- a/internal/proxy.go +++ b/internal/proxy.go @@ -72,12 +72,10 @@ func (proxyRoute ProxyRoute) ProxyHandler() http.HandlerFunc { } // Update the headers to allow for SSL redirection if !proxyRoute.disableXForward { - r.URL.Host = targetURL.Host r.URL.Scheme = targetURL.Scheme r.Header.Set("X-Forwarded-Host", r.Header.Get("Host")) r.Header.Set("X-Forwarded-For", getRealIP(r)) r.Header.Set("X-Real-IP", getRealIP(r)) - r.Host = targetURL.Host } backendURL, _ := url.Parse(proxyRoute.destination) if len(proxyRoute.backends) > 0 { diff --git a/internal/route.go b/internal/route.go index 34f1dea..5d9f7e9 100644 --- a/internal/route.go +++ b/internal/route.go @@ -35,6 +35,8 @@ func init() { func (gatewayServer GatewayServer) Initialize() *mux.Router { gateway := gatewayServer.gateway middlewares := gatewayServer.middlewares + //Routes background healthcheck + routesHealthCheck(gateway.Routes) r := mux.NewRouter() heath := HealthCheckRoute{ DisableRouteHealthCheckError: gateway.DisableRouteHealthCheckError, diff --git a/internal/server.go b/internal/server.go index 2fcb0cb..6b43c7b 100644 --- a/internal/server.go +++ b/internal/server.go @@ -26,6 +26,7 @@ import ( "time" ) +// Start starts the server func (gatewayServer GatewayServer) Start(ctx context.Context) error { logger.Info("Initializing routes...") route := gatewayServer.Initialize() @@ -64,7 +65,6 @@ func (gatewayServer GatewayServer) Start(ctx context.Context) error { } // Set KeepAlive httpServer.SetKeepAlivesEnabled(!gatewayServer.gateway.DisableKeepAlive) - httpsServer.SetKeepAlivesEnabled(!gatewayServer.gateway.DisableKeepAlive) go func() { logger.Info("Starting HTTP server listen=0.0.0.0:8080") if err := httpServer.ListenAndServe(); err != nil { diff --git a/internal/types.go b/internal/types.go index e2923dd..0dc0850 100644 --- a/internal/types.go +++ b/internal/types.go @@ -208,8 +208,8 @@ type Gateway struct { type RouteHealthCheck struct { Path string `yaml:"path"` - Interval int `yaml:"interval"` - Timeout int `yaml:"timeout"` + Interval string `yaml:"interval"` + Timeout string `yaml:"timeout"` HealthyStatuses []int `yaml:"healthyStatuses"` } type GatewayConfig struct { diff --git a/internal/var.go b/internal/var.go index 5c87d6f..4fdb6d4 100644 --- a/internal/var.go +++ b/internal/var.go @@ -11,3 +11,5 @@ const JWTAuth = "jwt" // JWT authentication middleware const OAuth = "oauth" // OAuth authentication middleware // Round-robin counter var counter uint32 + +var routes *[]Route diff --git a/util/helpers.go b/util/helpers.go index 3706784..c896cc5 100644 --- a/util/helpers.go +++ b/util/helpers.go @@ -10,11 +10,13 @@ You may get a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ import ( + "github.com/robfig/cron/v3" "net/url" "os" "regexp" "strconv" "strings" + "time" ) // FileExists checks if the file does exist @@ -122,3 +124,21 @@ func UrlParsePath(uri string) string { func HasWhitespace(s string) bool { return regexp.MustCompile(`\s`).MatchString(s) } + +// IsValidCronExpression verify cronExpression and returns boolean +func IsValidCronExpression(cronExpr string) bool { + // Parse the cron expression + _, err := cron.ParseStandard(cronExpr) + return err == nil +} + +func ParseDuration(durationStr string) (time.Duration, error) { + if durationStr == "" { + return 0, nil + } + duration, err := time.ParseDuration(durationStr) + if err != nil { + return 0, err + } + return duration, nil +} diff --git a/util/util_test.go b/util/util_test.go index c7d8682..5d59243 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -1 +1,35 @@ package util + +import ( + "log" + "testing" + "time" +) + +func TestConExpression(t *testing.T) { + cronExpression := "@every 30s" + if !IsValidCronExpression(cronExpression) { + t.Fatal("Cron expression should be valid") + } + log.Println(" Cron is valid") + +} + +func TestParseDuration(t *testing.T) { + d1, err1 := ParseDuration("20s") + if err1 != nil { + t.Error("Error:", err1) + } else { + log.Printf("Parsed duration: %d", d1) + log.Printf("Time out: %s\n", time.Now().Add(d1)) + + } + d2, err2 := ParseDuration("10m") + if err2 != nil { + t.Errorf("Error: %v", err2) + } else { + log.Printf("Parsed duration: %d\n", d2) + log.Printf("Time out: %s\n", time.Now().Add(d2)) + + } +}