crates/gunnar-http/src/lib.rs
//! **Smart HTTP: gunnar's second git transport, and no second protocol.**
//!
//! `git clone http://host/team/project.git` carries the *same* pkt-line
//! conversation `gunnar-ssh` carries. This crate is the wrapper that puts it in
//! HTTP requests. It contains no framing, no `ls-refs`, no negotiation and no
//! pack writer, and if it ever grows one of those, something has gone wrong:
//! those live in `gunnar-pktline` and `gunnar-wire`, which were written
//! transport-agnostic on purpose (one implementation).
//!
//! ```text
//! hyper ── route ── authenticate ── Dispatch::authorise_service ── GitTransport
//! ACL token the SAME ACL call SSH makes gunnar-wire
//! ```
//!
//! # What smart HTTP actually is
//!
//! Four URLs, measured against `git http-backend` 2.53.0 on 2026-08-03 rather
//! than read off a specification:
//!
//! | | |
//! |---|---|
//! | `GET /<path>/info/refs?service=git-upload-pack` | the advertisement. `Content-Type: application/x-git-upload-pack-advertisement`. **v0** opens `001e# service=git-upload-pack` then a flush then the ordinary advertisement; **v2 sends no such preamble at all** and opens `000eversion 2`. |
//! | `POST /<path>/git-upload-pack` | `application/x-git-upload-pack-request` in, `…-result` out. |
//! | `GET /<path>/info/refs?service=git-receive-pack` | the same, for push. |
//! | `POST /<path>/git-receive-pack` | the command list, options and packfile in one request. |
//!
//! Protocol v2 rides the same `POST`s and announces itself with a
//! `Git-Protocol: version=2` **header**, whose value is the same string SSH
//! sends as an environment variable and is parsed by the same function,
//! [`gunnar_wire::ProtocolVersion::from_git_protocol`].
//!
//! # Stateless-rpc, which is the whole difficulty
//!
//! Over SSH the negotiation state lives in the stack frame serving the
//! connection. Over HTTP each `POST` is independent, so it cannot: the client
//! re-sends its whole request every round, and the server answers one round and
//! stops. That is `gunnar_wire::Session::StatelessRpc`, and getting it wrong
//! does not produce an error — it produces a clone that hangs, because the
//! server is reading a body that has already ended while the client waits for a
//! response it is holding.
//!
//! ## `0002`, and who actually writes it
//!
//! `gunnar-pktline` has `Packet::ResponseEnd` (`0002`), documented as existing
//! for v2 stateless-rpc. **Measured 2026-08-03, a git server never sends it.**
//! Every response captured from `git http-backend` — v0 and v2, advertisement
//! and rpc — ends with a flush-pkt and nothing after it. `0002` is written by
//! the *client's* own `remote-curl` helper, which proxies protocol v2 for a
//! `fetch` running as a child process and has to mark where each HTTP response
//! ended in the single pipe it feeds that child. It is a marker inside git's
//! client, not a thing on the wire.
//!
//! So this crate does not emit it, and `gunnar-wire` rejects one arriving in a
//! request (`"a response-end-pkt in a v2 request"`). The decoder keeps handling
//! it because gunnar's own client half will need it the day it speaks HTTP.
//!
//! # Authentication and TLS
//!
//! SSH gives gunnar the key table for free; HTTP has nothing. See [`auth`] for
//! the mechanism. In one line: an access token names a principal, gunnar
//! terminates no TLS, and **every authorisation question is still
//! `AuthTable::may`** on that principal's grants. There is no HTTP ACL.
//!
//! Anonymous read obeys the **same `allow_anonymous` switch** as SSH, and
//! the same rule 0: `Read`, on a `Public` repository, nothing anywhere else.
//! The binary owns one flag and hands it to both listeners; two flags is how
//! one of them ends up stubbed.
//!
//! # Dependencies
//!
//! hyper 1 and `hyper-util`, **not** a framework, and both already in the
//! resolved appliance graph because tonic pulls them in for gRPC (which is
//! HTTP/2). Measured 2026-08-03: adding this transport moves the appliance's
//! crate count by **zero**. `axum` is in that graph too, under tonic's router,
//! and is deliberately not used here: `xtask`'s guard records it as a tracked
//! deviation on the grounds that *gunnar registers no HTTP route of its own*,
//! and this crate keeps that sentence true. git's URLs are a suffix match over
//! an arbitrary-depth prefix, which a router expresses no better than
//! [`route::resolve`] does.
#![deny(missing_docs)]
#![forbid(unsafe_code)]
#![doc(
html_logo_url = "https://gunnar.rs/download/gunnar.webp",
html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]
pub mod auth;
pub mod body;
pub mod lfs;
pub mod route;
pub mod service;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use gunnar_core::observe::{ServerMetrics, TARGET_GIT};
use gunnar_ssh::seam::GitTransport;
use gunnar_ssh::server::Dispatch;
pub use auth::{Cleartext, Credential};
pub use route::{resolve as resolve_route, Kind, Lfs, Route, RouteError};
pub use service::GitHttp;
/// How the HTTP listener behaves.
#[derive(Debug, Clone)]
pub struct HttpConfig {
/// Where to listen.
pub listen: SocketAddr,
/// Whether a keyless caller may read a `Public` repository.
///
/// **The same value `SshConfig::allow_anonymous` is given.** It is not a
/// separate policy and must never become one: an operator who turned
/// anonymous access off would otherwise have turned it off on one
/// transport.
pub allow_anonymous: bool,
/// Whether gunnar will read a credential off a plain connection.
pub cleartext: Cleartext,
/// What a client should be told to come back to for LFS objects, when the
/// request's own `Host` is not that.
///
/// `None` — the default — derives it from `X-Forwarded-Proto`,
/// `X-Forwarded-Host` and `Host`, which is right for a direct listener and
/// for every proxy that sets the two standard headers. An operator whose
/// proxy sets neither, or who publishes gunnar under a path prefix, states
/// the scheme-and-host half here: `https://git.example`.
///
/// It affects **only** the `href`s in an LFS batch response. gunnar's git
/// URLs are all relative to the request, so nothing else needs to know the
/// server's outside name — which is why this is not a general setting and
/// is documented on the field that uses it.
pub external_base: Option<String>,
/// The in-flight window between the pack writer and the socket, in bytes.
///
/// This is the *only* buffering on the response path: neither a clone's
/// packfile nor a push's is ever held whole. A few network buffers is the
/// right size; larger buys nothing because the pack writer is not the
/// bottleneck, and smaller costs wakeups.
pub stream_window: usize,
/// **How long a connection may hold a socket without becoming a request.**
///
/// Two clocks, because there are two ways to hold one and they fail
/// separately. A TLS connection that never finishes its handshake is
/// awaited by `acceptor.accept(socket)`, which has no timeout of its own;
/// an accepted connection that sends a partial request line, or nothing,
/// sits inside hyper's read. Neither ever ended: the listener had no clock
/// anywhere, so `n` open sockets cost `n` tasks for as long as a peer cared
/// to keep them, and closing them needed a restart.
///
/// Thirty seconds is generous for both — a handshake is one round trip and
/// a request line follows it immediately — and it bounds nothing a real
/// client does: this is the time to the FIRST byte of a request, not the
/// time to serve one. A clone that takes an hour is untouched.
///
/// `None` turns both off, for an operator who has a proxy doing it.
pub header_timeout: Option<Duration>,
/// **How many connections this listener will hold at once.**
///
/// The bound the accept loop did not have. `accept()` in a loop with a
/// `tokio::spawn` per connection is unbounded by construction: a peer that
/// opens sockets and does nothing costs a task and a file descriptor each,
/// and the first thing to break is not this listener but `accept` itself,
/// server-wide, at the process's descriptor limit — which is the failure
/// `tests/accept_emfile.rs` exists over on the SSH side.
///
/// This is deliberately NOT the `Admission` gate the two fronts share. That
/// one bounds git TRANSFERS and is entered once a request is understood; a
/// connection here has not sent a byte yet, and a ceiling that only applies
/// after parsing is no ceiling against a peer that never parses.
///
/// Over the limit the socket is closed rather than queued. There is nothing
/// truthful to answer with: no request has been read, so a `503` would be a
/// reply to something nobody said, and queueing is the unbounded growth
/// this exists to stop wearing a different shape.
///
/// `None` turns it off.
pub max_connections: Option<usize>,
/// Terminate TLS on this listener itself — the **single-executable**
/// deployment shape.
///
/// `None` (the default) is the proxy-terminated shape this listener has
/// always had: HTTP/1.1 cleartext, a TLS terminator in front, and the
/// `X-Forwarded-Proto` contract. `Some` makes gunnar the terminator:
/// rustls over the accepted socket, ALPN offering **h2 then http/1.1** —
/// the same arrangement holger's gateway runs — and every request on the
/// listener counts as TLS for the credential gate with no header trusted.
/// HTTP/2 is only reachable through here on purpose: a cleartext listener
/// has no ALPN, and h2c prior-knowledge is a thing no git client sends.
pub tls: Option<TlsIdentity>,
}
/// A server identity for the native-TLS listener: leaf-first PEM chain and its
/// private key, in memory — the caller decides whether they came from operator
/// files or from gunnar's own issuer (`Pki::issue_server_identity`).
#[derive(Clone)]
pub struct TlsIdentity {
/// Certificate chain, leaf first, PEM. Include the issuer certificate so a
/// client that pins the issuer can build the chain.
pub cert_chain_pem: String,
/// The leaf's private key, PEM (PKCS#8 or SEC1/RSA).
pub key_pem: String,
}
impl std::fmt::Debug for TlsIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// The key is a secret; a Debug that printed it would put it in any log
// line that ever formats the config.
f.debug_struct("TlsIdentity")
.field(
"cert_chain_pem",
&format!("{} bytes", self.cert_chain_pem.len()),
)
.field("key_pem", &"<redacted>")
.finish()
}
}
impl TlsIdentity {
/// Build the rustls server config: this identity, no client auth, ALPN
/// preferring h2.
fn acceptor(&self) -> std::io::Result<tokio_rustls::TlsAcceptor> {
use std::io::{Error, ErrorKind};
// **`rustls-pki-types`, not `rustls-pemfile`.** The two parse the same
// bytes into the same types; the difference is that rustls already
// depends on the first and nothing depended on the second. The smart-HTTP decision says
// the smart-HTTP feature rides the crates gRPC already pulls in, and
// `rustls-pemfile` was the one package `--features http` still added to
// the appliance image (309 → 310 third-party) — the guard's
// `appliance-growth` violation, spelled out in one dependency line.
use rustls_pki_types::pem::PemObject;
let certs: Vec<rustls_pki_types::CertificateDer<'static>> =
rustls_pki_types::CertificateDer::pem_slice_iter(self.cert_chain_pem.as_bytes())
.collect::<Result<_, _>>()
.map_err(|err| {
Error::new(ErrorKind::InvalidData, format!("certificate PEM: {err}"))
})?;
if certs.is_empty() {
return Err(Error::new(
ErrorKind::InvalidData,
"the TLS certificate PEM contains no certificate",
));
}
// `from_pem_slice` takes the FIRST key-shaped section and refuses a PEM
// with none, which is the same contract `rustls_pemfile::private_key`
// had — an absent key was an `Ok(None)` there and is an `Err` here, so
// the two error arms it needed become one.
let key = rustls_pki_types::PrivateKeyDer::from_pem_slice(self.key_pem.as_bytes())
.map_err(|err| Error::new(ErrorKind::InvalidData, format!("key PEM: {err}")))?;
// ring explicitly: aws-lc-rs may also be linked one day through some
// dependency, and rustls panics on first use when two providers are
// present and none is the process default. holger pins ring for the
// same reason (`ensure_crypto_provider`).
let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider());
let mut config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|err| Error::new(ErrorKind::InvalidData, format!("TLS versions: {err}")))?
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|err| Error::new(ErrorKind::InvalidData, format!("TLS identity: {err}")))?;
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(config)))
}
}
impl Default for HttpConfig {
fn default() -> Self {
HttpConfig {
listen: "127.0.0.1:8080".parse().expect("a literal address"),
// Matches SSH's default, and for the same reason: `Private` is the
// default for a repository, so nothing is anonymously readable
// until somebody publishes it.
allow_anonymous: true,
cleartext: Cleartext::RequireProxyTls,
external_base: None,
stream_window: 256 * 1024,
header_timeout: Some(Duration::from_secs(30)),
// 512 is tokio's own default blocking-thread ceiling, which is the
// number `gunnar-ssh`'s admission header names as the limit that
// used to be reached silently. A number chosen here, visible in one
// place, and far above anything a forge of this size serves at
// once.
max_connections: Some(512),
tls: None,
}
}
}
/// gunnar's HTTP listener.
///
/// Owns nothing the SSH listener does not also have: the same [`Dispatch`], the
/// same [`GitTransport`], the same [`ServerMetrics`].
pub struct GunnarHttp {
service: GitHttp,
listen: SocketAddr,
tls: Option<TlsIdentity>,
/// See [`HttpConfig::header_timeout`].
header_timeout: Option<Duration>,
/// See [`HttpConfig::max_connections`].
max_connections: Option<usize>,
}
impl GunnarHttp {
/// Build a listener over the control plane and the wire seam.
pub fn new(config: HttpConfig, dispatch: Dispatch, transport: Arc<dyn GitTransport>) -> Self {
GunnarHttp {
listen: config.listen,
tls: config.tls.clone(),
header_timeout: config.header_timeout,
max_connections: config.max_connections,
service: GitHttp::new(dispatch, transport, config),
}
}
/// Count into the server's shared counters.
pub fn with_metrics(mut self, metrics: Arc<ServerMetrics>) -> Self {
self.service = self.service.with_metrics(metrics);
self
}
/// Route this listener's git transfers through an **existing** admission
/// gate — the SSH listener's own (one implementation).
///
/// Owns nothing the SSH listener does not also have: this is the same
/// [`gunnar_ssh::Admission`], so a transfer over HTTP counts against the
/// same server-wide limit, the same per-principal share and the same memory
/// budget as one over SSH. Without it the two fronts each hold a whole
/// ceiling and the appliance's real concurrency is silently double what the
/// operator configured.
pub fn with_admission(mut self, admission: Arc<gunnar_ssh::Admission>) -> Self {
self.service = self.service.with_admission(admission);
self
}
/// Serve Git LFS over HTTP as well as git.
///
/// Without it the `…/info/lfs/…` URLs answer `501`, and a `git clone` of a
/// repository with LFS content completes with pointer files. See
/// [`crate::lfs`].
pub fn with_lfs(mut self, lfs: Arc<dyn gunnar_ssh::seam::LfsTransport>) -> Self {
self.service = self.service.with_lfs(lfs);
self
}
/// Bind the socket, so a caller can learn the port before serving.
///
/// Separate from [`GunnarHttp::serve`] because a test that asked for port 0
/// has no other way to find out what it got, and because binding is where
/// "address already in use" happens: a server that bound inside its serve
/// loop would report that failure as a task that quietly ended.
pub async fn bind(self) -> std::io::Result<Bound> {
let listener = tokio::net::TcpListener::bind(self.listen).await?;
let local = listener.local_addr()?;
// Built once here rather than per connection, and BEFORE serving: a
// PEM that does not parse must be "the server refused to start", never
// a listener that answers every handshake with an alert.
let acceptor = self.tls.as_ref().map(TlsIdentity::acceptor).transpose()?;
Ok(Bound {
service: self.service,
listener,
local,
acceptor,
header_timeout: self.header_timeout,
connections: self
.max_connections
.map(|max| Arc::new(tokio::sync::Semaphore::new(max))),
})
}
}
/// A bound HTTP listener that has not started accepting yet.
pub struct Bound {
service: GitHttp,
listener: tokio::net::TcpListener,
local: SocketAddr,
acceptor: Option<tokio_rustls::TlsAcceptor>,
/// See [`HttpConfig::header_timeout`].
header_timeout: Option<Duration>,
/// The ceiling from [`HttpConfig::max_connections`], as the permits that
/// enforce it. `None` is no ceiling.
///
/// A semaphore rather than a counter, and the permit is held by the
/// connection TASK, so it is returned when the task ends however it ends —
/// a clean close, a dropped socket, a panic. A counter decremented at the
/// bottom of the task leaks one on every path that does not reach the
/// bottom, which is the shape of leak that only shows up under the load the
/// ceiling exists for.
connections: Option<Arc<tokio::sync::Semaphore>>,
}
impl Bound {
/// The address actually bound.
pub fn local_addr(&self) -> SocketAddr {
self.local
}
/// Whether this listener terminates TLS itself.
pub fn tls(&self) -> bool {
self.acceptor.is_some()
}
/// Accept and serve until the task is dropped.
pub async fn serve(self) -> std::io::Result<()> {
tracing::info!(
target: TARGET_GIT,
event = "http.listening",
addr = %self.local,
"smart HTTP listening"
);
loop {
let (socket, peer) = match self.listener.accept().await {
Ok(accepted) => accepted,
Err(err) => {
tracing::warn!(target: TARGET_GIT, %err, "accept failed");
continue;
}
};
// **TCP_NODELAY.** Without it, a pkt-line response that ends in a
// small write sits in the kernel waiting for Nagle to coalesce it
// while the peer sits in delayed-ACK, and the pair costs ~40 ms per
// round trip. `gunnar-ssh` already sets it (`server.rs:426`); this
// listener did not, and `gunnar.vs_forge_ls_remote` measured the
// consequence on 2026-08-04: 92.1 ms for gunnar against 13.4 ms for
// gitea on the same request, through the same counting proxy. Go's
// `net/http` sets it on every accepted connection, which is why the
// two forges never paid it.
if let Err(err) = socket.set_nodelay(true) {
tracing::debug!(target: TARGET_GIT, %peer, %err, "TCP_NODELAY refused");
}
// ── the ceiling, before a task exists to hold the socket ────────
//
// Taken HERE and not inside the spawn, because a permit acquired
// inside the task is a task that was already spawned: the thing
// being bounded is the number of live tasks and descriptors, and a
// ceiling that admits everything and then makes it wait is the
// unbounded growth it exists to stop, with a queue in front.
//
// `try_acquire_owned`, so a full listener refuses immediately
// rather than holding the accept loop — the loop must keep running
// to notice that permits came back.
let permit = match self.connections.clone() {
Some(semaphore) => match semaphore.try_acquire_owned() {
Ok(permit) => Some(permit),
Err(_) => {
// `warn!`: one of these is a burst and a thousand is a
// listener that has been full for a while, and the
// thing that tells them apart is a count in a journal.
tracing::warn!(
target: TARGET_GIT,
event = "http.connection.refused",
%peer,
"refusing a connection: the listener is at its ceiling"
);
drop(socket);
continue;
}
},
None => None,
};
let service = self.service.clone();
let acceptor = self.acceptor.clone();
let header_timeout = self.header_timeout;
tokio::spawn(async move {
// Held for the life of the connection and released by the drop
// at the end of this task, whatever ends it.
let _permit = permit;
let handler = hyper::service::service_fn(move |request| {
let service = service.clone();
// `peer` reaches `call` explicitly rather than through a
// span, because the span is opened INSIDE `call` — one
// request per span, not one per connection. Keep-alive (and
// h2 multiplexing) serves many requests down one socket, and
// a connection-scoped id would put a clone and the `401`
// that preceded it under the same identifier.
async move {
Ok::<_, std::convert::Infallible>(service.call(request, Some(peer)).await)
}
});
match acceptor {
// ── the single-executable shape: rustls, then h1/h2 by
// whatever ALPN agreed. `auto` sniffs the negotiated
// protocol off the stream, so a git client that speaks h2
// multiplexes and one that speaks 1.1 is served exactly as
// before.
Some(acceptor) => {
// **A handshake that never completes is awaited here.**
// `accept` has no clock of its own, so a peer that opens
// a socket and sends nothing was awaited for as long as
// it liked, holding this task and this descriptor.
let handshake = acceptor.accept(socket);
let accepted = match header_timeout {
Some(limit) => match tokio::time::timeout(limit, handshake).await {
Ok(accepted) => accepted,
Err(_) => {
tracing::debug!(
target: TARGET_GIT,
event = "http.handshake.timeout",
%peer,
?limit,
"closing: the TLS handshake did not finish in time"
);
return;
}
},
None => handshake.await,
};
let stream = match accepted {
Ok(stream) => stream,
Err(err) => {
// A failed handshake is a client that dialled
// the wrong scheme, a scanner, or a cert the
// peer refuses — connection noise, not server
// trouble.
tracing::debug!(target: TARGET_GIT, %peer, %err, "TLS handshake failed");
return;
}
};
let io = hyper_util::rt::TokioIo::new(stream);
let mut builder = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
);
// The other half of the clock: a connection that
// completed its handshake and then sends a partial
// request line, or none. hyper's own setting, so the
// read it bounds is the read hyper is in.
if let Some(limit) = header_timeout {
// The timer is not optional and hyper does not
// default it: with `header_read_timeout` set and no
// `timer`, hyper 1.11 PANICS the connection task
// with "timeout `header_read_timeout` set, but no
// timer set" — MEASURED, and the connection then
// closes in under a millisecond, which reads from
// outside exactly like a very strict timeout
// working.
builder
.http1()
.timer(hyper_util::rt::TokioTimer::new())
.header_read_timeout(limit);
}
if let Err(err) = builder.serve_connection(io, handler).await {
tracing::debug!(target: TARGET_GIT, %peer, %err, "connection ended");
}
}
// ── the proxy-terminated shape, unchanged: HTTP/1.1 only,
// deliberately — h2 needs ALPN, ALPN needs TLS, and h2c
// prior-knowledge is a thing no git client sends. A proxy
// may speak h2 to the world and 1.1 here.
None => {
let io = hyper_util::rt::TokioIo::new(socket);
let mut builder = hyper::server::conn::http1::Builder::new();
if let Some(limit) = header_timeout {
// See the TLS arm: the timer is what makes the
// timeout a timeout rather than a panic.
builder
.timer(hyper_util::rt::TokioTimer::new())
.header_read_timeout(limit);
}
if let Err(err) = builder.serve_connection(io, handler).await {
tracing::debug!(target: TARGET_GIT, %peer, %err, "connection ended");
}
}
}
});
}
}
}