🐸 Frogue(Fed)

~ololduck/Frogue

Raw

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

use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_web::cookie::SameSite;
use actix_web::{App, HttpServer, middleware, web};
use miette::{IntoDiagnostic, WrapErr};
use migration::MigratorTrait;
use sea_orm::{Database, DatabaseConnection};
use tokio::sync::{RwLock, RwLockReadGuard};
use tracing::info;

use crate::config::*;

/// Contains all the envvar used to configure Frogue.
pub mod config {

    // === Federation ===
    /// The environment variable key that enables public https url configuration and federation. No default value.
    pub const FROGUE_PUBLIC_URL_VAR_KEY: &str = "FROGUE_PUBLIC_URL";

    // === HTTP ===
    /// Environment variable holding the socket to listen on, as `host:port`.
    pub const HTTP_BIND_ADDR_VAR_KEY: &str = "FROGUE_BIND_ADDR";
    pub const DEFAULT_HTTP_BIND_ADDR: &str = "127.0.0.1:6565";
    /// A 64 character-long string used to sign/verify the session cookie. Default: randomly generated, so a server restart will invalidate all current sessions.
    pub const SESSION_SECRET_VAR_KEY: &str = "FROGUE_SESSION_SECRET";
    /// Mainly for testing purpose, this will allow public urls as localhost
    pub const FROGUE_ALLOW_PRIVATE_HOSTS_VAR_KEY: &str = "FROGUE_ALLOW_PRIVATE_HOSTS";

    // === Storage ===
    /// Environment variable holding the directory bare git repositories live under.
    pub const REPO_ROOT_VAR_KEY: &str = "FROGUE_REPO_ROOT";
    pub const DEFAULT_REPO_ROOT: &str = "/tmp/frogue-repos";
    /// Where the database url should be found in the env
    pub const DATABASE_URL_VAR_KEY: &str = "FROGUE_DATABASE_URL";
    pub const DEFAULT_DATABASE_URL: &str = "sqlite:///tmp/frogue-db.sqlite?mode=rwc";

    // === SSH ===t
    /// Environment variable holding the name the SSH server should be advertised
    /// under in clone URLs.
    pub const SSH_HOST_VAR_KEY: &str = "FROGUE_SSH_HOST";
    pub const DEFAULT_SSH_HOST: &str = "localhost:2222";
    /// Environment variable holding the SSH user Frogue should advertise in clone URLs.
    pub const SSH_USER_VAR_KEY: &str = "FROGUE_SSH_USER";
    pub const DEFAULT_SSH_USER: &str = "git";
    /// Environment variable holding the socket the SSH server listens on, as
    /// `host:port`.
    pub const SSH_BIND_ADDR_VAR_KEY: &str = "FROGUE_SSH_BIND_ADDR";
    pub const DEFAULT_SSH_BIND_ADDR: &str = "127.0.0.1:2222";
    /// Environment variable holding the path to the SSH host key.
    pub const SSH_HOST_KEY_VAR_KEY: &str = "FROGUE_SSH_HOST_KEY";
    pub const DEFAULT_SSH_HOST_KEY_FILE: &str = "ssh_host_ed25519_key";
    /// Maximum number of concurrent SSH connections.
    pub const SSH_MAX_CONNECTIONS_VAR_KEY: &str = "FROGUE_SSH_MAX_CONNECTIONS";
    pub const DEFAULT_SSH_MAX_CONNECTIONS: usize = 30;
    /// Maximum number of concurrent guest (unregistered-key) SSH connections.
    pub const SSH_MAX_GUEST_CONNECTIONS_VAR_KEY: &str = "FROGUE_SSH_MAX_GUEST_CONNECTIONS";
    pub const DEFAULT_SSH_MAX_GUEST_CONNECTIONS: usize = 10;
    /// Timeout in seconds for completing the SSH TUI registration form.
    pub const SSH_TUI_TIMEOUT_SECONDS_VAR_KEY: &str = "FROGUE_SSH_TUI_TIMEOUT_SECONDS";
    pub const DEFAULT_SSH_TUI_TIMEOUT_SECONDS: u64 = 60;
    /// Timeout in seconds for a guest that authenticated but never opened the TUI.
    pub const SSH_GUEST_IDLE_TIMEOUT_SECONDS_VAR_KEY: &str =
        "FROGUE_SSH_GUEST_IDLE_TIMEOUT_SECONDS";
    pub const DEFAULT_SSH_GUEST_IDLE_TIMEOUT_SECONDS: u64 = 60;

