feat: add proxy backend errors interceptor
This commit is contained in:
26
README.md
26
README.md
@@ -11,8 +11,10 @@
|
|||||||
```
|
```
|
||||||
Goma Gateway is a lightweight API Gateway and Reverse Proxy.
|
Goma Gateway is a lightweight API Gateway and Reverse Proxy.
|
||||||
|
|
||||||
|
Simple, easy to use, and configure.
|
||||||
|
|
||||||
[](https://github.com/jkaninda/goma-gateway/actions/workflows/release.yml)
|
[](https://github.com/jkaninda/goma-gateway/actions/workflows/release.yml)
|
||||||
[](https://goreportcard.com/report/github.com/jkaninda/goma-gateway)
|
[](https://goreportcard.com/report/github.com/jkaninda/goma-gateway)
|
||||||
[](https://pkg.go.dev/github.com/jkaninda/goma-gateway)
|
[](https://pkg.go.dev/github.com/jkaninda/goma-gateway)
|
||||||

|

|
||||||
|
|
||||||
@@ -25,18 +27,20 @@ Goma Gateway is a lightweight API Gateway and Reverse Proxy.
|
|||||||
|
|
||||||
- [x] Reverse proxy
|
- [x] Reverse proxy
|
||||||
- [x] API Gateway
|
- [x] API Gateway
|
||||||
|
- [x] Domain/host based request routing
|
||||||
|
- [x] Multi domain request routing
|
||||||
- [x] Cors
|
- [x] Cors
|
||||||
- [ ] Add Load balancing feature
|
|
||||||
- [ ] Support TLS
|
- [ ] Support TLS
|
||||||
|
- [x] Backend errors interceptor
|
||||||
- [x] Authentication middleware
|
- [x] Authentication middleware
|
||||||
- [x] JWT `HTTP Bearer Token`
|
- [x] JWT `HTTP Bearer Token`
|
||||||
- [x] Basic-Auth
|
- [x] Basic-Auth
|
||||||
- [ ] OAuth2
|
- [ ] OAuth
|
||||||
- [x] Implement rate limiting
|
- [x] Implement rate limiting
|
||||||
- [x] In-Memory Token Bucket based
|
- [x] In-Memory Token Bucket based
|
||||||
- [x] In-Memory client IP based
|
- [x] In-Memory client IP based
|
||||||
- [ ] Distributed Rate Limiting for Token based across multiple instances using Redis
|
- [ ] Distributed Rate Limiting for Token based across multiple instances using Redis
|
||||||
- [ ] Distributed Rate Limiting for In-Memory client IP based across multiple instances using Redis
|
- [ ] Distributed Rate Limiting for In-Memory client IP based across multiple instances using Redis
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -124,6 +128,11 @@ gateway:
|
|||||||
disableRouteHealthCheckError: false
|
disableRouteHealthCheckError: false
|
||||||
# Disable display routes on start
|
# Disable display routes on start
|
||||||
disableDisplayRouteOnStart: false
|
disableDisplayRouteOnStart: false
|
||||||
|
# interceptErrors intercepts backend errors based on defined the status codes
|
||||||
|
interceptErrors:
|
||||||
|
# - 405
|
||||||
|
# - 500
|
||||||
|
# - 400
|
||||||
# Proxy Global HTTP Cors
|
# Proxy Global HTTP Cors
|
||||||
cors:
|
cors:
|
||||||
# Global routes cors for all routes
|
# Global routes cors for all routes
|
||||||
@@ -218,15 +227,16 @@ gateway:
|
|||||||
middlewares:
|
middlewares:
|
||||||
# Enable Basic auth authorization based
|
# Enable Basic auth authorization based
|
||||||
- name: local-auth-basic
|
- name: local-auth-basic
|
||||||
# Authentication types | jwt, basic, auth0
|
# Authentication types | jwtAuth, basicAuth, OAuth
|
||||||
type: basic
|
type: basicAuth
|
||||||
rule:
|
rule:
|
||||||
username: admin
|
username: admin
|
||||||
password: admin
|
password: admin
|
||||||
#Enables JWT authorization based on the result of a request and continues the request.
|
#Enables JWT authorization based on the result of a request and continues the request.
|
||||||
- name: google-auth
|
- name: google-auth
|
||||||
# Authentication types | jwt, basic, auth0
|
# Authentication types | jwtAuth, basicAuth, auth0
|
||||||
type: jwt
|
# jwt authorization based on the result of backend's response and continue the request when the client is authorized
|
||||||
|
type: jwtAuth
|
||||||
rule:
|
rule:
|
||||||
url: https://www.googleapis.com/auth/userinfo.email
|
url: https://www.googleapis.com/auth/userinfo.email
|
||||||
# Required headers, if not present in the request, the proxy will return 403
|
# Required headers, if not present in the request, the proxy will return 403
|
||||||
|
|||||||
14
goma.yml
14
goma.yml
@@ -18,6 +18,11 @@ gateway:
|
|||||||
disableRouteHealthCheckError: false
|
disableRouteHealthCheckError: false
|
||||||
# Disable display routes on start
|
# Disable display routes on start
|
||||||
disableDisplayRouteOnStart: false
|
disableDisplayRouteOnStart: false
|
||||||
|
# interceptErrors intercepts backend errors based on defined the status codes
|
||||||
|
interceptErrors:
|
||||||
|
# - 405
|
||||||
|
# - 500
|
||||||
|
# - 400
|
||||||
# Proxy Global HTTP Cors
|
# Proxy Global HTTP Cors
|
||||||
cors:
|
cors:
|
||||||
# Global routes cors for all routes
|
# Global routes cors for all routes
|
||||||
@@ -112,15 +117,16 @@ gateway:
|
|||||||
middlewares:
|
middlewares:
|
||||||
# Enable Basic auth authorization based
|
# Enable Basic auth authorization based
|
||||||
- name: local-auth-basic
|
- name: local-auth-basic
|
||||||
# Authentication types | jwt, basic, auth0
|
# Authentication types | jwtAuth, basicAuth, auth0
|
||||||
type: basic
|
type: basicAuth
|
||||||
rule:
|
rule:
|
||||||
username: admin
|
username: admin
|
||||||
password: admin
|
password: admin
|
||||||
#Enables JWT authorization based on the result of a request and continues the request.
|
#Enables JWT authorization based on the result of a request and continues the request.
|
||||||
- name: google-auth
|
- name: google-auth
|
||||||
# Authentication types | jwt, basic, auth0
|
# Authentication types | jwtAuth, basicAuth, OAuth
|
||||||
type: jwt
|
# jwt authorization based on the result of backend's response and continue the request when the client is authorized
|
||||||
|
type: jwtAuth
|
||||||
rule:
|
rule:
|
||||||
url: https://www.googleapis.com/auth/userinfo.email
|
url: https://www.googleapis.com/auth/userinfo.email
|
||||||
# Required headers, if not present in the request, the proxy will return 403
|
# Required headers, if not present in the request, the proxy will return 403
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ func Debug(msg string, args ...interface{}) {
|
|||||||
log.SetOutput(getStd(util.GetStringEnv("GOMA_ACCESS_LOG", "/dev/stdout")))
|
log.SetOutput(getStd(util.GetStringEnv("GOMA_ACCESS_LOG", "/dev/stdout")))
|
||||||
formattedMessage := fmt.Sprintf(msg, args...)
|
formattedMessage := fmt.Sprintf(msg, args...)
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
log.Printf("INFO: %s\n", msg)
|
log.Printf("DUBUG: %s\n", msg)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("INFO: %s\n", formattedMessage)
|
log.Printf("DUBUG: %s\n", formattedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func getStd(out string) *os.File {
|
func getStd(out string) *os.File {
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ type Route struct {
|
|||||||
HealthCheck string `yaml:"healthCheck"`
|
HealthCheck string `yaml:"healthCheck"`
|
||||||
// Blocklist Defines route blacklist
|
// Blocklist Defines route blacklist
|
||||||
Blocklist []string `yaml:"blocklist"`
|
Blocklist []string `yaml:"blocklist"`
|
||||||
|
// InterceptErrors intercepts backend errors based on the status codes
|
||||||
|
//
|
||||||
|
// Eg: [ 403, 405, 500 ]
|
||||||
|
InterceptErrors []int `yaml:"interceptErrors"`
|
||||||
// Middlewares Defines route middleware from Middleware names
|
// Middlewares Defines route middleware from Middleware names
|
||||||
Middlewares []RouteMiddleware `yaml:"middlewares"`
|
Middlewares []RouteMiddleware `yaml:"middlewares"`
|
||||||
}
|
}
|
||||||
@@ -147,7 +151,8 @@ type Gateway struct {
|
|||||||
ErrorLog string `yaml:"errorLog" env:"GOMA_ERROR_LOG=, overwrite"`
|
ErrorLog string `yaml:"errorLog" env:"GOMA_ERROR_LOG=, overwrite"`
|
||||||
DisableRouteHealthCheckError bool `yaml:"disableRouteHealthCheckError"`
|
DisableRouteHealthCheckError bool `yaml:"disableRouteHealthCheckError"`
|
||||||
//Disable dispelling routes on start
|
//Disable dispelling routes on start
|
||||||
DisableDisplayRouteOnStart bool `yaml:"disableDisplayRouteOnStart"`
|
DisableDisplayRouteOnStart bool `yaml:"disableDisplayRouteOnStart"`
|
||||||
|
InterceptErrors []int `yaml:"interceptErrors"`
|
||||||
// Cors contains the proxy global cors
|
// Cors contains the proxy global cors
|
||||||
Cors Cors `yaml:"cors"`
|
Cors Cors `yaml:"cors"`
|
||||||
// Routes defines the proxy routes
|
// Routes defines the proxy routes
|
||||||
@@ -351,7 +356,7 @@ func ToJWTRuler(input interface{}) (JWTRuler, error) {
|
|||||||
return JWTRuler{}, fmt.Errorf("error parsing yaml: %v", err)
|
return JWTRuler{}, fmt.Errorf("error parsing yaml: %v", err)
|
||||||
}
|
}
|
||||||
if jWTRuler.URL == "" {
|
if jWTRuler.URL == "" {
|
||||||
return JWTRuler{}, fmt.Errorf("error parsing yaml: empty url in jwt auth middleware")
|
return JWTRuler{}, fmt.Errorf("error parsing yaml: empty url in %s auth middleware", jwtAuth)
|
||||||
|
|
||||||
}
|
}
|
||||||
return *jWTRuler, nil
|
return *jWTRuler, nil
|
||||||
@@ -369,7 +374,7 @@ func ToBasicAuth(input interface{}) (BasicRule, error) {
|
|||||||
return BasicRule{}, fmt.Errorf("error parsing yaml: %v", err)
|
return BasicRule{}, fmt.Errorf("error parsing yaml: %v", err)
|
||||||
}
|
}
|
||||||
if basicAuth.Username == "" || basicAuth.Password == "" {
|
if basicAuth.Username == "" || basicAuth.Password == "" {
|
||||||
return BasicRule{}, fmt.Errorf("error parsing yaml: empty username/password in basic auth middleware")
|
return BasicRule{}, fmt.Errorf("error parsing yaml: empty username/password in %s middleware", basicAuth)
|
||||||
|
|
||||||
}
|
}
|
||||||
return *basicAuth, nil
|
return *basicAuth, nil
|
||||||
|
|||||||
94
pkg/middleware/error-interceptor.go
Normal file
94
pkg/middleware/error-interceptor.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InterceptErrors contains backend status code errors to intercept
|
||||||
|
type InterceptErrors struct {
|
||||||
|
Errors []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseRecorder intercepts the response body and status code
|
||||||
|
type responseRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
body *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResponseRecorder(w http.ResponseWriter) *responseRecorder {
|
||||||
|
return &responseRecorder{
|
||||||
|
ResponseWriter: w,
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
body: &bytes.Buffer{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rec *responseRecorder) WriteHeader(code int) {
|
||||||
|
rec.statusCode = code
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rec *responseRecorder) Write(data []byte) (int, error) {
|
||||||
|
return rec.body.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorInterceptor Middleware intercepts backend errors
|
||||||
|
func (intercept InterceptErrors) ErrorInterceptor(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec := newResponseRecorder(w)
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
if canIntercept(rec.statusCode, intercept.Errors) {
|
||||||
|
logger.Debug("Backend error intercepted")
|
||||||
|
logger.Debug("An error occurred in the backend, %d", rec.statusCode)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(rec.statusCode)
|
||||||
|
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||||
|
Success: false,
|
||||||
|
Code: rec.statusCode,
|
||||||
|
Message: http.StatusText(rec.statusCode),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No error: write buffered response to client
|
||||||
|
w.WriteHeader(rec.statusCode)
|
||||||
|
_, err := io.Copy(w, rec.body)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func canIntercept(code int, errors []int) bool {
|
||||||
|
for _, er := range errors {
|
||||||
|
if er == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -94,7 +94,9 @@ type AuthBasic struct {
|
|||||||
Params map[string]string
|
Params map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthMiddleware function, which will be called for each request
|
// AuthMiddleware authenticate the client using JWT
|
||||||
|
//
|
||||||
|
// authorization based on the result of backend's response and continue the request when the client is authorized
|
||||||
func (amw AuthJWT) AuthMiddleware(next http.Handler) http.Handler {
|
func (amw AuthJWT) AuthMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
for _, header := range amw.RequiredHeaders {
|
for _, header := range amw.RequiredHeaders {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func (rl *RateLimiter) RateLimitMiddleware() mux.MiddlewareFunc {
|
|||||||
rl.mu.Unlock()
|
rl.mu.Unlock()
|
||||||
|
|
||||||
if client.RequestCount > rl.Requests {
|
if client.RequestCount > rl.Requests {
|
||||||
logger.Warn("Too many request from IP: %s %s %s", clientID, r.URL, r.UserAgent())
|
logger.Debug("Too many request from IP: %s %s %s", clientID, r.URL, r.UserAgent())
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusTooManyRequests)
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||||
@@ -47,7 +47,7 @@ func (proxyRoute ProxyRoute) ProxyHandler() http.HandlerFunc {
|
|||||||
//Update Origin Cors Headers
|
//Update Origin Cors Headers
|
||||||
for _, origin := range proxyRoute.cors.Origins {
|
for _, origin := range proxyRoute.cors.Origins {
|
||||||
if origin == r.Header.Get("Origin") {
|
if origin == r.Header.Get("Origin") {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
w.Header().Set(accessControlAllowOrigin, origin)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,12 +90,6 @@ func (proxyRoute ProxyRoute) ProxyHandler() http.HandlerFunc {
|
|||||||
r.URL.Path = strings.Replace(r.URL.Path, fmt.Sprintf("%s/", proxyRoute.path), proxyRoute.rewrite, 1)
|
r.URL.Path = strings.Replace(r.URL.Path, fmt.Sprintf("%s/", proxyRoute.path), proxyRoute.rewrite, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proxy.ModifyResponse = func(response *http.Response) error {
|
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
||||||
//TODO || Add override backend errors | user can enable or disable it
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Custom error handler for proxy errors
|
// Custom error handler for proxy errors
|
||||||
proxy.ErrorHandler = ProxyErrorHandler
|
proxy.ErrorHandler = ProxyErrorHandler
|
||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
|
|||||||
16
pkg/route.go
16
pkg/route.go
@@ -40,6 +40,8 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
|||||||
// Add rate limit middleware to all routes, if defined
|
// Add rate limit middleware to all routes, if defined
|
||||||
r.Use(limiter.RateLimitMiddleware())
|
r.Use(limiter.RateLimitMiddleware())
|
||||||
}
|
}
|
||||||
|
// Add the errorInterceptor middleware
|
||||||
|
//r.Use(middleware.ErrorInterceptor)
|
||||||
for _, route := range gateway.Routes {
|
for _, route := range gateway.Routes {
|
||||||
if route.Path != "" {
|
if route.Path != "" {
|
||||||
blM := middleware.BlockListMiddleware{
|
blM := middleware.BlockListMiddleware{
|
||||||
@@ -63,7 +65,7 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
|||||||
} else {
|
} else {
|
||||||
//Check Authentication middleware
|
//Check Authentication middleware
|
||||||
switch rMiddleware.Type {
|
switch rMiddleware.Type {
|
||||||
case "basic":
|
case basicAuth, "basic":
|
||||||
basicAuth, err := ToBasicAuth(rMiddleware.Rule)
|
basicAuth, err := ToBasicAuth(rMiddleware.Rule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Error: %s", err.Error())
|
logger.Error("Error: %s", err.Error())
|
||||||
@@ -80,7 +82,7 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
|||||||
secureRouter.PathPrefix("/").Handler(proxyRoute.ProxyHandler()) // Proxy handler
|
secureRouter.PathPrefix("/").Handler(proxyRoute.ProxyHandler()) // Proxy handler
|
||||||
secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler
|
secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler
|
||||||
}
|
}
|
||||||
case "jwt":
|
case jwtAuth, "jwt":
|
||||||
jwt, err := ToJWTRuler(rMiddleware.Rule)
|
jwt, err := ToJWTRuler(rMiddleware.Rule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Error: %s", err.Error())
|
logger.Error("Error: %s", err.Error())
|
||||||
@@ -98,6 +100,9 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
|||||||
secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler
|
secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler
|
||||||
|
|
||||||
}
|
}
|
||||||
|
case OAuth, "auth0":
|
||||||
|
logger.Error("OAuth is not yet implemented")
|
||||||
|
logger.Info("Auth middleware ignored")
|
||||||
default:
|
default:
|
||||||
logger.Error("Unknown middleware type %s", rMiddleware.Type)
|
logger.Error("Unknown middleware type %s", rMiddleware.Type)
|
||||||
|
|
||||||
@@ -129,11 +134,16 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Error("Error, path is empty in route %s", route.Name)
|
logger.Error("Error, path is empty in route %s", route.Name)
|
||||||
logger.Info("Route path ignored: %s", route.Path)
|
logger.Debug("Route path ignored: %s", route.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Apply global Cors middlewares
|
// Apply global Cors middlewares
|
||||||
r.Use(CORSHandler(gateway.Cors)) // Apply CORS middleware
|
r.Use(CORSHandler(gateway.Cors)) // Apply CORS middleware
|
||||||
|
// Apply errorInterceptor middleware
|
||||||
|
interceptErrors := middleware.InterceptErrors{
|
||||||
|
Errors: gateway.InterceptErrors,
|
||||||
|
}
|
||||||
|
r.Use(interceptErrors.ErrorInterceptor)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
package pkg
|
package pkg
|
||||||
|
|
||||||
const ConfigFile = "/config/goma.yml"
|
const ConfigFile = "/config/goma.yml"
|
||||||
|
const accessControlAllowOrigin = "Access-Control-Allow-Origin"
|
||||||
|
const basicAuth = "basicAuth"
|
||||||
|
const jwtAuth = "jwtAuth"
|
||||||
|
const OAuth = "OAuth"
|
||||||
|
|||||||
Reference in New Issue
Block a user