gunnar-server 2.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **The counters, over HTTP**: one opt-in read-only listener that
//! answers *"is it slow or is it stuck"* without `perf` and without a restart.
//!
//! # What was missing
//!
//! [`ServerMetrics`](gunnar_core::observe::ServerMetrics) has nineteen numbers
//! in it and exactly one reader: the heartbeat, which writes a line to the log
//! every sixty seconds *when something changed*. That is the right default and
//! it is not enough on its own. A heartbeat cannot be asked a question — an
//! operator watching a transfer that has not finished waits a minute to find
//! out whether `bytes_out` moved, and a graph over an hour of an incident has
//! to be reconstructed by hand out of journal lines. `transfers_in_flight`, the
//! one number that distinguishes *slow* from *stuck*, is the one most worth
//! sampling at a rate a human chooses.
//!
//! # It does not open a port
//!
//! [`ScrapeConfig::bind`] is an `Option` and it is `None`. Nothing listens
//! unless `gunnar serve --metrics-bind <addr>` says so, which is the same shape
//! `--http-bind` already uses (smart HTTP) and for the same reason: a second
//! listening port is a decision an operator makes, and a metrics surface that
//! bound itself by default would be a worse defect than the missing metrics
//! were.
//!
//! **There is no authentication on it, and that is stated rather than
//! discovered.** The body is nineteen integers about this process — no
//! repository names, no principals, no references, no paths — so the surface is
//! deliberately not worth a credential, and a credential is exactly what an
//! operator cannot supply from a scraper's config half the time. What that
//! costs is that anyone who can reach the port learns how busy the server is,
//! which is why [`Bound::serve`] writes a `warn` when the address is not
//! loopback: it must be a decision that was noticed.
//!
//! # It costs no dependency
//!
//! hyper 1, hyper-util and http-body-util, all of which tonic already resolves
//! for gRPC. `cargo xtask guard` measures the appliance graph before and after:
//! **281 crates / 267 third-party, unchanged** (MEASURED 2026-08-05, the reference desktop). It
//! is deliberately not behind the `http` feature — that feature is off in the
//! shipped `gunnar-server` atom, and an appliance is precisely where an operator
//! has the least ability to attach a debugger, so it is the build that must be
//! able to answer this question, not the one that may.
//!
//! # It is not `gunnar-http`
//!
//! `gunnar-http` is the git smart-HTTP *transport*: four fixed git URLs, an
//! authorisation decision per request, gzip, pkt-line. This shares its accept
//! loop's shape and none of its body, and it lives here because putting it
//! there would mean an operator who wants counters compiles a git transport to
//! get them. The two loops are written once each; what one implementation forbids is a
//! second copy of a *decision*, and there is no decision in an accept loop.

use std::net::SocketAddr;
use std::sync::Arc;

use gunnar_core::observe::{ServerMetrics, SCRAPE_CONTENT_TYPE, TARGET_METRICS};

/// The one path that answers. Anything else is a `404`.
///
/// `/metrics` because that is what every scraper's default configuration
/// already asks for, and a forge is not the place to be interesting about a
/// URL.
pub const SCRAPE_PATH: &str = "/metrics";

/// What `--metrics-bind` was given, if anything.
#[derive(Debug, Clone, Copy, Default)]
pub struct ScrapeConfig {
    /// Where the scrape listener binds. `None` — the default — starts nothing:
    /// no socket, no task, no port.
    pub bind: Option<SocketAddr>,
}

/// A bound scrape listener that has not started accepting yet.
///
/// Split from serving for the same reason [`gunnar_http::Bound`] is: a caller
/// that asked for port 0 has no other way to learn what it got, and "address
/// already in use" has to surface as a failed `start` rather than as a task
/// that quietly ended.
pub struct Bound {
    listener: tokio::net::TcpListener,
    local: SocketAddr,
    metrics: Arc<ServerMetrics>,
}

