From aea0b10a34026aa9d7b28cd3f6c73aea778996c4 Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Wed, 27 May 2026 17:08:08 +1000 Subject: [PATCH] fix: cert pool changes requiring pebble restart --- internals/httputil/certloader_linux.go | 34 + internals/httputil/certloader_unix.go | 119 +++ internals/httputil/doc.go | 19 + internals/httputil/export_test.go | 28 + internals/httputil/generate.go | 17 + internals/httputil/transport.go | 333 +++++++++ internals/httputil/transport_test.go | 62 ++ internals/overlord/checkstate/checkers.go | 9 +- .../overlord/checkstate/checkers_test.go | 29 +- internals/overlord/checkstate/handlers.go | 4 +- internals/overlord/checkstate/manager.go | 25 +- internals/overlord/logstate/gatherer.go | 11 +- internals/overlord/logstate/loki/loki.go | 3 +- internals/overlord/logstate/manager.go | 20 +- .../logstate/opentelemetry/opentelemetry.go | 3 +- tools/gencertloader/main.go | 693 ++++++++++++++++++ 16 files changed, 1373 insertions(+), 36 deletions(-) create mode 100644 internals/httputil/certloader_linux.go create mode 100644 internals/httputil/certloader_unix.go create mode 100644 internals/httputil/doc.go create mode 100644 internals/httputil/export_test.go create mode 100644 internals/httputil/generate.go create mode 100644 internals/httputil/transport.go create mode 100644 internals/httputil/transport_test.go create mode 100644 tools/gencertloader/main.go diff --git a/internals/httputil/certloader_linux.go b/internals/httputil/certloader_linux.go new file mode 100644 index 000000000..fd167d53f --- /dev/null +++ b/internals/httputil/certloader_linux.go @@ -0,0 +1,34 @@ +// Code generated by tools/gencertloader from crypto/x509/root_linux.go (go1.26.3); DO NOT EDIT. + +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package httputil + +// import "internal/goos" + +// Possible certificate files; stop after finding one. +var certFiles = []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Gentoo etc. + "/etc/pki/tls/certs/ca-bundle.crt", // Fedora/RHEL 6 + "/etc/ssl/ca-bundle.pem", // OpenSUSE + "/etc/pki/tls/cacert.pem", // OpenELEC + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7 + "/etc/ssl/cert.pem", // Alpine Linux +} + +// Possible directories with certificate files; all will be read. +var certDirectories = []string{ + "/etc/ssl/certs", // SLES10/SLES11, https://golang.org/issue/12139 + "/etc/pki/tls/certs", // Fedora/RHEL +} + +// func init() { +// if goos.IsAndroid == 1 { +// certDirectories = append(certDirectories, +// "/system/etc/security/cacerts", // Android system roots +// "/data/misc/keychain/certs-added", // User trusted CA folder +// ) +// } +// } diff --git a/internals/httputil/certloader_unix.go b/internals/httputil/certloader_unix.go new file mode 100644 index 000000000..63122a9f6 --- /dev/null +++ b/internals/httputil/certloader_unix.go @@ -0,0 +1,119 @@ +// Code generated by tools/gencertloader from crypto/x509/root_unix.go (go1.26.3); DO NOT EDIT. + +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build aix || dragonfly || freebsd || (js && wasm) || linux || netbsd || openbsd || solaris || wasip1 + +package httputil + +import ( + "crypto/x509" + "io/fs" + "os" + "path/filepath" + "strings" +) + +const ( + // certFileEnv is the environment variable which identifies where to locate + // the SSL certificate file. If set this overrides the system default. + certFileEnv = "SSL_CERT_FILE" + + // certDirEnv is the environment variable which identifies which directory + // to check for SSL certificate files. If set this overrides the system default. + // It is a colon separated list of directories. + // See https://www.openssl.org/docs/man1.0.2/man1/c_rehash.html. + certDirEnv = "SSL_CERT_DIR" +) + +// func (c *Certificate) systemVerify(opts *VerifyOptions) (chains [][]*Certificate, err error) { +// return nil, nil +// } + +// func loadSystemRoots() (*CertPool, error) { +func loadSystemRoots() (*x509.CertPool, error) { + // roots := NewCertPool() + roots := x509.NewCertPool() + + files := certFiles + if f := os.Getenv(certFileEnv); f != "" { + files = []string{f} + } + + var firstErr error + var hasContent bool + for _, file := range files { + data, err := os.ReadFile(file) + if err == nil { + // roots.AppendCertsFromPEM(data) + // roots.AppendCertsFromPEM(data) + hasContent = roots.AppendCertsFromPEM(data) || hasContent + break + } + if firstErr == nil && !os.IsNotExist(err) { + firstErr = err + } + } + + dirs := certDirectories + if d := os.Getenv(certDirEnv); d != "" { + // OpenSSL and BoringSSL both use ":" as the SSL_CERT_DIR separator. + // See: + // * https://golang.org/issue/35325 + // * https://www.openssl.org/docs/man1.0.2/man1/c_rehash.html + dirs = strings.Split(d, ":") + } + + for _, directory := range dirs { + fis, err := readUniqueDirectoryEntries(directory) + if err != nil { + if firstErr == nil && !os.IsNotExist(err) { + firstErr = err + } + continue + } + for _, fi := range fis { + data, err := os.ReadFile(directory + "/" + fi.Name()) + if err == nil { + // roots.AppendCertsFromPEM(data) + // roots.AppendCertsFromPEM(data) + hasContent = roots.AppendCertsFromPEM(data) || hasContent + } + } + } + + // if roots.len() > 0 || firstErr == nil { + if hasContent || firstErr == nil { + return roots, nil + } + + return nil, firstErr +} + +// readUniqueDirectoryEntries is like os.ReadDir but omits +// symlinks that point within the directory. +func readUniqueDirectoryEntries(dir string) ([]fs.DirEntry, error) { + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + uniq := files[:0] + for _, f := range files { + if !isSameDirSymlink(f, dir) { + uniq = append(uniq, f) + } + } + return uniq, nil +} + +// isSameDirSymlink reports whether fi in dir is a symlink with a +// target not containing a slash. +func isSameDirSymlink(f fs.DirEntry, dir string) bool { + if f.Type()&fs.ModeSymlink == 0 { + return false + } + target, err := os.Readlink(filepath.Join(dir, f.Name())) + return err == nil && !strings.Contains(target, "/") +} diff --git a/internals/httputil/doc.go b/internals/httputil/doc.go new file mode 100644 index 000000000..e56ff5f61 --- /dev/null +++ b/internals/httputil/doc.go @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// Package httputil provides a lazy-loading, refreshable HTTP transport +// that reloads the system TLS root cert pool on demand. The cert pool +// discovery logic is generated from the Go standard library by the +// tools/conformcertloader tool. +package httputil diff --git a/internals/httputil/export_test.go b/internals/httputil/export_test.go new file mode 100644 index 000000000..057632b4c --- /dev/null +++ b/internals/httputil/export_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build linux + +package httputil + +// LoadSystemRoots exposes loadSystemRoots for conformance testing. +var LoadSystemRoots = loadSystemRoots + +// Initialised reports whether the Transport's underlying http.Transport +// has been created yet (i.e. whether lazyInit has run). +func (t *Transport) Initialised() bool { + t.mu.RLock() + defer t.mu.RUnlock() + return t.transport != nil +} diff --git a/internals/httputil/generate.go b/internals/httputil/generate.go new file mode 100644 index 000000000..272779db2 --- /dev/null +++ b/internals/httputil/generate.go @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package httputil + +//go:generate go run github.com/canonical/pebble/tools/gencertloader -output . diff --git a/internals/httputil/transport.go b/internals/httputil/transport.go new file mode 100644 index 000000000..ed9b4a3eb --- /dev/null +++ b/internals/httputil/transport.go @@ -0,0 +1,333 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build linux + +package httputil + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/canonical/pebble/internals/logger" +) + +// Transport is a lazy-loading, refreshable http.RoundTripper. The +// system root cert pool is loaded on first use via x509.SystemCertPool. +// Call Refresh to reload cert pool entries from disk, logging any +// additions or removals. +type Transport struct { + mu sync.RWMutex + transport *http.Transport + trackedCerts map[[32]byte]string // fingerprint -> subject string + fileStates []certFileState +} + +type certFileState struct { + path string + modTime time.Time + size int64 +} + +// NewTransport creates a Transport. No cert loading is done until the +// transport is first used via RoundTrip. +func NewTransport() *Transport { + return &Transport{} +} + +// RoundTrip implements http.RoundTripper. It lazily loads the system +// cert pool on the first call. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + err := t.lazyInit() + if err != nil { + return nil, fmt.Errorf("cannot initialise transport: %w", err) + } + t.mu.RLock() + transport := t.transport + t.mu.RUnlock() + return transport.RoundTrip(req) +} + +// Refresh reloads cert files from disk and compares against the +// currently tracked cert set. Added and removed certificates are +// logged via logger.Noticef. If the transport has not yet been used, +// Refresh is a no-op. If no cert files have changed on disk since the +// last Refresh, Refresh is also a no-op. +func (t *Transport) Refresh() error { + t.mu.RLock() + initialized := t.transport != nil + t.mu.RUnlock() + if !initialized { + return nil + } + if !t.certFilesChanged() { + return nil + } + certs, err := loadSystemCerts() + if err != nil { + return fmt.Errorf("cannot load system certs: %w", err) + } + newTracked := make(map[[32]byte]string, len(certs)) + for _, c := range certs { + fp := sha256.Sum256(c.Raw) + newTracked[fp] = c.Subject.String() + } + t.mu.Lock() + defer t.mu.Unlock() + changed := false + for fp, subject := range newTracked { + if _, ok := t.trackedCerts[fp]; !ok { + logger.Noticef( + "Certificate %q (%s) added to root cert pool.", + subject, formatFingerprint(fp), + ) + changed = true + } + } + for fp, subject := range t.trackedCerts { + if _, ok := newTracked[fp]; !ok { + logger.Noticef( + "Certificate %q (%s) removed from root cert pool.", + subject, formatFingerprint(fp), + ) + changed = true + } + } + if !changed { + return nil + } + pool := x509.NewCertPool() + for _, c := range certs { + pool.AddCert(c) + } + old := t.transport + t.transport = buildTransport(pool) + t.trackedCerts = newTracked + t.fileStates = currentFileStates() + old.CloseIdleConnections() + return nil +} + +// lazyInit loads the system cert pool on first use. +func (t *Transport) lazyInit() error { + t.mu.RLock() + initialized := t.transport != nil + t.mu.RUnlock() + if initialized { + return nil + } + t.mu.Lock() + defer t.mu.Unlock() + if t.transport != nil { + return nil + } + // Use the stdlib's memoized pool for the initial load. + pool, err := x509.SystemCertPool() + if err != nil { + return fmt.Errorf("cannot load system cert pool: %w", err) + } + // Load individual certs from disk to establish the initial tracked set. + certs, certsErr := loadSystemCerts() + if certsErr != nil { + logger.Noticef("Cannot track system cert pool: %v", certsErr) + } + tracked := make(map[[32]byte]string, len(certs)) + for _, c := range certs { + fp := sha256.Sum256(c.Raw) + tracked[fp] = c.Subject.String() + } + t.transport = buildTransport(pool) + t.trackedCerts = tracked + t.fileStates = currentFileStates() + return nil +} + +// certFilesChanged returns true if any cert file's mtime or size has +// changed since the last check. +func (t *Transport) certFilesChanged() bool { + t.mu.RLock() + prev := t.fileStates + t.mu.RUnlock() + curr := currentFileStates() + if len(prev) != len(curr) { + return true + } + for i, p := range prev { + c := curr[i] + if p.path != c.path || p.modTime != c.modTime || p.size != c.size { + return true + } + } + return false +} + +// currentFileStates returns the current mtime and size for each cert +// file and directory that exists, following the same discovery order +// as loadSystemRoots. +func currentFileStates() []certFileState { + var states []certFileState + seen := make(map[string]bool) + files := certFiles + if f := os.Getenv(certFileEnv); f != "" { + files = []string{f} + } + for _, path := range files { + info, err := os.Stat(path) + if err != nil { + continue + } + if !seen[path] { + seen[path] = true + states = append(states, certFileState{ + path: path, + modTime: info.ModTime(), + size: info.Size(), + }) + } + break // certFiles: stop after finding first + } + dirs := certDirectories + if d := os.Getenv(certDirEnv); d != "" { + dirs = strings.Split(d, ":") + } + for _, dir := range dirs { + dinfo, err := os.Stat(dir) + if err != nil { + continue + } + if !seen[dir] { + seen[dir] = true + states = append(states, certFileState{ + path: dir, + modTime: dinfo.ModTime(), + size: dinfo.Size(), + }) + } + fis, err := readUniqueDirectoryEntries(dir) + if err != nil { + continue + } + for _, fi := range fis { + path := dir + "/" + fi.Name() + info, err := fi.Info() + if err != nil { + continue + } + if !seen[path] { + seen[path] = true + states = append(states, certFileState{ + path: path, + modTime: info.ModTime(), + size: info.Size(), + }) + } + } + } + return states +} + +// loadSystemCerts reads individual x509 certificates from the system +// cert files, following the same discovery logic as loadSystemRoots. +func loadSystemCerts() ([]*x509.Certificate, error) { + var certs []*x509.Certificate + files := certFiles + if f := os.Getenv(certFileEnv); f != "" { + files = []string{f} + } + var firstErr error + for _, path := range files { + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) && firstErr == nil { + firstErr = err + } + continue + } + certs = append(certs, parsePEMCerts(data)...) + break + } + dirs := certDirectories + if d := os.Getenv(certDirEnv); d != "" { + dirs = strings.Split(d, ":") + } + for _, dir := range dirs { + fis, err := readUniqueDirectoryEntries(dir) + if err != nil { + if !os.IsNotExist(err) && firstErr == nil { + firstErr = err + } + continue + } + for _, fi := range fis { + data, err := os.ReadFile(dir + "/" + fi.Name()) + if err == nil { + certs = append(certs, parsePEMCerts(data)...) + } + } + } + if len(certs) > 0 || firstErr == nil { + return certs, nil + } + return nil, firstErr +} + +// parsePEMCerts parses all PEM-encoded certificates from data. +func parsePEMCerts(data []byte) []*x509.Certificate { + var certs []*x509.Certificate + for len(data) > 0 { + var block *pem.Block + block, data = pem.Decode(data) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + continue + } + certs = append(certs, cert) + } + return certs +} + +// buildTransport creates a new *http.Transport with the given cert +// pool, cloning DefaultTransport's connection settings. +func buildTransport(pool *x509.CertPool) *http.Transport { + t := http.DefaultTransport.(*http.Transport).Clone() + t.TLSClientConfig = &tls.Config{RootCAs: pool} + return t +} + +// formatFingerprint formats a SHA-256 fingerprint as a +// colon-separated hex string. +func formatFingerprint(fp [32]byte) string { + var sb strings.Builder + for i, b := range fp { + if i > 0 { + sb.WriteByte(':') + } + fmt.Fprintf(&sb, "%02x", b) + } + return sb.String() +} diff --git a/internals/httputil/transport_test.go b/internals/httputil/transport_test.go new file mode 100644 index 000000000..ba098e4fa --- /dev/null +++ b/internals/httputil/transport_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build linux + +package httputil_test + +import ( + "crypto/x509" + "testing" + + . "gopkg.in/check.v1" + + "github.com/canonical/pebble/internals/httputil" +) + +func Test(t *testing.T) { TestingT(t) } + +type transportSuite struct{} + +var _ = Suite(&transportSuite{}) + +// TestLoadSystemRootsConformance validates that our generated +// loadSystemRoots produces a cert pool equal to x509.SystemCertPool. +// This confirms the generated code correctly replicates stdlib behaviour. +func (s *transportSuite) TestLoadSystemRootsConformance(c *C) { + systemPool, err := x509.SystemCertPool() + c.Assert(err, IsNil) + + ourPool, err := httputil.LoadSystemRoots() + c.Assert(err, IsNil) + + c.Check(ourPool.Equal(systemPool), Equals, true) +} + +// TestTransportLazyLoad verifies no cert loading occurs until RoundTrip +// is called. +func (s *transportSuite) TestTransportLazyLoad(c *C) { + t := httputil.NewTransport() + // Transport is initialised but transport field must be nil. + c.Check(t.Initialised(), Equals, false) +} + +// TestTransportRefreshBeforeUseIsNoop verifies Refresh is a no-op +// before the transport has been used. +func (s *transportSuite) TestTransportRefreshBeforeUseIsNoop(c *C) { + t := httputil.NewTransport() + err := t.Refresh() + c.Assert(err, IsNil) + c.Check(t.Initialised(), Equals, false) +} diff --git a/internals/overlord/checkstate/checkers.go b/internals/overlord/checkstate/checkers.go index c9b2a099b..6fcd11d6e 100644 --- a/internals/overlord/checkstate/checkers.go +++ b/internals/overlord/checkstate/checkers.go @@ -44,14 +44,15 @@ const ( // httpChecker is a checker that ensures an HTTP GET at a specified URL returns 2xx. type httpChecker struct { - name string - url string - headers map[string]string + name string + url string + headers map[string]string + transport http.RoundTripper } func (c *httpChecker) check(ctx context.Context) error { logger.Debugf("Check %q (http): requesting %q", c.name, c.url) - client := &http.Client{} + client := &http.Client{Transport: c.transport} request, err := http.NewRequestWithContext(ctx, "GET", c.url, nil) if err != nil { return fmt.Errorf("cannot build request: %w", err) diff --git a/internals/overlord/checkstate/checkers_test.go b/internals/overlord/checkstate/checkers_test.go index 57608d577..477c70c04 100644 --- a/internals/overlord/checkstate/checkers_test.go +++ b/internals/overlord/checkstate/checkers_test.go @@ -56,15 +56,16 @@ func (s *CheckersSuite) TestHTTP(c *C) { defer server.Close() // Good 200 URL works - chk := &httpChecker{url: server.URL + "/foo/bar"} + chk := &httpChecker{url: server.URL + "/foo/bar", transport: nil} err := chk.check(context.Background()) c.Assert(err, IsNil) c.Assert(path, Equals, "/foo/bar") // Custom headers are sent through chk = &httpChecker{ - url: server.URL + "/foo/bar", - headers: map[string]string{"X-Name": "Bob Smith", "User-Agent": "pebble-test"}, + url: server.URL + "/foo/bar", + headers: map[string]string{"X-Name": "Bob Smith", "User-Agent": "pebble-test"}, + transport: nil, } err = chk.check(context.Background()) c.Assert(err, IsNil) @@ -73,14 +74,14 @@ func (s *CheckersSuite) TestHTTP(c *C) { c.Assert(headers.Get("User-Agent"), Equals, "pebble-test") // Non-2xx status code returns error - chk = &httpChecker{url: server.URL + "/404"} + chk = &httpChecker{url: server.URL + "/404", transport: nil} err = chk.check(context.Background()) c.Assert(err, ErrorMatches, "non-2xx status code 404") c.Assert(path, Equals, "/404") // In case of non-2xx status, short response body is fully included in error details response = "error details" - chk = &httpChecker{url: server.URL + "/500"} + chk = &httpChecker{url: server.URL + "/500", transport: nil} err = chk.check(context.Background()) c.Assert(err, ErrorMatches, "non-2xx status code 500") detailsErr, ok := err.(*detailsError) @@ -93,7 +94,7 @@ func (s *CheckersSuite) TestHTTP(c *C) { fmt.Fprintf(&output, "line %d\n", i) } response = output.String() - chk = &httpChecker{url: server.URL + "/500"} + chk = &httpChecker{url: server.URL + "/500", transport: nil} err = chk.check(context.Background()) c.Assert(err, ErrorMatches, "non-2xx status code 500") detailsErr, ok = err.(*detailsError) @@ -103,18 +104,18 @@ func (s *CheckersSuite) TestHTTP(c *C) { // Cancelled context returns error ctx, cancel := context.WithCancel(context.Background()) cancel() - chk = &httpChecker{url: server.URL} + chk = &httpChecker{url: server.URL, transport: nil} err = chk.check(ctx) c.Assert(err, ErrorMatches, ".* context canceled") // After server closed, should get a network dial error server.Close() - chk = &httpChecker{url: server.URL} + chk = &httpChecker{url: server.URL, transport: nil} err = chk.check(context.Background()) c.Assert(err, ErrorMatches, ".* connection refused") // Malformed URL returns an error - chk = &httpChecker{url: "#!@$%@#@"} + chk = &httpChecker{url: "#!@$%@#@", transport: nil} err = chk.check(ctx) c.Assert(err, ErrorMatches, "cannot build request: .*") } @@ -266,7 +267,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) { URL: "https://example.com/foo", Headers: map[string]string{"k": "v"}, }, - }) + }, nil) http, ok := chk.(*httpChecker) c.Assert(ok, Equals, true) c.Check(http.name, Equals, "http") @@ -279,7 +280,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) { Port: 80, Host: "localhost", }, - }) + }, nil) tcp, ok := chk.(*tcpChecker) c.Assert(ok, Equals, true) c.Check(tcp.name, Equals, "tcp") @@ -298,7 +299,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) { Group: "group", WorkingDir: "/working/dir", }, - }) + }, nil) exec, ok := chk.(*execChecker) c.Assert(ok, Equals, true) c.Assert(exec.name, Equals, "exec") @@ -329,7 +330,7 @@ func (s *CheckersSuite) TestExecContextNoOverride(c *C) { ServiceContext: "svc1", }, }) - chk := newChecker(config) + chk := newChecker(config, nil) exec, ok := chk.(*execChecker) c.Assert(ok, Equals, true) c.Check(exec.name, Equals, "exec") @@ -367,7 +368,7 @@ func (s *CheckersSuite) TestExecContextOverride(c *C) { WorkingDir: "/working/dir", }, }) - chk := newChecker(config) + chk := newChecker(config, nil) exec, ok := chk.(*execChecker) c.Assert(ok, Equals, true) c.Check(exec.name, Equals, "exec") diff --git a/internals/overlord/checkstate/handlers.go b/internals/overlord/checkstate/handlers.go index 88697f594..3477dd7bc 100644 --- a/internals/overlord/checkstate/handlers.go +++ b/internals/overlord/checkstate/handlers.go @@ -48,7 +48,7 @@ func (m *CheckManager) doPerformCheck(task *state.Task, tomb *tombpkg.Tomb) erro prevChangeID := data.prevChangeID m.checksLock.Unlock() - chk := newChecker(config) + chk := newChecker(config, m.transport) performCheck := func() (shouldExit bool, err error) { //lint:ignore SA1012 providing a nil context to tomb.Context() is valid @@ -166,7 +166,7 @@ func (m *CheckManager) doRecoverCheck(task *state.Task, tomb *tombpkg.Tomb) erro prevChangeID := data.prevChangeID m.checksLock.Unlock() - chk := newChecker(config) + chk := newChecker(config, m.transport) recoverCheck := func() (shouldExit bool, err error) { //lint:ignore SA1012 providing a nil context to tomb.Context() is valid diff --git a/internals/overlord/checkstate/manager.go b/internals/overlord/checkstate/manager.go index 99d2e324f..e8b9ab9da 100644 --- a/internals/overlord/checkstate/manager.go +++ b/internals/overlord/checkstate/manager.go @@ -17,6 +17,7 @@ package checkstate import ( "context" "fmt" + "net/http" "reflect" "sort" "strings" @@ -24,6 +25,7 @@ import ( "gopkg.in/tomb.v2" + "github.com/canonical/pebble/internals/httputil" "github.com/canonical/pebble/internals/logger" "github.com/canonical/pebble/internals/metrics" "github.com/canonical/pebble/internals/overlord/planstate" @@ -48,6 +50,7 @@ type CheckManager struct { checksLock sync.Mutex checks map[string]*checkData + transport *httputil.Transport } // FailureFunc is the type of function called when a failure action is triggered. @@ -56,9 +59,10 @@ type FailureFunc func(name string) // NewManager creates a new check manager. func NewManager(s *state.State, runner *state.TaskRunner, planMgr *planstate.PlanManager) *CheckManager { manager := &CheckManager{ - state: s, - checks: make(map[string]*checkData), - planMgr: planMgr, + state: s, + checks: make(map[string]*checkData), + planMgr: planMgr, + transport: httputil.NewTransport(), } // Health check changes can be long-running; ensure they don't get pruned. @@ -103,6 +107,10 @@ func (m *CheckManager) NotifyCheckFailed(f FailureFunc) { // PlanChanged handles updates to the plan (server configuration), // stopping the previous checks and starting the new ones as required. func (m *CheckManager) PlanChanged(newPlan *plan.Plan) { + if err := m.transport.Refresh(); err != nil { + logger.Noticef("Cannot refresh TLS cert pool: %v", err) + } + m.state.Lock() defer m.state.Unlock() @@ -254,13 +262,14 @@ func checkType(config *plan.Check) string { // newChecker creates a new checker of the configured type. Assumes // mergeServiceContext has already been called. -func newChecker(config *plan.Check) checker { +func newChecker(config *plan.Check, transport http.RoundTripper) checker { switch { case config.HTTP != nil: return &httpChecker{ - name: config.Name, - url: config.HTTP.URL, - headers: config.HTTP.Headers, + name: config.Name, + url: config.HTTP.URL, + headers: config.HTTP.Headers, + transport: transport, } case config.TCP != nil: @@ -658,7 +667,7 @@ func (m *CheckManager) RefreshCheck(ctx context.Context, check *plan.Check) (*Ch // If the check is stopped, run the check directly without using changes and tasks. if changeID == "" { - chk := newChecker(check) + chk := newChecker(check, m.transport) err := runCheck(ctx, chk, check.Timeout.Value) if err != nil { return getCheckInfo(), fmt.Errorf("%s", errorDetails(err)) diff --git a/internals/overlord/logstate/gatherer.go b/internals/overlord/logstate/gatherer.go index 47ecbdfe7..422e70eba 100644 --- a/internals/overlord/logstate/gatherer.go +++ b/internals/overlord/logstate/gatherer.go @@ -17,6 +17,7 @@ package logstate import ( "context" "fmt" + "net/http" "os" "time" @@ -89,6 +90,7 @@ type logGathererOptions struct { maxBufferedEntries int timeoutCurrentFlush time.Duration timeoutFinalFlush time.Duration + transport http.RoundTripper // method to get a new client newClient func(*plan.LogTarget) (logClient, error) } @@ -137,7 +139,10 @@ func fillDefaultOptions(options *logGathererOptions) *logGathererOptions { options.timeoutFinalFlush = timeoutFinalFlush } if options.newClient == nil { - options.newClient = newLogClient + t := options.transport + options.newClient = func(target *plan.LogTarget) (logClient, error) { + return newLogClient(target, t) + } } return options } @@ -367,13 +372,14 @@ type logClient interface { SetLabels(serviceName string, labels map[string]string) } -func newLogClient(target *plan.LogTarget) (logClient, error) { +func newLogClient(target *plan.LogTarget, transport http.RoundTripper) (logClient, error) { switch target.Type { case plan.LokiTarget: return loki.NewClient(&loki.ClientOptions{ TargetName: target.Name, Location: target.Location, UserAgent: fmt.Sprintf("%s/%s", cmd.ProgramName, cmd.Version), + Transport: transport, }), nil case plan.OpenTelemetryTarget: return opentelemetry.NewClient(&opentelemetry.ClientOptions{ @@ -381,6 +387,7 @@ func newLogClient(target *plan.LogTarget) (logClient, error) { Location: target.Location, UserAgent: fmt.Sprintf("%s/%s", cmd.ProgramName, cmd.Version), ScopeName: cmd.ProgramName, + Transport: transport, }), nil case plan.SyslogTarget: hostname, err := os.Hostname() diff --git a/internals/overlord/logstate/loki/loki.go b/internals/overlord/logstate/loki/loki.go index 1bba17640..07fd73510 100644 --- a/internals/overlord/logstate/loki/loki.go +++ b/internals/overlord/logstate/loki/loki.go @@ -54,7 +54,7 @@ func NewClient(options *ClientOptions) *Client { fillDefaultOptions(&opts) c := &Client{ options: &opts, - httpClient: &http.Client{Timeout: opts.RequestTimeout}, + httpClient: &http.Client{Timeout: opts.RequestTimeout, Transport: opts.Transport}, buffer: make([]entryWithService, 2*opts.MaxRequestEntries), labels: make(map[string]json.RawMessage), } @@ -70,6 +70,7 @@ type ClientOptions struct { UserAgent string TargetName string Location string + Transport http.RoundTripper } func fillDefaultOptions(options *ClientOptions) { diff --git a/internals/overlord/logstate/manager.go b/internals/overlord/logstate/manager.go index 465828993..4084df63d 100644 --- a/internals/overlord/logstate/manager.go +++ b/internals/overlord/logstate/manager.go @@ -17,6 +17,7 @@ package logstate import ( "sync" + "github.com/canonical/pebble/internals/httputil" "github.com/canonical/pebble/internals/logger" "github.com/canonical/pebble/internals/plan" "github.com/canonical/pebble/internals/servicelog" @@ -29,19 +30,30 @@ type LogManager struct { plan *plan.Plan newGatherer func(*plan.LogTarget) (*logGatherer, error) + transport *httputil.Transport } func NewLogManager() *LogManager { - return &LogManager{ - gatherers: map[string]*logGatherer{}, - buffers: map[string]*servicelog.RingBuffer{}, - newGatherer: newLogGatherer, + m := &LogManager{ + gatherers: map[string]*logGatherer{}, + buffers: map[string]*servicelog.RingBuffer{}, + transport: httputil.NewTransport(), } + m.newGatherer = func(t *plan.LogTarget) (*logGatherer, error) { + return newLogGathererInternal(t, &logGathererOptions{ + transport: m.transport, + }) + } + return m } // PlanChanged is called by the service manager when the plan changes. // Based on the new plan, we will Stop old gatherers and start new ones. func (m *LogManager) PlanChanged(pl *plan.Plan) { + if err := m.transport.Refresh(); err != nil { + logger.Noticef("Cannot refresh TLS cert pool: %v", err) + } + m.mu.Lock() defer m.mu.Unlock() diff --git a/internals/overlord/logstate/opentelemetry/opentelemetry.go b/internals/overlord/logstate/opentelemetry/opentelemetry.go index f41e94fc3..4ace82edb 100644 --- a/internals/overlord/logstate/opentelemetry/opentelemetry.go +++ b/internals/overlord/logstate/opentelemetry/opentelemetry.go @@ -131,7 +131,7 @@ func NewClient(options *ClientOptions) *Client { fillDefaultOptions(&opts) c := &Client{ options: &opts, - httpClient: &http.Client{Timeout: opts.RequestTimeout}, + httpClient: &http.Client{Timeout: opts.RequestTimeout, Transport: opts.Transport}, buffer: make([]entryWithService, 2*opts.MaxRequestEntries), resourceAttributes: make(map[string][]keyValue), } @@ -148,6 +148,7 @@ type ClientOptions struct { ScopeName string TargetName string Location string + Transport http.RoundTripper } func fillDefaultOptions(options *ClientOptions) { diff --git a/tools/gencertloader/main.go b/tools/gencertloader/main.go new file mode 100644 index 000000000..fc5a2c91b --- /dev/null +++ b/tools/gencertloader/main.go @@ -0,0 +1,693 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// Command gencertloader generates certloader_unix.go and +// certloader_linux.go in the output directory by extracting and +// transforming certificate loading code from the Go standard library. +package main + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// commentNote records a single mutation: searchFor is a line of the +// formatted output to locate; comment is the original code to insert +// as a comment immediately before that line. +type commentNote struct { + searchFor string + comment string +} + +// stmtTransform inspects a statement and, if it matches a pattern, +// returns the comment note for the mutation and the (possibly new) +// replacement statement. +type stmtTransform func(*token.FileSet, ast.Stmt) (commentNote, ast.Stmt, bool) + +func main() { + outDir, err := parseFlags() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + goroot, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + fmt.Fprintf(os.Stderr, "Error running go env GOROOT: %v\n", err) + os.Exit(1) + } + gorootStr := strings.TrimSpace(string(goroot)) + + fileSet := token.NewFileSet() + + unixSrc := filepath.Join( + gorootStr, "src", "crypto", "x509", "root_unix.go", + ) + if err := processUnix(fileSet, unixSrc, outDir); err != nil { + fmt.Fprintf(os.Stderr, "Error processing root_unix.go: %v\n", err) + os.Exit(1) + } + + linuxSrc := filepath.Join( + gorootStr, "src", "crypto", "x509", "root_linux.go", + ) + if err := processLinux(fileSet, linuxSrc, outDir); err != nil { + fmt.Fprintf(os.Stderr, "Error processing root_linux.go: %v\n", err) + os.Exit(1) + } +} + +func parseFlags() (string, error) { + args := os.Args[1:] + outDir := "." + for i := 0; i < len(args); i++ { + switch args[i] { + case "-output": + if i+1 >= len(args) { + return "", fmt.Errorf("-output flag requires an argument") + } + outDir = args[i+1] + i++ + default: + return "", fmt.Errorf("unknown flag: %s", args[i]) + } + } + return outDir, nil +} + +func processUnix(fset *token.FileSet, srcPath string, outDir string) error { + src, err := os.ReadFile(srcPath) + if err != nil { + return err + } + f, err := parser.ParseFile(fset, srcPath, src, parser.ParseComments) + if err != nil { + return err + } + + systemVerifyText := findFuncDeclText(fset, src, f.Decls, "systemVerify") + + var excluded []ast.Node + if fn := findFuncDecl(f.Decls, "systemVerify"); fn != nil { + excluded = append(excluded, fn) + } + f.Comments = filterComments(f.Comments, fset, excluded) + + f.Name.Name = "httputil" + f.Decls = removeMethod(f.Decls, "systemVerify") + addImport(f, "crypto/x509") + notes, funcStart := fixLoadSystemRoots(fset, f) + + var body strings.Builder + if err := format.Node(&body, fset, f); err != nil { + return fmt.Errorf("format certloader_unix.go: %w", err) + } + code := postProcessUnix(body.String(), notes, systemVerifyText, funcStart) + goVersion := goVersion() + return writeOutputText( + outDir, "certloader_unix.go", "root_unix.go", + goVersion, code, + ) +} + +func processLinux(fset *token.FileSet, srcPath string, outDir string) error { + src, err := os.ReadFile(srcPath) + if err != nil { + return err + } + f, err := parser.ParseFile(fset, srcPath, src, parser.ParseComments) + if err != nil { + return err + } + + importsText := findImportDeclText(fset, src, f) + initText := findFuncDeclText(fset, src, f.Decls, "init") + + var excluded []ast.Node + if imp := findImportDecl(f); imp != nil { + excluded = append(excluded, imp) + } + if fn := findFuncDecl(f.Decls, "init"); fn != nil { + excluded = append(excluded, fn) + } + f.Comments = filterComments(f.Comments, fset, excluded) + + f.Name.Name = "httputil" + f.Decls = removeInitFunction(f.Decls) + removeAllImports(f) + + var body strings.Builder + if err := format.Node(&body, fset, f); err != nil { + return fmt.Errorf("format certloader_linux.go: %w", err) + } + code := postProcessLinux( + body.String(), f.Name.Name, importsText, initText, + ) + goVersion := goVersion() + return writeOutputText( + outDir, "certloader_linux.go", "root_linux.go", + goVersion, code, + ) +} + +// goVersion returns the current Go toolchain version string (e.g. "go1.26.3"). +func goVersion() string { + out, err := exec.Command("go", "env", "GOVERSION").Output() + if err != nil { + return "unknown" + } + return strings.TrimSpace(string(out)) +} + +// formatNode formats any single AST node to trimmed source text. +func formatNode(fset *token.FileSet, node ast.Node) string { + var buf bytes.Buffer + _ = format.Node(&buf, fset, node) + return strings.TrimSpace(buf.String()) +} + +// firstLine returns the first newline-delimited line of s. +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func removeMethod(decls []ast.Decl, name string) []ast.Decl { + var result []ast.Decl + for _, decl := range decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Name.Name == name { + continue + } + result = append(result, decl) + } + return result +} + +func removeInitFunction(decls []ast.Decl) []ast.Decl { + var result []ast.Decl + for _, decl := range decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Name.Name == "init" { + continue + } + result = append(result, decl) + } + return result +} + +// fixLoadSystemRoots applies all mutations to the loadSystemRoots +// function. It returns a slice of comment notes (one per mutation) +// and the function-start string used to anchor the systemVerify +// comment block insertion. +func fixLoadSystemRoots( + fset *token.FileSet, f *ast.File, +) (notes []commentNote, funcStart string) { + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "loadSystemRoots" { + continue + } + funcStart = "func " + fn.Name.Name + "(" + notes = append(notes, fixReturnType(fset, fn)...) + notes = append(notes, transformBlock(fset, fn.Body, []stmtTransform{ + transformNewCertPool, + transformAppendCerts, + transformRootsLen, + })...) + // Insert var hasContent bool before the first range statement. + hasContentVar := &ast.DeclStmt{ + Decl: &ast.GenDecl{ + Tok: token.VAR, + Specs: []ast.Spec{ + &ast.ValueSpec{ + Names: []*ast.Ident{{Name: "hasContent"}}, + Type: &ast.Ident{Name: "bool"}, + }, + }, + }, + } + idx := indexOfFirstRange(fn.Body.List) + fn.Body.List = append( + fn.Body.List[:idx], + append([]ast.Stmt{hasContentVar}, fn.Body.List[idx:]...)..., + ) + return + } + return +} + +// fixReturnType changes *CertPool in the results list to *x509.CertPool, +// returning a comment note derived from the actual formatted signature. +func fixReturnType(fset *token.FileSet, fn *ast.FuncDecl) []commentNote { + if fn.Type.Results == nil { + return nil + } + for _, field := range fn.Type.Results.List { + star, ok := field.Type.(*ast.StarExpr) + if !ok { + continue + } + id, ok := star.X.(*ast.Ident) + if !ok || id.Name != "CertPool" { + continue + } + origSig := firstLine(formatNode(fset, fn)) + star.X = &ast.SelectorExpr{ + X: &ast.Ident{Name: "x509"}, + Sel: &ast.Ident{Name: "CertPool"}, + } + newSig := firstLine(formatNode(fset, fn)) + return []commentNote{{searchFor: newSig, comment: origSig}} + } + return nil +} + +// transformBlock applies each transform to every statement in block, +// recursing into nested block-containing statements. +func transformBlock( + fset *token.FileSet, + block *ast.BlockStmt, + ts []stmtTransform, +) []commentNote { + var notes []commentNote + for i, stmt := range block.List { + for _, t := range ts { + note, newStmt, ok := t(fset, stmt) + if !ok { + continue + } + block.List[i] = newStmt + notes = append(notes, note) + break + } + notes = append(notes, recurseBlocks(fset, block.List[i], ts)...) + } + return notes +} + +// recurseBlocks recurses transformBlock into the bodies of statements +// that contain nested block statements. +func recurseBlocks( + fset *token.FileSet, + stmt ast.Stmt, + ts []stmtTransform, +) []commentNote { + switch s := stmt.(type) { + case *ast.RangeStmt: + return transformBlock(fset, s.Body, ts) + case *ast.ForStmt: + return transformBlock(fset, s.Body, ts) + case *ast.IfStmt: + notes := transformBlock(fset, s.Body, ts) + switch e := s.Else.(type) { + case *ast.BlockStmt: + notes = append(notes, transformBlock(fset, e, ts)...) + case *ast.IfStmt: + notes = append(notes, recurseBlocks(fset, e, ts)...) + } + return notes + } + return nil +} + +// transformNewCertPool replaces NewCertPool() with x509.NewCertPool(), +// deriving the comment note from the formatted AST before and after. +func transformNewCertPool( + fset *token.FileSet, stmt ast.Stmt, +) (commentNote, ast.Stmt, bool) { + assign, ok := stmt.(*ast.AssignStmt) + if !ok || len(assign.Rhs) != 1 { + return commentNote{}, nil, false + } + call, ok := assign.Rhs[0].(*ast.CallExpr) + if !ok { + return commentNote{}, nil, false + } + id, ok := call.Fun.(*ast.Ident) + if !ok || id.Name != "NewCertPool" { + return commentNote{}, nil, false + } + orig := formatNode(fset, stmt) + call.Fun = &ast.SelectorExpr{ + X: &ast.Ident{Name: "x509"}, + Sel: &ast.Ident{Name: "NewCertPool"}, + } + return commentNote{ + searchFor: formatNode(fset, stmt), + comment: orig, + }, stmt, true +} + +// transformAppendCerts replaces roots.AppendCertsFromPEM(data) with +// hasContent = roots.AppendCertsFromPEM(data) || hasContent, deriving +// the comment note from the formatted AST before and after. +func transformAppendCerts( + fset *token.FileSet, stmt ast.Stmt, +) (commentNote, ast.Stmt, bool) { + exprStmt, ok := stmt.(*ast.ExprStmt) + if !ok { + return commentNote{}, nil, false + } + call, ok := exprStmt.X.(*ast.CallExpr) + if !ok || !isAppendCertsCall(call) { + return commentNote{}, nil, false + } + orig := formatNode(fset, exprStmt) + newStmt := &ast.AssignStmt{ + Lhs: []ast.Expr{&ast.Ident{Name: "hasContent"}}, + Tok: token.ASSIGN, + Rhs: []ast.Expr{ + &ast.BinaryExpr{ + X: call, + Op: token.LOR, + Y: &ast.Ident{Name: "hasContent"}, + }, + }, + } + return commentNote{ + searchFor: formatNode(fset, newStmt), + comment: orig, + }, newStmt, true +} + +// transformRootsLen replaces the roots.len() > 0 sub-expression in +// the final if condition with hasContent, deriving the comment note +// from the first line of the formatted if statement before and after. +func transformRootsLen( + fset *token.FileSet, stmt ast.Stmt, +) (commentNote, ast.Stmt, bool) { + ifStmt, ok := stmt.(*ast.IfStmt) + if !ok { + return commentNote{}, nil, false + } + binCond, ok := ifStmt.Cond.(*ast.BinaryExpr) + if !ok || binCond.Op != token.LOR { + return commentNote{}, nil, false + } + lhs, ok := binCond.X.(*ast.BinaryExpr) + if !ok || lhs.Op != token.GTR { + return commentNote{}, nil, false + } + call, ok := lhs.X.(*ast.CallExpr) + if !ok || !isRootsLenCall(call) { + return commentNote{}, nil, false + } + origLine := firstLine(formatNode(fset, ifStmt)) + binCond.X = &ast.Ident{Name: "hasContent"} + newLine := firstLine(formatNode(fset, ifStmt)) + return commentNote{ + searchFor: newLine, + comment: origLine, + }, stmt, true +} + +// indexOfFirstRange returns the index of the first RangeStmt in stmts. +func indexOfFirstRange(stmts []ast.Stmt) int { + for i, s := range stmts { + if _, ok := s.(*ast.RangeStmt); ok { + return i + } + } + return len(stmts) +} + +func isAppendCertsCall(call *ast.CallExpr) bool { + if len(call.Args) != 1 { + return false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + ident, ok := sel.X.(*ast.Ident) + return ok && ident.Name == "roots" && sel.Sel.Name == "AppendCertsFromPEM" +} + +func isRootsLenCall(call *ast.CallExpr) bool { + if len(call.Args) != 0 { + return false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + ident, ok := sel.X.(*ast.Ident) + return ok && ident.Name == "roots" && sel.Sel.Name == "len" +} + +// filterComments drops comments whose position falls inside one of +// the excluded nodes (i.e. comments belonging to removed declarations). +func filterComments( + comments []*ast.CommentGroup, + fset *token.FileSet, + exclude []ast.Node, +) []*ast.CommentGroup { + if len(exclude) == 0 { + return comments + } + var result []*ast.CommentGroup + for _, cg := range comments { + if len(cg.List) == 0 { + continue + } + pos := fset.Position(cg.Pos()).Offset + inExcluded := false + for _, node := range exclude { + start := fset.Position(node.Pos()).Offset + end := fset.Position(node.End()).Offset + if pos >= start && pos < end { + inExcluded = true + break + } + } + if !inExcluded { + result = append(result, cg) + } + } + return result +} + +// findFuncDecl returns the named function declaration or nil. +func findFuncDecl(decls []ast.Decl, name string) *ast.FuncDecl { + for _, decl := range decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name { + return fn + } + } + return nil +} + +// findImportDecl returns the first import declaration in f or nil. +func findImportDecl(f *ast.File) *ast.GenDecl { + for _, decl := range f.Decls { + if g, ok := decl.(*ast.GenDecl); ok && g.Tok == token.IMPORT { + return g + } + } + return nil +} + +// findFuncDeclText returns the source text of the named function +// declaration, or empty string if not found. +func findFuncDeclText( + fset *token.FileSet, src []byte, + decls []ast.Decl, name string, +) string { + for _, decl := range decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != name { + continue + } + start := fset.Position(fn.Pos()).Offset + end := fset.Position(fn.End()).Offset + return strings.TrimSpace(string(src[start:end])) + } + return "" +} + +// findImportDeclText returns the source text of the first import +// declaration in f, or empty string if none. +func findImportDeclText( + fset *token.FileSet, src []byte, f *ast.File, +) string { + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.IMPORT { + continue + } + start := fset.Position(genDecl.Pos()).Offset + end := fset.Position(genDecl.End()).Offset + return strings.TrimSpace(string(src[start:end])) + } + return "" +} + +// commentBlock prepends "// " to each non-empty line and "//" to +// empty lines. +func commentBlock(text string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + if line == "" { + lines[i] = "//" + } else { + lines[i] = "// " + line + } + } + return strings.Join(lines, "\n") +} + +// insertCommentBefore finds each line containing searchFor and inserts +// a comment line (with the same indentation) immediately before it. +func insertCommentBefore(text, searchFor, comment string) string { + lines := strings.Split(text, "\n") + var result []string + for _, line := range lines { + if strings.Contains(line, searchFor) { + indent := leadingTabs(line) + result = append(result, indent+"// "+comment) + } + result = append(result, line) + } + return strings.Join(result, "\n") +} + +// insertBefore inserts ins immediately before the first occurrence of +// before in s. +func insertBefore(s, before, ins string) string { + idx := strings.Index(s, before) + if idx < 0 { + return s + } + return s[:idx] + ins + s[idx:] +} + +// leadingTabs returns the leading tab characters of s. +func leadingTabs(s string) string { + trimmed := strings.TrimLeft(s, "\t") + return s[:len(s)-len(trimmed)] +} + +// postProcessUnix inserts comment lines for every mutation recorded +// in notes, and prepends the commented-out systemVerify block before +// the loadSystemRoots function. +func postProcessUnix( + code string, + notes []commentNote, + systemVerifyText, funcStart string, +) string { + if systemVerifyText != "" && funcStart != "" { + code = insertBefore( + code, + funcStart, + commentBlock(systemVerifyText)+"\n\n", + ) + } + for _, note := range notes { + code = insertCommentBefore(code, note.searchFor, note.comment) + } + return code +} + +// postProcessLinux inserts the commented-out import and init() for +// the linux certloader source. The package name is derived from the +// AST rather than hardcoded. +func postProcessLinux( + code, packageName, importsText, initText string, +) string { + if importsText != "" { + code = strings.Replace( + code, + "package "+packageName+"\n", + "package "+packageName+"\n\n"+commentBlock(importsText)+"\n", + 1, + ) + } + if initText != "" { + code = strings.TrimRight(code, "\n") + + "\n\n" + commentBlock(initText) + "\n" + } + return code +} + +// addImport adds a new import path to the file's existing import block. +func addImport(f *ast.File, path string) { + spec := &ast.ImportSpec{ + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: `"` + path + `"`, + }, + } + f.Imports = append(f.Imports, spec) + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.IMPORT { + continue + } + genDecl.Specs = append([]ast.Spec{spec}, genDecl.Specs...) + return + } + // No import block exists — create one. + genDecl := &ast.GenDecl{ + Tok: token.IMPORT, + Lparen: 1, + Specs: []ast.Spec{spec}, + } + f.Decls = append([]ast.Decl{genDecl}, f.Decls...) +} + +// removeAllImports removes all import declarations from f. +func removeAllImports(f *ast.File) { + f.Imports = nil + var decls []ast.Decl + for _, d := range f.Decls { + if g, ok := d.(*ast.GenDecl); ok && g.Tok == token.IMPORT { + continue + } + decls = append(decls, d) + } + f.Decls = decls +} + +func writeOutputText( + outDir, outFile, srcFile, ver, code string, +) error { + if err := os.MkdirAll(outDir, 0755); err != nil { + return err + } + var out strings.Builder + fmt.Fprintf( + &out, + "// Code generated by tools/gencertloader"+ + " from crypto/x509/%s (%s); DO NOT EDIT.\n\n", + srcFile, ver, + ) + out.WriteString(code) + return os.WriteFile( + filepath.Join(outDir, outFile), + []byte(out.String()), + fs.ModePerm, + ) +}