Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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");
                        }
                    }
                }
            });
        }
    }
}