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

nordisk/gunnar

crates/gunnar-ssh/tests/common/mod.rs

//! Shared scaffolding for the SSH integration tests.
//!
//! Everything here is a *fixture*, not a fake of the thing under test. The
//! server, the parser, the sandbox, the dispatch, the host-key path **and now
//! the identity table and repository catalog** are all the real ones:
//! `gunnar-core`'s, over `gunnar-store`'s in-memory backend. The only double
//! left is [`BannerTransport`], because `gunnar-wire` is not written yet
//! (the two lookup seams and their doubles are deleted).
//!
//! That is a real gain in what these tests prove. A key now has to be
//! registered with `AuthTable::register_key` from an `authorized_keys` line and
//! then be found again by the fingerprint the *russh* key produces. That is the
//! the `ssh-key` integration, across two semver-incompatible copies, rather
//! than a `BTreeMap` lookup the test itself populated.

#![allow(dead_code)]

use std::sync::Arc;
use std::time::Duration;

use gunnar_core::model::{Grant, Level, PrincipalKind, Visibility};
use gunnar_core::{AuthTable, ControlStore, NewRepo, RepoCatalog, RepoRef};
use gunnar_ssh::seam::BannerTransport;
use gunnar_ssh::{Access, Control, Fingerprint, GunnarSsh, Principal, SshConfig};
use russh::keys::ssh_key::{Algorithm, PrivateKey};
use russh::keys::PrivateKeyWithHashAlg;

/// Mint a throwaway ed25519 key from the OS generator the host key is minted
/// from (`gunnar_core::os_rng`).
pub fn mint_key() -> PrivateKey {
    PrivateKey::random(&mut gunnar_core::os_rng(), Algorithm::Ed25519).expect("mint an ed25519 key")
}

/// The SHA-256 fingerprint of a private key's public half, computed the way the
/// listener computes it: over the wire blob of *russh's* key type.
pub fn fingerprint_of(key: &PrivateKey) -> Fingerprint {
    gunnar_ssh::fingerprint_of_key(key.public_key()).expect("a freshly minted key re-encodes")
}

/// The `authorized_keys` line for a key, as an operator would paste it.
///
/// Written by russh's `ssh-key 0.7.0-rc.10` and parsed by `gunnar-core`'s
/// `ssh-key 0.6.7`. That crossing is the point, not an accident.
pub fn authorized_keys_line(key: &PrivateKey) -> String {
    key.public_key()
        .to_openssh()
        .expect("encode an authorized_keys line")
}

/// A principal for tests.
pub fn principal(id: &str) -> Principal {
    Principal::new(id, id, PrincipalKind::User).expect("a legal principal id")
}

/// `nordisk/osm-katana`, the repository every test uses.
pub fn the_repo() -> RepoRef {
    RepoRef::new("nordisk", "osm-katana").expect("a legitimate path")
}

/// A real control plane over an in-memory control store, kept reachable so a
/// test can corrupt it on purpose.
pub struct Plane {
    pub control: Control,
    pub store: Arc<dyn ControlStore>,
}

impl Plane {
    /// An empty control plane: no principals, no keys, no repositories.
    pub fn empty() -> Self {
        let store = gunnar_core::memory_control_store().expect("control store");
        Plane {
            control: Control::open(Arc::clone(&store)).expect("control plane"),
            store,
        }
    }

    /// Point one control document at bytes that are not a document.
    ///
    /// Through the real compare-and-swap, with the document's current version
    /// as the expectation, so this is a *write another writer could have made*
    /// and not a back door into the store.
    fn corrupt(&self, name: &str, junk: &[u8]) {
        let id = self.store.put_blob(junk).expect("store the junk");
        let current = self.store.read_doc(name).expect("read the document");
        assert!(
            self.store
                .commit_doc(name, current, id)
                .expect("commit the junk"),
            "the compare-and-swap refused; the fixture did not manage to corrupt {name}"
        );
    }

    pub fn auth(&self) -> &AuthTable {
        self.control.auth()
    }

    pub fn catalog(&self) -> &RepoCatalog {
        self.control.catalog()
    }

    /// Register [`the_repo`] in the catalog.
    pub fn with_the_repo(self) -> Self {
        let path = the_repo();
        self.catalog()
            .create_namespace(path.namespace())
            .expect("create namespace");
        self.catalog()
            .create(NewRepo::new(path).visibility(Visibility::Private))
            .expect("create repository");
        self
    }

    /// Add a principal, with no keys and no grants.
    pub fn with_principal(self, id: &str) -> Self {
        self.auth().add_principal(principal(id)).expect("principal");
        self
    }

    /// Register `key` to an existing principal, from its `authorized_keys`
    /// line, the same door an operator or `Access.RegisterKey` uses.
    pub fn with_key(self, id: &str, key: &PrivateKey) -> Self {
        self.auth()
            .register_key(id, &authorized_keys_line(key), None)
            .expect("register key");
        self
    }

    /// Grant `id` `level` on [`the_repo`].
    pub fn with_grant(self, id: &str, level: Access) -> Self {
        self.auth()
            .grant(Grant::repo(id, the_repo(), level))
            .expect("grant");
        self
    }

