zuka
zuka/src/control/proxy.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/control/proxy.rs
RSproxy.rs6.5 KBDownload
1// Proxying a request to a tenant.
2//
3// Streamed in both directions. A clone is a multi-gigabyte response and a push is a
4// multi-gigabyte request; buffering either would undo the work the transport layer
5// does to avoid exactly that, and would do it at the hop where there are N tenants
6// rather than one.
7//
8// Every proxied request carries a fresh signed assertion (`account::assertion`). It
9// is single-use and bound to this method, path and body, so a tenant that logs a
10// header, or an operator who reads one, holds nothing reusable.
11
12use crate::account::assertion::{header, Claims, Signer_};
13use crate::error::{Error, Result};
14use crate::http::response::Body;
15use futures::TryStreamExt;
16use http_body_util::{BodyExt, StreamBody};
17use hyper::body::{Frame, Incoming};
18use hyper::{Request, Response, StatusCode};
19use std::time::Duration;
20
21/// Headers that must not be forwarded in either direction.
22fn is_hop_by_hop(name: &str) -> bool {
23 const BLOCKED: &[&str] = &[
24 "connection",
25 "keep-alive",
26 "transfer-encoding",
27 "upgrade",
28 "proxy-authenticate",
29 "proxy-authorization",
30 "te",
31 "trailer",
32 "host",
33 // The caller's credential stops here: a tenant is told who is calling by the
34 // signed assertion, and forwarding the original token would let a tenant
35 // replay it against the control plane.
36 "authorization",
37 ];
38 BLOCKED.iter().any(|b| name.eq_ignore_ascii_case(b))
39}
40
41/// Forward one request to a tenant and stream the answer back.
42pub async fn forward(
43 client: &reqwest::Client,
44 signer: &Signer_,
45 endpoint: &str,
46 account: &str,
47 request: Request<Incoming>,
48 max_body: u64,
49) -> Result<Response<Body>> {
50 forward_as(client, Some((signer, account)), endpoint, request, max_body).await
51}
52
53/// Forward without vouching for anybody.
54///
55/// The public browser surface reaches a tenant with no credential, and it must stay
56/// that way through the hop: an assertion is the control plane stating "this request
57/// is that account", so signing one for an anonymous visitor would hand them the
58/// account's private repositories. The tenant sees no assertion, resolves no
59/// identity, and applies the public rule itself.
60pub async fn forward_anonymous(
61 client: &reqwest::Client,
62 endpoint: &str,
63 request: Request<Incoming>,
64 max_body: u64,
65) -> Result<Response<Body>> {
66 forward_as(client, None, endpoint, request, max_body).await
67}
68
69async fn forward_as(
70 client: &reqwest::Client,
71 vouch: Option<(&Signer_, &str)>,
72 endpoint: &str,
73 request: Request<Incoming>,
74 max_body: u64,
75) -> Result<Response<Body>> {
76 let method = request.method().clone();
77 let uri = request.uri().clone();
78 let path_and_query = uri
79 .path_and_query()
80 .map(|p| p.as_str())
81 .unwrap_or_else(|| uri.path());
82 let headers = request.headers().clone();
83
84 // Streamed, not buffered. A push is a multi-gigabyte pack, and this is the hop
85 // with N tenants behind it — buffering here would undo the work the transport
86 // does to avoid exactly that. The assertion is deliberately not bound to the
87 // body for the same reason; its nonce is what stops replay.
88 let _ = max_body;
89 let assertion = vouch
90 .map(|(signer, account)| signer.sign(&Claims::new(account, method.as_str(), uri.path())));
91
92 let upstream = request
93 .into_body()
94 .into_data_stream()
95 .map_err(std::io::Error::other);
96
97 let mut outbound = client
98 .request(
99 reqwest::Method::from_bytes(method.as_str().as_bytes())
100 .map_err(|_| Error::invalid("invalid-method", "unsupported method"))?,
101 format!("{}{}", endpoint.trim_end_matches('/'), path_and_query),
102 )
103 .body(reqwest::Body::wrap_stream(upstream));
104
105 if let Some(assertion) = assertion {
106 outbound = outbound.header(header(), assertion);
107 }
108
109 for (name, value) in headers.iter() {
110 // Never relay the assertion header a client sent. Without this, a caller
111 // could mint their own and the tenant would see two — and whichever it read
112 // first, one of them was not written by the control plane.
113 if !is_hop_by_hop(name.as_str()) && !name.as_str().eq_ignore_ascii_case(header()) {
114 outbound = outbound.header(name.as_str(), value.as_bytes());
115 }
116 }
117
118 let response = outbound.send().await.map_err(|e| {
119 // A tenant that is up but not answering is not the caller's fault, and is
120 // usually transient — it is still booting, or was just started.
121 eprintln!("[control] proxy to {endpoint} failed: {e}");
122 Error::RateLimited { retry_after: 5 }
123 })?;
124
125 let status =
126 StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
127 let mut builder = Response::builder().status(status);
128 for (name, value) in response.headers().iter() {
129 if !is_hop_by_hop(name.as_str()) {
130 builder = builder.header(name.as_str(), value.as_bytes());
131 }
132 }
133
134 let stream = response
135 .bytes_stream()
136 .map_ok(Frame::data)
137 .map_err(std::io::Error::other);
138
139 builder
140 .body(StreamBody::new(stream).boxed())
141 .map_err(|e| Error::Internal(anyhow::Error::from(e).context("build proxy response")))
142}
143
144/// A client configured for proxying.
145///
146/// No redirect following: a tenant redirecting us is either a bug or an attempt to
147/// make the control plane fetch something else.
148pub fn client(timeout: Duration) -> Result<reqwest::Client> {
149 reqwest::Client::builder()
150 .redirect(reqwest::redirect::Policy::none())
151 .connect_timeout(Duration::from_secs(5))
152 .timeout(timeout)
153 .build()
154 .map_err(|e| Error::Internal(anyhow::Error::from(e).context("build proxy client")))
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn the_callers_credential_is_not_forwarded_to_the_tenant() {
163 // A tenant learns who is calling from the signed assertion. Forwarding the
164 // original token would let it replay that token against the control plane.
165 assert!(is_hop_by_hop("Authorization"));
166 assert!(is_hop_by_hop("authorization"));
167 }
168
169 #[test]
170 fn hop_by_hop_headers_are_not_forwarded() {
171 for header in ["Connection", "Transfer-Encoding", "Upgrade", "Host", "TE"] {
172 assert!(is_hop_by_hop(header), "{header} must not cross the proxy");
173 }
174 assert!(!is_hop_by_hop("Content-Type"));
175 assert!(!is_hop_by_hop("Git-Protocol"));
176 }
177}