michal/tit

Browse tree · Show commit · Download archive

Blob: src/serve.rs

Raw · Blame

use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::time::Duration;

use rand::rng;
use ssh_key::{Algorithm, LineEnding, PrivateKey};
use thiserror::Error;

use crate::account::AccountService;
use crate::auth::{AuthError, SshPublicKey};
use crate::backup::OnlineBackupService;
use crate::config::{Config, ConfigError};
use crate::control::{ControlError, RunningControlServer};
use crate::git::transport::{GitRepositories, RepositoryPathError};
use crate::http::{ListenerReadiness, PublicWebConfig, RunningWebServer, WebError};
use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
use crate::maintenance::MaintenanceGate;
use crate::policy::PolicyError;
use crate::pull_request::{PullRequestError, PullRequestService};
use crate::session::{SessionError, WebLoginService};
use crate::ssh::{AuthorizedSshKeys, LoginApprover, RunningSshServer, SshServerError};
use crate::store::{Store, StoreError};
use crate::telemetry::Telemetry;

const SHUTDOWN_DRAIN_LIMIT: Duration = Duration::from_secs(10);

pub(crate) async fn run(config: &Config) -> Result<(), ServeError> {
    let telemetry = Telemetry::enabled();
    telemetry.lifecycle("server.start", "started");
    let _lock = InstanceLock::acquire(&config.instance_dir)?;
    let database = prepare_database(&config.instance_dir)?;
    let repository_root = prepare_repository_root(&config.instance_dir)?;
    let maintenance = MaintenanceGate::default();
    PullRequestService::new_with_gate(&database, &repository_root, maintenance.clone())
        .recover()?;
    let store = Store::open(&database)?;
    let keys = active_ssh_identities(&store)?;
    let git = GitRepositories::new_managed_authorized_with_gate(
        &repository_root,
        &database,
        maintenance.clone(),
    )?;
    drop(store);

    let accounts = AccountService::new(database.clone());
    let backup = OnlineBackupService::new(
        config.instance_dir.clone(),
        config.config_path.clone(),
        maintenance.clone(),
    );
    let control =
        RunningControlServer::start_with_backup(&config.instance_dir, accounts.clone(), backup)?;
    let authorized_keys = AuthorizedSshKeys::for_accounts(keys);
    let readiness = ListenerReadiness::default();

    let (http_clone_base, ssh_clone_base) = clone_bases(config)?;
    let login = WebLoginService::new(database, &config.public_url)?;
    let login_approver: LoginApprover =
        std::sync::Arc::new(move |secret, username, fingerprint| {
            login
                .approve(secret, username, fingerprint)
                .map(|approved| (approved.origin, approved.username))
                .map_err(|_| ())
        });
    let host_key = load_or_create_host_key(&config.instance_dir)?;
    let reload_keys = {
        let authorized_keys = authorized_keys.clone();
        std::sync::Arc::new(move |accounts: &AccountService| {
            let store = Store::open(accounts.database())?;
            let active = active_ssh_identities(&store)?;
            authorized_keys.replace_accounts(active);
            Ok(())
        })
    };
    let web = RunningWebServer::start_public_with_key_reload(
        config.http_listen,
        PublicWebConfig {
            instance_dir: config.instance_dir.clone(),
            http_clone_base,
            ssh_clone_base,
            max_request_bytes: usize::try_from(config.max_request_bytes)
                .map_err(|_| ServeError::RequestLimit)?,
            max_connections: usize::try_from(config.max_connections)
                .map_err(|_| ServeError::ConnectionLimit)?,
        },
        reload_keys,
        readiness.clone(),
        maintenance,
        telemetry.clone(),
    )
    .await?;
    let ssh = match RunningSshServer::start_with_dynamic_keys(
        config.ssh_listen,
        authorized_keys,
        git,
        host_key,
        usize::try_from(config.max_connections).map_err(|_| ServeError::ConnectionLimit)?,
        telemetry.clone(),
        login_approver,
    )
    .await
    {
        Ok(ssh) => ssh,
        Err(error) => {
            control.shutdown().await?;
            web.shutdown().await?;
            return Err(error.into());
        }
    };
    readiness.mark_ready();
    telemetry.lifecycle("server.readiness", "ready");

    let signal = shutdown_signal().await;
    readiness.mark_stopping();
    telemetry.lifecycle("server.shutdown", "started");
    let (ssh_result, web_result, control_result) = tokio::join!(
        ssh.shutdown_bounded(SHUTDOWN_DRAIN_LIMIT),
        web.shutdown_bounded(SHUTDOWN_DRAIN_LIMIT),
        control.shutdown_bounded(SHUTDOWN_DRAIN_LIMIT)
    );
    let ssh_drained = ssh_result?;
    let web_drained = web_result?;
    let control_drained = control_result?;
    let drained = ssh_drained && web_drained && control_drained;
    if !drained {
        eprintln!("tit: the shutdown drain limit expired; unfinished connections were canceled");
    }
    telemetry.lifecycle(
        "server.shutdown",
        if drained { "completed" } else { "bounded" },
    );
    signal.map_err(ServeError::Signal)
}