    /// Grant `id` `level` on a whole namespace.
    pub fn with_namespace_grant(self, id: &str, namespace: &str, level: Level) -> Self {
        self.auth()
            .grant(Grant::namespace(id, namespace, level))
            .expect("grant");
        self
    }

    /// **Break the catalog on purpose**, by pointing its document at something
    /// that is not a catalog.
    ///
    /// This is the red half of [`gunnar_ssh::DispatchError::Store`]: without a
    /// way to make a lookup *fail*, there is no way to see that a storage fault
    /// is reported as a fault rather than as a permission denial.
    pub fn corrupt_the_catalog(&self) {
        self.corrupt(self.catalog().ref_name(), b"not a catalog");
    }

    /// Break the identity table the same way.
    pub fn corrupt_the_auth_table(&self) {
        self.corrupt(self.auth().ref_name(), b"not an identity table");
    }
}

/// The ordinary case: one repository, one principal, one registered key, one
/// grant at `level`.
pub fn plane_with_key(key: &PrivateKey, level: Access) -> Plane {
    Plane::empty()
        .with_the_repo()
        .with_principal("principal-1")
        .with_key("principal-1", key)
        .with_grant("principal-1", level)
}

/// A server with a bound ephemeral loopback port and the banner transport.
pub struct Fixture {
    pub server: gunnar_ssh::BoundServer,
    pub transport: Arc<BannerTransport>,
}

/// Start a server on an ephemeral loopback port over `control`.
pub async fn start(control: Control, grace: Option<Duration>) -> Fixture {
    start_with_transport(control, grace, BannerTransport::new()).await
}

/// The same, with a transport double chosen by the caller.
pub async fn start_with_transport(
    control: Control,
    grace: Option<Duration>,
    transport: BannerTransport,
) -> Fixture {
    let mut config = SshConfig::ephemeral_loopback();
    if let Some(grace) = grace {
        config.exec_grace = Some(grace);
    }
    start_with_config(control, config, transport).await
}

/// The same again, with the whole [`SshConfig`] chosen by the caller. A test of
/// the admission limits needs that, because the shipped defaults
/// (256 / 32) cannot be reached from a test without opening 257 real SSH
/// channels.
pub async fn start_with_config(
    control: Control,
    config: SshConfig,
    transport: BannerTransport,
) -> Fixture {
    let transport = Arc::new(transport);
    let server = GunnarSsh::new(config, control, transport.clone())
        .bind(mint_key())
        .await
        .expect("bind an ephemeral loopback port");
    Fixture { server, transport }
}

/// The ordinary case, started: one key, one principal, one repository, `level`
/// access.
pub async fn start_with_key(key: &PrivateKey, level: Access) -> Fixture {
    start(plane_with_key(key, level).control, None).await
}

/// The same, watching a readiness flag the caller owns (`tests/readiness.rs`).
///
/// The flag is the seam `gunnar-server` publishes the boot window through; a
/// test owns one directly so the window can be held open for as long as an
/// assertion needs instead of being raced against a real disk test.
pub async fn start_with_readiness(
    key: &PrivateKey,
    level: Access,
    ready: Arc<std::sync::atomic::AtomicBool>,
) -> Fixture {
    start_with_readiness_and_config(key, level, ready, SshConfig::ephemeral_loopback()).await
}

/// [`start_with_readiness`] with the whole [`SshConfig`] chosen by the caller.
pub async fn start_with_readiness_and_config(
    key: &PrivateKey,
    level: Access,
    ready: Arc<std::sync::atomic::AtomicBool>,
    config: SshConfig,
) -> Fixture {
    let transport = Arc::new(BannerTransport::new());
    let server = GunnarSsh::new(
        config,
        plane_with_key(key, level).control,
        transport.clone(),
    )
    .with_readiness(ready)
    .bind(mint_key())
    .await
    .expect("bind an ephemeral loopback port");
    Fixture { server, transport }
}

/// A russh client that trusts whatever host key it is given. Host-key
/// verification is the *client's* job and lives in `gunnar-client`; asserting it
/// here would be asserting something this crate does not do.
pub struct TrustingClient;

impl russh::client::Handler for TrustingClient {
    type Error = russh::Error;

    async fn check_server_key(
        &mut self,
        _server_public_key: &russh::keys::PublicKeyOrCertificate,
    ) -> Result<bool, Self::Error> {
        Ok(true)
    }
}

/// Connect and authenticate with `key`. `Ok(false)` means the server refused
/// the key; `Err` means the connection itself failed.
pub async fn authenticate(
    addr: std::net::SocketAddr,
    key: &PrivateKey,
    user: &str,
) -> Result<Option<russh::client::Handle<TrustingClient>>, russh::Error> {
    let config = Arc::new(russh::client::Config {
        inactivity_timeout: Some(Duration::from_secs(15)),
        ..Default::default()
    });
    let mut session = russh::client::connect(config, addr, TrustingClient).await?;
    let result = session
        .authenticate_publickey(
            user,
            PrivateKeyWithHashAlg::new(Arc::new(key.clone()), None),
        )
        .await?;
    Ok(result.success().then_some(session))
}