    // === Registration Mode ===
    /// Environment variable for registration mode. See [`registration::RegistrationMode`]. default: open
    pub const REGISTRATION_MODE_VAR_KEY: &str = "FROGUE_REGISTRATION_MODE";
    /// Environment variable for invite validity in seconds.
    pub const INVITES_VALIDITY_SECONDS_VAR_KEY: &str = "FROGUE_INVITES_VALIDITY_SECONDS";
    /// Default invite validity: 48 hours.
    pub const DEFAULT_INVITES_VALIDITY_SECONDS: i64 = 172800;
    /// Environment variable for invites allowed per day.
    pub const INVITES_ALLOWED_PER_DAY_VAR_KEY: &str = "FROGUE_INVITES_ALLOWED_PER_DAY";
    /// Default invites allowed per day.
    pub const DEFAULT_INVITES_ALLOWED_PER_DAY: u64 = 5;
}

pub mod activitypub;
pub mod api;
pub mod auth;
pub mod comments;
pub mod entities;
pub mod git_hook;
pub mod highlight;
pub mod markdown;
pub mod models;
pub mod patches;
pub mod protocol;
pub mod registration;
pub mod repositories;
pub mod ssh;
pub mod static_files;
pub mod telemetry;
pub mod tickets;
pub mod views;
pub mod webui;

pub struct AppState {
    db: DatabaseConnection,
    /// The directory bare git repositories live under, one subdirectory per
    /// owner.
    repo_root: PathBuf,
    /// How to reach the SSH server, for the clone URLs the webui shows.
    ssh: ssh::Settings,
    /// Canonical public URL for generating links in git hook messages.
    public_url: String,
    /// HTTP client with tracing middleware for outbound ActivityPub requests.
    http_client: reqwest_middleware::ClientWithMiddleware,
}

impl AppState {
    /// The public URL, with trailing slash stripped.
    pub(crate) fn public_url(&self) -> &str {
        &self.public_url
    }

    /// The HTTP client with tracing middleware for outbound requests.
    pub(crate) fn http_client(&self) -> &reqwest_middleware::ClientWithMiddleware {
        &self.http_client
    }
}

/// Waits for a read of the application state.
///
/// The wait gets a span of its own so that a handler held up behind a writer -
/// and the time it spends there - shows up in a trace rather than reading as
/// unexplained handler latency. The reader is handed back so callers can drop
/// it as early as they do with `read()` itself.
#[tracing::instrument(level = "debug", skip_all)]
pub(crate) async fn state_read(state: &RwLock<AppState>) -> RwLockReadGuard<'_, AppState> {
    state.read().await
}

/// The directory to keep bare repositories under, created if missing.
fn repo_root() -> miette::Result<PathBuf> {
    let root = match std::env::var_os(REPO_ROOT_VAR_KEY) {
        Some(raw) => PathBuf::from(raw),
        None => PathBuf::from(DEFAULT_REPO_ROOT),
    };

    std::fs::create_dir_all(&root)
        .into_diagnostic()
        .wrap_err_with(|| format!("creating the repository root {}", root.display()))?;

    Ok(root)
}

/// The socket to bind: [`BIND_ADDR_VAR`] when set, [`DEFAULT_BIND_ADDR`]
/// otherwise. A value that is not a socket address is an error rather than a
/// silent fallback, so a typo in a deployment's environment is loud.
fn bind_addr() -> miette::Result<SocketAddr> {
    let Some(raw) = std::env::var_os(HTTP_BIND_ADDR_VAR_KEY) else {
        return Ok(DEFAULT_HTTP_BIND_ADDR
            .parse()
            .expect("the default bind address is a valid socket address"));
    };

    let raw = raw
        .to_str()
        .ok_or_else(|| miette::miette!("{} is not valid UTF-8", HTTP_BIND_ADDR_VAR_KEY))?;

    raw.parse().into_diagnostic().wrap_err_with(|| {
        format!(
            "{}={raw:?} is not a socket address, expected something like 127.0.0.1:6565",
            HTTP_BIND_ADDR_VAR_KEY
        )
    })
}

/// Parse a `usize` from an env var, returning the default if absent.
/// Malformed values are errors rather than silent fallbacks.
fn parse_usize_env(key: &str, default: usize) -> miette::Result<usize> {
    let Some(raw) = std::env::var_os(key) else {
        return Ok(default);
    };
    let raw = raw
        .to_str()
        .ok_or_else(|| miette::miette!("{key} is not valid UTF-8"))?;
    raw.parse::<usize>()
        .into_diagnostic()
        .wrap_err_with(|| format!("{key}={raw:?} is not a valid positive integer"))
}