fn active_ssh_identities(store: &Store) -> Result<Vec<(String, SshPublicKey)>, StoreError> {
    store
        .active_ssh_identities()?
        .into_iter()
        .map(|identity| {
            let key = SshPublicKey::parse(&identity.canonical_key)
                .map_err(|error| StoreError::Integrity(error.to_string()))?;
            if key.fingerprint() != identity.fingerprint {
                return Err(StoreError::Integrity(format!(
                    "SSH key fingerprint does not match for account {}",
                    identity.username
                )));
            }
            Ok((identity.username, key))
        })
        .collect()
}

const HOST_KEY_FILE: &str = "ssh_host_ed25519_key";
const MAX_HOST_KEY_BYTES: u64 = 64 * 1024;

fn load_or_create_host_key(instance_dir: &Path) -> Result<PrivateKey, ServeError> {
    let path = instance_dir.join(HOST_KEY_FILE);
    match fs::symlink_metadata(&path) {
        Ok(metadata) => {
            if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
                return Err(ServeError::InvalidHostKeyFile(path));
            }
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return create_host_key(&path);
        }
        Err(source) => return Err(ServeError::HostKeyIo { path, source }),
    }
    read_host_key(&path)
}

pub(crate) fn check_host_key(instance_dir: &Path) -> Result<(), ServeError> {
    let path = instance_dir.join(HOST_KEY_FILE);
    let metadata = fs::symlink_metadata(&path).map_err(|source| ServeError::HostKeyIo {
        path: path.clone(),
        source,
    })?;
    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
        return Err(ServeError::InvalidHostKeyFile(path));
    }
    read_host_key(&path)?;
    Ok(())
}

fn create_host_key(path: &Path) -> Result<PrivateKey, ServeError> {
    let key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
    let encoded = key.to_openssh(LineEnding::LF)?;
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(path)
        .map_err(|source| ServeError::HostKeyIo {
            path: path.to_owned(),
            source,
        })?;
    file.write_all(encoded.as_bytes())
        .and_then(|()| file.sync_all())
        .map_err(|source| ServeError::HostKeyIo {
            path: path.to_owned(),
            source,
        })?;
    Ok(key)
}

fn read_host_key(path: &Path) -> Result<PrivateKey, ServeError> {
    let file = File::open(path).map_err(|source| ServeError::HostKeyIo {
        path: path.to_owned(),
        source,
    })?;
    let metadata = file.metadata().map_err(|source| ServeError::HostKeyIo {
        path: path.to_owned(),
        source,
    })?;
    let mode = metadata.permissions().mode() & 0o777;
    if !metadata.file_type().is_file() {
        return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
    }
    if mode & 0o077 != 0 {
        return Err(ServeError::HostKeyPermissions {
            path: path.to_owned(),
            mode,
        });
    }
    if metadata.len() > MAX_HOST_KEY_BYTES {
        return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
    }
    let capacity = usize::try_from(metadata.len())
        .map_err(|_| ServeError::InvalidHostKeyFile(path.to_owned()))?;
    let mut encoded = Vec::with_capacity(capacity);
    file.take(MAX_HOST_KEY_BYTES + 1)
        .read_to_end(&mut encoded)
        .map_err(|source| ServeError::HostKeyIo {
            path: path.to_owned(),
            source,
        })?;
    if encoded.len() as u64 > MAX_HOST_KEY_BYTES {
        return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
    }
    let key = PrivateKey::from_openssh(&encoded)?;
    if key.algorithm() != Algorithm::Ed25519 || key.is_encrypted() {
        return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
    }
    Ok(key)
}

