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

nordisk/gunnar

crates/gunnar-front/src/web/hole.rs

//! **The hole: the CLI's way to the main's control plane, through the front's :443**
//! (GRAND-PLAN-V2 §10, the UI = CLI pattern, A6).
//!
//! The browser reaches the estate's server through the console; the CLI
//! (`gunnar control … --at https://<site>`) reaches the SAME server through
//! this: a gRPC request (HTTP/2, `content-type: application/grpc`) that arrives
//! on :443 is carried as it is to the primary's control plane, over the tunnel
//! the front already has to it (`<primary overlay>:50051`, the address the
//! console dials). Everything else on :443 (HTTP/1, the console's pages and
//! API, gRPC-Web) is untouched.
//!
//! **No logic here.** The server authenticates: the caller's own credential
//! (`gunnar-authorization`, an sshsig signed for the very method called) rides
//! through byte for byte, and the front adds none of its own, so a call is
//! never the front's. What the front does, it does as the console does for its
//! own doors:
//!
//! * **the face's own rules** (RA1-1) — it answers on the console's own names
//!   only ([`Hole::set_names`]: never a callback-only name, never a name this
//!   front does not serve), by default only to the estate's own network and
//!   the operator's overlay ([`Reach`], the site's [`FEED_CLI_HOLE`]), and it never carries a
//!   call that hands a fresh box to its first caller (`Bootstrap.Enroll` and
//!   its kin, [`BOOTSTRAP_CARRIED`]); each is refused at the front by name;
//! * **an allow-list of services** — the control plane's own (`gunnar.v1.*`,
//!   [`SERVICES`], held equal to gunnar-proto's by `tests/grpc_hole.rs`); any
//!   other path is `UNIMPLEMENTED` and never leaves the front;
//! * **a rate limit per client address** — the console's own token bucket
//!   (`gunnar_ui_server::security::KeyLimit`, the client address as the console
//!   reads it); past it a call is `RESOURCE_EXHAUSTED`, the gRPC spelling of 429;
//! * **an audit line for every call** — on the console's audit trail
//!   (`gunnar_ui::audit`, event `grpc.forward`), naming the method, the caller
//!   as its credential names itself (the key's fingerprint, or `anonymous`), the
//!   client address and the gRPC status the server answered (or why the front
//!   answered itself).
//!
//! **AIRGAP**: no new outbound. The one link it dials is the front-to-main link
//! the console already uses. It exists in the builds that serve the console
//! (the `console` feature, every front row).

use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::task::{Context, Poll};
use std::time::{Duration, Instant};

use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{header, HeaderMap, HeaderValue, Request, Response, StatusCode, Uri, Version};
use axum::Router;
use bytes::Bytes;
use hyper::body::Frame;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;

/// The control plane's services, the only ones the hole carries (gunnar-proto's
/// `service` declarations, all in package `gunnar.v1`).
pub const SERVICES: &[&str] = &[
    "gunnar.v1.Access",
    "gunnar.v1.Accounts",
    "gunnar.v1.Admin",
    "gunnar.v1.Billing",
    "gunnar.v1.Bootstrap",
    "gunnar.v1.Clans",
    "gunnar.v1.Enterprise",
    "gunnar.v1.Entitlement",
    "gunnar.v1.Events",
    "gunnar.v1.Forge",
    "gunnar.v1.Mirror",
    "gunnar.v1.Policy",
    "gunnar.v1.PullRequests",
    "gunnar.v1.Refs",
    "gunnar.v1.Repos",
    "gunnar.v1.Sealed",
    "gunnar.v1.Sso",
    "gunnar.v1.Usage",
    "gunnar.v1.UsageBatch",
    "gunnar.v1.Webhooks",
];

/// **Of `gunnar.v1.Bootstrap`, the methods the hole carries**: the two reads
/// (`gunnar control status`, `gunnar control grow status`). Every other
/// Bootstrap method is unauthenticated (or fed-key only) and hands a FRESH box
/// to whoever calls it first — `Enroll` (the first identity, "unauthenticated,
/// once, ever"), `BecomeTwin`, `Restore`, `AssignTunnelAddress`, `TwinCsr`,
/// `InstallTwinCertificate` — so none of them is ever carried from :443
/// (RA1-1): an estate's boxes are claimed by the installer over the tunnel, not
/// by whoever reaches the front first.
pub const BOOTSTRAP_CARRIED: &[&str] = &["Status", "GrowStatus"];

