feat: add configuration checking
This commit is contained in:
45
cmd/config/check.go
Normal file
45
cmd/config/check.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Jonas Kaninda
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
pkg "github.com/jkaninda/goma-gateway/internal"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var CheckConfigCmd = &cobra.Command{
|
||||||
|
Use: "check",
|
||||||
|
Short: "Check Goma Gateway configuration file",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
configFile, _ := cmd.Flags().GetString("config")
|
||||||
|
if configFile == "" {
|
||||||
|
log.Fatalln("no config file specified")
|
||||||
|
}
|
||||||
|
err := pkg.CheckConfig(configFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(" Error checking config file: %s\n", err)
|
||||||
|
}
|
||||||
|
log.Println("Goma Gateway configuration file checked successfully")
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
CheckConfigCmd.Flags().StringP("config", "c", "", "Path to the configuration filename")
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@ limitations under the License.
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/jkaninda/goma-gateway/pkg/logger"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Cmd = &cobra.Command{
|
var Cmd = &cobra.Command{
|
||||||
@@ -28,7 +28,7 @@ var Cmd = &cobra.Command{
|
|||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
logger.Fatal(`"config" accepts no argument %q`, args)
|
log.Fatalf("Config accepts no argument %q", args)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,4 +37,5 @@ var Cmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Cmd.AddCommand(InitConfigCmd)
|
Cmd.AddCommand(InitConfigCmd)
|
||||||
|
Cmd.AddCommand(CheckConfigCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
67
internal/checkConfig.go
Normal file
67
internal/checkConfig.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Jonas Kaninda
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package pkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/jkaninda/goma-gateway/util"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CheckConfig(fileName string) error {
|
||||||
|
if !util.FileExists(fileName) {
|
||||||
|
return fmt.Errorf("config file not found: %s", fileName)
|
||||||
|
}
|
||||||
|
buf, err := os.ReadFile(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c := &GatewayConfig{}
|
||||||
|
err = yaml.Unmarshal(buf, c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing the configuration file %q: %w", fileName, err)
|
||||||
|
}
|
||||||
|
gateway := &GatewayServer{
|
||||||
|
ctx: nil,
|
||||||
|
version: c.Version,
|
||||||
|
gateway: c.GatewayConfig,
|
||||||
|
middlewares: c.Middlewares,
|
||||||
|
}
|
||||||
|
for index, route := range gateway.gateway.Routes {
|
||||||
|
if len(route.Name) == 0 {
|
||||||
|
log.Printf("Warning: route name is empty, index: [%d]", index)
|
||||||
|
}
|
||||||
|
if route.Destination == "" && len(route.Backends) == 0 {
|
||||||
|
log.Printf("Error: no destination or backends specified for route: %s | index: [%d] \n", route.Name, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Check middleware
|
||||||
|
for index, mid := range c.Middlewares {
|
||||||
|
if util.HasWhitespace(mid.Name) {
|
||||||
|
log.Printf("Warning: Middleware contains whitespace: %s | index: [%d], please remove whitespace characters\n", mid.Name, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Routes count=%d Middlewares count=%d\n", len(gateway.gateway.Routes), len(gateway.middlewares))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ func (GatewayServer) Config(configFile string) (*GatewayServer, error) {
|
|||||||
}
|
}
|
||||||
return &GatewayServer{
|
return &GatewayServer{
|
||||||
ctx: nil,
|
ctx: nil,
|
||||||
|
version: c.Version,
|
||||||
gateway: c.GatewayConfig,
|
gateway: c.GatewayConfig,
|
||||||
middlewares: c.Middlewares,
|
middlewares: c.Middlewares,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -122,7 +123,7 @@ func initConfig(configFile string) {
|
|||||||
GatewayConfig: Gateway{
|
GatewayConfig: Gateway{
|
||||||
WriteTimeout: 15,
|
WriteTimeout: 15,
|
||||||
ReadTimeout: 15,
|
ReadTimeout: 15,
|
||||||
IdleTimeout: 60,
|
IdleTimeout: 30,
|
||||||
AccessLog: "/dev/Stdout",
|
AccessLog: "/dev/Stdout",
|
||||||
ErrorLog: "/dev/stderr",
|
ErrorLog: "/dev/stderr",
|
||||||
DisableRouteHealthCheckError: false,
|
DisableRouteHealthCheckError: false,
|
||||||
@@ -140,11 +141,14 @@ func initConfig(configFile string) {
|
|||||||
Routes: []Route{
|
Routes: []Route{
|
||||||
{
|
{
|
||||||
Name: "Public",
|
Name: "Public",
|
||||||
Path: "/public",
|
Path: "/",
|
||||||
Methods: []string{"GET"},
|
Methods: []string{"GET"},
|
||||||
Destination: "https://example.com",
|
Destination: "https://example.com",
|
||||||
Rewrite: "/",
|
Rewrite: "/",
|
||||||
HealthCheck: "",
|
HealthCheck: RouteHealthCheck{
|
||||||
|
Path: "/",
|
||||||
|
HealthyStatuses: []int{200, 404},
|
||||||
|
},
|
||||||
Middlewares: []string{"api-forbidden-paths"},
|
Middlewares: []string{"api-forbidden-paths"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,7 +156,7 @@ func initConfig(configFile string) {
|
|||||||
Path: "/protected",
|
Path: "/protected",
|
||||||
Destination: "https://example.com",
|
Destination: "https://example.com",
|
||||||
Rewrite: "/",
|
Rewrite: "/",
|
||||||
HealthCheck: "",
|
HealthCheck: RouteHealthCheck{},
|
||||||
Cors: Cors{
|
Cors: Cors{
|
||||||
Origins: []string{"http://localhost:3000", "https://dev.example.com"},
|
Origins: []string{"http://localhost:3000", "https://dev.example.com"},
|
||||||
Headers: map[string]string{
|
Headers: map[string]string{
|
||||||
@@ -164,12 +168,35 @@ func initConfig(configFile string) {
|
|||||||
Middlewares: []string{"basic-auth", "api-forbidden-paths"},
|
Middlewares: []string{"basic-auth", "api-forbidden-paths"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Hostname example",
|
Path: "/",
|
||||||
Hosts: []string{"example.com", "example.localhost"},
|
Name: "Hostname and load balancing example",
|
||||||
Path: "/",
|
Hosts: []string{"example.com", "example.localhost"},
|
||||||
Destination: "https://example.com",
|
InterceptErrors: []int{404, 405, 500},
|
||||||
|
RateLimit: 60,
|
||||||
|
Backends: []string{
|
||||||
|
"https://example.com",
|
||||||
|
"https://example2.com",
|
||||||
|
"https://example4.com",
|
||||||
|
},
|
||||||
Rewrite: "/",
|
Rewrite: "/",
|
||||||
HealthCheck: "",
|
HealthCheck: RouteHealthCheck{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/loadbalancing",
|
||||||
|
Name: "loadBalancing example",
|
||||||
|
Hosts: []string{"example.com", "example.localhost"},
|
||||||
|
Backends: []string{
|
||||||
|
"https://example.com",
|
||||||
|
"https://example2.com",
|
||||||
|
"https://example4.com",
|
||||||
|
},
|
||||||
|
Rewrite: "/",
|
||||||
|
HealthCheck: RouteHealthCheck{
|
||||||
|
Path: "/health/live",
|
||||||
|
HealthyStatuses: []int{200, 404},
|
||||||
|
Interval: 30,
|
||||||
|
Timeout: 10,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -207,7 +234,6 @@ func initConfig(configFile string) {
|
|||||||
"/swagger-ui/*",
|
"/swagger-ui/*",
|
||||||
"/v2/swagger-ui/*",
|
"/v2/swagger-ui/*",
|
||||||
"/api-docs/*",
|
"/api-docs/*",
|
||||||
"/internal/*",
|
|
||||||
"/actuator/*",
|
"/actuator/*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -234,12 +260,11 @@ func initConfig(configFile string) {
|
|||||||
Name: "oauth-authentik",
|
Name: "oauth-authentik",
|
||||||
Type: OAuth,
|
Type: OAuth,
|
||||||
Paths: []string{
|
Paths: []string{
|
||||||
"/protected",
|
"/*",
|
||||||
"/example-of-oauth",
|
|
||||||
},
|
},
|
||||||
Rule: OauthRulerMiddleware{
|
Rule: OauthRulerMiddleware{
|
||||||
ClientID: "xxx",
|
ClientID: "xxxx",
|
||||||
ClientSecret: "xxx",
|
ClientSecret: "xxxx",
|
||||||
RedirectURL: "http://localhost:8080/callback",
|
RedirectURL: "http://localhost:8080/callback",
|
||||||
Scopes: []string{"email", "openid"},
|
Scopes: []string{"email", "openid"},
|
||||||
JWTSecret: "your-strong-jwt-secret | It's optional",
|
JWTSecret: "your-strong-jwt-secret | It's optional",
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ func (heathRoute HealthCheckRoute) HealthCheckHandler(w http.ResponseWriter, r *
|
|||||||
for _, route := range heathRoute.Routes {
|
for _, route := range heathRoute.Routes {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
if route.HealthCheck != "" {
|
if route.HealthCheck.Path != "" {
|
||||||
err := healthCheck(route.Destination + route.HealthCheck)
|
err := healthCheck(route.Destination+route.HealthCheck.Path, route.HealthCheck.HealthyStatuses)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if heathRoute.DisableRouteHealthCheckError {
|
if heathRoute.DisableRouteHealthCheckError {
|
||||||
routes = append(routes, HealthCheckRouteResponse{Name: route.Name, Status: "unhealthy", Error: "Route healthcheck errors disabled"})
|
routes = append(routes, HealthCheckRouteResponse{Name: route.Name, Status: "unhealthy", Error: "Route healthcheck errors disabled"})
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
func healthCheck(healthURL string) error {
|
func healthCheck(healthURL string, healthyStatuses []int) error {
|
||||||
healthCheckURL, err := url.Parse(healthURL)
|
healthCheckURL, err := url.Parse(healthURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error parsing HealthCheck URL: %v ", err)
|
return fmt.Errorf("error parsing HealthCheck URL: %v ", err)
|
||||||
@@ -45,10 +46,16 @@ func healthCheck(healthURL string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
}
|
}
|
||||||
}(healthResp.Body)
|
}(healthResp.Body)
|
||||||
|
if len(healthyStatuses) > 0 {
|
||||||
if healthResp.StatusCode >= 400 {
|
if !slices.Contains(healthyStatuses, healthResp.StatusCode) {
|
||||||
logger.Debug("Error performing HealthCheck request: %v ", err)
|
logger.Error("Error performing HealthCheck request: %v ", err)
|
||||||
return fmt.Errorf("health check failed with status code %v", 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)
|
||||||
|
return fmt.Errorf("health check failed with status code %v", healthResp.StatusCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,21 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
|||||||
disableXForward: route.DisableHeaderXForward,
|
disableXForward: route.DisableHeaderXForward,
|
||||||
cors: route.Cors,
|
cors: route.Cors,
|
||||||
}
|
}
|
||||||
|
// create route
|
||||||
router := r.PathPrefix(route.Path).Subrouter()
|
router := r.PathPrefix(route.Path).Subrouter()
|
||||||
|
// Apply common exploits to the route
|
||||||
|
// Enable common exploits
|
||||||
|
if route.BlockCommonExploits {
|
||||||
|
logger.Info("Block common exploits enabled")
|
||||||
|
router.Use(middleware.BlockExploitsMiddleware)
|
||||||
|
}
|
||||||
|
// Apply route rate limit
|
||||||
|
if route.RateLimit > 0 {
|
||||||
|
//rateLimiter := middleware.NewRateLimiter(gateway.RateLimit, time.Minute)
|
||||||
|
limiter := middleware.NewRateLimiterWindow(route.RateLimit, time.Minute, route.Cors.Origins) // requests per minute
|
||||||
|
// Add rate limit middleware to all routes, if defined
|
||||||
|
router.Use(limiter.RateLimitMiddleware())
|
||||||
|
}
|
||||||
// Apply route Cors
|
// Apply route Cors
|
||||||
router.Use(CORSHandler(route.Cors))
|
router.Use(CORSHandler(route.Cors))
|
||||||
if len(route.Hosts) > 0 {
|
if len(route.Hosts) > 0 {
|
||||||
|
|||||||
@@ -143,27 +143,29 @@ type Route struct {
|
|||||||
//
|
//
|
||||||
// E.g. /cart to / => It will rewrite /cart path to /
|
// E.g. /cart to / => It will rewrite /cart path to /
|
||||||
Rewrite string `yaml:"rewrite"`
|
Rewrite string `yaml:"rewrite"`
|
||||||
// Destination Defines backend URL
|
|
||||||
Destination string `yaml:"destination"`
|
|
||||||
//
|
//
|
||||||
Backends []string `yaml:"backends"`
|
|
||||||
// Cors contains the route cors headers
|
|
||||||
Cors Cors `yaml:"cors"`
|
|
||||||
//RateLimit int `yaml:"rateLimit"`
|
|
||||||
// Methods allowed method
|
// Methods allowed method
|
||||||
Methods []string `yaml:"methods"`
|
Methods []string `yaml:"methods"`
|
||||||
|
// HealthCheck Defines the backend is health
|
||||||
|
HealthCheck RouteHealthCheck `yaml:"healthCheck"`
|
||||||
|
// Destination Defines backend URL
|
||||||
|
Destination string `yaml:"destination"`
|
||||||
|
Backends []string `yaml:"backends"`
|
||||||
|
// Cors contains the route cors headers
|
||||||
|
Cors Cors `yaml:"cors"`
|
||||||
|
RateLimit int `yaml:"rateLimit"`
|
||||||
// DisableHeaderXForward Disable X-forwarded header.
|
// DisableHeaderXForward Disable X-forwarded header.
|
||||||
//
|
//
|
||||||
// [X-Forwarded-Host, X-Forwarded-For, Host, Scheme ]
|
// [X-Forwarded-Host, X-Forwarded-For, Host, Scheme ]
|
||||||
//
|
//
|
||||||
// It will not match the backend route
|
// It will not match the backend route
|
||||||
DisableHeaderXForward bool `yaml:"disableHeaderXForward"`
|
DisableHeaderXForward bool `yaml:"disableHeaderXForward"`
|
||||||
// HealthCheck Defines the backend is health check PATH
|
|
||||||
HealthCheck string `yaml:"healthCheck"`
|
|
||||||
// InterceptErrors intercepts backend errors based on the status codes
|
// InterceptErrors intercepts backend errors based on the status codes
|
||||||
//
|
//
|
||||||
// Eg: [ 403, 405, 500 ]
|
// Eg: [ 403, 405, 500 ]
|
||||||
InterceptErrors []int `yaml:"interceptErrors"`
|
InterceptErrors []int `yaml:"interceptErrors"`
|
||||||
|
// BlockCommonExploits enable, disable block common exploits
|
||||||
|
BlockCommonExploits bool `yaml:"blockCommonExploits"`
|
||||||
// Middlewares Defines route middleware from Middleware names
|
// Middlewares Defines route middleware from Middleware names
|
||||||
Middlewares []string `yaml:"middlewares"`
|
Middlewares []string `yaml:"middlewares"`
|
||||||
}
|
}
|
||||||
@@ -203,11 +205,10 @@ type Gateway struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RouteHealthCheck struct {
|
type RouteHealthCheck struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Interval int `yaml:"interval"`
|
Interval int `yaml:"interval"`
|
||||||
Timeout int `yaml:"timeout"`
|
Timeout int `yaml:"timeout"`
|
||||||
HealthyStatuses []int `yaml:"healthyStatuses"`
|
HealthyStatuses []int `yaml:"healthyStatuses"`
|
||||||
UnhealthyStatuses []int `yaml:"unhealthyStatuses"`
|
|
||||||
}
|
}
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
@@ -225,6 +226,7 @@ type ErrorResponse struct {
|
|||||||
}
|
}
|
||||||
type GatewayServer struct {
|
type GatewayServer struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
version string
|
||||||
gateway Gateway
|
gateway Gateway
|
||||||
middlewares []Middleware
|
middlewares []Middleware
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ You may get a copy of the License at
|
|||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -115,3 +116,7 @@ func UrlParsePath(uri string) string {
|
|||||||
}
|
}
|
||||||
return parse.Path
|
return parse.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HasWhitespace(s string) bool {
|
||||||
|
return regexp.MustCompile(`\s`).MatchString(s)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user