gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **How gunnar reaches a company's identity provider** — the one
//! [`gunnar_grpc::sso::Egress`]: the blocking `ureq` this crate already links
//! (the mirror fetch's), with no redirect followed, no proxy from the
//! environment, ten seconds a call, a megabyte an answer, and every URL put to
//! [`gunnar_grpc::sso::url_allowed`] again here — an account admin typed it.
//!
//! **The name is resolved HERE, every answer checked, and the connection made
//! to exactly those answers** ([`GuardedResolver`]). `url_allowed` sees only
//! the URL's text, so `https://idp.attacker.example/` that resolves to
//! `127.0.0.1`, `10.x`, `169.254.169.254` (the cloud's metadata service) or
//! `[::1]` would have passed it. ureq's own resolver is replaced, not
//! wrapped: the addresses checked are the addresses ureq connects to — no
//! second lookup, so a DNS answer that changes between the check and the
//! connect (rebinding) has nothing to change.
//!
//! The answer's status is returned, never turned into an error: an OAuth
//! error body on a 400 carries the words the admin needs.
//!
//! **A box behind its front** (the lean estate, A19 row 14: no public IPv4, fed the front's
//! egress door, `nordisk_pairing::egress::proxy`) makes each call through that door: the URL
//! is still put to `url_allowed` here, the door names the host and refuses any address inside
//! a private, loopback, link-local or shared network ([`DoorOnly`] resolves the door alone).
//! An IdP on the operator's own internal network is then out of reach by design: the door is
//! a way out, never in.

use std::io::Read as _;
use std::net::{SocketAddr, ToSocketAddrs as _};
use std::sync::Arc;
use std::time::Duration;

use std::sync::RwLock;

use gunnar_grpc::sso::{addresses_allowed_in, url_allowed_in, Egress, PrivateNets, MAX_IDP_BODY};

const TIMEOUT: Duration = Duration::from_secs(10);

/// A name lookup: `(host, port)` → its addresses. The system's by default
/// ([`system_lookup`]); a test hands in its own (no real DNS in a test).
pub type Lookup = Arc<dyn Fn(&str, u16) -> std::io::Result<Vec<SocketAddr>> + Send + Sync>;

/// The system resolver (`getaddrinfo`, through `ToSocketAddrs`).
pub fn system_lookup() -> Lookup {
    Arc::new(|host: &str, port: u16| (host, port).to_socket_addrs().map(|a| a.collect()))
}

/// **ureq's resolver, replaced**: the lookup, then EVERY answer checked
/// ([`addresses_allowed`]), then exactly those answers handed to the
/// connector. One lookup per connection; nothing re-resolves.
#[derive(Clone)]
pub struct GuardedResolver {
    lookup: Lookup,
    allow_loopback: bool,
    /// The operator's internal networks an IdP may live on, read per lookup.
    nets: Arc<RwLock<PrivateNets>>,
}

impl std::fmt::Debug for GuardedResolver {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GuardedResolver")
            .field("allow_loopback", &self.allow_loopback)
            .finish_non_exhaustive()
    }
}

impl GuardedResolver {
    pub fn new(lookup: Lookup, allow_loopback: bool) -> Self {
        Self::with_nets(lookup, allow_loopback, Arc::default())
    }

    /// The same, admitting the operator's internal networks (`nets`, shared
    /// with `gunnar_grpc::sso::SsoState`: a re-fed list takes effect live).
    pub fn with_nets(lookup: Lookup, allow_loopback: bool, nets: Arc<RwLock<PrivateNets>>) -> Self {
        GuardedResolver {
            lookup,
            allow_loopback,
            nets,
        }
    }

    /// **Resolve `host` and keep the answer only if every address is one an
    /// IdP may have.** What the connection is then made to.
    pub fn resolve_checked(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, String> {
        // A bracketed IPv6 literal is an address, not a name.
        let bare = host.trim_start_matches('[').trim_end_matches(']');
        let addrs =
            (self.lookup)(bare, port).map_err(|e| format!("{bare} does not resolve: {e}"))?;
        let nets = self.nets.read().unwrap_or_else(|p| p.into_inner()).clone();
        addresses_allowed_in(bare, &addrs, self.allow_loopback, &nets)?;
        Ok(addrs)
    }
}

impl ureq::unversioned::resolver::Resolver for GuardedResolver {
    fn resolve(
        &self,
        uri: &ureq::http::Uri,
        _config: &ureq::config::Config,
        _timeout: ureq::unversioned::transport::NextTimeout,
    ) -> Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
        let host = uri
            .host()
            .ok_or_else(|| ureq::Error::BadUri(format!("{uri} names no host")))?;
        let port = uri.port_u16().unwrap_or(match uri.scheme_str() {
            Some("http") => 80,
            _ => 443,
        });
        let addrs = self.resolve_checked(host, port).map_err(|why| {
            ureq::Error::Io(std::io::Error::new(
                std::io::ErrorKind::PermissionDenied,
                why,
            ))
        })?;
        let mut out = self.empty();
        for addr in addrs.into_iter().take(16) {
            out.push(addr);
        }
        Ok(out)
    }
}

