From 1ba2684d7838312f97b62fc850d1a581a95c19d5 Mon Sep 17 00:00:00 2001 From: "Tilian R. Honig" Date: Fri, 31 Jul 2026 15:41:02 +0200 Subject: [PATCH] feat: add support for private-network upstreams --- docs/configuration.md | 17 +++++++++++++++++ internal/config/config.go | 13 +++++++++++++ internal/config/config_test.go | 6 ++++++ internal/server/server.go | 6 +++++- 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8998ac2..0cce0a6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -121,6 +121,23 @@ upstream: cargo_download: "https://static.crates.io/crates" ``` +### Private-Network Upstreams + +Upstream fetches pass through an SSRF dial gate that refuses loopback, RFC1918/ULA, CGNAT, and link-local addresses, so an upstream on a private network (for example a registry inside the same Kubernetes cluster) is unreachable by default. +To use one, whitelist its hostname explicitly: + +```yaml +upstream: + maven: "http://maven-mirror.internal.svc.cluster.local:8080/releases" + allow_private_hosts: + - "maven-mirror.internal.svc.cluster.local" +``` + +Or via environment variable (comma-separated): +`PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS=maven-mirror.internal.svc.cluster.local` + +Hostnames are matched case-insensitively and exactly. Only the listed hosts are exempted. + ## Authentication Configure authentication for private upstream registries. Auth is matched by URL prefix, and credentials can reference environment variables using `${VAR_NAME}` syntax. diff --git a/internal/config/config.go b/internal/config/config.go index ec510d2..8bde40d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -308,6 +308,11 @@ type UpstreamConfig struct { // Keys are URL prefixes that are matched against request URLs. // Example: "https://npm.pkg.github.com" matches all requests to that host. Auth map[string]AuthConfig `json:"auth" yaml:"auth"` + + // AllowPrivateHosts lists upstream hostnames exempted from the + // SSRF dial gate's loopback/private-address checks, for upstreams + // that deliberately live on a private network and need to be reached. + AllowPrivateHosts []string `json:"allow_private_hosts" yaml:"allow_private_hosts"` } // AuthForURL returns the auth config that matches the given URL. @@ -482,6 +487,14 @@ func (c *Config) LoadFromEnv() { if v := os.Getenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL"); v != "" { c.Upstream.GradlePluginPortal = v } + if v := os.Getenv("PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS"); v != "" { + c.Upstream.AllowPrivateHosts = c.Upstream.AllowPrivateHosts[:0] + for _, h := range strings.Split(v, ",") { + if h = strings.TrimSpace(h); h != "" { + c.Upstream.AllowPrivateHosts = append(c.Upstream.AllowPrivateHosts, h) + } + } + } if v := os.Getenv("PROXY_COOLDOWN_DEFAULT"); v != "" { c.Cooldown.Default = v } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index decc18f..bd0936d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "reflect" "testing" "time" ) @@ -273,6 +274,7 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("PROXY_LOG_LEVEL", testLevelDebug) t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public") t.Setenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL", "https://plugins.example.com/m2") + t.Setenv("PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS", "maven-mirror.internal.svc, registry.internal ,") t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "true") t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB") t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h") @@ -302,6 +304,10 @@ func TestLoadFromEnv(t *testing.T) { if cfg.Upstream.GradlePluginPortal != "https://plugins.example.com/m2" { t.Errorf("Upstream.GradlePluginPortal = %q, want %q", cfg.Upstream.GradlePluginPortal, "https://plugins.example.com/m2") } + wantHosts := []string{"maven-mirror.internal.svc", "registry.internal"} + if !reflect.DeepEqual(cfg.Upstream.AllowPrivateHosts, wantHosts) { + t.Errorf("Upstream.AllowPrivateHosts = %v, want %v", cfg.Upstream.AllowPrivateHosts, wantHosts) + } if !cfg.Gradle.BuildCache.ReadOnly { t.Error("Gradle.BuildCache.ReadOnly = false, want true") } diff --git a/internal/server/server.go b/internal/server/server.go index 13c5997..17a7b01 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -157,7 +157,11 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) { // Start starts the HTTP server. func (s *Server) Start() error { // Create shared components with circuit breaker - baseFetcher := fetch.NewFetcher(fetch.WithAuthFunc(s.authForURL)) + fetchOpts := []fetch.Option{fetch.WithAuthFunc(s.authForURL)} + if len(s.cfg.Upstream.AllowPrivateHosts) > 0 { + fetchOpts = append(fetchOpts, fetch.WithAllowPrivateHosts(s.cfg.Upstream.AllowPrivateHosts...)) + } + baseFetcher := fetch.NewFetcher(fetchOpts...) fetcher := fetch.NewCircuitBreakerFetcher(baseFetcher) resolver := fetch.NewResolver() cd := &cooldown.Config{