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;