diff --git a/README.md b/README.md index 41297ca..72d1972 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -111,6 +113,7 @@ You can still use the non-prefixed environment variables in the spirit of the ba | `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 | `` | @@ -156,6 +159,12 @@ query MyProducts @cached(refresh: true) { 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 diff --git a/graphql.go b/graphql.go index 25fd1c8..bf1ac0a 100644 --- a/graphql.go +++ b/graphql.go @@ -56,13 +56,14 @@ func prepareQueriesAndExemptions() { } type parseGraphQLQueryResult struct { - operationType string - operationName string - cacheTime int - cacheRequest bool - cacheRefresh bool - shouldBlock bool - shouldIgnore bool + operationType string + operationName string + activeEndpoint string + cacheTime int + cacheRequest bool + cacheRefresh bool + shouldBlock bool + shouldIgnore bool } func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) { @@ -70,7 +71,7 @@ func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) { 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())}) + 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) } @@ -97,15 +98,23 @@ func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) { res.shouldIgnore = false res.operationName = "undefined" + res.activeEndpoint = cfg.Server.HostGraphQL + for _, d := range p.Definitions { if oper, ok := d.(*ast.OperationDefinition); ok { - res.operationType = oper.Operation + res.operationType = strings.ToLower(oper.Operation) if oper.Name != nil { res.operationName = oper.Name.Value } - if strings.ToLower(res.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 ifNotInTest() { cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil) diff --git a/main.go b/main.go index a5f53f0..c6e026f 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,7 @@ func parseConfig() { 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", "") diff --git a/proxy.go b/proxy.go index b8a3386..6830e13 100644 --- a/proxy.go +++ b/proxy.go @@ -28,7 +28,7 @@ func createFasthttpClient(timeout int) *fasthttp.Client { } } -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()}) if ifNotInTest() { @@ -46,7 +46,7 @@ func proxyTheRequest(c *fiber.Ctx) error { err := retry.Do( func() error { - errInt := proxy.DoRedirects(c, cfg.Server.HostGraphQL+c.Path(), 3, cfg.Client.FastProxyClient) + 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() { diff --git a/proxy_test.go b/proxy_test.go index 5918d66..69fb276 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -12,37 +12,48 @@ func (suite *Tests) Test_proxyTheRequest() { } tests := []struct { - name string - query string - host string - path string headers map[string]string + name string + body string + host string + hostRO string + path string wantErr bool }{ { - name: "test_empty", - query: `query { - __type(name: "Query") { - name - } - }`, + 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", - query: `query { - __type(name: "Query") { - name - } - }`, + 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 { @@ -52,6 +63,10 @@ func (suite *Tests) Test_proxyTheRequest() { 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 { @@ -63,15 +78,15 @@ func (suite *Tests) Test_proxyTheRequest() { 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) + err := proxyTheRequest(ctx, res.activeEndpoint) if tt.wantErr { assert.NotNil(err, "Error is nil", tt.name) } else { diff --git a/server.go b/server.go index 0da9986..07bd636 100644 --- a/server.go +++ b/server.go @@ -37,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)) @@ -46,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() @@ -118,7 +122,7 @@ func processGraphQLRequest(c *fiber.Ctx) error { if parsedResult.shouldIgnore { cfg.Logger.Debug("Request passed as-is - probably not a GraphQL") - return proxyTheRequest(c) + return proxyTheRequest(c, parsedResult.activeEndpoint) } if parsedResult.cacheTime > 0 { @@ -153,10 +157,10 @@ func processGraphQLRequest(c *fiber.Ctx) error { 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, parsedResult.cacheTime) + proxyAndCacheTheRequest(c, queryCacheHash, parsedResult.cacheTime, parsedResult.activeEndpoint) } } else { - proxyTheRequest(c) + proxyTheRequest(c, parsedResult.activeEndpoint) } timeTaken := time.Since(startTime) @@ -168,8 +172,8 @@ func processGraphQLRequest(c *fiber.Ctx) error { } // Additional helper function to avoid code repetition -func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime 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) diff --git a/static/kubernetes-single-deployment-with-ro.yaml b/static/kubernetes-single-deployment-with-ro.yaml new file mode 100644 index 0000000..f7b36b7 --- /dev/null +++ b/static/kubernetes-single-deployment-with-ro.yaml @@ -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 diff --git a/struct_config.go b/struct_config.go index bdaed9d..81a2c66 100644 --- a/struct_config.go +++ b/struct_config.go @@ -33,16 +33,17 @@ type config struct { BlockIntrospection bool } Server struct { - HostGraphQL string - HealthcheckGraphQL string - AllowURLs []string - PortGraphQL int - PortMonitoring int - ApiPort int - PurgeEvery int - AccessLog bool - ReadOnlyMode bool - EnableApi bool - PurgeOnCrawl bool + HostGraphQL string + HostGraphQLReadOnly string + HealthcheckGraphQL string + AllowURLs []string + PortGraphQL int + PortMonitoring int + ApiPort int + PurgeEvery int + AccessLog bool + ReadOnlyMode bool + EnableApi bool + PurgeOnCrawl bool } }