🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Git over SSH.
//!
//! A git client reaches a repository by opening an SSH connection and asking
//! to run one command: `git-upload-pack '/owner/slug.git'` to fetch,
//! `git-receive-pack '/owner/slug.git'` to push. There is no shell in the
//! picture - the command is parsed by [`crate::repositories::parse_git_command`],
//! which accepts exactly those two words and nothing else - so the server runs
//! `git` as a child process on a single channel and shuttles its standard
//! streams back and forth.
//!
//! Connections are authenticated by public key against the keys users have
//! registered, not by password, and never anonymously. Authorisation is per
//! repository: a public one may be fetched by any key that authenticated, a
//! private one only by its owner's, and pushing is the owner's alone.
//!
//! [`handler`]: the per-connection session that carries that out.
//! [`server_config`]: the configuration, and the host key.

use std::io::Write;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use miette::{IntoDiagnostic, WrapErr, bail};
use russh::keys::ssh_key;
use russh::keys::{PrivateKey, load_secret_key};
use russh::server::{Config, Server};
use tokio::net::TcpListener;
use tokio::sync::{RwLock, Semaphore};
use tracing::{Instrument, Span, error, info, warn};

use crate::{AppState, SshThrottleConfig, config::*};

pub mod handler;
mod tui;

pub use handler::SessionHandler;

/// KINDA DEPRECIATED How to reach the SSH server, for the clone URLs the webui shows.
/// TODO: Remove fully
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
    pub host: String,
    pub port: u16,
}

impl Settings {
    /// The URL a git client clones, fetches from, or pushes to.
    pub fn clone_url(&self, owner: &str, slug: &str) -> String {
        let ssh_user = std::env::var(SSH_USER_VAR_KEY).unwrap_or(DEFAULT_SSH_USER.to_string());
        let public_host = std::env::var(SSH_HOST_VAR_KEY).unwrap_or(DEFAULT_SSH_HOST.to_string());
        format!("ssh://{ssh_user}@{public_host}/{owner}/{slug}.git")
    }
}

/// The socket to listen on: [`BIND_ADDR_VAR`] when set, [`DEFAULT_BIND_ADDR`]
/// otherwise, and an unset or malformed value is an error rather than a silent
/// fallback.
pub fn bind_addr() -> miette::Result<SocketAddr> {
    match std::env::var(SSH_BIND_ADDR_VAR_KEY) {
        Ok(bind) => Ok(bind
            .parse()
            .into_diagnostic()
            .wrap_err("Could not parse {bind} as as a valid ssh bind address")?),
        Err(std::env::VarError::NotPresent) => Ok(DEFAULT_SSH_BIND_ADDR
            .parse()
            .expect("Expected the DEFAULT_SSH_BIND_ADDR to be a valid bind address?!")),
        Err(e) => bail!("Could not decode value of envvar SSH_BIND_ADDR_VAR_KEY: {e}"),
    }
}

/// The [`Settings`] for a server bound to `bind_addr`.
///
/// The host is [`HOST_VAR`] when set. Otherwise a loopback bind advertises
/// `localhost`, since the address itself is not something outside the machine
/// can dial, and anything else advertises the address it is bound to.
pub fn settings(bind_addr: SocketAddr) -> Settings {
    Settings {
        host: advertised_host(bind_addr, std::env::var(SSH_HOST_VAR_KEY).ok()),
        port: bind_addr.port(),
    }
}

/// The host a clone URL should name for a server bound to `bind_addr`.
///
/// `configured` is [`HOST_VAR`], when it is set and not empty. Without it, an
/// address that no client can dial reads as `localhost`: loopback is only this
/// machine, and `0.0.0.0` or `::` are not something to connect to at all. A
/// deployment whose SSH server is reachable under a name says so with
/// `FROGUE_SSH_HOST`; this only decides what to fall back to.
fn advertised_host(bind_addr: SocketAddr, configured: Option<String>) -> String {
    let ip = bind_addr.ip();

    match configured {
        Some(host) if !host.is_empty() => host,
        _ if ip.is_loopback() || ip.is_unspecified() => "localhost".to_string(),
        _ => ip.to_string(),
    }
}

/// Where the host key lives: [`HOST_KEY_VAR`] when set, next to the repository
/// root otherwise.
///
/// Beside the root rather than inside it, so the key is not mistaken for an
/// owner's directory and so it survives as long as the data directory does.
fn host_key_path(repo_root: &Path) -> PathBuf {
    match std::env::var_os(SSH_HOST_KEY_VAR_KEY) {
        Some(path) => PathBuf::from(path),
        None => repo_root
            .parent()
            .unwrap_or(repo_root)
            .join(DEFAULT_SSH_HOST_KEY_FILE),
    }
}