/// Offer `keys` in order on ONE connection, and report how far the server let
/// the client get.
///
/// `Ok(n)` is the number of offers the server answered — with a refusal, or, on
/// the last one, possibly with an acceptance. `Err` at offer `n` means the
/// server dropped the connection rather than answering, which is what a
/// per-connection attempt limit looks like from the client's side.
///
/// Returns the acceptance too, so a caller can tell "refused every key and
/// stayed up" from "accepted one".
pub async fn offer_keys(
    addr: std::net::SocketAddr,
    keys: &[PrivateKey],
    user: &str,
) -> (usize, bool, Option<russh::Error>) {
    let config = Arc::new(russh::client::Config {
        inactivity_timeout: Some(Duration::from_secs(15)),
        ..Default::default()
    });
    let mut session = match russh::client::connect(config, addr, TrustingClient).await {
        Ok(session) => session,
        Err(err) => return (0, false, Some(err)),
    };
    let mut answered = 0usize;
    for key in keys {
        match session
            .authenticate_publickey(
                user,
                PrivateKeyWithHashAlg::new(Arc::new(key.clone()), None),
            )
            .await
        {
            Ok(result) => {
                answered += 1;
                if result.success() {
                    return (answered, true, None);
                }
            }
            Err(err) => return (answered, false, Some(err)),
        }
    }
    (answered, false, None)
}

/// What the server answered an exec request with.
#[derive(Debug, PartialEq, Eq)]
pub enum ExecOutcome {
    /// `channel_failure`: the request was refused outright.
    Refused,
    /// `channel_success`, plus whatever the server then wrote and the exit
    /// status it reported.
    Accepted {
        /// Everything received on the data band.
        stdout: String,
        /// The reported exit status, if one arrived.
        exit_status: Option<u32>,
    },
}

/// How the server answered a channel `env` request.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum EnvOutcome {
    /// `channel_success`. The variable was allowlisted and kept.
    Accepted,
    /// `channel_failure`: refused, which is the answer for everything that is
    /// not on [`gunnar_ssh::ENV_ALLOWLIST`].
    Refused,
    /// Nothing came back at all before the channel ended. A *distinct* outcome
    /// from `Refused`, because a server that silently ignores an env request
    /// leaves a real client waiting, and "refused" and "ignored" must not be
    /// spelled the same way in a test.
    Unanswered,
}

/// Open a channel, set `variables` on it, exec `command`, and read it out.
///
/// Returns what the server answered each env request with, in order, alongside
/// the exec outcome, so a test can assert on the *refusal* as well as on what
/// the request did afterwards.
pub async fn exec_with_env(
    session: &russh::client::Handle<TrustingClient>,
    variables: &[(&str, &str)],
    command: &str,
) -> Result<(Vec<EnvOutcome>, ExecOutcome), russh::Error> {
    let mut channel = session.channel_open_session().await?;

    let mut env_outcomes = Vec::new();
    for (name, value) in variables {
        channel.set_env(true, *name, *value).await?;
        let mut answer = EnvOutcome::Unanswered;
        while let Some(msg) = channel.wait().await {
            match msg {
                russh::ChannelMsg::Success => {
                    answer = EnvOutcome::Accepted;
                    break;
                }
                russh::ChannelMsg::Failure => {
                    answer = EnvOutcome::Refused;
                    break;
                }
                _ => {}
            }
        }
        env_outcomes.push(answer);
    }

    channel.exec(true, command).await?;
    Ok((env_outcomes, drain(channel).await))
}

/// Open a channel, exec `command`, and read the channel out.
pub async fn exec(
    session: &russh::client::Handle<TrustingClient>,
    command: &str,
) -> Result<ExecOutcome, russh::Error> {
    let channel = session.channel_open_session().await?;
    channel.exec(true, command).await?;
    Ok(drain(channel).await)
}

/// Read a channel to its end, collecting the data band and the exit status.
async fn drain(mut channel: russh::Channel<russh::client::Msg>) -> ExecOutcome {
    let mut stdout = Vec::new();
    let mut exit_status = None;
    let mut accepted = None;
    while let Some(msg) = channel.wait().await {
        match msg {
            russh::ChannelMsg::Success => accepted = Some(true),
            russh::ChannelMsg::Failure => {
                accepted = Some(false);
                break;
            }
            russh::ChannelMsg::Data { ref data } => stdout.extend_from_slice(data),
            // stderr is appended to the same text: no verb but
            // `git-lfs-authenticate` writes any, and its test reads the words.
            russh::ChannelMsg::ExtendedData { ref data, .. } => stdout.extend_from_slice(data),
            russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
            _ => {}
        }
    }

    match accepted {
        Some(true) => ExecOutcome::Accepted {
            stdout: String::from_utf8_lossy(&stdout).into_owned(),
            exit_status,
        },
        _ => ExecOutcome::Refused,
    }
}