holger-cli/src/conn.rs
//! **The one connection**: to `--at`, and nowhere else.
//!
//! A Holger is an airgap product, so its client dials exactly the server it is told to
//! and nothing beside it: no update check, no telemetry, no second host. `--at` is a
//! gRPC door (`host:port`, `http://host:port`, or `https://host:port` for TLS); the
//! credential rides on every call as `authorization: Bearer …`, exactly as the console
//! attaches it — over TLS, or in clear only to this machine by name ([`bearer_may_travel`]).
//! Every call has a deadline ([`CALL_DEADLINE`]) and a stream an idle limit ([`STREAM_IDLE`]).
use std::path::Path;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint};
use crate::error::CliError;
/// The door asked for when `--at` and `$HOLGER_AT` are both absent: this box's own.
pub const DEFAULT_AT: &str = "http://127.0.0.1:50051";
/// Pure: `--at` as a URL. A bare `host:port` is TLS (`https://`, lane HOL-NCSC), except this
/// machine by name (`localhost`, a 127/8 or `::1` literal), the one place a Holger serves
/// plain gRPC without `insecure: true`; plain gRPC elsewhere is asked for by `http://`. A URL
/// with a path, a query or a scheme other than `http`/`https` is refused, because it is not a
/// gRPC door.
pub fn door_url(at: &str) -> Result<String, CliError> {
let at = at.trim();
if at.is_empty() {
return Err(CliError::InvalidArgs(
"--at is empty: name the server's gRPC door (host:port)".into(),
));
}
let (scheme, rest) = match at.split_once("://") {
Some(named) => named,
None if loopback_by_name(&host_of(&format!("x://{}", at.trim_end_matches('/')))) => {
("http", at)
}
None => ("https", at),
};
let rest = rest.trim_end_matches('/');
let url = format!("{scheme}://{rest}");
if scheme != "http" && scheme != "https" {
return Err(CliError::InvalidArgs(format!(
"--at {at}: a gRPC door is http:// or https://, not {scheme}://"
)));
}
if rest.is_empty()
|| rest.contains('/')
|| rest.contains('?')
|| rest.contains('#')
|| rest.contains('@')
{
return Err(CliError::InvalidArgs(format!(
"--at {at}: name the door alone (host:port), with no path, query or user"
)));
}
Ok(url)
}
/// Pure: the host `--at` names (what the TLS name is checked against).
pub fn host_of(url: &str) -> String {
let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
match rest.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default().to_owned(),
None => rest
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(rest)
.to_owned(),
}
}
/// Pure: **may a credential travel to this door?** Over `https://` always; over plain gRPC
/// only to this machine, by NAME (`localhost`, a 127/8 or `::1` literal; nothing is resolved):
/// the address dialled (`--connect` where given, else `--at`'s host). Anywhere else a bearer
/// in clear text is a credential handed to everybody on the way (RA2-4).
pub fn bearer_may_travel(url: &str, connect: Option<&str>) -> bool {
if url.starts_with("https://") {
return true;
}
let dialled = match connect {
Some(addr) => host_of(&format!("http://{}", addr.trim())),
None => host_of(url),
};
loopback_by_name(&dialled)
}
/// Pure: `host` names this machine, by its name alone: `localhost`, or a loopback literal.
pub fn loopback_by_name(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
/// How long one call may take before it is given up (`DEADLINE_EXCEEDED`, said by name).
pub const CALL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120);
/// How long a stream (an export, a migration watch) may stay silent before it is given up.
pub const STREAM_IDLE: std::time::Duration = std::time::Duration::from_secs(120);
/// **One call within `deadline`**, or [`CliError::Unreachable`] (`holger-cli-4`) naming the
/// verb and the deadline.
pub async fn within<T>(
verb: &str,
deadline: std::time::Duration,
call: impl std::future::Future<Output = T>,
) -> Result<T, CliError> {
tokio::time::timeout(deadline, call).await.map_err(|_| {
CliError::Unreachable(format!(
"{verb}: the server did not answer within {} s; given up",
deadline.as_secs()
))
})
}
/// The endpoint for `at`: TLS for `https://`, trusting `ca` (a PEM file) when given, else
/// the public web roots compiled in (no system store is read and nothing is fetched).
///
/// `connect` (`--connect host:port`, as curl's `--connect-to`): dial THAT address instead of
/// resolving `--at`'s host, while the name stays `--at`'s (the TLS name and the
/// `:authority` a front routes by). Still one connection, to the address the person named.
pub fn endpoint(at: &str, ca: Option<&Path>, connect: Option<&str>) -> Result<Endpoint, CliError> {
let url = door_url(at)?;
let dial_url = match connect {
None => url.clone(),
Some(addr) => {
let addr = addr.trim();
if addr.is_empty() || addr.contains('/') || addr.contains("://") || !addr.contains(':')
{
return Err(CliError::InvalidArgs(format!(
"--connect {addr}: name an address as host:port"
)));
}
let scheme = url.split_once("://").map(|(s, _)| s).unwrap_or("http");
format!("{scheme}://{addr}")
}
};
let mut ep = Endpoint::from_shared(dial_url)
.map_err(|e| CliError::InvalidArgs(format!("--at {at}: {e}")))?;
if connect.is_some() {
let origin: tonic::codegen::http::Uri = url
.parse()
.map_err(|e| CliError::InvalidArgs(format!("--at {at}: {e}")))?;
ep = ep.origin(origin);
}
if url.starts_with("https://") {
let mut tls = ClientTlsConfig::new().domain_name(host_of(&url));
match ca {
Some(path) => {
let path = crate::error::existing(path)?;
let pem = std::fs::read(&path)
.map_err(|e| CliError::Local(format!("reading {}: {e}", path.display())))?;
tls = tls.ca_certificate(Certificate::from_pem(pem));
}
None => tls = tls.with_webpki_roots(),
}
ep = ep
.tls_config(tls)
.map_err(|e| CliError::InvalidArgs(format!("--at {at}: {e}")))?;
} else if ca.is_some() {
return Err(CliError::InvalidArgs(format!(
"--ca is for an https:// door, and --at {at} is plain"
)));
}
// A dead peer under a long stream is found by the HTTP/2 ping, not by waiting for ever.
Ok(ep
.connect_timeout(std::time::Duration::from_secs(10))
.http2_keep_alive_interval(std::time::Duration::from_secs(30))
.keep_alive_timeout(std::time::Duration::from_secs(20))
.keep_alive_while_idle(true))
}
/// Attaches the person's credential to every call, as the console's interceptor does.
#[derive(Clone)]
pub struct Bearer(Option<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>);
impl Bearer {
pub fn new(token: Option<&str>) -> Result<Self, CliError> {
match token {
None => Ok(Bearer(None)),
Some(t) => format!("Bearer {}", t.trim())
.parse()
.map(|v| Bearer(Some(v)))
.map_err(|_| {
CliError::Local("the credential holds characters a header cannot carry".into())
}),
}
}
}
impl tonic::service::Interceptor for Bearer {
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
if let Some(v) = &self.0 {
req.metadata_mut().insert("authorization", v.clone());
}
Ok(req)
}
}
/// The channel every service client of one command shares.
pub type Authed = InterceptedService<Channel, Bearer>;
/// Dial `--at` once (eagerly, so a server that is not there says so before anything else).
pub async fn dial(
at: &str,
ca: Option<&Path>,
connect: Option<&str>,
token: Option<&str>,
) -> Result<Authed, CliError> {
// A credential is never sent in clear to another host (RA2-4): refused before dialling.
if token.is_some() && !bearer_may_travel(&door_url(at)?, connect) {
return Err(CliError::InvalidArgs(format!(
"--at {at}: the credential would travel in clear (plain gRPC) to a host that is not this machine; use https:// (with --ca for your own CA), or a loopback address"
)));
}
let ep = endpoint(at, ca, connect)?;
let channel = ep.connect().await.map_err(|e| {
let mut why = e.to_string();
let mut src = std::error::Error::source(&e);
while let Some(s) = src {
why = format!("{why}: {s}");
src = s.source();
}
CliError::Unreachable(format!("the gRPC door at {at} was not reached: {why}"))
})?;
Ok(InterceptedService::new(channel, Bearer::new(token)?))
}
#[cfg(test)]
mod tests {
use super::door_url as door_url_of;
/// ★ HOL-NCSC: **a bare `host:port` is TLS.** Plain gRPC is named (`http://`), or is this
/// machine by name, the one place a Holger serves without TLS.
#[test]
fn a_bare_door_is_https_unless_it_is_this_machine() {
for at in ["holger.example:50051", "10.0.0.7:50051", "[2001:db8::1]:1"] {
assert_eq!(door_url_of(at).unwrap(), format!("https://{at}"), "{at}");
}
for at in ["127.0.0.1:50051", "localhost:1", "[::1]:1"] {
assert_eq!(door_url_of(at).unwrap(), format!("http://{at}"), "{at}");
}
assert_eq!(
door_url_of("http://holger.example:1").unwrap(),
"http://holger.example:1",
"a named scheme is kept"
);
}
use super::*;
#[test]
fn at_names_a_door_and_only_a_door() {
assert_eq!(
door_url("holger.lan:50051").unwrap(),
"https://holger.lan:50051",
"a bare door is TLS (HOL-NCSC)"
);
assert_eq!(
door_url("http://holger.lan:50051").unwrap(),
"http://holger.lan:50051"
);
assert_eq!(
door_url("https://holger.example:443/").unwrap(),
"https://holger.example:443"
);
assert_eq!(
door_url("http://[::1]:50051").unwrap(),
"http://[::1]:50051"
);
for bad in [
"",
"ftp://h:1",
"https://h:1/grpc",
"http://h:1?x=1",
"http://user@h:1",
"https://",
] {
let e = door_url(bad).unwrap_err();
assert_eq!(e.code().as_deref(), Some("holger-cli-1"), "{bad}: {e}");
}
}
#[test]
fn the_tls_name_is_the_host_at_names() {
assert_eq!(host_of("https://holger.example:443"), "holger.example");
assert_eq!(host_of("https://[::1]:443"), "::1");
assert_eq!(host_of("http://h"), "h");
}
#[test]
fn a_ca_file_is_for_tls_and_must_exist() {
assert_eq!(
endpoint("http://h:1", Some(Path::new("/x")), None)
.unwrap_err()
.code()
.as_deref(),
Some("holger-cli-1")
);
assert_eq!(
endpoint("https://h:1", Some(Path::new("/no/such/ca.pem")), None)
.unwrap_err()
.code()
.as_deref(),
Some("holger-cli-2")
);
assert!(
endpoint("https://h:1", None, None).is_ok(),
"the compiled-in roots"
);
assert_eq!(
endpoint("h:1", None, None).unwrap().uri().to_string(),
"https://h:1/"
);
assert_eq!(
endpoint("127.0.0.1:1", None, None)
.unwrap()
.uri()
.to_string(),
"http://127.0.0.1:1/"
);
}
#[test]
fn connect_dials_the_named_address_and_keeps_the_name_of_at() {
let ep = endpoint(
"https://repo.holger.test:8443",
None,
Some("127.0.0.1:9443"),
)
.unwrap();
assert_eq!(
ep.uri().to_string(),
"https://127.0.0.1:9443/",
"the one address dialled"
);
for bad in ["", "127.0.0.1", "http://x:1", "h:1/path"] {
assert_eq!(
endpoint("h:1", None, Some(bad))
.unwrap_err()
.code()
.as_deref(),
Some("holger-cli-1"),
"{bad:?}"
);
}
}
#[test]
fn the_credential_rides_as_the_consoles_bearer() {
use tonic::service::Interceptor as _;
let mut b = Bearer::new(Some(" tok ")).unwrap();
let r = b.call(tonic::Request::new(())).unwrap();
assert_eq!(r.metadata().get("authorization").unwrap(), "Bearer tok");
let mut anon = Bearer::new(None).unwrap();
assert!(anon
.call(tonic::Request::new(()))
.unwrap()
.metadata()
.get("authorization")
.is_none());
assert!(Bearer::new(Some("bad\nline")).is_err());
}
#[tokio::test]
async fn a_door_nobody_listens_on_is_unreachable() {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
drop(l);
let e = dial(&format!("127.0.0.1:{port}"), None, None, None)
.await
.unwrap_err();
assert_eq!(e.code().as_deref(), Some("holger-cli-4"), "{e}");
}
/// ★ RA2-4: **a credential never goes in clear to another host.** A bare `host:port` or an
/// `http://` door that is not this machine (by name) is refused before anything is dialled;
/// TLS, a loopback name, a `--connect` to a loopback address, and a call with no credential
/// are not.
#[test]
fn a_credential_never_goes_in_clear_to_another_host() {
for (at, connect) in [
("http://holger.example:50051", None),
("http://10.0.0.7:50051", None),
("http://[2001:db8::1]:1", None),
("http://127.0.0.1:1", Some("192.0.2.1:1")),
] {
assert!(
!bearer_may_travel(&door_url(at).unwrap(), connect),
"{at} {connect:?}"
);
}
for (at, connect) in [
("https://holger.example:443", None),
("http://127.0.0.1:1", None),
("http://127.9.9.9:1", None),
("http://localhost:1", None),
("http://[::1]:1", None),
("http://holger.example:1", Some("127.0.0.1:9")),
] {
assert!(
bearer_may_travel(&door_url(at).unwrap(), connect),
"{at} {connect:?}"
);
}
}
#[tokio::test]
async fn a_bearer_over_plain_grpc_to_another_host_is_refused_before_dialling() {
let e = dial("http://holger.example:50051", None, None, Some("tok"))
.await
.unwrap_err();
assert_eq!(e.code().as_deref(), Some("holger-cli-1"), "{e}");
assert!(e.to_string().contains("in clear"), "{e}");
// No credential: nothing to protect, the door is dialled (and is not there).
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let anon = dial(&format!("127.0.0.1:{port}"), None, None, None)
.await
.unwrap_err();
assert_eq!(anon.code().as_deref(), Some("holger-cli-4"), "{anon}");
}
/// ★ RA2-4: **every call has a deadline.** A call that never answers is given up and said
/// by name, never a hang.
#[tokio::test(start_paused = true)]
async fn a_call_that_never_answers_is_given_up() {
let hang = std::future::pending::<()>();
let r = tokio::time::timeout(
std::time::Duration::from_secs(3600),
within("health", CALL_DEADLINE, hang),
)
.await;
let e = r.expect("the call was given up").unwrap_err();
assert_eq!(e.code().as_deref(), Some("holger-cli-4"), "{e}");
assert!(
e.to_string().contains("health") && e.to_string().contains("did not answer within"),
"{e}"
);
assert_eq!(
within("health", CALL_DEADLINE, async { 7 }).await.unwrap(),
7
);
}
}