mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-06-11 00:09:37 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
760a168365
|
|||
|
bc305dd8e9
|
|||
|
b4c047819f
|
|||
|
1390e7cdd1
|
|||
|
a71b3950db
|
|||
|
827c26e88d
|
|||
|
30528e4a9a
|
|||
|
94657ddff4
|
|||
|
a29733a52a
|
|||
|
105c624426
|
|||
|
1a790ffb52
|
|||
| 0b642f8be1 | |||
|
9c9fa94140
|
|||
|
93318df9fe
|
|||
|
b497ad1d1c
|
|||
|
3e6fa2036e
|
|||
|
4640eb2596
|
|||
|
3d70018179
|
|||
|
8fc5782d29
|
|||
|
4255f87efd
|
|||
|
1e299c0dc4
|
|||
|
35e6069f5e
|
|||
|
ef8731300c
|
|||
| 92359c1114 | |||
|
2be4f17ea3
|
|||
|
3cb9088b73
|
|||
|
f50f98b3d6
|
|||
|
29f7fec5a3
|
|||
|
57cf36ba02
|
|||
|
2a0302ab75
|
|||
| 29ffb8a817 | |||
|
6ac3937066
|
|||
|
089d05b7c3
|
@@ -11,7 +11,7 @@ help: ## display this help
|
||||
|
||||
.PHONY: run
|
||||
run: build ## run application
|
||||
@LOG_LEVEL=debug BLOCK_SCHEMA_INTROSPECTION=false JWT_ROLE_RATE_LIMIT=false JWT_ROLE_CLAIM_PATH="Hasura.x-hasura-default-role" JWT_USER_CLAIM_PATH="Hasura.x-hasura-user-id" HOST_GRAPHQL=https://hasura8.lan/ ./graphql-proxy
|
||||
@LOG_LEVEL=debug PURGE_METRICS_ON_CRAWL=true BLOCK_SCHEMA_INTROSPECTION=false CACHE_TTL=10 JWT_ROLE_RATE_LIMIT=false JWT_ROLE_CLAIM_PATH="Hasura.x-hasura-default-role" JWT_USER_CLAIM_PATH="Hasura.x-hasura-user-id" HOST_GRAPHQL=https://hasura8.lan/ HEALTHCHECK_GRAPHQL_URL=https://hasura8.lan/v1/graphql ./graphql-proxy
|
||||
|
||||
.PHONY: build
|
||||
build: ## build the binary
|
||||
|
||||
@@ -6,17 +6,83 @@ This project is in active use by [telegram-bot.app](https://telegram-bot.app), a
|
||||
|
||||

|
||||
|
||||
You can find the example of the Kubernetes manifest in the [example deployment](static/kubernetes-deployment.yaml) file.
|
||||
- [graphql monitoring proxy](#graphql-monitoring-proxy)
|
||||
- [Why this project exists](#why-this-project-exists)
|
||||
- [How to deploy](#how-to-deploy)
|
||||
- [Note on websocket support](#note-on-websocket-support)
|
||||
- [Endpoints](#endpoints)
|
||||
- [Features](#features)
|
||||
- [Configuration](#configuration)
|
||||
- [Speed](#speed)
|
||||
- [Caching](#caching)
|
||||
- [Security](#security)
|
||||
- [Role-based rate limiting](#role-based-rate-limiting)
|
||||
- [Read-only mode](#read-only-mode)
|
||||
- [Allowing access to listed URLs](#allowing-access-to-listed-urls)
|
||||
- [Blocking introspection](#blocking-introspection)
|
||||
- [API endpoints](#api-endpoints)
|
||||
- [Ban or unban the user](#ban-or-unban-the-user)
|
||||
- [General](#general)
|
||||
- [Metrics which matter](#metrics-which-matter)
|
||||
- [Healthcheck](#healthcheck)
|
||||
- [Monitoring endpoint](#monitoring-endpoint)
|
||||
|
||||
### Why this project exists
|
||||
|
||||
I wanted to monitor the queries and responses of our graphql endpoint. Still, we didn't want to pay the price of the graphql server itself ( and I will not point fingers at a particular well-known project), as monitoring and basic security features should be a standard, free functionality.
|
||||
|
||||
### How to deploy
|
||||
|
||||
You can find the example of the Kubernetes manifest in the [example standalone deployment](static/kubernetes-deployment.yaml) or [example combined deployment](static/kubernetes-single-deployment.yaml) files. Observed advantage of multideployment is that it allows the network requests to travel via localhost, without leaving the deployment which brings quite significant network performance boost.
|
||||
|
||||
#### Note on websocket support
|
||||
|
||||
Proxy in its current version 0.5.30 does not support websockets. If you need to proxy the websocket requests - you can use following trick whilst setting up the proxy. As I'm a big fan of Traefik - there's an example which works with the mentioned above combined deployment.
|
||||
|
||||
<details>
|
||||
<summary>Click to show working Traefik Ingress Route example.</summary>
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.containo.us/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: hasura-internal
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
# NON WEBSOCKET CONNECTION
|
||||
- kind: Rule
|
||||
match: Host(`example.com`) && PathPrefix(`/v1/graphql`) && !HeadersRegexp(`Upgrade`, `websocket`)
|
||||
services:
|
||||
- name: hasura-w-proxy-internal
|
||||
port: proxy
|
||||
middlewares:
|
||||
- name: compression
|
||||
namespace: default
|
||||
|
||||
# WEBSOCKET CONNECTION
|
||||
- kind: Rule
|
||||
match: Host(`example.com`) && PathPrefix(`/v1/graphql`) && HeadersRegexp(`Upgrade`, `websocket`)
|
||||
services:
|
||||
- name: hasura-w-proxy-internal
|
||||
port: hasura
|
||||
middlewares:
|
||||
- name: compression
|
||||
namespace: default
|
||||
```
|
||||
|
||||
In this case, both proxy and websockets will be available under the `/v1/graphql` path, and the websocket connection will be proxied directly to the hasura service, bypassing the proxy.
|
||||
|
||||
</details>
|
||||
|
||||
### Endpoints
|
||||
|
||||
* `:8080/*` - the graphql passthrough endpoint
|
||||
* `:9393/metrics` - the prometheus metrics endpoint
|
||||
* `:8080/healthz` - the healthcheck endpoint
|
||||
* `:8080/livez` - the liveness probe endpoint
|
||||
* `:9090/api/*` - the monitoring proxy API endpoint
|
||||
|
||||
### Features
|
||||
|
||||
@@ -31,6 +97,7 @@ I wanted to monitor the queries and responses of our graphql endpoint. Still, we
|
||||
| security | Rate limiting queries based on user role |
|
||||
| security | Blocking mutations in read-only mode |
|
||||
| security | Allow access only to listed URLs |
|
||||
| security | Ban / unban specific user from accessing the application |
|
||||
|
||||
|
||||
### Configuration
|
||||
@@ -40,20 +107,29 @@ I wanted to monitor the queries and responses of our graphql endpoint. Still, we
|
||||
| `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/` |
|
||||
| `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 | `` |
|
||||
| `JWT_ROLE_FROM_HEADER` | Header name to extract the role from | `` |
|
||||
| `ROLE_FROM_HEADER` | Header name to extract the role from | `` |
|
||||
| `ROLE_RATE_LIMIT` | Enable request rate limiting based on role| `false` |
|
||||
| `ENABLE_GLOBAL_CACHE` | Enable the cache | `false` |
|
||||
| `CACHE_TTL` | The cache TTL | `60` |
|
||||
| `LOG_LEVEL` | The log level | `info` |
|
||||
| `BLOCK_SCHEMA_INTROSPECTION`| Blocks the schema introspection | `false` |
|
||||
| `ALLOWED_INTROSPECTION` | Allow only certain queries in introspection | `` |
|
||||
| `ENABLE_ACCESS_LOG` | Enable the access log | `false` |
|
||||
| `READ_ONLY_MODE` | Enable the read only mode | `false` |
|
||||
| `ALLOWED_URLS` | Allow access only to certain URLs | `/v1/graphql,/v1/version` |
|
||||
| `ENABLE_API` | Enable the monitoring API | `false` |
|
||||
| `API_PORT` | The port to expose the monitoring API | `9090` |
|
||||
| `BANNED_USERS_FILE` | The path to the file with banned users | `/go/src/app/banned_users.json` |
|
||||
| `PROXIED_CLIENT_TIMEOUT` | The timeout for the proxied client in seconds | `120` |
|
||||
| `PURGE_METRICS_ON_CRAWL` | Purge metrics on each /metrics crawl | `false` |
|
||||
| `PURGE_METRICS_ON_TIMER` | Purge metrics every x seconds. `0` - disabled | `0` |
|
||||
|
||||
### Speed
|
||||
|
||||
### Caching
|
||||
#### Caching
|
||||
|
||||
The cache engine is enabled in the background by default, using no additional resources.
|
||||
You can then start using the cache by setting the `ENABLE_GLOBAL_CACHE` environment variable to `true` - which will enable the cache for all queries without introspection. You can leave the global cache disabled and enable the cache for specific queries by adding the `@cached` directive to the query.
|
||||
@@ -61,7 +137,13 @@ You can then start using the cache by setting the `ENABLE_GLOBAL_CACHE` environm
|
||||
In the case of the `@cached` you can add additional parameters to the directive which will set the cache for specific queries to the provided time.
|
||||
For example, `query MyCachedQuery @cached(ttl: 90) ....` will set the cache for the query to 90 seconds.
|
||||
|
||||
### Role-based rate limiting
|
||||
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.
|
||||
|
||||
Since version `0.5.30` the cache is gzipped in the memory, which should optimise the memory usage quite significantly.
|
||||
|
||||
### Security
|
||||
|
||||
#### Role-based rate limiting
|
||||
|
||||
You can rate limit requests using the `ROLE_RATE_LIMIT` environment variable. If enabled, the proxy will rate limit the requests based on the role claim in the JWT token. You can then provide the JSON file in the following format to specify the limits.
|
||||
The default interval is `second`, but you can use other values as well. If you want to disable the rate limiting for a specific role, you can set the `req` to `0`.
|
||||
@@ -99,16 +181,68 @@ Remember to include the `-` role, which is used for unauthenticated users or whe
|
||||
If rate limit has been reached - the proxy will return `429 Too Many Requests` error.
|
||||
|
||||
|
||||
### Read-only mode
|
||||
#### Read-only mode
|
||||
|
||||
You can enable the read-only mode by setting the `READ_ONLY_MODE` environment variable to `true` - which will block all the `mutation` queries.
|
||||
|
||||
### Allowing access to listed URLs
|
||||
#### Allowing access to listed URLs
|
||||
|
||||
You can allow access only to certain URLs by setting the `ALLOWED_URLS` environment variable to a comma-separated list of URLs. If enabled - other URLs will return `403 Forbidden` error and request will **not** reach the proxied service.
|
||||
|
||||
#### Blocking introspection
|
||||
|
||||
### Monitoring endpoint
|
||||
You can block the schema introspection by setting the `BLOCK_SCHEMA_INTROSPECTION` environment variable to `true` - which will block all the queries with introspection parts, like:
|
||||
|
||||
`__schema`, `__type`, `__typename`, `__directive`, `__directivelocation`, `__field`, `__inputvalue`, `__enumvalue`, `__typekind`, `__fieldtype`, `__inputobjecttype`, `__enumtype`, `__uniontype`, `__scalars`, `__objects`, `__interfaces`, `__unions`, `__enums`, `__inputobjects`, `__directives`
|
||||
|
||||
If you'd like to keep blocking of the schema introspection on but allow one or more of from the list of above for any reason, you can use the `ALLOWED_INTROSPECTION` environment variable to specify the list of allowed queries.
|
||||
|
||||
`ALLOWED_INTROSPECTION="__typename,__type"`
|
||||
|
||||
### API endpoints
|
||||
|
||||
#### Ban or unban the user
|
||||
|
||||
Your monitoring system can detect user misbehaving, for example trying to extract / scrap the data. To prevent user from doing so you can use the simple API to ban the user from accessing the application.
|
||||
|
||||
To do so - you need to enable the api by setting env variable `ENABLE_API=true` which will expose the API on the port `API_PORT=9090`. Nedless to say - keep it secure and don't expose it outside of your cluster.
|
||||
|
||||
Then you can use the following endpoints:
|
||||
|
||||
* `POST /api/user-ban` - ban the user from accessing the application
|
||||
* `POST /api/user-unban` - unban the user from accessing the application
|
||||
|
||||
Both endpoints require the `user_id` parameter to be present in the request body and allow you to provide the reason for the ban.
|
||||
|
||||
Example request:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
http://localhost:9090/api/user-ban \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"user_id": "1337",
|
||||
"reason": "Scraping data"
|
||||
}'
|
||||
```
|
||||
|
||||
Ban details will be stored in the `banned_users.json` file, which you can mount as a file or configmap to the `/go/src/app/banned_users.json` path ( or use `BANNED_USERS_FILE` environment variable to specify the path to the file). The file operation is important if you have multiple instances of the proxy running, as it will allow you to ban the user from accessing the application on all instances.
|
||||
|
||||
### General
|
||||
|
||||
#### Metrics which matter
|
||||
|
||||
You can always enable `PURGE_METRICS_ON_CRAWL` environment variable to purge the metrics on each `/metrics` crawl. This will allow you to see only the current metrics, without potential leftovers from the previous crawls. This is useful if you want to monitor the metrics in real-time and / or limit the amount of data ingested into the monitoring system. When enabled you will most likely need to update your monitoring queries.
|
||||
|
||||
With the `PURGE_METRICS_ON_CRAWL` enabled, the `graphql_proxy_requests_failed`, `graphql_proxy_requests_skipped` and `graphql_proxy_requests_succesful` metrics will remain between resets.
|
||||
|
||||
If you prefer more control over the metrics purging - you can enable `PURGE_METRICS_ON_TIMER` environment variable and set the interval in seconds. This will allow you to purge the metrics on a regular basis, for example every 90 seconds. It could be better solution if you have multiple crawlers checking the metrics endpoints and you want to avoid the situation when metrics are purged by for example healthcheck.
|
||||
|
||||
#### Healthcheck
|
||||
|
||||
If you'd like the `/healthz` endpoint to perform actual check for the connectivity to the graphql endpoint - set the `HEALTHCHECK_GRAPHQL_URL` environment variable to the exact URL of the graphql endpoint. The query executed will be `query { __typename }` and if the response is not `200 OK` - the healthcheck will fail. Remember that the endpoint is a full URL which you'd like to check, so it should include the protocol, host and path - for example `http://localhost:8080/v1/graphql` and it's NOT the same as value of `HOST_GRAPHQL` environment variable which should provide only the host, without path, ending with slash.
|
||||
|
||||
#### Monitoring endpoint
|
||||
|
||||
Example metrics produced by the proxy:
|
||||
|
||||
@@ -125,4 +259,4 @@ graphql_proxy_executed_query{user_id="-",op_type="query",op_name="checkIfSpamAIR
|
||||
graphql_proxy_requests_failed 324
|
||||
graphql_proxy_requests_skipped 0
|
||||
graphql_proxy_requests_succesful 454823
|
||||
```
|
||||
```
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/gofrs/flock"
|
||||
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
|
||||
)
|
||||
|
||||
var bannedUsersIDs map[string]string = make(map[string]string)
|
||||
|
||||
func enableApi() {
|
||||
if cfg.Server.EnableApi {
|
||||
apiserver := fiber.New(fiber.Config{
|
||||
DisableStartupMessage: true,
|
||||
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
|
||||
})
|
||||
|
||||
api := apiserver.Group("/api")
|
||||
api.Post("/user-ban", apiBanUser)
|
||||
api.Post("/user-unban", apiUnbanUser)
|
||||
|
||||
go periodicallyReloadBannedUsers()
|
||||
err := apiserver.Listen(fmt.Sprintf(":%d", cfg.Server.ApiPort))
|
||||
if err != nil {
|
||||
cfg.Logger.Critical("Can't start the service", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func periodicallyReloadBannedUsers() {
|
||||
for {
|
||||
loadBannedUsers()
|
||||
cfg.Logger.Debug("Banned users reloaded", map[string]interface{}{"users": bannedUsersIDs})
|
||||
<-time.After(10 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func checkIfUserIsBanned(c *fiber.Ctx, userID string) bool {
|
||||
_, found := bannedUsersIDs[userID]
|
||||
cfg.Logger.Debug("Checking if user is banned", map[string]interface{}{"user_id": userID, "found": found})
|
||||
if found {
|
||||
cfg.Logger.Info("User is banned", map[string]interface{}{"user_id": userID})
|
||||
c.Status(403).SendString("User is banned")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
type apiBanUserRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func apiBanUser(c *fiber.Ctx) error {
|
||||
var req apiBanUserRequest
|
||||
err := c.BodyParser(&req)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't parse the ban user request", map[string]interface{}{"error": err.Error()})
|
||||
return err
|
||||
}
|
||||
bannedUsersIDs[req.UserID] = req.Reason
|
||||
cfg.Logger.Info("Banned user", map[string]interface{}{"user_id": req.UserID, "reason": req.Reason})
|
||||
storeBannedUsers()
|
||||
c.Status(200).SendString("OK: user banned")
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiUnbanUser(c *fiber.Ctx) error {
|
||||
var req apiBanUserRequest
|
||||
err := c.BodyParser(&req)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't parse the unban user request", map[string]interface{}{"error": err.Error()})
|
||||
return err
|
||||
}
|
||||
delete(bannedUsersIDs, req.UserID)
|
||||
cfg.Logger.Info("Unbanned user", map[string]interface{}{"user_id": req.UserID})
|
||||
storeBannedUsers()
|
||||
c.Status(200).SendString("OK: user unbanned")
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeBannedUsers() {
|
||||
fileLock := flock.New(fmt.Sprintf("%s.lock", cfg.Api.BannedUsersFile))
|
||||
err := fileLock.Lock()
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't lock the file", map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer fileLock.Unlock()
|
||||
data, err := json.Marshal(bannedUsersIDs)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't marshal banned users", map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err = os.WriteFile(cfg.Api.BannedUsersFile, data, 0644)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't write banned users to file", map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func loadBannedUsers() {
|
||||
if _, err := os.Stat(cfg.Api.BannedUsersFile); os.IsNotExist(err) {
|
||||
cfg.Logger.Info("Banned users file doesn't exist - creating it", map[string]interface{}{"file": cfg.Api.BannedUsersFile})
|
||||
_, err := os.Create(cfg.Api.BannedUsersFile)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't create the file", map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fileLock := flock.New(fmt.Sprintf("%s.lock", cfg.Api.BannedUsersFile))
|
||||
err := fileLock.RLock() // Use RLock for read lock
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't lock the file [load]", map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer fileLock.Unlock()
|
||||
|
||||
data, err := os.ReadFile(cfg.Api.BannedUsersFile)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't read banned users from file", map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(data, &bannedUsersIDs)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't unmarshal banned users", map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
@@ -10,19 +9,17 @@ import (
|
||||
)
|
||||
|
||||
func calculateHash(c *fiber.Ctx) string {
|
||||
return strutil.Md5(fmt.Sprintf("%s", c.Body()))
|
||||
return strutil.Md5(c.Body())
|
||||
}
|
||||
|
||||
func enableCache() {
|
||||
cfg.Cache.CacheClient = libpack_cache.New(time.Duration(cfg.Cache.CacheTTL) * time.Second * 2)
|
||||
cfg.Cache.CacheClient = libpack_cache.New(time.Duration(cfg.Cache.CacheTTL) * time.Second * 100)
|
||||
}
|
||||
|
||||
func cacheLookup(hash string) []byte {
|
||||
if cfg.Cache.CacheClient != nil {
|
||||
obj, found := cfg.Cache.CacheClient.Get(hash)
|
||||
if found {
|
||||
return obj
|
||||
}
|
||||
obj, found := cfg.Cache.CacheClient.Get(hash)
|
||||
if found {
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Vendored
+79
-26
@@ -1,6 +1,9 @@
|
||||
package libpack_cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -12,16 +15,21 @@ type CacheEntry struct {
|
||||
|
||||
type Cache struct {
|
||||
sync.RWMutex
|
||||
entries map[string]CacheEntry
|
||||
entries sync.Map
|
||||
globalTTL time.Duration
|
||||
bytePool sync.Pool
|
||||
}
|
||||
|
||||
func New(globalTTL time.Duration) *Cache {
|
||||
cache := &Cache{
|
||||
entries: make(map[string]CacheEntry),
|
||||
globalTTL: globalTTL,
|
||||
}
|
||||
|
||||
// Initialize the byte pool.
|
||||
cache.bytePool.New = func() interface{} {
|
||||
return make([]byte, 0)
|
||||
}
|
||||
|
||||
// Start the cache cleanup.
|
||||
go cache.cleanupRoutine(globalTTL)
|
||||
return cache
|
||||
@@ -39,54 +47,99 @@ func (c *Cache) cleanupRoutine(globalTTL time.Duration) {
|
||||
func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(ttl)
|
||||
if expiresAt.After(now.Add(c.globalTTL)) {
|
||||
expiresAt = now.Add(c.globalTTL)
|
||||
compressedValue, err := c.compress(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.entries[key] = CacheEntry{
|
||||
Value: value,
|
||||
// Get a byte slice from the pool and ensure it's properly sized.
|
||||
b := c.bytePool.Get().([]byte)
|
||||
if cap(b) < len(compressedValue) {
|
||||
b = make([]byte, len(compressedValue))
|
||||
} else {
|
||||
b = b[:len(compressedValue)]
|
||||
}
|
||||
|
||||
copy(b, compressedValue)
|
||||
|
||||
entry := CacheEntry{
|
||||
Value: b,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
c.entries.Store(key, entry)
|
||||
}
|
||||
|
||||
func (c *Cache) Get(key string) ([]byte, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
entry, ok := c.entries[key]
|
||||
if !ok || entry.ExpiresAt.Before(time.Now()) {
|
||||
entry, ok := c.entries.Load(key)
|
||||
if !ok || entry.(CacheEntry).ExpiresAt.Before(time.Now()) {
|
||||
return nil, false
|
||||
}
|
||||
compressedValue := entry.(CacheEntry).Value
|
||||
value, err := c.decompress(compressedValue)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.Value, true
|
||||
return value, true
|
||||
}
|
||||
|
||||
func (c *Cache) Delete(key string) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
delete(c.entries, key)
|
||||
entry, ok := c.entries.Load(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Return the byte slice to the pool.
|
||||
c.bytePool.Put(entry.(CacheEntry).Value)
|
||||
|
||||
// Delete the entry from the cache.
|
||||
c.entries.Delete(key)
|
||||
}
|
||||
|
||||
func (c *Cache) CleanExpiredEntries() {
|
||||
now := time.Now()
|
||||
toDelete := make([]string, 0)
|
||||
|
||||
c.RLock()
|
||||
for key, entry := range c.entries {
|
||||
c.entries.Range(func(key, value interface{}) bool {
|
||||
entry := value.(CacheEntry)
|
||||
if entry.ExpiresAt.Before(now) {
|
||||
toDelete = append(toDelete, key)
|
||||
}
|
||||
}
|
||||
c.RUnlock()
|
||||
// Return the byte slice to the pool.
|
||||
c.bytePool.Put(entry.Value)
|
||||
|
||||
// Separate the deletion to its own critical section to reduce lock contention.
|
||||
c.Lock()
|
||||
for _, key := range toDelete {
|
||||
delete(c.entries, key)
|
||||
}
|
||||
c.Unlock()
|
||||
// Delete the entry from the cache.
|
||||
c.entries.Delete(key)
|
||||
}
|
||||
|
||||
// Return true to continue iterating over the map.
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Cache) compress(data []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := gzip.NewWriter(&buf)
|
||||
_, err := w.Write(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (c *Cache) decompress(data []byte) ([]byte, error) {
|
||||
r, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
Vendored
+148
@@ -0,0 +1,148 @@
|
||||
package libpack_cache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type CacheTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (suite *CacheTestSuite) SetupTest() {
|
||||
}
|
||||
|
||||
func TestCachingTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(CacheTestSuite))
|
||||
}
|
||||
|
||||
func (suite *CacheTestSuite) Test_New() {
|
||||
suite.T().Run("should return a new cache", func(t *testing.T) {
|
||||
cache := New(2 * time.Second)
|
||||
suite.NotNil(cache)
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *CacheTestSuite) Test_CacheUse() {
|
||||
cache := New(30 * time.Second)
|
||||
tests := []struct {
|
||||
name string
|
||||
cache_value string
|
||||
}{
|
||||
{
|
||||
name: "test1",
|
||||
cache_value: "test1-123",
|
||||
},
|
||||
{
|
||||
name: "test2",
|
||||
cache_value: "test2-123",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
suite.T().Run(tt.name, func(t *testing.T) {
|
||||
cache.Set(tt.name, []byte(tt.name), 5*time.Second)
|
||||
c, ok := cache.Get(tt.name)
|
||||
suite.Equal(true, ok)
|
||||
suite.Equal(tt.name, string(c))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CacheTestSuite) Test_CacheDelete() {
|
||||
cache := New(30 * time.Second)
|
||||
tests := []struct {
|
||||
name string
|
||||
cache_value string
|
||||
}{
|
||||
{
|
||||
name: "test1",
|
||||
cache_value: "test1-123",
|
||||
},
|
||||
{
|
||||
name: "test2",
|
||||
cache_value: "test2-123",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
suite.T().Run(tt.name, func(t *testing.T) {
|
||||
cache.Set(tt.name, []byte(tt.name), 5*time.Second)
|
||||
c, ok := cache.Get(tt.name)
|
||||
suite.Equal(true, ok)
|
||||
suite.Equal(tt.name, string(c))
|
||||
cache.Delete(tt.name)
|
||||
c, ok = cache.Get(tt.name)
|
||||
suite.Equal(false, ok)
|
||||
suite.Equal("", string(c))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CacheTestSuite) Test_CacheExpire() {
|
||||
cache := New(30 * time.Second)
|
||||
tests := []struct {
|
||||
name string
|
||||
cache_value string
|
||||
ttl time.Duration
|
||||
}{
|
||||
{
|
||||
name: "test1",
|
||||
cache_value: "test1-123",
|
||||
ttl: 2 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "test2",
|
||||
cache_value: "test2-123",
|
||||
ttl: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
suite.T().Run(tt.name, func(t *testing.T) {
|
||||
cache.Set(tt.name, []byte(tt.name), tt.ttl)
|
||||
c, ok := cache.Get(tt.name)
|
||||
suite.Equal(true, ok)
|
||||
suite.Equal(tt.name, string(c))
|
||||
time.Sleep(tt.ttl)
|
||||
c, ok = cache.Get(tt.name)
|
||||
suite.Equal(false, ok)
|
||||
suite.Equal("", string(c))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CacheTestSuite) Test_CacheCleanExpiredEntries() {
|
||||
cache := New(5 * time.Second)
|
||||
tests := []struct {
|
||||
name string
|
||||
cache_value string
|
||||
ttl time.Duration
|
||||
}{
|
||||
{
|
||||
name: "test1",
|
||||
cache_value: "test1-123",
|
||||
ttl: 2 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "test2",
|
||||
cache_value: "test2-123",
|
||||
ttl: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
suite.T().Run(tt.name, func(t *testing.T) {
|
||||
cache.Set(tt.name, []byte(tt.name), tt.ttl)
|
||||
c, ok := cache.Get(tt.name)
|
||||
suite.Equal(true, ok)
|
||||
suite.Equal(tt.name, string(c))
|
||||
time.Sleep(tt.ttl)
|
||||
c, ok = cache.Get(tt.name)
|
||||
suite.Equal(false, ok)
|
||||
suite.Equal("", string(c))
|
||||
cache.CleanExpiredEntries()
|
||||
c, ok = cache.Get(tt.name)
|
||||
suite.Equal(false, ok)
|
||||
suite.Equal("", string(c))
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -27,7 +27,7 @@ func (suite *Tests) Test_cacheLookup() {
|
||||
{
|
||||
name: "test_existent",
|
||||
args: args{
|
||||
hash: "00000000000000000000000000000000000001",
|
||||
hash: "00000000000000000000000000000000001337",
|
||||
},
|
||||
want: []byte("it's fine."),
|
||||
addCache: struct {
|
||||
@@ -40,7 +40,7 @@ func (suite *Tests) Test_cacheLookup() {
|
||||
for _, tt := range tests {
|
||||
suite.T().Run(tt.name, func(t *testing.T) {
|
||||
if tt.addCache.data != nil {
|
||||
cfg.Cache.CacheClient.Set(tt.args.hash, tt.addCache.data, time.Duration(1)*time.Second)
|
||||
cfg.Cache.CacheClient.Set(tt.args.hash, tt.addCache.data, time.Duration(90*time.Second))
|
||||
}
|
||||
got := cacheLookup(tt.args.hash)
|
||||
assert.Equal(tt.want, got, "Unexpected cache lookup result")
|
||||
|
||||
@@ -3,31 +3,31 @@ module github.com/lukaszraczylo/graphql-monitoring-proxy
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/VictoriaMetrics/metrics v1.24.0
|
||||
github.com/VictoriaMetrics/metrics v1.25.3
|
||||
github.com/buger/jsonparser v1.1.1
|
||||
github.com/gofiber/fiber/v2 v2.49.2
|
||||
github.com/gookit/goutil v0.6.13
|
||||
github.com/gofiber/fiber/v2 v2.51.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/gookit/goutil v0.6.14
|
||||
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.1.32
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.3
|
||||
github.com/rs/zerolog v1.31.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/valyala/fasthttp v1.50.0
|
||||
github.com/valyala/fasthttp v1.51.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/akyoto/cache v1.0.6 // indirect
|
||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||
github.com/avast/retry-go/v4 v4.5.0 // indirect
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/avast/retry-go/v4 v4.5.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/klauspost/compress v1.17.0 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // 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.19 // 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
|
||||
@@ -40,11 +40,11 @@ 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.17.0 // indirect
|
||||
golang.org/x/sync v0.4.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/term v0.13.0 // indirect
|
||||
golang.org/x/text v0.13.0 // 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/text v0.14.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
github.com/VictoriaMetrics/metrics v1.24.0 h1:ILavebReOjYctAGY5QU2F9X0MYvkcrG3aEn2RKa1Zkw=
|
||||
github.com/VictoriaMetrics/metrics v1.24.0/go.mod h1:eFT25kvsTidQFHb6U0oa0rTrDRdz4xTYjpL8+UPohys=
|
||||
github.com/akyoto/cache v1.0.6 h1:5XGVVYoi2i+DZLLPuVIXtsNIJ/qaAM16XT0LaBaXd2k=
|
||||
github.com/akyoto/cache v1.0.6/go.mod h1:WfxTRqKhfgAG71Xh6E3WLpjhBtZI37O53G4h5s+3iM4=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/avast/retry-go/v4 v4.5.0 h1:QoRAZZ90cj5oni2Lsgl2GW8mNTnUCnmpx/iKpwVisHg=
|
||||
github.com/avast/retry-go/v4 v4.5.0/go.mod h1:7hLEXp0oku2Nir2xBAsg0PTphp9z71bN5Aq1fboC3+I=
|
||||
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/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=
|
||||
@@ -14,21 +12,23 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofiber/fiber/v2 v2.49.2 h1:ONEN3/Vc+dUCxxDgZZwpqvhISgHqb+bu+isBiEyKEQs=
|
||||
github.com/gofiber/fiber/v2 v2.49.2/go.mod h1:gNsKnyrmfEWFpJxQAV0qvW6l70K1dZGno12oLtukcts=
|
||||
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/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.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/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.13 h1:ttg7yMda6Q9fkE4P+YTwozd2wH1Le0CQldTAtOFBr7o=
|
||||
github.com/gookit/goutil v0.6.13/go.mod h1:YyDBddefmjS+mU2PDPgCcjVzTDM5WgExiDv5ZA/b8I8=
|
||||
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/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.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
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,13 +40,14 @@ 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.1.32 h1:udiz2hnLNO2b/hc9Z0xcjYt+HcFbChQQuIY4HZmky80=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.1.32/go.mod h1:c1nN/qtjsvpqpkBFsnBCYCDLdLMuWzy/zxeJzTjm5qg=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.3 h1:OwsJsQENDmLH2Qw37nuzUSht+m5tofv5H9T6zhDbckA=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.3/go.mod h1:fYwnUZ1xJqvJSfbU9k8GMMI9Flan2dNXSvg/arS7rzU=
|
||||
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=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
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=
|
||||
@@ -73,8 +74,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
|
||||
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.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e9M=
|
||||
github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
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=
|
||||
@@ -85,19 +86,19 @@ 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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
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/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.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
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/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=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
+103
-24
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
)
|
||||
|
||||
var retrospection_queries = []string{
|
||||
var introspection_queries = []string{
|
||||
"__schema",
|
||||
"__type",
|
||||
"__typename",
|
||||
@@ -34,7 +35,39 @@ var retrospection_queries = []string{
|
||||
}
|
||||
|
||||
// Saving the introspection queries as a map O(1) operation instead of O(n) for a slice.
|
||||
var retrospectionQuerySet = make(map[string]struct{}, len(retrospection_queries))
|
||||
|
||||
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
|
||||
}()
|
||||
}
|
||||
|
||||
func parseGraphQLQuery(c *fiber.Ctx) (operationType, operationName string, cacheRequest bool, cache_time int, should_block bool, should_ignore bool) {
|
||||
should_ignore = true
|
||||
@@ -42,21 +75,27 @@ func parseGraphQLQuery(c *fiber.Ctx) (operationType, operationName string, cache
|
||||
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())})
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
if flag.Lookup("test.v") == nil {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
// get the query
|
||||
query, ok := m["query"].(string)
|
||||
if !ok {
|
||||
cfg.Logger.Error("Can't find the query", map[string]interface{}{"query": query, "m_val": m})
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
if flag.Lookup("test.v") == nil {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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})
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
if flag.Lookup("test.v") == nil {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,19 +104,21 @@ func parseGraphQLQuery(c *fiber.Ctx) (operationType, operationName string, cache
|
||||
for _, d := range p.Definitions {
|
||||
if oper, ok := d.(*ast.OperationDefinition); ok {
|
||||
operationType = oper.Operation
|
||||
|
||||
if oper.Name != nil {
|
||||
operationName = oper.Name.Value
|
||||
}
|
||||
|
||||
if strings.ToLower(operationType) == "mutation" && cfg.Server.ReadOnlyMode {
|
||||
cfg.Logger.Warning("Mutation blocked", m)
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
if flag.Lookup("test.v") == nil {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
}
|
||||
c.Status(403).SendString("The server is in read-only mode")
|
||||
should_block = true
|
||||
return
|
||||
}
|
||||
|
||||
if oper.Name != nil {
|
||||
operationName = oper.Name.Value
|
||||
} else {
|
||||
operationName = "undefined"
|
||||
}
|
||||
for _, dir := range oper.Directives {
|
||||
if dir.Name.Value == "cached" {
|
||||
cacheRequest = true
|
||||
@@ -85,29 +126,67 @@ func parseGraphQLQuery(c *fiber.Ctx) (operationType, operationName string, cache
|
||||
if arg.Name.Value == "ttl" {
|
||||
cache_time, err = strconv.Atoi(arg.Value.GetValue().(string))
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't parse the ttl", map[string]interface{}{"ttl": arg.Value.GetValue().(string)})
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, 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 {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Security.BlockIntrospection {
|
||||
for _, s := range oper.SelectionSet.Selections {
|
||||
for _, s2 := range s.GetSelectionSet().Selections {
|
||||
if _, exists := retrospectionQuerySet[s2.(*ast.Field).Name.Value]; exists {
|
||||
cfg.Logger.Warning("Introspection query blocked", m)
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
c.Status(403).SendString("Introspection queries are not allowed")
|
||||
should_block = true
|
||||
return
|
||||
}
|
||||
}
|
||||
should_block = checkSelections(c, oper.GetSelectionSet().Selections)
|
||||
if should_block {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func checkSelections(c *fiber.Ctx, selections []ast.Selection) bool {
|
||||
for _, s := range selections {
|
||||
field, ok := s.(*ast.Field)
|
||||
if !ok {
|
||||
continue // or handle the case where the type assertion fails
|
||||
}
|
||||
shouldBlock := checkIfContainsIntrospection(c, field.Name.Value)
|
||||
if shouldBlock {
|
||||
return true
|
||||
}
|
||||
if field.SelectionSet != nil {
|
||||
if checkSelections(c, field.GetSelectionSet().Selections) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkIfContainsIntrospection(c *fiber.Ctx, whatever string) (should_block bool) {
|
||||
whateverLower := strings.ToLower(whatever)
|
||||
got_exemption := false
|
||||
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
|
||||
}
|
||||
}
|
||||
if !got_exemption {
|
||||
should_block = true
|
||||
}
|
||||
}
|
||||
if should_block {
|
||||
if flag.Lookup("test.v") == nil {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
}
|
||||
c.Status(403).SendString("Introspection queries are not allowed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
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
|
||||
}
|
||||
|
||||
type queries struct {
|
||||
body string
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
suppliedSettings *config
|
||||
suppliedQuery queries
|
||||
wantResults results
|
||||
}{
|
||||
{
|
||||
name: "test empty body",
|
||||
suppliedQuery: queries{
|
||||
body: "",
|
||||
headers: map[string]string{},
|
||||
},
|
||||
wantResults: results{
|
||||
is_cached: false,
|
||||
should_block: false,
|
||||
should_ignore: true,
|
||||
op_name: "",
|
||||
op_type: "",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test empty json",
|
||||
suppliedQuery: queries{
|
||||
body: "{}",
|
||||
headers: map[string]string{},
|
||||
},
|
||||
wantResults: results{
|
||||
is_cached: false,
|
||||
should_block: false,
|
||||
should_ignore: true,
|
||||
op_name: "",
|
||||
op_type: "",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test empty with some random garbage",
|
||||
suppliedQuery: queries{
|
||||
body: "{\"variables\": {\"id\": \"1\"}}",
|
||||
headers: map[string]string{},
|
||||
},
|
||||
wantResults: results{
|
||||
is_cached: false,
|
||||
should_block: false,
|
||||
should_ignore: true,
|
||||
op_name: "",
|
||||
op_type: "",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test valid query with op name",
|
||||
suppliedQuery: queries{
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test valid query with op name, variables and cache",
|
||||
suppliedQuery: queries{
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test valid query with op name, cache and ttl",
|
||||
suppliedQuery: queries{
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test valid query with op name, cache and INVALID ttl",
|
||||
suppliedQuery: queries{
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test mutation query with op name",
|
||||
suppliedQuery: queries{
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test mutation query with config: read only",
|
||||
suppliedSettings: func() *config {
|
||||
cfg.Server.ReadOnlyMode = true
|
||||
return cfg
|
||||
}(),
|
||||
suppliedQuery: queries{
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test simple query with introspection __schema",
|
||||
suppliedQuery: queries{
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test simple query with introspection __schema config: block introspection",
|
||||
suppliedSettings: func() *config {
|
||||
cfg.Security.BlockIntrospection = true
|
||||
return cfg
|
||||
}(),
|
||||
suppliedQuery: queries{
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test user supplied query with introspection #1 - config: block",
|
||||
suppliedSettings: func() *config {
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test user supplied query with introspection #1 - config: block & allow __schema",
|
||||
suppliedSettings: func() *config {
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "test invalid query",
|
||||
suppliedQuery: queries{
|
||||
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: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.T().Run(tt.name, func(t *testing.T) {
|
||||
cfg = &config{}
|
||||
cfg.Logger = libpack_logging.NewLogger()
|
||||
defer func() {
|
||||
cfg = &config{}
|
||||
}()
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
ctx_headers := func() *fasthttp.RequestHeader {
|
||||
h := fasthttp.RequestHeader{}
|
||||
for k, v := range tt.suppliedQuery.headers {
|
||||
h.Add(k, v)
|
||||
}
|
||||
return &h
|
||||
}()
|
||||
|
||||
ctx_request := fasthttp.Request{
|
||||
Header: *ctx_headers,
|
||||
}
|
||||
|
||||
ctx_request.AppendBody([]byte(tt.suppliedQuery.body))
|
||||
|
||||
ctx := app.AcquireCtx(&fasthttp.RequestCtx{
|
||||
Request: ctx_request,
|
||||
})
|
||||
|
||||
defer 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)
|
||||
|
||||
if tt.wantResults.returnCode > 0 {
|
||||
assert.Equal(tt.wantResults.returnCode, ctx.Response().StatusCode(), "Unexpected return code", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,9 @@ import (
|
||||
|
||||
var cfg *config
|
||||
|
||||
func init() {
|
||||
for _, query := range retrospection_queries {
|
||||
retrospectionQuerySet[query] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func parseConfig() {
|
||||
libpack_config.PKG_NAME = "graphql_proxy"
|
||||
var c config
|
||||
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/")
|
||||
@@ -30,9 +24,17 @@ func parseConfig() {
|
||||
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.Security.IntrospectionAllowed = func() []string {
|
||||
urls := envutil.Getenv("ALLOWED_INTROSPECTION", "")
|
||||
if urls == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(urls, ",")
|
||||
}()
|
||||
c.Logger = libpack_logging.NewLogger()
|
||||
c.Server.HealthcheckGraphQL = envutil.Getenv("HEALTHCHECK_GRAPHQL_URL", "")
|
||||
c.Client.GQLClient = graphql.NewConnection()
|
||||
c.Client.GQLClient.SetEndpoint(c.Server.HostGraphQL)
|
||||
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.AllowURLs = func() []string {
|
||||
@@ -42,10 +44,18 @@ func parseConfig() {
|
||||
}
|
||||
return strings.Split(urls, ",")
|
||||
}()
|
||||
c.Client.FastProxyClient = createFasthttpClient()
|
||||
c.Client.ClientTimeout = envutil.GetInt("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)
|
||||
cfg = &c
|
||||
enableCache() // takes close to no resources, but can be used with dynamic query cache
|
||||
loadRatelimitConfig()
|
||||
enableApi()
|
||||
prepareQueriesAndExemptions()
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
+3
-5
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
assertions "github.com/stretchr/testify/assert"
|
||||
@@ -21,14 +20,13 @@ func (suite *Tests) SetupTest() {
|
||||
}
|
||||
|
||||
func (suite *Tests) BeforeTest(suiteName, testName string) {
|
||||
fmt.Println("BeforeTest")
|
||||
cfg = &config{}
|
||||
parseConfig()
|
||||
StartMonitoringServer()
|
||||
}
|
||||
|
||||
// func (suite *Tests) AfterTest(suiteName, testName string) {)
|
||||
|
||||
func TestSuite(t *testing.T) {
|
||||
cfg = &config{}
|
||||
parseConfig()
|
||||
StartMonitoringServer()
|
||||
suite.Run(t, new(Tests))
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
)
|
||||
|
||||
func StartMonitoringServer() {
|
||||
cfg.Monitoring = libpack_monitoring.NewMonitoring()
|
||||
cfg.Monitoring = libpack_monitoring.NewMonitoring(cfg.Server.PurgeOnCrawl, cfg.Server.PurgeEvery)
|
||||
cfg.Monitoring.AddMetricsPrefix("graphql_proxy")
|
||||
cfg.Monitoring.RegisterDefaultMetrics()
|
||||
}
|
||||
|
||||
+14
-2
@@ -2,16 +2,23 @@ package libpack_monitoring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
|
||||
)
|
||||
|
||||
func (ms *MetricsSetup) get_metrics_name(name string, labels map[string]string) (complete_name string) {
|
||||
var err error
|
||||
if labels == nil {
|
||||
labels = make(map[string]string)
|
||||
}
|
||||
labels["microservice"] = libpack_config.PKG_NAME
|
||||
labels["pod"], err = os.Hostname()
|
||||
if err != nil {
|
||||
labels["pod"] = "unknown"
|
||||
}
|
||||
|
||||
if ms.metrics_prefix != "" {
|
||||
complete_name = ms.metrics_prefix + "_" + name
|
||||
@@ -19,9 +26,14 @@ func (ms *MetricsSetup) get_metrics_name(name string, labels map[string]string)
|
||||
complete_name = name
|
||||
}
|
||||
if labels != nil {
|
||||
keys := make([]string, 0, len(labels))
|
||||
for k := range labels {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
complete_name += "{"
|
||||
for k, v := range labels {
|
||||
complete_name += k + "=\"" + v + "\","
|
||||
for _, k := range keys {
|
||||
complete_name += k + "=\"" + labels[k] + "\","
|
||||
}
|
||||
complete_name = strings.TrimSuffix(complete_name, ",")
|
||||
complete_name += "}"
|
||||
|
||||
+40
-13
@@ -10,28 +10,48 @@ import (
|
||||
"github.com/VictoriaMetrics/metrics"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gookit/goutil/envutil"
|
||||
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
|
||||
logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
)
|
||||
|
||||
type MetricsSetup struct {
|
||||
metrics_prefix string
|
||||
metrics_set *metrics.Set
|
||||
metrics_prefix string
|
||||
metrics_set *metrics.Set
|
||||
metrics_set_custom *metrics.Set
|
||||
}
|
||||
|
||||
var (
|
||||
log *logging.LogConfig
|
||||
log *logging.LogConfig
|
||||
purgeMetricsOnCrawl bool
|
||||
purgeMetricsEvery int
|
||||
)
|
||||
|
||||
func NewMonitoring() *MetricsSetup {
|
||||
func NewMonitoring(purgeOnCrawl bool, purgeEvery int) *MetricsSetup {
|
||||
purgeMetricsOnCrawl = purgeOnCrawl
|
||||
purgeMetricsEvery = purgeEvery
|
||||
log = logging.NewLogger()
|
||||
ms := &MetricsSetup{}
|
||||
ms.metrics_set = metrics.NewSet()
|
||||
ms.metrics_set_custom = metrics.NewSet()
|
||||
go ms.startPrometheusEndpoint()
|
||||
|
||||
if purgeEvery > 0 {
|
||||
ticker := time.NewTicker(time.Duration(purgeEvery) * time.Second)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
ms.PurgeMetrics()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return ms
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) startPrometheusEndpoint() {
|
||||
app := fiber.New()
|
||||
app := fiber.New(fiber.Config{
|
||||
DisableStartupMessage: true,
|
||||
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
|
||||
})
|
||||
app.Get("/metrics", ms.metricsEndpoint)
|
||||
err := app.Listen(fmt.Sprintf(":%d", envutil.GetInt("MONITORING_PORT", 9393)))
|
||||
if err != nil {
|
||||
@@ -41,12 +61,16 @@ func (ms *MetricsSetup) startPrometheusEndpoint() {
|
||||
|
||||
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 {
|
||||
ms.PurgeMetrics()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) AddMetricsPrefix(prefix string) {
|
||||
ms.metrics_prefix = prefix
|
||||
return
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) ListActiveMetrics() []string {
|
||||
@@ -58,7 +82,7 @@ func (ms *MetricsSetup) RegisterMetricsGauge(metric_name string, labels map[stri
|
||||
log.Critical("RegisterMetricsGauge() error", map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name})
|
||||
return nil
|
||||
}
|
||||
return ms.metrics_set.GetOrCreateGauge(ms.get_metrics_name(metric_name, labels), func() float64 {
|
||||
return ms.metrics_set_custom.GetOrCreateGauge(ms.get_metrics_name(metric_name, labels), func() float64 {
|
||||
// get current value of the gauge and add val to it
|
||||
return val
|
||||
})
|
||||
@@ -69,7 +93,10 @@ func (ms *MetricsSetup) RegisterMetricsCounter(metric_name string, labels map[st
|
||||
log.Critical("RegisterMetricsCounter() error", map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name})
|
||||
return nil
|
||||
}
|
||||
return ms.metrics_set.GetOrCreateCounter(ms.get_metrics_name(metric_name, labels))
|
||||
if metric_name == MetricsSucceeded || metric_name == MetricsFailed || metric_name == MetricsSkipped {
|
||||
return ms.metrics_set.GetOrCreateCounter(ms.get_metrics_name(metric_name, labels))
|
||||
}
|
||||
return ms.metrics_set_custom.GetOrCreateCounter(ms.get_metrics_name(metric_name, labels))
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) RegisterFloatCounter(metric_name string, labels map[string]string) *metrics.FloatCounter {
|
||||
@@ -77,7 +104,7 @@ func (ms *MetricsSetup) RegisterFloatCounter(metric_name string, labels map[stri
|
||||
log.Critical("RegisterFloatCounter() error", map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name})
|
||||
return nil
|
||||
}
|
||||
return ms.metrics_set.GetOrCreateFloatCounter(ms.get_metrics_name(metric_name, labels))
|
||||
return ms.metrics_set_custom.GetOrCreateFloatCounter(ms.get_metrics_name(metric_name, labels))
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) RegisterMetricsSummary(metric_name string, labels map[string]string) *metrics.Summary {
|
||||
@@ -85,7 +112,7 @@ func (ms *MetricsSetup) RegisterMetricsSummary(metric_name string, labels map[st
|
||||
log.Critical("RegisterMetricsSummary() error", map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name})
|
||||
return nil
|
||||
}
|
||||
return ms.metrics_set.GetOrCreateSummary(ms.get_metrics_name(metric_name, labels))
|
||||
return ms.metrics_set_custom.GetOrCreateSummary(ms.get_metrics_name(metric_name, labels))
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) RegisterMetricsHistogram(metric_name string, labels map[string]string) *metrics.Histogram {
|
||||
@@ -93,7 +120,7 @@ func (ms *MetricsSetup) RegisterMetricsHistogram(metric_name string, labels map[
|
||||
log.Critical("RegisterMetricsHistogram() error", map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name})
|
||||
return nil
|
||||
}
|
||||
return ms.metrics_set.GetOrCreateHistogram(ms.get_metrics_name(metric_name, labels))
|
||||
return ms.metrics_set_custom.GetOrCreateHistogram(ms.get_metrics_name(metric_name, labels))
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) Increment(metric_name string, labels map[string]string) {
|
||||
@@ -121,9 +148,9 @@ func (ms *MetricsSetup) UpdateSummary(metric_name string, labels map[string]stri
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) RemoveMetrics(metric_name string, labels map[string]string) {
|
||||
ms.metrics_set.UnregisterMetric(ms.get_metrics_name(metric_name, labels))
|
||||
ms.metrics_set_custom.UnregisterMetric(ms.get_metrics_name(metric_name, labels))
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) PurgeMetrics() {
|
||||
ms.metrics_set.UnregisterAllMetrics()
|
||||
ms.metrics_set_custom.UnregisterAllMetrics()
|
||||
}
|
||||
|
||||
@@ -11,17 +11,16 @@ import (
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func createFasthttpClient() *fasthttp.Client {
|
||||
func createFasthttpClient(timeout int) *fasthttp.Client {
|
||||
return &fasthttp.Client{
|
||||
Name: "graphql_proxy",
|
||||
NoDefaultUserAgentHeader: true,
|
||||
TLSConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
MaxConnsPerHost: 100,
|
||||
MaxIdleConnDuration: 2 * time.Minute,
|
||||
ReadTimeout: time.Second * 10,
|
||||
WriteTimeout: time.Second * 10,
|
||||
MaxConnsPerHost: 200,
|
||||
ReadTimeout: time.Second * time.Duration(timeout),
|
||||
WriteTimeout: time.Second * time.Duration(timeout),
|
||||
DisableHeaderNamesNormalizing: true,
|
||||
}
|
||||
}
|
||||
@@ -33,20 +32,20 @@ func proxyTheRequest(c *fiber.Ctx) error {
|
||||
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)
|
||||
|
||||
cfg.Logger.Debug("Proxying the request", map[string]interface{}{"path": c.Path(), "body": string(c.Request().Body())})
|
||||
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)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": err.Error()})
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
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()})
|
||||
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)
|
||||
|
||||
@@ -2,12 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -15,43 +18,59 @@ var json = jsoniter.ConfigCompatibleWithStandardLibrary
|
||||
|
||||
// StartHTTPProxy starts the HTTP and points it to the GraphQL server.
|
||||
func StartHTTPProxy() {
|
||||
server := fiber.New()
|
||||
server := fiber.New(fiber.Config{
|
||||
DisableStartupMessage: true,
|
||||
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
|
||||
})
|
||||
|
||||
server.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
}))
|
||||
|
||||
// add middleware to check if the request is a GraphQL query
|
||||
server.Use(AddRequestUUID)
|
||||
|
||||
server.Get("/healthz", healthCheck)
|
||||
server.Get("/livez", healthCheck)
|
||||
|
||||
server.Post("/*", processGraphQLRequest)
|
||||
server.Get("/*", proxyTheRequest)
|
||||
|
||||
server.Get("/healthz", healthCheck)
|
||||
cfg.Logger.Info("GraphQL query proxy started", map[string]interface{}{"port": cfg.Server.PortGraphQL})
|
||||
err := server.Listen(fmt.Sprintf(":%d", cfg.Server.PortGraphQL))
|
||||
if err != nil {
|
||||
cfg.Logger.Critical("Can't start the service", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func AddRequestUUID(c *fiber.Ctx) error {
|
||||
c.Locals("request_uuid", uuid.NewString())
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
func checkAllowedURLs(c *fiber.Ctx) bool {
|
||||
if len(cfg.Server.AllowURLs) == 0 {
|
||||
if len(allowedUrls) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, allowedURL := range cfg.Server.AllowURLs {
|
||||
if c.Path() == allowedURL {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
_, ok := allowedUrls[c.Path()]
|
||||
return ok
|
||||
}
|
||||
|
||||
func healthCheck(c *fiber.Ctx) error {
|
||||
// query := `{ __typename }`
|
||||
// _, err := cfg.Client.GQLClient.Query(query, nil, nil)
|
||||
// if err != nil {
|
||||
// cfg.Logger.Error("Can't reach the GraphQL server", map[string]interface{}{"error": err.Error()})
|
||||
// cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
// return c.SendStatus(500)
|
||||
// }
|
||||
return c.SendStatus(200)
|
||||
if len(cfg.Server.HealthcheckGraphQL) > 0 {
|
||||
cfg.Logger.Debug("Health check enabled", map[string]interface{}{"url": cfg.Server.HealthcheckGraphQL})
|
||||
query := `{ __typename }`
|
||||
_, err := cfg.Client.GQLClient.Query(query, nil, nil)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Can't reach the GraphQL server", map[string]interface{}{"error": err.Error()})
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
c.Status(500).SendString("Can't reach the GraphQL server with {__typename} query")
|
||||
return err
|
||||
}
|
||||
}
|
||||
cfg.Logger.Debug("Health check returning OK")
|
||||
c.Status(200).SendString("Health check OK")
|
||||
return nil
|
||||
}
|
||||
|
||||
func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
@@ -67,6 +86,10 @@ func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
extractedUserID, extractedRoleName = extractClaimsFromJWTHeader(string(authorization))
|
||||
}
|
||||
|
||||
if checkIfUserIsBanned(c, extractedUserID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(cfg.Client.RoleFromHeader) > 0 {
|
||||
extractedRoleName = string(c.Request().Header.Peek(cfg.Client.RoleFromHeader))
|
||||
if extractedRoleName == "" {
|
||||
@@ -89,13 +112,21 @@ func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if should_ignore {
|
||||
cfg.Logger.Debug("Request passed as-is - not a GraphQL")
|
||||
cfg.Logger.Debug("Request passed as-is - probably not a GraphQL")
|
||||
return proxyTheRequest(c)
|
||||
}
|
||||
|
||||
if cache_time > 0 {
|
||||
cfg.Logger.Debug("Cache time set via query", map[string]interface{}{"cache_time": cache_time})
|
||||
cache_time = cfg.Cache.CacheTTL
|
||||
} 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})
|
||||
} else {
|
||||
cache_time = cfg.Cache.CacheTTL
|
||||
}
|
||||
}
|
||||
|
||||
wasCached := false
|
||||
@@ -106,11 +137,11 @@ func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
queryCacheHash = calculateHash(c)
|
||||
|
||||
if cachedResponse := cacheLookup(queryCacheHash); cachedResponse != nil {
|
||||
cfg.Logger.Debug("Cache hit", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID})
|
||||
cfg.Logger.Debug("Cache hit", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")})
|
||||
c.Send(cachedResponse)
|
||||
wasCached = true
|
||||
} else {
|
||||
cfg.Logger.Debug("Cache miss", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID})
|
||||
cfg.Logger.Debug("Cache miss", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")})
|
||||
proxyAndCacheTheRequest(c, queryCacheHash, cache_time)
|
||||
}
|
||||
} else {
|
||||
@@ -120,7 +151,7 @@ func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
timeTaken := time.Since(startTime)
|
||||
|
||||
// Logging & Monitoring
|
||||
logAndMonitorRequest(c, extractedUserID, opType, opName, wasCached, timeTaken, startTime)
|
||||
go logAndMonitorRequest(c, extractedUserID, opType, opName, wasCached, timeTaken, startTime)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -148,13 +179,14 @@ func logAndMonitorRequest(c *fiber.Ctx, userID, opType, opName string, wasCached
|
||||
|
||||
if cfg.Server.AccessLog {
|
||||
cfg.Logger.Info("Request processed", map[string]interface{}{
|
||||
"ip": c.IP(),
|
||||
"fwd-ip": string(c.Request().Header.Peek("X-Forwarded-For")),
|
||||
"user_id": userID,
|
||||
"op_type": opType,
|
||||
"op_name": opName,
|
||||
"time": duration,
|
||||
"cache": wasCached,
|
||||
"ip": c.IP(),
|
||||
"fwd-ip": string(c.Request().Header.Peek("X-Forwarded-For")),
|
||||
"user_id": userID,
|
||||
"op_type": opType,
|
||||
"op_name": opName,
|
||||
"time": duration,
|
||||
"cache": wasCached,
|
||||
"request_uuid": c.Locals("request_uuid"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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: 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: 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: proxy
|
||||
port: 8181
|
||||
targetPort: 8181
|
||||
- name: monitoring
|
||||
port: 9393
|
||||
targetPort: 9393
|
||||
selector:
|
||||
app: hasura-w-proxy-internal
|
||||
type: support
|
||||
type: ClusterIP
|
||||
+18
-7
@@ -15,12 +15,17 @@ type config struct {
|
||||
|
||||
// Server holds the configuration of the server _ONLY_.
|
||||
Server struct {
|
||||
PortGraphQL int
|
||||
PortMonitoring int
|
||||
HostGraphQL string
|
||||
AccessLog bool
|
||||
ReadOnlyMode bool
|
||||
AllowURLs []string
|
||||
PortGraphQL int
|
||||
PortMonitoring int
|
||||
HostGraphQL string
|
||||
HealthcheckGraphQL string
|
||||
AccessLog bool
|
||||
ReadOnlyMode bool
|
||||
AllowURLs []string
|
||||
EnableApi bool
|
||||
ApiPort int
|
||||
PurgeOnCrawl bool
|
||||
PurgeEvery int
|
||||
}
|
||||
|
||||
Client struct {
|
||||
@@ -31,6 +36,7 @@ type config struct {
|
||||
GQLClient *graphql.BaseClient
|
||||
FastProxyClient *fasthttp.Client
|
||||
proxy string
|
||||
ClientTimeout int
|
||||
}
|
||||
|
||||
Cache struct {
|
||||
@@ -39,7 +45,12 @@ type config struct {
|
||||
CacheClient *libpack_cache.Cache
|
||||
}
|
||||
|
||||
Api struct {
|
||||
BannedUsersFile string
|
||||
}
|
||||
|
||||
Security struct {
|
||||
BlockIntrospection bool
|
||||
BlockIntrospection bool
|
||||
IntrospectionAllowed []string
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user