/// **The front setting: who may use the hole** (RA1-1, GRAND-PLAN-V2 §10: "in
/// the airgap default the hole is only reachable on your own network"), read
/// from the site's [`FEED_CLI_HOLE`] (RA1-14).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Reach {
    /// **The default**: only a client on the estate's own network or the
    /// operator's overlay ([`own_network`]); a call from anywhere else is
    /// refused at the front.
    #[default]
    OwnNetwork,
    /// Any client address (still only on the console's own names, still
    /// signed, limited and audited): a site whose operators use the CLI from
    /// the internet (gunnar.rs's front row names it).
    Public,
}

/// **The site's feed key that sets [`Reach`]** (RA1-14; monetize-shop-pairing
/// `contract::FEED_CLI_HOLE`): `public` only on a site whose declaration says
/// `cli_hole_public` (gunnar.rs); unfed is [`Reach::OwnNetwork`]. It was the medium's
/// `GUNNAR_FRONT_HOLE`, which every sold estate's front was laid with too.
pub const FEED_CLI_HOLE: &str = "front_cli_hole";
/// [`FEED_CLI_HOLE`]'s values.
pub const REACH_OWN_NETWORK: &str = "own-network";
pub const REACH_PUBLIC: &str = "public";

/// Pure: **the setting's value** — unset or empty is [`Reach::OwnNetwork`];
/// any other word than the two is an error (the caller says it and keeps the
/// default: fail closed).
pub fn reach_from(value: Option<&str>) -> Result<Reach, String> {
    let v = value
        .map(|v| v.trim().to_ascii_lowercase())
        .unwrap_or_default();
    match v.as_str() {
        "" | REACH_OWN_NETWORK => Ok(Reach::OwnNetwork),
        REACH_PUBLIC => Ok(Reach::Public),
        other => Err(format!(
            "REFUSED [front-hole-setting] {FEED_CLI_HOLE}={other:?} is neither `{REACH_OWN_NETWORK}` nor `{REACH_PUBLIC}`; the CLI's gRPC hole stays `{REACH_OWN_NETWORK}`"
        )),
    }
}

/// **The reach the site's feed names** ([`FEED_CLI_HOLE`]); an unknown word is said and the
/// default kept.
pub fn reach_of(feed: &std::collections::BTreeMap<String, String>) -> Reach {
    match reach_from(feed.get(FEED_CLI_HOLE).map(String::as_str)) {
        Ok(r) => r,
        Err(why) => {
            tracing::error!("{why}");
            Reach::OwnNetwork
        }
    }
}

