gitaway 0.1.0-alpha.1

Reach your self-hosted git server from anywhere, with a tunnel only gitaway uses
Documentation
//! Running a network command away from home, through the profile's tunnel.
//!
//! ```text
//!  git:// remote? ──────────────────────────────▶ error: use ssh:// or https://
//!  take the profile's lock (wait if it's busy)
//!  load the device key
//!  open the tunnel ─▶ start the proxy ─▶ run git as a child, pointed at the proxy
//!  wait for git (Ctrl+C reaches git too; a SIGTERM to gitaway is passed on)
//!  git failed and the tunnel knows why? ────────▶ say why
//!  close the proxy and the tunnel, exit with git's code
//! ```
//!
//! Unlike at home, gitaway can't hand over to git with `exec`: it must stay
//! alive to run the tunnel, so it spawns git and waits.

use std::ffi::OsString;
use std::process::ExitStatus;
use std::sync::Arc;
use std::time::Duration;

use crate::config::lock::ProfileLock;
use crate::config::name::ProfileName;
use crate::config::store::Store;
use crate::error::Error;
use crate::git::remote_url::Scheme;
use crate::git::repo::Repo;
use crate::git::runner::{Git, exit_code, spawn_error};
use crate::keys::store::{DeviceSecrets, KeyStore};
use crate::proxy::Proxy;
use crate::proxy::git_env::{self, SshSettings};
use crate::transport::{self, Transport};

use super::network::AwayPlan;

/// How long to wait for background tasks to stop after git has finished.
pub(crate) const SHUTDOWN_GRACE: Duration = Duration::from_secs(1);

/// Runs git through the tunnel and returns git's exit code.
pub fn run(plan: AwayPlan) -> Result<i32, Error> {
    if plan.remote.scheme == Scheme::Git {
        return Err(Error::msg(format!(
            "git:// remotes can't go through the tunnel. Use the server's ssh:// or https:// \
             URL instead (`git remote set-url <remote> <url>`). Profile `{}`",
            plan.name
        )));
    }

    let store = super::open_store()?;
    let name = &plan.name;
    let (_lock, secrets) = take_tunnel(&store, name)?;

    let repo = Repo::new(Git::new(&plan.global_options));
    let core_ssh_command = repo.ssh_command()?;
    let exe = std::env::current_exe()
        .map_err(|e| Error::msg(format!("couldn't find gitaway's own path: {e}")))?;

    let runtime = runtime()?;
    let result = runtime.block_on(async {
        let transport: Arc<dyn Transport> =
            Arc::from(transport::open(name.as_str(), &plan.profile, secrets).await?);
        let proxy = Proxy::start(transport, plan.profile.hosts.clone())
            .await
            .map_err(|e| Error::msg(format!("couldn't start the local proxy: {e}")))?;

        let git_ssh_command = std::env::var("GIT_SSH_COMMAND").ok();
        let git_ssh = std::env::var("GIT_SSH").ok();
        let ssh = SshSettings {
            git_ssh_command: git_ssh_command.as_deref(),
            core_ssh_command: core_ssh_command.as_deref(),
            git_ssh: git_ssh.as_deref(),
        };
        let existing_count = std::env::var("GIT_CONFIG_COUNT").ok();
        let env = git_env::build(
            proxy.address(),
            proxy.token(),
            &exe,
            ssh,
            existing_count.as_deref(),
        )
        .map_err(Error::msg)?;

        let status = run_git(&plan.args, &env).await?;
        let code = exit_code(status);
        if code != 0 {
            for failure in proxy.failures() {
                eprintln!("gitaway: {failure}");
            }
        }
        Ok(code)
    });
    // Everything spawned on the runtime (proxy, tunnel) stops here.
    runtime.shutdown_timeout(SHUTDOWN_GRACE);
    result
}

/// Takes the profile's lock (waiting if another command holds it, since the
/// server keeps one session per device key) and loads the device key. Hold
/// the lock until the tunnel is closed.
pub(crate) fn take_tunnel(
    store: &Store,
    name: &ProfileName,
) -> Result<(ProfileLock, DeviceSecrets), Error> {
    let lock = ProfileLock::acquire(store.dir(), name, || {
        eprintln!(
            "gitaway: another gitaway command is using profile `{name}`'s tunnel; waiting for it to finish…"
        );
    })
    .map_err(|e| Error::msg(e.to_string()))?;

    let keys = KeyStore::from_env(store.dir().clone())?;
    let secrets = keys.load(name)?.ok_or_else(|| {
        Error::msg(format!(
            "profile `{name}` has no device key, so it can't open the tunnel. \
             Create one with `gitaway key rotate {name}` and give the server its public key"
        ))
    })?;
    Ok((lock, secrets))
}

/// Starts the small runtime the tunnel and proxy run on.
pub(crate) fn runtime() -> Result<tokio::runtime::Runtime, Error> {
    tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .map_err(|e| Error::msg(format!("couldn't start the tunnel's runtime: {e}")))
}

/// Spawns git with the proxy's environment and waits for it.
async fn run_git(args: &[OsString], env: &git_env::GitEnv) -> Result<ExitStatus, Error> {
    let mut command = Git::new(&[]).command(args);
    for (name, value) in &env.set {
        command.env(name, value);
    }
    for name in &env.remove {
        command.env_remove(name);
    }
    let mut child = tokio::process::Command::from(command)
        .spawn()
        .map_err(spawn_error)?;
    wait(&mut child)
        .await
        .map_err(|e| Error::msg(format!("lost track of git: {e}")))
}

/// Waits for git. Ctrl+C, Ctrl+\ and a closed terminal reach git directly
/// (it's in the same process group), so gitaway just keeps waiting for it
/// to finish. A SIGTERM sent to gitaway alone is passed on to git.
#[cfg(unix)]
async fn wait(child: &mut tokio::process::Child) -> std::io::Result<ExitStatus> {
    use tokio::signal::unix::{SignalKind, signal};
    let mut interrupt = signal(SignalKind::interrupt())?;
    let mut quit = signal(SignalKind::quit())?;
    let mut hangup = signal(SignalKind::hangup())?;
    let mut terminate = signal(SignalKind::terminate())?;
    loop {
        tokio::select! {
            status = child.wait() => return status,
            _ = interrupt.recv() => {}
            _ = quit.recv() => {}
            _ = hangup.recv() => {}
            _ = terminate.recv() => forward_terminate(child),
        }
    }
}

#[cfg(unix)]
fn forward_terminate(child: &tokio::process::Child) {
    use rustix::process::{Pid, Signal, kill_process};
    let pid = child
        .id()
        .and_then(|id| i32::try_from(id).ok())
        .and_then(Pid::from_raw);
    if let Some(pid) = pid {
        // If git has already exited, there's nothing to tell.
        let _ = kill_process(pid, Signal::TERM);
    }
}

/// Waits for git. Ctrl+C reaches git directly, so gitaway keeps waiting.
#[cfg(not(unix))]
async fn wait(child: &mut tokio::process::Child) -> std::io::Result<ExitStatus> {
    loop {
        tokio::select! {
            status = child.wait() => return status,
            _ = tokio::signal::ctrl_c() => {}
        }
    }
}