diff --git a/README.md b/README.md index 559f1a80..3ec9f3da 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,7 @@ pluginManagement { ### Gradle HTTP Build Cache -Configure in `settings.gradle(.kts)`: +Configure in `settings.gradle.kts`: ```kotlin buildCache { @@ -311,11 +311,13 @@ buildCache { } remote { url = uri("http://localhost:8080/gradle/") - push = true + isPush = System.getenv("CI") != null } } ``` +The proxy rejects uploads unless `gradle.build_cache.read_only` is set to `false`, see [docs/configuration.md](docs/configuration.md#gradle-build-cache). + ### NuGet Configure in `nuget.config`: diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 75f00978..f6e7f958 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -82,7 +82,7 @@ // PROXY_LOG_FORMAT - Log format // PROXY_ACCESS_LOG_PATH - JSONL access log path // PROXY_UPSTREAM_* - Upstream URLs and network access controls -// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Disable Gradle PUT uploads +// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Set false to allow Gradle PUT uploads (default true) // PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE - Max Gradle PUT request body size // PROXY_GRADLE_BUILD_CACHE_MAX_AGE - Gradle cache max age eviction // PROXY_GRADLE_BUILD_CACHE_MAX_SIZE - Gradle cache max total size @@ -257,7 +257,7 @@ func runServe() { fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_OCI_DEFAULT Default OCI registry upstream URL\n") fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_DEBIAN Debian repository upstream URL\n") fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_RPM RPM repository upstream URL\n") - fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Disable Gradle PUT uploads\n") + fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Set false to allow Gradle PUT uploads (default true)\n") fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE Max Gradle PUT request body size\n") fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_AGE Gradle cache max age eviction\n") fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_SIZE Gradle cache max total size\n") diff --git a/config.example.yaml b/config.example.yaml index accae89a..326f9187 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -347,8 +347,9 @@ upstream: # Gradle HttpBuildCache configuration gradle: build_cache: - # Set to true to disable PUT uploads (read-only cache mode) - read_only: false + # PUT uploads are rejected by default; set to false on the instance + # your CI jobs upload to + read_only: true # Maximum accepted Gradle cache upload body size # Required and must be > 0 diff --git a/docs/configuration.md b/docs/configuration.md index ffd57fe2..53cffe75 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -674,7 +674,7 @@ The `/gradle` endpoint supports optional safeguards for upload control and cache ```yaml gradle: build_cache: - read_only: false + read_only: true max_upload_size: "100MB" max_age: "168h" max_size: "20GB" @@ -683,12 +683,14 @@ gradle: | Config | Environment | Description | |--------|-------------|-------------| -| `gradle.build_cache.read_only` | `PROXY_GRADLE_BUILD_CACHE_READ_ONLY` | Disable PUT uploads and keep GET/HEAD read-only | +| `gradle.build_cache.read_only` | `PROXY_GRADLE_BUILD_CACHE_READ_ONLY` | Reject PUT uploads and serve GET/HEAD only (default `true`; the variable takes `true`/`false` or `1`/`0`, any other value keeps uploads off and logs a warning) | | `gradle.build_cache.max_upload_size` | `PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE` | Maximum accepted PUT body size (must be > 0) | | `gradle.build_cache.max_age` | `PROXY_GRADLE_BUILD_CACHE_MAX_AGE` | Delete entries older than this duration (default `168h`, also accepts days such as `7d`; set `0` to disable) | | `gradle.build_cache.max_size` | `PROXY_GRADLE_BUILD_CACHE_MAX_SIZE` | Total size cap for `_gradle/http-build-cache`, deleting oldest first (`0` disables) | | `gradle.build_cache.sweep_interval` | `PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL` | Frequency for background eviction sweeps | +Uploads are off by default, so a client that can reach the proxy cannot overwrite cache entries that end up in other people's builds. Set `read_only: false` (or `PROXY_GRADLE_BUILD_CACHE_READ_ONLY=false`) on the instance your CI jobs upload to, and let only CI push (`isPush = System.getenv("CI") != null`). Before this default changed, uploads were accepted without configuration; setups that upload need the setting now. + `max_age` and `max_size` are independent and can be combined. When both are set, age-based eviction runs first, then size-based eviction trims remaining entries oldest-first. ## Cooldown diff --git a/internal/config/config.go b/internal/config/config.go index a9d7c212..b4ad942c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -198,6 +198,9 @@ type Config struct { // Health configures the /health endpoint behavior. Health HealthConfig `json:"health" yaml:"health"` + + // envWarnings collects environment values LoadFromEnv ignored. + envWarnings []string } // CooldownConfig configures version cooldown periods. @@ -463,6 +466,7 @@ type GradleConfig struct { // GradleBuildCacheConfig configures Gradle HttpBuildCache safeguards. type GradleBuildCacheConfig struct { // ReadOnly disables PUT uploads and keeps cache reads (GET/HEAD) enabled. + // Default: true; set false on the instance that CI uploads to. ReadOnly bool `json:"read_only" yaml:"read_only"` // MaxUploadSize caps a single PUT body size (e.g., "100MB"). Must be > 0. @@ -1064,7 +1068,7 @@ func Default() *Config { }, Gradle: GradleConfig{ BuildCache: GradleBuildCacheConfig{ - ReadOnly: false, + ReadOnly: true, MaxUploadSize: defaultGradleMaxUploadSizeStr, MaxAge: "168h", MaxSize: "", @@ -1120,6 +1124,28 @@ func setEnvBool(dst *bool, key string) { } } +// setGradleReadOnlyFromEnv parses strictly: an unrecognised value keeps +// uploads off instead of opening them, and is reported via EnvWarnings. +func (c *Config) setGradleReadOnlyFromEnv() { + const key = "PROXY_GRADLE_BUILD_CACHE_READ_ONLY" + v := os.Getenv(key) + if v == "" { + return + } + readOnly, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + c.Gradle.BuildCache.ReadOnly = true + c.envWarnings = append(c.envWarnings, fmt.Sprintf("ignoring %s=%q: not a boolean, keeping the Gradle build cache read-only", key, v)) + return + } + c.Gradle.BuildCache.ReadOnly = readOnly +} + +// EnvWarnings returns the environment values LoadFromEnv ignored. +func (c *Config) EnvWarnings() []string { + return c.envWarnings +} + func setEnvStringSlice(dst *[]string, key string) { value := os.Getenv(key) if value == "" { @@ -1222,7 +1248,7 @@ func (c *Config) LoadFromEnv() { setEnvString(&c.ArtifactMaxSize, "PROXY_ARTIFACT_MAX_SIZE") setEnvString(&c.MetadataRewriteCacheSize, "PROXY_METADATA_REWRITE_CACHE_SIZE") setEnvString(&c.HTTPTimeout, "PROXY_HTTP_TIMEOUT") - setEnvBool(&c.Gradle.BuildCache.ReadOnly, "PROXY_GRADLE_BUILD_CACHE_READ_ONLY") + c.setGradleReadOnlyFromEnv() setEnvString(&c.Gradle.BuildCache.MaxUploadSize, "PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE") setEnvString(&c.Gradle.BuildCache.MaxAge, "PROXY_GRADLE_BUILD_CACHE_MAX_AGE") setEnvString(&c.Gradle.BuildCache.MaxSize, "PROXY_GRADLE_BUILD_CACHE_MAX_SIZE") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e3b0a2c5..4750e5c5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -138,6 +138,9 @@ func TestDefault(t *testing.T) { if cfg.Gradle.BuildCache.MaxAge != "168h" { t.Errorf("Gradle.BuildCache.MaxAge = %q, want %q", cfg.Gradle.BuildCache.MaxAge, "168h") } + if !cfg.Gradle.BuildCache.ReadOnly { + t.Error("Gradle.BuildCache.ReadOnly = false, want true by default") + } if len(cfg.Upstream.AllowPrivateHosts) != 0 { t.Errorf("Upstream.AllowPrivateHosts = %v, want empty", cfg.Upstream.AllowPrivateHosts) } @@ -417,6 +420,64 @@ upstream: } } +func TestLoadGradleBuildCacheReadOnly(t *testing.T) { + tests := []struct { + name string + content string + want bool + }{ + {"block without read_only keeps default", "gradle:\n build_cache:\n max_age: \"12h\"\n", true}, + {"explicit false allows uploads", "gradle:\n build_cache:\n read_only: false\n", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil { + t.Fatalf("writing config file: %v", err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Gradle.BuildCache.ReadOnly != tt.want { + t.Errorf("Gradle.BuildCache.ReadOnly = %v, want %v", cfg.Gradle.BuildCache.ReadOnly, tt.want) + } + }) + } +} + +func TestLoadGradleBuildCacheReadOnlyFromEnv(t *testing.T) { + tests := []struct { + value string + fileRO bool + want bool + warnings int + }{ + {"false", true, false, 0}, + {"0", true, false, 0}, + {" FALSE ", true, false, 0}, + {"True", false, true, 0}, + {"1", false, true, 0}, + {"yes", false, true, 1}, + {"off", false, true, 1}, + {"", false, false, 0}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", tt.value) + cfg := Default() + cfg.Gradle.BuildCache.ReadOnly = tt.fileRO + cfg.LoadFromEnv() + if cfg.Gradle.BuildCache.ReadOnly != tt.want { + t.Errorf("Gradle.BuildCache.ReadOnly = %v, want %v", cfg.Gradle.BuildCache.ReadOnly, tt.want) + } + if got := len(cfg.EnvWarnings()); got != tt.warnings { + t.Errorf("len(EnvWarnings()) = %d, want %d: %v", got, tt.warnings, cfg.EnvWarnings()) + } + }) + } +} + func TestLoadJSON(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") @@ -466,7 +527,7 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("PROXY_UI_FAILURE_DETAILS", "true") t.Setenv("PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS", "registry.internal, 10.0.0.12") t.Setenv("PROXY_UPSTREAM_ALLOW_LOOPBACK", "true") - t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "true") + t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "false") t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB") t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h") t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_SIZE", "10GB") @@ -517,8 +578,8 @@ func TestLoadFromEnv(t *testing.T) { t.Errorf("Upstream.AllowPrivateHosts after empty env = %q, want unchanged", got) } - if !cfg.Gradle.BuildCache.ReadOnly { - t.Error("Gradle.BuildCache.ReadOnly = false, want true") + if cfg.Gradle.BuildCache.ReadOnly { + t.Error("Gradle.BuildCache.ReadOnly = true, want false from the environment") } if cfg.Gradle.BuildCache.MaxUploadSize != "32MB" { t.Errorf("Gradle.BuildCache.MaxUploadSize = %q, want %q", cfg.Gradle.BuildCache.MaxUploadSize, "32MB") diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index dbc3a69d..69f352aa 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -359,13 +359,14 @@ index-url = ` + baseURL + `/pypi/simple/`), Language: "Java/Kotlin", Endpoint: "/gradle/", Instructions: template.HTML(`

Configure Gradle to use the proxy for HttpBuildCache:

-
// In settings.gradle(.kts)
+
// In settings.gradle.kts
 buildCache {
   remote<HttpBuildCache> {
     url = uri("` + baseURL + `/gradle/")
-    push = true
+    isPush = System.getenv("CI") != null
   }
-}
`), +}
+

Uploads need gradle.build_cache.read_only: false on the proxy.

`), }, { ID: "nuget", diff --git a/internal/server/gradle_read_only_test.go b/internal/server/gradle_read_only_test.go new file mode 100644 index 00000000..0911bffb --- /dev/null +++ b/internal/server/gradle_read_only_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "io" + "log/slog" + "net" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/git-pkgs/proxy/internal/config" +) + +func startGradleServer(t *testing.T, readOnly bool) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserving proxy address: %v", err) + } + t.Cleanup(func() { _ = listener.Close() }) + + tempDir := t.TempDir() + cfg := config.Default() + cfg.Listen = listener.Addr().String() + cfg.BaseURL = "http://" + cfg.Listen + cfg.Database.Path = filepath.Join(tempDir, "proxy.db") + cfg.Storage.URL = "file://" + filepath.Join(tempDir, "artifacts") + cfg.Gradle.BuildCache.ReadOnly = readOnly + if err := cfg.Validate(); err != nil { + t.Fatalf("validating config: %v", err) + } + + proxyServer, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), BuildInfo{Version: "test", Commit: "test"}) + if err != nil { + t.Fatalf("creating server: %v", err) + } + startErr := make(chan error, 1) + go func() { startErr <- proxyServer.Start(listener) }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxyServer.Shutdown(ctx) + <-startErr + }) + return cfg.BaseURL +} + +func gradleRequest(t *testing.T, method, url, body string) int { + t.Helper() + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("building request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + _ = resp.Body.Close() + return resp.StatusCode +} + +func TestGradleBuildCacheReadOnlyFollowsConfig(t *testing.T) { + readOnly := startGradleServer(t, config.Default().Gradle.BuildCache.ReadOnly) + if got := gradleRequest(t, http.MethodPut, readOnly+"/gradle/abc123", "entry"); got != http.StatusMethodNotAllowed { + t.Errorf("PUT with default config = %d, want %d", got, http.StatusMethodNotAllowed) + } + if got := gradleRequest(t, http.MethodGet, readOnly+"/gradle/abc123", ""); got != http.StatusNotFound { + t.Errorf("GET after rejected PUT = %d, want %d", got, http.StatusNotFound) + } + + writable := startGradleServer(t, false) + if got := gradleRequest(t, http.MethodPut, writable+"/gradle/abc123", "entry"); got != http.StatusCreated { + t.Errorf("PUT with read_only false = %d, want %d", got, http.StatusCreated) + } + if got := gradleRequest(t, http.MethodGet, writable+"/gradle/abc123", ""); got != http.StatusOK { + t.Errorf("GET after PUT = %d, want %d", got, http.StatusOK) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d9699cd4..f9bdf5d5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -379,6 +379,9 @@ func (s *Server) serve(listener net.Listener) error { "ui_url", s.cfg.UIBaseURL, "storage", s.storage.URL(), "database", s.cfg.Database.String()) + for _, warning := range s.cfg.EnvWarnings() { + s.logger.Warn(warning) + } go s.updateCacheStatsMetrics() go s.startEvictionLoop(bgCtx) go s.startRetentionLoop(bgCtx)