commit 1923506e0ac7e92c497e1e1ededa812f102cc431 Author: Jonas Kaninda Date: Sun Oct 27 06:10:27 2024 +0100 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7de0c7f --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..10b9356 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3530887 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 }}" + diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..01ddbf4 --- /dev/null +++ b/.github/workflows/go.yml @@ -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 ./... \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a8d6916 --- /dev/null +++ b/.github/workflows/release.yml @@ -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" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1996d9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/.history +data +compose.yaml +.env +test.md +.DS_Store +goma-gateway +goma +/.idea +bin +Makefile +NOTES.md +tests \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b7fcb86 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1353923 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d668b9 --- /dev/null +++ b/README.md @@ -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 diff --git a/cmd/config/config.go b/cmd/config/config.go new file mode 100644 index 0000000..ce6112b --- /dev/null +++ b/cmd/config/config.go @@ -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) +} diff --git a/cmd/config/init.go b/cmd/config/init.go new file mode 100644 index 0000000..b892c8b --- /dev/null +++ b/cmd/config/init.go @@ -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") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..814ff8b --- /dev/null +++ b/cmd/root.go @@ -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) + +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..5634272 --- /dev/null +++ b/cmd/server.go @@ -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") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8387f1d --- /dev/null +++ b/go.mod @@ -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 + +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..957e728 --- /dev/null +++ b/go.sum @@ -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= diff --git a/goma.yml b/goma.yml new file mode 100644 index 0000000..1e3a85a --- /dev/null +++ b/goma.yml @@ -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 \ No newline at end of file diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..bdb15dd --- /dev/null +++ b/internal/logger/logger.go @@ -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 + + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f41279e --- /dev/null +++ b/main.go @@ -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() +} diff --git a/pkg/config.go b/pkg/config.go new file mode 100644 index 0000000..b0c0c6b --- /dev/null +++ b/pkg/config.go @@ -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 +} diff --git a/pkg/handler.go b/pkg/handler.go new file mode 100644 index 0000000..456613c --- /dev/null +++ b/pkg/handler.go @@ -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 + } +} diff --git a/pkg/healthCheck.go b/pkg/healthCheck.go new file mode 100644 index 0000000..ae071ee --- /dev/null +++ b/pkg/healthCheck.go @@ -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 +} diff --git a/pkg/helpers.go b/pkg/helpers.go new file mode 100644 index 0000000..fba4a60 --- /dev/null +++ b/pkg/helpers.go @@ -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...") +} diff --git a/pkg/middleware.go b/pkg/middleware.go new file mode 100644 index 0000000..63724b3 --- /dev/null +++ b/pkg/middleware.go @@ -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 +} diff --git a/pkg/middleware/bloclist.go b/pkg/middleware/bloclist.go new file mode 100644 index 0000000..dc443b2 --- /dev/null +++ b/pkg/middleware/bloclist.go @@ -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 +} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go new file mode 100644 index 0000000..30bf8eb --- /dev/null +++ b/pkg/middleware/middleware.go @@ -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) + }) + +} diff --git a/pkg/middleware/rate_limiter.go b/pkg/middleware/rate_limiter.go new file mode 100644 index 0000000..8508cb8 --- /dev/null +++ b/pkg/middleware/rate_limiter.go @@ -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) + }) + } +} diff --git a/pkg/middleware_test.go b/pkg/middleware_test.go new file mode 100644 index 0000000..822bb82 --- /dev/null +++ b/pkg/middleware_test.go @@ -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 +} diff --git a/pkg/proxy.go b/pkg/proxy.go new file mode 100644 index 0000000..65535a1 --- /dev/null +++ b/pkg/proxy.go @@ -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 + +} diff --git a/pkg/route.go b/pkg/route.go new file mode 100644 index 0000000..e7308c8 --- /dev/null +++ b/pkg/route.go @@ -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()) +} diff --git a/pkg/server.go b/pkg/server.go new file mode 100644 index 0000000..9dfbcdc --- /dev/null +++ b/pkg/server.go @@ -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 + +} diff --git a/pkg/server_test.go b/pkg/server_test.go new file mode 100644 index 0000000..d40048e --- /dev/null +++ b/pkg/server_test.go @@ -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") + }) + +} diff --git a/pkg/var.go b/pkg/var.go new file mode 100644 index 0000000..4850aef --- /dev/null +++ b/pkg/var.go @@ -0,0 +1,3 @@ +package pkg + +const ConfigFile = "/config/goma.yml" diff --git a/util/constants.go b/util/constants.go new file mode 100644 index 0000000..2bcc2a9 --- /dev/null +++ b/util/constants.go @@ -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 +} diff --git a/util/helpers.go b/util/helpers.go new file mode 100644 index 0000000..264bfad --- /dev/null +++ b/util/helpers.go @@ -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 +} diff --git a/util/util_test.go b/util/util_test.go new file mode 100644 index 0000000..c7d8682 --- /dev/null +++ b/util/util_test.go @@ -0,0 +1 @@ +package util