Initial commit
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
GOMA_LISTEN_ADDR=localhost:8080
|
||||
GOMA_CONFIG_FILE=/config/goma.yml
|
||||
GOMA_ACCESS_LOG=/dev/Stdout
|
||||
GOMA_ERROR_LOG=/dev/stderr
|
||||
GOMA_WRITE_TIMEOUT=15
|
||||
GOMA_READ_TIMEOUT=15
|
||||
GOMA_IDLE_TIMEOUT=30
|
||||
GOMA_RATE_LIMITER=10
|
||||
GOMA_ENABLE_ROUTE_HEALTH_CHECK_ERROR= true
|
||||
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
32
.github/workflows/build.yml
vendored
Normal file
32
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
file: "./Dockerfile"
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
build-args: |
|
||||
appVersion=develop-${{ github.sha }}
|
||||
tags: |
|
||||
"${{vars.BUILDKIT_IMAGE}}:develop-${{ github.sha }}"
|
||||
|
||||
28
.github/workflows/go.yml
vendored
Normal file
28
.github/workflows/go.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# This workflow will build a golang project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
||||
|
||||
name: Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main","develop" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23.2'
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
41
.github/workflows/release.yml
vendored
Normal file
41
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v0.**
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Get the tag name
|
||||
id: get_tag_name
|
||||
run: echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
file: "./Dockerfile"
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
build-args: |
|
||||
appVersion=${{ env.TAG_NAME }}
|
||||
tags: |
|
||||
"${{vars.BUILDKIT_IMAGE}}:${{ env.TAG_NAME }}"
|
||||
"${{vars.BUILDKIT_IMAGE}}:latest"
|
||||
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/.history
|
||||
data
|
||||
compose.yaml
|
||||
.env
|
||||
test.md
|
||||
.DS_Store
|
||||
goma-gateway
|
||||
goma
|
||||
/.idea
|
||||
bin
|
||||
Makefile
|
||||
NOTES.md
|
||||
tests
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM golang:1.23.2 AS build
|
||||
WORKDIR /app
|
||||
ARG appVersion=""
|
||||
# Copy the source code.
|
||||
COPY . .
|
||||
# Installs Go dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Build
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-X 'util.Version=${appVersion}'" -o /app/goma
|
||||
|
||||
FROM alpine:3.20.3
|
||||
ENV TZ=UTC
|
||||
ARG WORKDIR="/config"
|
||||
ARG CERTSDIR="${WORKDIR}/certs"
|
||||
ARG appVersion=""
|
||||
ARG user="goma"
|
||||
ENV VERSION=${appVersion}
|
||||
LABEL author="Jonas Kaninda"
|
||||
LABEL version=${appVersion}
|
||||
LABEL github="github.com/jkaninda/goma"
|
||||
|
||||
|
||||
RUN apk --update add --no-cache tzdata ca-certificates curl
|
||||
RUN mkdir -p ${WORKDIR} ${CERTSDIR} && \
|
||||
chmod a+rw ${WORKDIR} ${CERTSDIR}
|
||||
COPY --from=build /app/goma /usr/local/bin/goma
|
||||
RUN chmod +x /usr/local/bin/goma && \
|
||||
ln -s /usr/local/bin/goma /usr/bin/goma
|
||||
RUN addgroup -S ${user} && adduser -S ${user} -G ${user}
|
||||
|
||||
USER ${user}
|
||||
WORKDIR $WORKDIR
|
||||
ENTRYPOINT ["/usr/local/bin/goma"]
|
||||
13
LICENSE
Normal file
13
LICENSE
Normal file
@@ -0,0 +1,13 @@
|
||||
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.
|
||||
244
README.md
Normal file
244
README.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Goma Gateway - simple lightweight API Gateway and Reverse Proxy.
|
||||
|
||||
```
|
||||
_____
|
||||
/ ____|
|
||||
| | __ ___ _ __ ___ __ _
|
||||
| | |_ |/ _ \| '_ ` _ \ / _` |
|
||||
| |__| | (_) | | | | | | (_| |
|
||||
\_____|\___/|_| |_| |_|\__,_|
|
||||
|
||||
```
|
||||
Goma Gateway is a lightweight API Gateway and Reverse Proxy.
|
||||
|
||||
[](https://github.com/jkaninda/goma/actions/workflows/release.yml)
|
||||
[](https://goreportcard.com/report/github.com/jkaninda/goma-gateway)
|
||||
[](https://pkg.go.dev/github.com/jkaninda/goma-gateway)
|
||||

|
||||
|
||||
## Links:
|
||||
|
||||
- [Docker Hub](https://hub.docker.com/r/jkaninda/goma-gateway)
|
||||
- [Github](https://github.com/jkaninda/goma-gateway)
|
||||
|
||||
### Feature
|
||||
|
||||
- [x] Reverse proxy
|
||||
- [x] API Gateway
|
||||
- [x] Cors
|
||||
- [ ] Add Load balancing feature
|
||||
- [ ] Support TLS
|
||||
- [x] Authentication middleware
|
||||
- [x] JWT `HTTP Bearer Token`
|
||||
- [x] Basic-Auth
|
||||
- [ ] OAuth2
|
||||
- [x] Implement rate limiting
|
||||
- [x] In-Memory Token Bucket based
|
||||
- [x] In-Memory client IP based
|
||||
- [ ] 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
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Initialize configuration
|
||||
|
||||
```shell
|
||||
docker run --rm --name goma-gateway \
|
||||
-v "${PWD}/config:/config" \
|
||||
jkaninda/goma-gateway config init --output /config/goma.yml
|
||||
```
|
||||
### 2. Run server
|
||||
|
||||
```shell
|
||||
docker run --rm --name goma-gateway \
|
||||
-v "${PWD}/config:/config" \
|
||||
-p 80:80 \
|
||||
jkaninda/goma-gateway server
|
||||
```
|
||||
|
||||
### 3. Start server with a custom config
|
||||
```shell
|
||||
docker run --rm --name goma-gateway \
|
||||
-v "${PWD}/config:/config" \
|
||||
-p 80:80 \
|
||||
jkaninda/goma-gateway server --config /config/config.yml
|
||||
```
|
||||
### 4. Healthcheck
|
||||
|
||||
[http://localhost/healthz](http://localhost/healthz)
|
||||
|
||||
> Healthcheck response body
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"routes": [
|
||||
{
|
||||
"name": "Store",
|
||||
"status": "healthy",
|
||||
"error": ""
|
||||
},
|
||||
{
|
||||
"name": "Authentication service",
|
||||
"status": "unhealthy",
|
||||
"error": "error performing HealthCheck request: Get \"http://authentication-service:8080/internal/health/ready\": dial tcp: lookup authentication-service on 127.0.0.11:53: no such host "
|
||||
|
||||
},
|
||||
{
|
||||
"name": "Notification",
|
||||
"status": "undefined",
|
||||
"error": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Create a config file in this format
|
||||
## Customize configuration file
|
||||
|
||||
Example of configuration file
|
||||
```yaml
|
||||
## Goma - simple lightweight API Gateway and Reverse Proxy.
|
||||
# Goma Gateway configurations
|
||||
gateway:
|
||||
########## Global settings
|
||||
listenAddr: 0.0.0.0:80
|
||||
# Proxy write timeout
|
||||
writeTimeout: 15
|
||||
# Proxy read timeout
|
||||
readTimeout: 15
|
||||
# Proxy idle timeout
|
||||
idleTimeout: 60
|
||||
# Proxy rate limit, it's In-Memory Token Bucket
|
||||
# Distributed Rate Limiting for Token based across multiple instances is not yet integrated
|
||||
rateLimiter: 0
|
||||
accessLog: "/dev/Stdout"
|
||||
errorLog: "/dev/stderr"
|
||||
## Returns backend route healthcheck errors
|
||||
disableRouteHealthCheckError: false
|
||||
# Disable display routes on start
|
||||
disableDisplayRouteOnStart: false
|
||||
# Proxy Global HTTP Cors
|
||||
cors:
|
||||
# Cors origins are global for all routes
|
||||
origins:
|
||||
- https://example.com
|
||||
- https://dev.example.com
|
||||
- http://localhost:80
|
||||
# Allowed headers are global for all routes
|
||||
headers:
|
||||
Access-Control-Allow-Headers: 'Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers, X-Client-Id, X-Session-Id'
|
||||
Access-Control-Allow-Credentials: 'true'
|
||||
Access-Control-Max-Age: 1728000
|
||||
##### Define routes
|
||||
routes:
|
||||
# Example of a route | 1
|
||||
- name: Store
|
||||
path: /store
|
||||
## Rewrite a request path
|
||||
# e.g rewrite: /store to /
|
||||
rewrite: /
|
||||
destination: 'http://store-service:8080'
|
||||
#DisableHeaderXForward Disable X-forwarded header.
|
||||
# [X-Forwarded-Host, X-Forwarded-For, Host, Scheme ]
|
||||
# It will not match the backend route, by default, it's disabled
|
||||
disableHeaderXForward: false
|
||||
# Internal health check
|
||||
healthCheck: /internal/health/ready
|
||||
# Proxy route HTTP Cors
|
||||
cors:
|
||||
headers:
|
||||
Access-Control-Allow-Methods: 'GET'
|
||||
Access-Control-Allow-Headers: 'Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers, X-Client-Id, X-Session-Id'
|
||||
Access-Control-Allow-Credentials: 'true'
|
||||
Access-Control-Max-Age: 1728000
|
||||
#### Define route blocklist paths
|
||||
blocklist:
|
||||
- /swagger-ui/*
|
||||
- /v2/swagger-ui/*
|
||||
- /api-docs/*
|
||||
- /internal/*
|
||||
- /actuator/*
|
||||
##### Define route middlewares from middlewares names
|
||||
## The name must be unique
|
||||
## List of middleware name
|
||||
middlewares:
|
||||
# path to protect
|
||||
- path: /user/account
|
||||
# Rules defines which specific middleware applies to a route path
|
||||
rules:
|
||||
- auth
|
||||
# path to protect
|
||||
- path: /cart
|
||||
# Rules defines which specific middleware applies to a route path
|
||||
rules:
|
||||
- google-auth
|
||||
- auth
|
||||
- path: /history
|
||||
http:
|
||||
url: http://security-service:8080/security/authUser
|
||||
headers:
|
||||
#Key from backend authentication header, and inject to the request with custom key name
|
||||
userId: X-Auth-UserId
|
||||
userCountryId: X-Auth-UserCountryId
|
||||
params:
|
||||
userCountryId: X-countryId
|
||||
# Example of a route | 2
|
||||
- name: Authentication service
|
||||
path: /auth
|
||||
rewrite: /
|
||||
destination: 'http://security-service:8080'
|
||||
healthCheck: /internal/health/ready
|
||||
cors: {}
|
||||
blocklist: []
|
||||
middlewares: []
|
||||
# Example of a route | 3
|
||||
- name: Notification
|
||||
path: /notification
|
||||
rewrite: /
|
||||
destination: 'http://notification-service:8080'
|
||||
healthCheck:
|
||||
cors: {}
|
||||
blocklist: []
|
||||
middlewares: []
|
||||
|
||||
#Defines proxy middlewares
|
||||
middlewares:
|
||||
# Enable Basic auth authorization based
|
||||
- name: local-auth-basic
|
||||
# Authentication types | jwt, basic, auth0
|
||||
type: basic
|
||||
rule:
|
||||
username: admin
|
||||
password: admin
|
||||
#Enables JWT authorization based on the result of a request and continues the request.
|
||||
- name: google-auth
|
||||
# Authentication types | jwt, basic, auth0
|
||||
type: jwt
|
||||
rule:
|
||||
url: https://www.googleapis.com/auth/userinfo.email
|
||||
# Required headers, if not present in the request, the proxy will return 403
|
||||
requiredHeaders:
|
||||
- Authorization
|
||||
#Sets the request variable to the given value after the authorization request completes.
|
||||
#
|
||||
# Add header to the next request from AuthRequest header, depending on your requirements
|
||||
# Key is AuthRequest's response header Key, and value is Request's header Key
|
||||
# In case you want to get headers from the Authentication service and inject them into the next request's headers
|
||||
#Sets the request variable to the given value after the authorization request completes.
|
||||
#
|
||||
# Add header to the next request from AuthRequest header, depending on your requirements
|
||||
# Key is AuthRequest's response header Key, and value is Request's header Key
|
||||
# In case you want to get headers from the Authentication service and inject them into the next request's headers
|
||||
headers:
|
||||
userId: X-Auth-UserId
|
||||
userCountryId: X-Auth-UserCountryId
|
||||
# In case you want to get headers from the Authentication service and inject them to the next request's params
|
||||
params:
|
||||
auth_userCountryId: countryId
|
||||
```
|
||||
|
||||
## Requirement
|
||||
|
||||
- Docker
|
||||
40
cmd/config/config.go
Normal file
40
cmd/config/config.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Package config Package cmd /
|
||||
/*
|
||||
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 (
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Goma configuration",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 {
|
||||
return
|
||||
} else {
|
||||
logger.Fatal(`"config" accepts no argument %q`, args)
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.AddCommand(InitConfigCmd)
|
||||
}
|
||||
39
cmd/config/init.go
Normal file
39
cmd/config/init.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
/*
|
||||
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 (
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"github.com/jkaninda/goma-gateway/pkg"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var InitConfigCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize Goma Gateway configuration file",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 {
|
||||
pkg.InitConfig(cmd)
|
||||
} else {
|
||||
logger.Fatal(`"config" accepts no argument %q`, args)
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
InitConfigCmd.Flags().StringP("output", "o", "", "config file output")
|
||||
}
|
||||
47
cmd/root.go
Normal file
47
cmd/root.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Package cmd /
|
||||
/*
|
||||
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 cmd
|
||||
|
||||
import (
|
||||
"github.com/jkaninda/goma-gateway/cmd/config"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"github.com/jkaninda/goma-gateway/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// rootCmd represents
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "goma",
|
||||
Short: "Goma Gateway is a lightweight API Gateway, Reverse Proxy",
|
||||
Long: `.`,
|
||||
Example: "",
|
||||
Version: util.FullVersion(),
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
logger.Fatal("Error executing root command %v", err)
|
||||
}
|
||||
}
|
||||
func init() {
|
||||
rootCmd.AddCommand(ServerCmd)
|
||||
rootCmd.AddCommand(config.Cmd)
|
||||
|
||||
}
|
||||
51
cmd/server.go
Normal file
51
cmd/server.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Package cmd /
|
||||
/*
|
||||
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 cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"github.com/jkaninda/goma-gateway/pkg"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var ServerCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start server",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
pkg.Intro()
|
||||
configFile, _ := cmd.Flags().GetString("config")
|
||||
if configFile == "" {
|
||||
configFile = pkg.GetConfigPaths()
|
||||
}
|
||||
ctx := context.Background()
|
||||
g := pkg.GatewayServer{}
|
||||
gs, err := g.Config(configFile)
|
||||
if err != nil {
|
||||
logger.Fatal("Could not load configuration: %v", err)
|
||||
}
|
||||
if err := gs.Start(ctx); err != nil {
|
||||
logger.Fatal("Could not start server: %v", err)
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
ServerCmd.Flags().StringP("config", "", "", "Goma config file")
|
||||
}
|
||||
28
go.mod
Normal file
28
go.mod
Normal file
@@ -0,0 +1,28 @@
|
||||
module github.com/jkaninda/goma-gateway
|
||||
|
||||
go 1.23.2
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect
|
||||
github.com/go-redis/redis v6.15.9+incompatible // indirect
|
||||
github.com/go-redis/redis_rate v6.5.0+incompatible // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
|
||||
)
|
||||
47
go.sum
Normal file
47
go.sum
Normal file
@@ -0,0 +1,47 @@
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ=
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/getsentry/sentry-go v0.29.1 h1:DyZuChN8Hz3ARxGVV8ePaNXh1dQ7d76AiB117xcREwA=
|
||||
github.com/getsentry/sentry-go v0.29.1/go.mod h1:x3AtIzN01d6SiWkderzaH28Tm0lgkafpJ5Bm3li39O0=
|
||||
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
|
||||
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-redis/redis_rate v6.5.0+incompatible h1:K/G+KaoJgO3kbkLLbfdg0kzJsHhhk0gVGTMgstKgbsM=
|
||||
github.com/go-redis/redis_rate v6.5.0+incompatible/go.mod h1:Jxe7BhQuVncH6fUQ2rwoAkc8SesjCGIWkm6fNRQo4Qg=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo=
|
||||
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.1/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
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=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
138
goma.yml
Normal file
138
goma.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
## Goma - simple lightweight API Gateway and Reverse Proxy.
|
||||
# Goma Gateway configurations
|
||||
gateway:
|
||||
########## Global settings
|
||||
listenAddr: 0.0.0.0:80
|
||||
# Proxy write timeout
|
||||
writeTimeout: 15
|
||||
# Proxy read timeout
|
||||
readTimeout: 15
|
||||
# Proxy idle timeout
|
||||
idleTimeout: 60
|
||||
# Proxy rate limit, it's In-Memory Token Bucket
|
||||
# Distributed Rate Limiting for Token based across multiple instances is not yet integrated
|
||||
rateLimiter: 0
|
||||
accessLog: "/dev/Stdout"
|
||||
errorLog: "/dev/stderr"
|
||||
## Returns backend route healthcheck errors
|
||||
disableRouteHealthCheckError: false
|
||||
# Disable display routes on start
|
||||
disableDisplayRouteOnStart: false
|
||||
# Proxy Global HTTP Cors
|
||||
cors:
|
||||
# Cors origins are global for all routes
|
||||
origins:
|
||||
- https://example.com
|
||||
- https://dev.example.com
|
||||
- http://localhost:80
|
||||
# Allowed headers are global for all routes
|
||||
headers:
|
||||
Access-Control-Allow-Headers: 'Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers, X-Client-Id, X-Session-Id'
|
||||
Access-Control-Allow-Credentials: 'true'
|
||||
Access-Control-Max-Age: 1728000
|
||||
##### Define routes
|
||||
routes:
|
||||
# Example of a route | 1
|
||||
- name: Store
|
||||
path: /store
|
||||
## Rewrite a request path
|
||||
# e.g rewrite: /store to /
|
||||
rewrite: /
|
||||
destination: 'http://store-service:8080'
|
||||
#DisableHeaderXForward Disable X-forwarded header.
|
||||
# [X-Forwarded-Host, X-Forwarded-For, Host, Scheme ]
|
||||
# It will not match the backend route, by default, it's disabled
|
||||
disableHeaderXForward: false
|
||||
# Internal health check
|
||||
healthCheck: /internal/health/ready
|
||||
# Proxy route HTTP Cors
|
||||
cors:
|
||||
headers:
|
||||
Access-Control-Allow-Methods: 'GET'
|
||||
Access-Control-Allow-Headers: 'Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers, X-Client-Id, X-Session-Id'
|
||||
Access-Control-Allow-Credentials: 'true'
|
||||
Access-Control-Max-Age: 1728000
|
||||
#### Define route blocklist paths
|
||||
blocklist:
|
||||
- /swagger-ui/*
|
||||
- /v2/swagger-ui/*
|
||||
- /api-docs/*
|
||||
- /internal/*
|
||||
- /actuator/*
|
||||
##### Define route middlewares from middlewares names
|
||||
## The name must be unique
|
||||
## List of middleware name
|
||||
middlewares:
|
||||
# path to protect
|
||||
- path: /user/account
|
||||
# Rules defines which specific middleware applies to a route path
|
||||
rules:
|
||||
- auth
|
||||
# path to protect
|
||||
- path: /cart
|
||||
# Rules defines which specific middleware applies to a route path
|
||||
rules:
|
||||
- google-auth
|
||||
- auth
|
||||
- path: /history
|
||||
http:
|
||||
url: http://security-service:8080/security/authUser
|
||||
headers:
|
||||
#Key from backend authentication header, and inject to the request with custom key name
|
||||
userId: X-Auth-UserId
|
||||
userCountryId: X-Auth-UserCountryId
|
||||
params:
|
||||
userCountryId: X-countryId
|
||||
# Example of a route | 2
|
||||
- name: Authentication service
|
||||
path: /auth
|
||||
rewrite: /
|
||||
destination: 'http://security-service:8080'
|
||||
healthCheck: /internal/health/ready
|
||||
cors: {}
|
||||
blocklist: []
|
||||
middlewares: []
|
||||
# Example of a route | 3
|
||||
- name: Notification
|
||||
path: /notification
|
||||
rewrite: /
|
||||
destination: 'http://notification-service:8080'
|
||||
healthCheck:
|
||||
cors: {}
|
||||
blocklist: []
|
||||
middlewares: []
|
||||
|
||||
#Defines proxy middlewares
|
||||
middlewares:
|
||||
# Enable Basic auth authorization based
|
||||
- name: local-auth-basic
|
||||
# Authentication types | jwt, basic, auth0
|
||||
type: basic
|
||||
rule:
|
||||
username: admin
|
||||
password: admin
|
||||
#Enables JWT authorization based on the result of a request and continues the request.
|
||||
- name: google-auth
|
||||
# Authentication types | jwt, basic, auth0
|
||||
type: jwt
|
||||
rule:
|
||||
url: https://www.googleapis.com/auth/userinfo.email
|
||||
# Required headers, if not present in the request, the proxy will return 403
|
||||
requiredHeaders:
|
||||
- Authorization
|
||||
#Sets the request variable to the given value after the authorization request completes.
|
||||
#
|
||||
# Add header to the next request from AuthRequest header, depending on your requirements
|
||||
# Key is AuthRequest's response header Key, and value is Request's header Key
|
||||
# In case you want to get headers from the Authentication service and inject them into the next request's headers
|
||||
#Sets the request variable to the given value after the authorization request completes.
|
||||
#
|
||||
# Add header to the next request from AuthRequest header, depending on your requirements
|
||||
# Key is AuthRequest's response header Key, and value is Request's header Key
|
||||
# In case you want to get headers from the Authentication service and inject them into the next request's headers
|
||||
headers:
|
||||
userId: X-Auth-UserId
|
||||
userCountryId: X-Auth-UserCountryId
|
||||
# In case you want to get headers from the Authentication service and inject them to the next request's params
|
||||
params:
|
||||
auth_userCountryId: countryId
|
||||
97
internal/logger/logger.go
Normal file
97
internal/logger/logger.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package logger
|
||||
|
||||
/*
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"github.com/jkaninda/goma-gateway/util"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
msg string
|
||||
args interface{}
|
||||
}
|
||||
|
||||
// Info returns info log
|
||||
func Info(msg string, args ...interface{}) {
|
||||
log.SetOutput(getStd(util.GetStringEnv("GOMA_ACCESS_LOG", "/dev/stdout")))
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
log.Printf("INFO: %s\n", msg)
|
||||
} else {
|
||||
log.Printf("INFO: %s\n", formattedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Warn returns warning log
|
||||
func Warn(msg string, args ...interface{}) {
|
||||
log.SetOutput(getStd(util.GetStringEnv("GOMA_ACCESS_LOG", "/dev/stdout")))
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
log.Printf("WARN: %s\n", msg)
|
||||
} else {
|
||||
log.Printf("WARN: %s\n", formattedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Error error message
|
||||
func Error(msg string, args ...interface{}) {
|
||||
log.SetOutput(getStd(util.GetStringEnv("GOMA_ERROR_LOG", "/dev/stdout")))
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
log.Printf("ERROR: %s\n", msg)
|
||||
} else {
|
||||
log.Printf("ERROR: %s\n", formattedMessage)
|
||||
|
||||
}
|
||||
}
|
||||
func Fatal(msg string, args ...interface{}) {
|
||||
log.SetOutput(os.Stdout)
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
log.Printf("ERROR: %s\n", msg)
|
||||
} else {
|
||||
log.Printf("ERROR: %s\n", formattedMessage)
|
||||
}
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func Debug(msg string, args ...interface{}) {
|
||||
log.SetOutput(getStd(util.GetStringEnv("GOMA_ACCESS_LOG", "/dev/stdout")))
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
log.Printf("INFO: %s\n", msg)
|
||||
} else {
|
||||
log.Printf("INFO: %s\n", formattedMessage)
|
||||
}
|
||||
}
|
||||
func getStd(out string) *os.File {
|
||||
switch out {
|
||||
case "/dev/stdout":
|
||||
return os.Stdout
|
||||
case "/dev/stderr":
|
||||
return os.Stderr
|
||||
case "/dev/stdin":
|
||||
return os.Stdin
|
||||
default:
|
||||
return os.Stdout
|
||||
|
||||
}
|
||||
}
|
||||
23
main.go
Normal file
23
main.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
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 "github.com/jkaninda/goma-gateway/cmd"
|
||||
|
||||
func main() {
|
||||
|
||||
cmd.Execute()
|
||||
}
|
||||
362
pkg/config.go
Normal file
362
pkg/config.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package pkg
|
||||
|
||||
/*
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"github.com/jkaninda/goma-gateway/util"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
"os"
|
||||
)
|
||||
|
||||
var cfg *Gateway
|
||||
|
||||
type Config struct {
|
||||
file string
|
||||
}
|
||||
type BasicRule struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type Cors struct {
|
||||
// Cors Allowed origins,
|
||||
//e.g:
|
||||
//
|
||||
// - http://localhost:80
|
||||
//
|
||||
// - https://example.com
|
||||
Origins []string `yaml:"origins"`
|
||||
//
|
||||
//e.g:
|
||||
//
|
||||
//Access-Control-Allow-Origin: '*'
|
||||
//
|
||||
// Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS'
|
||||
//
|
||||
// Access-Control-Allow-Cors: 'Content-Type, Authorization'
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
}
|
||||
|
||||
// JWTRuler authentication using HTTP GET method
|
||||
//
|
||||
// JWTRuler contains the authentication details
|
||||
type JWTRuler struct {
|
||||
// URL contains the authentication URL, it supports HTTP GET method only.
|
||||
URL string `yaml:"url"`
|
||||
// RequiredHeaders , contains required before sending request to the backend.
|
||||
RequiredHeaders []string `yaml:"requiredHeaders"`
|
||||
// Headers Add header to the backend from Authentication request's header, depending on your requirements.
|
||||
// Key is Http's response header Key, and value is the backend Request's header Key.
|
||||
// In case you want to get headers from Authentication service and inject them to backend request's headers.
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
// Params same as Headers, contains the request params.
|
||||
//
|
||||
// Gets authentication headers from authentication request and inject them as request params to the backend.
|
||||
//
|
||||
// Key is Http's response header Key, and value is the backend Request's request param Key.
|
||||
//
|
||||
// In case you want to get headers from Authentication service and inject them to next request's params.
|
||||
//
|
||||
//e.g: Header X-Auth-UserId to query userId
|
||||
Params map[string]string `yaml:"params"`
|
||||
}
|
||||
|
||||
// Middleware defined the route middleware
|
||||
type Middleware struct {
|
||||
//Path contains the name of middleware and must be unique
|
||||
Name string `yaml:"name"`
|
||||
// Type contains authentication types
|
||||
//
|
||||
// basic, jwt, auth0, rateLimit
|
||||
Type string `yaml:"type"`
|
||||
// Rule contains rule type of
|
||||
Rule interface{} `yaml:"rule"`
|
||||
}
|
||||
type MiddlewareName struct {
|
||||
name string `yaml:"name"`
|
||||
}
|
||||
type RouteMiddleware struct {
|
||||
//Path contains the path to protect
|
||||
Path string `yaml:"path"`
|
||||
//Rules defines which specific middleware applies to a route path
|
||||
Rules []string `yaml:"rules"`
|
||||
}
|
||||
|
||||
// Route defines gateway route
|
||||
type Route struct {
|
||||
// Name defines route name
|
||||
Name string `yaml:"name"`
|
||||
// Path defines route path
|
||||
Path string `yaml:"path"`
|
||||
// Rewrite rewrites route path to desired path
|
||||
//
|
||||
// E.g. /cart to / => It will rewrite /cart path to /
|
||||
Rewrite string `yaml:"rewrite"`
|
||||
// Destination Defines backend URL
|
||||
Destination string `yaml:"destination"`
|
||||
// Cors contains the route cors headers
|
||||
Cors Cors `yaml:"cors"`
|
||||
// DisableHeaderXForward Disable X-forwarded header.
|
||||
//
|
||||
// [X-Forwarded-Host, X-Forwarded-For, Host, Scheme ]
|
||||
//
|
||||
// It will not match the backend route
|
||||
DisableHeaderXForward bool `yaml:"disableHeaderXForward"`
|
||||
// HealthCheck Defines the backend is health check PATH
|
||||
HealthCheck string `yaml:"healthCheck"`
|
||||
// Blocklist Defines route blacklist
|
||||
Blocklist []string `yaml:"blocklist"`
|
||||
// Middlewares Defines route middleware from Middleware names
|
||||
Middlewares []RouteMiddleware `yaml:"middlewares"`
|
||||
}
|
||||
|
||||
// Gateway contains Goma Proxy Gateway's configs
|
||||
type Gateway struct {
|
||||
// ListenAddr Defines the server listenAddr
|
||||
//
|
||||
//e.g: localhost:8080
|
||||
ListenAddr string `yaml:"listenAddr" env:"GOMA_LISTEN_ADDR, overwrite"`
|
||||
// WriteTimeout defines proxy write timeout
|
||||
WriteTimeout int `yaml:"writeTimeout" env:"GOMA_WRITE_TIMEOUT, overwrite"`
|
||||
// ReadTimeout defines proxy read timeout
|
||||
ReadTimeout int `yaml:"readTimeout" env:"GOMA_READ_TIMEOUT, overwrite"`
|
||||
// IdleTimeout defines proxy idle timeout
|
||||
IdleTimeout int `yaml:"idleTimeout" env:"GOMA_IDLE_TIMEOUT, overwrite"`
|
||||
// RateLimiter Defines number of request peer minute
|
||||
RateLimiter int `yaml:"rateLimiter" env:"GOMA_RATE_LIMITER, overwrite"`
|
||||
AccessLog string `yaml:"accessLog" env:"GOMA_ACCESS_LOG, overwrite"`
|
||||
ErrorLog string `yaml:"errorLog" env:"GOMA_ERROR_LOG=, overwrite"`
|
||||
DisableRouteHealthCheckError bool `yaml:"disableRouteHealthCheckError"`
|
||||
//Disable dispelling routes on start
|
||||
DisableDisplayRouteOnStart bool `yaml:"disableDisplayRouteOnStart"`
|
||||
// Cors contains the proxy global cors
|
||||
Cors Cors `yaml:"cors"`
|
||||
// Routes defines the proxy routes
|
||||
Routes []Route `yaml:"routes"`
|
||||
}
|
||||
type GatewayConfig struct {
|
||||
GatewayConfig Gateway `yaml:"gateway"`
|
||||
Middlewares []Middleware `yaml:"middlewares"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents the structure of the JSON error response
|
||||
type ErrorResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
type GatewayServer struct {
|
||||
ctx context.Context
|
||||
gateway Gateway
|
||||
middlewares []Middleware
|
||||
}
|
||||
|
||||
// Config reads config file and returns Gateway
|
||||
func (GatewayServer) Config(configFile string) (*GatewayServer, error) {
|
||||
if util.FileExists(configFile) {
|
||||
buf, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
util.SetEnv("GOMA_CONFIG_FILE", configFile)
|
||||
c := &GatewayConfig{}
|
||||
err = yaml.Unmarshal(buf, c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("in file %q: %w", configFile, err)
|
||||
}
|
||||
return &GatewayServer{
|
||||
ctx: nil,
|
||||
gateway: c.GatewayConfig,
|
||||
middlewares: c.Middlewares,
|
||||
}, nil
|
||||
}
|
||||
logger.Error("Configuration file not found: %v", configFile)
|
||||
logger.Info("Generating new configuration file...")
|
||||
initConfig(ConfigFile)
|
||||
logger.Info("Server configuration file is available at %s", ConfigFile)
|
||||
util.SetEnv("GOMA_CONFIG_FILE", ConfigFile)
|
||||
buf, err := os.ReadFile(ConfigFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &GatewayConfig{}
|
||||
err = yaml.Unmarshal(buf, c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("in file %q: %w", ConfigFile, err)
|
||||
}
|
||||
logger.Info("Generating new configuration file...done")
|
||||
logger.Info("Starting server with default configuration")
|
||||
return &GatewayServer{
|
||||
ctx: nil,
|
||||
gateway: c.GatewayConfig,
|
||||
middlewares: c.Middlewares,
|
||||
}, nil
|
||||
}
|
||||
func GetConfigPaths() string {
|
||||
return util.GetStringEnv("GOMAY_CONFIG_FILE", ConfigFile)
|
||||
}
|
||||
func InitConfig(cmd *cobra.Command) {
|
||||
configFile, _ := cmd.Flags().GetString("output")
|
||||
if configFile == "" {
|
||||
configFile = GetConfigPaths()
|
||||
}
|
||||
initConfig(configFile)
|
||||
return
|
||||
|
||||
}
|
||||
func initConfig(configFile string) {
|
||||
if configFile == "" {
|
||||
configFile = GetConfigPaths()
|
||||
}
|
||||
conf := &GatewayConfig{
|
||||
GatewayConfig: Gateway{
|
||||
ListenAddr: "0.0.0.0:80",
|
||||
WriteTimeout: 15,
|
||||
ReadTimeout: 15,
|
||||
IdleTimeout: 60,
|
||||
AccessLog: "/dev/Stdout",
|
||||
ErrorLog: "/dev/stderr",
|
||||
DisableRouteHealthCheckError: false,
|
||||
DisableDisplayRouteOnStart: false,
|
||||
RateLimiter: 0,
|
||||
Cors: Cors{
|
||||
Origins: []string{"http://localhost:8080", "https://example.com"},
|
||||
Headers: map[string]string{
|
||||
"Access-Control-Allow-Headers": "Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers, X-Client-Id, X-Session-Id",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Max-Age": "1728000",
|
||||
},
|
||||
},
|
||||
Routes: []Route{
|
||||
{
|
||||
Name: "HealthCheck",
|
||||
Path: "/healthy",
|
||||
Destination: "http://localhost:8080",
|
||||
Rewrite: "/health",
|
||||
HealthCheck: "",
|
||||
Cors: Cors{
|
||||
Headers: map[string]string{
|
||||
"Access-Control-Allow-Headers": "Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers, X-Client-Id, X-Session-Id",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Max-Age": "1728000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Basic auth",
|
||||
Path: "/basic",
|
||||
Destination: "http://localhost:8080",
|
||||
Rewrite: "/health",
|
||||
HealthCheck: "",
|
||||
Blocklist: []string{},
|
||||
Cors: Cors{},
|
||||
Middlewares: []RouteMiddleware{
|
||||
{
|
||||
Path: "/basic/auth",
|
||||
Rules: []string{"basic-auth", "google-jwt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Middlewares: []Middleware{
|
||||
{
|
||||
Name: "basic-auth",
|
||||
Type: "basic",
|
||||
Rule: BasicRule{
|
||||
Username: "goma",
|
||||
Password: "goma",
|
||||
},
|
||||
}, {
|
||||
Name: "google-jwt",
|
||||
Type: "jwt",
|
||||
Rule: JWTRuler{
|
||||
URL: "https://www.googleapis.com/auth/userinfo.email",
|
||||
Headers: map[string]string{},
|
||||
Params: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
yamlData, err := yaml.Marshal(&conf)
|
||||
if err != nil {
|
||||
logger.Fatal("Error serializing configuration %v", err.Error())
|
||||
}
|
||||
err = os.WriteFile(configFile, yamlData, 0644)
|
||||
if err != nil {
|
||||
logger.Fatal("Unable to write config file %s", err)
|
||||
}
|
||||
logger.Info("Configuration file has been initialized successfully")
|
||||
}
|
||||
func Get() *Gateway {
|
||||
if cfg == nil {
|
||||
c := &Gateway{}
|
||||
c.Setup(GetConfigPaths())
|
||||
cfg = c
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
func (Gateway) Setup(conf string) *Gateway {
|
||||
if util.FileExists(conf) {
|
||||
buf, err := os.ReadFile(conf)
|
||||
if err != nil {
|
||||
return &Gateway{}
|
||||
}
|
||||
util.SetEnv("GOMA_CONFIG_FILE", conf)
|
||||
c := &GatewayConfig{}
|
||||
err = yaml.Unmarshal(buf, c)
|
||||
if err != nil {
|
||||
logger.Fatal("Error loading configuration %v", err.Error())
|
||||
}
|
||||
return &c.GatewayConfig
|
||||
}
|
||||
return &Gateway{}
|
||||
|
||||
}
|
||||
func (middleware Middleware) name() {
|
||||
|
||||
}
|
||||
func ToJWTRuler(input interface{}) (JWTRuler, error) {
|
||||
jWTRuler := new(JWTRuler)
|
||||
var bytes []byte
|
||||
bytes, err := yaml.Marshal(input)
|
||||
if err != nil {
|
||||
return JWTRuler{}, fmt.Errorf("error marshalling yaml: %v", err)
|
||||
}
|
||||
err = yaml.Unmarshal(bytes, jWTRuler)
|
||||
if err != nil {
|
||||
return JWTRuler{}, fmt.Errorf("error unmarshalling yaml: %v", err)
|
||||
}
|
||||
return *jWTRuler, nil
|
||||
}
|
||||
|
||||
func ToBasicAuth(input interface{}) (BasicRule, error) {
|
||||
basicAuth := new(BasicRule)
|
||||
var bytes []byte
|
||||
bytes, err := yaml.Marshal(input)
|
||||
if err != nil {
|
||||
return BasicRule{}, fmt.Errorf("error marshalling yaml: %v", err)
|
||||
}
|
||||
err = yaml.Unmarshal(bytes, basicAuth)
|
||||
if err != nil {
|
||||
return BasicRule{}, fmt.Errorf("error unmarshalling yaml: %v", err)
|
||||
}
|
||||
return *basicAuth, nil
|
||||
}
|
||||
107
pkg/handler.go
Normal file
107
pkg/handler.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package pkg
|
||||
|
||||
/*
|
||||
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 (
|
||||
"encoding/json"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CORSHandler handles CORS headers for incoming requests
|
||||
//
|
||||
// Adds CORS headers to the response dynamically based on the provided headers map[string]string
|
||||
func CORSHandler(cors Cors) mux.MiddlewareFunc {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers from the cors config
|
||||
//Update Cors Headers
|
||||
for k, v := range cors.Headers {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
//Update Origin Cors Headers
|
||||
for _, origin := range cors.Origins {
|
||||
if origin == r.Header.Get("Origin") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
|
||||
}
|
||||
}
|
||||
// Handle preflight requests (OPTIONS)
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
// Pass the request to the next handler
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyErrorHandler catches backend errors and returns a custom response
|
||||
func ProxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||
logger.Error("Proxy error: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
err = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"code": http.StatusBadGateway,
|
||||
"message": "The service is currently unavailable. Please try again later.",
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// HealthCheckHandler handles health check of routes
|
||||
func (heathRoute HealthCheckRoute) HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||
logger.Info("%s %s %s %s", r.Method, r.RemoteAddr, r.URL, r.UserAgent())
|
||||
var routes []HealthCheckRouteResponse
|
||||
for _, route := range heathRoute.Routes {
|
||||
if route.HealthCheck != "" {
|
||||
err := HealthCheck(route.Destination + route.HealthCheck)
|
||||
if err != nil {
|
||||
logger.Error("Route %s: %v", route.Name, err)
|
||||
if heathRoute.DisableRouteHealthCheckError {
|
||||
routes = append(routes, HealthCheckRouteResponse{Name: route.Name, Status: "unhealthy", Error: "Route healthcheck errors disabled"})
|
||||
continue
|
||||
}
|
||||
routes = append(routes, HealthCheckRouteResponse{Name: route.Name, Status: "unhealthy", Error: err.Error()})
|
||||
continue
|
||||
} else {
|
||||
logger.Info("Route %s is healthy", route.Name)
|
||||
routes = append(routes, HealthCheckRouteResponse{Name: route.Name, Status: "healthy", Error: ""})
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
logger.Error("Route %s's healthCheck is undefined", route.Name)
|
||||
routes = append(routes, HealthCheckRouteResponse{Name: route.Name, Status: "undefined", Error: ""})
|
||||
continue
|
||||
|
||||
}
|
||||
}
|
||||
response := HealthCheckResponse{
|
||||
Status: "healthy",
|
||||
Routes: routes,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
err := json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
67
pkg/healthCheck.go
Normal file
67
pkg/healthCheck.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package pkg
|
||||
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type HealthCheckRoute struct {
|
||||
DisableRouteHealthCheckError bool
|
||||
Routes []Route
|
||||
}
|
||||
|
||||
// HealthCheckResponse represents the health check response structure
|
||||
type HealthCheckResponse struct {
|
||||
Status string `json:"status"`
|
||||
Routes []HealthCheckRouteResponse `json:"routes"`
|
||||
}
|
||||
type HealthCheckRouteResponse struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func HealthCheck(healthURL string) error {
|
||||
healthCheckURL, err := url.Parse(healthURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing HealthCheck URL: %v ", err)
|
||||
}
|
||||
// Create a new request for the route
|
||||
healthReq, err := http.NewRequest("GET", healthCheckURL.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating HealthCheck request: %v ", err)
|
||||
}
|
||||
// Perform the request to the route's healthcheck
|
||||
client := &http.Client{}
|
||||
healthResp, err := client.Do(healthReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error performing HealthCheck request: %v ", err)
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
}
|
||||
}(healthResp.Body)
|
||||
|
||||
if healthResp.StatusCode >= 400 {
|
||||
return fmt.Errorf("health check failed with status code %v", healthResp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
24
pkg/helpers.go
Normal file
24
pkg/helpers.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package pkg
|
||||
|
||||
/*
|
||||
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 get a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/common-nighthawk/go-figure"
|
||||
"github.com/jkaninda/goma-gateway/util"
|
||||
)
|
||||
|
||||
func Intro() {
|
||||
nameFigure := figure.NewFigure("Goma", "", true)
|
||||
nameFigure.Print()
|
||||
fmt.Printf("Version: %s\n", util.FullVersion())
|
||||
fmt.Println("Copyright (c) 2024 Jonas Kaninda")
|
||||
fmt.Println("Starting Goma server...")
|
||||
}
|
||||
38
pkg/middleware.go
Normal file
38
pkg/middleware.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func searchMiddleware(rules []string, middlewares []Middleware) (Middleware, error) {
|
||||
for _, m := range middlewares {
|
||||
if slices.Contains(rules, m.Name) {
|
||||
return m, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return Middleware{}, errors.New("no middleware found with name " + strings.Join(rules, ";"))
|
||||
}
|
||||
func getMiddleware(rule string, middlewares []Middleware) (Middleware, error) {
|
||||
for _, m := range middlewares {
|
||||
if strings.Contains(rule, m.Name) {
|
||||
|
||||
return m, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return Middleware{}, errors.New("no middleware found with name " + rule)
|
||||
}
|
||||
|
||||
type RoutePath struct {
|
||||
route Route
|
||||
path string
|
||||
rules []string
|
||||
middlewares []Middleware
|
||||
router *mux.Router
|
||||
}
|
||||
99
pkg/middleware/bloclist.go
Normal file
99
pkg/middleware/bloclist.go
Normal file
@@ -0,0 +1,99 @@
|
||||
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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"github.com/jkaninda/goma-gateway/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BlocklistMiddleware checks if the request path is forbidden and returns 403 Forbidden
|
||||
func (blockList BlockListMiddleware) BlocklistMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, block := range blockList.List {
|
||||
if isPathBlocked(r.URL.Path, util.ParseURLPath(blockList.Path+block)) {
|
||||
logger.Error("Access to %s is forbidden", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Success: false,
|
||||
Code: http.StatusNotFound,
|
||||
Message: fmt.Sprintf("Not found: %s", r.URL.Path),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to determine if the request path is blocked
|
||||
func isPathBlocked(requestPath, blockedPath string) bool {
|
||||
// Handle exact match
|
||||
if requestPath == blockedPath {
|
||||
return true
|
||||
}
|
||||
// Handle wildcard match (e.g., /admin/* should block /admin and any subpath)
|
||||
if strings.HasSuffix(blockedPath, "/*") {
|
||||
basePath := strings.TrimSuffix(blockedPath, "/*")
|
||||
if strings.HasPrefix(requestPath, basePath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter with the specified refill rate and token capacity
|
||||
func NewRateLimiter(maxTokens int, refillRate time.Duration) *TokenRateLimiter {
|
||||
return &TokenRateLimiter{
|
||||
tokens: maxTokens,
|
||||
maxTokens: maxTokens,
|
||||
refillRate: refillRate,
|
||||
lastRefill: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow checks if a request is allowed based on the current token bucket
|
||||
func (rl *TokenRateLimiter) Allow() bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
// Refill tokens based on the time elapsed
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(rl.lastRefill)
|
||||
tokensToAdd := int(elapsed / rl.refillRate)
|
||||
if tokensToAdd > 0 {
|
||||
rl.tokens = min(rl.maxTokens, rl.tokens+tokensToAdd)
|
||||
rl.lastRefill = now
|
||||
}
|
||||
|
||||
// Check if there are enough tokens to allow the request
|
||||
if rl.tokens > 0 {
|
||||
rl.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject request if no tokens are available
|
||||
return false
|
||||
}
|
||||
276
pkg/middleware/middleware.go
Normal file
276
pkg/middleware/middleware.go
Normal file
@@ -0,0 +1,276 @@
|
||||
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 (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter defines rate limit properties.
|
||||
type RateLimiter struct {
|
||||
Requests int
|
||||
Window time.Duration
|
||||
ClientMap map[string]*Client
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Client stores request count and window expiration for each client.
|
||||
type Client struct {
|
||||
RequestCount int
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiterWindow creates a new RateLimiter.
|
||||
func NewRateLimiterWindow(requests int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
Requests: requests,
|
||||
Window: window,
|
||||
ClientMap: make(map[string]*Client),
|
||||
}
|
||||
}
|
||||
|
||||
type TokenRateLimiter struct {
|
||||
tokens int
|
||||
maxTokens int
|
||||
refillRate time.Duration
|
||||
lastRefill time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ProxyResponseError represents the structure of the JSON error response
|
||||
type ProxyResponseError struct {
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// AuthJWT Define struct
|
||||
type AuthJWT struct {
|
||||
AuthURL string
|
||||
RequiredHeaders []string
|
||||
Headers map[string]string
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
// AuthenticationMiddleware Define struct
|
||||
type AuthenticationMiddleware struct {
|
||||
AuthURL string
|
||||
RequiredHeaders []string
|
||||
Headers map[string]string
|
||||
Params map[string]string
|
||||
}
|
||||
type BlockListMiddleware struct {
|
||||
Path string
|
||||
Destination string
|
||||
List []string
|
||||
}
|
||||
|
||||
// AuthBasic Define Basic auth
|
||||
type AuthBasic struct {
|
||||
Username string
|
||||
Password string
|
||||
Headers map[string]string
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
// AuthMiddleware function, which will be called for each request
|
||||
func (amw AuthJWT) AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, header := range amw.RequiredHeaders {
|
||||
if r.Header.Get(header) == "" {
|
||||
logger.Error("Proxy error, missing %s header", header)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Message: "Missing Authorization header",
|
||||
Code: http.StatusForbidden,
|
||||
Success: false,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
//token := r.Header.Get("Authorization")
|
||||
authURL, err := url.Parse(amw.AuthURL)
|
||||
if err != nil {
|
||||
logger.Error("Error parsing auth URL: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
err = json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Message: "Internal Server Error",
|
||||
Code: http.StatusInternalServerError,
|
||||
Success: false,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
// Create a new request for /authentication
|
||||
authReq, err := http.NewRequest("GET", authURL.String(), nil)
|
||||
if err != nil {
|
||||
logger.Error("Proxy error creating authentication request: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
err = json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Message: "Internal Server Error",
|
||||
Code: http.StatusInternalServerError,
|
||||
Success: false,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
// Copy headers from the original request to the new request
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
authReq.Header.Set(name, value)
|
||||
}
|
||||
}
|
||||
// Copy cookies from the original request to the new request
|
||||
for _, cookie := range r.Cookies() {
|
||||
authReq.AddCookie(cookie)
|
||||
}
|
||||
// Perform the request to the auth service
|
||||
client := &http.Client{}
|
||||
authResp, err := client.Do(authReq)
|
||||
if err != nil || authResp.StatusCode != http.StatusOK {
|
||||
logger.Info("%s %s %s %s", r.Method, r.RemoteAddr, r.URL, r.UserAgent())
|
||||
logger.Error("Proxy authentication error")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
err = json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Message: "Unauthorized",
|
||||
Code: http.StatusUnauthorized,
|
||||
Success: false,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
|
||||
}
|
||||
}(authResp.Body)
|
||||
// Inject specific header tp the current request's header
|
||||
// Add header to the next request from AuthRequest header, depending on your requirements
|
||||
if amw.Headers != nil {
|
||||
for k, v := range amw.Headers {
|
||||
r.Header.Set(v, authResp.Header.Get(k))
|
||||
}
|
||||
}
|
||||
query := r.URL.Query()
|
||||
// Add query parameters to the next request from AuthRequest header, depending on your requirements
|
||||
if amw.Params != nil {
|
||||
for k, v := range amw.Params {
|
||||
query.Set(v, authResp.Header.Get(k))
|
||||
}
|
||||
}
|
||||
r.URL.RawQuery = query.Encode()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// AuthMiddleware checks for the Authorization header and verifies the credentials
|
||||
func (basicAuth AuthBasic) AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
logger.Error("Proxy error, missing Authorization header")
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Success: false,
|
||||
Code: http.StatusUnauthorized,
|
||||
Message: "Unauthorized",
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
// Check if the Authorization header contains "Basic" scheme
|
||||
if !strings.HasPrefix(authHeader, "Basic ") {
|
||||
logger.Error("Proxy error, missing Basic Authorization header")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Success: false,
|
||||
Code: http.StatusUnauthorized,
|
||||
Message: "Unauthorized",
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the base64 encoded username:password string
|
||||
payload, err := base64.StdEncoding.DecodeString(authHeader[len("Basic "):])
|
||||
if err != nil {
|
||||
logger.Error("Proxy error, missing Basic Authorization header")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Success: false,
|
||||
Code: http.StatusUnauthorized,
|
||||
Message: "Unauthorized",
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Split the payload into username and password
|
||||
pair := strings.SplitN(string(payload), ":", 2)
|
||||
if len(pair) != 2 || pair[0] != basicAuth.Username || pair[1] != basicAuth.Password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Success: false,
|
||||
Code: http.StatusUnauthorized,
|
||||
Message: "Unauthorized",
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Continue to the next handler if the authentication is successful
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
}
|
||||
90
pkg/middleware/rate_limiter.go
Normal file
90
pkg/middleware/rate_limiter.go
Normal file
@@ -0,0 +1,90 @@
|
||||
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 (
|
||||
"encoding/json"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimitMiddleware limits request based on the number of tokens peer minutes.
|
||||
func (rl *TokenRateLimiter) RateLimitMiddleware() mux.MiddlewareFunc {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !rl.Allow() {
|
||||
// Rate limit exceeded, return a 429 Too Many Requests response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Success: false,
|
||||
Code: http.StatusTooManyRequests,
|
||||
Message: "Too many requests. Please try again later.",
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Proceed to the next handler if rate limit is not exceeded
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitMiddleware limits request based on the number of requests peer minutes.
|
||||
func (rl *RateLimiter) RateLimitMiddleware() mux.MiddlewareFunc {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
//TODO:
|
||||
clientID := r.RemoteAddr
|
||||
logger.Info(clientID)
|
||||
|
||||
rl.mu.Lock()
|
||||
client, exists := rl.ClientMap[clientID]
|
||||
if !exists || time.Now().After(client.ExpiresAt) {
|
||||
client = &Client{
|
||||
RequestCount: 0,
|
||||
ExpiresAt: time.Now().Add(rl.Window),
|
||||
}
|
||||
rl.ClientMap[clientID] = client
|
||||
}
|
||||
client.RequestCount++
|
||||
rl.mu.Unlock()
|
||||
|
||||
if client.RequestCount > rl.Requests {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
err := json.NewEncoder(w).Encode(ProxyResponseError{
|
||||
Success: false,
|
||||
Code: http.StatusTooManyRequests,
|
||||
Message: "Too many requests. Please try again later.",
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Proceed to the next handler if rate limit is not exceeded
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
110
pkg/middleware_test.go
Normal file
110
pkg/middleware_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package pkg
|
||||
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"gopkg.in/yaml.v3"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const MidName = "google-jwt"
|
||||
|
||||
var rules = []string{"fake", "jwt", "google-jwt"}
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
TestInit(t)
|
||||
middlewares := []Middleware{
|
||||
{
|
||||
Name: "basic-auth",
|
||||
Type: "basic",
|
||||
Rule: BasicRule{
|
||||
Username: "goma",
|
||||
Password: "goma",
|
||||
},
|
||||
}, {
|
||||
Name: MidName,
|
||||
Type: "jwt",
|
||||
Rule: JWTRuler{
|
||||
URL: "https://www.googleapis.com/auth/userinfo.email",
|
||||
Headers: map[string]string{},
|
||||
Params: map[string]string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
yamlData, err := yaml.Marshal(&middlewares)
|
||||
if err != nil {
|
||||
t.Fatalf("Error serializing configuration %v", err.Error())
|
||||
}
|
||||
err = os.WriteFile(configFile, yamlData, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to write config file %s", err)
|
||||
}
|
||||
log.Printf("Config file written to %s", configFile)
|
||||
}
|
||||
|
||||
func TestReadMiddleware(t *testing.T) {
|
||||
TestMiddleware(t)
|
||||
middlewares := getMiddlewares(t)
|
||||
middleware, err := searchMiddleware(rules, middlewares)
|
||||
if err != nil {
|
||||
t.Fatalf("Error searching middleware %s", err.Error())
|
||||
}
|
||||
switch middleware.Type {
|
||||
case "basic":
|
||||
log.Println("Basic auth")
|
||||
basicAuth, err := ToBasicAuth(middleware.Rule)
|
||||
if err != nil {
|
||||
log.Fatalln("error:", err)
|
||||
}
|
||||
log.Printf("Username: %s and password: %s\n", basicAuth.Username, basicAuth.Password)
|
||||
case "jwt":
|
||||
log.Println("JWT auth")
|
||||
jwt, err := ToJWTRuler(middleware.Rule)
|
||||
if err != nil {
|
||||
log.Fatalln("error:", err)
|
||||
}
|
||||
log.Printf("JWT authentification URL is %s\n", jwt.URL)
|
||||
default:
|
||||
t.Errorf("Unknown middleware type %s", middleware.Type)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestFoundMiddleware(t *testing.T) {
|
||||
middlewares := getMiddlewares(t)
|
||||
middleware, err := searchMiddleware(rules, middlewares)
|
||||
if err != nil {
|
||||
t.Errorf("Error getting middleware %v", err)
|
||||
}
|
||||
fmt.Println(middleware.Type)
|
||||
}
|
||||
|
||||
func getMiddlewares(t *testing.T) []Middleware {
|
||||
buf, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read config file %s", configFile)
|
||||
}
|
||||
c := &[]Middleware{}
|
||||
err = yaml.Unmarshal(buf, c)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to parse config file %s", configFile)
|
||||
}
|
||||
return *c
|
||||
}
|
||||
113
pkg/proxy.go
Normal file
113
pkg/proxy.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package pkg
|
||||
|
||||
/*
|
||||
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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProxyRoute struct {
|
||||
path string
|
||||
rewrite string
|
||||
destination string
|
||||
cors Cors
|
||||
disableXForward bool
|
||||
}
|
||||
|
||||
// ProxyHandler proxies requests to the backend
|
||||
func (proxyRoute ProxyRoute) ProxyHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
logger.Info("%s %s %s %s", r.Method, r.RemoteAddr, r.URL, r.UserAgent())
|
||||
// Set CORS headers from the cors config
|
||||
//Update Cors Headers
|
||||
for k, v := range proxyRoute.cors.Headers {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
|
||||
//Update Origin Cors Headers
|
||||
for _, origin := range proxyRoute.cors.Origins {
|
||||
if origin == r.Header.Get("Origin") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
|
||||
}
|
||||
}
|
||||
// Handle preflight requests (OPTIONS)
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
// Parse the target backend URL
|
||||
targetURL, err := url.Parse(proxyRoute.destination)
|
||||
if err != nil {
|
||||
logger.Error("Error parsing backend URL: %s", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
err := json.NewEncoder(w).Encode(ErrorResponse{
|
||||
Message: "Internal server error",
|
||||
Code: http.StatusInternalServerError,
|
||||
Success: false,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
// 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", r.RemoteAddr)
|
||||
r.Header.Set("X-Real-IP", r.RemoteAddr)
|
||||
r.Host = targetURL.Host
|
||||
}
|
||||
// Create proxy
|
||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
// Rewrite
|
||||
if proxyRoute.path != "" && proxyRoute.rewrite != "" {
|
||||
// Rewrite the path
|
||||
if strings.HasPrefix(r.URL.Path, fmt.Sprintf("%s/", proxyRoute.path)) {
|
||||
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
|
||||
proxy.ErrorHandler = ProxyErrorHandler
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowed(cors []string, r *http.Request) bool {
|
||||
for _, origin := range cors {
|
||||
if origin == r.Header.Get("Origin") {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
return false
|
||||
|
||||
}
|
||||
133
pkg/route.go
Normal file
133
pkg/route.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package pkg
|
||||
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"github.com/jkaninda/goma-gateway/pkg/middleware"
|
||||
"github.com/jkaninda/goma-gateway/util"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
||||
gateway := gatewayServer.gateway
|
||||
middlewares := gatewayServer.middlewares
|
||||
r := mux.NewRouter()
|
||||
heath := HealthCheckRoute{
|
||||
DisableRouteHealthCheckError: gateway.DisableRouteHealthCheckError,
|
||||
Routes: gateway.Routes,
|
||||
}
|
||||
// Define the health check route
|
||||
r.HandleFunc("/health", heath.HealthCheckHandler).Methods("GET")
|
||||
r.HandleFunc("/healthz", heath.HealthCheckHandler).Methods("GET")
|
||||
// Apply global Cors middlewares
|
||||
r.Use(CORSHandler(gateway.Cors)) // Apply CORS middleware
|
||||
if gateway.RateLimiter != 0 {
|
||||
//rateLimiter := middleware.NewRateLimiter(gateway.RateLimiter, time.Minute)
|
||||
limiter := middleware.NewRateLimiterWindow(gateway.RateLimiter, time.Minute) // requests per minute
|
||||
// Add rate limit middleware to all routes, if defined
|
||||
r.Use(limiter.RateLimitMiddleware())
|
||||
}
|
||||
for _, route := range gateway.Routes {
|
||||
blM := middleware.BlockListMiddleware{
|
||||
Path: route.Path,
|
||||
List: route.Blocklist,
|
||||
}
|
||||
// Add block access middleware to all route, if defined
|
||||
r.Use(blM.BlocklistMiddleware)
|
||||
//if route.Middlewares != nil {
|
||||
for _, mid := range route.Middlewares {
|
||||
secureRouter := r.PathPrefix(util.ParseURLPath(route.Path + mid.Path)).Subrouter()
|
||||
proxyRoute := ProxyRoute{
|
||||
path: route.Path,
|
||||
rewrite: route.Rewrite,
|
||||
destination: route.Destination,
|
||||
disableXForward: route.DisableHeaderXForward,
|
||||
cors: route.Cors,
|
||||
}
|
||||
rMiddleware, err := searchMiddleware(mid.Rules, middlewares)
|
||||
if err != nil {
|
||||
logger.Error("Middleware name not found")
|
||||
} else {
|
||||
//Check Authentication middleware
|
||||
switch rMiddleware.Type {
|
||||
case "basic":
|
||||
basicAuth, err := ToBasicAuth(rMiddleware.Rule)
|
||||
if err != nil {
|
||||
|
||||
logger.Error("Error: %s", err.Error())
|
||||
} else {
|
||||
amw := middleware.AuthBasic{
|
||||
Username: basicAuth.Username,
|
||||
Password: basicAuth.Password,
|
||||
Headers: nil,
|
||||
Params: nil,
|
||||
}
|
||||
// Apply JWT authentication middleware
|
||||
secureRouter.Use(amw.AuthMiddleware)
|
||||
}
|
||||
case "jwt":
|
||||
jwt, err := ToJWTRuler(rMiddleware.Rule)
|
||||
if err != nil {
|
||||
|
||||
} else {
|
||||
amw := middleware.AuthJWT{
|
||||
AuthURL: jwt.URL,
|
||||
RequiredHeaders: jwt.RequiredHeaders,
|
||||
Headers: jwt.Headers,
|
||||
Params: jwt.Params,
|
||||
}
|
||||
// Apply JWT authentication middleware
|
||||
secureRouter.Use(amw.AuthMiddleware)
|
||||
|
||||
}
|
||||
default:
|
||||
logger.Error("Unknown middleware type %s", rMiddleware.Type)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
secureRouter.Use(CORSHandler(route.Cors))
|
||||
secureRouter.PathPrefix("/").Handler(proxyRoute.ProxyHandler()) // Proxy handler
|
||||
secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler
|
||||
}
|
||||
proxyRoute := ProxyRoute{
|
||||
path: route.Path,
|
||||
rewrite: route.Rewrite,
|
||||
destination: route.Destination,
|
||||
disableXForward: route.DisableHeaderXForward,
|
||||
cors: route.Cors,
|
||||
}
|
||||
|
||||
router := r.PathPrefix(route.Path).Subrouter()
|
||||
router.Use(CORSHandler(route.Cors))
|
||||
router.PathPrefix("/").Handler(proxyRoute.ProxyHandler())
|
||||
}
|
||||
return r
|
||||
|
||||
}
|
||||
|
||||
func printRoute(routes []Route) {
|
||||
t := table.NewWriter()
|
||||
t.AppendHeader(table.Row{"Name", "Route", "Rewrite", "Destination"})
|
||||
for _, route := range routes {
|
||||
t.AppendRow(table.Row{route.Name, route.Path, route.Rewrite, route.Destination})
|
||||
}
|
||||
fmt.Println(t.Render())
|
||||
}
|
||||
68
pkg/server.go
Normal file
68
pkg/server.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package pkg
|
||||
|
||||
/*
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/jkaninda/goma-gateway/internal/logger"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (gatewayServer GatewayServer) Start(ctx context.Context) error {
|
||||
logger.Info("Initializing routes...")
|
||||
route := gatewayServer.Initialize()
|
||||
logger.Info("Initializing routes...done")
|
||||
srv := &http.Server{
|
||||
Addr: gatewayServer.gateway.ListenAddr,
|
||||
WriteTimeout: time.Second * time.Duration(gatewayServer.gateway.WriteTimeout),
|
||||
ReadTimeout: time.Second * time.Duration(gatewayServer.gateway.ReadTimeout),
|
||||
IdleTimeout: time.Second * time.Duration(gatewayServer.gateway.IdleTimeout),
|
||||
Handler: route, // Pass our instance of gorilla/mux in.
|
||||
}
|
||||
if !gatewayServer.gateway.DisableDisplayRouteOnStart {
|
||||
printRoute(gatewayServer.gateway.Routes)
|
||||
}
|
||||
go func() {
|
||||
|
||||
logger.Info("Started Goma Gateway server on %v", gatewayServer.gateway.ListenAddr)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
logger.Error("Error starting Goma Gateway server: %v", err)
|
||||
}
|
||||
}()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-ctx.Done()
|
||||
shutdownCtx := context.Background()
|
||||
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
_, err := fmt.Fprintf(os.Stderr, "error shutting down Goma Gateway server: %s\n", err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
return nil
|
||||
|
||||
}
|
||||
52
pkg/server_test.go
Normal file
52
pkg/server_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testPath = "./tests"
|
||||
|
||||
var configFile = filepath.Join(testPath, "goma.yml")
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
err := os.MkdirAll(testPath, os.ModePerm)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStart(t *testing.T) {
|
||||
TestInit(t)
|
||||
initConfig(configFile)
|
||||
g := GatewayServer{}
|
||||
gatewayServer, err := g.Config(configFile)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
route := gatewayServer.Initialize()
|
||||
route.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
_, err := rw.Write([]byte("Hello Goma Proxy"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed writing HTTP response: %v", err)
|
||||
}
|
||||
})
|
||||
assertResponseBody := func(t *testing.T, s *httptest.Server, expectedBody string) {
|
||||
resp, err := s.Client().Get(s.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error getting from server: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected a status code of 200, got %v", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
t.Run("httpServer", func(t *testing.T) {
|
||||
s := httptest.NewServer(route)
|
||||
defer s.Close()
|
||||
assertResponseBody(t, s, "Hello Goma Proxy")
|
||||
})
|
||||
|
||||
}
|
||||
3
pkg/var.go
Normal file
3
pkg/var.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package pkg
|
||||
|
||||
const ConfigFile = "/config/goma.yml"
|
||||
31
util/constants.go
Normal file
31
util/constants.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package util
|
||||
|
||||
/*
|
||||
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 get a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var Version string
|
||||
|
||||
func VERSION(def string) string {
|
||||
build := os.Getenv("VERSION")
|
||||
if build == "" {
|
||||
return def
|
||||
}
|
||||
return build
|
||||
}
|
||||
func FullVersion() string {
|
||||
ver := Version
|
||||
if b := VERSION(""); b != "" {
|
||||
return b
|
||||
}
|
||||
return ver
|
||||
}
|
||||
84
util/helpers.go
Normal file
84
util/helpers.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package util
|
||||
|
||||
/*
|
||||
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 get a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileExists checks if the file does exist
|
||||
func FileExists(filename string) bool {
|
||||
info, err := os.Stat(filename)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
func GetStringEnv(key, defaultValue string) string {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func GetIntEnv(key string, defaultValue int) int {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return defaultValue
|
||||
|
||||
}
|
||||
i, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
|
||||
}
|
||||
return i
|
||||
|
||||
}
|
||||
func GetBoolEnv(key string, defaultValue bool) bool {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return defaultValue
|
||||
|
||||
}
|
||||
b, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return b
|
||||
|
||||
}
|
||||
|
||||
// SetEnv Set env
|
||||
func SetEnv(name, value string) {
|
||||
err := os.Setenv(name, value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
func MergeSlices(slice1, slice2 []string) []string {
|
||||
return append(slice1, slice2...)
|
||||
}
|
||||
|
||||
// ParseURLPath returns a URL path
|
||||
func ParseURLPath(urlPath string) string {
|
||||
// Replace any double slashes with a single slash
|
||||
urlPath = strings.ReplaceAll(urlPath, "//", "/")
|
||||
|
||||
// Ensure the path starts with a single leading slash
|
||||
if !strings.HasPrefix(urlPath, "/") {
|
||||
urlPath = "/" + urlPath
|
||||
}
|
||||
return urlPath
|
||||
}
|
||||
1
util/util_test.go
Normal file
1
util/util_test.go
Normal file
@@ -0,0 +1 @@
|
||||
package util
|
||||
Reference in New Issue
Block a user