/// **The resolver of a call through the front's egress door**: ureq resolves the door's
/// address alone (the door names the destination), and nothing else.
#[derive(Debug, Clone, Copy)]
pub struct DoorOnly(pub SocketAddr);

impl ureq::unversioned::resolver::Resolver for DoorOnly {
    fn resolve(
        &self,
        uri: &ureq::http::Uri,
        _config: &ureq::config::Config,
        _timeout: ureq::unversioned::transport::NextTimeout,
    ) -> Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
        let at = (
            uri.host().map(|h| {
                h.trim_start_matches('[')
                    .trim_end_matches(']')
                    .parse::<std::net::IpAddr>()
                    .ok()
            }),
            uri.port_u16(),
        );
        if at != (Some(Some(self.0.ip())), Some(self.0.port())) {
            return Err(ureq::Error::Io(std::io::Error::new(
                std::io::ErrorKind::PermissionDenied,
                format!(
                    "{uri}: a box behind its front resolves its egress door alone ({})",
                    self.0
                ),
            )));
        }
        let mut out = self.empty();
        out.push(self.0);
        Ok(out)
    }
}

/// The `ureq` egress.
pub struct UreqEgress {
    agent: ureq::Agent,
    /// The resolver every agent of this egress connects through (the account
    /// CA's per-call agent too: the guard is the same).
    resolver: GuardedResolver,
    allow_loopback_http: bool,
    nets: Arc<RwLock<PrivateNets>>,
}

/// The one configuration every call of this egress is made with; `proxy` is the front's
/// egress door of a box behind its front, else none (never one from the environment).
fn config(tls: Option<ureq::tls::TlsConfig>, proxy: Option<ureq::Proxy>) -> ureq::config::Config {
    let builder = ureq::Agent::config_builder()
        .redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
        .max_redirects(0)
        .timeout_connect(Some(TIMEOUT))
        .timeout_global(Some(TIMEOUT))
        .proxy(proxy)
        .http_status_as_error(false);
    match tls {
        Some(tls) => builder.tls_config(tls).build(),
        None => builder.build(),
    }
}

impl UreqEgress {
    /// `allow_loopback_http`: the operator's test door
    /// (`GUNNAR_SSO_ALLOW_LOOPBACK_HTTP=1`) — an IdP on this box's loopback.
    pub fn new(allow_loopback_http: bool) -> Self {
        Self::with_lookup(allow_loopback_http, system_lookup())
    }

    /// The same, resolving names with `lookup` (a test's).
    pub fn with_lookup(allow_loopback_http: bool, lookup: Lookup) -> Self {
        Self::with_nets(allow_loopback_http, lookup, Arc::default())
    }

    /// The same, admitting the operator's internal networks (`nets`, the
    /// handle `gunnar_grpc::sso::SsoState::private_nets_handle` hands out: a
    /// re-fed list takes effect on the next call).
    pub fn with_nets(
        allow_loopback_http: bool,
        lookup: Lookup,
        nets: Arc<RwLock<PrivateNets>>,
    ) -> Self {
        let resolver = GuardedResolver::with_nets(lookup, allow_loopback_http, Arc::clone(&nets));
        let agent = ureq::Agent::with_parts(
            config(None, None),
            ureq::unversioned::transport::DefaultConnector::default(),
            resolver.clone(),
        );
        UreqEgress {
            agent,
            resolver,
            allow_loopback_http,
            nets,
        }
    }

    fn nets(&self) -> PrivateNets {
        self.nets.read().unwrap_or_else(|p| p.into_inner()).clone()
    }

    /// **An agent for ONE call that trusts `ca` beside the public roots** —
    /// an account's own IdP CA. Built per call and dropped after it: the CA is
    /// never a root of any other call, of this account or another.
    fn agent_trusting(&self, ca: &[Vec<u8>]) -> ureq::Agent {
        let tls = Self::tls_trusting(ca);
        ureq::Agent::with_parts(
            config(Some(tls), None),
            ureq::unversioned::transport::DefaultConnector::default(),
            self.resolver.clone(),
        )
    }

