aegis-tool 0.3.3

Aegis SSH client and managed host agent.
Documentation
use std::net::IpAddr;
use std::path::Path;

use anyhow::{Context, Result, anyhow, bail};

use crate::api::AuthenticatedApiClient;
use crate::ui;
use crate::{
    cli::{SshArgs, TransferArgs},
    config::{CachedHost, CachedNetworkConfig, SHARED_CACHE_PATH, load_cached_network},
    wireguard_endpoint::preferred_wireguard_endpoint_ip,
};

use super::{host_list, list};

pub(super) struct AvailableHostLookup<'a> {
    api_base_override: Option<&'a str>,
    network: &'a str,
    host: &'a str,
    allow_pending: bool,
    refresh: bool,
}

impl<'a> AvailableHostLookup<'a> {
    pub(super) fn new(
        api_base_override: Option<&'a str>,
        network: &'a str,
        host: &'a str,
        allow_pending: bool,
    ) -> Self {
        Self {
            api_base_override,
            network,
            host,
            allow_pending,
            refresh: true,
        }
    }

    pub(super) fn refresh(mut self, refresh: bool) -> Self {
        self.refresh = refresh;
        self
    }

    pub(super) fn load(&self) -> Result<CachedHost> {
        if !crate::api::uses_local_agent(self.api_base_override)? {
            return self.load_from_api();
        }
        if let Some(host) = load_cached_network(Path::new(SHARED_CACHE_PATH), self.network)?
            .and_then(|network| {
                network
                    .hosts
                    .into_iter()
                    .find(|host| host.matches(self.host))
            })
        {
            if host_is_visible(&host, self.allow_pending)
                && host_offers_ssh(&host)
                && host_has_pinned_public_key(&host)
            {
                return Ok(host);
            }
            if !host_is_visible(&host, self.allow_pending) {
                if self.refresh {
                    ui::warn(&format!(
                        "host `{}` is pending in the cache; refreshing host inventory",
                        self.host
                    ));
                } else {
                    bail!(
                        "host `{}` is pending in the cache; rerun with `--allow-pending` to use it or `--refresh` to sync host inventory",
                        self.host
                    );
                }
            } else if !host_offers_ssh(&host) {
                bail!("host `{}` offers no SSH access", self.host);
            } else {
                if self.refresh {
                    ui::warn(&format!(
                        "host `{}` is cached without a pinned host public key; refreshing host inventory",
                        self.host
                    ));
                } else {
                    bail!(
                        "host `{}` is cached without a pinned host public key; rerun with `--refresh` to sync host inventory",
                        self.host
                    );
                }
            }
        } else if !self.refresh {
            bail!(
                "host `{}` is not cached in network `{}`; run `aegis list --network {} --refresh` or rerun with `--refresh`",
                self.host,
                self.network,
                self.network,
            );
        } else {
            ui::warn(&format!(
                "host `{}` is not cached in network `{}`; refreshing host inventory",
                self.host, self.network
            ));
        }
        self.load_from_api()
    }

    fn load_from_api(&self) -> Result<CachedHost> {
        match list::refresh_host_cache_for_network(self.api_base_override, self.network)?
            .into_iter()
            .find(|host| host.matches(self.host))
        {
            Some(host) if !host_is_visible(&host, self.allow_pending) => {
                bail!(
                    "host `{}` is pending; rerun with `--allow-pending` to use it",
                    self.host
                )
            }
            Some(host) if !host_offers_ssh(&host) => {
                bail!("host `{}` offers no SSH access", self.host)
            }
            Some(host) if !host_has_pinned_public_key(&host) => {
                bail!("host `{}` is missing a pinned host public key", self.host)
            }
            Some(host) => Ok(host),
            None => bail!(
                "unknown host `{}` in network `{}` after refresh",
                self.host,
                self.network
            ),
        }
    }
}