#[cfg(unix)]
async fn shutdown_signal() -> std::io::Result<()> {
    let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
    tokio::select! {
        result = tokio::signal::ctrl_c() => result,
        _ = terminate.recv() => Ok(()),
    }
}

#[cfg(not(unix))]
async fn shutdown_signal() -> std::io::Result<()> {
    tokio::signal::ctrl_c().await
}

fn clone_bases(config: &Config) -> Result<(String, String), ConfigError> {
    let (http, ssh) = config.clone_urls("owner", "repository")?;
    let suffix = "/owner/repository";
    Ok((
        http.as_str()
            .strip_suffix(suffix)
            .expect("the HTTP clone URL contains the supplied path")
            .to_owned(),
        ssh.as_str()
            .strip_suffix(suffix)
            .expect("the SSH clone URL contains the supplied path")
            .to_owned(),
    ))
}

#[derive(Debug, Error)]
pub(crate) enum ServeError {
    #[error(transparent)]
    Instance(#[from] InstanceError),
    #[error(transparent)]
    Store(#[from] StoreError),
    #[error(transparent)]
    Policy(#[from] PolicyError),
    #[error(transparent)]
    PullRequest(#[from] PullRequestError),
    #[error(transparent)]
    Authentication(#[from] AuthError),
    #[error(transparent)]
    Repository(#[from] RepositoryPathError),
    #[error(transparent)]
    Configuration(#[from] ConfigError),
    #[error(transparent)]
    Web(#[from] WebError),
    #[error(transparent)]
    Ssh(#[from] SshServerError),
    #[error(transparent)]
    Session(#[from] SessionError),
    #[error(transparent)]
    Control(#[from] ControlError),
    #[error("cannot wait for a shutdown signal: {0}")]
    Signal(std::io::Error),
    #[error("cannot read or write SSH host key {path}: {source}")]
    HostKeyIo {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("SSH host key path is not a valid Ed25519 private-key file: {0}")]
    InvalidHostKeyFile(PathBuf),
    #[error("SSH host key permissions for {path} are {mode:o}, expected 600 or more restrictive")]
    HostKeyPermissions { path: PathBuf, mode: u32 },
    #[error(transparent)]
    HostKey(#[from] ssh_key::Error),
    #[error("the HTTP request limit does not fit this platform")]
    RequestLimit,
    #[error("the connection limit does not fit this platform")]
    ConnectionLimit,
}

#[cfg(test)]
mod tests {
    use std::os::unix::fs::symlink;

    use tempfile::TempDir;

    use super::*;

    #[test]
    fn persists_a_private_host_key_and_rejects_unsafe_replacements() {
        let directory = TempDir::new().expect("create a host-key directory");
        let first = load_or_create_host_key(directory.path()).expect("create a host key");
        let path = directory.path().join(HOST_KEY_FILE);
        assert_eq!(
            fs::metadata(&path)
                .expect("inspect the host key")
                .permissions()
                .mode()
                & 0o777,
            0o600
        );
        let second = load_or_create_host_key(directory.path()).expect("read the host key");
        assert_eq!(first.public_key(), second.public_key());

        let mut permissions = fs::metadata(&path)
            .expect("inspect the host key")
            .permissions();
        permissions.set_mode(0o644);
        fs::set_permissions(&path, permissions).expect("make the host key unsafe");
        assert!(matches!(
            load_or_create_host_key(directory.path()),
            Err(ServeError::HostKeyPermissions { mode: 0o644, .. })
        ));

        fs::remove_file(&path).expect("remove the host key");
        symlink(directory.path().join("target"), &path).expect("replace the host key with a link");
        assert!(matches!(
            load_or_create_host_key(directory.path()),
            Err(ServeError::InvalidHostKeyFile(candidate)) if candidate == path
        ));
    }
}