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

nordisk/holger

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
        );
    }
}