railwayapp 4.61.1

Interact with Railway via CLI
use std::{
    io::IsTerminal,
    path::{Path, PathBuf},
    sync::Arc,
};

use anyhow::{Context, Result, bail};
use russh::keys::{Algorithm, HashAlg, PrivateKeyWithHashAlg};

use crate::{controllers::ssh_keys::find_local_ssh_keys, telemetry};

pub(super) async fn authenticate<H>(
    session: &mut russh::client::Handle<H>,
    username: &str,
) -> Result<()>
where
    H: russh::client::Handler,
{
    let key_paths = discover_private_key_paths()?;

    if key_paths.is_empty() {
        bail!(
            "No SSH private keys were found in ~/.ssh. Generate one with `ssh-keygen -t ed25519`, then register it with `railway ssh keys add`."
        );
    }

    let mut load_errors = Vec::new();
    let mut attempted_auth = false;

    for path in key_paths {
        let key = match load_secret_key(&path)? {
            Ok(key) => key,
            Err(err) => {
                load_errors.push(format!("{}: {err}", path.display()));
                continue;
            }
        };

        attempted_auth = true;
        let hash_alg = rsa_hash_alg(session, key.algorithm()).await?;
        let auth_result = session
            .authenticate_publickey(
                username,
                PrivateKeyWithHashAlg::new(Arc::new(key), hash_alg),
            )
            .await
            .with_context(|| format!("Failed to authenticate with SSH key {}", path.display()))?;

        if auth_result.success() {
            return Ok(());
        }
    }

    if attempted_auth {
        bail!(
            "SSH authentication failed. Ensure one of your ~/.ssh keys is registered with Railway using `railway ssh keys add`."
        );
    }

    bail!(
        "No loadable SSH private keys were found in ~/.ssh.\n\nFor agents/non-interactive runs, use an unencrypted SSH key registered with Railway via `railway ssh keys add`, or run this command from an interactive terminal where a passphrase can be entered.\n\nSkipped keys:\n{}",
        load_errors.join("\n")
    );
}

fn load_secret_key(path: &Path) -> Result<Result<russh::keys::PrivateKey, anyhow::Error>> {
    match russh::keys::load_secret_key(path, None) {
        Ok(key) => Ok(Ok(key)),
        Err(err) if telemetry::is_agent() || !std::io::stdin().is_terminal() => {
            Ok(Err(anyhow::anyhow!(
                "{err}. If this key is encrypted, agents cannot enter SSH key passphrases. Add an unencrypted key with `railway ssh keys add --key {}` or run from an interactive terminal.",
                path.display()
            )))
        }
        Err(_err) => {
            let passphrase =
                inquire::Password::new(&format!("Enter passphrase for SSH key {}", path.display()))
                    .without_confirmation()
                    .with_render_config(crate::commands::Configs::get_render_config())
                    .prompt()
                    .context("Failed to prompt for SSH key passphrase")?;

            Ok(russh::keys::load_secret_key(path, Some(&passphrase))
                .with_context(|| format!("Failed to load SSH key {}", path.display())))
        }
    }
}

fn discover_private_key_paths() -> Result<Vec<PathBuf>> {
    let mut paths = Vec::new();

    for public_key in find_local_ssh_keys()? {
        if let Some(private_key_path) = private_key_path_for_public_key(&public_key.path) {
            if private_key_path.is_file() {
                paths.push(private_key_path);
            }
        }
    }

    Ok(paths)
}

fn private_key_path_for_public_key(public_key_path: &Path) -> Option<PathBuf> {
    let file_name = public_key_path.file_name()?.to_str()?;
    let private_key_file_name = file_name.strip_suffix(".pub")?;
    Some(public_key_path.with_file_name(private_key_file_name))
}

async fn rsa_hash_alg<H>(
    session: &russh::client::Handle<H>,
    algorithm: Algorithm,
) -> Result<Option<HashAlg>>
where
    H: russh::client::Handler,
{
    if !matches!(algorithm, Algorithm::Rsa { .. }) {
        return Ok(None);
    }

    Ok(session
        .best_supported_rsa_hash()
        .await
        .context("Failed to determine server-supported RSA signature algorithm")?
        .flatten())
}