diff --git a/sandboxd/egress/proxy.go b/sandboxd/egress/proxy.go index b96142eb..65b0a2e6 100644 --- a/sandboxd/egress/proxy.go +++ b/sandboxd/egress/proxy.go @@ -12,10 +12,13 @@ import ( "slices" "strings" "sync" + "time" "github.com/cocoonstack/sandbox/sandboxd/utils" ) +const idleConnTimeout = 90 * time.Second + // hopHeaders are hop-by-hop and proxy-scoped headers this hop owns: stripped // from forwarded requests and from relayed responses rather than passed on. var hopHeaders = []string{ @@ -85,15 +88,12 @@ func New(sandbox, tenant string, policy Evaluator, secrets Secrets, ca *CA, dial dial: dial, // The stdlib default of 2 idle conns per host re-dials bursty // same-host plaintext traffic. - tr: &http.Transport{DialContext: dial, MaxIdleConnsPerHost: 8}, + tr: &http.Transport{DialContext: dial, MaxIdleConnsPerHost: 8, IdleConnTimeout: idleConnTimeout}, conns: map[net.Conn]struct{}{}, } if ca != nil { - p.mitmTr = &http.Transport{ - DialContext: dial, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, - MaxIdleConnsPerHost: 8, - } + p.mitmTr = p.tr.Clone() + p.mitmTr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} p.leaves = map[string]*tls.Certificate{} } return p @@ -118,6 +118,10 @@ func (p *Proxy) Close() { for _, conn := range conns { _ = conn.Close() } + p.tr.CloseIdleConnections() + if p.mitmTr != nil { + p.mitmTr.CloseIdleConnections() + } } // track registers conn for Close; false means already closed, conn closed instead. diff --git a/sandboxd/egress/proxy_test.go b/sandboxd/egress/proxy_test.go index d6471c25..2e9c3a8a 100644 --- a/sandboxd/egress/proxy_test.go +++ b/sandboxd/egress/proxy_test.go @@ -52,6 +52,43 @@ func TestForwardAllowInjectsSecretAndOverwritesGuestHeader(t *testing.T) { } } +func TestCloseReleasesIdleUpstreamConns(t *testing.T) { + closed := make(chan struct{}, 4) + upstream := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "hello") + })) + upstream.Config.ConnState = func(_ net.Conn, st http.ConnState) { + if st == http.StateClosed { + closed <- struct{}{} + } + } + upstream.Start() + defer upstream.Close() + + policy := Policy{Allow: []Rule{{Host: "api.internal"}}} + p := New("sb_1", "acme", policy, nil, nil, fixedDial(upstream.Listener.Addr().String()), nil) + front := httptest.NewServer(p) + defer front.Close() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://api.internal/x", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + resp, err := proxyClient(t, front.URL).Do(req) + if err != nil { + t.Fatalf("proxied GET: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + p.Close() + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("upstream connection still open after Close") + } +} + func TestForwardNeverInjectsInterceptSecret(t *testing.T) { reached := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/sdk/go/silkd/silkdtest/fake.go b/sdk/go/silkd/silkdtest/fake.go index 15c10965..4ff21542 100644 --- a/sdk/go/silkd/silkdtest/fake.go +++ b/sdk/go/silkd/silkdtest/fake.go @@ -287,9 +287,16 @@ func (f *Fake) fsFind(conn net.Conn, req *wire.FsFind) { if req.Glob != "" { nameRe = globRegexp(req.Glob) } - walkErr := filepath.WalkDir(f.abs(req.Path), func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err + root := f.abs(req.Path) + walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if path == root { + return err + } + return nil + } + if d.IsDir() { + return nil } if nameRe != nil && !nameRe.MatchString(d.Name()) { return nil diff --git a/silkd/src/find.rs b/silkd/src/find.rs index 92df425e..b069ae55 100644 --- a/silkd/src/find.rs +++ b/silkd/src/find.rs @@ -2,7 +2,7 @@ //! rewrites matches in named files. Regex handling lives here so agents pass a //! pattern as data rather than shell-quoting it through exec. -use std::path::Path; +use std::path::{Path, PathBuf}; use regex::Regex; use tokio::fs; @@ -32,28 +32,32 @@ pub async fn find( Some(Ok(re)) => Some(re), Some(Err(e)) => return proto::error_frame(w, ErrorKind::BadRequest, e.to_string()).await, }; - let mut stack = vec![path]; + let mut stack = vec![PathBuf::from(path)]; + let mut root = true; while let Some(dir) = stack.pop() { let mut rd = match fs::read_dir(&dir).await { Ok(rd) => rd, - Err(e) => return err_frame(w, &e, "read_dir").await, + Err(e) if root => return err_frame(w, &e, "read_dir").await, + Err(_) => continue, }; loop { let ent = match rd.next_entry().await { Ok(Some(ent)) => ent, Ok(None) => break, - Err(e) => return err_frame(w, &e, "read_dir").await, + Err(e) if root => return err_frame(w, &e, "read_dir").await, + Err(_) => break, }; let Ok(ft) = ent.file_type().await else { continue; }; let p = ent.path(); if ft.is_dir() { - stack.push(p.to_string_lossy().into_owned()); + stack.push(p); } else if ft.is_file() && name_matches(&p, name_re.as_ref()) { scan_file(w, &re, &p).await?; } } + root = false; } proto::write_frame(w, &Response::Done).await }