/// Bind the scrape listener.
pub async fn bind(addr: SocketAddr, metrics: Arc<ServerMetrics>) -> std::io::Result<Bound> {
    let listener = tokio::net::TcpListener::bind(addr).await?;
    let local = listener.local_addr()?;
    Ok(Bound {
        listener,
        local,
        metrics,
    })
}

impl Bound {
    /// The address actually bound.
    pub fn local_addr(&self) -> SocketAddr {
        self.local
    }

    /// Accept and answer until the task is dropped.
    pub async fn serve(self) -> std::io::Result<()> {
        tracing::info!(
            target: TARGET_METRICS,
            event = "metrics.listening",
            addr = %self.local,
            path = SCRAPE_PATH,
            "the counters are readable over HTTP"
        );
        if let Some(warning) = public_warning(self.local) {
            tracing::warn!(
                target: TARGET_METRICS,
                event = "metrics.public",
                addr = %self.local,
                "{warning}"
            );
        }
        loop {
            let (socket, peer) = match self.listener.accept().await {
                Ok(accepted) => accepted,
                Err(err) => {
                    tracing::warn!(target: TARGET_METRICS, %err, "accept failed");
                    continue;
                }
            };
            // TCP_NODELAY for the reason `gunnar_http::Bound::serve` records at
            // its own call: a small response written without it waits on Nagle
            // against the peer's delayed ACK, and a scrape body is nothing but
            // one small write.
            if let Err(err) = socket.set_nodelay(true) {
                tracing::debug!(target: TARGET_METRICS, %peer, %err, "TCP_NODELAY refused");
            }
            let metrics = Arc::clone(&self.metrics);
            // A32-14, stays a task: one task per metrics scrape connection.
            tokio::spawn(async move {
                let io = hyper_util::rt::TokioIo::new(socket);
                let handler = hyper::service::service_fn(move |request| {
                    let metrics = Arc::clone(&metrics);
                    async move { Ok::<_, std::convert::Infallible>(answer(&request, &metrics)) }
                });
                // HTTP/1.1, like the git transport and for the same reason: h2
                // for a client is negotiated by ALPN, which needs TLS, which
                // gunnar does not terminate. Every scraper speaks 1.1.
                if let Err(err) = hyper::server::conn::http1::Builder::new()
                    .serve_connection(io, handler)
                    .await
                {
                    tracing::debug!(target: TARGET_METRICS, %peer, %err, "connection ended");
                }
            });
        }
    }
}

/// The line to write when the bound address is reachable from off this host,
/// or `None` when it is not.
///
/// **Pure, and given the address rather than reading it**, so both directions
/// can be seen from a unit test without this process binding `0.0.0.0`. A
/// warning nobody has watched fire on a public address is a warning that may
/// well be an `if` written backwards, and a warning that fires on *every*
/// server is one operators learn to skip — the pair is the claim, so the pair
/// is what is asserted.
///
/// The unspecified address (`0.0.0.0`, `[::]`) counts as public, and it is the
/// case this exists for: an operator who types it meaning "localhost" gets a
/// line at start-up rather than a message from someone else's dashboard.
fn public_warning(addr: SocketAddr) -> Option<String> {
    (!addr.ip().is_loopback()).then(|| {
        format!(
            "the scrape endpoint on {addr} is NOT on loopback and has no authentication: \
             anyone who can reach this address can read this server's counters"
        )
    })
}