/// The server's host key, generated the first time it is needed.
///
/// Generated rather than shipped so that two deployments are not the same host
/// to a client that has already seen one of them, and written `0600` so only
/// the server can read the private half.
#[tracing::instrument(level = "debug", skip_all)]
fn load_host_key(path: &Path) -> miette::Result<PrivateKey> {
    if path.exists() {
        let key = load_secret_key(path, None)
            .into_diagnostic()
            .wrap_err_with(|| format!("loading the SSH host key at {}", path.display()))?;
        info!(path = %path.display(), "using the existing SSH host key");
        return Ok(key);
    }

    let key = PrivateKey::random(&mut rand::rng(), ssh_key::Algorithm::Ed25519)
        .into_diagnostic()
        .wrap_err("generating an SSH host key")?;

    let openssh = key
        .to_openssh(ssh_key::LineEnding::LF)
        .into_diagnostic()
        .wrap_err("encoding the generated SSH host key")?;

    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .into_diagnostic()
            .wrap_err_with(|| format!("creating {}", parent.display()))?;
    }

    write_host_key(path, openssh.as_bytes())?;

    info!(path = %path.display(), "generated a new SSH host key");
    Ok(key)
}

/// Writes the private half of a host key, readable by its owner alone.
fn write_host_key(path: &Path, key: &[u8]) -> miette::Result<()> {
    #[cfg(unix)]
    use std::os::unix::fs::OpenOptionsExt;

    let mut file = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(path)
        .into_diagnostic()
        .wrap_err_with(|| format!("creating the SSH host key at {}", path.display()))?;

    file.write_all(key)
        .into_diagnostic()
        .wrap_err_with(|| format!("writing the SSH host key at {}", path.display()))
}

/// Builds the server configuration, loading or generating the host key.
pub fn server_config(repo_root: &Path) -> miette::Result<Arc<Config>> {
    let mut config = Config::default();
    config.keys.push(load_host_key(&host_key_path(repo_root))?);
    Ok(Arc::new(config))
}

/// Accepts SSH connections and gives each its own [`handler::Handler`].
struct GitServer {
    config: Arc<Config>,
    state: Arc<RwLock<AppState>>,
    /// Semaphore limiting total concurrent connections.
    connection_semaphore: Arc<Semaphore>,
    /// Semaphore limiting concurrent guest (unregistered) connections.
    guest_semaphore: Arc<Semaphore>,
    /// SSH throttle configuration.
    throttle_config: SshThrottleConfig,
}

impl russh::server::Server for GitServer {
    type Handler = SessionHandler;

    fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
        SessionHandler::new(
            self.state.clone(),
            peer_addr,
            self.guest_semaphore.clone(),
            self.throttle_config.clone(),
        )
    }

    fn handle_session_error(&mut self, error: <Self::Handler as russh::server::Handler>::Error) {
        // One connection out of many; not fatal, but invisible from the
        // client's side unless it is written down.
        error!(%error, "an SSH connection failed");
    }
}

/// The root span for one SSH connection.
///
/// Everything the connection goes on to do - authenticating, authorising a
/// command, running `git` and shuttling its streams - nests under this, so a
/// clone or a push reads as a single trace rather than a scatter of events.
///
/// The name is how a trace is recognised as SSH when it is read back, and
/// `network.transport` says the same in the vocabulary a collector filters on:
/// a query for either turns up every SSH trace, and the web traces lack both.
fn connection_span(peer: Option<SocketAddr>) -> Span {
    let client = peer.map_or_else(|| "unknown".to_string(), |addr| addr.to_string());

    tracing::info_span!(
        "ssh.connection",
        network.transport = "ssh",
        client.address = %client,
        user.name = tracing::field::Empty,
    )
}

/// Serves git over SSH on `listener`, one thread and one runtime of its own.
///
/// The listener is bound by the caller so that a port already in use is
/// reported before a thread exists to complain from inside.
pub fn serve(
    listener: std::net::TcpListener,
    config: Arc<Config>,
    state: Arc<RwLock<AppState>>,
    throttle_config: SshThrottleConfig,
) -> miette::Result<()> {
    std::thread::Builder::new()
        .name("frogue-ssh".to_string())
        .spawn(move || run(config, listener, state, throttle_config))
        .into_diagnostic()
        .wrap_err("starting the SSH server thread")?;

    Ok(())
}

