Add gzip support to API handlers (#38675)

**Related issue:** Resolves #37944 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing
- [x] QA'd all new/changed functionality manually

## New Fleet configuration settings

- [x] Setting(s) is/are explicitly excluded from GitOps (it's a server
configuration)
This commit is contained in:
Zach Wasserman 2026-01-29 03:21:18 -08:00 committed by GitHub
parent be3079b4fd
commit 3a0b72a329
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 99 additions and 3 deletions

View file

@ -0,0 +1 @@
- Add `gzip_responses` server configuration option that allows the server to gzip API responses when the client indicates support through the `Accept-Encoding: gzip` request header.

2
go.mod
View file

@ -89,7 +89,7 @@ require (
github.com/igm/sockjs-go/v3 v3.0.2
github.com/jmoiron/sqlx v1.3.5
github.com/josephspurrier/goversioninfo v1.4.0
github.com/klauspost/compress v1.18.0
github.com/klauspost/compress v1.18.3
github.com/kolide/launcher v1.0.12
github.com/lib/pq v1.10.9
github.com/macadmins/osquery-extension v1.2.7

4
go.sum
View file

@ -562,8 +562,8 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab h1:KVR7cs+oPyy85i+8t1ZaNSy1bymCy5FuWyt51pdrXu4=

View file

@ -118,6 +118,7 @@ type ServerConfig struct {
CleanupDistTargetsAge time.Duration `yaml:"cleanup_dist_targets_age"`
MaxInstallerSizeBytes int64 `yaml:"max_installer_size"`
TrustedProxies string `yaml:"trusted_proxies"`
GzipResponses bool `yaml:"gzip_responses"`
}
func (s *ServerConfig) DefaultHTTPServer(ctx context.Context, handler http.Handler) *http.Server {
@ -1213,6 +1214,7 @@ func (man Manager) addConfigs() {
man.addConfigByteSize("server.max_installer_size", installersize.Human(installersize.DefaultMaxInstallerSize), "Maximum size in bytes for software installer uploads (e.g. 10GiB, 500MB, 1G)")
man.addConfigString("server.trusted_proxies", "",
"Trusted proxy configuration for client IP extraction: 'none' (RemoteAddr only), a header name (e.g., 'True-Client-IP'), a hop count (e.g., '2'), or comma-separated IP/CIDR ranges")
man.addConfigBool("server.gzip_responses", false, "Enable gzip-compressed responses for supported clients")
// Hide the sandbox flag as we don't want it to be discoverable for users for now
man.hideConfig("server.sandbox_enabled")
@ -1683,6 +1685,7 @@ func (man Manager) LoadConfig() FleetConfig {
CleanupDistTargetsAge: man.getConfigDuration("server.cleanup_dist_targets_age"),
MaxInstallerSizeBytes: man.getConfigByteSize("server.max_installer_size"),
TrustedProxies: man.getConfigString("server.trusted_proxies"),
GzipResponses: man.getConfigBool("server.gzip_responses"),
},
Auth: AuthConfig{
BcryptCost: man.getConfigInt("auth.bcrypt_cost"),

View file

@ -11,6 +11,8 @@ import (
"strings"
"time"
"github.com/klauspost/compress/gzhttp"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/publicip"
@ -139,6 +141,12 @@ func MakeHandler(
}
}
if config.Server.GzipResponses {
r.Use(func(h http.Handler) http.Handler {
return gzhttp.GzipHandler(h)
})
}
// Add middleware to extract the client IP and set it in the request context.
r.Use(func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -2,6 +2,7 @@ package service
import (
"bufio"
"context"
"fmt"
"net/http"
"net/http/httptest"
@ -10,8 +11,12 @@ import (
"strings"
"testing"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
kithttp "github.com/go-kit/kit/transport/http"
kitlog "github.com/go-kit/log"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
@ -327,3 +332,82 @@ func mockRouteHandler(route *mux.Route, status int) (verb, path string, err erro
route.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) })
return meths[0], path, nil
}
func TestGzipResponses(t *testing.T) {
ds := new(mock.Store)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
testRoute := func(r *mux.Router, opts []kithttp.ServerOption) {
r.Handle("/api/test-gzip", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Write enough data to trigger gzip (default threshold is 1500 bytes)
data := make([]byte, 2000)
for i := range data {
data[i] = 'a'
}
_, err := w.Write(data)
require.NoError(t, err)
}))
}
t.Run("Enabled", func(t *testing.T) {
cfg := config.TestConfig()
cfg.Server.GzipResponses = true
_, server := RunServerForTestsWithDS(t, ds, &TestServerOpts{
FleetConfig: &cfg,
FeatureRoutes: []endpointer.HandlerRoutesFunc{testRoute},
SkipCreateTestUsers: true,
})
defer server.Close()
t.Run("WithAcceptEncoding", func(t *testing.T) {
req, err := http.NewRequest("GET", server.URL+"/api/test-gzip", nil)
require.NoError(t, err)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := fleethttp.NewClient().Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "Expected gzip Content-Encoding when enabled")
})
t.Run("WithoutAcceptEncoding", func(t *testing.T) {
req, err := http.NewRequest("GET", server.URL+"/api/test-gzip", nil)
require.NoError(t, err)
// Do NOT set Accept-Encoding header
transport := fleethttp.NewTransport()
transport.DisableCompression = true // Prevents automatic addition of Accept-Encoding: gzip
client := fleethttp.NewClient()
client.Transport = transport
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Empty(t, resp.Header.Get("Content-Encoding"), "Expected no gzip Content-Encoding when Accept-Encoding not set")
})
})
t.Run("Disabled", func(t *testing.T) {
t.Parallel()
cfg := config.TestConfig()
cfg.Server.GzipResponses = false
_, server := RunServerForTestsWithDS(t, ds, &TestServerOpts{
FleetConfig: &cfg,
FeatureRoutes: []endpointer.HandlerRoutesFunc{testRoute},
SkipCreateTestUsers: true,
})
defer server.Close()
req, err := http.NewRequest("GET", server.URL+"/api/test-gzip", nil)
require.NoError(t, err)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := fleethttp.NewClient().Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Empty(t, resp.Header.Get("Content-Encoding"), "Expected no gzip Content-Encoding when disabled")
})
}