use std::net::SocketAddr;
use std::sync::Arc;
use gunnar_core::observe::{ServerMetrics, SCRAPE_CONTENT_TYPE, TARGET_METRICS};
pub const SCRAPE_PATH: &str = "/metrics";
#[derive(Debug, Clone, Copy, Default)]
pub struct ScrapeConfig {
pub bind: Option<SocketAddr>,
}
pub struct Bound {
listener: tokio::net::TcpListener,
local: SocketAddr,
metrics: Arc<ServerMetrics>,
}
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 {
pub fn local_addr(&self) -> SocketAddr {
self.local
}
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;
}
};
if let Err(err) = socket.set_nodelay(true) {
tracing::debug!(target: TARGET_METRICS, %peer, %err, "TCP_NODELAY refused");
}
let metrics = Arc::clone(&self.metrics);
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)) }
});
if let Err(err) = hyper::server::conn::http1::Builder::new()
.serve_connection(io, handler)
.await
{
tracing::debug!(target: TARGET_METRICS, %peer, %err, "connection ended");
}
});
}
}
}
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"
)
})
}
fn answer(
request: &hyper::Request<hyper::body::Incoming>,
metrics: &ServerMetrics,
) -> hyper::Response<http_body_util::Full<bytes::Bytes>> {
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 {
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)
.header(hyper::header::CACHE_CONTROL, "no-store")
.body(http_body_util::Full::new(bytes::Bytes::from(body)))
.expect("a response built from constant headers")
}
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::*;
#[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"
);
}
#[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}"
);
}
}
}