Initial commit

This commit is contained in:
Jonas Kaninda
2024-10-27 06:10:27 +01:00
commit 1923506e0a
35 changed files with 2592 additions and 0 deletions

9
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,244 @@
# Goma Gateway - simple lightweight API Gateway and Reverse Proxy.
```
_____
/ ____|
| | __ ___ _ __ ___ __ _
| | |_ |/ _ \| '_ ` _ \ / _` |
| |__| | (_) | | | | | | (_| |
\_____|\___/|_| |_| |_|\__,_|
```
Goma Gateway is a lightweight API Gateway and Reverse Proxy.
[![Build](https://github.com/jkaninda/goma-gateway/actions/workflows/release.yml/badge.svg)](https://github.com/jkaninda/goma/actions/workflows/release.yml)
[![Go Report](https://goreportcard.com/badge/github.com/jkaninda/goma-gateway)](https://goreportcard.com/report/github.com/jkaninda/goma-gateway)
[![Go Reference](https://pkg.go.dev/badge/github.com/jkaninda/goma-gateway.svg)](https://pkg.go.dev/github.com/jkaninda/goma-gateway)
![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/jkaninda/goma-gateway?style=flat-square)
## 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
}

View 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)
})
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
package pkg
const ConfigFile = "/config/goma.yml"

31
util/constants.go Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
package util