samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
238 lines· 7.4 KB ·Rust
Raw
1//! Git "smart HTTP" protocol for cloning and fetching.
2//!
3//! Public repositories can be cloned anonymously; private ones accept HTTP
4//! basic auth with the account password. Pushing is only offered over SSH,
5//! where access is controlled by SSH keys.
6
7use std::io::Read;
8use std::process::Stdio;
9
10use axum::body::{Body, Bytes};
11use axum::extract::{Path, Query, State};
12use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
13use axum::response::{IntoResponse, Response};
14use base64::Engine;
15use base64::engine::general_purpose::STANDARD;
16use serde::Deserialize;
17use tokio::io::AsyncWriteExt;
18use tokio::process::Command;
19use tokio_util::io::ReaderStream;
20
21use super::account::verify_password;
22use crate::db::{self, Repo};
23use crate::state::AppState;
24
25fn plain(status: StatusCode, message: &str) -> Response {
26 (status, format!("{message}\n")).into_response()
27}
28
29fn unauthorized() -> Response {
30 let mut resp = plain(StatusCode::UNAUTHORIZED, "Authentication required");
31 resp.headers_mut().insert(
32 header::WWW_AUTHENTICATE,
33 HeaderValue::from_static("Basic realm=\"Openhub\""),
34 );
35 resp
36}
37
38/// Resolves the repository and checks read access, returning an HTTP error
39/// response (401 or 404) when the request may not proceed.
40async fn authorize(
41 state: &AppState,
42 headers: &HeaderMap,
43 owner: &str,
44 name: &str,
45) -> Result<Repo, Response> {
46 let name = name.strip_suffix(".git").unwrap_or(name);
47 let not_found = || plain(StatusCode::NOT_FOUND, "Repository not found");
48 let repo = match db::repo_by_name(&state.db, owner, name).await {
49 Ok(Some(r)) => r,
50 Ok(None) => return Err(not_found()),
51 Err(e) => {
52 tracing::error!("repository lookup failed: {e}");
53 return Err(plain(StatusCode::INTERNAL_SERVER_ERROR, "Internal error"));
54 }
55 };
56 if !repo.is_private {
57 return Ok(repo);
58 }
59 let Some((login, password)) = basic_auth(headers) else {
60 return Err(unauthorized());
61 };
62 let user = match db::user_by_login(&state.db, &login).await {
63 Ok(Some(u)) => u,
64 _ => return Err(unauthorized()),
65 };
66 if !verify_password(password, user.password_hash.clone()).await {
67 return Err(unauthorized());
68 }
69 match state.access(&repo, Some(&user)).await {
70 Ok(a) if a.read => Ok(repo),
71 _ => Err(not_found()),
72 }
73}
74
75fn basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
76 let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
77 let encoded = value.strip_prefix("Basic ")?;
78 let decoded = String::from_utf8(STANDARD.decode(encoded.trim()).ok()?).ok()?;
79 let (user, pass) = decoded.split_once(':')?;
80 Some((user.to_string(), pass.to_string()))
81}
82
83fn protocol_v2(headers: &HeaderMap) -> Option<String> {
84 headers
85 .get("git-protocol")
86 .and_then(|v| v.to_str().ok())
87 .filter(|v| {
88 v.chars()
89 .all(|c| c.is_ascii_alphanumeric() || "=:.".contains(c))
90 })
91 .map(str::to_string)
92}
93
94fn pkt_line(s: &str) -> Vec<u8> {
95 let mut out = format!("{:04x}", s.len() + 4).into_bytes();
96 out.extend_from_slice(s.as_bytes());
97 out
98}
99
100#[derive(Deserialize)]
101pub struct InfoRefsQuery {
102 service: Option<String>,
103}
104
105pub async fn info_refs(
106 State(state): State<AppState>,
107 Path((owner, name)): Path<(String, String)>,
108 Query(q): Query<InfoRefsQuery>,
109 headers: HeaderMap,
110) -> Response {
111 match q.service.as_deref() {
112 Some("git-upload-pack") => {}
113 Some("git-receive-pack") => {
114 return plain(
115 StatusCode::FORBIDDEN,
116 "Pushing over HTTP is not supported. Add an SSH key in your Openhub settings and push over SSH.",
117 );
118 }
119 _ => {
120 return plain(
121 StatusCode::FORBIDDEN,
122 "Only the smart HTTP protocol is supported",
123 );
124 }
125 }
126 let repo = match authorize(&state, &headers, &owner, &name).await {
127 Ok(r) => r,
128 Err(resp) => return resp,
129 };
130 let path = state.config.repo_path(repo.id);
131 let protocol = protocol_v2(&headers);
132 let mut cmd = Command::new(&state.config.git_bin);
133 cmd.args(["upload-pack", "--stateless-rpc", "--advertise-refs"])
134 .arg(&path)
135 .stdin(Stdio::null())
136 .stderr(Stdio::null());
137 if let Some(p) = &protocol {
138 cmd.env("GIT_PROTOCOL", p);
139 }
140 let output = match cmd.output().await {
141 Ok(o) if o.status.success() => o.stdout,
142 Ok(o) => {
143 tracing::error!("git upload-pack --advertise-refs failed: {}", o.status);
144 return plain(StatusCode::INTERNAL_SERVER_ERROR, "git failed");
145 }
146 Err(e) => {
147 tracing::error!("could not run git: {e}");
148 return plain(StatusCode::INTERNAL_SERVER_ERROR, "git is not available");
149 }
150 };
151 let mut body = Vec::with_capacity(output.len() + 64);
152 // Protocol v2 responses start directly with the capability advertisement.
153 if !protocol.as_deref().is_some_and(|p| p.contains("version=2")) {
154 body.extend(pkt_line("# service=git-upload-pack\n"));
155 body.extend_from_slice(b"0000");
156 }
157 body.extend(output);
158 (
159 [
160 (
161 header::CONTENT_TYPE,
162 "application/x-git-upload-pack-advertisement",
163 ),
164 (header::CACHE_CONTROL, "no-cache"),
165 ],
166 body,
167 )
168 .into_response()
169}
170
171pub async fn upload_pack(
172 State(state): State<AppState>,
173 Path((owner, name)): Path<(String, String)>,
174 headers: HeaderMap,
175 body: Bytes,
176) -> Response {
177 let repo = match authorize(&state, &headers, &owner, &name).await {
178 Ok(r) => r,
179 Err(resp) => return resp,
180 };
181 let gzipped = headers
182 .get(header::CONTENT_ENCODING)
183 .and_then(|v| v.to_str().ok())
184 .is_some_and(|v| v.contains("gzip"));
185 let input = if gzipped {
186 let mut out = Vec::new();
187 if flate2::read::GzDecoder::new(&body[..])
188 .read_to_end(&mut out)
189 .is_err()
190 {
191 return plain(StatusCode::BAD_REQUEST, "Invalid gzip body");
192 }
193 out
194 } else {
195 body.to_vec()
196 };
197
198 let path = state.config.repo_path(repo.id);
199 let mut cmd = Command::new(&state.config.git_bin);
200 cmd.args(["upload-pack", "--stateless-rpc"])
201 .arg(&path)
202 .stdin(Stdio::piped())
203 .stdout(Stdio::piped())
204 .stderr(Stdio::null())
205 .kill_on_drop(true);
206 if let Some(p) = protocol_v2(&headers) {
207 cmd.env("GIT_PROTOCOL", p);
208 }
209 let mut child = match cmd.spawn() {
210 Ok(c) => c,
211 Err(e) => {
212 tracing::error!("could not run git: {e}");
213 return plain(StatusCode::INTERNAL_SERVER_ERROR, "git is not available");
214 }
215 };
216 let mut stdin = child.stdin.take().expect("piped stdin");
217 let stdout = child.stdout.take().expect("piped stdout");
218 tokio::spawn(async move {
219 let _ = stdin.write_all(&input).await;
220 drop(stdin);
221 let _ = child.wait().await;
222 });
223 (
224 [
225 (header::CONTENT_TYPE, "application/x-git-upload-pack-result"),
226 (header::CACHE_CONTROL, "no-cache"),
227 ],
228 Body::from_stream(ReaderStream::new(stdout)),
229 )
230 .into_response()
231}
232
233pub async fn receive_pack() -> Response {
234 plain(
235 StatusCode::FORBIDDEN,
236 "Pushing over HTTP is not supported. Push over SSH instead.",
237 )
238}