From 93711d17325d86fbc4c098707d594a67881fed5e Mon Sep 17 00:00:00 2001 From: Jimmy Wong Date: Thu, 22 Oct 2026 15:33:37 +0100 Subject: [PATCH 1/4] Add AdditionalCACerts to Configuration.Build This new config option add the ability to connect to a MitM'ing proxy --- docs/config.html | 7 +++++++ src/build/build_step.go | 10 ++++++--- src/build/remote_file_test.go | 39 +++++++++++++++++++++++++++++++++++ src/core/config.go | 29 ++++++++++++++++++++++++++ src/core/config_test.go | 18 ++++++++++++++++ src/update/update.go | 6 ++++++ 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/docs/config.html b/docs/config.html index 6b72691721..a4d64dfc8f 100644 --- a/docs/config.html +++ b/docs/config.html @@ -788,6 +788,13 @@

HttpProxy

{{ index .ConfigHelpText "build.httpproxy" }}

+
  • +
    +

    AdditionalCACerts

    + +

    {{ index .ConfigHelpText "build.additionalcacerts" }}

    +
    +
  • HashCheckers

    diff --git a/src/build/build_step.go b/src/build/build_step.go index 8cf817fdae..d2951dcd43 100644 --- a/src/build/build_step.go +++ b/src/build/build_step.go @@ -41,6 +41,7 @@ var errStop = fmt.Errorf("stopping build") // httpClient is the shared http client that we use for fetching remote files. var httpClient *retryablehttp.Client var httpClientOnce sync.Once +var httpClientErr error var httpClientLimiter chan struct{} var successfulRemoteTargetBuildDuration = metrics.NewHistogramVec( @@ -1058,15 +1059,18 @@ func fetchRemoteFile(state *core.BuildState, target *core.BuildTarget) error { httpClient = retryablehttp.NewClient() httpClient.Logger = &cli.HTTPLogWrapper{Log: log} + transport := httpClient.HTTPClient.Transport.(*http.Transport) if state.Config.Build.HTTPProxy != "" { - httpClient.HTTPClient.Transport = &http.Transport{ - Proxy: http.ProxyURL(state.Config.Build.HTTPProxy.AsURL()), - } + transport.Proxy = http.ProxyURL(state.Config.Build.HTTPProxy.AsURL()) } + transport.TLSClientConfig, httpClientErr = state.Config.TLSConfig() httpClient.HTTPClient.Timeout = time.Duration(state.Config.Build.Timeout) httpClientLimiter = make(chan struct{}, state.Config.Build.ParallelDownloads) }) + if httpClientErr != nil { + return httpClientErr + } if err := prepareDirectory(target.OutDir(), false); err != nil { return err diff --git a/src/build/remote_file_test.go b/src/build/remote_file_test.go index 6fe86a6705..55ead23028 100644 --- a/src/build/remote_file_test.go +++ b/src/build/remote_file_test.go @@ -1,8 +1,13 @@ package build import ( + "encoding/pem" "net" "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -97,3 +102,37 @@ func TestBasicAuth(t *testing.T) { err = fetchRemoteFile(state, target) require.NoError(t, err) } + +func TestAdditionalCACerts(t *testing.T) { + s := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Write([]byte("wibble")) + })) + defer s.Close() + certFile := filepath.Join(t.TempDir(), "ca.pem") + err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.Certificate().Raw}), 0644) + require.NoError(t, err) + + // The client is only configured once, so reset it to pick up config changes. + resetHTTPClient := func() { + httpClientOnce = sync.Once{} + httpClientErr = nil + } + resetHTTPClient() + t.Cleanup(resetHTTPClient) + + state, target := newState("//pkg:additional_ca_certs_test") + target.IsRemoteFile = true + target.Sources = []core.BuildInput{core.URLLabel(s.URL + "/file")} + target.AddOutput("file") + + err = fetchRemoteFile(state, target) + assert.ErrorContains(t, err, "certificate") + + resetHTTPClient() + state.Config.Build.AdditionalCACerts = []string{certFile} + err = fetchRemoteFile(state, target) + require.NoError(t, err) + contents, err := os.ReadFile(filepath.Join(target.TmpDir(), "file")) + require.NoError(t, err) + assert.Equal(t, "wibble", string(contents)) +} diff --git a/src/core/config.go b/src/core/config.go index f293c867a2..54c0ac40f4 100644 --- a/src/core/config.go +++ b/src/core/config.go @@ -4,6 +4,8 @@ package core import ( "crypto/sha1" + "crypto/tls" + "crypto/x509" "fmt" "io" iofs "io/fs" @@ -520,6 +522,7 @@ type Configuration struct { PassEnv []string `help:"A list of environment variables to pass from the current environment to build rules. For example\n\nPassEnv = HTTP_PROXY\n\nwould copy your HTTP_PROXY environment variable to the build env for any rules."` PassUnsafeEnv []string `help:"Similar to PassEnv, a list of environment variables to pass from the current environment to build rules. Unlike PassEnv, the environment variable values are not used when calculating build target hashes."` HTTPProxy cli.URL `help:"A URL to use as a proxy server for downloads. Only applies to internal ones - e.g. self-updates or remote_file rules."` + AdditionalCACerts []string `help:"A list of paths to PEM-encoded CA certificates to trust in addition to the system trust store when making HTTPS connections, including to HTTPProxy. Only applies to internal downloads - e.g. self-updates or remote_file rules. Relative paths are resolved against the repo root."` HashCheckers []string `help:"Set of hash algos supported by the 'hashes' argument on build rules. Defaults to: sha1,sha256,blake3." options:"sha1,sha256,blake3,xxhash,crc32,crc64"` HashFunction string `help:"The hash function to use internally for build actions." options:"sha1,sha256,blake3,xxhash,crc32,crc64"` ExitOnError bool `help:"True to have build actions automatically fail on error (essentially passing -e to the shell they run in)." var:"EXIT_ON_ERROR"` @@ -805,6 +808,32 @@ func (config *Configuration) EnsurePleaseLocation() { } } +// TLSConfig returns the TLS config to use for internal HTTPS downloads. It trusts the system cert +// pool plus any certificates in Build.AdditionalCACerts, or is nil if there are none of the latter. +func (config *Configuration) TLSConfig() (*tls.Config, error) { + if len(config.Build.AdditionalCACerts) == 0 { + return nil, nil + } + pool, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("failed to load system cert pool: %w", err) + } + for _, path := range config.Build.AdditionalCACerts { + path = fs.ExpandHomePath(path) + if !filepath.IsAbs(path) { + path = filepath.Join(RepoRoot, path) + } + pem, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read CA certificate: %w", err) + } + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("no valid PEM-encoded certificates found in %s", path) + } + } + return &tls.Config{RootCAs: pool}, nil +} + // Path returns the slice of strings corresponding to the PATH env var. func (config *Configuration) Path() []string { config.GetBuildEnv() // ensure it is initialised diff --git a/src/core/config_test.go b/src/core/config_test.go index d4d047ebf6..d80d8e688c 100644 --- a/src/core/config_test.go +++ b/src/core/config_test.go @@ -3,6 +3,7 @@ package core import ( "bytes" "os" + "path/filepath" "reflect" "strings" "testing" @@ -441,3 +442,20 @@ func TestPluginConfig(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []string{"fooc"}, config.Plugin["foo"].ExtraValues["fooctool"]) } + +func TestTLSConfig(t *testing.T) { + config := DefaultConfiguration() + tlsConfig, err := config.TLSConfig() + assert.NoError(t, err) + assert.Nil(t, tlsConfig) + + config.Build.AdditionalCACerts = []string{"/does/not/exist.pem"} + _, err = config.TLSConfig() + assert.Error(t, err) + + notPEM := filepath.Join(t.TempDir(), "not.pem") + require.NoError(t, os.WriteFile(notPEM, []byte("wibble"), 0644)) + config.Build.AdditionalCACerts = []string{notPEM} + _, err = config.TLSConfig() + assert.ErrorContains(t, err, "no valid PEM-encoded certificates") +} diff --git a/src/update/update.go b/src/update/update.go index 81220ec50b..1c18a6503b 100644 --- a/src/update/update.go +++ b/src/update/update.go @@ -57,6 +57,12 @@ func pleaseVersion() semver.Version { func CheckAndUpdate(config *core.Configuration, updatesEnabled, updateCommand, forceUpdate, verify, progress, prerelease bool) { httpClient = retryablehttp.NewClient() httpClient.Logger = &cli.HTTPLogWrapper{Log: log} + // This runs on every invocation, so only warn here; any download that needed the certs will fail anyway. + if tlsConfig, err := config.TLSConfig(); err != nil { + log.Warning("Failed to load additional CA certificates, falling back to the system trust store: %s", err) + } else { + httpClient.HTTPClient.Transport.(*http.Transport).TLSClientConfig = tlsConfig + } if !shouldUpdate(config, updatesEnabled, updateCommand, prerelease) && !forceUpdate { clean(config, updateCommand) From db6229794dcc6414aa364913ac400347ede8eb56 Mon Sep 17 00:00:00 2001 From: Jimmy Wong Date: Thu, 22 Oct 2026 16:53:30 +0100 Subject: [PATCH 2/4] pacify CI --- src/build/remote_file_test.go | 6 ++++-- src/core/config_test.go | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/build/remote_file_test.go b/src/build/remote_file_test.go index 55ead23028..8a181e5f47 100644 --- a/src/build/remote_file_test.go +++ b/src/build/remote_file_test.go @@ -108,8 +108,10 @@ func TestAdditionalCACerts(t *testing.T) { writer.Write([]byte("wibble")) })) defer s.Close() - certFile := filepath.Join(t.TempDir(), "ca.pem") - err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.Certificate().Raw}), 0644) + // t.TempDir() is relative when run remotely (TMPDIR is .), and relative paths are resolved against RepoRoot. + certFile, err := filepath.Abs(filepath.Join(t.TempDir(), "ca.pem")) + require.NoError(t, err) + err = os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.Certificate().Raw}), 0644) require.NoError(t, err) // The client is only configured once, so reset it to pick up config changes. diff --git a/src/core/config_test.go b/src/core/config_test.go index d80d8e688c..10c94bbe04 100644 --- a/src/core/config_test.go +++ b/src/core/config_test.go @@ -453,7 +453,9 @@ func TestTLSConfig(t *testing.T) { _, err = config.TLSConfig() assert.Error(t, err) - notPEM := filepath.Join(t.TempDir(), "not.pem") + // t.TempDir() is relative when run remotely (TMPDIR is .), and relative paths are resolved against RepoRoot. + notPEM, err := filepath.Abs(filepath.Join(t.TempDir(), "not.pem")) + require.NoError(t, err) require.NoError(t, os.WriteFile(notPEM, []byte("wibble"), 0644)) config.Build.AdditionalCACerts = []string{notPEM} _, err = config.TLSConfig() From 1d52467b87a58c4af1bd5c56eb4567bd2899e727 Mon Sep 17 00:00:00 2001 From: Jimmy Wong Date: Thu, 22 Oct 2026 17:52:50 +0100 Subject: [PATCH 3/4] Update changelog --- ChangeLog | 1 + 1 file changed, 1 insertion(+) diff --git a/ChangeLog b/ChangeLog index 3ddb6f19e6..c004884df6 100644 --- a/ChangeLog +++ b/ChangeLog @@ -5,6 +5,7 @@ Version 17.34.0 * Subinclude cycle detection (#3615) * Fix for `plz export --notrim` in some cases (#3621) * `plz init plugin go` now writes a filegroup for `go.mod` and sets `modfile` (#3587) + * New `AdditionalCACerts` config option to work with intercepting `HTTPProxy` (#3639) Version 17.33.0 --------------- From d9e4ffb9c99e9dce46b0e7dbc867b8af9f6e4109 Mon Sep 17 00:00:00 2001 From: Jimmy Wong Date: Fri, 23 Oct 2026 15:29:09 +0100 Subject: [PATCH 4/4] Die during update when HTTP client fails to load the TLS config --- src/update/update.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/update/update.go b/src/update/update.go index 1c18a6503b..64650e1528 100644 --- a/src/update/update.go +++ b/src/update/update.go @@ -57,12 +57,11 @@ func pleaseVersion() semver.Version { func CheckAndUpdate(config *core.Configuration, updatesEnabled, updateCommand, forceUpdate, verify, progress, prerelease bool) { httpClient = retryablehttp.NewClient() httpClient.Logger = &cli.HTTPLogWrapper{Log: log} - // This runs on every invocation, so only warn here; any download that needed the certs will fail anyway. - if tlsConfig, err := config.TLSConfig(); err != nil { - log.Warning("Failed to load additional CA certificates, falling back to the system trust store: %s", err) - } else { - httpClient.HTTPClient.Transport.(*http.Transport).TLSClientConfig = tlsConfig + tlsConfig, err := config.TLSConfig() + if err != nil { + log.Fatalf("Failed to load additional CA certificates: %s", err) } + httpClient.HTTPClient.Transport.(*http.Transport).TLSClientConfig = tlsConfig if !shouldUpdate(config, updatesEnabled, updateCommand, prerelease) && !forceUpdate { clean(config, updateCommand)