/// Pure: **is `ip` on the estate's own network or the operator's overlay** —
/// loopback, the private IPv4 ranges (RFC 1918, which hold the estate's utility
/// network and its WireGuard overlay `10.44.0.0/24`), the shared range
/// `100.64.0.0/10` (an operator's overlay: Tailscale, Headscale, a carrier's
/// NAT), link-local, and IPv6's unique-local `fc00::/7` and link-local
/// `fe80::/10` (an IPv4-mapped address is read as its IPv4).
pub fn own_network(ip: std::net::IpAddr) -> bool {
    use std::net::IpAddr;
    match ip.to_canonical() {
        IpAddr::V4(v4) => {
            let [a, b, ..] = v4.octets();
            v4.is_loopback()
                || v4.is_private()
                || v4.is_link_local()
                || (a == 100 && (64..128).contains(&b))
        }
        IpAddr::V6(v6) => {
            let first = v6.segments()[0];
            v6.is_loopback() || (first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80
        }
    }
}

/// Pure: **does the hole carry this method** — every method of a carried
/// service ([`allowed`]), but of `gunnar.v1.Bootstrap` only
/// [`BOOTSTRAP_CARRIED`].
pub fn method_carried(path: &str) -> bool {
    if !allowed(path) {
        return false;
    }
    match service_of(path) {
        Some("gunnar.v1.Bootstrap") => path
            .rsplit('/')
            .next()
            .is_some_and(|m| BOOTSTRAP_CARRIED.contains(&m)),
        _ => true,
    }
}

/// Calls a minute from one client address (the bucket also holds this many at
/// once). A person's verb is a handful of calls; a script listing every
/// repository a few hundred.
pub const PER_CLIENT_PER_MINUTE: u32 = 600;
/// Client addresses the limiter holds at most (the console's cap).
pub const CLIENTS_HELD: usize = 4096;
/// How long one dial to the control plane may take.
const DIAL: Duration = Duration::from_secs(5);

/// The gRPC status codes the front answers with itself.
pub const RESOURCE_EXHAUSTED: u32 = 8;
pub const PERMISSION_DENIED: u32 = 7;
pub const UNIMPLEMENTED: u32 = 12;
pub const UNAVAILABLE: u32 = 14;

/// **One call through the hole, as its audit line says it.**
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Forwarded {
    /// `Service.Method` (`Repos.List`), or the path as asked when it is not a
    /// gRPC method path.
    pub method: String,
    /// The caller as its credential names itself: the key's fingerprint, or
    /// `anonymous` (the server verifies it; the front only says it).
    pub caller: String,
    /// The client address the limit is kept by.
    pub client: String,
    /// The gRPC status, as a number (`0` is OK); `None` when the answer ended
    /// without one (the stream was cut).
    pub status: Option<u32>,
    /// Who answered: `main` (the control plane) or the front's own refusal
    /// (`refused-name`, `refused-network`, `refused-service`, `refused-method`,
    /// `rate-limited`, `no-primary`, `unreachable`).
    pub answered_by: &'static str,
    pub ms: u64,
}

/// Where an audit line goes (the console's audit trail in production; a test's list).
pub type AuditSink = Arc<dyn Fn(&Forwarded) + Send + Sync>;

/// The audit trail's sink: `gunnar_ui::audit`, event `grpc.forward`.
pub fn console_audit() -> AuditSink {
    Arc::new(|f: &Forwarded| {
        let status = f
            .status
            .map(|s| s.to_string())
            .unwrap_or_else(|| "none".to_string());
        tracing::info!(
            target: gunnar_ui_server::auth::AUDIT_TARGET,
            event = "grpc.forward",
            method = %f.method,
            caller = %f.caller,
            client = %f.client,
            grpc_status = %status,
            answered_by = f.answered_by,
            ms = f.ms,
            "a CLI call went through the front to the main's control plane"
        );
    })
}

/// **The hole**: where it forwards to, its limit, its audit sink. A handle: its
/// clones share all three, and a retarget is seen by the next call.
#[derive(Clone)]
pub struct Hole(Arc<Inner>);

struct Inner {
    target: RwLock<Option<String>>,
    /// The console's own names: the only hosts the hole answers on.
    names: RwLock<Vec<String>>,
    reach: RwLock<Reach>,
    limit: RwLock<Arc<gunnar_ui_server::security::KeyLimit>>,
    audit: RwLock<AuditSink>,
    client: Client<HttpConnector, Body>,
}

impl Default for Hole {
    fn default() -> Self {
        Hole::new()
    }
}

impl Hole {
    /// No target yet ([`UNAVAILABLE`] until [`Hole::retarget`]), the default
    /// limit, the console's audit trail.
    pub fn new() -> Hole {
        let mut http = HttpConnector::new();
        http.set_connect_timeout(Some(DIAL));
        http.set_nodelay(true);
        let client = Client::builder(hyper_util::rt::TokioExecutor::new())
            .http2_only(true)
            .build(http);
        Hole(Arc::new(Inner {
            target: RwLock::new(None),
            names: RwLock::new(Vec::new()),
            reach: RwLock::new(Reach::default()),
            limit: RwLock::new(Arc::new(gunnar_ui_server::security::KeyLimit::new(
                PER_CLIENT_PER_MINUTE,
                CLIENTS_HELD,
            ))),
            audit: RwLock::new(console_audit()),
            client,
        }))
    }

    /// **Point the hole at `to`** (`<host>:<port>` of the control plane), live:
    /// the next call goes there. Answers whether it changed.
    pub fn retarget(&self, to: Option<String>) -> bool {
        let mut t = self.0.target.write().unwrap_or_else(|p| p.into_inner());
        if *t == to {
            return false;
        }
        tracing::info!(from = ?*t, to = ?to, "front: the CLI's gRPC hole on :443 re-targeted — the next call goes to the main's control plane");
        *t = to;
        true
    }

    pub fn target(&self) -> Option<String> {
        self.0
            .target
            .read()
            .unwrap_or_else(|p| p.into_inner())
            .clone()
    }

    /// **The console's own names** (the served names but the callback-only
    /// ones, [`crate::web::WebConfig::console_names`]): from the next call the
    /// hole answers on these hosts only. Before any is set it answers on none.
    pub fn set_names(&self, names: &[String]) {
        let names: Vec<String> = names
            .iter()
            .map(|n| n.trim().to_ascii_lowercase())
            .filter(|n| !n.is_empty())
            .collect();
        *self.0.names.write().unwrap_or_else(|p| p.into_inner()) = names;
    }

    pub fn names(&self) -> Vec<String> {
        self.0
            .names
            .read()
            .unwrap_or_else(|p| p.into_inner())
            .clone()
    }

    /// **Who may use the hole** ([`Reach`]), from the next call.
    pub fn set_reach(&self, reach: Reach) {
        *self.0.reach.write().unwrap_or_else(|p| p.into_inner()) = reach;
    }

    pub fn reach(&self) -> Reach {
        *self.0.reach.read().unwrap_or_else(|p| p.into_inner())
    }

    /// A new limit: `per_minute` calls a minute per client address, from the next call.
    pub fn set_per_minute(&self, per_minute: u32) {
        *self.0.limit.write().unwrap_or_else(|p| p.into_inner()) = Arc::new(
            gunnar_ui_server::security::KeyLimit::new(per_minute, CLIENTS_HELD),
        );
    }

    /// Audit into `sink` instead of the console's trail (tests).
    pub fn set_audit(&self, sink: AuditSink) {
        *self.0.audit.write().unwrap_or_else(|p| p.into_inner()) = sink;
    }

    fn audit(&self, f: &Forwarded) {
        let sink = self
            .0
            .audit
            .read()
            .unwrap_or_else(|p| p.into_inner())
            .clone();
        sink(f);
    }

    /// **Carry one gRPC call** (the caller checked [`is_grpc`]).
    pub async fn forward(&self, mut req: Request<Body>) -> Response<Body> {
        let t = Instant::now();
        let path = req.uri().path().to_string();
        let method = method_of(&path).unwrap_or_else(|| path.clone());
        let caller = claimed_caller(req.headers());
        let client = gunnar_ui_server::security::client_ip(
            req.headers(),
            req.extensions().get::<ConnectInfo<SocketAddr>>(),
        )
        .0;
        let said = |status: u32, by: &'static str, message: &str| {
            self.audit(&Forwarded {
                method: method.clone(),
                caller: caller.clone(),
                client: client.clone(),
                status: Some(status),
                answered_by: by,
                ms: t.elapsed().as_millis() as u64,
            });
            grpc_answer(status, message)
        };
        // The face's rules first (RA1-1): the console's own names only, never a
        // callback-only one or a name this front does not serve.
        let host = request_host(&req);
        if !host.as_ref().is_some_and(|h| self.names().contains(h)) {
            return said(PERMISSION_DENIED, "refused-name", "REFUSED [front-hole-name] this name carries no control plane: the CLI's gRPC hole answers on the console's own names only");
        }
        if self.reach() == Reach::OwnNetwork
            && !client.parse::<std::net::IpAddr>().is_ok_and(own_network)
        {
            return said(
                PERMISSION_DENIED,
                "refused-network",
                "REFUSED [front-hole-network] this front's CLI hole is open to the estate's own network and the operator's overlay only (GUNNAR_FRONT_HOLE=own-network, the airgap default)",
            );
        }
        if !allowed(&path) {
            return said(
                UNIMPLEMENTED,
                "refused-service",
                "this front carries the gunnar control plane's own services only",
            );
        }
        if !method_carried(&path) {
            return said(
                PERMISSION_DENIED,
                "refused-method",
                "REFUSED [front-hole-method] this call hands a fresh box to its first caller and is never carried through the front; of Bootstrap the hole carries Status and GrowStatus only",
            );
        }
        let limit = self
            .0
            .limit
            .read()
            .unwrap_or_else(|p| p.into_inner())
            .clone();
        if !limit.allow(&client) {
            return said(
                RESOURCE_EXHAUSTED,
                "rate-limited",
                "too many calls from this address through the front; wait a minute and call again",
            );
        }
        let Some(to) = self.target() else {
            return said(
                UNAVAILABLE,
                "no-primary",
                "this front knows no primary yet; call again shortly",
            );
        };
        let Ok(uri) = upstream_uri(&to, req.uri()) else {
            return said(
                UNAVAILABLE,
                "unreachable",
                "this front's control-plane address is not usable",
            );
        };
        *req.uri_mut() = uri;
        *req.version_mut() = Version::HTTP_2;
        req.headers_mut().remove(header::HOST);
        match self.0.client.request(req).await {
            Ok(resp) => {
                let at_head = grpc_status(resp.headers());
                let rec = Forwarded {
                    method,
                    caller,
                    client,
                    status: at_head,
                    answered_by: "main",
                    ms: 0,
                };
                let hole = self.clone();
                resp.map(|b| {
                    Body::new(Audited {
                        inner: b,
                        rec: Some(rec),
                        t,
                        hole,
                    })
                })
            }
            Err(e) => {
                tracing::warn!(to = %to, error = %e, "front: the CLI's gRPC hole could not reach the main's control plane");
                said(
                    UNAVAILABLE,
                    "unreachable",
                    "the main's control plane cannot be reached from this front right now",
                )
            }
        }
    }
}