/// Parse a `u64` from an env var, returning the default if absent.
fn parse_u64_env(key: &str, default: u64) -> miette::Result<u64> {
    let Some(raw) = std::env::var_os(key) else {
        return Ok(default);
    };
    let raw = raw
        .to_str()
        .ok_or_else(|| miette::miette!("{key} is not valid UTF-8"))?;
    raw.parse::<u64>()
        .into_diagnostic()
        .wrap_err_with(|| format!("{key}={raw:?} is not a valid positive integer"))
}

/// SSH throttling configuration, parsed once at startup.
#[derive(Clone)]
pub struct SshThrottleConfig {
    pub max_connections: usize,
    pub max_guests: usize,
    pub tui_timeout_secs: u64,
    pub guest_idle_timeout_secs: u64,
}

/// Parses all SSH throttling env vars. Malformed values are startup errors.
pub(crate) fn ssh_throttle_config() -> miette::Result<SshThrottleConfig> {
    Ok(SshThrottleConfig {
        max_connections: parse_usize_env(SSH_MAX_CONNECTIONS_VAR_KEY, DEFAULT_SSH_MAX_CONNECTIONS)?,
        max_guests: parse_usize_env(
            SSH_MAX_GUEST_CONNECTIONS_VAR_KEY,
            DEFAULT_SSH_MAX_GUEST_CONNECTIONS,
        )?,
        tui_timeout_secs: parse_u64_env(
            SSH_TUI_TIMEOUT_SECONDS_VAR_KEY,
            DEFAULT_SSH_TUI_TIMEOUT_SECONDS,
        )?,
        guest_idle_timeout_secs: parse_u64_env(
            SSH_GUEST_IDLE_TIMEOUT_SECONDS_VAR_KEY,
            DEFAULT_SSH_GUEST_IDLE_TIMEOUT_SECONDS,
        )?,
    })
}

/// Creates the hooks directory with pre-receive and proc-receive scripts.
///
/// The hooks are simple shell scripts that call back into the frogue binary
/// with the appropriate mode. Environment variables are expected to be set
/// by the SSH handler before invoking git.
fn create_hooks_directory(hooks_dir: &Path, hook_path: &Path) -> miette::Result<()> {
    std::fs::create_dir_all(hooks_dir)
        .into_diagnostic()
        .wrap_err_with(|| format!("creating hooks directory at {}", hooks_dir.display()))?;

    let pre_receive_path = hooks_dir.join("pre-receive");
    let pre_receive_content = format!(
        "#!/bin/sh\nexec \"{}\" git-hook pre-receive \"$@\"\n",
        hook_path.display()
    );
    std::fs::write(&pre_receive_path, pre_receive_content)
        .into_diagnostic()
        .wrap_err("writing pre-receive hook")?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&pre_receive_path, std::fs::Permissions::from_mode(0o755))
            .into_diagnostic()
            .wrap_err("setting pre-receive hook permissions")?;
    }

    let proc_receive_path = hooks_dir.join("proc-receive");
    let proc_receive_content = format!(
        "#!/bin/sh\nexec \"{}\" git-hook proc-receive \"$@\"\n",
        hook_path.display()
    );
    std::fs::write(&proc_receive_path, proc_receive_content)
        .into_diagnostic()
        .wrap_err("writing proc-receive hook")?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&proc_receive_path, std::fs::Permissions::from_mode(0o755))
            .into_diagnostic()
            .wrap_err("setting proc-receive hook permissions")?;
    }

    let post_receive_path = hooks_dir.join("post-receive");
    let post_receive_content = format!(
        "#!/bin/sh\nexec \"{}\" git-hook post-receive \"$@\"\n",
        hook_path.display()
    );
    std::fs::write(&post_receive_path, post_receive_content)
        .into_diagnostic()
        .wrap_err("writing post-receive hook")?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&post_receive_path, std::fs::Permissions::from_mode(0o755))
            .into_diagnostic()
            .wrap_err("setting post-receive hook permissions")?;
    }

    Ok(())
}

