Compare commits

...

23 Commits

Author SHA1 Message Date
lukaszraczylo e495cf23d9 Read only endpoint support (#10)
* This change introduces ability to set additional endpoint leading to the
instance of the graphql server connected to the read only database.
If regular query is detected and endpoint for `HOST_GRAPHQL_READONLY` value is set,
the query will be proxied to it. Mutations and non-graphql will be sent
to the `HOST_GRAPHQL` endpoint.
2024-03-12 11:16:35 +00:00
lukaszraczylo ba1fef9b57 Improve stats gathering (#9) 2024-03-05 22:42:30 +00:00
lukaszraczylo 3a18e0e935 Improve stats gathering and tests improvements. (#8) 2024-03-05 22:40:06 +00:00
lukaszraczylo b6c284b66d Update dependencies. 2024-03-05 14:40:45 +00:00
lukaszraczylo 88ef1aac7f Reuse http client and add retry to the proxied requests. 2024-03-05 14:38:03 +00:00
lukaszraczylo 6d32278851 Reuse http client. 2024-03-05 14:24:49 +00:00
lukaszraczylo f2085c8491 Update dependencies. 2024-02-15 10:22:31 +00:00
lukaszraczylo ebbb1c53f5 Micro fixes. 2024-02-15 10:21:51 +00:00
lukaszraczylo 0bdea741bf Move results to the struct for ease of management. 2024-02-15 09:50:51 +00:00
lukaszraczylo 4cb0d22874 Return 403 on blocked queries. 2024-02-15 09:34:57 +00:00
lukaszraczylo 9910bb1d45 Update documentation. 2024-02-15 09:31:49 +00:00
lukaszraczylo 756c63c0d1 Add support for 'refresh' in @cached section of the query.
Example:

```
query MyProducts @cached(refresh: true) {
  products {
    id
    name
  }
}
```
2024-02-15 09:29:27 +00:00
lukaszraczylo 029e0166c0 Docker to use distroless image. 2024-02-08 20:50:50 +00:00
lukaszraczylo 4cf27e0e3b Missed on files requiring json encoding. 2024-02-05 15:37:24 +00:00
lukaszraczylo 3149a27466 Update semver config. 2024-02-05 15:31:45 +00:00
lukaszraczylo bb28f2fcd8 Change the json library to the fully compatible ( and fast ) one. 2024-02-05 15:30:59 +00:00
lukaszraczylo d3a8da1dcf Move location of the global proxy client from the per-req to main.
There's no need to re-create it every single time.
2024-02-05 14:35:33 +00:00
lukaszraczylo 794cb1ddf4 Add the prefixed environment variables to avoid potential conflicts. 2024-02-05 14:24:17 +00:00
lukaszraczylo 95f2236c96 Dependencies refresh. 2024-02-05 13:31:33 +00:00
lukaszraczylo 1ff568a271 Update dependencies. 2024-01-11 10:39:15 +00:00
lukaszraczylo b19b17b7c4 Realign the structs to decrease memory footprint.
Add the timeout settings to address the connection drops.
2023-12-14 17:16:38 +00:00
lukaszraczylo cd9c650226 Remove compression from proxied request. 2023-12-13 23:13:34 +00:00
lukaszraczylo d09940ebc4 Update connection settings. 2023-12-13 22:50:41 +00:00
27 changed files with 877 additions and 351 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Run tests on PR
on:
pull_request:
branches:
- "main"
push:
paths-ignore:
- "**/**.md"
- "**/**.yaml"
- "static/**"
branches:
- "!main"
env:
GO_VERSION: ">=1.21"
jobs:
# This job is responsible for preparation of the build
# environment variables.
prepare:
name: Preparing build context
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
id: cache
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Go get dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
go get ./...
# This job is responsible for running tests and linting the codebase
test:
name: "Unit testing"
# needs: [prepare]
runs-on: ubuntu-latest
# container: github/super-linter:v4
needs: [prepare]
services:
# Label used to access the service container
redis:
# Docker Hub image
image: redis
# Set health checks to wait until redis has started
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Install dependencies
run: |
go mod tidy
- name: Run unit tests
env:
REDIS_HOST: redis
REDIS_PORT: 6379
run: |
export REDIS_SERVER="$REDIS_HOST:$REDIS_PORT"
CI_RUN=${CI} make test
+1 -3
View File
@@ -1,9 +1,7 @@
FROM alpine:latest
RUN apk add --no-cache ca-certificates
FROM gcr.io/distroless/base-debian12:nonroot
WORKDIR /go/src/app
ARG TARGETARCH
ARG TARGETOS
ADD dist/bot-$TARGETOS-$TARGETARCH /go/src/app/graphql-proxy
ADD static/default-ratelimit.json /app/ratelimit.json
RUN chmod +x /go/src/app/graphql-proxy
ENTRYPOINT ["/go/src/app/graphql-proxy"]
+24
View File
@@ -15,6 +15,7 @@ This project is in active use by [telegram-bot.app](https://telegram-bot.app), a
- [Configuration](#configuration)
- [Speed](#speed)
- [Caching](#caching)
- [Read-only endpoint](#read-only-endpoint)
- [Security](#security)
- [Role-based rate limiting](#role-based-rate-limiting)
- [Read-only mode](#read-only-mode)
@@ -93,6 +94,7 @@ In this case, both proxy and websockets will be available under the `/v1/graphql
| monitor | Extracting the query name and type and adding it as a label to metrics|
| monitor | Calculating the query duration and adding it to the metrics |
| speed | Caching the queries, together with per-query cache and TTL |
| speed | Support for READ ONLY graphql endpoint |
| security | Blocking schema introspection |
| security | Rate limiting queries based on user role |
| security | Blocking mutations in read-only mode |
@@ -102,11 +104,16 @@ In this case, both proxy and websockets will be available under the `/v1/graphql
### Configuration
All the environment variables **should** be prefixed with `GMP_` to avoid conflicts with other applications.
If `GMP_` prefixed environment variable is present - it will take precedence over the non-prefixed one.
You can still use the non-prefixed environment variables in the spirit of the backward compatibility, but it's not recommended.
| Parameter | Description | Default Value |
|---------------------------|------------------------------------------|----------------------------|
| `MONITORING_PORT` | The port to expose the metrics endpoint | `9393` |
| `PORT_GRAPHQL` | The port to expose the graphql endpoint | `8080` |
| `HOST_GRAPHQL` | The host to proxy the graphql endpoint | `http://localhost/` |
| `HOST_GRAPHQL_READONLY` | The host to proxy the read-only graphql endpoint | `` |
| `HEALTHCHECK_GRAPHQL_URL` | The URL to check the health of the graphql endpoint | `` |
| `JWT_USER_CLAIM_PATH` | Path to the user claim in the JWT token | `` |
| `JWT_ROLE_CLAIM_PATH` | Path to the role claim in the JWT token | `` |
@@ -139,8 +146,25 @@ For example, `query MyCachedQuery @cached(ttl: 90) ....` will set the cache for
You can also set cache for specific query by using `X-Cache-Graphql-Query` header, which will set the cache for the query to the provided time, for example `X-Cache-Graphql-Query: 90` will set the cache for the query to 90 seconds.
You can also force refresh of the cache by using `@cached(refresh: true)` directive in the query, for example:
```
query MyProducts @cached(refresh: true) {
products {
id
name
}
}
```
Since version `0.5.30` the cache is gzipped in the memory, which should optimise the memory usage quite significantly.
#### Read-only endpoint
You can now specify the read-only GraphQL endpoint by setting the `HOST_GRAPHQL_READONLY` environment variable. The default value is empty, preventing the proxy from using the read-only endpoint for the queries and directing all the requests to the main endpoint specified as `HOST_GRAPHQL`. If the `HOST_GRAPHQL_READONLY` is set, the proxy will use the read-only endpoint for the queries with the `query` type and the main endpoint for the `mutation` type queries. Format of the read-only endpoint is the same as `HOST_GRAPHQL` endpoint, for example `http://localhost:8080/`.
You can check out the [example of combined deployment with RW and read-only hasura](static/kubernetes-single-deployment-with-ro.yaml).
### Security
#### Role-based rate limiting
+1
View File
@@ -5,6 +5,7 @@ import (
"os"
"time"
"github.com/goccy/go-json"
fiber "github.com/gofiber/fiber/v2"
"github.com/gofrs/flock"
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
+5 -1
View File
@@ -13,7 +13,7 @@ func calculateHash(c *fiber.Ctx) string {
}
func enableCache() {
cfg.Cache.CacheClient = libpack_cache.New(time.Duration(cfg.Cache.CacheTTL) * time.Second * 100)
cfg.Cache.CacheClient = libpack_cache.New(time.Duration(cfg.Cache.CacheTTL) * time.Second)
}
func cacheLookup(hash string) []byte {
@@ -23,3 +23,7 @@ func cacheLookup(hash string) []byte {
}
return nil
}
func cacheDelete(hash string) {
cfg.Cache.CacheClient.Delete(hash)
}
+3 -3
View File
@@ -9,15 +9,15 @@ import (
)
type CacheEntry struct {
Value []byte
ExpiresAt time.Time
Value []byte
}
type Cache struct {
sync.RWMutex
bytePool sync.Pool
entries sync.Map
globalTTL time.Duration
bytePool sync.Pool
sync.RWMutex
}
func New(globalTTL time.Duration) *Cache {
+1 -2
View File
@@ -1,7 +1,6 @@
package main
import (
"testing"
"time"
)
@@ -38,7 +37,7 @@ func (suite *Tests) Test_cacheLookup() {
},
}
for _, tt := range tests {
suite.T().Run(tt.name, func(t *testing.T) {
suite.Run(tt.name, func() {
if tt.addCache.data != nil {
cfg.Cache.CacheClient.Set(tt.args.hash, tt.addCache.data, time.Duration(90*time.Second))
}
+1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"github.com/goccy/go-json"
"github.com/lukaszraczylo/ask"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
)
+1 -3
View File
@@ -1,7 +1,5 @@
package main
import "testing"
func (suite *Tests) Test_extractClaimsFromJWTHeader() {
jwt_token_for_tests := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiSGFzdXJhIjp7IngtaGFzdXJhLWFsbG93ZWQtcm9sZXMiOlsiZ3Vlc3QiLCJ1c2VyIiwiZ3JvdXBhZG1pbiIsInBheWFkbWluIl0sIngtaGFzdXJhLWRlZmF1bHQtcm9sZSI6Imd1ZXN0IiwieC1oYXN1cmEtdXNlci1pZCI6IjE2NyIsIngtaGFzdXJhLXVzZXItdXVpZCI6ImRkM2U2ZTM1LTA0MDktNDNiMC1iZmYxLWNlZjNjNmVkNWYxMCJ9LCJpc3MiOiJBdXRoU2VydmljZSIsImV4cCI6MTY5NjgwMTcyNiwibmJmIjoxNjk2NTg1NzI2LCJpYXQiOjE2OTY1ODU3MjZ9.dsJ5JKzG5tXOlqeZ_Gfe2XC-vyrcwtYwOGfhvt8q9UY"
@@ -68,7 +66,7 @@ func (suite *Tests) Test_extractClaimsFromJWTHeader() {
},
}
for _, tt := range tests {
suite.T().Run(tt.name, func(t *testing.T) {
suite.Run(tt.name, func() {
if len(tt.jwt_token_path) > 0 {
cfg.Client.JWTUserClaimPath = tt.jwt_token_path
}
+16 -18
View File
@@ -3,36 +3,34 @@ module github.com/lukaszraczylo/graphql-monitoring-proxy
go 1.21
require (
github.com/VictoriaMetrics/metrics v1.25.3
github.com/VictoriaMetrics/metrics v1.33.0
github.com/avast/retry-go/v4 v4.5.1
github.com/buger/jsonparser v1.1.1
github.com/gofiber/fiber/v2 v2.51.0
github.com/goccy/go-json v0.10.2
github.com/gofiber/fiber/v2 v2.52.2
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.5.0
github.com/gookit/goutil v0.6.14
github.com/google/uuid v1.6.0
github.com/gookit/goutil v0.6.15
github.com/graphql-go/graphql v0.8.1
github.com/json-iterator/go v1.1.12
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415
github.com/lukaszraczylo/go-ratecounter v0.1.8
github.com/lukaszraczylo/go-simple-graphql v1.2.6
github.com/rs/zerolog v1.31.0
github.com/lukaszraczylo/go-simple-graphql v1.2.9
github.com/rs/zerolog v1.32.0
github.com/stretchr/testify v1.8.4
github.com/valyala/fasthttp v1.51.0
github.com/valyala/fasthttp v1.52.0
)
require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/avast/retry-go/v4 v4.5.1 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/compress v1.17.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fastrand v1.1.0 // indirect
@@ -40,10 +38,10 @@ require (
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.15.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/term v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+30 -39
View File
@@ -1,34 +1,32 @@
github.com/VictoriaMetrics/metrics v1.25.3 h1:Zcxyj8JbAB6CQU51Er3D7RBRupcP55DevVQi9cFqo2Q=
github.com/VictoriaMetrics/metrics v1.25.3/go.mod h1:ZKmlI+QN6b9LUC0OiHNp2LiGQGlBy4U1re6Slooln1o=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/VictoriaMetrics/metrics v1.33.0 h1:EnkDEaGiL2u95t+W76GfecC/LMYpy+tFrexYzBWQIAc=
github.com/VictoriaMetrics/metrics v1.33.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/avast/retry-go/v4 v4.5.1 h1:AxIx0HGi4VZ3I02jr78j5lZ3M6x1E0Ivxa6b0pUUh7o=
github.com/avast/retry-go/v4 v4.5.1/go.mod h1:/sipNsvNB3RRuT5iNcb6h73nw3IBmXJ/H3XrCQYSOpc=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.51.0 h1:JNACcZy5e2tGApWB2QrRpenTWn0fq0hkFm6k0C86gKQ=
github.com/gofiber/fiber/v2 v2.51.0/go.mod h1:xaQRZQJGqnKOQnbQw+ltvku3/h8QxvNi8o6JiJ7Ll0U=
github.com/gofiber/fiber/v2 v2.52.2 h1:b0rYH6b06Df+4NyrbdptQL8ifuxw/Tf2DgfkZkDaxEo=
github.com/gofiber/fiber/v2 v2.52.2/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gookit/goutil v0.6.14 h1:96elyOG4BvVoDaiT7vx1vHPrVyEtFfYlPPBODR0/FGQ=
github.com/gookit/goutil v0.6.14/go.mod h1:YyDBddefmjS+mU2PDPgCcjVzTDM5WgExiDv5ZA/b8I8=
github.com/gookit/goutil v0.6.15 h1:mMQ0ElojNZoyPD0eVROk5QXJPh2uKR4g06slgPDF5Jo=
github.com/gookit/goutil v0.6.15/go.mod h1:qdKdYEHQdEtyH+4fNdQNZfJHhI0jUZzHxQVAV3DaMDY=
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -40,8 +38,8 @@ github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415 h1:lvI8Wlbg4PxkR
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c=
github.com/lukaszraczylo/go-ratecounter v0.1.8 h1:ZYm6Wkn58ZAlFWRmC7PaD4oAYHWcu8/0MUDWGe3PnJQ=
github.com/lukaszraczylo/go-ratecounter v0.1.8/go.mod h1:TqXEOCtFJStk1i0tkipprv1kiDHGon1MVUisjSTBSKM=
github.com/lukaszraczylo/go-simple-graphql v1.2.6 h1:DH4u5W3qc9PedPKuyJuyG5Pw17swKkBc74MGioCLzno=
github.com/lukaszraczylo/go-simple-graphql v1.2.6/go.mod h1:fYwnUZ1xJqvJSfbU9k8GMMI9Flan2dNXSvg/arS7rzU=
github.com/lukaszraczylo/go-simple-graphql v1.2.9 h1:JKIvAw+4O8vwTv2rZKKRtn0DjLbM8XdKXZHns31Ntvc=
github.com/lukaszraczylo/go-simple-graphql v1.2.9/go.mod h1:YOX06PIgxUyFDJZu5FFFo/9FyGTFSh9Zuld2bU8DywU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -50,32 +48,25 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ=
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
@@ -86,17 +77,17 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
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/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+62 -51
View File
@@ -1,10 +1,10 @@
package main
import (
"flag"
"strconv"
"strings"
"github.com/goccy/go-json"
fiber "github.com/gofiber/fiber/v2"
"github.com/graphql-go/graphql/language/ast"
"github.com/graphql-go/graphql/language/parser"
@@ -40,42 +40,39 @@ var introspectionQuerySet = map[string]struct{}{}
var introspectionAllowedQueries = map[string]struct{}{}
var allowedUrls = map[string]struct{}{}
func prepareQueriesAndExemptions() {
introspectionQuerySet = map[string]struct{}{}
introspectionQuerySet = func() map[string]struct{} {
rsqs := make(map[string]struct{}, len(introspection_queries))
for _, query := range introspection_queries {
rsqs[strings.ToLower(query)] = struct{}{}
}
return rsqs
}()
introspectionAllowedQueries = map[string]struct{}{}
introspectionAllowedQueries = func() map[string]struct{} {
rsqs := make(map[string]struct{}, len(cfg.Security.IntrospectionAllowed))
for _, query := range cfg.Security.IntrospectionAllowed {
rsqs[strings.ToLower(query)] = struct{}{}
}
return rsqs
}()
allowedUrls = map[string]struct{}{}
allowedUrls = func() map[string]struct{} {
rsqs := make(map[string]struct{}, len(cfg.Server.AllowURLs))
for _, query := range cfg.Server.AllowURLs {
rsqs[strings.ToLower(query)] = struct{}{}
}
return rsqs
}()
// Utility function to convert a slice of strings to a map for O(1) lookups.
func sliceToMap(slice []string) map[string]struct{} {
resultMap := make(map[string]struct{}, len(slice))
for _, item := range slice {
resultMap[strings.ToLower(item)] = struct{}{}
}
return resultMap
}
func parseGraphQLQuery(c *fiber.Ctx) (operationType, operationName string, cacheRequest bool, cache_time int, should_block bool, should_ignore bool) {
should_ignore = true
func prepareQueriesAndExemptions() {
introspectionQuerySet = sliceToMap(introspection_queries)
introspectionAllowedQueries = sliceToMap(cfg.Security.IntrospectionAllowed)
allowedUrls = sliceToMap(cfg.Server.AllowURLs)
}
type parseGraphQLQueryResult struct {
operationType string
operationName string
activeEndpoint string
cacheTime int
cacheRequest bool
cacheRefresh bool
shouldBlock bool
shouldIgnore bool
}
func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) {
res = &parseGraphQLQueryResult{shouldIgnore: true}
m := make(map[string]interface{})
err := json.Unmarshal(c.Body(), &m)
if err != nil {
cfg.Logger.Debug("Can't unmarshal the request", map[string]interface{}{"error": err.Error(), "body": string(c.Body())})
if flag.Lookup("test.v") == nil {
cfg.Logger.Error("Can't unmarshal the request", map[string]interface{}{"error": err.Error(), "body": string(c.Body())})
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
return
@@ -84,7 +81,7 @@ func parseGraphQLQuery(c *fiber.Ctx) (operationType, operationName string, cache
query, ok := m["query"].(string)
if !ok {
cfg.Logger.Error("Can't find the query", map[string]interface{}{"query": query, "m_val": m})
if flag.Lookup("test.v") == nil {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
return
@@ -93,53 +90,64 @@ func parseGraphQLQuery(c *fiber.Ctx) (operationType, operationName string, cache
p, err := parser.Parse(parser.ParseParams{Source: query})
if err != nil {
cfg.Logger.Error("Can't parse the query", map[string]interface{}{"query": query, "m_val": m})
if flag.Lookup("test.v") == nil {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
return
}
should_ignore = false
operationName = "undefined"
res.shouldIgnore = false
res.operationName = "undefined"
res.activeEndpoint = cfg.Server.HostGraphQL
for _, d := range p.Definitions {
if oper, ok := d.(*ast.OperationDefinition); ok {
operationType = oper.Operation
res.operationType = strings.ToLower(oper.Operation)
if oper.Name != nil {
operationName = oper.Name.Value
res.operationName = oper.Name.Value
}
if strings.ToLower(operationType) == "mutation" && cfg.Server.ReadOnlyMode {
// If the query is a mutation then direct it to the RW endpoint,
// otherwise direct it to the RO endpoint if it's set.
if cfg.Server.HostGraphQLReadOnly != "" && res.operationType != "mutation" {
res.activeEndpoint = cfg.Server.HostGraphQLReadOnly
}
if res.operationType == "mutation" && cfg.Server.ReadOnlyMode {
cfg.Logger.Warning("Mutation blocked", m)
if flag.Lookup("test.v") == nil {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
c.Status(403).SendString("The server is in read-only mode")
should_block = true
res.shouldBlock = true
return
}
for _, dir := range oper.Directives {
if dir.Name.Value == "cached" {
cacheRequest = true
res.cacheRequest = true
for _, arg := range dir.Arguments {
if arg.Name.Value == "ttl" {
cache_time, err = strconv.Atoi(arg.Value.GetValue().(string))
res.cacheTime, err = strconv.Atoi(arg.Value.GetValue().(string))
if err != nil {
cfg.Logger.Error("Can't parse the ttl, using global", map[string]interface{}{"bad_ttl": arg.Value.GetValue().(string)})
if flag.Lookup("test.v") == nil {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
return
}
}
if arg.Name.Value == "refresh" {
res.cacheRefresh = arg.Value.GetValue().(bool)
}
}
}
}
if cfg.Security.BlockIntrospection {
should_block = checkSelections(c, oper.GetSelectionSet().Selections)
if should_block {
res.shouldBlock = checkSelections(c, oper.GetSelectionSet().Selections)
if res.shouldBlock {
return
}
}
@@ -167,23 +175,26 @@ func checkSelections(c *fiber.Ctx, selections []ast.Selection) bool {
return false
}
func checkIfContainsIntrospection(c *fiber.Ctx, whatever string) (should_block bool) {
func checkIfContainsIntrospection(c *fiber.Ctx, whatever string) (shouldBlock bool) {
whateverLower := strings.ToLower(whatever)
got_exemption := false
// If the query is an introspection query, we need to check if it's allowed.
if _, exists := introspectionQuerySet[whateverLower]; exists {
if len(cfg.Security.IntrospectionAllowed) > 0 {
if _, allowed_exists := introspectionAllowedQueries[whateverLower]; allowed_exists {
cfg.Logger.Debug("Introspection query allowed, passing through", map[string]interface{}{"query": whatever})
got_exemption = true
should_block = false
shouldBlock = false
}
}
if !got_exemption {
should_block = true
shouldBlock = true
}
}
if should_block {
if flag.Lookup("test.v") == nil {
if shouldBlock {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
c.Status(403).SendString("Introspection queries are not allowed")
+118 -113
View File
@@ -1,28 +1,24 @@
package main
import (
"testing"
fiber "github.com/gofiber/fiber/v2"
libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
"github.com/valyala/fasthttp"
)
func (suite *Tests) Test_parseGraphQLQuery() {
type results struct {
is_cached bool
cached_ttl int
should_block bool
should_ignore bool
op_name string
op_type string
returnCode int
op_name string
op_type string
cached_ttl int
returnCode int
is_cached bool
shouldBlock bool
shouldIgnore bool
}
type queries struct {
body string
headers map[string]string
body string
}
tests := []struct {
@@ -38,11 +34,11 @@ func (suite *Tests) Test_parseGraphQLQuery() {
headers: map[string]string{},
},
wantResults: results{
is_cached: false,
should_block: false,
should_ignore: true,
op_name: "",
op_type: "",
is_cached: false,
shouldBlock: false,
shouldIgnore: true,
op_name: "",
op_type: "",
},
},
@@ -53,11 +49,11 @@ func (suite *Tests) Test_parseGraphQLQuery() {
headers: map[string]string{},
},
wantResults: results{
is_cached: false,
should_block: false,
should_ignore: true,
op_name: "",
op_type: "",
is_cached: false,
shouldBlock: false,
shouldIgnore: true,
op_name: "",
op_type: "",
},
},
@@ -68,11 +64,11 @@ func (suite *Tests) Test_parseGraphQLQuery() {
headers: map[string]string{},
},
wantResults: results{
is_cached: false,
should_block: false,
should_ignore: true,
op_name: "",
op_type: "",
is_cached: false,
shouldBlock: false,
shouldIgnore: true,
op_name: "",
op_type: "",
},
},
@@ -82,11 +78,11 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"query MyQuery { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __typename } }\"}",
},
wantResults: results{
is_cached: false,
should_block: false,
should_ignore: false,
op_name: "MyQuery",
op_type: "query",
is_cached: false,
shouldBlock: false,
shouldIgnore: false,
op_name: "MyQuery",
op_type: "query",
},
},
@@ -96,11 +92,11 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"query MyQuery @cached { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __typename } }\", \"variables\": {\"id\": \"1\"}}",
},
wantResults: results{
is_cached: true,
should_block: false,
should_ignore: false,
op_name: "MyQuery",
op_type: "query",
is_cached: true,
shouldBlock: false,
shouldIgnore: false,
op_name: "MyQuery",
op_type: "query",
},
},
@@ -110,12 +106,27 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"query MyQuery @cached(ttl: 60) { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __typename } }\", \"variables\": {\"id\": \"1\"}}",
},
wantResults: results{
is_cached: true,
cached_ttl: 60,
should_block: false,
should_ignore: false,
op_name: "MyQuery",
op_type: "query",
is_cached: true,
cached_ttl: 60,
shouldBlock: false,
shouldIgnore: false,
op_name: "MyQuery",
op_type: "query",
},
},
{
name: "test valid query with op name, force refreshed cache",
suppliedQuery: queries{
body: "{\"query\":\"query MyQuery @cached(refresh: true) { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __typename } }\", \"variables\": {\"id\": \"1\"}}",
},
wantResults: results{
is_cached: true,
cached_ttl: 0,
shouldBlock: false,
shouldIgnore: false,
op_name: "MyQuery",
op_type: "query",
},
},
@@ -125,12 +136,12 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"query MyQuery @cached(ttl: nope) { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __typename } }\", \"variables\": {\"id\": \"1\"}}",
},
wantResults: results{
is_cached: true,
cached_ttl: 0,
should_block: false,
should_ignore: false,
op_name: "MyQuery",
op_type: "query",
is_cached: true,
cached_ttl: 0,
shouldBlock: false,
shouldIgnore: false,
op_name: "MyQuery",
op_type: "query",
},
},
@@ -140,17 +151,18 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"mutation MyMutation { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __typename } }\"}",
},
wantResults: results{
is_cached: false,
should_block: false,
should_ignore: false,
op_name: "MyMutation",
op_type: "mutation",
is_cached: false,
shouldBlock: false,
shouldIgnore: false,
op_name: "MyMutation",
op_type: "mutation",
},
},
{
name: "test mutation query with config: read only",
suppliedSettings: func() *config {
parseConfig()
cfg.Server.ReadOnlyMode = true
return cfg
}(),
@@ -158,12 +170,12 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"mutation MyMutation { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __typename } }\"}",
},
wantResults: results{
is_cached: false,
should_block: true,
should_ignore: false,
op_name: "MyMutation",
op_type: "mutation",
returnCode: 403,
is_cached: false,
shouldBlock: true,
shouldIgnore: false,
op_name: "MyMutation",
op_type: "mutation",
returnCode: 403,
},
},
@@ -173,17 +185,18 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"mutation MyMutation { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __schema } }\"}",
},
wantResults: results{
is_cached: false,
should_block: false,
should_ignore: false,
op_name: "MyMutation",
op_type: "mutation",
is_cached: false,
shouldBlock: false,
shouldIgnore: false,
op_name: "MyMutation",
op_type: "mutation",
},
},
{
name: "test simple query with introspection __schema config: block introspection",
suppliedSettings: func() *config {
parseConfig()
cfg.Security.BlockIntrospection = true
return cfg
}(),
@@ -191,12 +204,12 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"query MyIntroQuery { tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __schema } }\"}",
},
wantResults: results{
is_cached: false,
should_block: true,
should_ignore: false,
op_name: "MyIntroQuery",
op_type: "query",
returnCode: 403,
is_cached: false,
shouldBlock: true,
shouldIgnore: false,
op_name: "MyIntroQuery",
op_type: "query",
returnCode: 403,
},
},
@@ -206,19 +219,18 @@ func (suite *Tests) Test_parseGraphQLQuery() {
parseConfig()
cfg.Security.BlockIntrospection = true
cfg.Security.IntrospectionAllowed = []string{}
prepareQueriesAndExemptions()
return cfg
}(),
suppliedQuery: queries{
body: "{\"query\":\"{__schema {queryType {fields {name description}}}}\"}",
},
wantResults: results{
is_cached: false,
should_block: true,
should_ignore: false,
op_name: "undefined",
op_type: "query",
returnCode: 403,
is_cached: false,
shouldBlock: true,
shouldIgnore: false,
op_name: "undefined",
op_type: "query",
returnCode: 403,
},
},
@@ -228,19 +240,18 @@ func (suite *Tests) Test_parseGraphQLQuery() {
parseConfig()
cfg.Security.BlockIntrospection = true
cfg.Security.IntrospectionAllowed = []string{"__schema"}
prepareQueriesAndExemptions()
return cfg
}(),
suppliedQuery: queries{
body: "{\"query\":\"{__schema {queryType {fields {name description}}}}\"}",
},
wantResults: results{
is_cached: false,
should_block: false,
should_ignore: false,
op_name: "undefined",
op_type: "query",
returnCode: 200,
is_cached: false,
shouldBlock: false,
shouldIgnore: false,
op_name: "undefined",
op_type: "query",
returnCode: 200,
},
},
@@ -250,25 +261,19 @@ func (suite *Tests) Test_parseGraphQLQuery() {
body: "{\"query\":\"query MyQuery tg_users(where: {handle: {_eq: \\\"tozuo\\\"}}) { id __typename } \"}",
},
wantResults: results{
is_cached: false,
should_block: false,
should_ignore: true,
op_name: "",
op_type: "",
is_cached: false,
shouldBlock: false,
shouldIgnore: true,
op_name: "",
op_type: "",
},
},
}
for _, tt := range tests {
suite.T().Run(tt.name, func(t *testing.T) {
suite.Run(tt.name, func() {
cfg = &config{}
cfg.Logger = libpack_logging.NewLogger()
defer func() {
cfg = &config{}
}()
app := fiber.New()
parseConfig()
ctx_headers := func() *fasthttp.RequestHeader {
h := fasthttp.RequestHeader{}
for k, v := range tt.suppliedQuery.headers {
@@ -283,29 +288,29 @@ func (suite *Tests) Test_parseGraphQLQuery() {
ctx_request.AppendBody([]byte(tt.suppliedQuery.body))
ctx := app.AcquireCtx(&fasthttp.RequestCtx{
ctx := suite.app.AcquireCtx(&fasthttp.RequestCtx{
Request: ctx_request,
})
defer app.ReleaseCtx(ctx)
// defer func() {
// cfg = &config{}
// parseConfig()
// suite.app.ReleaseCtx(ctx)
// }()
assert.NotNil(ctx, "Fiber context is nil")
if tt.suppliedSettings != nil {
cfg = tt.suppliedSettings
}
defer func() {
cfg = &config{}
}()
opType, opName, cacheFromQuery, cached_ttl, shouldBlock, should_ignore := parseGraphQLQuery(ctx)
assert.Equal(tt.wantResults.op_type, opType, "Unexpected operation type", tt.name)
assert.Equal(tt.wantResults.op_name, opName, "Unexpected operation name", tt.name)
assert.Equal(tt.wantResults.is_cached, cacheFromQuery, "Unexpected cache value", tt.name)
assert.Equal(tt.wantResults.cached_ttl, cached_ttl, "Unexpected cache TTL value", tt.name)
assert.Equal(tt.wantResults.should_block, shouldBlock, "Unexpected block value", tt.name)
assert.Equal(tt.wantResults.should_ignore, should_ignore, "Unexpected ignore value", tt.name)
prepareQueriesAndExemptions()
parseResult := parseGraphQLQuery(ctx)
assert.Equal(tt.wantResults.op_type, parseResult.operationType, "Unexpected operation type "+tt.name)
assert.Equal(tt.wantResults.op_name, parseResult.operationName, "Unexpected operation name "+tt.name)
assert.Equal(tt.wantResults.is_cached, parseResult.cacheRequest, "Unexpected cache value "+tt.name)
assert.Equal(tt.wantResults.cached_ttl, parseResult.cacheTime, "Unexpected cache TTL value "+tt.name)
assert.Equal(tt.wantResults.shouldBlock, parseResult.shouldBlock, "Unexpected block value "+tt.name)
assert.Equal(tt.wantResults.shouldIgnore, parseResult.shouldIgnore, "Unexpected ignore value "+tt.name)
if tt.wantResults.returnCode > 0 {
assert.Equal(tt.wantResults.returnCode, ctx.Response().StatusCode(), "Unexpected return code", tt.name)
+6
View File
@@ -59,10 +59,16 @@ func (lw *LogConfig) log(w io.Writer, level zerolog.Level, message string, v map
}
func (lw *LogConfig) Debug(message string, v ...map[string]interface{}) {
if !lw.logger.Debug().Enabled() {
return
}
lw.log(os.Stdout, zerolog.DebugLevel, message, mergeMaps(v))
}
func (lw *LogConfig) Info(message string, v ...map[string]interface{}) {
if !lw.logger.Info().Enabled() {
return
}
lw.log(os.Stdout, zerolog.InfoLevel, message, mergeMaps(v))
}
+2 -2
View File
@@ -183,7 +183,7 @@ func (suite *LoggingTestSuite) TestLogConfig_AllHandlers() {
}
for _, tt := range tests {
suite.T().Run(tt.name, func(t *testing.T) {
suite.Run(tt.name, func() {
if tt.envMinLogLevel != "" {
os.Setenv("LOG_LEVEL", tt.envMinLogLevel)
defer os.Unsetenv("LOG_LEVEL")
@@ -274,7 +274,7 @@ func (suite *LoggingTestSuite) TestFullMessage() {
}
for _, tt := range tests {
suite.T().Run(tt.name, func(t *testing.T) {
suite.Run(tt.name, func() {
if tt.envMinLogLevel != "" {
os.Setenv("LOG_LEVEL", tt.envMinLogLevel)
defer os.Unsetenv("LOG_LEVEL")
+50 -21
View File
@@ -1,8 +1,11 @@
package main
import (
"flag"
"os"
"strings"
"github.com/gofiber/fiber/v2/middleware/proxy"
"github.com/gookit/goutil/envutil"
graphql "github.com/lukaszraczylo/go-simple-graphql"
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
@@ -11,47 +14,69 @@ import (
var cfg *config
// function get value from the env where the value can be anything
func getDetailsFromEnv[T any](key string, defaultValue T) T {
var result any
if _, ok := os.LookupEnv("GMP_" + key); ok {
key = "GMP_" + key
}
switch v := any(defaultValue).(type) {
case string:
result = envutil.Getenv(key, v)
case int:
result = envutil.GetInt(key, v)
case bool:
result = envutil.GetBool(key, v)
default:
result = defaultValue
}
return result.(T)
}
func parseConfig() {
libpack_config.PKG_NAME = "graphql_proxy"
c := config{}
c.Server.PortGraphQL = envutil.GetInt("PORT_GRAPHQL", 8080)
c.Server.PortMonitoring = envutil.GetInt("MONITORING_PORT", 9393)
c.Server.HostGraphQL = envutil.Getenv("HOST_GRAPHQL", "http://localhost/")
c.Client.JWTUserClaimPath = envutil.Getenv("JWT_USER_CLAIM_PATH", "")
c.Client.JWTRoleClaimPath = envutil.Getenv("JWT_ROLE_CLAIM_PATH", "")
c.Client.RoleFromHeader = envutil.Getenv("ROLE_FROM_HEADER", "")
c.Client.RoleRateLimit = envutil.GetBool("ROLE_RATE_LIMIT", false)
c.Cache.CacheEnable = envutil.GetBool("ENABLE_GLOBAL_CACHE", false)
c.Cache.CacheTTL = envutil.GetInt("CACHE_TTL", 60)
c.Security.BlockIntrospection = envutil.GetBool("BLOCK_SCHEMA_INTROSPECTION", false)
c.Server.PortGraphQL = getDetailsFromEnv("PORT_GRAPHQL", 8080)
c.Server.PortMonitoring = getDetailsFromEnv("MONITORING_PORT", 9393)
c.Server.HostGraphQL = getDetailsFromEnv("HOST_GRAPHQL", "http://localhost/")
c.Server.HostGraphQLReadOnly = getDetailsFromEnv("HOST_GRAPHQL_READONLY", "")
c.Client.JWTUserClaimPath = getDetailsFromEnv("JWT_USER_CLAIM_PATH", "")
c.Client.JWTRoleClaimPath = getDetailsFromEnv("JWT_ROLE_CLAIM_PATH", "")
c.Client.RoleFromHeader = getDetailsFromEnv("ROLE_FROM_HEADER", "")
c.Client.RoleRateLimit = getDetailsFromEnv("ROLE_RATE_LIMIT", false)
c.Cache.CacheEnable = getDetailsFromEnv("ENABLE_GLOBAL_CACHE", false)
c.Cache.CacheTTL = getDetailsFromEnv("CACHE_TTL", 60)
c.Security.BlockIntrospection = getDetailsFromEnv("BLOCK_SCHEMA_INTROSPECTION", false)
c.Security.IntrospectionAllowed = func() []string {
urls := envutil.Getenv("ALLOWED_INTROSPECTION", "")
urls := getDetailsFromEnv("ALLOWED_INTROSPECTION", "")
if urls == "" {
return nil
}
return strings.Split(urls, ",")
}()
c.Logger = libpack_logging.NewLogger()
c.Server.HealthcheckGraphQL = envutil.Getenv("HEALTHCHECK_GRAPHQL_URL", "")
c.Server.HealthcheckGraphQL = getDetailsFromEnv("HEALTHCHECK_GRAPHQL_URL", "")
c.Client.GQLClient = graphql.NewConnection()
c.Client.GQLClient.SetEndpoint(c.Server.HealthcheckGraphQL)
c.Server.AccessLog = envutil.GetBool("ENABLE_ACCESS_LOG", false)
c.Server.ReadOnlyMode = envutil.GetBool("READ_ONLY_MODE", false)
c.Server.AccessLog = getDetailsFromEnv("ENABLE_ACCESS_LOG", false)
c.Server.ReadOnlyMode = getDetailsFromEnv("READ_ONLY_MODE", false)
c.Server.AllowURLs = func() []string {
urls := envutil.Getenv("ALLOWED_URLS", "")
urls := getDetailsFromEnv("ALLOWED_URLS", "")
if urls == "" {
return nil
}
return strings.Split(urls, ",")
}()
c.Client.ClientTimeout = envutil.GetInt("PROXIED_CLIENT_TIMEOUT", 120)
c.Client.ClientTimeout = getDetailsFromEnv("PROXIED_CLIENT_TIMEOUT", 120)
c.Client.FastProxyClient = createFasthttpClient(c.Client.ClientTimeout)
c.Server.EnableApi = envutil.GetBool("ENABLE_API", false)
c.Server.ApiPort = envutil.GetInt("API_PORT", 9090)
c.Api.BannedUsersFile = envutil.Getenv("BANNED_USERS_FILE", "/go/src/app/banned_users.json")
c.Server.PurgeOnCrawl = envutil.GetBool("PURGE_METRICS_ON_CRAWL", false)
c.Server.PurgeEvery = envutil.GetInt("PURGE_METRICS_ON_TIMER", 0)
proxy.WithClient(c.Client.FastProxyClient) // setting the global proxy client here instead of per request
c.Server.EnableApi = getDetailsFromEnv("ENABLE_API", false)
c.Server.ApiPort = getDetailsFromEnv("API_PORT", 9090)
c.Api.BannedUsersFile = getDetailsFromEnv("BANNED_USERS_FILE", "/go/src/app/banned_users.json")
c.Server.PurgeOnCrawl = getDetailsFromEnv("PURGE_METRICS_ON_CRAWL", false)
c.Server.PurgeEvery = getDetailsFromEnv("PURGE_METRICS_ON_TIMER", 0)
cfg = &c
enableCache() // takes close to no resources, but can be used with dynamic query cache
loadRatelimitConfig()
enableApi()
@@ -63,3 +88,7 @@ func main() {
StartMonitoringServer()
StartHTTPProxy()
}
func ifNotInTest() bool {
return flag.Lookup("test.v") == nil
}
+79 -3
View File
@@ -1,25 +1,54 @@
package main
import (
"os"
"testing"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
assertions "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type Tests struct {
suite.Suite
app *fiber.App
}
var (
assert *assertions.Assertions
)
func (suite *Tests) SetupTest() {
assert = assertions.New(suite.T())
func (suite *Tests) BeforeTest(suiteName, testName string) {
}
func (suite *Tests) BeforeTest(suiteName, testName string) {
func (suite *Tests) SetupTest() {
assert = assertions.New(suite.T())
suite.app = fiber.New(
fiber.Config{
DisableStartupMessage: true,
JSONEncoder: json.Marshal,
JSONDecoder: json.Unmarshal,
},
)
parseConfig()
StartMonitoringServer()
cfg.Logger = libpack_logging.NewLogger()
// Setup environment variables here if needed
os.Setenv("GMP_TEST_STRING", "testValue")
os.Setenv("GMP_TEST_INT", "123")
os.Setenv("GMP_TEST_BOOL", "true")
os.Setenv("NON_GMP_TEST_INT", "31337")
}
// TearDownTest is run after each test to clean up
func (suite *Tests) TearDownTest() {
// Clean up environment variables here if needed
os.Unsetenv("GMP_TEST_STRING")
os.Unsetenv("GMP_TEST_INT")
os.Unsetenv("GMP_TEST_BOOL")
os.Unsetenv("NON_GMP_TEST_INT")
}
// func (suite *Tests) AfterTest(suiteName, testName string) {)
@@ -30,3 +59,50 @@ func TestSuite(t *testing.T) {
StartMonitoringServer()
suite.Run(t, new(Tests))
}
func (suite *Tests) Test_envVariableSetting() {
tests := []struct {
defaultValue any
expected any
name string
envKey string
}{
{
name: "test_string",
envKey: "TEST_STRING",
defaultValue: "default",
expected: "testValue",
},
{
name: "test_int",
envKey: "TEST_INT",
defaultValue: 0,
expected: 123,
},
{
name: "test_bool",
envKey: "TEST_BOOL",
defaultValue: false,
expected: true,
},
{
name: "test_non_prefixed",
envKey: "NON_GMP_TEST_INT",
defaultValue: 0,
expected: 31337,
},
{
name: "test_non_existing",
envKey: "NON_EXISTING",
defaultValue: "default_val",
expected: "default_val",
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
result := getDetailsFromEnv(tt.envKey, tt.defaultValue)
assert.Equal(tt.expected, result)
})
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
)
func StartMonitoringServer() {
cfg.Monitoring = libpack_monitoring.NewMonitoring(cfg.Server.PurgeOnCrawl, cfg.Server.PurgeEvery)
cfg.Monitoring = libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{PurgeOnCrawl: cfg.Server.PurgeOnCrawl, PurgeEvery: cfg.Server.PurgeEvery})
cfg.Monitoring.AddMetricsPrefix("graphql_proxy")
cfg.Monitoring.RegisterDefaultMetrics()
}
+24 -17
View File
@@ -4,6 +4,7 @@
package libpack_monitoring
import (
"flag"
"fmt"
"time"
@@ -15,33 +16,39 @@ import (
)
type MetricsSetup struct {
metrics_prefix string
metrics_set *metrics.Set
metrics_set_custom *metrics.Set
ic *InitConfig
metrics_prefix string
}
var (
log *logging.LogConfig
purgeMetricsOnCrawl bool
purgeMetricsEvery int
log *logging.LogConfig
)
func NewMonitoring(purgeOnCrawl bool, purgeEvery int) *MetricsSetup {
purgeMetricsOnCrawl = purgeOnCrawl
purgeMetricsEvery = purgeEvery
type InitConfig struct {
PurgeOnCrawl bool
PurgeEvery int
}
func NewMonitoring(ic *InitConfig) *MetricsSetup {
log = logging.NewLogger()
ms := &MetricsSetup{}
ms := &MetricsSetup{ic: ic}
ms.metrics_set = metrics.NewSet()
ms.metrics_set_custom = metrics.NewSet()
go ms.startPrometheusEndpoint()
// if not testing, start the prometheus endpoint
if purgeEvery > 0 {
ticker := time.NewTicker(time.Duration(purgeEvery) * time.Second)
go func() {
for range ticker.C {
ms.PurgeMetrics()
}
}()
if flag.Lookup("test.v") == nil {
go ms.startPrometheusEndpoint()
if ic.PurgeEvery > 0 {
ticker := time.NewTicker(time.Duration(ic.PurgeEvery) * time.Second)
go func() {
for range ticker.C {
ms.PurgeMetrics()
}
}()
}
}
return ms
@@ -63,7 +70,7 @@ func (ms *MetricsSetup) metricsEndpoint(c *fiber.Ctx) error {
ms.metrics_set.WritePrometheus(c.Response().BodyWriter())
ms.metrics_set_custom.WritePrometheus(c.Response().BodyWriter())
if purgeMetricsOnCrawl && purgeMetricsEvery == 0 {
if ms.ic.PurgeOnCrawl && ms.ic.PurgeEvery == 0 {
ms.PurgeMetrics()
}
return nil
+6 -4
View File
@@ -1,8 +1,10 @@
package libpack_monitoring
const (
MetricsSucceeded = "requests_succesful"
MetricsFailed = "requests_failed"
MetricsDuration = "requests_duration"
MetricsSkipped = "requests_skipped"
MetricsSucceeded = "requests_succesful"
MetricsFailed = "requests_failed"
MetricsDuration = "requests_duration"
MetricsSkipped = "requests_skipped"
MetricsExecutedQuery = "executed_query"
MetricsTimedQuery = "timed_query"
)
+36 -9
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/avast/retry-go/v4"
fiber "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/proxy"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
@@ -18,37 +19,63 @@ func createFasthttpClient(timeout int) *fasthttp.Client {
TLSConfig: &tls.Config{
InsecureSkipVerify: true,
},
MaxConnsPerHost: 200,
MaxConnsPerHost: 2048,
ReadTimeout: time.Second * time.Duration(timeout),
WriteTimeout: time.Second * time.Duration(timeout),
MaxIdleConnDuration: time.Second * time.Duration(timeout),
MaxConnDuration: time.Second * time.Duration(timeout),
DisableHeaderNamesNormalizing: true,
}
}
func proxyTheRequest(c *fiber.Ctx) error {
func proxyTheRequest(c *fiber.Ctx, currentEndpoint string) error {
if !checkAllowedURLs(c) {
cfg.Logger.Error("Request blocked", map[string]interface{}{"path": c.Path()})
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
c.Status(403).SendString("Request blocked - not allowed URL")
return nil
}
c.Request().Header.DisableNormalizing()
c.Request().Header.Add("X-Real-IP", c.IP())
c.Request().Header.Add(fiber.HeaderXForwardedFor, string(c.Request().Header.Peek("X-Forwarded-For")))
proxy.WithClient(cfg.Client.FastProxyClient)
c.Request().Header.Del(fiber.HeaderAcceptEncoding)
cfg.Logger.Debug("Proxying the request", map[string]interface{}{"path": c.Path(), "body": string(c.Request().Body()), "headers": c.GetReqHeaders(), "request_uuid": c.Locals("request_uuid")})
err := proxy.DoRedirects(c, cfg.Server.HostGraphQL+c.Path(), 3)
err := retry.Do(
func() error {
errInt := proxy.DoRedirects(c, currentEndpoint+c.Path(), 3, cfg.Client.FastProxyClient)
if errInt != nil {
cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": errInt.Error()})
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
return errInt
}
return nil
},
retry.OnRetry(func(n uint, err error) {
cfg.Logger.Warning("Retrying the request", map[string]interface{}{"path": c.Path(), "error": err.Error()})
}),
retry.Attempts(uint(3)),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Duration(250*time.Millisecond)),
retry.LastErrorOnly(true),
)
if err != nil {
cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": err.Error()})
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
cfg.Logger.Warning("Can't proxy the request", map[string]interface{}{"error": err.Error()})
return err
}
cfg.Logger.Debug("Received proxied response", map[string]interface{}{"path": c.Path(), "response_body": string(c.Response().Body()), "response_code": c.Response().StatusCode(), "headers": c.GetRespHeaders(), "request_uuid": c.Locals("request_uuid")})
if c.Response().StatusCode() != 200 {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
return fmt.Errorf("Received non-200 response from the GraphQL server: %d", c.Response().StatusCode())
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"github.com/valyala/fasthttp"
)
func (suite *Tests) Test_proxyTheRequest() {
supplied_headers := map[string]string{
"X-Forwarded-For": "127.0.0.1",
"Content-Type": "application/json",
}
tests := []struct {
headers map[string]string
name string
body string
host string
hostRO string
path string
wantErr bool
}{
{
name: "test_empty",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://telegram-bot.app/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: false,
},
{
name: "test_wrong_url",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://google.com/",
path: "/v1/wrongURL",
headers: supplied_headers,
wantErr: true,
},
{
name: "Test read only mode",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://google.com/",
hostRO: "https://telegram-bot.app/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: false,
},
{
name: "Test read only mode wrong host",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://telegram-bot.app/",
hostRO: "https://google.com/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: true,
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
cfg = &config{}
parseConfig()
cfg.Server.HostGraphQL = tt.host
if tt.hostRO != "" {
cfg.Server.HostGraphQLReadOnly = tt.hostRO
}
ctx_headers := func() *fasthttp.RequestHeader {
h := fasthttp.RequestHeader{}
for k, v := range tt.headers {
h.Add(k, v)
}
return &h
}()
ctx_request := fasthttp.Request{
Header: *ctx_headers,
}
ctx_request.SetBody([]byte(tt.body))
ctx_request.SetRequestURI(tt.path)
ctx_request.Header.SetMethod("POST")
ctx := suite.app.AcquireCtx(&fasthttp.RequestCtx{
Request: ctx_request,
})
res := parseGraphQLQuery(ctx)
assert.NotNil(ctx, "Fiber context is nil", tt.name)
err := proxyTheRequest(ctx, res.activeEndpoint)
if tt.wantErr {
assert.NotNil(err, "Error is nil", tt.name)
} else {
assert.Nil(err, "Error is not nil", tt.name)
}
})
}
}
+4 -2
View File
@@ -4,13 +4,15 @@ import (
"os"
"time"
"github.com/goccy/go-json"
goratecounter "github.com/lukaszraczylo/go-ratecounter"
)
type RateLimitConfig struct {
Req int `json:"req"`
Interval string `json:"interval"`
RateCounterTicker *goratecounter.RateCounter
Interval string `json:"interval"`
Req int `json:"req"`
}
var rateLimits map[string]RateLimitConfig
+1 -2
View File
@@ -9,8 +9,7 @@ wording:
- initial
- fix
minor:
- change
- improve
- release
major:
- breaking
- breaking
+39 -24
View File
@@ -5,22 +5,25 @@ import (
"strconv"
"time"
"github.com/goccy/go-json"
fiber "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/google/uuid"
jsoniter "github.com/json-iterator/go"
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
)
var json = jsoniter.ConfigCompatibleWithStandardLibrary
// StartHTTPProxy starts the HTTP and points it to the GraphQL server.
func StartHTTPProxy() {
server := fiber.New(fiber.Config{
DisableStartupMessage: true,
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
IdleTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second * 2,
ReadTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second * 2,
WriteTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second * 2,
JSONEncoder: json.Marshal,
JSONDecoder: json.Unmarshal,
})
server.Use(cors.New(cors.Config{
@@ -34,7 +37,7 @@ func StartHTTPProxy() {
server.Get("/livez", healthCheck)
server.Post("/*", processGraphQLRequest)
server.Get("/*", proxyTheRequest)
server.Get("/*", proxyTheRequestToDefault)
cfg.Logger.Info("GraphQL query proxy started", map[string]interface{}{"port": cfg.Server.PortGraphQL})
err := server.Listen(fmt.Sprintf(":%d", cfg.Server.PortGraphQL))
@@ -43,6 +46,10 @@ func StartHTTPProxy() {
}
}
func proxyTheRequestToDefault(c *fiber.Ctx) error {
return proxyTheRequest(c, cfg.Server.HostGraphQL)
}
func AddRequestUUID(c *fiber.Ctx) error {
c.Locals("request_uuid", uuid.NewString())
return c.Next()
@@ -87,6 +94,7 @@ func processGraphQLRequest(c *fiber.Ctx) error {
}
if checkIfUserIsBanned(c, extractedUserID) {
c.Status(403).SendString("User is banned")
return nil
}
@@ -106,66 +114,73 @@ func processGraphQLRequest(c *fiber.Ctx) error {
}
}
opType, opName, cacheFromQuery, cache_time, shouldBlock, should_ignore := parseGraphQLQuery(c)
if shouldBlock {
parsedResult := parseGraphQLQuery(c)
if parsedResult.shouldBlock {
c.Status(403).SendString("Request blocked")
return nil
}
if should_ignore {
if parsedResult.shouldIgnore {
cfg.Logger.Debug("Request passed as-is - probably not a GraphQL")
return proxyTheRequest(c)
return proxyTheRequest(c, parsedResult.activeEndpoint)
}
if cache_time > 0 {
cfg.Logger.Debug("Cache time set via query", map[string]interface{}{"cache_time": cache_time})
if parsedResult.cacheTime > 0 {
cfg.Logger.Debug("Cache time set via query", map[string]interface{}{"cacheTime": parsedResult.cacheTime})
} else {
// If not set via query, try setting via header
cacheQuery := c.Request().Header.Peek("X-Cache-Graphql-Query")
if cacheQuery != nil {
cache_time, _ = strconv.Atoi(string(cacheQuery))
cfg.Logger.Debug("Cache time set via header", map[string]interface{}{"cache_time": cache_time})
parsedResult.cacheTime, _ = strconv.Atoi(string(cacheQuery))
cfg.Logger.Debug("Cache time set via header", map[string]interface{}{"cacheTime": parsedResult.cacheTime})
} else {
cache_time = cfg.Cache.CacheTTL
parsedResult.cacheTime = cfg.Cache.CacheTTL
}
}
wasCached := false
if parsedResult.cacheRefresh {
cfg.Logger.Debug("Cache refresh requested via query", map[string]interface{}{"user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")})
cacheDelete(calculateHash(c))
}
// Handling Cache Logic
if cacheFromQuery || cfg.Cache.CacheEnable {
cfg.Logger.Debug("Cache enabled", map[string]interface{}{"via_query": cacheFromQuery, "via_env": cfg.Cache.CacheEnable})
if parsedResult.cacheRequest || cfg.Cache.CacheEnable {
cfg.Logger.Debug("Cache enabled", map[string]interface{}{"via_query": parsedResult.cacheRequest, "via_env": cfg.Cache.CacheEnable})
queryCacheHash = calculateHash(c)
if cachedResponse := cacheLookup(queryCacheHash); cachedResponse != nil {
cfg.Logger.Debug("Cache hit", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")})
c.Request().Header.Add("X-Cache-Hit", "true")
c.Send(cachedResponse)
wasCached = true
} else {
cfg.Logger.Debug("Cache miss", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")})
proxyAndCacheTheRequest(c, queryCacheHash, cache_time)
proxyAndCacheTheRequest(c, queryCacheHash, parsedResult.cacheTime, parsedResult.activeEndpoint)
}
} else {
proxyTheRequest(c)
proxyTheRequest(c, parsedResult.activeEndpoint)
}
timeTaken := time.Since(startTime)
// Logging & Monitoring
go logAndMonitorRequest(c, extractedUserID, opType, opName, wasCached, timeTaken, startTime)
logAndMonitorRequest(c, extractedUserID, parsedResult.operationType, parsedResult.operationName, wasCached, timeTaken, startTime)
return nil
}
// Additional helper function to avoid code repetition
func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cache_time int) {
err := proxyTheRequest(c)
func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime int, currentEndpoint string) {
err := proxyTheRequest(c, currentEndpoint)
if err != nil {
cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": err.Error()})
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
c.Status(500).SendString("Can't proxy the request - try again later")
return
}
cfg.Cache.CacheClient.Set(queryCacheHash, c.Response().Body(), time.Duration(cache_time)*time.Second)
cfg.Cache.CacheClient.Set(queryCacheHash, c.Response().Body(), time.Duration(cacheTime)*time.Second)
c.Send(c.Response().Body())
}
@@ -191,10 +206,10 @@ func logAndMonitorRequest(c *fiber.Ctx, userID, opType, opName string, wasCached
}
cfg.Monitoring.Increment(libpack_monitoring.MetricsSucceeded, nil)
cfg.Monitoring.Increment("executed_query", labels)
cfg.Monitoring.Increment(libpack_monitoring.MetricsExecutedQuery, labels)
if !wasCached {
cfg.Monitoring.UpdateDuration("timed_query", labels, startTime)
cfg.Monitoring.Update("timed_query", labels, float64(duration.Milliseconds()))
cfg.Monitoring.UpdateDuration(libpack_monitoring.MetricsTimedQuery, labels, startTime)
cfg.Monitoring.Update(libpack_monitoring.MetricsTimedQuery, labels, float64(duration.Milliseconds()))
}
}
@@ -0,0 +1,162 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: hasura-w-proxy-internal
labels:
app: hasura-w-proxy-internal
type: support
spec:
replicas: 2
selector:
matchLabels:
app: hasura-w-proxy-internal
type: support
template:
metadata:
labels:
app: hasura-w-proxy-internal
type: support
spec:
securityContext:
runAsUser: 65534 # nobody
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
containers:
- name: hasura
image: hasura/graphql-engine:v2.33.1-ce
ports:
- name: hasura-internal
containerPort: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 30
resources:
limits:
cpu: "1"
memory: "640Mi"
requests:
cpu: "0.75"
memory: "512Mi"
env:
- name: HASURA_GRAPHQL_DATABASE_URL
value: postgres://postgres:xxx@yyy:5432/postgres
- name: HASURA_GRAPHQL_ENABLE_CONSOLE
value: "true"
- name: HASURA_GRAPHQL_DEV_MODE
value: "true"
- name: HASURA_GRAPHQL_ENABLE_TELEMETRY
value: "false"
- name: HASURA_GRAPHQL_EXPERIMENTAL_FEATURES
value: "inherited_roles"
- name: HASURA_GRAPHQL_PG_CONNECTIONS
value: "20"
- name: HASURA_GRAPHQL_LOG_LEVEL
value: "error"
- name: hasura-ro
image: hasura/graphql-engine:v2.33.1-ce
ports:
- name: hasura-internal-ro
containerPort: 8088
livenessProbe:
httpGet:
path: /healthz
port: 8088
initialDelaySeconds: 30
resources:
limits:
cpu: "1"
memory: "640Mi"
requests:
cpu: "0.75"
memory: "512Mi"
env:
- name: HASURA_GRAPHQL_DATABASE_URL
value: postgres://postgres:xxx@yyy.read-only:5432/postgres
- name: HASURA_GRAPHQL_ENABLE_CONSOLE
value: "true"
- name: HASURA_GRAPHQL_DEV_MODE
value: "true"
- name: HASURA_GRAPHQL_ENABLE_TELEMETRY
value: "false"
- name: HASURA_GRAPHQL_EXPERIMENTAL_FEATURES
value: "inherited_roles"
- name: HASURA_GRAPHQL_PG_CONNECTIONS
value: "20"
- name: HASURA_GRAPHQL_LOG_LEVEL
value: "error"
- name: HASURA_PORT
value: "8088"
- name: graphql-proxy
image: ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest
imagePullPolicy: Always
resources:
limits:
cpu: "1"
memory: "640Mi"
requests:
cpu: "0.75"
memory: "128Mi"
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 5
ports:
- name: web
containerPort: 8181
- name: monitoring
containerPort: 9393
env:
- name: PORT_GRAPHQL
value: "8181"
- name: MONITORING_PORT
value: "9393"
- name: HOST_GRAPHQL
value: http://localhost:8080/
- name: HOST_GRAPHQL_READONLY
value: http://localhost:8088/
- name: ENABLE_GLOBAL_CACHE
value: "true"
- name: CACHE_TTL
value: "10"
---
apiVersion: v1
kind: Service
metadata:
name: hasura-w-proxy-internal
labels:
app: hasura-w-proxy-internal
type: support
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9393"
prometheus.io/path: "/metrics"
spec:
ports:
- name: hasura
port: 8080
targetPort: 8080
- name: hasura-ro
port: 8088
targetPort: 8088
- name: proxy
port: 8181
targetPort: 8181
- name: monitoring
port: 9393
targetPort: 9393
selector:
app: hasura-w-proxy-internal
type: support
type: ClusterIP
+26 -33
View File
@@ -10,47 +10,40 @@ import (
// config is a struct that holds the configuration of the application.
type config struct {
Cache struct {
CacheClient *libpack_cache.Cache
CacheTTL int
CacheEnable bool
}
Logger *libpack_logging.LogConfig
Monitoring *libpack_monitoring.MetricsSetup
// Server holds the configuration of the server _ONLY_.
Server struct {
PortGraphQL int
PortMonitoring int
HostGraphQL string
HealthcheckGraphQL string
AccessLog bool
ReadOnlyMode bool
AllowURLs []string
EnableApi bool
ApiPort int
PurgeOnCrawl bool
PurgeEvery int
}
Client struct {
JWTUserClaimPath string
JWTRoleClaimPath string
RoleRateLimit bool
RoleFromHeader string
Api struct{ BannedUsersFile string }
Client struct {
GQLClient *graphql.BaseClient
FastProxyClient *fasthttp.Client
JWTUserClaimPath string
JWTRoleClaimPath string
RoleFromHeader string
proxy string
ClientTimeout int
RoleRateLimit bool
}
Cache struct {
CacheEnable bool
CacheTTL int
CacheClient *libpack_cache.Cache
}
Api struct {
BannedUsersFile string
}
Security struct {
BlockIntrospection bool
IntrospectionAllowed []string
BlockIntrospection bool
}
Server struct {
HostGraphQL string
HostGraphQLReadOnly string
HealthcheckGraphQL string
AllowURLs []string
PortGraphQL int
PortMonitoring int
ApiPort int
PurgeEvery int
AccessLog bool
ReadOnlyMode bool
EnableApi bool
PurgeOnCrawl bool
}
}