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);
}
}