diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 68898005..dc4b012e 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -51,7 +51,7 @@ jobs: needs: - draft-release env: - X_GO_DISTRIBUTION: "https://go.dev/dl/go1.24.11.linux-amd64.tar.gz" + X_GO_DISTRIBUTION: "https://go.dev/dl/go1.25.8.linux-amd64.tar.gz" APIFIREWALL_NAMESPACE: "github.com/wallarm/api-firewall" strategy: matrix: @@ -162,7 +162,7 @@ jobs: needs: - draft-release env: - X_GO_VERSION: "1.24.11" + X_GO_VERSION: "1.25.8" APIFIREWALL_NAMESPACE: "github.com/wallarm/api-firewall" strategy: matrix: @@ -181,7 +181,7 @@ jobs: - uses: addnab/docker-run-action@v3 with: - image: golang:${{ env.X_GO_VERSION }}-alpine3.22 + image: golang:${{ env.X_GO_VERSION }}-alpine3.23 options: > --volume ${{ github.workspace }}:/build --workdir /build @@ -272,19 +272,19 @@ jobs: include: - arch: armv6 distro: bookworm - go_distribution: https://go.dev/dl/go1.24.11.linux-armv6l.tar.gz + go_distribution: https://go.dev/dl/go1.25.8.linux-armv6l.tar.gz artifact: armv6-libc - arch: aarch64 distro: bookworm - go_distribution: https://go.dev/dl/go1.24.11.linux-arm64.tar.gz + go_distribution: https://go.dev/dl/go1.25.8.linux-arm64.tar.gz artifact: arm64-libc - arch: armv6 distro: alpine_latest - go_distribution: https://go.dev/dl/go1.24.11.linux-armv6l.tar.gz + go_distribution: https://go.dev/dl/go1.25.8.linux-armv6l.tar.gz artifact: armv6-musl - arch: aarch64 distro: alpine_latest - go_distribution: https://go.dev/dl/go1.24.11.linux-arm64.tar.gz + go_distribution: https://go.dev/dl/go1.25.8.linux-arm64.tar.gz artifact: arm64-musl steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 508160d0..e37dd27a 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -30,7 +30,7 @@ jobs: docker build -t wallarm/api-firewall:${{ github.sha }} . - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@v0.35.0 with: image-ref: 'wallarm/api-firewall:${{ github.sha }}' format: 'sarif' diff --git a/.gitignore b/.gitignore index 27028b93..ef15cd6e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ vendor/ .DS_Store .idea/ /dev/ + +# Claude Code configuration +CLAUDE.md +.claude/ diff --git a/Dockerfile b/Dockerfile index dc0499ed..0762a0a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24-alpine3.22 AS build +FROM golang:1.25-alpine3.23 AS build ARG APIFIREWALL_NAMESPACE ARG APIFIREWALL_VERSION @@ -24,7 +24,7 @@ RUN go mod download -x && \ # Smoke test RUN ./api-firewall -v -FROM alpine:3.22 AS composer +FROM alpine:3.23 AS composer WORKDIR /output @@ -34,7 +34,7 @@ COPY docker-entrypoint.sh ./usr/local/bin/docker-entrypoint.sh RUN chmod 755 ./usr/local/bin/* && \ chown root:root ./usr/local/bin/* -FROM alpine:3.22 +FROM alpine:3.23 RUN adduser -u 1000 -H -h /opt -D -s /bin/sh api-firewall diff --git a/Makefile b/Makefile index 8ea318b7..d9fcadaf 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION := 0.9.5 +VERSION := 0.9.6 NAMESPACE := github.com/wallarm/api-firewall .DEFAULT_GOAL := build diff --git a/cmd/api-firewall/internal/handlers/graphql/run.go b/cmd/api-firewall/internal/handlers/graphql/run.go index 9f59b6ee..3b1ac2b7 100644 --- a/cmd/api-firewall/internal/handlers/graphql/run.go +++ b/cmd/api-firewall/internal/handlers/graphql/run.go @@ -24,9 +24,8 @@ import ( const ( logPrefix = "main" - initialPoolCapacity = 100 - livenessEndpoint = "/v1/liveness" - readinessEndpoint = "/v1/readiness" + livenessEndpoint = "/v1/liveness" + readinessEndpoint = "/v1/readiness" ) func Run(logger zerolog.Logger) error { @@ -113,28 +112,20 @@ func Run(logger zerolog.Logger) error { } } - initialCap := initialPoolCapacity - - if cfg.Server.ClientPoolCapacity < initialPoolCapacity { - initialCap = 1 - } - - options := proxy.Options{ - InitialPoolCapacity: initialCap, - ClientPoolCapacity: cfg.Server.ClientPoolCapacity, - InsecureConnection: cfg.Server.InsecureConnection, - RootCA: cfg.Server.RootCA, + pool, err := proxy.NewPoolV2(host, &proxy.PoolV2Options{ MaxConnsPerHost: cfg.Server.MaxConnsPerHost, + MaxIdleConnDuration: cfg.Server.MaxIdleConnDuration, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, + DialTimeout: cfg.Server.DialTimeout, ReadBufferSize: cfg.Server.ReadBufferSize, WriteBufferSize: cfg.Server.WriteBufferSize, MaxResponseBodySize: cfg.Server.MaxResponseBodySize, - DialTimeout: cfg.Server.DialTimeout, + InsecureConnection: cfg.Server.InsecureConnection, + RootCA: cfg.Server.RootCA, + HealthCheckInterval: cfg.Server.HealthCheckInterval, Logger: logger, - } - - pool, err := proxy.NewChanPool(host, &options) + }) if err != nil { return errors.Wrap(err, "proxy pool init") } diff --git a/cmd/api-firewall/internal/handlers/proxy/run.go b/cmd/api-firewall/internal/handlers/proxy/run.go index 96a48e7e..5b49189f 100644 --- a/cmd/api-firewall/internal/handlers/proxy/run.go +++ b/cmd/api-firewall/internal/handlers/proxy/run.go @@ -1,9 +1,7 @@ package proxy import ( - "context" "mime" - "net" "net/url" "os" "os/signal" @@ -29,9 +27,8 @@ import ( ) const ( - initialPoolCapacity = 100 - livenessEndpoint = "/v1/liveness" - readinessEndpoint = "/v1/readiness" + livenessEndpoint = "/v1/liveness" + readinessEndpoint = "/v1/readiness" ) func Run(logger zerolog.Logger) error { @@ -145,63 +142,20 @@ func Run(logger zerolog.Logger) error { } } - initialCap := initialPoolCapacity - - if cfg.Server.ClientPoolCapacity < initialPoolCapacity { - initialCap = 1 - } - - // default DNS resolver - resolver := &net.Resolver{ - PreferGo: true, - StrictErrors: false, - } - - // configuration of the custom DNS server - if cfg.DNS.Nameserver.Host != "" { - var builder strings.Builder - builder.WriteString(cfg.DNS.Nameserver.Host) - builder.WriteString(":") - builder.WriteString(cfg.DNS.Nameserver.Port) - - resolver.Dial = func(ctx context.Context, network, address string) (net.Conn, error) { - d := net.Dialer{ - Timeout: cfg.DNS.LookupTimeout, - } - return d.DialContext(ctx, cfg.DNS.Nameserver.Proto, builder.String()) - } - } - - // init DNS resolver - dnsCacheOptions := proxy.DNSCacheOptions{ - UseCache: cfg.DNS.Cache, - Logger: logger, - FetchTimeout: cfg.DNS.FetchTimeout, - LookupTimeout: cfg.DNS.LookupTimeout, - } - - dnsResolver, err := proxy.NewDNSResolver(resolver, &dnsCacheOptions) - if err != nil { - return errors.Wrap(err, "DNS cache resolver init") - } - - options := proxy.Options{ - InitialPoolCapacity: initialCap, - ClientPoolCapacity: cfg.Server.ClientPoolCapacity, - InsecureConnection: cfg.Server.InsecureConnection, - RootCA: cfg.Server.RootCA, + pool, err := proxy.NewPoolV2(host, &proxy.PoolV2Options{ MaxConnsPerHost: cfg.Server.MaxConnsPerHost, + MaxIdleConnDuration: cfg.Server.MaxIdleConnDuration, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, + DialTimeout: cfg.Server.DialTimeout, ReadBufferSize: cfg.Server.ReadBufferSize, WriteBufferSize: cfg.Server.WriteBufferSize, MaxResponseBodySize: cfg.Server.MaxResponseBodySize, - DialTimeout: cfg.Server.DialTimeout, - DNSConfig: cfg.DNS, + InsecureConnection: cfg.Server.InsecureConnection, + RootCA: cfg.Server.RootCA, + HealthCheckInterval: cfg.Server.HealthCheckInterval, Logger: logger, - DNSResolver: dnsResolver, - } - pool, err := proxy.NewChanPool(host, &options) + }) if err != nil { return errors.Wrap(err, "proxy pool init") } diff --git a/cmd/api-firewall/tests/main_graphql_bench_test.go b/cmd/api-firewall/tests/main_graphql_bench_test.go index e4b79956..82fe2c60 100644 --- a/cmd/api-firewall/tests/main_graphql_bench_test.go +++ b/cmd/api-firewall/tests/main_graphql_bench_test.go @@ -3,7 +3,6 @@ package tests import ( "bytes" "errors" - "net" "net/http" "net/url" "os" @@ -126,38 +125,14 @@ func BenchmarkGraphQL(b *testing.B) { } host := serverURL.Host - initialCap := 100 - - // default DNS resolver - resolver := &net.Resolver{ - PreferGo: true, - StrictErrors: false, - } - - // init DNS resolver - dnsCacheOptions := proxy.DNSCacheOptions{ - UseCache: false, - Logger: logger, - LookupTimeout: 1000 * time.Millisecond, - } - - dnsResolver, err := proxy.NewDNSResolver(resolver, &dnsCacheOptions) - if err != nil { - b.Fatal(err, "DNS cache resolver init") - } - - options := proxy.Options{ - InitialPoolCapacity: initialCap, - ClientPoolCapacity: 1000, - InsecureConnection: true, - MaxConnsPerHost: 512, - ReadTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, - DialTimeout: 5 * time.Second, - DNSResolver: dnsResolver, - Logger: logger, - } - pool, err := proxy.NewChanPool(host, &options) + pool, err := proxy.NewPoolV2(host, &proxy.PoolV2Options{ + InsecureConnection: true, + MaxConnsPerHost: 512, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + DialTimeout: 5 * time.Second, + Logger: logger, + }) if err != nil { b.Fatalf("proxy pool init: %v", err) } diff --git a/cmd/api-firewall/tests/main_test.go b/cmd/api-firewall/tests/main_test.go index 143703d9..cc7e752c 100644 --- a/cmd/api-firewall/tests/main_test.go +++ b/cmd/api-firewall/tests/main_test.go @@ -328,7 +328,7 @@ var testSupportedEncodingSchemas = []string{"gzip", "deflate", "br"} const ( testOauthBearerToken = "testtesttest" testOauthJWTTokenRS = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJqd3QudGVzdC5naXRodWIuaW8iLCJzdWIiOiJldmFuZGVyIiwiYXVkIjoibmFpbWlzaCIsImlhdCI6MTYzODUwNjIxNywiZXhwIjozNTMxOTM3ODc1LCJzY29wZSI6InJlYWQgd3JpdGUifQ.MPC35ZX52qWE4AktY1Bs-HVEWUUYrByfRVUSL9GbzZhZfXlfcNkF-qNRK_EDG2eviE4UHb6CFVZeYTsO5MyKg0H3shp79LeZTA2XzCuCZvzAqA7EQrpUKiKof-9af5g3jIRU4YFxvtpp8XxXGHaMvbIy4gqQJ7WEsOksYOytEsbLtsCs880zxCJb1iM4Bu9Q_Nl-wW1NeYSZyHYZP7es7gVvb9Bbm6qYW4qcVbt20pW4dguBGEvUvLM6axqeTZe7JgtqU__uUwkcIS6bu711Y7Zi-TpeZAMp506Wx8qZrhi7Ea0QFZUMjoF0O7jgRtps_BlbqBXNoleMO-kKnSkd6A" - testOauthJWTTokenHS = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2Mzg1MDU4OTYsImV4cCI6MTc3MDA0MTg5NiwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjoiTWFuYWdlciIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.GgtDHEjw_zCbzcYR0rxrC-A2QKDeSpif7QBhCUlmqdk" + testOauthJWTTokenHS = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJSb2xlIjoiTWFuYWdlciIsIlN1cm5hbWUiOiJSb2NrZXQiLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJleHAiOjIwOTU4OTEyMDAsImlhdCI6MTYzODUwNTg5NiwiaXNzIjoiT25saW5lIEpXVCBCdWlsZGVyIiwic2NvcGUiOiJyZWFkIHdyaXRlIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSJ9.zj2pfsriqlnWR9-x2lKHP4hyE6zJ4fskhgeZbbW0no4" testOauthJWTKeyHS = "qwertyuiopasdfghjklzxcvbnm123456" testContentType = "test" @@ -1506,7 +1506,7 @@ func (s *ServiceTests) testOauthIntrospectionReadSuccess(t *testing.T) { serverConf := config.Backend{ ProtectedAPI: config.ProtectedAPI{ URL: "", - ClientPoolCapacity: 1000, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -1593,7 +1593,7 @@ func (s *ServiceTests) testOauthIntrospectionReadUnsuccessful(t *testing.T) { serverConf := config.Backend{ ProtectedAPI: config.ProtectedAPI{ URL: "", - ClientPoolCapacity: 1000, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -1659,7 +1659,7 @@ func (s *ServiceTests) testOauthIntrospectionInvalidResponse(t *testing.T) { serverConf := config.Backend{ ProtectedAPI: config.ProtectedAPI{ URL: "", - ClientPoolCapacity: 1000, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -1725,7 +1725,7 @@ func (s *ServiceTests) testOauthIntrospectionReadWriteSuccess(t *testing.T) { serverConf := config.Backend{ ProtectedAPI: config.ProtectedAPI{ URL: "", - ClientPoolCapacity: 1000, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -1796,7 +1796,7 @@ func (s *ServiceTests) testOauthIntrospectionContentTypeRequest(t *testing.T) { serverConf := config.Backend{ ProtectedAPI: config.ProtectedAPI{ URL: "", - ClientPoolCapacity: 1000, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -1859,7 +1859,7 @@ func (s *ServiceTests) testOauthJWTRS256(t *testing.T) { serverConf := config.Backend{ ProtectedAPI: config.ProtectedAPI{ URL: "", - ClientPoolCapacity: 1000, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -1936,7 +1936,7 @@ func (s *ServiceTests) testOauthJWTHS256(t *testing.T) { serverConf := config.Backend{ ProtectedAPI: config.ProtectedAPI{ URL: "", - ClientPoolCapacity: 1000, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -2843,7 +2843,7 @@ func (s *ServiceTests) testCustomHostHeader(t *testing.T) { ProtectedAPI: config.ProtectedAPI{ URL: "http://localhost:28290", RequestHostHeader: "testCustomHost", - ClientPoolCapacity: 1000, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -2864,24 +2864,13 @@ func (s *ServiceTests) testCustomHostHeader(t *testing.T) { Server: serverConf, } - ipAddrs := []net.IPAddr{} - ipAddrs = append(ipAddrs, net.IPAddr{IP: net.IPv4(127, 0, 0, 1), Zone: ""}) - - s.dnsCache.EXPECT().LookupIPAddr(gomock.Any(), gomock.Any()).Return(ipAddrs, nil).Times(2) - - options := proxy.Options{ - InitialPoolCapacity: 1, - ClientPoolCapacity: cfg.Server.ClientPoolCapacity, - InsecureConnection: cfg.Server.InsecureConnection, - RootCA: cfg.Server.RootCA, - MaxConnsPerHost: cfg.Server.MaxConnsPerHost, - ReadTimeout: cfg.Server.ReadTimeout, - WriteTimeout: cfg.Server.WriteTimeout, - DialTimeout: cfg.Server.DialTimeout, - DNSResolver: s.dnsCache, - Logger: s.logger, - } - pool, err := proxy.NewChanPool("localhost:28290", &options) + pool, err := proxy.NewPoolV2("localhost:28290", &proxy.PoolV2Options{ + MaxConnsPerHost: cfg.Server.MaxConnsPerHost, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + DialTimeout: cfg.Server.DialTimeout, + Logger: s.logger, + }) if err != nil { t.Fatal(err) } @@ -2941,7 +2930,7 @@ func (s *ServiceTests) testDNSCacheFetch(t *testing.T) { ProtectedAPI: config.ProtectedAPI{ URL: "http://localhost:28290", RequestHostHeader: "testCustomHost", - ClientPoolCapacity: 1, + InsecureConnection: false, RootCA: "", MaxConnsPerHost: 512, @@ -2966,27 +2955,13 @@ func (s *ServiceTests) testDNSCacheFetch(t *testing.T) { }, } - options := proxy.Options{ - InitialPoolCapacity: 1, - ClientPoolCapacity: cfg.Server.ClientPoolCapacity, - InsecureConnection: cfg.Server.InsecureConnection, - RootCA: cfg.Server.RootCA, - MaxConnsPerHost: cfg.Server.MaxConnsPerHost, - ReadTimeout: cfg.Server.ReadTimeout, - WriteTimeout: cfg.Server.WriteTimeout, - DialTimeout: cfg.Server.DialTimeout, - DNSConfig: cfg.DNS, - DNSResolver: s.dnsCache, - Logger: s.logger, - } - - localIP := net.ParseIP("127.0.0.1") - ipAddrs := []net.IPAddr{} - ipAddrs = append(ipAddrs, net.IPAddr{IP: localIP, Zone: ""}) - - s.dnsCache.EXPECT().LookupIPAddr(gomock.Any(), gomock.Any()).Return(ipAddrs, nil).Times(3) - - pool, err := proxy.NewChanPool("localhost:28290", &options) + pool, err := proxy.NewPoolV2("localhost:28290", &proxy.PoolV2Options{ + MaxConnsPerHost: cfg.Server.MaxConnsPerHost, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + DialTimeout: cfg.Server.DialTimeout, + Logger: s.logger, + }) if err != nil { t.Fatal(err) } diff --git a/cmd/api-firewall/tests/wallarm_api2_update.db b/cmd/api-firewall/tests/wallarm_api2_update.db index e258ef34..d95120dd 100644 Binary files a/cmd/api-firewall/tests/wallarm_api2_update.db and b/cmd/api-firewall/tests/wallarm_api2_update.db differ diff --git a/demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml b/demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml index 1b686e62..7b4f7109 100644 --- a/demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml +++ b/demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.5 + image: wallarm/api-firewall:v0.9.6 restart: on-failure environment: APIFW_URL: "http://0.0.0.0:8080" diff --git a/demo/docker-compose/docker-compose-api-mode.yml b/demo/docker-compose/docker-compose-api-mode.yml index 98ff2c9a..bd1e42fb 100644 --- a/demo/docker-compose/docker-compose-api-mode.yml +++ b/demo/docker-compose/docker-compose-api-mode.yml @@ -2,7 +2,7 @@ version: '3.8' services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.5 + image: wallarm/api-firewall:v0.9.6 restart: on-failure environment: APIFW_MODE: "api" diff --git a/demo/docker-compose/docker-compose-graphql-mode.yml b/demo/docker-compose/docker-compose-graphql-mode.yml index 89c66208..828d2ba0 100644 --- a/demo/docker-compose/docker-compose-graphql-mode.yml +++ b/demo/docker-compose/docker-compose-graphql-mode.yml @@ -2,7 +2,7 @@ version: '3.8' services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.5 + image: wallarm/api-firewall:v0.9.6 restart: on-failure environment: APIFW_MODE: "graphql" diff --git a/demo/docker-compose/docker-compose.yml b/demo/docker-compose/docker-compose.yml index 22449f71..3691d88e 100644 --- a/demo/docker-compose/docker-compose.yml +++ b/demo/docker-compose/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.5 + image: wallarm/api-firewall:v0.9.6 restart: on-failure environment: APIFW_URL: "http://0.0.0.0:8080" diff --git a/demo/kubernetes/volumes/helm/api-firewall.yaml b/demo/kubernetes/volumes/helm/api-firewall.yaml index f53b299d..2383bfc9 100644 --- a/demo/kubernetes/volumes/helm/api-firewall.yaml +++ b/demo/kubernetes/volumes/helm/api-firewall.yaml @@ -10,7 +10,7 @@ manifest: "url": "https://kennethreitz.org", "email": "me@kennethreitz.org" }, - "version": "0.9.5" + "version": "0.9.6" }, "servers": [ { diff --git a/docs/configuration-guides/allowlist.md b/docs/configuration-guides/allowlist.md index 4a17dfc2..40a4d1a0 100644 --- a/docs/configuration-guides/allowlist.md +++ b/docs/configuration-guides/allowlist.md @@ -33,7 +33,7 @@ docker run --rm -it --network api-firewall-network --network-alias api-firewall -e APIFW_URL= -e APIFW_SERVER_URL= \ -e APIFW_REQUEST_VALIDATION= -e APIFW_RESPONSE_VALIDATION= \ -e APIFW_ALLOW_IP_FILE=/opt/ip-allowlist.txt -e APIFW_ALLOW_IP_HEADER_NAME="X-Real-IP" \ - -p 8088:8088 wallarm/api-firewall:v0.9.5 + -p 8088:8088 wallarm/api-firewall:v0.9.6 ``` | Environment variable | Description | diff --git a/docs/configuration-guides/system-settings.md b/docs/configuration-guides/system-settings.md index 3cf7c582..379a4d37 100644 --- a/docs/configuration-guides/system-settings.md +++ b/docs/configuration-guides/system-settings.md @@ -10,7 +10,6 @@ To fine-tune system API Firewall settings, use the following optional environmen | `APIFW_SERVER_READ_TIMEOUT`
(for [`PROXY`](../installation-guides/docker-container.md) and [`graphql`](../installation-guides/graphql/docker-container.md) modes) | Backend → ProtectedAPI → `ReadTimeout` | The timeout for API Firewall to read the full response (including the body) returned to the request by the application. The default value is `5s`. | | `APIFW_SERVER_WRITE_TIMEOUT`
(for [`PROXY`](../installation-guides/docker-container.md) and [`graphql`](../installation-guides/graphql/docker-container.md) modes) | Backend → ProtectedAPI → `WriteTimeout` | The timeout for API Firewall to write the full request (including the body) to the application. The default value is `5s`. | | `APIFW_SERVER_DIAL_TIMEOUT`
(for [`PROXY`](../installation-guides/docker-container.md) and [`graphql`](../installation-guides/graphql/docker-container.md) modes) | `DialTimeout` | The timeout for API Firewall to connect to the application. The default value is `200ms`. | -| `APIFW_SERVER_CLIENT_POOL_CAPACITY`
(for [`PROXY`](../installation-guides/docker-container.md) and [`graphql`](../installation-guides/graphql/docker-container.md) modes) | `ClientPoolCapacity` | Maximum number of the fasthttp clients. The default value is `1000`. | | `APIFW_HEALTH_HOST` | HealthAPIHost | The host of the health check service. The default value is `0.0.0.0:9667`. The liveness probe service path is `/v1/liveness` and the readiness service path is `/v1/readiness`. | | `APIFW_READ_BUFFER_SIZE`
(for APIFW server) | `ReadBufferSize` | Per-connection buffer size for request reading. This also limits the maximum header size. Increase this buffer if your clients send multi-KB RequestURIs and/or multi-KB headers (for example, BIG cookies). The default value is `8192`. | | `APIFW_WRITE_BUFFER_SIZE`
(for APIFW server) | `WriteBufferSize` | Per-connection buffer size for response writing. The default value is `8192`. | @@ -21,6 +20,8 @@ To fine-tune system API Firewall settings, use the following optional environmen | `APIFW_SERVER_READ_BUFFER_SIZE`
(for HTTP client sending requests) | `ReadBufferSize` | Per-connection buffer size for request reading. This also limits the maximum header size. The default value is `8192`. | | `APIFW_SERVER_WRITE_BUFFER_SIZE`
(for HTTP client sending requests) | `WriteBufferSize` | Per-connection buffer size for response writing. The default value is `8192`. | | `APIFW_SERVER_MAX_RESPONSE_BODY_SIZE`
(for HTTP client sending requests) | `MaxResponseBodySize` | Maximum response body size. The default value is `0` (means "unlimited"). | +| `APIFW_SERVER_HEALTH_CHECK_INTERVAL`
(for [`PROXY`](../installation-guides/docker-container.md) and [`graphql`](../installation-guides/graphql/docker-container.md) modes) | Backend → ProtectedAPI → `HealthCheckInterval` | Health check interval for backend servers. The default value is `30s`. | +| `APIFW_SERVER_MAX_IDLE_CONN_DURATION`
(for [`PROXY`](../installation-guides/docker-container.md) and [`graphql`](../installation-guides/graphql/docker-container.md) modes) | Backend → ProtectedAPI → `MaxIdleConnDuration` | Maximum duration for keeping idle connections alive. The default value is `10s`. | ??? info "Example of `apifw.yaml`" diff --git a/docs/include/apifw-yaml-example.md b/docs/include/apifw-yaml-example.md index 3c75b0a8..04c1f30c 100644 --- a/docs/include/apifw-yaml-example.md +++ b/docs/include/apifw-yaml-example.md @@ -72,7 +72,6 @@ Backend: ProtectedAPI: URL: "http://localhost:3000/v1/" RequestHostHeader: "" - ClientPoolCapacity: 1000 InsecureConnection: false RootCA: "" MaxConnsPerHost: 512 @@ -83,4 +82,6 @@ Backend: WriteBufferSize: 8192 MaxResponseBodySize: 0 DeleteAcceptEncoding: false + HealthCheckInterval: "30s" + MaxIdleConnDuration: "10s" ``` \ No newline at end of file diff --git a/docs/installation-guides/api-mode.md b/docs/installation-guides/api-mode.md index 970f3868..8f28ece0 100644 --- a/docs/installation-guides/api-mode.md +++ b/docs/installation-guides/api-mode.md @@ -38,7 +38,7 @@ Use the following command to run the API Firewall container: ``` docker run --rm -it -v :/var/lib/wallarm-api/1/wallarm_api.db \ - -e APIFW_MODE=API -p 8282:8282 wallarm/api-firewall:v0.9.5 + -e APIFW_MODE=API -p 8282:8282 wallarm/api-firewall:v0.9.6 ``` You can pass to the container the following variables: diff --git a/docs/installation-guides/docker-container.md b/docs/installation-guides/docker-container.md index e9aa6ab7..6fc72397 100644 --- a/docs/installation-guides/docker-container.md +++ b/docs/installation-guides/docker-container.md @@ -27,7 +27,7 @@ networks: services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.5 + image: wallarm/api-firewall:v0.9.6 restart: on-failure volumes: - : @@ -171,6 +171,6 @@ To start API Firewall on Docker, you can also use regular Docker commands as in -v : -e APIFW_API_SPECS= \ -e APIFW_URL= -e APIFW_SERVER_URL= \ -e APIFW_REQUEST_VALIDATION= -e APIFW_RESPONSE_VALIDATION= \ - -p 8088:8088 wallarm/api-firewall:v0.9.5 + -p 8088:8088 wallarm/api-firewall:v0.9.6 ``` 4. When the environment is started, test it and enable traffic on API Firewall following steps 6 and 7. diff --git a/docs/installation-guides/graphql/docker-container.md b/docs/installation-guides/graphql/docker-container.md index a0c14c09..12434edd 100644 --- a/docs/installation-guides/graphql/docker-container.md +++ b/docs/installation-guides/graphql/docker-container.md @@ -29,7 +29,7 @@ networks: services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.5 + image: wallarm/api-firewall:v0.9.6 restart: on-failure volumes: - : @@ -200,6 +200,6 @@ To start API Firewall on Docker, you can also use regular Docker commands as in -e APIFW_GRAPHQL_MAX_QUERY_COMPLEXITY= \ -e APIFW_GRAPHQL_MAX_QUERY_DEPTH= -e APIFW_GRAPHQL_NODE_COUNT_LIMIT= \ -e APIFW_GRAPHQL_INTROSPECTION= \ - -p 8088:8088 wallarm/api-firewall:v0.9.5 + -p 8088:8088 wallarm/api-firewall:v0.9.6 ``` 4. When the environment is started, test it and enable traffic on API Firewall following steps 6 and 7. diff --git a/docs/release-notes.md b/docs/release-notes.md index 9c813ca8..7d14e01c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,6 +2,13 @@ This page describes new releases of Wallarm API Firewall. +## v0.9.6 (2026-04-03) + +* Upgrade Go to 1.25.8 +* Upgrade dependencies +* Replace ChanPool with lock-free PoolV2 using fasthttp internal pooling +* Bump Alpine version to 3.23 + ## v0.9.5 (2025-12-05) * Upgrade Go to 1.24.11 diff --git a/go.mod b/go.mod index e4f28621..e5b883ab 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,34 @@ module github.com/wallarm/api-firewall -go 1.24.10 +go 1.25.8 require ( - github.com/andybalholm/brotli v1.2.0 + github.com/andybalholm/brotli v1.2.1 github.com/ardanlabs/conf v1.5.0 github.com/clbanning/mxj/v2 v2.7.0 - github.com/corazawaf/coraza/v3 v3.3.3 + github.com/corazawaf/coraza/v3 v3.6.0 github.com/dgraph-io/ristretto v0.2.0 github.com/fasthttp/websocket v1.5.12 - github.com/foxcpp/go-mockdns v1.1.0 - github.com/gabriel-vasile/mimetype v1.4.11 + github.com/foxcpp/go-mockdns v1.2.0 + github.com/gabriel-vasile/mimetype v1.4.13 github.com/getkin/kin-openapi v0.133.0 github.com/go-playground/validator v9.31.0+incompatible - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/mock v1.6.0 github.com/google/uuid v1.6.0 github.com/karlseguin/ccache/v2 v2.0.8 - github.com/klauspost/compress v1.18.1 - github.com/mattn/go-sqlite3 v1.14.32 + github.com/klauspost/compress v1.18.5 + github.com/mattn/go-sqlite3 v1.14.38 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 - github.com/rs/zerolog v1.34.0 + github.com/rs/zerolog v1.35.0 github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/valyala/fasthttp v1.68.0 - github.com/valyala/fastjson v1.6.4 + github.com/valyala/fasthttp v1.69.0 + github.com/valyala/fastjson v1.6.10 github.com/wundergraph/graphql-go-tools v1.67.4 - golang.org/x/sync v0.18.0 + golang.org/x/sync v0.20.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -37,9 +37,9 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/buger/jsonparser v1.1.1 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/corazawaf/libinjection-go v0.2.3 // indirect + github.com/corazawaf/libinjection-go v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect @@ -49,8 +49,12 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect + github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/imdario/mergo v0.3.16 // indirect @@ -58,12 +62,14 @@ require ( github.com/jensneuse/byte-template v0.0.0-20231025215717-69252eb3ed56 // indirect github.com/jensneuse/pipeline v0.0.0-20200117120358-9fb4de085cd6 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/kaptinlin/go-i18n v0.1.4 // indirect + github.com/kaptinlin/jsonschema v0.4.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516 // indirect + github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/miekg/dns v1.1.68 // indirect + github.com/miekg/dns v1.1.69 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -99,14 +105,14 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.39.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.43.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect nhooyr.io/websocket v1.8.17 // indirect diff --git a/go.sum b/go.sum index 8283799a..65f1cfdd 100644 --- a/go.sum +++ b/go.sum @@ -10,25 +10,24 @@ github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZC github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/ardanlabs/conf v1.5.0 h1:5TwP6Wu9Xi07eLFEpiCUF3oQXh9UzHMDVnD3u/I5d5c= github.com/ardanlabs/conf v1.5.0/go.mod h1:ILsMo9dMqYzCxDjDXTiwMI0IgxOJd0MOiucbQY2wlJw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -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/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU= -github.com/corazawaf/coraza/v3 v3.3.3 h1:kqjStHAgWqwP5dh7n0vhTOF0a3t+VikNS/EaMiG0Fhk= -github.com/corazawaf/coraza/v3 v3.3.3/go.mod h1:xSaXWOhFMSbrV8qOOfBKAyw3aOqfwaSaOy5BgSF8XlA= -github.com/corazawaf/libinjection-go v0.2.3 h1:rKiRM8sqbb2A+brb5N8wQ2gyg4oL0tsr3rcMJwVB5Yc= -github.com/corazawaf/libinjection-go v0.2.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/corazawaf/coraza/v3 v3.6.0 h1:rfsGl6eRBzzUAyADFcpuO7qXLt0DZtYWhfTIuhcyAjQ= +github.com/corazawaf/coraza/v3 v3.6.0/go.mod h1:q7gszZCSufoHIy9jV2NCgk+glYwZpP2mIKgbu2dZkvE= +github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI= +github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -45,14 +44,14 @@ github.com/evanphx/json-patch/v5 v5.1.0 h1:B0aXl1o/1cP8NbviYiBMkcHBtUjIJ1/Ccg6b+ github.com/evanphx/json-patch/v5 v5.1.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= -github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= -github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= -github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= @@ -71,9 +70,12 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -87,6 +89,10 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs= +github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY= +github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q= +github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -109,13 +115,17 @@ github.com/jensneuse/pipeline v0.0.0-20200117120358-9fb4de085cd6 h1:y8hvuqbuVGFN github.com/jensneuse/pipeline v0.0.0-20200117120358-9fb4de085cd6/go.mod h1:UsfzaMt+keVOxa007GcCJMFeTHr6voRfBGMQEW7DkdM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs= +github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg= +github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64= +github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0= github.com/karlseguin/ccache/v2 v2.0.8 h1:lT38cE//uyf6KcFok0rlgXtGFBWxkI6h/qg4tbFyDnA= github.com/karlseguin/ccache/v2 v2.0.8/go.mod h1:2BDThcfQMf/c0jnZowt16eW405XIqZPavt+HoYEtcxQ= github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003 h1:vJ0Snvo+SLMY72r5J4sEfkuE7AFbixEP2qRbEcum/wA= github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -130,22 +140,19 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/logrusorgru/aurora/v3 v3.0.0 h1:R6zcoZZbvVcGMvDCKo45A9U/lzYyzl5NfYIvznmDfE4= github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc= -github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516 h1:aAO0L0ulox6m/CLRYvJff+jWXYYCKGpEm3os7dM/Z+M= -github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae h1:yyMUG1VUd6IjV5jonMKpLXgwm9AzkfRsYisdCXc5OVI= +github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -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-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= +github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= @@ -193,9 +200,8 @@ github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktE github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= @@ -246,10 +252,10 @@ github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5 github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= 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.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= -github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= -github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= @@ -286,8 +292,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -297,8 +303,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -311,8 +317,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -320,8 +326,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -332,15 +338,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -355,8 +360,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= @@ -368,13 +373,13 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/helm/api-firewall/Chart.yaml b/helm/api-firewall/Chart.yaml index 0f3977e4..da4120ab 100644 --- a/helm/api-firewall/Chart.yaml +++ b/helm/api-firewall/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v1 name: api-firewall version: 0.7.2 -appVersion: 0.9.5 +appVersion: 0.9.6 description: Wallarm OpenAPI-based API Firewall home: https://github.com/wallarm/api-firewall icon: https://static.wallarm.com/wallarm-logo.svg diff --git a/internal/config/backend.go b/internal/config/backend.go index 7785e61b..f81d1566 100644 --- a/internal/config/backend.go +++ b/internal/config/backend.go @@ -27,7 +27,6 @@ type Oauth struct { type ProtectedAPI struct { URL string `conf:"default:http://localhost:3000/v1/" validate:"required,url"` RequestHostHeader string `conf:""` - ClientPoolCapacity int `conf:"default:1000" validate:"gt=0"` InsecureConnection bool `conf:"default:false"` RootCA string `conf:""` MaxConnsPerHost int `conf:"default:512"` @@ -38,6 +37,8 @@ type ProtectedAPI struct { WriteBufferSize int `conf:"default:8192"` MaxResponseBodySize int `conf:"default:0"` DeleteAcceptEncoding bool `conf:"default:false"` + HealthCheckInterval time.Duration `conf:"default:30s"` + MaxIdleConnDuration time.Duration `conf:"default:10s"` } type Backend struct { diff --git a/internal/config/backend_test.go b/internal/config/backend_test.go new file mode 100644 index 00000000..d51b37f6 --- /dev/null +++ b/internal/config/backend_test.go @@ -0,0 +1,114 @@ +package config + +import ( + "reflect" + "testing" + "time" +) + +func TestProtectedAPI_HasExpectedFields(t *testing.T) { + // Verify fields that should exist + rt := reflect.TypeOf(ProtectedAPI{}) + + expectedFields := []struct { + name string + typeName string + }{ + {"URL", "string"}, + {"RequestHostHeader", "string"}, + {"InsecureConnection", "bool"}, + {"RootCA", "string"}, + {"MaxConnsPerHost", "int"}, + {"ReadTimeout", "Duration"}, + {"WriteTimeout", "Duration"}, + {"DialTimeout", "Duration"}, + {"ReadBufferSize", "int"}, + {"WriteBufferSize", "int"}, + {"MaxResponseBodySize", "int"}, + {"DeleteAcceptEncoding", "bool"}, + {"HealthCheckInterval", "Duration"}, + {"MaxIdleConnDuration", "Duration"}, + } + + for _, ef := range expectedFields { + field, ok := rt.FieldByName(ef.name) + if !ok { + t.Errorf("expected field %q to exist on ProtectedAPI", ef.name) + continue + } + if field.Type.Name() != ef.typeName { + t.Errorf("field %q: expected type %s, got %s", ef.name, ef.typeName, field.Type.Name()) + } + } +} + +func TestProtectedAPI_RemovedFields(t *testing.T) { + // Verify fields that were removed no longer exist + rt := reflect.TypeOf(ProtectedAPI{}) + + removedFields := []string{ + "ClientPoolCapacity", + "UsePoolV2", + "PoolV2HealthCheckPeriod", + } + + for _, name := range removedFields { + if _, ok := rt.FieldByName(name); ok { + t.Errorf("field %q should have been removed from ProtectedAPI", name) + } + } +} + +func TestProtectedAPI_Defaults(t *testing.T) { + // Verify zero-value struct has expected zero values + // (actual defaults are applied by the conf library at parse time, + // but we can verify the struct tags are present) + rt := reflect.TypeOf(ProtectedAPI{}) + + expectedDefaults := map[string]string{ + "MaxConnsPerHost": "default:512", + "ReadTimeout": "default:5s", + "WriteTimeout": "default:5s", + "DialTimeout": "default:200ms", + "ReadBufferSize": "default:8192", + "WriteBufferSize": "default:8192", + "MaxResponseBodySize": "default:0", + "HealthCheckInterval": "default:30s", + "MaxIdleConnDuration": "default:10s", + "InsecureConnection": "default:false", + } + + for fieldName, expectedTag := range expectedDefaults { + field, ok := rt.FieldByName(fieldName) + if !ok { + t.Errorf("field %q not found", fieldName) + continue + } + confTag := field.Tag.Get("conf") + if confTag != expectedTag { + t.Errorf("field %q: expected conf tag %q, got %q", fieldName, expectedTag, confTag) + } + } +} + +func TestProtectedAPI_ZeroValue(t *testing.T) { + var cfg ProtectedAPI + + // Zero value should have zero durations + if cfg.HealthCheckInterval != 0 { + t.Errorf("expected zero HealthCheckInterval, got %v", cfg.HealthCheckInterval) + } + if cfg.MaxIdleConnDuration != 0 { + t.Errorf("expected zero MaxIdleConnDuration, got %v", cfg.MaxIdleConnDuration) + } + if cfg.InsecureConnection { + t.Error("expected InsecureConnection to be false") + } + + // Verify types work correctly when set + cfg.HealthCheckInterval = 30 * time.Second + cfg.MaxIdleConnDuration = 10 * time.Second + if cfg.HealthCheckInterval != 30*time.Second { + t.Errorf("unexpected HealthCheckInterval: %v", cfg.HealthCheckInterval) + } +} diff --git a/internal/platform/proxy/chainpool.go b/internal/platform/proxy/chainpool.go deleted file mode 100644 index ba9221b2..00000000 --- a/internal/platform/proxy/chainpool.go +++ /dev/null @@ -1,345 +0,0 @@ -package proxy - -// Copyright 2018 The yeqown Author. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -import ( - "context" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "github.com/rs/zerolog" - "net" - "os" - "strings" - "sync" - "time" - - "github.com/valyala/fasthttp" - - "github.com/wallarm/api-firewall/internal/config" -) - -var defaultLookUpTimeout = 1 * time.Second - -var ( - errInvalidCapacitySetting = errors.New("invalid capacity settings") - errInvalidOptions = errors.New("invalid settings") - errClosed = errors.New("chan closed") - errFailedDNSCacheResolverInit = errors.New("DNS cache resolver init failed") -) - -func (p *chanPool) tryResolveAndFetchOneIP(host string) (string, error) { - - var resolvedIP string - - ipAddrs, err := p.dnsResolver.LookupIPAddr(context.Background(), host) - if err != nil { - return "", err - } - - for _, ip := range ipAddrs { - if ip.IP.To4() != nil { - resolvedIP = ip.String() - return resolvedIP, nil - } - } - - for _, ip := range ipAddrs { - if ip.IP.To16() != nil { - resolvedIP = ip.String() - return resolvedIP, nil - } - } - - return resolvedIP, nil -} - -type HTTPClient interface { - Do(req *fasthttp.Request, resp *fasthttp.Response) error -} - -func (p *chanPool) factory(connAddr string) HTTPClient { - - proxyClient := fasthttp.Client{ - NoDefaultUserAgentHeader: true, - DisableHeaderNamesNormalizing: true, - DisablePathNormalizing: true, - Dial: func(addr string) (net.Conn, error) { - return fasthttp.DialTimeout(connAddr, p.options.DialTimeout) - }, - TLSConfig: p.tlsConfig, - MaxConnsPerHost: p.options.MaxConnsPerHost, - ReadTimeout: p.options.ReadTimeout, - WriteTimeout: p.options.WriteTimeout, - ReadBufferSize: p.options.ReadBufferSize, - WriteBufferSize: p.options.WriteBufferSize, - MaxResponseBodySize: p.options.MaxResponseBodySize, - } - - return &proxyClient -} - -type Pool interface { - // Get returns a new ReverseProxy from the pool. - Get() (HTTPClient, string, error) - - // Put the ReverseProxy puts it back to the Pool. - Put(string, HTTPClient) error - - // Close closes the pool and all its connections. After Close() the pool is - // no longer usable. - Close() -} - -// Pool interface impelement based on channel -// there is a channel to contain ReverseProxy object, -// and provide Get and Put method to handle with RevsereProxy -type chanPool struct { - // mutex makes the chanPool woking with goroutine safely - mutex sync.RWMutex - - // reverseProxyChan chan of getting the *ReverseProxy and putting it back - reverseProxyChanLB map[string]chan HTTPClient - - // factory is factory method to generate ReverseProxy - options *Options - host string - port string - - initResolvedIP string - initConnAddr string - - tlsConfig *tls.Config - dnsResolver DNSCache -} - -type Options struct { - InitialPoolCapacity int - ClientPoolCapacity int - InsecureConnection bool - RootCA string - MaxConnsPerHost int - DNSConfig config.DNS - - ReadTimeout time.Duration - WriteTimeout time.Duration - DialTimeout time.Duration - - ReadBufferSize int - WriteBufferSize int - MaxResponseBodySize int - - Logger zerolog.Logger - - DNSResolver DNSCache -} - -// NewChanPool to new a pool with some params -func NewChanPool(hostAddr string, options *Options) (Pool, error) { - - if options == nil { - return nil, errInvalidOptions - } - - if options.InitialPoolCapacity < 0 || options.ClientPoolCapacity <= 0 || options.InitialPoolCapacity > options.ClientPoolCapacity { - return nil, errInvalidCapacitySetting - } - - dnsResolver := options.DNSResolver - - if options.DNSResolver == nil { - // default DNS resolver - resolver := &net.Resolver{ - PreferGo: true, - StrictErrors: false, - } - - // init DNS resolver - dnsCacheOptions := DNSCacheOptions{ - UseCache: false, - Logger: options.Logger, - LookupTimeout: defaultLookUpTimeout, - } - - newDnsResolver, err := NewDNSResolver(resolver, &dnsCacheOptions) - if err != nil { - return nil, errFailedDNSCacheResolverInit - } - - dnsResolver = newDnsResolver - } - - // Get the SystemCertPool, continue with an empty pool on error - rootCAs, err := x509.SystemCertPool() - if err != nil { - return nil, err - } - - if options.RootCA != "" { - // Read in the cert file - certs, err := os.ReadFile(options.RootCA) - if err != nil { - return nil, fmt.Errorf("failed to append %q to RootCAs: %v", options.RootCA, err) - } - - // Append our cert to the system pool - if ok := rootCAs.AppendCertsFromPEM(certs); !ok { - return nil, errors.New("no certs appended, using system certs only") - } - } - - tlsConfig := &tls.Config{ - InsecureSkipVerify: options.InsecureConnection, - RootCAs: rootCAs, - } - - host, port, err := net.SplitHostPort(hostAddr) - if err != nil { - return nil, err - } - - // initialize the chanPool - pool := &chanPool{ - mutex: sync.RWMutex{}, - reverseProxyChanLB: make(map[string]chan HTTPClient), - options: options, - host: host, - port: port, - tlsConfig: tlsConfig, - dnsResolver: dnsResolver, - } - - ip, err := pool.tryResolveAndFetchOneIP(host) - if err != nil { - return nil, err - } - - var builder strings.Builder - - builder.WriteString(ip) - builder.WriteString(":") - builder.WriteString(port) - - pool.initConnAddr = builder.String() - - // create initial connections, if something goes wrong, - // just close the pool error out. - for i := 0; i < options.InitialPoolCapacity; i++ { - - ip, err = pool.tryResolveAndFetchOneIP(pool.host) - if err != nil { - continue - } - - builder.Reset() - - builder.WriteString(ip) - builder.WriteString(":") - builder.WriteString(port) - - connAddr := builder.String() - - proxy := pool.factory(connAddr) - if pool.reverseProxyChanLB[ip] == nil { - pool.reverseProxyChanLB[ip] = make(chan HTTPClient, options.ClientPoolCapacity) - } - pool.reverseProxyChanLB[ip] <- proxy - } - - return pool, nil -} - -// Close close the pool -func (p *chanPool) Close() { - p.mutex.Lock() - defer p.mutex.Unlock() - - for ip := range p.reverseProxyChanLB { - reverseProxyChan := p.reverseProxyChanLB[ip] - p.reverseProxyChanLB[ip] = nil - - if reverseProxyChan == nil { - return - } - - close(reverseProxyChan) - } - - p.dnsResolver.Stop() - -} - -// Get a *ReverseProxy from pool, it will get an error while -// reverseProxyChan is nil or pool has been closed -func (p *chanPool) Get() (HTTPClient, string, error) { - - var resolvedIP, connAddr string - - connAddr = p.initConnAddr - resolvedIP = p.initResolvedIP - - if p.options.DNSConfig.Cache { - ip, err := p.tryResolveAndFetchOneIP(p.host) - if err != nil { - return nil, "", err - } - resolvedIP = ip - - var builder strings.Builder - - builder.WriteString(ip) - builder.WriteString(":") - builder.WriteString(p.port) - - connAddr = builder.String() - } - - reverseProxyChan := p.reverseProxyChanLB[resolvedIP] - - if reverseProxyChan == nil { - p.reverseProxyChanLB[resolvedIP] = make(chan HTTPClient, p.options.ClientPoolCapacity) - reverseProxyChan = p.reverseProxyChanLB[resolvedIP] - } - - // wrap our connections with out custom net.Conn implementation (wrapConn - // method) that puts the connection back to the pool if it's closed. - select { - case proxy := <-reverseProxyChan: - if proxy == nil { - return nil, resolvedIP, errClosed - } - return proxy, resolvedIP, nil - default: - proxy := p.factory(connAddr) - return proxy, resolvedIP, nil - } -} - -// Put ... put a *ReverseProxy object back into chanPool -func (p *chanPool) Put(ip string, proxy HTTPClient) error { - if proxy == nil { - return errors.New("proxy is nil. rejecting") - } - - p.mutex.RLock() - defer p.mutex.RUnlock() - - reverseProxyChan := p.reverseProxyChanLB[ip] - - if reverseProxyChan == nil { - // pool is closed, close passed connection - return nil - } - - // put the resource back into the pool. If the pool is full, this will - // block and the default case will be executed. - select { - case reverseProxyChan <- proxy: - return nil - default: - return nil - } -} diff --git a/internal/platform/proxy/loadbalancer.go b/internal/platform/proxy/loadbalancer.go new file mode 100644 index 00000000..c982c5ab --- /dev/null +++ b/internal/platform/proxy/loadbalancer.go @@ -0,0 +1,172 @@ +package proxy + +import ( + "net" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog" +) + +// LoadBalancer provides round-robin load balancing across multiple backends +// with health checking support. It is lock-free for the hot path (Next). +type LoadBalancer struct { + backends []string + healthy []atomic.Bool + current atomic.Uint64 + + healthCheckInterval time.Duration + healthCheckTimeout time.Duration + stopCh chan struct{} + stopped atomic.Bool + wg sync.WaitGroup + logger zerolog.Logger +} + +// LoadBalancerOptions configures the load balancer +type LoadBalancerOptions struct { + Backends []string + HealthCheckInterval time.Duration + HealthCheckTimeout time.Duration + Logger zerolog.Logger +} + +// NewLoadBalancer creates a new load balancer with the given backends +func NewLoadBalancer(opts *LoadBalancerOptions) *LoadBalancer { + if opts.HealthCheckTimeout == 0 { + opts.HealthCheckTimeout = 1 * time.Second + } + + lb := &LoadBalancer{ + backends: opts.Backends, + healthy: make([]atomic.Bool, len(opts.Backends)), + stopCh: make(chan struct{}), + healthCheckInterval: opts.HealthCheckInterval, + healthCheckTimeout: opts.HealthCheckTimeout, + logger: opts.Logger, + } + + // Mark all backends as healthy initially + for i := range lb.healthy { + lb.healthy[i].Store(true) + } + + // Start health check goroutine if interval is configured + if opts.HealthCheckInterval > 0 && len(opts.Backends) > 0 { + lb.wg.Add(1) + go lb.runHealthChecks() + } + + return lb +} + +// Next returns the next healthy backend using round-robin selection. +// This method is lock-free and safe for concurrent use. +func (lb *LoadBalancer) Next() string { + n := len(lb.backends) + if n == 0 { + return "" + } + + // Fast path: single backend + if n == 1 { + return lb.backends[0] + } + + // Round-robin with health awareness + start := lb.current.Add(1) + for i := 0; i < n; i++ { + idx := (int(start) + i) % n + if lb.healthy[idx].Load() { + return lb.backends[idx] + } + } + + // Fallback: return any backend if all appear unhealthy + // This prevents total failure when health checks are failing + return lb.backends[int(start)%n] +} + +func (lb *LoadBalancer) GetHealthyCount() int { + count := 0 + for i := range lb.healthy { + if lb.healthy[i].Load() { + count++ + } + } + return count +} + +func (lb *LoadBalancer) GetBackends() []string { + return append([]string(nil), lb.backends...) +} + +func (lb *LoadBalancer) IsHealthy(idx int) bool { + if idx < 0 || idx >= len(lb.healthy) { + return false + } + return lb.healthy[idx].Load() +} + +func (lb *LoadBalancer) SetHealthy(idx int, healthy bool) { + if idx >= 0 && idx < len(lb.healthy) { + lb.healthy[idx].Store(healthy) + } +} + +// runHealthChecks periodically checks all backends +func (lb *LoadBalancer) runHealthChecks() { + defer lb.wg.Done() + + ticker := time.NewTicker(lb.healthCheckInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + lb.checkAll() + case <-lb.stopCh: + return + } + } +} + +// checkAll probes all backends and updates their health status +func (lb *LoadBalancer) checkAll() { + for i, backend := range lb.backends { + healthy := lb.probe(backend) + wasHealthy := lb.healthy[i].Swap(healthy) + + // Log health status changes + if healthy != wasHealthy { + if healthy { + lb.logger.Info(). + Str("backend", backend). + Msg("Backend became healthy") + } else { + lb.logger.Warn(). + Str("backend", backend). + Msg("Backend became unhealthy") + } + } + } +} + +// probe checks if a backend is reachable +func (lb *LoadBalancer) probe(backend string) bool { + conn, err := net.DialTimeout("tcp", backend, lb.healthCheckTimeout) + if err != nil { + return false + } + conn.Close() + return true +} + +func (lb *LoadBalancer) Stop() { + if lb.stopped.Swap(true) { + return + } + close(lb.stopCh) + lb.wg.Wait() +} diff --git a/internal/platform/proxy/loadbalancer_test.go b/internal/platform/proxy/loadbalancer_test.go new file mode 100644 index 00000000..d1d25d62 --- /dev/null +++ b/internal/platform/proxy/loadbalancer_test.go @@ -0,0 +1,258 @@ +package proxy + +import ( + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/rs/zerolog" +) + +func TestLoadBalancer_Next_SingleBackend(t *testing.T) { + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: []string{"127.0.0.1:8080"}, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + backend := lb.Next() + if backend != "127.0.0.1:8080" { + t.Errorf("expected 127.0.0.1:8080, got %s", backend) + } +} + +func TestLoadBalancer_Next_RoundRobin(t *testing.T) { + backends := []string{"127.0.0.1:8080", "127.0.0.1:8081", "127.0.0.1:8082"} + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + // Track how many times each backend is selected + counts := make(map[string]int) + + // Call Next many times + iterations := 300 + for i := 0; i < iterations; i++ { + backend := lb.Next() + counts[backend]++ + } + + // Each backend should be called approximately equal times + expectedPerBackend := iterations / len(backends) + tolerance := 5 // Allow some variance due to round-robin starting point + + for _, backend := range backends { + count := counts[backend] + if count < expectedPerBackend-tolerance || count > expectedPerBackend+tolerance { + t.Errorf("backend %s called %d times, expected around %d", backend, count, expectedPerBackend) + } + } +} + +func TestLoadBalancer_Next_SkipsUnhealthy(t *testing.T) { + backends := []string{"127.0.0.1:8080", "127.0.0.1:8081", "127.0.0.1:8082"} + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + // Mark first backend as unhealthy + lb.SetHealthy(0, false) + + // Count selections + counts := make(map[string]int) + iterations := 100 + for i := 0; i < iterations; i++ { + backend := lb.Next() + counts[backend]++ + } + + // First backend should not be selected + if counts["127.0.0.1:8080"] > 0 { + t.Errorf("unhealthy backend was selected %d times", counts["127.0.0.1:8080"]) + } + + // Other backends should share the load + if counts["127.0.0.1:8081"] == 0 { + t.Error("backend 8081 was never selected") + } + if counts["127.0.0.1:8082"] == 0 { + t.Error("backend 8082 was never selected") + } +} + +func TestLoadBalancer_Next_FallbackWhenAllUnhealthy(t *testing.T) { + backends := []string{"127.0.0.1:8080", "127.0.0.1:8081"} + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + // Mark all backends as unhealthy + lb.SetHealthy(0, false) + lb.SetHealthy(1, false) + + // Should still return a backend (fallback behavior) + backend := lb.Next() + if backend == "" { + t.Error("expected a backend even when all are unhealthy") + } +} + +func TestLoadBalancer_Next_EmptyBackends(t *testing.T) { + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: []string{}, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + backend := lb.Next() + if backend != "" { + t.Errorf("expected empty string for empty backends, got %s", backend) + } +} + +func TestLoadBalancer_GetHealthyCount(t *testing.T) { + backends := []string{"127.0.0.1:8080", "127.0.0.1:8081", "127.0.0.1:8082"} + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + // All should be healthy initially + if count := lb.GetHealthyCount(); count != 3 { + t.Errorf("expected 3 healthy, got %d", count) + } + + // Mark one unhealthy + lb.SetHealthy(1, false) + if count := lb.GetHealthyCount(); count != 2 { + t.Errorf("expected 2 healthy, got %d", count) + } +} + +func TestLoadBalancer_ConcurrentAccess(t *testing.T) { + backends := []string{"127.0.0.1:8080", "127.0.0.1:8081", "127.0.0.1:8082"} + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + var wg sync.WaitGroup + iterations := 10000 + goroutines := 10 + + var successCount atomic.Int64 + + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + backend := lb.Next() + if backend != "" { + successCount.Add(1) + } + } + }() + } + + wg.Wait() + + expected := int64(goroutines * iterations) + if successCount.Load() != expected { + t.Errorf("expected %d successful calls, got %d", expected, successCount.Load()) + } +} + +func TestLoadBalancer_HealthCheck_Integration(t *testing.T) { + // Start a simple TCP server + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start test server: %v", err) + } + defer listener.Close() + + // Accept connections in background + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + backends := []string{listener.Addr().String(), "127.0.0.1:1"} // One reachable, one not + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + HealthCheckInterval: 100 * time.Millisecond, + HealthCheckTimeout: 50 * time.Millisecond, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + // Wait for health check to run + time.Sleep(200 * time.Millisecond) + + // First backend should be healthy, second should be unhealthy + if !lb.IsHealthy(0) { + t.Error("expected first backend to be healthy") + } + if lb.IsHealthy(1) { + t.Error("expected second backend to be unhealthy") + } +} + +func BenchmarkLoadBalancer_Next(b *testing.B) { + backends := []string{ + "127.0.0.1:8080", + "127.0.0.1:8081", + "127.0.0.1:8082", + "127.0.0.1:8083", + } + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _ = lb.Next() + } +} + +func BenchmarkLoadBalancer_Next_Parallel(b *testing.B) { + backends := []string{ + "127.0.0.1:8080", + "127.0.0.1:8081", + "127.0.0.1:8082", + "127.0.0.1:8083", + } + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + Logger: zerolog.Nop(), + }) + defer lb.Stop() + + b.ResetTimer() + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = lb.Next() + } + }) +} diff --git a/internal/platform/proxy/pool_v2.go b/internal/platform/proxy/pool_v2.go new file mode 100644 index 00000000..23d275cb --- /dev/null +++ b/internal/platform/proxy/pool_v2.go @@ -0,0 +1,231 @@ +package proxy + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + "github.com/valyala/fasthttp" +) + +var ( + errInvalidOptions = errors.New("invalid settings") + errPoolClosed = errors.New("pool is closed") + errNoBackends = errors.New("no backends available") +) + +// HTTPClient is the interface for making HTTP requests. +type HTTPClient interface { + Do(req *fasthttp.Request, resp *fasthttp.Response) error +} + +// Pool manages HTTP client connections to backend servers. +type Pool interface { + Get() (HTTPClient, string, error) + Put(string, HTTPClient) error + Close() +} + +// PoolV2 is a lock-free connection pool that leverages +// fasthttp.Client's internal connection pooling. +// +// Key design: +// - Lock-free operation using atomic values +// - Single fasthttp.Client with proper MaxConnsPerHost +// - Zero allocations per request in Get/Put +// - Built-in load balancing with health checks +type PoolV2 struct { + client *fasthttp.Client + lb *LoadBalancer + tlsConfig *tls.Config + closed atomic.Bool + logger zerolog.Logger + + // Cached for metrics/debugging + host string + port string + hostPort string +} + +// PoolV2Options configures the PoolV2 connection pool +type PoolV2Options struct { + // Connection settings + MaxConnsPerHost int + MaxIdleConnDuration time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + DialTimeout time.Duration + + // Buffer sizes + ReadBufferSize int + WriteBufferSize int + MaxResponseBodySize int + + // TLS configuration + InsecureConnection bool + RootCA string + + // Load balancing - if empty, uses single backend from hostAddr + Backends []string + HealthCheckInterval time.Duration + + // Logging + Logger zerolog.Logger +} + +// NewPoolV2 creates a new lock-free connection pool +func NewPoolV2(hostAddr string, opts *PoolV2Options) (Pool, error) { + if opts == nil { + return nil, errInvalidOptions + } + + // Parse host address + host, port, err := net.SplitHostPort(hostAddr) + if err != nil { + return nil, fmt.Errorf("invalid host address: %w", err) + } + + // Build TLS configuration + tlsConfig, err := BuildTLSConfig(opts.InsecureConnection, opts.RootCA) + if err != nil { + return nil, fmt.Errorf("failed to build TLS config: %w", err) + } + + // Determine backends for load balancer + backends := opts.Backends + if len(backends) == 0 { + // Single backend mode - resolve initial IP + ips, err := net.LookupIP(host) + if err != nil { + return nil, fmt.Errorf("failed to resolve host: %w", err) + } + + // Collect all resolved IPs as backends + for _, ip := range ips { + if ipv4 := ip.To4(); ipv4 != nil { + backends = append(backends, net.JoinHostPort(ipv4.String(), port)) + } + } + + // Fallback to IPv6 if no IPv4 + if len(backends) == 0 { + for _, ip := range ips { + if ipv6 := ip.To16(); ipv6 != nil { + backends = append(backends, net.JoinHostPort(ipv6.String(), port)) + } + } + } + + // Last resort: use original host + if len(backends) == 0 { + backends = []string{hostAddr} + } + } + + // Create load balancer + lb := NewLoadBalancer(&LoadBalancerOptions{ + Backends: backends, + HealthCheckInterval: opts.HealthCheckInterval, + HealthCheckTimeout: opts.DialTimeout, + Logger: opts.Logger, + }) + + p := &PoolV2{ + lb: lb, + tlsConfig: tlsConfig, + logger: opts.Logger, + host: host, + port: port, + hostPort: net.JoinHostPort(host, port), + } + + // Create single fasthttp.Client that manages its own connection pool + p.client = &fasthttp.Client{ + NoDefaultUserAgentHeader: true, + DisableHeaderNamesNormalizing: true, + DisablePathNormalizing: true, + MaxConnsPerHost: opts.MaxConnsPerHost, + MaxIdleConnDuration: opts.MaxIdleConnDuration, + ReadTimeout: opts.ReadTimeout, + WriteTimeout: opts.WriteTimeout, + ReadBufferSize: opts.ReadBufferSize, + WriteBufferSize: opts.WriteBufferSize, + MaxResponseBodySize: opts.MaxResponseBodySize, + TLSConfig: tlsConfig, + Dial: func(addr string) (net.Conn, error) { + // Use load balancer to select backend + backend := lb.Next() + if backend == "" { + return nil, errNoBackends + } + return fasthttp.DialTimeout(backend, opts.DialTimeout) + }, + } + + p.logger.Info(). + Str("host", host). + Str("port", port). + Int("backends", len(backends)). + Int("max_conns_per_host", opts.MaxConnsPerHost). + Msg("PoolV2 initialized") + + return p, nil +} + +// Get returns the shared HTTP client. The actual backend is selected +// inside the Dial function when the connection is established. +func (p *PoolV2) Get() (HTTPClient, string, error) { + if p.closed.Load() { + return nil, "", errPoolClosed + } + + return p.client, p.hostPort, nil +} + +// Put is a no-op -- fasthttp.Client manages connection lifecycle internally. +func (p *PoolV2) Put(ip string, client HTTPClient) error { + // No-op: fasthttp.Client handles connection reuse automatically + return nil +} + +func (p *PoolV2) Close() { + if p.closed.Swap(true) { + // Already closed + return + } + + // Stop load balancer health checks + p.lb.Stop() + + // Close idle connections + p.client.CloseIdleConnections() + + p.logger.Info(). + Str("host", p.host). + Str("port", p.port). + Msg("PoolV2 closed") +} + +func (p *PoolV2) Stats() PoolV2Stats { + return PoolV2Stats{ + Host: p.host, + Port: p.port, + Backends: p.lb.GetBackends(), + HealthyCount: p.lb.GetHealthyCount(), + IsClosed: p.closed.Load(), + } +} + +// PoolV2Stats contains pool statistics +type PoolV2Stats struct { + Host string + Port string + Backends []string + HealthyCount int + IsClosed bool +} + diff --git a/internal/platform/proxy/chainpool_mock.go b/internal/platform/proxy/pool_v2_mock.go similarity index 96% rename from internal/platform/proxy/chainpool_mock.go rename to internal/platform/proxy/pool_v2_mock.go index fd82bbe0..58ac88f7 100644 --- a/internal/platform/proxy/chainpool_mock.go +++ b/internal/platform/proxy/pool_v2_mock.go @@ -1,7 +1,6 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: ./internal/platform/proxy/chainpool.go +// Source: ./internal/platform/proxy/pool_v2.go -// Package proxy is a generated GoMock package. package proxy import ( diff --git a/internal/platform/proxy/pool_v2_test.go b/internal/platform/proxy/pool_v2_test.go new file mode 100644 index 00000000..51a9a0b9 --- /dev/null +++ b/internal/platform/proxy/pool_v2_test.go @@ -0,0 +1,487 @@ +package proxy + +import ( + "net" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/valyala/fasthttp" +) + +func TestPoolV2_NewPoolV2_ValidConfig(t *testing.T) { + // Start a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Extract host:port from server URL + host := server.Listener.Addr().String() + + pool, err := NewPoolV2(host, &PoolV2Options{ + MaxConnsPerHost: 100, + MaxIdleConnDuration: 10 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + // Get should work + client, backend, err := pool.Get() + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if client == nil { + t.Error("expected non-nil client") + } + if backend == "" { + t.Error("expected non-empty backend") + } +} + +func TestPoolV2_GetPut_NoAllocation(t *testing.T) { + // Start a test server + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + defer listener.Close() + + pool, err := NewPoolV2(listener.Addr().String(), &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + // Warm up + for i := 0; i < 100; i++ { + client, ip, _ := pool.Get() + pool.Put(ip, client) + } + + // Measure allocations + allocs := testing.AllocsPerRun(1000, func() { + client, ip, _ := pool.Get() + pool.Put(ip, client) + }) + + // Should have zero allocations per Get/Put cycle + if allocs > 0 { + t.Errorf("expected 0 allocations, got %.2f", allocs) + } +} + +func TestPoolV2_Close_PreventsFurtherGet(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + defer listener.Close() + + pool, err := NewPoolV2(listener.Addr().String(), &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + + // Close the pool + pool.Close() + + // Get should return error + _, _, err = pool.Get() + if err == nil { + t.Error("expected error after close") + } +} + +func TestPoolV2_ConcurrentGetPut(t *testing.T) { + // Start a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + host := server.Listener.Addr().String() + + pool, err := NewPoolV2(host, &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + var wg sync.WaitGroup + goroutines := 100 + iterations := 1000 + + var successCount atomic.Int64 + var errorCount atomic.Int64 + + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + client, ip, err := pool.Get() + if err != nil { + errorCount.Add(1) + continue + } + if client == nil { + errorCount.Add(1) + continue + } + pool.Put(ip, client) + successCount.Add(1) + } + }() + } + + wg.Wait() + + expected := int64(goroutines * iterations) + if successCount.Load() != expected { + t.Errorf("expected %d successes, got %d (errors: %d)", + expected, successCount.Load(), errorCount.Load()) + } +} + +func TestPoolV2_ActualRequest(t *testing.T) { + // Start a test server that echoes back + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + host := server.Listener.Addr().String() + + pool, err := NewPoolV2(host, &PoolV2Options{ + MaxConnsPerHost: 100, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + // Make actual request + client, ip, err := pool.Get() + if err != nil { + t.Fatalf("Get failed: %v", err) + } + defer pool.Put(ip, client) + + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + req.SetRequestURI("http://" + host + "/test") + req.Header.SetMethod("GET") + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + err = client.Do(req, resp) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + if resp.StatusCode() != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode()) + } + + body := string(resp.Body()) + if body != `{"status":"ok"}` { + t.Errorf("unexpected body: %s", body) + } +} + +func TestPoolV2_Stats(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + defer listener.Close() + + pool, err := NewPoolV2(listener.Addr().String(), &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + // Type assertion to get PoolV2 specific methods + poolV2, ok := pool.(*PoolV2) + if !ok { + t.Fatal("expected *PoolV2") + } + + stats := poolV2.Stats() + if stats.IsClosed { + t.Error("expected pool to be open") + } + if len(stats.Backends) == 0 { + t.Error("expected at least one backend") + } + if stats.HealthyCount == 0 { + t.Error("expected at least one healthy backend") + } +} + +func TestPoolV2_InvalidHostAddress(t *testing.T) { + _, err := NewPoolV2("invalid-no-port", &PoolV2Options{ + MaxConnsPerHost: 100, + Logger: zerolog.Nop(), + }) + if err == nil { + t.Error("expected error for invalid host address") + } +} + +func TestPoolV2_NilOptions(t *testing.T) { + _, err := NewPoolV2("127.0.0.1:8080", nil) + if err == nil { + t.Error("expected error for nil options") + } +} + +func TestPoolV2_ExplicitBackends(t *testing.T) { + // Start two test servers + server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("server1")) + })) + defer server1.Close() + + server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("server2")) + })) + defer server2.Close() + + pool, err := NewPoolV2("127.0.0.1:0", &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Backends: []string{ + server1.Listener.Addr().String(), + server2.Listener.Addr().String(), + }, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + poolV2 := pool.(*PoolV2) + stats := poolV2.Stats() + if len(stats.Backends) != 2 { + t.Errorf("expected 2 backends, got %d", len(stats.Backends)) + } + + // Make requests - should succeed via one of the backends + client, _, err := pool.Get() + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + req.SetRequestURI("http://127.0.0.1/test") + req.Header.SetMethod("GET") + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + err = client.Do(req, resp) + if err != nil { + t.Fatalf("request failed: %v", err) + } + if resp.StatusCode() != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode()) + } + + body := string(resp.Body()) + if body != "server1" && body != "server2" { + t.Errorf("unexpected body: %s", body) + } +} + +func TestPoolV2_Close_Idempotent(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + defer listener.Close() + + pool, err := NewPoolV2(listener.Addr().String(), &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + + // Close twice - should not panic + pool.Close() + pool.Close() +} + +func TestPoolV2_Get_ReturnsHostPort(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + defer listener.Close() + + addr := listener.Addr().String() + pool, err := NewPoolV2(addr, &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + _, backend, err := pool.Get() + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + // Get should return the original host:port, not a resolved IP + if backend != addr { + t.Errorf("expected backend %q, got %q", addr, backend) + } +} + +func TestPoolV2_UnresolvableHost(t *testing.T) { + _, err := NewPoolV2("this-host-does-not-exist.invalid:8080", &PoolV2Options{ + MaxConnsPerHost: 100, + Logger: zerolog.Nop(), + }) + if err == nil { + t.Error("expected error for unresolvable host") + } +} + +func TestPoolV2_WithTLSOptions(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + defer listener.Close() + + pool, err := NewPoolV2(listener.Addr().String(), &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + InsecureConnection: true, + Logger: zerolog.Nop(), + }) + if err != nil { + t.Fatalf("failed to create pool with insecure TLS: %v", err) + } + defer pool.Close() + + poolV2 := pool.(*PoolV2) + if poolV2.tlsConfig == nil { + t.Error("expected non-nil TLS config") + } + if !poolV2.tlsConfig.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be true") + } +} + +func TestPoolV2_InvalidRootCA(t *testing.T) { + _, err := NewPoolV2("127.0.0.1:8080", &PoolV2Options{ + MaxConnsPerHost: 100, + RootCA: "/nonexistent/ca.pem", + Logger: zerolog.Nop(), + }) + if err == nil { + t.Error("expected error for invalid root CA") + } +} + +func BenchmarkPoolV2_Get(b *testing.B) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatalf("failed to start listener: %v", err) + } + defer listener.Close() + + pool, err := NewPoolV2(listener.Addr().String(), &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + b.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + client, ip, _ := pool.Get() + pool.Put(ip, client) + } +} + +func BenchmarkPoolV2_Get_Parallel(b *testing.B) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatalf("failed to start listener: %v", err) + } + defer listener.Close() + + pool, err := NewPoolV2(listener.Addr().String(), &PoolV2Options{ + MaxConnsPerHost: 100, + DialTimeout: 1 * time.Second, + Logger: zerolog.Nop(), + }) + if err != nil { + b.Fatalf("failed to create pool: %v", err) + } + defer pool.Close() + + b.ResetTimer() + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + client, ip, _ := pool.Get() + pool.Put(ip, client) + } + }) +} + diff --git a/internal/platform/proxy/tls.go b/internal/platform/proxy/tls.go new file mode 100644 index 00000000..1cc9d9f5 --- /dev/null +++ b/internal/platform/proxy/tls.go @@ -0,0 +1,34 @@ +package proxy + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" +) + +// BuildTLSConfig creates a TLS configuration with optional custom root CA. +func BuildTLSConfig(insecure bool, rootCA string) (*tls.Config, error) { + rootCAs, err := x509.SystemCertPool() + if err != nil { + // On some systems (e.g. scratch containers), system pool is unavailable + rootCAs = x509.NewCertPool() + } + + if rootCA != "" { + certs, err := os.ReadFile(rootCA) + if err != nil { + return nil, fmt.Errorf("failed to read root CA %q: %w", rootCA, err) + } + + if ok := rootCAs.AppendCertsFromPEM(certs); !ok { + return nil, errors.New("failed to append root CA certificates") + } + } + + return &tls.Config{ + InsecureSkipVerify: insecure, + RootCAs: rootCAs, + }, nil +} diff --git a/internal/platform/proxy/tls_test.go b/internal/platform/proxy/tls_test.go new file mode 100644 index 00000000..e147ba4d --- /dev/null +++ b/internal/platform/proxy/tls_test.go @@ -0,0 +1,183 @@ +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +// generateTestCACert creates a self-signed CA certificate PEM file for testing. +func generateTestCACert(t *testing.T, dir string) string { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("failed to create certificate: %v", err) + } + + certPath := filepath.Join(dir, "ca.pem") + f, err := os.Create(certPath) + if err != nil { + t.Fatalf("failed to create cert file: %v", err) + } + defer f.Close() + + if err := pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { + t.Fatalf("failed to write PEM: %v", err) + } + + return certPath +} + +func TestBuildTLSConfig(t *testing.T) { + tests := []struct { + name string + insecure bool + rootCA string + setupFunc func(t *testing.T) string // returns rootCA path + wantErr bool + errContain string + checkFunc func(t *testing.T, cfg *tls.Config) + }{ + { + name: "defaults - no custom CA, secure", + insecure: false, + rootCA: "", + checkFunc: func(t *testing.T, cfg *tls.Config) { + if cfg.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be false") + } + if cfg.RootCAs == nil { + t.Error("expected non-nil RootCAs") + } + }, + }, + { + name: "insecure mode", + insecure: true, + rootCA: "", + checkFunc: func(t *testing.T, cfg *tls.Config) { + if !cfg.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be true") + } + }, + }, + { + name: "valid custom CA", + insecure: false, + setupFunc: func(t *testing.T) string { + return generateTestCACert(t, t.TempDir()) + }, + checkFunc: func(t *testing.T, cfg *tls.Config) { + if cfg.RootCAs == nil { + t.Error("expected non-nil RootCAs") + } + }, + }, + { + name: "nonexistent CA file", + insecure: false, + rootCA: "/nonexistent/path/ca.pem", + wantErr: true, + errContain: "failed to read root CA", + }, + { + name: "invalid PEM content", + insecure: false, + setupFunc: func(t *testing.T) string { + dir := t.TempDir() + path := filepath.Join(dir, "bad.pem") + if err := os.WriteFile(path, []byte("not a valid PEM"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + return path + }, + wantErr: true, + errContain: "failed to append root CA", + }, + { + name: "insecure with custom CA", + insecure: true, + setupFunc: func(t *testing.T) string { + return generateTestCACert(t, t.TempDir()) + }, + checkFunc: func(t *testing.T, cfg *tls.Config) { + if !cfg.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be true") + } + if cfg.RootCAs == nil { + t.Error("expected non-nil RootCAs even in insecure mode") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCA := tt.rootCA + if tt.setupFunc != nil { + rootCA = tt.setupFunc(t) + } + + cfg, err := BuildTLSConfig(tt.insecure, rootCA) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContain != "" && !contains(err.Error(), tt.errContain) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errContain) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg == nil { + t.Fatal("expected non-nil config") + } + + if tt.checkFunc != nil { + tt.checkFunc(t, cfg) + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/platform/proxy/wsClient.go b/internal/platform/proxy/wsClient.go index 3a32a886..c9b982fd 100644 --- a/internal/platform/proxy/wsClient.go +++ b/internal/platform/proxy/wsClient.go @@ -2,18 +2,14 @@ package proxy import ( "bytes" - "crypto/tls" - "crypto/x509" "fmt" "io" "net/http" - "os" "path" "sync" "time" "github.com/fasthttp/websocket" - "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/savsgio/gotils/strconv" "github.com/valyala/fasthttp" @@ -92,30 +88,11 @@ func builtinForwardHeaderHandler(ctx *fasthttp.RequestCtx) (forwardHeader http.H func NewWSClient(logger zerolog.Logger, options *WSClientOptions) (WebSocketClient, error) { - // get the SystemCertPool, continue with an empty pool on error - rootCAs, err := x509.SystemCertPool() + tlsConfig, err := BuildTLSConfig(options.InsecureConnection, options.RootCA) if err != nil { return nil, err } - if options.RootCA != "" { - // read in the cert file - certs, err := os.ReadFile(options.RootCA) - if err != nil { - return nil, fmt.Errorf("failed to append %q to RootCAs: %v", options.RootCA, err) - } - - // append our cert to the system pool - if ok := rootCAs.AppendCertsFromPEM(certs); !ok { - return nil, errors.New("no certs appended, using system certs only") - } - } - - tlsConfig := &tls.Config{ - InsecureSkipVerify: options.InsecureConnection, - RootCAs: rootCAs, - } - dialer := websocket.Dialer{ TLSClientConfig: tlsConfig, HandshakeTimeout: options.DialTimeout, diff --git a/resources/test/docker-compose-api-mode.yml b/resources/test/docker-compose-api-mode.yml index 93247a12..c8d199e9 100644 --- a/resources/test/docker-compose-api-mode.yml +++ b/resources/test/docker-compose-api-mode.yml @@ -2,7 +2,7 @@ version: '3.8' services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.5 + image: wallarm/api-firewall:v0.9.6 build: context: ../../ dockerfile: Dockerfile