diff --git a/packages/envd/internal/api/api.gen.go b/packages/envd/internal/api/api.gen.go index 75bdd14d5e..45bea2b82b 100644 --- a/packages/envd/internal/api/api.gen.go +++ b/packages/envd/internal/api/api.gen.go @@ -23,6 +23,15 @@ const ( File EntryInfoType = "file" ) +// CACertificate A CA certificate to install into the system trust store +type CACertificate struct { + // Cert PEM-encoded CA certificate + Cert string `json:"cert"` + + // Name Filename (without extension) for the certificate + Name string `json:"name"` +} + // ComposeRequest defines model for ComposeRequest. type ComposeRequest struct { // Destination Destination file path for the composed file @@ -174,6 +183,9 @@ type PostInitJSONBody struct { // AccessToken Access token for secure access to envd service AccessToken *SecureToken `json:"accessToken,omitempty"` + // CaCertificates CA certificates to install into the system trust store + CaCertificates *[]CACertificate `json:"caCertificates,omitempty"` + // DefaultUser The default user to use for operations DefaultUser *string `json:"defaultUser,omitempty"` diff --git a/packages/envd/internal/api/init.go b/packages/envd/internal/api/init.go index 1dd422e8a8..021c189e14 100644 --- a/packages/envd/internal/api/init.go +++ b/packages/envd/internal/api/init.go @@ -8,7 +8,10 @@ import ( "io" "net/http" "net/netip" + "os" "os/exec" + "path/filepath" + "regexp" "sync" "time" @@ -215,8 +218,15 @@ func (a *API) SetData(ctx context.Context, logger zerolog.Logger, data PostInitJ a.defaults.Workdir = data.DefaultWorkdir } + var wg sync.WaitGroup + + if data.CaCertificates != nil && len(*data.CaCertificates) > 0 { + wg.Go(func() { + a.installCACerts(ctx, *data.CaCertificates) + }) + } + if data.VolumeMounts != nil { - var wg sync.WaitGroup for _, volume := range *data.VolumeMounts { logger.Debug().Msgf("Mounting %s at %q", volume.NfsTarget, volume.Path) @@ -224,10 +234,10 @@ func (a *API) SetData(ctx context.Context, logger zerolog.Logger, data PostInitJ a.setupNfs(context.WithoutCancel(ctx), volume.NfsTarget, volume.Path) }) } - - wg.Wait() } + wg.Wait() + return nil } @@ -253,6 +263,50 @@ func (a *API) setupNfs(ctx context.Context, nfsTarget, path string) { } } +// certNameRe matches safe filenames for CA certificates: lowercase letters, +// digits, hyphens, and underscores only. No path separators or dots allowed. +var certNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// validCertName reports whether name is safe to use as a filename in the +// CA certificate directory. +func validCertName(name string) bool { + return name != "" && certNameRe.MatchString(name) +} + +// installCACerts writes PEM-encoded CA certificates to the system trust store +// and runs update-ca-certificates to register them with the OS. +// +// This call is intentionally synchronous: certs must be fully installed before +// user processes start or TLS connections through the proxy will fail. +func (a *API) installCACerts(ctx context.Context, certs []CACertificate) { + certDir := a.certDir + + if err := os.MkdirAll(certDir, 0o755); err != nil { + a.logger.Error().Err(err).Msg("failed to create ca-certificates directory") + + return + } + + for _, c := range certs { + if !validCertName(c.Name) { + a.logger.Error().Str("name", c.Name).Msg("skipping CA certificate with invalid name") + + continue + } + + certPath := filepath.Join(certDir, c.Name+".crt") + if err := os.WriteFile(certPath, []byte(c.Cert), 0o644); err != nil { + a.logger.Error().Err(err).Str("name", c.Name).Msg("failed to write CA certificate") + + continue + } + } + + out, err := exec.CommandContext(ctx, "update-ca-certificates").CombinedOutput() + logFn := a.getLogger(err) + logFn.Str("output", string(out)).Msg("update-ca-certificates") +} + func (a *API) SetupHyperloop(address string) { a.hyperloopLock.Lock() defer a.hyperloopLock.Unlock() diff --git a/packages/envd/internal/api/init_test.go b/packages/envd/internal/api/init_test.go index 9877104e09..8ffb06842a 100644 --- a/packages/envd/internal/api/init_test.go +++ b/packages/envd/internal/api/init_test.go @@ -2,6 +2,13 @@ package api import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "os" "path/filepath" "strings" @@ -18,6 +25,29 @@ import ( utilsShared "github.com/e2b-dev/infra/packages/shared/pkg/utils" ) +// generateTestCACert creates a minimal self-signed CA certificate and returns +// it as a PEM-encoded string. The cert is not written to disk. +func generateTestCACert(t *testing.T) string { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + func TestSimpleCases(t *testing.T) { t.Parallel() testCases := map[string]func(string) string{ @@ -585,3 +615,144 @@ func TestSetData(t *testing.T) { assert.Equal(t, "value", val) }) } + +func TestInstallCACerts(t *testing.T) { + t.Parallel() + ctx := context.Background() + + newAPIWithTempCertDir := func(t *testing.T) (*API, string) { + t.Helper() + api := newTestAPI(nil, &mockMMDSClient{}) + api.certDir = t.TempDir() + + return api, api.certDir + } + + t.Run("writes single cert file with .crt extension", func(t *testing.T) { + t.Parallel() + api, certDir := newAPIWithTempCertDir(t) + certPEM := generateTestCACert(t) + + api.installCACerts(ctx, []CACertificate{{Name: "my-proxy-ca", Cert: certPEM}}) + + content, err := os.ReadFile(filepath.Join(certDir, "my-proxy-ca.crt")) + require.NoError(t, err) + assert.Equal(t, certPEM, string(content)) + }) + + t.Run("writes multiple cert files", func(t *testing.T) { + t.Parallel() + api, certDir := newAPIWithTempCertDir(t) + cert1 := generateTestCACert(t) + cert2 := generateTestCACert(t) + + api.installCACerts(ctx, []CACertificate{ + {Name: "ca-one", Cert: cert1}, + {Name: "ca-two", Cert: cert2}, + }) + + content, err := os.ReadFile(filepath.Join(certDir, "ca-one.crt")) + require.NoError(t, err) + assert.Equal(t, cert1, string(content)) + + content, err = os.ReadFile(filepath.Join(certDir, "ca-two.crt")) + require.NoError(t, err) + assert.Equal(t, cert2, string(content)) + }) + + t.Run("rejects name with path traversal", func(t *testing.T) { + t.Parallel() + api, certDir := newAPIWithTempCertDir(t) + certPEM := generateTestCACert(t) + + api.installCACerts(ctx, []CACertificate{{Name: "../../../etc/evil", Cert: certPEM}}) + + // The name is invalid so no file should be written anywhere. + _, err := os.ReadFile(filepath.Join(certDir, "evil.crt")) + assert.True(t, os.IsNotExist(err), "no file should be written for a traversal name") + + _, err = os.ReadFile("/etc/evil.crt") + assert.True(t, os.IsNotExist(err), "cert must not escape the cert directory") + }) + + t.Run("rejects name with dots or slashes", func(t *testing.T) { + t.Parallel() + api, certDir := newAPIWithTempCertDir(t) + certPEM := generateTestCACert(t) + + for _, bad := range []string{"ca.bad", "ca/bad", "ca bad", ""} { + api.installCACerts(ctx, []CACertificate{{Name: bad, Cert: certPEM}}) + } + + entries, err := os.ReadDir(certDir) + require.NoError(t, err) + assert.Empty(t, entries, "no files should be written for invalid names") + }) + + t.Run("cert content is valid PEM", func(t *testing.T) { + t.Parallel() + api, certDir := newAPIWithTempCertDir(t) + certPEM := generateTestCACert(t) + + api.installCACerts(ctx, []CACertificate{{Name: "valid-ca", Cert: certPEM}}) + + raw, err := os.ReadFile(filepath.Join(certDir, "valid-ca.crt")) + require.NoError(t, err) + + block, _ := pem.Decode(raw) + require.NotNil(t, block, "expected valid PEM block in written file") + assert.Equal(t, "CERTIFICATE", block.Type) + + _, err = x509.ParseCertificate(block.Bytes) + require.NoError(t, err, "expected parseable X.509 certificate") + }) + + t.Run("skips failing cert and writes remaining certs", func(t *testing.T) { + t.Parallel() + api, certDir := newAPIWithTempCertDir(t) + certPEM := generateTestCACert(t) + + // Pre-create a directory at the path where "bad-ca.crt" would be written. + // WriteFile will fail because the path is a directory, not a regular file. + require.NoError(t, os.Mkdir(filepath.Join(certDir, "bad-ca.crt"), 0o755)) + + api.installCACerts(ctx, []CACertificate{ + {Name: "bad-ca", Cert: certPEM}, // will fail — path is a directory + {Name: "good-ca", Cert: certPEM}, // must still be written + }) + + _, err := os.ReadFile(filepath.Join(certDir, "good-ca.crt")) + require.NoError(t, err, "good-ca.crt should be written even though bad-ca failed") + }) + + t.Run("re-init with one cert updates its content and leaves other cert untouched", func(t *testing.T) { + t.Parallel() + api, certDir := newAPIWithTempCertDir(t) + + certAv1 := generateTestCACert(t) + certB := generateTestCACert(t) + + // First init: two certs. + api.installCACerts(ctx, []CACertificate{ + {Name: "ca-a", Cert: certAv1}, + {Name: "ca-b", Cert: certB}, + }) + + // Second init: only cert-a with new content — cert-b is not mentioned. + certAv2 := generateTestCACert(t) + api.installCACerts(ctx, []CACertificate{ + {Name: "ca-a", Cert: certAv2}, + }) + + // cert-a must hold the new content. + contentA, err := os.ReadFile(filepath.Join(certDir, "ca-a.crt")) + require.NoError(t, err) + assert.Equal(t, certAv2, string(contentA), "ca-a should have updated content after second init") + assert.NotEqual(t, certAv1, string(contentA), "ca-a should no longer hold first-round content") + + // cert-b must still be present and unchanged. + contentB, err := os.ReadFile(filepath.Join(certDir, "ca-b.crt")) + require.NoError(t, err) + assert.Equal(t, certB, string(contentB), "ca-b should be unchanged after second init") + }) +} diff --git a/packages/envd/internal/api/store.go b/packages/envd/internal/api/store.go index 2524206226..498b6bfb56 100644 --- a/packages/envd/internal/api/store.go +++ b/packages/envd/internal/api/store.go @@ -37,8 +37,14 @@ type API struct { lastSetTime *utils.AtomicMax initLock sync.Mutex + + // certDir is the directory where CA certificates are written before + // update-ca-certificates is run. Overridable in tests. + certDir string } +const systemCertDir = "/usr/local/share/ca-certificates" + func New(l *zerolog.Logger, defaults *execcontext.Defaults, mmdsChan chan *host.MMDSOpts, isNotFC bool) *API { return &API{ logger: l, @@ -48,6 +54,7 @@ func New(l *zerolog.Logger, defaults *execcontext.Defaults, mmdsChan chan *host. mmdsClient: &DefaultMMDSClient{}, lastSetTime: utils.NewAtomicMax(), accessToken: &SecureToken{}, + certDir: systemCertDir, } } diff --git a/packages/envd/package.json b/packages/envd/package.json deleted file mode 100644 index b79080125a..0000000000 --- a/packages/envd/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@e2b/envd", - "version": "0.0.0-automated", - "description": "E2B Env Daemon", - "repository": "https://github.com/e2b-dev/infra", - "homepage": "https://e2b.dev", - "bugs": "https://github.com/e2b-dev/infra/issues", - "license": "MIT", - "author": "E2B", - "type": "module", - "url": "https://github.com/e2b-dev/infra/releases/download/v{{version}}/{{bin_name}}_{{version}}_{{platform}}_{{arch}}.tar.gz", - "bin": "bin/envd", - "release": { - "branches": [ - "main" - ] - } -} \ No newline at end of file diff --git a/packages/envd/pkg/version.go b/packages/envd/pkg/version.go index e15331fdfa..d51e146561 100644 --- a/packages/envd/pkg/version.go +++ b/packages/envd/pkg/version.go @@ -1,3 +1,3 @@ package pkg -const Version = "0.5.8" +const Version = "0.5.9" diff --git a/packages/envd/spec/envd.yaml b/packages/envd/spec/envd.yaml index 7dc0b3a9e8..f3af3ad7ee 100644 --- a/packages/envd/spec/envd.yaml +++ b/packages/envd/spec/envd.yaml @@ -64,6 +64,11 @@ paths: defaultWorkdir: type: string description: The default working directory to use for operations + caCertificates: + type: array + description: CA certificates to install into the system trust store + items: + $ref: "#/components/schemas/CACertificate" responses: "204": description: Env vars set, the time and metadata is synced with the host @@ -378,6 +383,20 @@ components: username: type: string description: User for setting ownership and resolving relative paths + CACertificate: + type: object + description: A CA certificate to install into the system trust store + additionalProperties: false + required: + - name + - cert + properties: + name: + type: string + description: Filename (without extension) for the certificate + cert: + type: string + description: PEM-encoded CA certificate VolumeMount: type: object description: Volume mount configuration diff --git a/packages/orchestrator/benchmark_test.go b/packages/orchestrator/benchmark_test.go index bf21c90ab7..830a76709b 100644 --- a/packages/orchestrator/benchmark_test.go +++ b/packages/orchestrator/benchmark_test.go @@ -185,7 +185,7 @@ func BenchmarkBaseImageLaunch(b *testing.B) { b.Cleanup(templateCache.Stop) sandboxes := sandbox.NewSandboxesMap() - sandboxFactory := sandbox.NewFactory(config.BuilderConfig, networkPool, devicePool, featureFlags, hoststats.NewNoopDelivery(), cgroup.NewNoopManager(), sandboxes) + sandboxFactory := sandbox.NewFactory(config.BuilderConfig, networkPool, devicePool, featureFlags, hoststats.NewNoopDelivery(), cgroup.NewNoopManager(), network.NewNoopEgressProxy(), sandboxes) dockerhubRepository, err := dockerhub.GetRemoteRepository(b.Context()) require.NoError(b, err) diff --git a/packages/orchestrator/cmd/create-build/main.go b/packages/orchestrator/cmd/create-build/main.go index 4cff864073..cb4aad596b 100644 --- a/packages/orchestrator/cmd/create-build/main.go +++ b/packages/orchestrator/cmd/create-build/main.go @@ -299,7 +299,7 @@ func doBuild( defer templateCache.Stop() buildMetrics, _ := metrics.NewBuildMetrics(noop.MeterProvider{}) - sandboxFactory := sandbox.NewFactory(c.BuilderConfig, networkPool, devicePool, featureFlags, hoststats.NewNoopDelivery(), cgroup.NewNoopManager(), sandboxes) + sandboxFactory := sandbox.NewFactory(c.BuilderConfig, networkPool, devicePool, featureFlags, hoststats.NewNoopDelivery(), cgroup.NewNoopManager(), network.NewNoopEgressProxy(), sandboxes) builder := build.NewBuilder( builderConfig, l, featureFlags, sandboxFactory, diff --git a/packages/orchestrator/cmd/resume-build/main.go b/packages/orchestrator/cmd/resume-build/main.go index 684c88d6cf..c17ae3b9d0 100644 --- a/packages/orchestrator/cmd/resume-build/main.go +++ b/packages/orchestrator/cmd/resume-build/main.go @@ -1052,7 +1052,7 @@ func run(ctx context.Context, buildID string, iterations int, coldStart, noPrefe if verbose { fmt.Println("🔧 Creating sandbox factory...") } - factory := sandbox.NewFactory(config.BuilderConfig, networkPool, devicePool, flags, hoststats.NewNoopDelivery(), cgroup.NewNoopManager(), sandboxes) + factory := sandbox.NewFactory(config.BuilderConfig, networkPool, devicePool, flags, hoststats.NewNoopDelivery(), cgroup.NewNoopManager(), network.NewNoopEgressProxy(), sandboxes) fmt.Printf("📦 Loading %s...\n", buildID) tmpl, err := cache.GetTemplate(ctx, buildID, false, false) diff --git a/packages/orchestrator/cmd/smoketest/smoke_test.go b/packages/orchestrator/cmd/smoketest/smoke_test.go index 3718dadb8e..fa358f06f6 100644 --- a/packages/orchestrator/cmd/smoketest/smoke_test.go +++ b/packages/orchestrator/cmd/smoketest/smoke_test.go @@ -228,7 +228,7 @@ func newTestInfra(t *testing.T, ctx context.Context) *testInfra { ti.closers = append(ti.closers, func(ctx context.Context) { sandboxProxy.Close(ctx) }) // Factory + Builder - factory := sandbox.NewFactory(orcConfig.BuilderConfig, networkPool, devicePool, flags, hoststats.NewNoopDelivery(), cgroup.NewNoopManager(), sandboxes) + factory := sandbox.NewFactory(orcConfig.BuilderConfig, networkPool, devicePool, flags, hoststats.NewNoopDelivery(), cgroup.NewNoopManager(), network.NewNoopEgressProxy(), sandboxes) ti.factory = factory buildMetrics, _ := metrics.NewBuildMetrics(noop.MeterProvider{}) diff --git a/packages/orchestrator/pkg/factories/run.go b/packages/orchestrator/pkg/factories/run.go index 609ee4fc90..c474cd19e3 100644 --- a/packages/orchestrator/pkg/factories/run.go +++ b/packages/orchestrator/pkg/factories/run.go @@ -532,7 +532,7 @@ func run(config cfg.Config, opts Options) (success bool) { closers = append(closers, closer{"network pool", networkPool.Close}) // sandbox factory - sandboxFactory := sandbox.NewFactory(config.BuilderConfig, networkPool, devicePool, featureFlags, hostStatsDelivery, cgroupManager, sandboxes) + sandboxFactory := sandbox.NewFactory(config.BuilderConfig, networkPool, devicePool, featureFlags, hostStatsDelivery, cgroupManager, egressSetup.Proxy, sandboxes) // isolated filesystems cache (for nfs proxy) builder := chrooted.NewBuilder(config) diff --git a/packages/orchestrator/pkg/sandbox/envd.go b/packages/orchestrator/pkg/sandbox/envd.go index 13a08c3a78..c4b717d818 100644 --- a/packages/orchestrator/pkg/sandbox/envd.go +++ b/packages/orchestrator/pkg/sandbox/envd.go @@ -16,6 +16,7 @@ import ( "go.uber.org/zap" "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/envd" + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/network" "github.com/e2b-dev/infra/packages/shared/pkg/consts" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" @@ -42,6 +43,7 @@ func (s *Sandbox) doRequestWithInfiniteRetries( DefaultUser: utils.DerefOrDefault(s.Config.Envd.DefaultUser, ""), DefaultWorkdir: utils.DerefOrDefault(s.Config.Envd.DefaultWorkdir, ""), VolumeMounts: s.convertMounts(s.Config.VolumeMounts), + CaCertificates: s.convertCACertificates(s.CACertificates), } for { @@ -95,6 +97,16 @@ func (s *Sandbox) convertMounts(mounts []VolumeMountConfig) []envd.VolumeMount { return results } +func (s *Sandbox) convertCACertificates(certs []network.CACertificate) []envd.CACertificate { + results := make([]envd.CACertificate, 0, len(certs)) + + for _, cert := range certs { + results = append(results, envd.CACertificate{Name: cert.Name, Cert: cert.Cert}) + } + + return results +} + func (s *Sandbox) initEnvd(ctx context.Context) (e error) { ctx, span := tracer.Start(ctx, "envd-init", trace.WithAttributes(telemetry.WithEnvdVersion(s.Config.Envd.Version))) defer func() { diff --git a/packages/orchestrator/pkg/sandbox/envd/envd.gen.go b/packages/orchestrator/pkg/sandbox/envd/envd.gen.go index f77d914b4b..f435ebbb42 100644 --- a/packages/orchestrator/pkg/sandbox/envd/envd.gen.go +++ b/packages/orchestrator/pkg/sandbox/envd/envd.gen.go @@ -18,6 +18,15 @@ const ( File EntryInfoType = "file" ) +// CACertificate A CA certificate to install into the system trust store +type CACertificate struct { + // Cert PEM-encoded CA certificate + Cert string `json:"cert"` + + // Name Filename (without extension) for the certificate + Name string `json:"name"` +} + // ComposeRequest defines model for ComposeRequest. type ComposeRequest struct { // Destination Destination file path for the composed file @@ -169,6 +178,9 @@ type PostInitJSONBody struct { // AccessToken Access token for secure access to envd service AccessToken SecureToken `json:"accessToken,omitempty"` + // CaCertificates CA certificates to install into the system trust store + CaCertificates []CACertificate `json:"caCertificates,omitempty"` + // DefaultUser The default user to use for operations DefaultUser string `json:"defaultUser,omitempty"` diff --git a/packages/orchestrator/pkg/sandbox/envd_test.go b/packages/orchestrator/pkg/sandbox/envd_test.go new file mode 100644 index 0000000000..845197bd55 --- /dev/null +++ b/packages/orchestrator/pkg/sandbox/envd_test.go @@ -0,0 +1,116 @@ +package sandbox + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coreos/go-iptables/iptables" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/envd" + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/network" +) + +// mockEgressProxy is a test EgressProxy that returns a fixed set of CA certificates. +type mockEgressProxy struct { + certs []network.CACertificate +} + +func (m *mockEgressProxy) OnSlotCreate(_ *network.Slot, _ *iptables.IPTables) error { return nil } +func (m *mockEgressProxy) OnSlotDelete(_ *network.Slot, _ *iptables.IPTables) error { return nil } +func (m *mockEgressProxy) CACertificates() []network.CACertificate { return m.certs } + +// newTestSandboxWithCerts builds a minimal Sandbox that has CACertificates set — +// mirroring what Factory.CreateSandbox does with f.egressProxy.CACertificates(). +func newTestSandboxWithCerts(certs []network.CACertificate) *Sandbox { + return &Sandbox{ + Metadata: &Metadata{ + internalConfig: internalConfig{EnvdInitRequestTimeout: 5 * time.Second}, + Config: NewConfig(Config{}), + Runtime: RuntimeMetadata{SandboxID: "test-sandbox"}, + }, + CACertificates: certs, + } +} + +func TestConvertCACertificates(t *testing.T) { + t.Parallel() + + t.Run("converts network certs to envd certs preserving name and PEM", func(t *testing.T) { + t.Parallel() + + proxy := &mockEgressProxy{ + certs: []network.CACertificate{ + {Name: "proxy-ca", Cert: "-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----\n"}, + {Name: "custom-ca", Cert: "-----BEGIN CERTIFICATE-----\nDEF\n-----END CERTIFICATE-----\n"}, + }, + } + + sbx := newTestSandboxWithCerts(proxy.CACertificates()) + result := sbx.convertCACertificates(sbx.CACertificates) + + require.Len(t, result, 2) + assert.Equal(t, "proxy-ca", result[0].Name) + assert.Equal(t, "-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----\n", result[0].Cert) + assert.Equal(t, "custom-ca", result[1].Name) + assert.Equal(t, "-----BEGIN CERTIFICATE-----\nDEF\n-----END CERTIFICATE-----\n", result[1].Cert) + }) + + t.Run("returns empty slice for nil certs", func(t *testing.T) { + t.Parallel() + sbx := newTestSandboxWithCerts(nil) + result := sbx.convertCACertificates(nil) + assert.Empty(t, result) + }) +} + +// TestEnvdInitSendsCACertificates verifies the full injection chain: +// EgressProxy.CACertificates() → Sandbox.CACertificates → POST /init body. +// +// Not parallel: overrides the package-level sandboxHttpClient. +func TestEnvdInitSendsCACertificates(t *testing.T) { //nolint:paralleltest + proxy := &mockEgressProxy{ + certs: []network.CACertificate{ + {Name: "proxy-ca", Cert: "-----BEGIN CERTIFICATE-----\nPROXY\n-----END CERTIFICATE-----\n"}, + {Name: "custom-ca", Cert: "-----BEGIN CERTIFICATE-----\nCUSTOM\n-----END CERTIFICATE-----\n"}, + }, + } + + // Simulate what Factory.CreateSandbox does when assigning egress proxy certs. + sbx := newTestSandboxWithCerts(proxy.CACertificates()) + + var captured envd.PostInitJSONBody + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/init", r.URL.Path) + + err := json.NewDecoder(r.Body).Decode(&captured) + assert.NoError(t, err) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + // Temporarily swap the package-level client so the sandbox reaches our test server. + orig := sandboxHttpClient + sandboxHttpClient = http.Client{Timeout: 5 * time.Second} + defer func() { sandboxHttpClient = orig }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, _, err := sbx.doRequestWithInfiniteRetries(ctx, http.MethodPost, server.URL+"/init") + require.NoError(t, err) + defer resp.Body.Close() + + require.Len(t, captured.CaCertificates, 2) + assert.Equal(t, proxy.certs[0].Name, captured.CaCertificates[0].Name) + assert.Equal(t, proxy.certs[0].Cert, captured.CaCertificates[0].Cert) + assert.Equal(t, proxy.certs[1].Name, captured.CaCertificates[1].Name) + assert.Equal(t, proxy.certs[1].Cert, captured.CaCertificates[1].Cert) +} diff --git a/packages/orchestrator/pkg/sandbox/network/egressproxy.go b/packages/orchestrator/pkg/sandbox/network/egressproxy.go index 42a8a34bf0..c80377876c 100644 --- a/packages/orchestrator/pkg/sandbox/network/egressproxy.go +++ b/packages/orchestrator/pkg/sandbox/network/egressproxy.go @@ -4,13 +4,38 @@ import ( "github.com/coreos/go-iptables/iptables" ) +type CACertificate struct { + // Name is the filename (without extension) used when installing the cert into the trust store. + Name string + + // Cert is the PEM-encoded CA certificate. + Cert string +} + type EgressProxy interface { OnSlotCreate(s *Slot, tables *iptables.IPTables) error OnSlotDelete(s *Slot, tables *iptables.IPTables) error + + CACertificates() []CACertificate } // NoopEgressProxy is a no-op implementation of EgressProxy. type NoopEgressProxy struct{} -func (NoopEgressProxy) OnSlotCreate(_ *Slot, _ *iptables.IPTables) error { return nil } -func (NoopEgressProxy) OnSlotDelete(_ *Slot, _ *iptables.IPTables) error { return nil } +var _ EgressProxy = (*NoopEgressProxy)(nil) + +func NewNoopEgressProxy() NoopEgressProxy { + return NoopEgressProxy{} +} + +func (NoopEgressProxy) OnSlotCreate(_ *Slot, _ *iptables.IPTables) error { + return nil +} + +func (NoopEgressProxy) OnSlotDelete(_ *Slot, _ *iptables.IPTables) error { + return nil +} + +func (NoopEgressProxy) CACertificates() []CACertificate { + return nil +} diff --git a/packages/orchestrator/pkg/sandbox/sandbox.go b/packages/orchestrator/pkg/sandbox/sandbox.go index a03d94efb3..89683dd4d9 100644 --- a/packages/orchestrator/pkg/sandbox/sandbox.go +++ b/packages/orchestrator/pkg/sandbox/sandbox.go @@ -229,6 +229,8 @@ type Sandbox struct { // It was used to store the config to allow API restarts APIStoredConfig *orchestrator.SandboxConfig + CACertificates []network.CACertificate + exit *utils.ErrorOnce stop utils.Lazy[error] @@ -273,6 +275,7 @@ type Factory struct { featureFlags *featureflags.Client hostStatsDelivery hoststats.Delivery cgroupManager cgroup.Manager + egressProxy network.EgressProxy } func NewFactory( @@ -282,6 +285,7 @@ func NewFactory( featureFlags *featureflags.Client, hostStatsDelivery hoststats.Delivery, cgroupManager cgroup.Manager, + egressProxy network.EgressProxy, sandboxes *Map, ) *Factory { return &Factory{ @@ -292,6 +296,7 @@ func NewFactory( featureFlags: featureFlags, hostStatsDelivery: hostStatsDelivery, cgroupManager: cgroupManager, + egressProxy: egressProxy, } } @@ -467,6 +472,8 @@ func (f *Factory) CreateSandbox( APIStoredConfig: apiConfigToStore, + CACertificates: f.egressProxy.CACertificates(), + exit: exit, } @@ -805,6 +812,7 @@ func (f *Factory) ResumeSandbox( cleanup: cleanup, APIStoredConfig: apiConfigToStore, + CACertificates: f.egressProxy.CACertificates(), exit: exit, } diff --git a/packages/orchestrator/pkg/tcpfirewall/proxy.go b/packages/orchestrator/pkg/tcpfirewall/proxy.go index c640a0666b..6c5967df8a 100644 --- a/packages/orchestrator/pkg/tcpfirewall/proxy.go +++ b/packages/orchestrator/pkg/tcpfirewall/proxy.go @@ -175,6 +175,10 @@ func (p *Proxy) Close(_ context.Context) error { return nil } +func (p *Proxy) CACertificates() []network.CACertificate { + return nil +} + // handlerFunc is the signature for connection handlers. type handlerFunc func(ctx context.Context, conn net.Conn, dstIP net.IP, dstPort int, sbx *sandbox.Sandbox, logger logger.Logger, metrics *Metrics, protocol Protocol) diff --git a/tests/integration/internal/envd/generated.go b/tests/integration/internal/envd/generated.go index f254034502..83586e87a8 100644 --- a/tests/integration/internal/envd/generated.go +++ b/tests/integration/internal/envd/generated.go @@ -27,6 +27,15 @@ const ( File EntryInfoType = "file" ) +// CACertificate A CA certificate to install into the system trust store +type CACertificate struct { + // Cert PEM-encoded CA certificate + Cert string `json:"cert"` + + // Name Filename (without extension) for the certificate + Name string `json:"name"` +} + // ComposeRequest defines model for ComposeRequest. type ComposeRequest struct { // Destination Destination file path for the composed file @@ -178,6 +187,9 @@ type PostInitJSONBody struct { // AccessToken Access token for secure access to envd service AccessToken *SecureToken `json:"accessToken,omitempty"` + // CaCertificates CA certificates to install into the system trust store + CaCertificates *[]CACertificate `json:"caCertificates,omitempty"` + // DefaultUser The default user to use for operations DefaultUser *string `json:"defaultUser,omitempty"`