From eb76c819095712f583fad3c2c07c81cdb7c6a190 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sat, 27 Jun 2026 11:52:18 -0400 Subject: [PATCH] feat(relay): route git pre-receive policy callback over UDS when configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-receive hook curls the relay's own /internal/git/policy callback. By default it targets the TCP loopback 127.0.0.1:{bind_port}. In a mesh deployment (e.g. Istio) that loopback port can be iptables-captured by the sidecar, so the relay's call to itself fails with 'network error reaching policy service' and every push is rejected. The relay already serves the same router over its UDS listener (main.rs) when BUZZ_UDS_PATH is set, so the policy endpoint is already reachable over the socket — only the hook's curl target needs to change. - transport.rs: when config.uds_path is set, point BUZZ_HOOK_URL at a placeholder host (curl ignores host:port under --unix-socket) and pass the socket path via BUZZ_HOOK_SOCKET. Extract policy_hook_url() as a pure, unit-testable helper. When uds_path is None, behavior is byte-identical to before (TCP loopback) — local/dev/CI are untouched. - hook.rs: add --unix-socket "$BUZZ_HOOK_SOCKET" to the policy curl iff BUZZ_HOOK_SOCKET is non-empty. --unix-socket is stock libcurl. - Unit tests for both URL branches. Pairs with a tf-stacks chart change that sets BUZZ_UDS_PATH + a shared emptyDir mount; that removes the relay's dependency on the mesh loopback entirely. Refs RESEARCH/SPROUT_GIT_POLICY_DEPLOY_MISMATCH.md. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-relay/src/api/git/hook.rs | 10 ++++ crates/buzz-relay/src/api/git/transport.rs | 65 ++++++++++++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/api/git/hook.rs b/crates/buzz-relay/src/api/git/hook.rs index 8b01366616..9424e4464e 100644 --- a/crates/buzz-relay/src/api/git/hook.rs +++ b/crates/buzz-relay/src/api/git/hook.rs @@ -122,7 +122,17 @@ fi SAFE_REPO_ID=$(printf '%s' "$BUZZ_REPO_ID" | sed 's/\\/\\\\/g; s/"/\\"/g') BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${BUZZ_REPO_OWNER}\",\"pusher_pubkey\":\"${BUZZ_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}" +# Route the policy callback over a unix socket when the relay configured one +# (BUZZ_HOOK_SOCKET). This lets the hook reach the relay's own endpoint even +# when the loopback TCP port is intercepted in-pod (e.g. by an Istio sidecar). +# When unset/empty, fall through to the TCP URL exactly as before. +CURL_TRANSPORT=() +if [ -n "${BUZZ_HOOK_SOCKET:-}" ]; then + CURL_TRANSPORT=(--unix-socket "$BUZZ_HOOK_SOCKET") +fi + HTTP_CODE=$(curl --silent --max-time 10 \ + "${CURL_TRANSPORT[@]}" \ -o "$RESP_FILE" \ -w "%{http_code}" \ -X POST \ diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 02cd71ca61..3abdbf2cd2 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -627,6 +627,21 @@ pub async fn upload_pack( ) } +/// Build the URL the pre-receive hook curls for the `/internal/git/policy` +/// callback. +/// +/// When a UDS path is configured the hook routes over the unix socket (the +/// caller adds `--unix-socket`), and curl ignores the URL host:port — so the +/// host here is a placeholder. Otherwise the hook curls the relay's own TCP +/// loopback on its bind port. Kept as a small pure fn so the host/port +/// selection is unit-testable without standing up a relay. +fn policy_hook_url(uds_path: Option<&str>, bind_port: u16) -> String { + match uds_path { + Some(_) => "http://localhost/internal/git/policy".to_string(), + None => format!("http://127.0.0.1:{bind_port}/internal/git/policy"), + } +} + /// `POST /git/{owner}/{repo}/git-receive-pack` /// /// Handles push — client sends ref updates + pack data. @@ -695,12 +710,23 @@ pub async fn receive_pack( })?; // Build hook env vars for the pre-receive hook. - let hook_url = format!( - "http://127.0.0.1:{}/internal/git/policy", - state.config.bind_addr.port() + // + // The hook curls the relay's own /internal/git/policy callback. By default + // it targets the TCP loopback `127.0.0.1:{bind_port}`. When a UDS is + // configured (BUZZ_UDS_PATH), the same router is also served over the unix + // socket (see main.rs), so we point the hook at the socket via curl's + // --unix-socket. This dodges any in-pod L4 interception of the loopback + // TCP port (e.g. an Istio sidecar's iptables capture of the app port), + // which otherwise breaks the relay's call to itself. + // + // Under --unix-socket curl ignores the URL host:port and uses only the + // path, so the URL host is a placeholder when a socket is set. + let hook_url = policy_hook_url( + state.config.uds_path.as_deref(), + state.config.bind_addr.port(), ); let hooks_dir = repo.path().join("hooks").display().to_string(); - let hook_env = vec![ + let mut hook_env = vec![ ("BUZZ_HOOK_URL", hook_url), ( "BUZZ_HOOK_SECRET", @@ -716,6 +742,12 @@ pub async fn receive_pack( ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()), ("GIT_CONFIG_VALUE_0", hooks_dir), ]; + // When a UDS is configured, route the policy callback over it. The hook + // only honors BUZZ_HOOK_SOCKET when it is non-empty, so the TCP path is + // untouched for local/dev/CI where BUZZ_UDS_PATH is unset. + if let Some(ref uds_path) = state.config.uds_path { + hook_env.push(("BUZZ_HOOK_SOCKET", uds_path.clone())); + } // Run receive-pack against the tempdir. Returns the *owned* subprocess // output (PackOutput) — crucially NOT a Response, so the post-push @@ -1329,4 +1361,29 @@ mod track_c_tests { let caps = String::from_utf8_lossy(&body); assert!(caps.contains("object-format=sha256")); } + + #[test] + fn policy_hook_url_uses_tcp_loopback_without_uds() { + // No UDS configured → hook curls the relay's own TCP loopback on its + // bind port. Unchanged behavior for local/dev/CI. + assert_eq!( + policy_hook_url(None, 3000), + "http://127.0.0.1:3000/internal/git/policy" + ); + assert_eq!( + policy_hook_url(None, 8080), + "http://127.0.0.1:8080/internal/git/policy" + ); + } + + #[test] + fn policy_hook_url_uses_placeholder_host_with_uds() { + // UDS configured → curl routes over --unix-socket and ignores the URL + // host:port, so the bind port must not leak into the URL. The path is + // what matters; the host is a placeholder. + let url = policy_hook_url(Some("/run/buzz/relay.sock"), 3000); + assert_eq!(url, "http://localhost/internal/git/policy"); + assert!(!url.contains("3000")); + assert!(!url.contains("127.0.0.1")); + } }