Files
1e01173fb4 MUL-8060 fix(daemon): trust the backend's SSL_CERT_DIR CAs when syncing repos (#9312)
* fix(daemon): report the checkout's own repo sync error

ensureRepoReady read the sync failure from a per-workspace slot that any
concurrent background sync overwrote, so a later successful sync of another
repo erased the error and checkout reported only "repo is configured but
not synced". syncWorkspaceReposContext now returns its error to the caller
and the shared slot is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* feat(server): share SSL_CERT_DIR CAs with daemons for git

Daemons clone with the system git, which never sees the backend's
SSL_CERT_DIR, so a repository on a host with an internal CA could not
sync even after the operator made the backend trust that CA.

The new gitca package mirrors crypto/x509's SSL_CERT_DIR loading, skips
the system directories, and keeps only certificates crypto/x509 trusts.
The register and workspace repos responses carry them as ca_certs. The
daemon writes its system CA bundle plus those certificates to a bundle
file, adds http.sslCAInfo to its git environment, and records it in the
cache and isolated checkout config so agents' fetch and push use it too.
An empty value removes the bundle; Windows daemons skip it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs: document private CA trust for daemons

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(daemon): keep operator CA trust and existing checkouts working

Review of the private CA support found two regressions for repositories
that already worked:

- Removing the last backend CA deleted the bundle while existing linked
  worktrees and isolated checkouts still pointed at it, so their HTTPS
  fetch and push failed. The bundle is now rewritten to the machine's own
  trust instead, and each repository drops the reference the next time
  the cache configures it.
- The bundle started from the system bundle and was injected through the
  environment, overriding an operator's general http.sslCAInfo and any
  repository-local value. It now starts from the file a general
  http.sslCAInfo names (system bundle otherwise), the environment no
  longer carries it, clone passes it with -c, and repository config is
  only set where unset and only unset where it is ours. URL-scoped
  settings, repository-local values, and GIT_SSL_CAINFO keep precedence.

Isolated checkouts record the setting before their first checkout, which
can lazily fetch from origin, and clones before the refspec migration
fetch.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Iliya Anastasov <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 14:25:27 +08:00

158 lines
4.9 KiB
Go

// Package gitca extracts the private CA certificates an operator added to the
// backend's trust store through SSL_CERT_DIR, so the server can hand them to
// daemons. Daemons clone with the system git binary, which never reads the
// backend's SSL_CERT_DIR; without these certificates a repository on a host
// with an internal CA cannot sync.
package gitca
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
)
// MaxBundleSize caps the PEM sent to daemons. A handful of internal CAs is a
// few KB; anything near this limit is a misconfigured directory (for example
// the whole system store mounted under another path).
const MaxBundleSize = 256 << 10
// Load returns the PEM of every certificate in the non-system directories of
// SSL_CERT_DIR that crypto/x509 also trusts, or nil when there are none.
// Problems with individual files or certificates are logged and skipped so a
// bad file never blocks the server from starting.
func Load(logger *slog.Logger) []byte {
certs, warnings := collect(os.Getenv(certDirEnv), systemCertDirectories)
for _, w := range warnings {
logger.Warn("git CA: skipping certificate", "detail", w)
}
if len(certs) == 0 {
return nil
}
roots, err := x509.SystemCertPool()
if err != nil {
logger.Warn("git CA: system cert pool unavailable; not sharing SSL_CERT_DIR certificates with daemons", "error", err)
return nil
}
var bundle bytes.Buffer
fingerprints := make([]string, 0, len(certs))
for _, cert := range certs {
if err := trustedBy(cert, roots); err != nil {
logger.Warn("git CA: certificate is not trusted by the backend; skipping", "subject", cert.Subject.String(), "error", err)
continue
}
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
if bundle.Len()+len(block) > MaxBundleSize {
logger.Warn("git CA: bundle size limit reached; skipping remaining certificates", "limit_bytes", MaxBundleSize)
break
}
bundle.Write(block)
fingerprints = append(fingerprints, fingerprint(cert))
}
if bundle.Len() == 0 {
return nil
}
logger.Info("git CA: sharing SSL_CERT_DIR certificates with daemons", "count", len(fingerprints), "sha256", fingerprints)
return bundle.Bytes()
}
// trustedBy reports whether crypto/x509 accepts cert as a trust anchor, so
// daemons receive exactly what the backend itself trusts even if collect ever
// drifts from the standard library's loader.
func trustedBy(cert *x509.Certificate, roots *x509.CertPool) error {
_, err := cert.Verify(x509.VerifyOptions{
Roots: roots,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
})
return err
}
// collect mirrors the SSL_CERT_DIR half of loadSystemRoots in
// crypto/x509/root_unix.go (Go 1.26): split on ":", read each directory while
// skipping symlinks that point within it, and parse every file as PEM. Unlike
// the standard library it skips the system directories, keeps the parsed
// certificates instead of a CertPool, and drops duplicates.
func collect(certDir string, systemDirs []string) ([]*x509.Certificate, []string) {
if certDir == "" {
return nil, nil
}
system := make(map[string]struct{}, len(systemDirs))
for _, d := range systemDirs {
system[filepath.Clean(d)] = struct{}{}
}
var (
certs []*x509.Certificate
warnings []string
seen = map[[sha256.Size]byte]struct{}{}
)
for _, dir := range strings.Split(certDir, ":") {
if dir == "" {
continue
}
if _, ok := system[filepath.Clean(dir)]; ok {
continue
}
entries, err := readUniqueDirectoryEntries(dir)
if err != nil {
if !os.IsNotExist(err) {
warnings = append(warnings, fmt.Sprintf("read %s: %v", dir, err))
}
continue
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
// Subdirectories land here too; crypto/x509 ignores them the same way.
continue
}
for _, cert := range parseCertificates(path, data, &warnings) {
sum := sha256.Sum256(cert.Raw)
if _, dup := seen[sum]; dup {
continue
}
seen[sum] = struct{}{}
certs = append(certs, cert)
}
}
}
return certs, warnings
}
// parseCertificates matches CertPool.AppendCertsFromPEM: non-CERTIFICATE
// blocks and certificates that fail to parse are skipped, not fatal.
func parseCertificates(path string, data []byte, warnings *[]string) []*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" || len(block.Headers) != 0 {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
*warnings = append(*warnings, fmt.Sprintf("parse %s: %v", path, err))
continue
}
certs = append(certs, cert)
}
return certs
}
func fingerprint(cert *x509.Certificate) string {
sum := sha256.Sum256(cert.Raw)
return hex.EncodeToString(sum[:])
}