/// Serves HTTP until the process is stopped.
pub async fn run() -> miette::Result<()> {
    // Has to be installed before anything that logs, and kept alive until the
    // end of `run` so that spans are still flushed while shutting down.
    let telemetry = telemetry::init();

    let bind_addr = bind_addr()?;

    let database_url = std::env::var(DATABASE_URL_VAR_KEY).unwrap_or(
        concat!(
            "sqlite:///tmp/",
            env!["CARGO_CRATE_NAME"],
            ".sqlite?mode=rwc"
        )
        .to_string(),
    );

    let pool = Database::connect(&database_url).await.into_diagnostic()?;

    // The migrations are compiled into the binary so a deployment only has to
    // start the server; `up` is a no-op when there is nothing pending.
    migration::Migrator::up(&pool, None)
        .await
        .into_diagnostic()
        .wrap_err("running database migrations")?;

    // Bare repositories live on disk under a root of their own, created here so
    // that a deployment pointing at a volume only has to mount it.
    let repo_root = repo_root()?;

    // The SSH server is bound here rather than inside its thread so that a port
    // already in use fails loudly at startup instead of from somewhere nobody
    // is watching. Host key loading and generation happen here for the same
    // reason.
    let ssh_bind_addr = ssh::bind_addr()?;
    let ssh_listener = std::net::TcpListener::bind(ssh_bind_addr)
        .into_diagnostic()
        .wrap_err_with(|| format!("binding the SSH server to {ssh_bind_addr}"))?;
    ssh_listener
        .set_nonblocking(true)
        .into_diagnostic()
        .wrap_err("putting the SSH listener in non-blocking mode")?;
    let ssh_config = ssh::server_config(&repo_root)?;
    let ssh_throttle = ssh_throttle_config()?;

    // Get the path to the current executable for git hooks
    let hook_path = std::env::current_exe()
        .into_diagnostic()
        .wrap_err("failed to get current executable path")?;

    // Read the public URL from environment (optional).
    // Normalize once at startup: strip trailing `/` so URL builders don't
    // produce `//~user` when the env has a trailing slash.
    let public_url = std::env::var(FROGUE_PUBLIC_URL_VAR_KEY)
        .unwrap_or_default()
        .trim_end_matches('/')
        .to_string();

    // Create hooks directory before SSH starts listening
    let hooks_dir = repo_root.join(".frogue-hooks");
    create_hooks_directory(&hooks_dir, &hook_path)?;

    let app_state = Arc::new(RwLock::new(AppState {
        db: pool,
        repo_root,
        ssh: ssh::settings(ssh_bind_addr),
        public_url,
        http_client: reqwest_middleware::ClientBuilder::new(
            reqwest::Client::builder()
                .user_agent(concat!(
                    env!("CARGO_PKG_NAME"),
                    "/",
                    env!("CARGO_PKG_VERSION"),
                    " +",
                    env!("CARGO_PKG_REPOSITORY")
                ))
                .build()
                .into_diagnostic()?,
        )
        .with(reqwest_tracing::TracingMiddleware::default())
        .build(),
    }));

    ssh::serve(ssh_listener, ssh_config, app_state.clone(), ssh_throttle)?;

    // Spawn the ActivityPub delivery worker if federation is enabled
    let _delivery_handle = if activitypub::enabled(&*app_state.read().await) {
        Some(activitypub::delivery::spawn(app_state.clone()))
    } else {
        None
    };

    info!(%ssh_bind_addr, "ssh git server listening");
    info!(database_url, "database connection established");

    let request_logger = telemetry.request_logger();

    // The cookie is signed with this key, so it has to be the same for every
    // request and stay put across restarts; see `auth::session_key`.
    let session_key = auth::session_key();

    // Hashing the embedded assets costs microseconds once per process, so it
    // happens here rather than in whichever request arrives first, and the
    // spans it emits land in the startup trace. See `static_files::warm_up`.
    static_files::warm_up();

    info!(%bind_addr, "binding server");

    HttpServer::new(move || {
        App::new()
            .wrap(
                SessionMiddleware::builder(CookieSessionStore::default(), session_key.clone())
                    .cookie_name("frogue_session".to_string())
                    .cookie_http_only(true)
                    .cookie_same_site(SameSite::Lax)
                    // The server speaks plain HTTP; TLS is termination in front
                    // of it, so a `Secure` cookie would never be sent back.
                    .cookie_secure(false)
                    .build(),
            )
            .wrap(middleware::Compress::default())
            .wrap(middleware::NormalizePath::trim())
            .wrap(request_logger.clone())
            .app_data(web::Data::new(app_state.clone()))
            .service(static_files::serve)
            .configure(webui::configure)
            .service(web::scope("/api").configure(api::configure))
    })
    .bind(bind_addr)
    .into_diagnostic()?
    .run()
    .await
    .into_diagnostic()?;

    info!("server stopped, flushing telemetry");

    Ok(())
}

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

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

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