/// Pure: **is this request the hole's** — HTTP/2 with `content-type`
/// `application/grpc` (or `application/grpc+<codec>`). gRPC-Web and every
/// HTTP/1 request are not.
pub fn is_grpc<B>(req: &Request<B>) -> bool {
    if req.version() != Version::HTTP_2 {
        return false;
    }
    let Some(ct) = req
        .headers()
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
    else {
        return false;
    };
    let ct = ct.trim().to_ascii_lowercase();
    ct == "application/grpc"
        || ct.starts_with("application/grpc+")
        || ct.starts_with("application/grpc;")
}

/// The request's host, lower case and without a port: `Host`, else the URI's
/// authority (HTTP/2 `:authority`) — as the face's own rules read it.
fn request_host<B>(req: &Request<B>) -> Option<String> {
    let h = req
        .headers()
        .get(header::HOST)
        .and_then(|h| h.to_str().ok())
        .map(str::to_string)
        .or_else(|| req.uri().host().map(str::to_string))?;
    let h = h.trim();
    let bare = if let Some(rest) = h.strip_prefix('[') {
        rest.split(']').next().unwrap_or(rest)
    } else {
        h.split(':').next().unwrap_or(h)
    };
    Some(bare.to_ascii_lowercase())
}

/// Pure: the service of a gRPC path (`/gunnar.v1.Repos/List` → `gunnar.v1.Repos`).
pub fn service_of(path: &str) -> Option<&str> {
    let rest = path.strip_prefix('/')?;
    let (service, method) = rest.split_once('/')?;
    (!service.is_empty() && !method.is_empty() && !method.contains('/')).then_some(service)
}