pub(super) fn load_network_config(
    api: &mut AuthenticatedApiClient,
    network: &str,
) -> Result<CachedNetworkConfig> {
    if crate::api::uses_local_agent(Some(api.api_base()))?
        && let Some(cached) = load_cached_network(Path::new(SHARED_CACHE_PATH), network)?
    {
        return Ok(cached.config);
    }
    api.get_networks()?
        .networks
        .remove(network)
        .ok_or_else(|| anyhow!("unknown aegis network `{network}`"))
}

pub(super) fn logical_dns_host(alias: &str, suffix: Option<&str>) -> Option<String> {
    let suffix = suffix?.trim().trim_start_matches('.').trim_end_matches('.');
    (!suffix.is_empty()).then(|| format!("{alias}.{suffix}"))
}

pub(super) fn host_is_visible(host: &CachedHost, allow_pending: bool) -> bool {
    allow_pending || !host.pending
}

pub(super) fn host_offers_ssh(host: &CachedHost) -> bool {
    host.ssh.as_ref().and_then(|ssh| ssh.port).is_some()
}

pub(super) fn host_has_pinned_public_key(host: &CachedHost) -> bool {
    host.ssh
        .as_ref()
        .and_then(|ssh| ssh.public_key.as_deref())
        .is_some()
}

pub(super) fn host_ssh_port(host: &CachedHost) -> Result<u16> {
    host.ssh
        .as_ref()
        .and_then(|ssh| ssh.port)
        .ok_or_else(|| anyhow!("host `{}` offers no SSH access", host.alias()))
}

pub(super) fn validate_login_principal(value: &str) -> Result<()> {
    crate::principal_grants::validate_login_principal(value)
}

pub(super) fn host_registration_ssh_port(host: &CachedHost) -> Option<u16> {
    host.ssh.as_ref().and_then(|ssh| ssh.port)
}

pub(super) fn host_registration_ssh_public_key(host: &CachedHost) -> Option<&str> {
    host.ssh.as_ref().and_then(|ssh| ssh.public_key.as_deref())
}

pub(super) fn filter_visible_hosts(hosts: Vec<CachedHost>, allow_pending: bool) -> Vec<CachedHost> {
    let mut hosts = hosts
        .into_iter()
        .filter(|host| host_is_visible(host, allow_pending))
        .collect::<Vec<_>>();
    sort_hosts_for_list(&mut hosts);
    hosts
}

pub(super) fn sort_hosts_for_list(hosts: &mut [CachedHost]) {
    hosts.sort_by(|left, right| {
        host_list::list_host_kind(left)
            .cmp(&host_list::list_host_kind(right))
            .then_with(|| left.alias().cmp(right.alias()))
    });
}

#[derive(Clone, Copy)]
enum AddressFamily {
    Auto,
    Ipv4,
    Ipv6,
}

trait ConnectOptions {
    fn use_endpoint(&self) -> bool;
    fn address_family(&self) -> AddressFamily;
    fn ipv4_flag(&self) -> &'static str;
    fn ipv6_flag(&self) -> &'static str;
}

impl ConnectOptions for SshArgs {
    fn use_endpoint(&self) -> bool {
        self.use_endpoint
    }

    fn address_family(&self) -> AddressFamily {
        requested_address_family(self.ipv4, self.ipv6)
    }

    fn ipv4_flag(&self) -> &'static str {
        "--ipv4"
    }

    fn ipv6_flag(&self) -> &'static str {
        "--ipv6"
    }
}

impl ConnectOptions for TransferArgs {
    fn use_endpoint(&self) -> bool {
        self.use_endpoint
    }

    fn address_family(&self) -> AddressFamily {
        requested_address_family(self.ipv4, self.ipv6)
    }

    fn ipv4_flag(&self) -> &'static str {
        "--ipv4"
    }

    fn ipv6_flag(&self) -> &'static str {
        "--ipv6"
    }
}

