crates/gunnar-http/src/service.rs
//! One HTTP request, answered.
//!
//! ```text
//! route → authenticate → Dispatch::authorise_service → GitTransport
//! (ACL sandbox) (token) (the ONE ACL call) (gunnar-wire)
//! ```
//!
//! # The status codes, and why each one is that one
//!
//! | | |
//! |---|---|
//! | **401** + `WWW-Authenticate: Basic` | nothing was presented and the request is not allowed anonymously. Also what a *wrong* credential gets. This is what makes `git` prompt. |
//! | **403** | credentials over cleartext, and the dumb protocol. Retrying will not help, and a 401 would invite the client to send the password again. |
//! | **404** | authenticated, and the repository either does not exist or is not theirs. **The two are one answer**, exactly as over SSH: a caller who could tell them apart could enumerate private repositories one guess at a time. |
//! | **503** | the identity table or the catalog could not be read. A fault, never reported as a refusal. |
//!
//! The 401-before-404 order is what keeps the indistinguishability honest: an
//! *unauthenticated* request gets 401 whether the repository is private,
//! public-but-anonymous-disabled, or absent, so the response says nothing about
//! which.
//!
//! # Errors after the first byte
//!
//! Once the response head is out, there is no status code left to send. A
//! transport failure mid-pack therefore ends the body early and is logged
//! server-side — which is what `git http-backend` does too, and what `git`
//! reports as an unexpected disconnect. Everything that *can* be decided before
//! the head is: routing, authentication and the grant check all happen first.
use std::sync::Arc;
use bytes::Bytes;
use http::{header, HeaderValue, Method, Request, Response, StatusCode};
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use gunnar_core::observe::{RequestId, ServerMetrics, TARGET_GIT};
use gunnar_core::AuthTable;
use gunnar_ssh::seam::{Delivery, GitTransport, ServiceRequest};
// **The admission gate is `gunnar-ssh`'s, reused — not a second one.**
// `Admission`/`AdmissionGuard`/`Refusal` are the same types the SSH front
// acquires a permit from, `Metered` is the same byte-meter that feeds the
// memory ledger, and `OpClass::for_ssh_service` classifies an HTTP transfer by
// exactly the wire service it carries. `gunnar-http` already depends on
// `gunnar-ssh` for the seam, so this door costs no new edge.
use gunnar_ssh::admission::Metered;
use gunnar_ssh::governor::OpClass;
use gunnar_ssh::{Admission, AdmissionGuard, Refusal};
use tracing::Instrument as _;
/// The header every response carries the request id in.
///
/// Lower-case and `x-`-prefixed: it is gunnar's own, not a registered one, and
/// a client that does not know it ignores it. `git` prints response headers
/// under `GIT_CURL_VERBOSE=1`, which is how an operator reads it off a failing
/// clone without a proxy in the middle.
pub const REQUEST_ID_HEADER: header::HeaderName =
header::HeaderName::from_static("x-gunnar-request-id");
use gunnar_ssh::server::{Dispatch, DispatchError};
use gunnar_ssh::GitService;
use crate::auth::{self, AuthError, AuthenticateError};
use crate::body::{self, HalfDuplex, NoBody, RequestBody, Streamed};
use crate::lfs;
use crate::route::{self, RouteError};
use crate::HttpConfig;
/// A request body, in whichever shape its endpoint needs.
///
/// The two JSON endpoints have theirs read and bounded before the grant check
/// (their content selects the grant); an object transfer keeps hyper's stream,
/// because an LFS object is a video and must never be collected.
enum Bodied {
/// Already read, bounded by [`lfs::MAX_BATCH_REQUEST_BYTES`].
Json(Vec<u8>),
/// Still on the socket.
Raw(Incoming),
}
/// The response body type: one box so a fixed message and a streamed packfile
/// are the same type.
pub type Body = BoxBody<Bytes, std::io::Error>;
/// Everything one listener needs to answer a request.
#[derive(Clone)]
pub struct GitHttp {
dispatch: Dispatch,
transport: Arc<dyn GitTransport>,
/// The LFS object store seam, or `None` on a listener that serves git over
/// HTTP and no LFS.
///
/// An `Option` rather than a default no-op implementation, because a no-op
/// would answer every batch `200 {"objects":[]}` and a clone would report
/// success with pointer files on disk — the exact failure this surface was
/// built to end. `None` answers `501`, which git-lfs reports.
lfs: Option<Arc<dyn gunnar_ssh::seam::LfsTransport>>,
config: HttpConfig,
metrics: Arc<ServerMetrics>,
/// **The one admission gate this listener routes every git transfer
/// through** (one implementation).
///
/// Before this field the smart-HTTP front had no ceiling at all: 64
/// concurrent clients got 64 slots and nothing was ever refused, so an
/// HTTP-fronted deployment had zero backpressure and no counter to see it
/// on. It is a `gunnar_ssh::Admission` and not a second design because the
/// two transports must count against **one** server-wide limit, **one**
/// per-principal share and **one** memory budget — the binary hands the SSH
/// listener's own `Arc` in with [`GitHttp::with_admission`] so it is
/// literally the same gate. A listener built without that call still gets a
/// safe default (the shipped SSH ceilings), never an unmetered one.
admission: Arc<Admission>,
}
impl GitHttp {
/// Serve `dispatch`'s repositories through `transport`.
pub fn new(dispatch: Dispatch, transport: Arc<dyn GitTransport>, config: HttpConfig) -> Self {
GitHttp {
dispatch,
transport,
lfs: None,
config,
metrics: Arc::new(ServerMetrics::new()),
// The shipped SSH defaults, so a bare `gunnar-http` listener is
// never the unmetered one the HTTP front used to be. The binary overrides this
// with the SSH listener's own gate (`with_admission`), which is the
// only way the two fronts share a limit rather than each holding
// half of it.
admission: Arc::new(Admission::with_memory_budget(
Some(gunnar_ssh::config::DEFAULT_MAX_CONCURRENT_TRANSFERS),
Some(gunnar_ssh::config::DEFAULT_MAX_TRANSFERS_PER_PRINCIPAL),
gunnar_ssh::governor::DEFAULT_MEMORY_BUDGET_BYTES,
)),
}
}
/// Route this listener's git transfers through an **existing** admission
/// gate — the SSH listener's own (one implementation).
///
/// This is the wiring that makes SSH and HTTP one server rather than two: a
/// transfer over either transport takes a slot from the same server-wide
/// limit, is held to the same per-principal share, and reserves against the
/// same memory budget. Without it a `--http-bind` appliance would serve up
/// to its whole configured concurrency **again**, on top of SSH, which is
/// the doubled ceiling nobody would have chosen.
pub fn with_admission(mut self, admission: Arc<Admission>) -> Self {
self.admission = admission;
self
}
/// What the admission gate has done since this listener started — the
/// visibility half of the shared gate, the mirror of
/// [`gunnar_ssh::BoundServer::admission`].
///
/// A ceiling whose saturation cannot be read is the same undiagnosable "the
/// server is slow" the SSH front carried before it had a ceiling; `admitted`,
/// `refused_*`, `in_flight` and `peak_in_flight` are what an operator, or a
/// test, reads instead. When the gate is shared with SSH this counts both
/// transports, which is the point.
pub fn admission(&self) -> gunnar_ssh::AdmissionStats {
self.admission.stats()
}
/// Serve Git LFS over HTTP out of `lfs`.
///
/// Without this the LFS URLs answer `501`. It is a separate call rather
/// than a fourth parameter of [`GitHttp::new`] so that `gunnar-http`'s own
/// tests can build a listener with no object store at all and assert what
/// that one says.
pub fn with_lfs(mut self, lfs: Arc<dyn gunnar_ssh::seam::LfsTransport>) -> Self {
self.lfs = Some(lfs);
self
}
/// Count into the **server's** counters rather than a private set.
///
/// The binary mints one [`ServerMetrics`] and hands the same `Arc` to the
/// SSH listener, the wire transport and here, so `connections_accepted` is
/// one reading of one server and not one per transport.
pub fn with_metrics(mut self, metrics: Arc<ServerMetrics>) -> Self {
self.metrics = metrics;
self
}
/// The identity table, for authentication.
fn auth(&self) -> &AuthTable {
self.dispatch.control().auth()
}
/// Answer one request, under a span that names it.
///
/// # Every line this request emits carries one id, and so does the answer
///
/// SSH has had `ssh.connection` with a `conn` since the connection span landed: a
/// [`RequestId`] on a span, `principal` and `repo` declared `Empty` and
/// recorded as they become known, so no call site repeats a field and
/// `conn=<id>` greps a whole transfer out of a log. HTTP had **nothing**.
/// Its lines named a principal here, a path there and a repository
/// somewhere else, the `git.upload_pack.served` line that `gunnar-cli`
/// emits for an HTTP transfer named none of them, and there was no way at
/// all to tie two of them together — let alone to tie any of them to the
/// `500` a client was looking at.
///
/// So this is the same scheme, not a second one (one implementation): the same
/// `RequestId`, the same `Empty`-then-`record` shape, the same targets.
///
/// **One span per REQUEST, not per connection.** HTTP/1.1 keep-alive
/// serves many requests down one socket — a `GET /info/refs` and the
/// `POST` that follows it, and often a refusal before either — and a
/// connection-scoped id would file all of them under one identifier,
/// which is the opposite of what an operator is trying to do.
///
/// The id also goes back on the wire, as `x-gunnar-request-id`, on **every
/// response including every refusal**. That is the half that makes it
/// worth anything: an operator holding a client's `403` can now name the
/// line in the server's log, which is precisely what the counters record said they
/// could not do.
pub async fn call(
&self,
request: Request<Incoming>,
peer: Option<std::net::SocketAddr>,
) -> Response<Body> {
let id = RequestId::next();
let span = tracing::info_span!(
target: TARGET_GIT,
"http.request",
req = %id,
method = %request.method(),
path = request.uri().path(),
peer = tracing::field::Empty,
principal = tracing::field::Empty,
repo = tracing::field::Empty,
);
if let Some(peer) = peer {
span.record("peer", tracing::field::display(peer));
}
let mut response = self
.serve_one(request, &span, id)
.instrument(span.clone())
.await;
// Stamped here, once, on the way out. Every arm below — every refusal,
// every stream — goes through this return, so no arm can forget it and
// a new one cannot be added without it.
response.headers_mut().insert(
REQUEST_ID_HEADER,
HeaderValue::from_str(&id.to_string()).expect("a request id is ascii hex"),
);
response
}
/// **Who is asking.** One authority, shared by the git and LFS surfaces,
/// so the two cannot disagree about a credential.
///
/// `Err` is the finished refusal, already counted (boxed: a response is
/// several times the size of the answer).
fn who(
&self,
request: &Request<Incoming>,
) -> Result<(auth::Credential, gunnar_core::Principal, Option<String>), Box<Response<Body>>>
{
let header_value = |name: &str| {
request
.headers()
.get(name)
.and_then(|value| value.to_str().ok())
};
let credential = auth::credential(header_value(header::AUTHORIZATION.as_str()))
.map_err(|err| Box::new(self.refuse_auth(err.into())))?;
let over_tls = auth::over_tls(
self.config.cleartext,
header_value(auth::FORWARDED_PROTO),
self.config.tls.is_some(),
);
let (principal, token) = auth::principal_for(
self.auth(),
&credential,
self.config.allow_anonymous,
over_tls,
)
.map_err(|err| Box::new(self.refuse_auth(err)))?;
Ok((credential, principal, token))
}
/// The request itself, with the span already entered.
async fn serve_one(
&self,
request: Request<Incoming>,
span: &tracing::Span,
id: RequestId,
) -> Response<Body> {
let method = request.method().clone();
let path = request.uri().path().to_owned();
let query = request.uri().query().map(str::to_owned);
// The liveness probe answers before anything else: no credential, no
// repository, nothing but "the HTTP listener is up".
if route::is_health(&method, &path) {
return status(StatusCode::OK, "ok");
}
let route = match route::resolve(&method, &path, query.as_deref()) {
Ok(route) => route,
Err(err) => return self.refuse_route(&method, &path, err),
};
// ── who ────────────────────────────────────────────────────────────
let (credential, principal, token) = match self.who(&request) {
Ok(who) => who,
Err(refusal) => return *refusal,
};
// Recorded on the span, not repeated at each call site: from here on
// every line this request emits carries the principal, including the
// ones inside `gunnar-cli`'s transport that know nothing about HTTP.
span.record("principal", tracing::field::display(&principal.id));
span.record("repo", tracing::field::display(route.repo.key()));
if principal.is_anonymous() {
self.metrics.add_connections_anonymous(1);
}
if let Some(hash) = &token {
// Best effort: a table that cannot be written must not stop a
// request that has already been authenticated against it.
//
// 🚨 THIS IS THE SERVING TIER'S CONCURRENCY CEILING. MEASURED on
// the reference desktop 2026-08-11, one `gunnar-server` pinned to a 4-CPU cpuset,
// `ls-refs` over smart HTTP (the request path with no packfile
// behind it), the same binary in both arms and the only difference
// whether this line runs:
//
// | concurrent | with the touch | without | server CPU used |
// |---|---|---|---|
// | 1 | 236 req/s | 378 req/s | 0.85 → 0.96 of 4 |
// | 16 | 573 req/s | 1416 req/s | 1.94 → 3.74 of 4 |
// | 64 | 819 req/s | 1484 req/s | 2.59 → 3.84 of 4 |
//
// The mechanism is not this call's own cost, it is what it
// contends on. `last_used` lives in ONE control document
// (`refs/gunnar/auth`) shared by every principal and every token,
// so [`gunnar_core::AuthTable::touch_token`] is a
// read-modify-compare-and-swap that every authenticated request in
// the process aims at the same pointer. At 16 concurrent requests
// the server's own `store.doc_txn` log recorded **2 370 lost
// compare-and-swaps across 1 600 requests** (1.48 retries each),
// each one re-serialising the whole document and writing a fresh
// blob, and **56 of those 1 600 exhausted `MAX_ATTEMPTS` and lost
// the update silently** — this is `let _ =`, so a dropped
// `last_used` says nothing to anybody. Commit latency was p50 2 ms,
// p99 19 ms, max 31 ms for a metadata touch.
//
// Everything else on the request path measured flat: per-request
// futex, `pread64` and `mprotect` counts do not grow from 1 to 16
// clients, the `Mutex<HandleCache>` costs nothing (16 clients on 16
// distinct repositories ran within 4% of 16 on one), and the pack
// path itself saturates 96% of the CPUs it is given. This line is
// the one place where CPU stops converting into throughput.
//
// It barely shows on a clone — 1.7 ms against ~120 ms of packfile,
// and the fetch arm A/B'd inside noise — but time-to-first-byte at
// 16 concurrent fetches went 1.4–2.1 ms → 9.7–56.6 ms, and a forge's
// real traffic is small requests, not clones.
//
// FIXED 2026-08-12 by granularity, not removal:
// `AuthTable::touch_token` now skips the CAS for a token whose
// `last_used` is fresher than `LAST_USED_GRANULARITY` (60 s), so
// the write leaves the steady-state request path — the measurement
// that forced it is `gunnar.multiuser_scaling`, where 102 users
// plateaued at 82 ops/s with 862 of ~1700 requests dropping the
// update in one window. `touch_key` — the SSH twin — still has no
// caller, so the two transports still do not agree about whether a
// credential's use is recorded.
//
// A failed touch is best-effort but no longer silent: the request
// has authenticated either way, and the debug line is what ties a
// `store.doc_txn` give-up in the log to this caller.
if let Err(err) = self.auth().touch_token(hash, gunnar_core::now_ms()) {
tracing::debug!(%err, "last_used was not recorded for this request");
}
}
// The LFS surface leaves here: it is the same authentication and, a few
// lines further in, the *same* `authorise_service` call, but the
// service it asks about cannot be known until the request body has been
// read, so it cannot share the next paragraph.
if let route::Kind::Lfs(endpoint) = route.kind.clone() {
return self
.serve_lfs(request, &route, endpoint, &principal, &credential, id)
.await;
}
let (service, advertisement) = match route.kind {
route::Kind::Advertisement(service) => (service, true),
route::Kind::Rpc(service) => (service, false),
// Handled above and returned; matched here rather than with a
// wildcard so a new `Kind` is a compile error and not a 500.
route::Kind::Lfs(_) => unreachable!("the lfs surface returned above"),
};
// ── may they ───────────────────────────────────────────────────────
//
// `authorise_service` is `gunnar-ssh`'s decision, entered from a URL
// instead of an exec line. There is no second ACL here and there must
// never be one.
let service_request =
match self
.dispatch
.authorise_service(&principal, None, service, route.repo.clone())
{
Ok(request) => request,
Err(err) => return self.refuse_dispatch(&principal, &credential, err),
};
self.metrics.add_connections_accepted(1);
let git_protocol = request
.headers()
.get("git-protocol")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let service_request = service_request
// **Who this push will be attributed to** (GUN-014). `None` is
// passed to `authorise_service` above for the key fingerprint and
// is correct — HTTP authenticates with a token and there is no key
// — but it left `RefUpdated`, `RefDeleted` and the attestation
// with an empty fingerprint and no other field naming the
// credential, while the code that reads it states an empty value
// can only mean the anonymous read path. A PREFIX of the token's
// hash, never the token and never its whole hash: enough to join
// two pushes to one credential and to `Access.ListKeys`, and not
// enough to be a credential itself if the log leaks.
.with_credential(token.as_ref().map(|hash| {
let hash = hash.as_str();
format!("token:{}", &hash[..12.min(hash.len())])
}))
.with_git_protocol(git_protocol)
.with_delivery(if advertisement {
Delivery::Advertisement
} else {
Delivery::StatelessRpc
});
// ── the explicit ceiling, on the HTTP front door ───────────────────
//
// After authorisation, deliberately and for the same reason the SSH
// path gates after it (`gunnar_ssh::server`): "the server is
// busy" is a fact about the server, and telling it to a caller who may
// not touch this repository would leak that the repository exists. The
// order also means the gate meters transfers gunnar was going to serve.
//
// This is the SAME gate the SSH transfer takes a permit from — the same
// server-wide limit, the same per-principal share, the same memory
// budget — so a git transfer over HTTP counts against SSH's ceilings
// and not a second set (one implementation). It **refuses** over the limit rather
// than queueing; the response is a real `429 Too Many Requests`, which
// a git client and a proxy both read as transient, so the caller is
// told to come back instead of watching a socket go quiet.
//
// Each smart-HTTP request is its own transfer and takes its own slot:
// the advertisement (`GET info/refs`, which is also `ls-remote`) and
// the `POST` rpc are separate requests, and both do real work behind
// the seam, so both are metered — exactly as the single SSH
// `git-upload-pack` exec that carries both is.
let class = OpClass::for_ssh_service(service);
let guard =
match self
.admission
.try_admit(&principal.id, &service_request.repo.namespace, class)
{
Ok(guard) => guard,
Err(refusal) => return self.refuse_admission(&principal, service, refusal),
};
if advertisement {
self.advertise(service_request, guard).await
} else {
self.rpc(request, service_request, guard).await
}
}
/// The finished `429` for a transfer the admission gate would not admit,
/// with the same counters on the log line the SSH refusal files.
///
/// `429 Too Many Requests`, not `503`: on this listener `503` already means
/// "a table could not be read" — a fault, never a refusal — and a
/// saturation shed is the opposite, a healthy server saying *not right now*.
/// `Retry-After` says so in the client's own vocabulary. The body is the
/// gate's own `client_message`, so a human running `git clone` with
/// `GIT_CURL_VERBOSE=1` reads the same sentence the log recorded.
fn refuse_admission(
&self,
principal: &gunnar_core::Principal,
service: GitService,
refusal: Refusal,
) -> Response<Body> {
// No separate `ServerMetrics` bump: the refusal counters live in the
// admission gate itself (`AdmissionStats::refused_*`), which is exactly
// where the SSH path reads them from too — one meter for one gate, not
// a second tally that has to be kept in step with the first.
let stats = self.admission.stats();
tracing::warn!(
target: TARGET_GIT,
event = "http.transfer.refused",
// Which gate, not just "a gate": the anonymous ceiling fires
// with the box nearly empty, and a line that still said
// `admission-gate-full` there would send an operator to raise a
// limit that was never reached. See `gunnar_ssh::Refusal::reason`.
reason = refusal.reason(),
%service,
principal = %principal.id,
%refusal,
in_flight = stats.in_flight,
peak_in_flight = stats.peak_in_flight,
// The GOVERNED limit in force at the refusal, not the configured
// one — the same number the SSH refusal reports, for the same
// reason: an operator who read one number from a config file and a
// different one from a log would conclude the gate was broken.
limit = stats.limit,
reserved_bytes = stats.reserved_bytes,
refused_total = stats.refused(),
"smart-HTTP transfer REFUSED by the admission gate"
);
// A PAUSE is not load: 503 with a Retry-After in minutes, so a proxy
// or a CI runner backs off for the growth's whole window instead of
// hammering a 429 every second. Everything else is the gate being
// full, which is the 429 it always was.
let (code, retry_after) = match refusal {
Refusal::Paused { .. } => (StatusCode::SERVICE_UNAVAILABLE, "120"),
_ => (StatusCode::TOO_MANY_REQUESTS, "1"),
};
let mut response = status(code, &refusal.client_message());
response
.headers_mut()
.insert(header::RETRY_AFTER, HeaderValue::from_static(retry_after));
response
}
/// **Git LFS over HTTP**, from the same principal the git URLs produced.
///
/// ```text
/// route → authenticate → read the body → Dispatch::authorise_service → the store
/// ACL token (batch only) the SAME ACL call gunnar-lfs
/// ```
///
/// The body is read **before** the grant check and not after, and that is
/// forced rather than chosen: a batch's direction lives in its JSON
/// (`"operation"`), and the direction is what decides whether the caller
/// needs Read or Write. Nothing is read from a *store* before the check,
/// which is the property that matters; what a bad body can cost an
/// unauthenticated caller is one bounded read and a `400`, and the answer
/// is the same whether or not the repository exists.
///
/// Refusals here are the LFS batch API's error document rather than
/// `text/plain`, because that is what a client parses — but the *decision*
/// and the *sentence* are `gunnar-ssh`'s, unchanged. `401` carries
/// `LFS-Authenticate`, which is the header the specification names, beside
/// the `WWW-Authenticate` git's own credential helper looks for.
async fn serve_lfs(
&self,
request: Request<Incoming>,
route: &route::Route,
endpoint: route::Lfs,
principal: &gunnar_core::Principal,
credential: &auth::Credential,
id: RequestId,
) -> Response<Body> {
// A server built without a `LfsTransport` says so, rather than 404ing
// as though the URL were wrong. git-lfs turns 501 into "this endpoint
// does not do LFS" and stops, which is the true statement.
let Some(transport) = self.lfs.as_ref() else {
tracing::debug!(
target: TARGET_GIT,
event = "http.lfs.unserved",
repo = %route.repo.key(),
"an lfs request arrived at a listener with no lfs backend wired"
);
return lfs::error_with_id(
StatusCode::NOT_IMPLEMENTED,
"this gunnar serves git over HTTP but has no LFS object store attached \
to its HTTP listener"
.to_owned(),
id,
);
};
let method = request.method().clone();
let headers = request.headers().clone();
let json_endpoint = matches!(endpoint, route::Lfs::Batch | route::Lfs::Verify);
if json_endpoint && !lfs::accepts_lfs_json(&headers) {
return lfs::error_with_id(
StatusCode::NOT_ACCEPTABLE,
format!("this endpoint answers `{}` only", lfs::LFS_CONTENT_TYPE),
id,
);
}
// Read the JSON body, if this endpoint has one. Bounded on the way in.
let (parts, incoming) = request.into_parts();
let body = if json_endpoint {
match lfs::read_bounded(incoming, lfs::MAX_BATCH_REQUEST_BYTES).await {
Ok(bytes) => Bodied::Json(bytes),
Err(message) => return lfs::error_with_id(StatusCode::BAD_REQUEST, message, id),
}
} else {
Bodied::Raw(incoming)
};
// The direction, and therefore the grant. `service_for` is the one
// place it is decided; see its own test for `PUT` never being read.
let batch = match &body {
Bodied::Json(bytes) if matches!(endpoint, route::Lfs::Batch) => {
match lfs::parse_batch(bytes) {
Ok(parsed) => Some(parsed),
Err(message) => {
return lfs::error_with_id(StatusCode::BAD_REQUEST, message, id)
}
}
}
_ => None,
};
let service = lfs::service_for(
&endpoint,
&method,
batch.as_ref().and_then(lfs::operation_of),
);
// ── may they ───────────────────────────────────────────────────────
//
// The same call, with the same arguments, that the two git verbs make
// three paragraphs up. `GitService::LfsTransfer(operation)` is the same
// enum value `gunnar_ssh::command::parse` produces for
// `git-lfs-transfer <path> <operation>`, so the directional grant SSH
// enforces is enforced here by the same table row.
let service_request =
match self
.dispatch
.authorise_service(principal, None, service, route.repo.clone())
{
Ok(request) => request,
Err(err) => return self.refuse_lfs_dispatch(principal, credential, err, id),
};
self.metrics.add_connections_accepted(1);
let context = lfs::Context {
transport,
request: service_request,
endpoint: lfs::endpoint_for(
self.config.external_base.as_deref(),
self.config.tls.is_some(),
parts.uri.authority().map(|a| a.as_str()),
&headers,
&route.base,
),
authorization: headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::to_owned),
stream_window: self.config.stream_window,
id,
headers,
};
match (endpoint, body) {
(route::Lfs::Batch, _) => {
lfs::batch(context, batch.expect("a batch route parsed its body")).await
}
(route::Lfs::Verify, Bodied::Json(bytes)) => match lfs::parse_verify(&bytes) {
Ok(parsed) => lfs::verify(context, parsed).await,
Err(message) => lfs::error(&context, StatusCode::BAD_REQUEST, message),
},
(route::Lfs::Object(oid), Bodied::Raw(incoming)) => {
if method == Method::PUT {
lfs::upload(context, &oid, incoming).await
} else {
lfs::download(context, &oid).await
}
}
// `read_bounded` runs exactly for the two JSON endpoints and the
// raw body survives for the object one, so these cannot pair up
// differently. Matched rather than wildcarded so a future endpoint
// has to say which it is.
(route::Lfs::Verify, Bodied::Raw(_)) | (route::Lfs::Object(_), Bodied::Json(_)) => {
let _ = parts;
lfs::error(
&context,
StatusCode::INTERNAL_SERVER_ERROR,
"the request body was read in the wrong shape for this endpoint".to_owned(),
)
}
}
}
/// `GET /<path>/info/refs?service=…`.
///
/// The `# service=` preamble belongs to **v0 only**. Protocol v2 answers
/// this request with the capability advertisement directly, with no
/// preamble at all — captured from `git http-backend` 2.53.0 on 2026-08-03,
/// where the v0 response opens `001e# service=git-upload-pack` + flush and
/// the v2 response opens `000eversion 2`.
///
/// **`git` tolerates the preamble on a v2 response**, measured by sending
/// one deliberately: `remote-curl.c`'s `discover_refs` consumes a leading
/// `# service=` line whatever version follows it, and the clone succeeds.
/// So this is not a bug being avoided; it is thirty bytes of divergence
/// from the reference server that some *other* client would be entitled to
/// disagree about. It is asserted on the bytes in
/// `gunnar-cli/tests/http_transport.rs`, because no `git clone` test could
/// ever fail on it.
async fn advertise(&self, request: ServiceRequest, guard: AdmissionGuard) -> Response<Body> {
let verb = request.service.verb();
let content_type = format!("application/x-{verb}-advertisement");
let version =
gunnar_wire::ProtocolVersion::from_git_protocol(request.git_protocol.as_deref());
let (body, mut writer) = Streamed::pair(self.config.stream_window);
if version == gunnar_wire::ProtocolVersion::V0 {
use tokio::io::AsyncWriteExt as _;
let mut preamble = Vec::new();
gunnar_pktline::write_text(&mut preamble, format!("# service={verb}").as_bytes())
.expect("a Vec does not fail to write");
gunnar_pktline::write_flush(&mut preamble).expect("a Vec does not fail to write");
if writer.write_all(&preamble).await.is_err() {
return status(StatusCode::SERVICE_UNAVAILABLE, "the client went away");
}
}
self.spawn_transport(request, HalfDuplex::new(NoBody, writer), guard);
self.streaming(content_type, body)
}
/// `POST /<path>/git-upload-pack` or `…/git-receive-pack`.
async fn rpc(
&self,
http: Request<Incoming>,
request: ServiceRequest,
guard: AdmissionGuard,
) -> Response<Body> {
let verb = request.service.verb();
let content_type = format!("application/x-{verb}-result");
let gzipped = http
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.eq_ignore_ascii_case("gzip"));
let (_, incoming) = http.into_parts();
let read = if gzipped {
// **Bounded on the way IN** (GUN-005). This was
// `incoming.collect()`, which accumulates the whole body with no
// ceiling, and the comment here claimed a bound that belonged to
// the next step: `gunzip`'s limit is on the INFLATED size, so the
// step that allocated had none. `Content-Encoding: gzip` plus
// `Transfer-Encoding: chunked` plus arbitrarily many bytes — which
// need not be valid gzip, since nothing inflates them until the
// read has finished — was remote memory exhaustion at 1:1
// bandwidth cost, anonymous on a public repository, against a
// process meant to run as PID 1.
//
// In memory is still right, and only ever on this path: `git`
// gzips a request body it has already buffered and never a
// packfile. `read_bounded` is the reader the LFS
// endpoints already use (one implementation), and `gunzip`'s
// output ceiling stays as the second line.
let collected =
match lfs::read_bounded(incoming, crate::body::MAX_GZIPPED_REQUEST_INPUT_BYTES)
.await
{
Ok(collected) => collected,
Err(why) => return status(StatusCode::PAYLOAD_TOO_LARGE, &why),
};
match body::gunzip(&collected) {
Ok(inflated) => RequestBody::buffered(inflated),
Err(err) => {
return status(
StatusCode::BAD_REQUEST,
&format!("the gzipped request body could not be read: {err}"),
)
}
}
} else {
RequestBody::streaming(incoming)
};
let (body, writer) = Streamed::pair(self.config.stream_window);
self.spawn_transport(request, HalfDuplex::new(read, writer), guard);
self.streaming(content_type, body)
}
/// Hand the stream to the seam and let it run.
///
/// Spawned rather than awaited, for the same reason `gunnar-ssh` spawns it:
/// the response head has to go out before the transport has finished, or
/// nothing is streaming.
///
/// **`.instrument(…)` is not decoration.** `tokio::spawn` starts a task
/// with no span at all — the current span is thread-local and does not
/// cross the spawn — so without this the ENTIRE transfer, every line
/// `gunnar-wire` and `gunnar-cli`'s transport emit, would fall outside
/// `http.request` and carry no `req` at all. That is the same trap
/// `gunnar-cli`'s `WireTransport::serve` documents for `spawn_blocking`,
/// and the transfer is the part an operator most wants correlated.
fn spawn_transport<S: gunnar_ssh::seam::DuplexStream + 'static>(
&self,
request: ServiceRequest,
stream: S,
guard: AdmissionGuard,
) {
let transport = Arc::clone(&self.transport);
let service = request.service;
let repo = request.repo.key();
let principal = request.principal.id.clone();
let span = tracing::Span::current();
// ── the admission permit is held for the WHOLE transfer ─────────────
//
// The guard was taken in `serve_one`, before the response head went
// out, and it releases its slot, its per-principal count and its memory
// reservation on drop — so it must live exactly as long as the transfer
// does, which is this spawned task. `Metered` wraps the same duplex the
// seam was already going to be handed (no second copy of the bytes) and
// charges the memory ledger as the pack moves, exactly as the SSH path
// does; `entered_service` files the one sojourn sample the governor's
// controller reads, so an HTTP transfer is not `unmeasured_sojourn`.
let admitted = Arc::new(guard);
let stream = Metered::new(stream, Arc::clone(&admitted));
// ── the SAME standing fans the SSH path uses ────────────────────────
//
// `gunnar-http` holds its own `Arc<dyn GitTransport>` and calls
// `serve()` itself, so wiring only `gunnar-ssh`'s `Connection::exec`
// left every HTTP transfer on tokio's blocking pool — which is most of
// the `population_*` and `multiuser_scaling` load, i.e. exactly the
// arms a concurrency change is judged by. `pipeline::fans()` is process
// -wide precisely so both transports queue into one place and one limit.
let class = OpClass::for_ssh_service(service);
let handle = tokio::runtime::Handle::current();
let body = async move {
// The FIRST line, as on the SSH path: everything between the permit
// and here was the transfer WAITING (the fan handoff), and this is
// what makes that wait a governed sojourn sample instead of an
// invisible queue.
admitted.entered_service();
if let Err(err) = transport.serve(request, Box::new(stream)).await {
// After the head is out there is no status left to send, so
// this is the only place the failure is visible. It must not be
// silent (measured, not asserted): a truncated pack and a completed one look the
// same from the server's side otherwise.
tracing::warn!(
target: TARGET_GIT,
event = "http.transfer.failed",
%service,
%repo,
%principal,
%err,
"a smart-HTTP transfer ended early"
);
}
}
.instrument(span);
if let Err(refused) =
gunnar_ssh::pipeline::fans().submit(class, Box::new(move || handle.block_on(body)))
{
// The head is already out on this path, so there is no status left
// to send — the same asymmetry the failure arm above documents.
// Saying it in the log is the only honest option left.
tracing::warn!(
target: TARGET_GIT,
event = "http.transfer.refused",
class = ?class,
%refused,
"the standing fan for this class refused the transfer"
);
}
}
fn streaming(&self, content_type: String, body: Streamed) -> Response<Body> {
let mut response = Response::new(BodyExt::boxed(body));
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&content_type).expect("a git content type is ascii"),
);
// git's own http-backend sends all three, and they matter: a caching
// proxy that served a stale advertisement would hand a client object
// ids the server no longer has.
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, max-age=0, must-revalidate"),
);
headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
headers.insert(
header::EXPIRES,
HeaderValue::from_static("Fri, 01 Jan 1980 00:00:00 GMT"),
);
response
}
// ── refusals ───────────────────────────────────────────────────────────
fn refuse_route(&self, method: &Method, path: &str, err: RouteError) -> Response<Body> {
tracing::debug!(
target: TARGET_GIT,
event = "http.route.refused",
%method,
path,
%err,
"not a smart-HTTP git request"
);
let code = match err {
RouteError::Dumb => StatusCode::FORBIDDEN,
_ => StatusCode::NOT_FOUND,
};
status(code, &err.to_string())
}
fn refuse_auth(&self, err: AuthenticateError) -> Response<Body> {
self.metrics.add_connections_refused(1);
match err {
AuthenticateError::Store(err) => {
tracing::error!(
target: TARGET_GIT,
event = "http.auth.fault",
error = format!("{err:#}"),
"the identity table could not be read"
);
status(
StatusCode::SERVICE_UNAVAILABLE,
"the gunnar control plane is unavailable; try again",
)
}
AuthenticateError::Auth(auth @ AuthError::Cleartext) => {
tracing::warn!(
target: TARGET_GIT,
event = "http.auth.cleartext",
"a credential was presented over a plain connection and refused"
);
status(StatusCode::FORBIDDEN, &auth.to_string())
}
AuthenticateError::Auth(auth) => {
// `warn`, not `debug`, and the same level the SSH door uses for
// the same event (`ssh.auth.refused`,
// `gunnar-ssh/src/server.rs:702`).
//
// At `debug` this was the one refusal in the whole server that
// a production log could not see: `http.auth.fault` is `error`,
// `http.auth.cleartext` is `warn`, and an ordinary bad token —
// the only one an attacker produces, thousands of times, while
// guessing — was invisible at the shipped default. The counter
// moved (`add_connections_refused` above) but the heartbeat that
// prints it runs once a minute and names no principal, no path
// and no peer, so nothing said *which* door was being tried.
tracing::warn!(
target: TARGET_GIT,
event = "http.auth.refused",
reason = %auth,
"authentication failed"
);
unauthorized(&auth.to_string())
}
}
}
/// The ACL said no, or the catalog could not answer.
///
/// **An unauthenticated caller gets 401, not 404**, whatever the reason,
/// and that is the whole of the indistinguishability property: a stranger
/// who probes `/team/does-not-exist.git` and `/team/private.git` gets the
/// identical response, so the server has told them nothing. Once a
/// credential *has* been accepted, 404 is safe and more useful, because the
/// caller already knows they are somebody.
fn refuse_dispatch(
&self,
principal: &gunnar_core::Principal,
credential: &auth::Credential,
err: DispatchError,
) -> Response<Body> {
self.metrics.add_connections_refused(1);
if let DispatchError::Store(store) = &err {
tracing::error!(
target: TARGET_GIT,
event = "http.dispatch.fault",
error = format!("{store:#}"),
"the control plane could not answer"
);
return status(
StatusCode::SERVICE_UNAVAILABLE,
"the gunnar control plane is unavailable; try again",
);
}
tracing::info!(
target: TARGET_GIT,
event = "http.dispatch.denied",
principal = %principal.id,
reason = %err,
"refused"
);
// `client_message` is `gunnar-ssh`'s wording, reused rather than
// rewritten: SSH and HTTP say the same sentence for the same refusal.
let message = err
.client_message()
.unwrap_or_else(|| "repository not found, or you do not have access".to_owned());
if matches!(credential, auth::Credential::None) {
unauthorized(&message)
} else {
status(StatusCode::NOT_FOUND, &message)
}
}
/// The ACL said no to an LFS request.
///
/// The same decision and the same sentence as [`GitHttp::refuse_dispatch`]
/// — including the 401-before-404 rule that keeps a private repository
/// indistinguishable from an absent one — rendered as the batch API's error
/// document, because that is what a client on this surface parses.
fn refuse_lfs_dispatch(
&self,
principal: &gunnar_core::Principal,
credential: &auth::Credential,
err: DispatchError,
id: RequestId,
) -> Response<Body> {
self.metrics.add_connections_refused(1);
if let DispatchError::Store(store) = &err {
tracing::error!(
target: TARGET_GIT,
event = "http.lfs.dispatch.fault",
error = format!("{store:#}"),
"the control plane could not answer"
);
return lfs::error_with_id(
StatusCode::SERVICE_UNAVAILABLE,
"the gunnar control plane is unavailable; try again".to_owned(),
id,
);
}
tracing::info!(
target: TARGET_GIT,
event = "http.lfs.dispatch.denied",
principal = %principal.id,
reason = %err,
"refused"
);
let message = err
.client_message()
.unwrap_or_else(|| "repository not found, or you do not have access".to_owned());
if matches!(credential, auth::Credential::None) {
let mut response = lfs::error_with_id(StatusCode::UNAUTHORIZED, message, id);
let headers = response.headers_mut();
// `LFS-Authenticate` is the header the batch API names for a 401.
// `WWW-Authenticate` is what git's credential helper looks for, and
// without it `git lfs` reports the refusal instead of prompting.
// Both, because they are read by different code in the same client.
headers.insert(
header::HeaderName::from_static("lfs-authenticate"),
HeaderValue::from_static("Basic realm=\"gunnar\""),
);
headers.insert(
header::WWW_AUTHENTICATE,
HeaderValue::from_static("Basic realm=\"gunnar\""),
);
response
} else {
lfs::error_with_id(StatusCode::NOT_FOUND, message, id)
}
}
}
/// A 401 that makes `git` ask for a password.
///
/// Without the `WWW-Authenticate` header a client has no way to know that
/// authenticating is what would help, and `git clone` reports the refusal
/// instead of prompting.
fn unauthorized(message: &str) -> Response<Body> {
let mut response = status(StatusCode::UNAUTHORIZED, message);
response.headers_mut().insert(
header::WWW_AUTHENTICATE,
HeaderValue::from_static("Basic realm=\"gunnar\""),
);
response
}
/// A plain-text response.
fn status(code: StatusCode, message: &str) -> Response<Body> {
let body = Full::new(Bytes::from(format!("{message}\n")))
.map_err(|never| match never {})
.boxed();
let mut response = Response::new(body);
*response.status_mut() = code;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}
/// The two verbs, for a caller that wants to name one.
pub const SERVED: &[GitService] = &[GitService::UploadPack, GitService::ReceivePack];