/// Pure: `Service.Method` of a gRPC path, the package cut (`Repos.List`).
pub fn method_of(path: &str) -> Option<String> {
    let service = service_of(path)?;
    let method = path.rsplit('/').next()?;
    let short = service.rsplit('.').next().unwrap_or(service);
    Some(format!("{short}.{method}"))
}

/// Pure: does the hole carry this path ([`SERVICES`])?
pub fn allowed(path: &str) -> bool {
    service_of(path).is_some_and(|s| SERVICES.contains(&s))
}

/// Pure: **the caller as its credential names itself** — the fingerprint field
/// of a `gunnar-sshsig-*` credential (`gunnar-authorization`), else `anonymous`.
/// Shape only: the server verifies it.
pub fn claimed_caller(headers: &HeaderMap) -> String {
    let named = headers
        .get("gunnar-authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| {
            let mut f = v.split_whitespace();
            let scheme = f.next()?;
            let fp = f.next()?;
            (scheme.starts_with("gunnar-sshsig-")
                && fp.len() <= 128
                && fp.chars().all(|c| c.is_ascii_graphic()))
            .then(|| fp.to_string())
        });
    named.unwrap_or_else(|| "anonymous".to_string())
}

/// Pure: the control plane's URI for `asked` (`http://<to><path?query>`).
pub fn upstream_uri(to: &str, asked: &Uri) -> Result<Uri, axum::http::Error> {
    let pq = asked.path_and_query().map(|p| p.as_str()).unwrap_or("/");
    Uri::builder()
        .scheme("http")
        .authority(to)
        .path_and_query(pq)
        .build()
}

/// The `grpc-status` header, as a number.
fn grpc_status(h: &HeaderMap) -> Option<u32> {
    h.get("grpc-status")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.trim().parse().ok())
}

/// **A gRPC answer the front gives itself**: trailers-only (HTTP 200, the
/// status in the headers), as the gRPC wire spells an error with no message.
pub fn grpc_answer(status: u32, message: &str) -> Response<Body> {
    let mut r = Response::new(Body::empty());
    *r.status_mut() = StatusCode::OK;
    let h = r.headers_mut();
    h.insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("application/grpc"),
    );
    h.insert("grpc-status", HeaderValue::from(status));
    if let Ok(v) = HeaderValue::from_str(&percent_message(message)) {
        h.insert("grpc-message", v);
    }
    r
}

