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,
}
}