fn requested_address_family(ipv4: bool, ipv6: bool) -> AddressFamily {
    if ipv4 {
        AddressFamily::Ipv4
    } else if ipv6 {
        AddressFamily::Ipv6
    } else {
        AddressFamily::Auto
    }
}

pub(super) fn resolve_ssh_connect_host(host: &CachedHost, args: &SshArgs) -> Result<String> {
    resolve_connect_host_for(host, args)
}

pub(super) fn resolve_transfer_connect_host(
    host: &CachedHost,
    args: &TransferArgs,
) -> Result<String> {
    resolve_connect_host_for(host, args)
}

pub(super) fn ssh_mesh_route_targets(host: &CachedHost, args: &SshArgs) -> Result<Vec<IpAddr>> {
    mesh_route_targets(host, args)
}

pub(super) fn transfer_mesh_route_targets(
    host: &CachedHost,
    args: &TransferArgs,
) -> Result<Vec<IpAddr>> {
    mesh_route_targets(host, args)
}

fn mesh_route_targets(host: &CachedHost, options: &impl ConnectOptions) -> Result<Vec<IpAddr>> {
    if options.use_endpoint() {
        return Ok(Vec::new());
    }
    let value = resolve_connect_host_for(host, options)?;
    Ok(vec![value.parse::<IpAddr>().with_context(|| {
        format!(
            "host `{}` has an invalid mesh address `{value}`",
            host.alias()
        )
    })?])
}

fn resolve_connect_host_for(host: &CachedHost, options: &impl ConnectOptions) -> Result<String> {
    if options.use_endpoint() {
        return preferred_wireguard_endpoint_ip(host.wireguard_endpoints()).ok_or_else(|| {
            anyhow!(
                "host `{}` has no published WireGuard endpoint",
                host.alias()
            )
        });
    }
    match options.address_family() {
        AddressFamily::Auto => resolve_mesh_connect_host(host),
        AddressFamily::Ipv4 => mesh_ipv4_connect_host(host).ok_or_else(|| {
            anyhow!(
                "host `{}` has no mesh IPv4 address and cannot be reached with `{}`",
                host.alias(),
                options.ipv4_flag()
            )
        }),
        AddressFamily::Ipv6 => mesh_ipv6_connect_host(host).ok_or_else(|| {
            anyhow!(
                "host `{}` has no mesh IPv6 address and cannot be reached with `{}`",
                host.alias(),
                options.ipv6_flag()
            )
        }),
    }
}

pub(super) fn resolve_mesh_connect_host(host: &CachedHost) -> Result<String> {
    mesh_ipv4_connect_host(host)
        .or_else(|| mesh_ipv6_connect_host(host))
        .ok_or_else(|| {
            anyhow!(
                "host `{}` has no mesh address and cannot be reached on the mesh",
                host.alias()
            )
        })
}

pub(super) fn mesh_ipv4_connect_host(host: &CachedHost) -> Option<String> {
    host.internal_ipv4()
        .map(str::to_string)
        .or_else(|| host.wireguard_ipv4().map(str::to_string))
}

pub(super) fn mesh_ipv6_connect_host(host: &CachedHost) -> Option<String> {
    host.internal_ipv6()
        .map(str::to_string)
        .or_else(|| host.wireguard_ipv6().map(str::to_string))
}

pub(super) fn resolve_connect_host(host: &CachedHost) -> Result<String> {
    resolve_mesh_connect_host(host)
}

pub(super) fn host_wireguard_identity(host: &CachedHost) -> Result<(String, String)> {
    Ok((
        host.wireguard_ipv4()
            .map(str::to_string)
            .ok_or_else(|| anyhow!("host `{}` is missing wireguard IPv4", host.alias()))?,
        host.wireguard_ipv6()
            .map(str::to_string)
            .ok_or_else(|| anyhow!("host `{}` is missing wireguard IPv6", host.alias()))?,
    ))
}