/// The gRPC message's percent-encoding (space and the non-printables).
fn percent_message(m: &str) -> String {
    let mut out = String::new();
    for b in m.bytes() {
        if (0x20..0x7f).contains(&b) && b != b'%' {
            out.push(b as char);
        } else {
            out.push_str(&format!("%{b:02X}"));
        }
    }
    out
}

/// The control plane's answer body, audited when it ends (its trailers carry
/// the status) or is dropped.
struct Audited {
    inner: hyper::body::Incoming,
    rec: Option<Forwarded>,
    t: Instant,
    hole: Hole,
}

impl Audited {
    fn finish(&mut self) {
        if let Some(mut rec) = self.rec.take() {
            rec.ms = self.t.elapsed().as_millis() as u64;
            self.hole.audit(&rec);
        }
    }
}

impl hyper::body::Body for Audited {
    type Data = Bytes;
    type Error = hyper::Error;

    fn poll_frame(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Result<Frame<Bytes>, hyper::Error>>> {
        let polled = Pin::new(&mut self.inner).poll_frame(cx);
        match &polled {
            Poll::Ready(Some(Ok(frame))) => {
                if let (Some(trailers), Some(rec)) = (frame.trailers_ref(), self.rec.as_mut()) {
                    if let Some(s) = grpc_status(trailers) {
                        rec.status = Some(s);
                    }
                }
            }
            Poll::Ready(None) | Poll::Ready(Some(Err(_))) => self.finish(),
            Poll::Pending => {}
        }
        polled
    }

    fn is_end_stream(&self) -> bool {
        self.inner.is_end_stream()
    }

    fn size_hint(&self) -> hyper::body::SizeHint {
        self.inner.size_hint()
    }
}

impl Drop for Audited {
    fn drop(&mut self) {
        self.finish();
    }
}

/// **`face` with the hole in front of it**: a gRPC request ([`is_grpc`]) is the
/// hole's, everything else goes to `face` untouched. The hole keeps the face's
/// rules itself ([`Hole::forward`]: the console's own names, the reach, the
/// methods), so a callback-only name carries no control plane.
pub fn with_hole(face: Router, hole: Hole) -> Router {
    Router::new()
        .fallback_service(face)
        .layer(axum::middleware::from_fn(
            move |req: axum::extract::Request, next: axum::middleware::Next| {
                let hole = hole.clone();
                async move {
                    if is_grpc(&req) {
                        hole.forward(req).await
                    } else {
                        next.run(req).await
                    }
                }
            },
        ))
}

/// How often the hole looks at the verdict again.
pub const FOLLOW_EVERY: Duration = Duration::from_secs(2);

/// **Follow the primary and the site's feed for the life of the process**: the hole's target is
/// the primary's control plane over the tunnel (`<overlay>:50051`, what the console dials) and its
/// reach is the site's [`FEED_CLI_HOLE`], both looked at again every [`FOLLOW_EVERY`]. A jera job
/// (`front-hole`; RA1-15, A32-14): listed, a panicked look an ALERT line and a FAILED row, stopped
/// at the clean stop. Returns at once.
pub fn follow(hole: Hole, vm: Arc<crate::vm::FrontVm>) {
    let say: gunnar_common::jobs::Say = Arc::new(|l: &str| tracing::error!("front: {l}"));
    let started = gunnar_common::jobs::start("front-hole", FOLLOW_EVERY, say, {
        let mut said: Option<Reach> = None;
        move || {
            if let Some(reply) = vm.latest() {
                hole.retarget(target_of(&reply));
                let reach = reach_of(&reply.feed);
                if said != Some(reach) {
                    hole.set_reach(reach);
                    tracing::info!(reach = ?reach, "front: the CLI's gRPC hole serves the console's own names, for {}", if reach == Reach::Public { "any client address (the site's feed says public)" } else { "the estate's own network and the operator's overlay only" });
                    said = Some(reach);
                }
            }
            gunnar_common::jobs::Round::Again
        }
    });
    if let Err(why) = started {
        tracing::error!(
            "front: the CLI hole's follow job could not start, so the hole has no target: {why}"
        );
    }
}

/// Pure: where the hole goes, by the verdict — the console's control target.
pub fn target_of(reply: &nordisk_pairing::join::JoinReply) -> Option<String> {
    crate::console::target(reply).map(|t| t.control)
}

#[cfg(test)]
mod tests;