//! 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::{PrivateKey, load_secret_key, ssh_key};
use russh::server::{Config, Server};
use tokio::net::TcpListener;
use tokio::sync::{RwLock, Semaphore};
use tracing::{Instrument, Span, error, info, warn};
use crate::config::*;
use crate::{AppState, SshThrottleConfig};
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"
);
}
}