/// Drives connections until the listener stops accepting.
///
/// Its own multi-threaded runtime: the SSH server is not part of the web
/// server's event loop, so a client that stalls mid-fetch cannot hold up a
/// request handler.
fn run(
    config: Arc<Config>,
    listener: std::net::TcpListener,
    state: Arc<RwLock<AppState>>,
    throttle_config: SshThrottleConfig,
) {
    let runtime = match tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
    {
        Ok(runtime) => runtime,
        Err(error) => {
            error!(%error, "the SSH server could not build its runtime");
            return;
        }
    };

    runtime.block_on(async move {
        let listener = match TcpListener::from_std(listener) {
            Ok(listener) => listener,
            Err(error) => {
                error!(%error, "the SSH server could not take over its listener");
                return;
            }
        };

        // Create semaphores for connection throttling
        let connection_semaphore = Arc::new(Semaphore::new(throttle_config.max_connections));
        let guest_semaphore = Arc::new(Semaphore::new(throttle_config.max_guests));

        let mut server = GitServer {
            config,
            state,
            connection_semaphore,
            guest_semaphore,
            throttle_config,
        };

        loop {
            match listener.accept().await {
                Ok((stream, peer_addr)) => {
                    // Try to acquire a connection permit before spawning
                    let permit = match server.connection_semaphore.clone().try_acquire_owned() {
                        Ok(permit) => permit,
                        Err(_) => {
                            warn!(
                                ?peer_addr,
                                "rejecting SSH connection: too many concurrent connections"
                            );
                            continue;
                        }
                    };

                    let handler = server.new_client(Some(peer_addr));
                    // The whole connection is entered in its own span, so the
                    // handler's work nests under it without every method
                    // having to thread a span through by hand.
                    let span = handler.connection_span();
                    let config = server.config.clone();

                    // Each connection runs to completion on its own; a slow or
                    // stalled client only ever holds up itself.
                    tokio::spawn(
                        async move {
                            // Hold the permit for the lifetime of this task
                            let _permit = permit;

                            match russh::server::run_stream(config, stream, handler).await {
                                Ok(session) => {
                                    if let Err(error) = session.await {
                                        debug_connection_error(peer_addr, &error);
                                    }
                                }
                                Err(error) => debug_connection_error(peer_addr, &error),
                            }
                        }
                        .instrument(span),
                    );
                }
                Err(error) => {
                    error!(%error, "the SSH server could not accept a connection");
                }
            }
        }
    });
}

/// Writes down why a connection ended, at `debug` rather than `error`: a
/// client that disconnects mid-clone is ordinary, not a fault.
fn debug_connection_error(peer_addr: SocketAddr, error: &impl std::fmt::Display) {
    tracing::debug!(%peer_addr, %error, "an SSH connection ended");
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_bind_addr_is_loopback() {
        let addr: SocketAddr = DEFAULT_SSH_BIND_ADDR.parse().expect("valid socket address");

        assert!(addr.ip().is_loopback(), "{addr} should be loopback only");
        assert_eq!(addr.port(), 2222);
    }

    #[test]
    fn clone_url_addresses_the_owner_and_slug() {
        let settings = Settings {
            host: "localhost".to_string(),
            port: 2222,
        };

        assert_eq!(
            settings.clone_url("alice", "notes"),
            "ssh://git@localhost:2222/alice/notes.git"
        );
    }

    #[test]
    fn the_advertised_host_is_never_one_a_client_cannot_dial() {
        let address = |ip: &str| SocketAddr::new(ip.parse().expect("valid IP"), 2222);

        // Loopback is only this machine; a wildcard bind is not an address to
        // connect to. Neither is worth putting in a clone URL.
        assert_eq!(advertised_host(address("127.0.0.1"), None), "localhost");
        assert_eq!(advertised_host(address("0.0.0.0"), None), "localhost");
        assert_eq!(advertised_host(address("::"), None), "localhost");

        // A named address is the one to dial.
        assert_eq!(advertised_host(address("203.0.113.9"), None), "203.0.113.9");

        // A configured host wins, unless it is empty, which is not a host.
        assert_eq!(
            advertised_host(address("0.0.0.0"), Some("forge.example".to_string())),
            "forge.example"
        );
        assert_eq!(
            advertised_host(address("0.0.0.0"), Some(String::new())),
            "localhost"
        );
    }
}