From 8b30e54b803b8952656735efd20d5eed93dfd76e Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Wed, 2 Sep 2026 15:02:00 +0200 Subject: [PATCH 1/5] feat(proxyfault): pass an interception CA so HTTP faults reach HTTPS deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An HTTPStatus fault could only be applied to cleartext HTTP, because the proxy never decrypted TLS. transparent-proxy can now terminate a matched HTTPS connection when given a CA, so thread that through: Opts.TLSInterceptCA emits --tls-ca-cert/--tls-ca-key on start. The paths are read inside the proxy's sidecar, whose rootfs is an overlay of the extension's own filesystem, so they refer to files in the extension (a mounted Secret) and the key is never written into the target. The flags are start-only: --revert reconstructs interception rules, which do not depend on the CA. Nil keeps the previous behaviour of never decrypting. Snapshot also carries tls_intercept_rejected, the count of connections whose client refused the minted certificate — the signal that the CA is missing from the target's truststore, and deliberately not counted as faulted. --- go/.DS_Store | Bin 0 -> 6148 bytes .../network/proxyfault/proxyfault.go | 55 ++++++++++++++---- .../network/proxyfault/proxyfault_test.go | 31 ++++++++++ 3 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 go/.DS_Store diff --git a/go/.DS_Store b/go/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6a40cb4cd286a5ae14c4da889d726bbf41b619bc GIT binary patch literal 6148 zcmeHKF;2rk5ZntcB9I_L6qK$g=<)*-nF@&}4@e+E5GBEp5-JMZAkiSi6Vf5k@B}If zKENM%1+%v&$ERS~X%L{@=x*%uj_+n1du<{zt*d^Ms6j+^G{#^Z-8aFOkfIKOaIg#>inRXdfNu zY~ck47`B0Jex+be=rD4a3gQUD2o)$)<32Hr&@mpmyvSiHD0DJX^2xZFjr&3|c6OXU zwCQ9bLG9H6b-;IEO?Dex{|}45|9z6~sRQc3zjD9?aXW57lB=yt;<(m^Xk9c8&PxR? k1)VF$c7sdtE}9J-3wZ+=IZOpHg3uQMYlC*`z>hlc1*)pbssI20 literal 0 HcmV?d00001 diff --git a/go/action_kit_commons/network/proxyfault/proxyfault.go b/go/action_kit_commons/network/proxyfault/proxyfault.go index d8c9ad6e..983cdc8d 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault.go @@ -62,6 +62,23 @@ type Fault struct { Hosts []string } +// TLSInterceptCA points at the certificate authority the proxy uses to mint +// per-SNI certificates, which is what lets an HTTPStatus fault reach an HTTPS +// dependency instead of only a cleartext one. +// +// The CA belongs to the customer: they generate it, choose how long it lives, +// and install it in the truststores of the workloads they want to fault. The +// extension only points the proxy at the pair. +// +// The paths are read inside the proxy's sidecar, whose root filesystem is an +// overlay of the extension's own — so these are paths in the extension +// container (e.g. a mounted Secret), and the key is never written into the +// target. +type TLSInterceptCA struct { + CertPath string + KeyPath string +} + // Opts configures interception and the injected fault. type Opts struct { ExecutionId string @@ -83,6 +100,10 @@ type Opts struct { ExcludeCIDRs []net.IPNet Ports []uint16 Fault Fault + // TLSInterceptCA, when set, lets an HTTPStatus fault also apply to HTTPS + // connections. Nil (the default) means TLS is never decrypted and HTTPS is + // spliced through untouched. + TLSInterceptCA *TLSInterceptCA } // Proxy is a running transparent-proxy instance. @@ -104,18 +125,23 @@ type HostStat struct { // Snapshot mirrors the transparent-proxy metrics JSON emitted on stdout. type Snapshot struct { - ConnectionsMatched int64 `json:"connections_matched"` - ConnectionsActive int64 `json:"connections_active"` - ConnectionsProxied int64 `json:"connections_proxied"` - ConnectionsAborted int64 `json:"connections_aborted"` - ConnectionsDropped int64 `json:"connections_dropped"` - ConnectionsFaulted int64 `json:"connections_faulted"` - LatencyApplied int64 `json:"latency_applied"` - HTTPResponsesInjected int64 `json:"http_responses_injected"` - UpstreamErrors int64 `json:"upstream_errors"` - BytesToUpstream int64 `json:"bytes_to_upstream"` - BytesToClient int64 `json:"bytes_to_client"` - PerHost map[string]HostStat `json:"per_host,omitempty"` + ConnectionsMatched int64 `json:"connections_matched"` + ConnectionsActive int64 `json:"connections_active"` + ConnectionsProxied int64 `json:"connections_proxied"` + ConnectionsAborted int64 `json:"connections_aborted"` + ConnectionsDropped int64 `json:"connections_dropped"` + ConnectionsFaulted int64 `json:"connections_faulted"` + LatencyApplied int64 `json:"latency_applied"` + HTTPResponsesInjected int64 `json:"http_responses_injected"` + // TLSInterceptRejected counts HTTPS connections on which the client refused + // the minted certificate. A non-zero value is the canonical "the CA is not in + // the target's truststore (or the client pins certificates)" signal — the + // fault never applied, so these are deliberately not counted as faulted. + TLSInterceptRejected int64 `json:"tls_intercept_rejected"` + UpstreamErrors int64 `json:"upstream_errors"` + BytesToUpstream int64 `json:"bytes_to_upstream"` + BytesToClient int64 `json:"bytes_to_client"` + PerHost map[string]HostStat `json:"per_host,omitempty"` } // SortedHosts returns the per-host keys in a stable order. @@ -226,6 +252,11 @@ func (o Opts) startArgs() []string { if len(o.Fault.Hosts) > 0 { args = append(args, "--fault-hosts", strings.Join(o.Fault.Hosts, ",")) } + // Start-only: --revert reconstructs the interception rules, which do not + // depend on the CA. + if o.TLSInterceptCA != nil { + args = append(args, "--tls-ca-cert", o.TLSInterceptCA.CertPath, "--tls-ca-key", o.TLSInterceptCA.KeyPath) + } return args } diff --git a/go/action_kit_commons/network/proxyfault/proxyfault_test.go b/go/action_kit_commons/network/proxyfault/proxyfault_test.go index fcb85590..8aa8557c 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault_test.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault_test.go @@ -14,6 +14,7 @@ import ( "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func mustCIDR(t *testing.T, s string) net.IPNet { @@ -86,6 +87,36 @@ func TestStartArgs_probability(t *testing.T) { "nil must omit the flag so the proxy default applies") } +func TestStartArgs_tlsInterceptCA(t *testing.T) { + o := sampleOpts(t) + + // Unset: TLS is never decrypted, so the flags must be absent entirely. + got := strings.Join(o.startArgs(), " ") + assert.NotContains(t, got, "--tls-ca-cert") + assert.NotContains(t, got, "--tls-ca-key") + + o.TLSInterceptCA = &TLSInterceptCA{CertPath: "/etc/steadybit/ca.crt", KeyPath: "/etc/steadybit/ca.key"} + got = strings.Join(o.startArgs(), " ") + assert.Contains(t, got, "--tls-ca-cert /etc/steadybit/ca.crt") + assert.Contains(t, got, "--tls-ca-key /etc/steadybit/ca.key") + + // Revert only reconstructs interception rules, which do not depend on the CA. + assert.NotContains(t, strings.Join(o.revertArgs(), " "), "--tls-ca") +} + +func TestSnapshot_tlsInterceptRejected(t *testing.T) { + // The rejected counter must survive the stdout round-trip, since it is the + // signal that the CA is missing from the target's truststore. + var c metricsCollector + c.collectFromReader(strings.NewReader( + `{"connections_matched":2,"connections_faulted":0,"tls_intercept_rejected":2}`+"\n"), zerolog.Nop()) + + snap, ok := c.snapshot() + require.True(t, ok) + assert.Equal(t, int64(2), snap.TLSInterceptRejected) + assert.Equal(t, int64(0), snap.ConnectionsFaulted) +} + func TestRevertArgs(t *testing.T) { got := strings.Join(sampleOpts(t).revertArgs(), " ") // Revert must reproduce the same chain identity (exec-id) and filter so the From 6cb29525ae43a94447b63e4c87932db00486cbbc Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Wed, 2 Sep 2026 15:44:57 +0200 Subject: [PATCH 2/5] fix(proxyfault): hand the interception CA over stdin, not by path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit passed --tls-ca-cert/--tls-ca-key, on the assumption that the sidecar could read a path in the extension's filesystem. That is wrong for the runc backend: the bundle rootfs is an overlay of the extension's "/", and an overlay does not carry the extension's submounts, so a CA mounted from a Kubernetes Secret is not visible by path inside the sidecar. Verified against the real mount options — the mount point appears as an empty directory. TLSInterceptCA now carries the PEM itself and it is written to the proxy's stdin, with only --tls-ca-stdin on the command line. That works identically for both backends, keeps the key out of argv, and never writes it to a disk the target could reach. startAndMonitor takes the stdin payload so both backends share one path; it is written and the pipe closed in the background, since the proxy reads stdin to EOF at startup. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA --- .../network/proxyfault/lifecycle_test.go | 18 ++++- .../network/proxyfault/proxyfault.go | 69 +++++++++++++++---- .../network/proxyfault/proxyfault_test.go | 18 +++-- 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/go/action_kit_commons/network/proxyfault/lifecycle_test.go b/go/action_kit_commons/network/proxyfault/lifecycle_test.go index 6a8010aa..61e494d7 100644 --- a/go/action_kit_commons/network/proxyfault/lifecycle_test.go +++ b/go/action_kit_commons/network/proxyfault/lifecycle_test.go @@ -54,7 +54,7 @@ func Test_waitExited_returns_when_never_started(t *testing.T) { // value, so a second Stop blocked and Exited() went back to reporting running. func Test_exited_stable_across_repeated_stop(t *testing.T) { b := newProcessBase() - require.NoError(t, b.startAndMonitor(exec.Command("sh", "-c", "exit 0"), "test")) + require.NoError(t, b.startAndMonitor(exec.Command("sh", "-c", "exit 0"), "test", nil)) waitForExit(t, &b) ex, err := b.Exited() @@ -74,10 +74,24 @@ func Test_exited_stable_across_repeated_stop(t *testing.T) { // A non-zero exit is surfaced (and remains readable after Stop). func Test_exited_reports_error(t *testing.T) { b := newProcessBase() - require.NoError(t, b.startAndMonitor(exec.Command("sh", "-c", "exit 7"), "test")) + require.NoError(t, b.startAndMonitor(exec.Command("sh", "-c", "exit 7"), "test", nil)) waitForExit(t, &b) b.waitExited() ex, err := b.Exited() require.True(t, ex) require.Error(t, err) } + +// The interception CA is handed over on stdin, so it never appears on the +// command line and is never written to a filesystem the target can reach. +func Test_startAndMonitor_writesStdin(t *testing.T) { + b := newProcessBase() + // cat echoes stdin to stdout, which the metrics scanner drains; the point is + // that the process sees the payload and reaches EOF so it can exit. + require.NoError(t, b.startAndMonitor(exec.Command("cat"), "test", []byte("PEM-PAYLOAD\n"))) + waitForExit(t, &b) + + ex, err := b.Exited() + require.True(t, ex, "cat must reach EOF once stdin is closed") + require.NoError(t, err) +} diff --git a/go/action_kit_commons/network/proxyfault/proxyfault.go b/go/action_kit_commons/network/proxyfault/proxyfault.go index 983cdc8d..63411a25 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault.go @@ -62,21 +62,35 @@ type Fault struct { Hosts []string } -// TLSInterceptCA points at the certificate authority the proxy uses to mint +// TLSInterceptCA carries the certificate authority the proxy uses to mint // per-SNI certificates, which is what lets an HTTPStatus fault reach an HTTPS // dependency instead of only a cleartext one. // // The CA belongs to the customer: they generate it, choose how long it lives, -// and install it in the truststores of the workloads they want to fault. The -// extension only points the proxy at the pair. +// and install it in the truststores of the workloads they want to fault. This +// package only relays it. // -// The paths are read inside the proxy's sidecar, whose root filesystem is an -// overlay of the extension's own — so these are paths in the extension -// container (e.g. a mounted Secret), and the key is never written into the -// target. +// It is carried as PEM rather than as file paths on purpose. The runc backend +// runs the proxy in a bundle whose rootfs is an overlay of the extension's "/", +// and an overlay does not carry the extension's submounts — so a CA mounted +// from a Kubernetes Secret is simply not visible by path inside the sidecar +// (verified: the mount point appears as an empty directory). Passing the PEM +// over the process's stdin works identically for both backends, keeps the key +// off the command line, and never writes it to a disk the target could reach. type TLSInterceptCA struct { - CertPath string - KeyPath string + CertPEM []byte + KeyPEM []byte +} + +// pemStream is what the proxy reads from stdin: one PEM stream carrying both +// halves. Order is irrelevant to the parser on the other side. +func (c *TLSInterceptCA) pemStream() []byte { + out := make([]byte, 0, len(c.CertPEM)+len(c.KeyPEM)+1) + out = append(out, c.CertPEM...) + if len(c.CertPEM) > 0 && c.CertPEM[len(c.CertPEM)-1] != '\n' { + out = append(out, '\n') + } + return append(out, c.KeyPEM...) } // Opts configures interception and the injected fault. @@ -253,13 +267,22 @@ func (o Opts) startArgs() []string { args = append(args, "--fault-hosts", strings.Join(o.Fault.Hosts, ",")) } // Start-only: --revert reconstructs the interception rules, which do not - // depend on the CA. + // depend on the CA. The PEM itself goes over stdin, never argv. if o.TLSInterceptCA != nil { - args = append(args, "--tls-ca-cert", o.TLSInterceptCA.CertPath, "--tls-ca-key", o.TLSInterceptCA.KeyPath) + args = append(args, "--tls-ca-stdin") } return args } +// stdinPayload is what gets written to the proxy's stdin at start: the +// interception CA, or nothing when HTTPS is not being decrypted. +func (o Opts) stdinPayload() []byte { + if o.TLSInterceptCA == nil { + return nil + } + return o.TLSInterceptCA.pemStream() +} + // sortedKeys returns a map's keys in a deterministic order so the built argv is // stable (which keeps revert-arg matching and tests predictable). func sortedKeys(m map[string]string) []string { @@ -357,8 +380,26 @@ func (b *processBase) waitExited() { <-b.done } -func (b *processBase) startAndMonitor(cmd *exec.Cmd, logId string) error { +// startAndMonitor starts cmd and watches it. stdin, when non-empty, is written +// to the process and the pipe then closed — this is how the interception CA is +// handed over without touching argv or the filesystem. +func (b *processBase) startAndMonitor(cmd *exec.Cmd, logId string, stdin []byte) error { logger := log.With().Str("id", logId).Logger() + if len(stdin) > 0 { + w, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to pipe transparent-proxy stdin: %w", err) + } + // Written after Start below; the proxy reads stdin to EOF at startup. + defer func() { + go func() { + defer func() { _ = w.Close() }() + if _, werr := w.Write(stdin); werr != nil { + logger.Warn().Err(werr).Msg("failed to write CA to transparent-proxy stdin") + } + }() + }() + } // stdout carries the JSON metrics stream (scraped for statistics); stderr // carries the proxy's structured logs. stdout, err := cmd.StdoutPipe() @@ -418,7 +459,7 @@ func newNetnsProcess(targetProcess ociruntime.LinuxProcessInfo, opts Opts) (Prox func (p *netnsProxy) Start() error { log.Trace().Str("cmd", p.opts.String()).Msg("starting transparent-proxy via ip netns exec") - return p.startAndMonitor(p.cmd, "transparent-proxy") + return p.startAndMonitor(p.cmd, "transparent-proxy", p.opts.stdinPayload()) } func (p *netnsProxy) Stop() error { @@ -494,7 +535,7 @@ func (d *runcProxy) Start() error { if err != nil { return fmt.Errorf("failed to create run command: %w", err) } - return d.startAndMonitor(cmd, d.bundle.ContainerId()) + return d.startAndMonitor(cmd, d.bundle.ContainerId(), d.opts.stdinPayload()) } func (d *runcProxy) Stop() error { diff --git a/go/action_kit_commons/network/proxyfault/proxyfault_test.go b/go/action_kit_commons/network/proxyfault/proxyfault_test.go index 8aa8557c..c3caf58e 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault_test.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault_test.go @@ -92,13 +92,21 @@ func TestStartArgs_tlsInterceptCA(t *testing.T) { // Unset: TLS is never decrypted, so the flags must be absent entirely. got := strings.Join(o.startArgs(), " ") - assert.NotContains(t, got, "--tls-ca-cert") - assert.NotContains(t, got, "--tls-ca-key") + assert.NotContains(t, got, "--tls-ca") - o.TLSInterceptCA = &TLSInterceptCA{CertPath: "/etc/steadybit/ca.crt", KeyPath: "/etc/steadybit/ca.key"} + assert.Nil(t, o.stdinPayload()) + + o.TLSInterceptCA = &TLSInterceptCA{CertPEM: []byte("CERT-PEM"), KeyPEM: []byte("KEY-PEM")} got = strings.Join(o.startArgs(), " ") - assert.Contains(t, got, "--tls-ca-cert /etc/steadybit/ca.crt") - assert.Contains(t, got, "--tls-ca-key /etc/steadybit/ca.key") + assert.Contains(t, got, "--tls-ca-stdin") + // The key must never reach the command line. + assert.NotContains(t, got, "CERT-PEM") + assert.NotContains(t, got, "KEY-PEM") + + // Both halves are handed over on stdin as one PEM stream. + payload := string(o.stdinPayload()) + assert.Contains(t, payload, "CERT-PEM") + assert.Contains(t, payload, "KEY-PEM") // Revert only reconstructs interception rules, which do not depend on the CA. assert.NotContains(t, strings.Join(o.revertArgs(), " "), "--tls-ca") From 8063eabee449ccb6be84e8f2d801206941d26c3c Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 4 Sep 2026 10:20:11 +0200 Subject: [PATCH 3/5] fix(proxyfault): tighten the CA handover, and drop a stray .DS_Store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings. The CA key was written from a deferred goroutine, which also ran on the StdoutPipe error path where cmd.Start had never been called. os/exec does not close the child end of the pipe on that path, so a failed start leaked a descriptor and left a copy of the private key resident for a process that never existed. The write now happens only after Start succeeds. --tls-ca-stdin was gated on the CA pointer being non-nil while the stdin write was gated on the payload being non-empty, so a half-populated CA told the proxy to read a stream that was never written — it would have hung reading an empty stdin and failed in a way that reads like a certificate problem rather than a configuration one. Both now share interceptCAPayload, which treats a missing half as no CA at all. Finally, an errant `git add` had committed go/.DS_Store; removed, and .DS_Store is now ignored. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA --- .gitignore | 3 ++ go/.DS_Store | Bin 6148 -> 0 bytes .../network/proxyfault/proxyfault.go | 46 ++++++++++++------ .../network/proxyfault/proxyfault_test.go | 18 +++++++ 4 files changed, 53 insertions(+), 14 deletions(-) delete mode 100644 go/.DS_Store diff --git a/.gitignore b/.gitignore index 44ce48c6..fc0b0813 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ /action_kit_api.go **/coverage.out **/*.o + +# macOS Finder metadata +.DS_Store diff --git a/go/.DS_Store b/go/.DS_Store deleted file mode 100644 index 6a40cb4cd286a5ae14c4da889d726bbf41b619bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKF;2rk5ZntcB9I_L6qK$g=<)*-nF@&}4@e+E5GBEp5-JMZAkiSi6Vf5k@B}If zKENM%1+%v&$ERS~X%L{@=x*%uj_+n1du<{zt*d^Ms6j+^G{#^Z-8aFOkfIKOaIg#>inRXdfNu zY~ck47`B0Jex+be=rD4a3gQUD2o)$)<32Hr&@mpmyvSiHD0DJX^2xZFjr&3|c6OXU zwCQ9bLG9H6b-;IEO?Dex{|}45|9z6~sRQc3zjD9?aXW57lB=yt;<(m^Xk9c8&PxR? k1)VF$c7sdtE}9J-3wZ+=IZOpHg3uQMYlC*`z>hlc1*)pbssI20 diff --git a/go/action_kit_commons/network/proxyfault/proxyfault.go b/go/action_kit_commons/network/proxyfault/proxyfault.go index 63411a25..81cdbc74 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault.go @@ -268,19 +268,33 @@ func (o Opts) startArgs() []string { } // Start-only: --revert reconstructs the interception rules, which do not // depend on the CA. The PEM itself goes over stdin, never argv. - if o.TLSInterceptCA != nil { + // + // Gated on the same condition as the payload: telling the proxy to read its + // CA from stdin while writing nothing there would leave it reading an empty + // stream, which is a far more confusing failure than not enabling it. + if _, ok := o.interceptCAPayload(); ok { args = append(args, "--tls-ca-stdin") } return args } +// interceptCAPayload returns the PEM to hand the proxy on stdin, and whether a +// usable CA was configured at all. A half-populated CA counts as unusable: the +// proxy needs both halves, and silently sending one produces a startup failure +// that reads like a certificate problem. +func (o Opts) interceptCAPayload() ([]byte, bool) { + ca := o.TLSInterceptCA + if ca == nil || len(ca.CertPEM) == 0 || len(ca.KeyPEM) == 0 { + return nil, false + } + return ca.pemStream(), true +} + // stdinPayload is what gets written to the proxy's stdin at start: the // interception CA, or nothing when HTTPS is not being decrypted. func (o Opts) stdinPayload() []byte { - if o.TLSInterceptCA == nil { - return nil - } - return o.TLSInterceptCA.pemStream() + payload, _ := o.interceptCAPayload() + return payload } // sortedKeys returns a map's keys in a deterministic order so the built argv is @@ -385,20 +399,13 @@ func (b *processBase) waitExited() { // handed over without touching argv or the filesystem. func (b *processBase) startAndMonitor(cmd *exec.Cmd, logId string, stdin []byte) error { logger := log.With().Str("id", logId).Logger() + var stdinPipe io.WriteCloser if len(stdin) > 0 { w, err := cmd.StdinPipe() if err != nil { return fmt.Errorf("failed to pipe transparent-proxy stdin: %w", err) } - // Written after Start below; the proxy reads stdin to EOF at startup. - defer func() { - go func() { - defer func() { _ = w.Close() }() - if _, werr := w.Write(stdin); werr != nil { - logger.Warn().Err(werr).Msg("failed to write CA to transparent-proxy stdin") - } - }() - }() + stdinPipe = w } // stdout carries the JSON metrics stream (scraped for statistics); stderr // carries the proxy's structured logs. @@ -411,6 +418,17 @@ func (b *processBase) startAndMonitor(cmd *exec.Cmd, logId string, stdin []byte) return fmt.Errorf("failed to start transparent-proxy: %w", err) } b.started.Store(true) + // Only after a successful Start. On the error paths above os/exec never + // closes the child end of the pipe, so writing there would leak a descriptor + // and leave a copy of the CA key resident for a process that never ran. + if stdinPipe != nil { + go func() { + defer func() { _ = stdinPipe.Close() }() + if _, werr := stdinPipe.Write(stdin); werr != nil { + logger.Warn().Err(werr).Msg("failed to write CA to transparent-proxy stdin") + } + }() + } scanDone := make(chan struct{}) go func() { defer close(scanDone) diff --git a/go/action_kit_commons/network/proxyfault/proxyfault_test.go b/go/action_kit_commons/network/proxyfault/proxyfault_test.go index c3caf58e..f447a426 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault_test.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault_test.go @@ -194,3 +194,21 @@ func TestStartArgs_MetricsStdoutAndNoFlush(t *testing.T) { t.Errorf("missing no-flush flag: %s", args) } } + +// A half-populated CA is unusable: the proxy needs both halves. Telling it to +// read the CA from stdin while writing nothing there would leave it reading an +// empty stream — a failure that reads like a certificate problem rather than a +// configuration one. +func TestStartArgs_tlsInterceptCA_halfPopulated(t *testing.T) { + for _, ca := range []*TLSInterceptCA{ + {CertPEM: []byte("CERT-ONLY")}, + {KeyPEM: []byte("KEY-ONLY")}, + {}, + } { + o := sampleOpts(t) + o.TLSInterceptCA = ca + assert.NotContains(t, strings.Join(o.startArgs(), " "), "--tls-ca-stdin", + "the flag must not be set without a complete CA") + assert.Nil(t, o.stdinPayload()) + } +} From 0390e3d50526790d46a501ede75af6b72b2036ec Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 4 Sep 2026 11:34:38 +0200 Subject: [PATCH 4/5] fix(proxyfault): close the stdin pipe if the stdout pipe fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flagged by the PR review as unreachable in practice — StdoutPipe only fails when cmd.Stdout is already set, and cmd is always freshly constructed — but returning there after StdinPipe succeeded would strand its write end. Cheap to close, and the ordering is easy to change later without noticing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA --- go/action_kit_commons/network/proxyfault/proxyfault.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/go/action_kit_commons/network/proxyfault/proxyfault.go b/go/action_kit_commons/network/proxyfault/proxyfault.go index 81cdbc74..8233d188 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault.go @@ -411,6 +411,12 @@ func (b *processBase) startAndMonitor(cmd *exec.Cmd, logId string, stdin []byte) // carries the proxy's structured logs. stdout, err := cmd.StdoutPipe() if err != nil { + // Unreachable in practice (StdoutPipe only fails when cmd.Stdout is + // already set, and cmd is always freshly built), but returning here + // without closing the stdin pipe would strand its write end. + if stdinPipe != nil { + _ = stdinPipe.Close() + } return fmt.Errorf("failed to pipe transparent-proxy stdout: %w", err) } cmd.Stderr = &logWriter{logger: logger} From fda5db7ff7979dea5306e494bcc6b728f910c28a Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 4 Sep 2026 13:54:46 +0200 Subject: [PATCH 5/5] feat(proxyfault): forward the leaf-validity setting to the proxy TLSInterceptCA.LeafValidity emits --tls-leaf-validity, so an extension can expose how long minted per-SNI certificates live. Zero omits the flag and keeps the proxy's own default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA --- go/action_kit_commons/network/proxyfault/proxyfault.go | 7 +++++++ .../network/proxyfault/proxyfault_test.go | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/go/action_kit_commons/network/proxyfault/proxyfault.go b/go/action_kit_commons/network/proxyfault/proxyfault.go index 8233d188..8913da6a 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault.go @@ -80,6 +80,10 @@ type Fault struct { type TLSInterceptCA struct { CertPEM []byte KeyPEM []byte + // LeafValidity, when >0, overrides how long the per-SNI certificates the + // proxy mints stay valid. Always clamped to the CA's own expiry by the + // proxy. Zero keeps the proxy's built-in default. + LeafValidity time.Duration } // pemStream is what the proxy reads from stdin: one PEM stream carrying both @@ -274,6 +278,9 @@ func (o Opts) startArgs() []string { // stream, which is a far more confusing failure than not enabling it. if _, ok := o.interceptCAPayload(); ok { args = append(args, "--tls-ca-stdin") + if o.TLSInterceptCA.LeafValidity > 0 { + args = append(args, "--tls-leaf-validity", o.TLSInterceptCA.LeafValidity.String()) + } } return args } diff --git a/go/action_kit_commons/network/proxyfault/proxyfault_test.go b/go/action_kit_commons/network/proxyfault/proxyfault_test.go index f447a426..d7bcd496 100644 --- a/go/action_kit_commons/network/proxyfault/proxyfault_test.go +++ b/go/action_kit_commons/network/proxyfault/proxyfault_test.go @@ -212,3 +212,13 @@ func TestStartArgs_tlsInterceptCA_halfPopulated(t *testing.T) { assert.Nil(t, o.stdinPayload()) } } + +func TestStartArgs_tlsLeafValidity(t *testing.T) { + o := sampleOpts(t) + o.TLSInterceptCA = &TLSInterceptCA{CertPEM: []byte("C"), KeyPEM: []byte("K")} + // Unset: the proxy's own default applies, so the flag is omitted. + assert.NotContains(t, strings.Join(o.startArgs(), " "), "--tls-leaf-validity") + + o.TLSInterceptCA.LeafValidity = 2 * time.Hour + assert.Contains(t, strings.Join(o.startArgs(), " "), "--tls-leaf-validity 2h0m0s") +}