From 6df4189ceddc2eedd43e8a85b43bc05f68c76723 Mon Sep 17 00:00:00 2001 From: Akshay Oppiliappan Date: Sun, 4 Oct 2026 09:47:37 +0100 Subject: [PATCH] knot2/worker: redirect clones to the artifacts remote fetches now 302 (info/refs) or 307 (a bare upload-pack post) to the repo's artifacts remote with the knot's read token as url userinfo, so git talks to artifacts directly. git keeps the knot as origin and comes back through it on the next fetch for a fresh token. pushes are still forwarded so the knot sees the ref updates. Co-Authored-By: Claude Opus 5.5 (1M context) --- knot2/worker/src/proxy.rs | 74 +++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/knot2/worker/src/proxy.rs b/knot2/worker/src/proxy.rs index cad5ef795..f35f780fc 100644 --- a/knot2/worker/src/proxy.rs +++ b/knot2/worker/src/proxy.rs @@ -1,7 +1,8 @@ -// knot2's git smart-http routes, forwarded to the repo's artifacts remote. -// fetches use the knot's read token; pushes carry the pusher's own artifacts -// token. a push's ref commands and report-status are read on the way through -// so the knot can announce what moved. +// knot2's git smart-http routes. fetches redirect to the repo's artifacts +// remote with the knot's read token in the url, so git talks to artifacts +// directly. pushes carry the pusher's own artifacts token and are forwarded, +// their ref commands and report-status read on the way through so the knot +// can announce what moved. use worker::{Fetch, Headers, Method, Request, RequestInit, Response}; @@ -82,6 +83,39 @@ async fn resolve(knot: &Knot, path: &GitPath) -> Result, KnotErro } } +// userinfo may not hold the token's '?', '=' and friends as-is +fn userinfo_escape(value: &str) -> String { + value + .bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (byte as char).to_string(), + _ => format!("%{byte:02X}"), + }) + .collect() +} + +// 302 for info/refs, which git follows and rebases the fetch onto; 307 keeps a +// bare upload-pack post a post +async fn redirect(knot: &Knot, hosted: &Hosted, route: &GitRoute, service: Option<&str>) -> worker::Result { + let token = match knot.read_token(hosted).await { + Ok(token) => token, + Err(error) => return plain(503, &error.to_string()), + }; + let base = hosted.remote.trim_end_matches('/'); + let base = match (token, base.split_once("://")) { + (Some(token), Some((scheme, rest))) => format!("{scheme}://x:{}@{rest}", userinfo_escape(&token)), + _ => base.to_string(), + }; + let (location, status) = match route { + GitRoute::InfoRefs => (format!("{base}/info/refs?service={}", service.unwrap_or_default()), 302), + _ => (format!("{base}/git-upload-pack"), 307), + }; + let headers = Headers::new(); + headers.set("Location", &location)?; + headers.set("Cache-Control", "no-store")?; + Ok(Response::empty()?.with_status(status).with_headers(headers)) +} + fn copy_header(from: &Headers, to: &Headers, name: &str) -> worker::Result<()> { if let Some(value) = from.get(name)? { to.set(name, &value)?; @@ -191,29 +225,25 @@ pub async fn serve( { return plain(400, "unsupported service"); } + if !receive { + return redirect(knot, &hosted, &path.route, service.as_deref()).await; + } let incoming = req.headers().clone(); let outgoing = Headers::new(); for name in ["Content-Type", "Content-Encoding", "Accept", "Git-Protocol", "User-Agent"] { copy_header(&incoming, &outgoing, name)?; } - match receive { - true => match incoming.get("Authorization")? { - Some(value) => outgoing.set("Authorization", &value)?, - None => { - let headers = Headers::new(); - headers.set("WWW-Authenticate", "Basic realm=\"knot\"")?; - headers.set("Content-Type", "text/plain; charset=utf-8")?; - return Ok(Response::from_bytes(b"push needs an artifacts token".to_vec())? - .with_status(401) - .with_headers(headers)); - } - }, - false => match knot.read_token(&hosted).await { - Ok(Some(token)) => outgoing.set("Authorization", &format!("Bearer {token}"))?, - Ok(None) => {} - Err(error) => return plain(503, &error.to_string()), - }, + match incoming.get("Authorization")? { + Some(value) => outgoing.set("Authorization", &value)?, + None => { + let headers = Headers::new(); + headers.set("WWW-Authenticate", "Basic realm=\"knot\"")?; + headers.set("Content-Type", "text/plain; charset=utf-8")?; + return Ok(Response::from_bytes(b"push needs an artifacts token".to_vec())? + .with_status(401) + .with_headers(headers)); + } } let base = hosted.remote.trim_end_matches('/'); @@ -223,7 +253,7 @@ pub async fn serve( Method::Get, None, ), - GitRoute::UploadPack => (format!("{base}/git-upload-pack"), Method::Post, Some(req.bytes().await?)), + GitRoute::UploadPack => return plain(400, "unsupported service"), GitRoute::ReceivePack => (format!("{base}/git-receive-pack"), Method::Post, Some(req.bytes().await?)), }; let commands = match (&path.route, &body) { -- 2.51.2