    /// The public roots and `ca` beside them.
    fn tls_trusting(ca: &[Vec<u8>]) -> ureq::tls::TlsConfig {
        let mut roots: Vec<ureq::tls::Certificate<'static>> =
            webpki_root_certs::TLS_SERVER_ROOT_CERTS
                .iter()
                .map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned())
                .collect();
        roots.extend(
            ca.iter()
                .map(|der| ureq::tls::Certificate::from_der(der).to_owned()),
        );
        ureq::tls::TlsConfig::builder()
            .root_certs(ureq::tls::RootCerts::Specific(Arc::new(roots)))
            .build()
    }

    /// **The agent of one call of a box behind its front**: through the fed egress door
    /// (`nordisk_pairing::egress::proxy`), resolving the door alone ([`DoorOnly`]); `None` on a
    /// box with its own way out.
    fn through_the_door(ca: &[Vec<u8>]) -> Option<ureq::Agent> {
        let door = nordisk_pairing::egress::proxy()?;
        let at = nordisk_pairing::egress::door_addr(door).ok()?;
        let proxy = ureq::Proxy::new(door).ok()?;
        let tls = (!ca.is_empty()).then(|| Self::tls_trusting(ca));
        Some(ureq::Agent::with_parts(
            config(tls, Some(proxy)),
            ureq::unversioned::transport::DefaultConnector::default(),
            DoorOnly(at),
        ))
    }

    fn get_with(&self, agent: &ureq::Agent, url: &str) -> Result<(u16, String), String> {
        url_allowed_in(url, self.allow_loopback_http, &self.nets())?;
        let response = agent
            .get(url)
            .header(
                "Accept",
                "application/json, application/samlmetadata+xml, application/xml, text/xml",
            )
            .call()
            .map_err(|e| format!("{e}"))?;
        Self::read(response)
    }

    fn post_with(
        &self,
        agent: &ureq::Agent,
        url: &str,
        authorization: &str,
        body: &str,
    ) -> Result<(u16, String), String> {
        url_allowed_in(url, self.allow_loopback_http, &self.nets())?;
        let mut request = agent
            .post(url)
            .header("Accept", "application/json")
            .header("Content-Type", "application/x-www-form-urlencoded");
        if !authorization.is_empty() {
            request = request.header("Authorization", authorization);
        }
        // `{e}` of a transport error never carries the body (the secret is in
        // the header and the code in the body; neither is in an error string).
        let response = request.send(body).map_err(|e| format!("{e}"))?;
        Self::read(response)
    }

    fn read(response: ureq::http::Response<ureq::Body>) -> Result<(u16, String), String> {
        let status = response.status().as_u16();
        let mut body = Vec::new();
        response
            .into_body()
            .into_reader()
            .take(MAX_IDP_BODY as u64 + 1)
            .read_to_end(&mut body)
            .map_err(|e| format!("reading the answer: {e}"))?;
        if body.len() > MAX_IDP_BODY {
            return Err(format!("the answer is larger than {MAX_IDP_BODY} bytes"));
        }
        Ok((status, String::from_utf8_lossy(&body).into_owned()))
    }
}

impl Egress for UreqEgress {
    fn get(&self, url: &str) -> Result<(u16, String), String> {
        match Self::through_the_door(&[]) {
            Some(agent) => self.get_with(&agent, url),
            None => self.get_with(&self.agent, url),
        }
    }

    fn post_form(
        &self,
        url: &str,
        authorization: &str,
        body: &str,
    ) -> Result<(u16, String), String> {
        match Self::through_the_door(&[]) {
            Some(agent) => self.post_with(&agent, url, authorization, body),
            None => self.post_with(&self.agent, url, authorization, body),
        }
    }

    fn get_trusting(&self, url: &str, ca: &[Vec<u8>]) -> Result<(u16, String), String> {
        if ca.is_empty() {
            return self.get(url);
        }
        match Self::through_the_door(ca) {
            Some(agent) => self.get_with(&agent, url),
            None => self.get_with(&self.agent_trusting(ca), url),
        }
    }

    fn post_form_trusting(
        &self,
        url: &str,
        authorization: &str,
        body: &str,
        ca: &[Vec<u8>],
    ) -> Result<(u16, String), String> {
        if ca.is_empty() {
            return self.post_form(url, authorization, body);
        }
        match Self::through_the_door(ca) {
            Some(agent) => self.post_with(&agent, url, authorization, body),
            None => self.post_with(&self.agent_trusting(ca), url, authorization, body),
        }
    }
}