/// Answer one request.
///
/// Pure but for the counter read: given a request and the counters it returns
/// the whole response, so every status this endpoint can produce is reachable
/// from a test without a socket. It is `fn` and not `async fn` because there is
/// nothing here to await — reading nineteen relaxed atomics and formatting them
/// is not I/O, and making it look like I/O would invite someone to put some in.
fn answer(
    request: &hyper::Request<hyper::body::Incoming>,
    metrics: &ServerMetrics,
) -> hyper::Response<http_body_util::Full<bytes::Bytes>> {
    // `HEAD` as well as `GET`: a scraper's health check and a human's `curl -I`
    // both send it, and hyper drops the body itself. Refusing it would produce
    // a 405 for a request this endpoint can obviously answer.
    if request.method() != hyper::Method::GET && request.method() != hyper::Method::HEAD {
        return text(
            hyper::StatusCode::METHOD_NOT_ALLOWED,
            "this endpoint reads counters; it accepts GET and HEAD\n",
        );
    }
    if request.uri().path() != SCRAPE_PATH {
        // The 404 names the one path that works. An operator who typed
        // `/metrics/` or pointed a scraper at `/` gets the answer in the body
        // rather than a bare status they have to go and look up.
        //
        // Built from `SCRAPE_PATH` and not from a second literal: this sentence
        // is the only documentation of the URL a lost operator will read, and a
        // hard-coded copy of it is the one that would still say `/metrics`
        // after the constant moved.
        return text(
            hyper::StatusCode::NOT_FOUND,
            format!("no such path; the counters are at {SCRAPE_PATH}\n"),
        );
    }
    let body = metrics.snapshot().render_prometheus();
    hyper::Response::builder()
        .status(hyper::StatusCode::OK)
        .header(hyper::header::CONTENT_TYPE, SCRAPE_CONTENT_TYPE)
        // No caching, ever. A scraper that got a cached body would draw a flat
        // line through an incident, which is the one wrong answer this whole
        // module exists to prevent.
        .header(hyper::header::CACHE_CONTROL, "no-store")
        .body(http_body_util::Full::new(bytes::Bytes::from(body)))
        .expect("a response built from constant headers")
}

/// A plain-text response, so the error bodies are built one way.
fn text(
    status: hyper::StatusCode,
    body: impl Into<bytes::Bytes>,
) -> hyper::Response<http_body_util::Full<bytes::Bytes>> {
    hyper::Response::builder()
        .status(status)
        .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .body(http_body_util::Full::new(body.into()))
        .expect("a response built from constant headers")
}

#[cfg(test)]
mod tests {
    use super::*;

    // `hyper::body::Incoming` cannot be constructed outside hyper, so `answer`
    // is not reachable from a unit test at all. That is the right way round:
    // this endpoint's claim is about what an HTTP CLIENT receives, and
    // `tests/metrics_endpoint.rs` asserts it over a real socket against a real
    // `gunnar serve`. What is left here is what needs no wire.

    #[test]
    fn the_scrape_path_is_the_one_every_scraper_already_asks_for() {
        assert_eq!(SCRAPE_PATH, "/metrics");
    }

    #[test]
    fn nothing_binds_unless_an_address_was_given() {
        assert!(
            ScrapeConfig::default().bind.is_none(),
            "the default must not open a port"
        );
    }

    /// Both directions of the exposure warning, which is the only way to know
    /// the `if` is not written backwards.
    #[test]
    fn the_exposure_warning_fires_off_loopback_and_only_off_loopback() {
        let quiet = ["127.0.0.1:9100", "127.5.6.7:1", "[::1]:9100"];
        for addr in quiet {
            let addr: SocketAddr = addr.parse().expect("a literal address");
            assert!(
                public_warning(addr).is_none(),
                "{addr} is loopback and was warned about; a warning on every server is one \
                 nobody reads"
            );
        }
        let loud = [
            "0.0.0.0:9100",
            "[::]:9100",
            "10.0.0.4:9100",
            "192.168.1.9:80",
        ];
        for addr in loud {
            let addr: SocketAddr = addr.parse().expect("a literal address");
            let warning = public_warning(addr).unwrap_or_else(|| {
                panic!("{addr} is reachable from off this host and was NOT warned about")
            });
            assert!(
                warning.contains(&addr.to_string()),
                "the warning for {addr} does not name the address it is about: {warning}"
            );
            assert!(
                warning.contains("no authentication"),
                "the warning for {addr} does not say what the exposure IS: {warning}"
            );
        }
    }
}