use axum::http::{HeaderMap, header};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use crate::auth::Namespaces;
use crate::model::Action;
use crate::namespace::Namespace;
mod forges;
pub use forges::Forge;
#[derive(Debug, Clone)]
pub struct Config {
pub bind: SocketAddr,
pub storage_root: PathBuf,
pub public_url: Option<String>,
pub action_lifetime: u32,
pub gc_grace: Duration,
pub staging_max_age: Duration,
pub lock_max_age: Option<Duration>,
pub max_object_size: Option<u64>,
pub max_concurrent_transfers: usize,
pub repo_quota: Option<u64>,
pub compression: Option<i32>,
pub encryption_key: Option<KeySource>,
pub storage: Storage,
pub auth: Auth,
pub dashboard: Option<Dashboard>,
pub forges: Vec<Forge>,
}
#[derive(Debug, Clone)]
pub struct Dashboard {
pub dir: PathBuf,
pub admins: Option<Namespace>,
}
const DASHBOARD_DIR: &str = "/usr/share/lfsx/dashboard";
fn dashboard(
enabled: Option<&str>,
dir: Option<&str>,
repo: Option<&str>,
auth: &Auth,
) -> Option<Dashboard> {
if enabled != Some("true") {
return None;
}
let admins = repo.filter(|repo| !repo.is_empty()).map(|repo| {
repo.split_once('/')
.and_then(|(org, name)| Namespace::new(org, name).ok())
.unwrap_or_else(|| panic!("LFSX_DASHBOARD_REPO is not org/repo: {repo}"))
});
if admins.is_none() && matches!(auth, Auth::Forge { .. }) {
panic!(
"LFSX_DASHBOARD=true needs LFSX_DASHBOARD_REPO: the dashboard is shown to the admins of \
that repository, and nobody else"
);
}
Some(Dashboard {
dir: dir
.filter(|dir| !dir.is_empty())
.unwrap_or(DASHBOARD_DIR)
.into(),
admins,
})
}
#[derive(Debug, Clone)]
pub enum Storage {
Local,
Bucket {
dialect: Dialect,
presign: bool,
cache: Option<DiskCache>,
locking: bool,
},
}
#[derive(Debug, Clone)]
pub enum Dialect {
S3 {
endpoint: String,
bucket: String,
region: String,
access_key: String,
secret_key: String,
path_style: bool,
},
Azure {
endpoint: String,
account: String,
container: String,
credential: AzureCredential,
},
Gcs {
endpoint: String,
bucket: String,
credential: GcsCredential,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GcsCredential {
ServiceAccount(PathBuf),
Metadata,
Anonymous,
}
fn gcs_credential(value: Option<&str>) -> GcsCredential {
match value.filter(|value| !value.is_empty()) {
None => GcsCredential::Metadata,
Some("none") => GcsCredential::Anonymous,
Some(path) => GcsCredential::ServiceAccount(path.into()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AzureCredential {
AccountKey(String),
Sas(String),
Identity,
}
fn azure_credential(key: Option<&str>, sas: Option<&str>) -> AzureCredential {
let key = key.filter(|key| !key.is_empty());
let sas = sas.filter(|sas| !sas.is_empty());
match (key, sas) {
(Some(_), Some(_)) => panic!(
"LFSX_AZURE_ACCOUNT_KEY and LFSX_AZURE_SAS_TOKEN are both set: pick one, or neither \
to authenticate with the pod's managed or workload identity"
),
(Some(key), None) => AzureCredential::AccountKey(key.to_owned()),
(None, Some(sas)) => AzureCredential::Sas(sas.to_owned()),
(None, None) => AzureCredential::Identity,
}
}
impl Storage {
fn from_env() -> Self {
let kind = std::env::var("LFSX_STORAGE").unwrap_or_default();
if !matches!(kind.as_str(), "s3" | "azure" | "gcs") {
return Self::Local;
}
let required = |name: &str| {
std::env::var(name)
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| panic!("LFSX_STORAGE={kind} needs {name}"))
};
let dialect = if kind == "gcs" {
Dialect::Gcs {
endpoint: std::env::var("LFSX_GCS_ENDPOINT")
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "https://storage.googleapis.com".into()),
bucket: required("LFSX_GCS_BUCKET"),
credential: gcs_credential(std::env::var("LFSX_GCS_CREDENTIALS").ok().as_deref()),
}
} else if kind == "azure" {
let account = required("LFSX_AZURE_ACCOUNT");
Dialect::Azure {
endpoint: std::env::var("LFSX_AZURE_ENDPOINT")
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| format!("https://{account}.blob.core.windows.net")),
container: required("LFSX_AZURE_CONTAINER"),
credential: azure_credential(
std::env::var("LFSX_AZURE_ACCOUNT_KEY").ok().as_deref(),
std::env::var("LFSX_AZURE_SAS_TOKEN").ok().as_deref(),
),
account,
}
} else {
Dialect::S3 {
endpoint: required("LFSX_S3_ENDPOINT"),
bucket: required("LFSX_S3_BUCKET"),
region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
access_key: required("LFSX_S3_ACCESS_KEY"),
secret_key: required("LFSX_S3_SECRET_KEY"),
path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
}
};
Self::Bucket {
dialect,
presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
cache: disk_cache(
std::env::var("LFSX_S3_CACHE_DIR").ok().as_deref(),
std::env::var("LFSX_S3_CACHE_MAX_BYTES").ok().as_deref(),
),
locking: true,
}
}
}
#[derive(Debug, Clone)]
pub enum Auth {
Forge {
provider: Provider,
api_url: String,
github_app: Option<GithubApp>,
cache_ttl: Duration,
rejection_ttl: Duration,
lookup_budget: Option<u32>,
anonymous_read: bool,
restricted: Namespaces,
allowed: Option<Namespaces>,
},
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiskCache {
pub dir: PathBuf,
pub max_bytes: u64,
}
fn disk_cache(dir: Option<&str>, max_bytes: Option<&str>) -> Option<DiskCache> {
let dir = dir.filter(|dir| !dir.is_empty())?;
let Some(max_bytes) = max_bytes
.map(str::trim)
.and_then(|raw| raw.parse::<u64>().ok())
.filter(|ceiling| *ceiling > 0)
else {
tracing::warn!(
"LFSX_S3_CACHE_DIR is set without a usable LFSX_S3_CACHE_MAX_BYTES, so nothing is \
cached: a cache with no ceiling would fill the volume this server stages uploads on"
);
return None;
};
Some(DiskCache {
dir: PathBuf::from(dir),
max_bytes,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeySource {
File(PathBuf),
Command(String),
}
fn encryption_key(file: Option<&str>, command: Option<&str>) -> Option<KeySource> {
let file = file.filter(|path| !path.is_empty());
let command = command.filter(|hook| !hook.is_empty());
match (file, command) {
(None, None) => None,
(Some(path), None) => Some(KeySource::File(PathBuf::from(path))),
(None, Some(hook)) => Some(KeySource::Command(hook.to_owned())),
(Some(_), Some(_)) => panic!(
"LFSX_ENCRYPTION_KEY_FILE and LFSX_ENCRYPTION_KEY_COMMAND are both set: they are two \
answers to where the keys live, and picking one for you is how the wrong keys get used"
),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GithubApp {
pub app_id: String,
pub key_file: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provider {
Github,
Gitlab,
Gitea,
}
impl Provider {
fn default_api_url(self) -> Option<&'static str> {
match self {
Self::Github => Some("https://api.github.com"),
Self::Gitlab => Some("https://gitlab.com/api/v4"),
Self::Gitea => None,
}
}
fn api_url_variable(self) -> &'static str {
match self {
Self::Github => "LFSX_GITHUB_API_URL",
Self::Gitlab => "LFSX_GITLAB_API_URL",
Self::Gitea => "LFSX_GITEA_API_URL",
}
}
}
const CACHE_TTL: Duration = Duration::from_secs(60);
const REJECTION_TTL: Duration = Duration::from_secs(10);
const LOOKUP_BUDGET: u32 = 600;
const TRANSFER_CAP: usize = 128;
const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
impl Config {
pub fn from_env() -> Self {
let bind = std::env::var("LFSX_BIND")
.ok()
.and_then(|raw| raw.parse().ok())
.unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
let storage_root = std::env::var("LFSX_STORAGE_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
let public_url = std::env::var("LFSX_PUBLIC_URL")
.ok()
.filter(|url| !url.is_empty())
.map(|url| url.trim_end_matches('/').to_owned());
Self {
bind,
storage_root,
public_url,
action_lifetime: 1800,
gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
lock_max_age: seconds("LFSX_LOCK_MAX_AGE"),
max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
max_concurrent_transfers: transfer_cap(
std::env::var("LFSX_MAX_CONCURRENT_TRANSFERS")
.ok()
.as_deref(),
),
repo_quota: bytes("LFSX_REPO_QUOTA"),
compression: compression(),
encryption_key: encryption_key(
std::env::var("LFSX_ENCRYPTION_KEY_FILE").ok().as_deref(),
std::env::var("LFSX_ENCRYPTION_KEY_COMMAND").ok().as_deref(),
),
storage: Storage::from_env(),
dashboard: None,
forges: Vec::new(),
auth: Auth::from_env(),
}
.with_dashboard()
.with_forges()
}
fn with_forges(self) -> Self {
Self {
forges: forges::from_env(&self.auth),
..self
}
}
fn with_dashboard(self) -> Self {
Self {
dashboard: dashboard(
std::env::var("LFSX_DASHBOARD").ok().as_deref(),
std::env::var("LFSX_DASHBOARD_DIR").ok().as_deref(),
std::env::var("LFSX_DASHBOARD_REPO").ok().as_deref(),
&self.auth,
),
..self
}
}
pub fn base_url(&self, headers: &HeaderMap) -> String {
if let Some(configured) = &self.public_url {
return configured.clone();
}
let scheme = headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|scheme| matches!(*scheme, "http" | "https"))
.unwrap_or("http");
let authority = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|host| is_an_authority(host))
.unwrap_or("localhost");
format!("{scheme}://{authority}")
}
pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
format!("{base}/{}/objects/{oid}", ns.url_path())
}
pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
format!("{base}/{}/objects/verify", ns.url_path())
}
pub fn action(&self, href: String) -> Action {
Action {
href,
header: None,
expires_in: self.action_lifetime,
}
}
pub fn signed_action(&self, href: String, headers: Vec<(String, String)>) -> Action {
Action {
href,
header: Some(headers.into_iter().collect()),
expires_in: self.action_lifetime,
}
}
}
fn anonymous_read(value: Option<&str>) -> bool {
value == Some("true")
}
impl Auth {
fn from_env() -> Self {
if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
tracing::warn!(
"LFSX_AUTH=disabled: every request is accepted, run this on a trusted network only"
);
if is_set(std::env::var("LFSX_ALLOWED").ok().as_deref()) {
tracing::warn!(
"LFSX_ALLOWED is set and LFSX_AUTH=disabled, so it does nothing: with no forge \
to ask, every repository is served"
);
}
if is_set(std::env::var("LFSX_RESTRICTED").ok().as_deref()) {
tracing::warn!(
"LFSX_RESTRICTED is set and LFSX_AUTH=disabled, so it does nothing: every \
caller already holds every right"
);
}
return Self::Disabled;
}
let provider = provider(std::env::var("LFSX_AUTH").ok().as_deref());
Self::Forge {
provider,
api_url: api_url(
provider,
std::env::var(provider.api_url_variable()).ok().as_deref(),
),
cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
lookup_budget: lookup_budget(std::env::var("LFSX_AUTH_LOOKUP_BUDGET").ok().as_deref()),
github_app: github_app(provider),
anonymous_read: anonymous_read(std::env::var("LFSX_ANONYMOUS_READ").ok().as_deref()),
restricted: Namespaces::parse(
"LFSX_RESTRICTED",
std::env::var("LFSX_RESTRICTED").ok().as_deref(),
),
allowed: allowed(
"LFSX_ALLOWED",
std::env::var("LFSX_ALLOWED").ok().as_deref(),
),
}
}
}
fn is_set(value: Option<&str>) -> bool {
value.is_some_and(|value| !value.trim().is_empty())
}
fn allowed(variable: &str, value: Option<&str>) -> Option<Namespaces> {
is_set(value).then(|| Namespaces::parse(variable, value))
}
fn github_app(provider: Provider) -> Option<GithubApp> {
let id = std::env::var("LFSX_GITHUB_APP_ID")
.ok()
.filter(|id| !id.is_empty());
let key_file = std::env::var("LFSX_GITHUB_APP_KEY_FILE")
.ok()
.filter(|path| !path.is_empty());
match (id, key_file) {
(None, None) => None,
(Some(app_id), Some(key_file)) => {
if provider != Provider::Github {
tracing::warn!(
"LFSX_GITHUB_APP_ID is set but LFSX_AUTH is not github, so it does nothing"
);
return None;
}
Some(GithubApp {
app_id,
key_file: PathBuf::from(key_file),
})
}
_ => panic!(
"LFSX_GITHUB_APP_ID and LFSX_GITHUB_APP_KEY_FILE come together: one without the \
other is half an identity, and guessing which half was meant is worse than stopping"
),
}
}
fn lookup_budget(value: Option<&str>) -> Option<u32> {
match value.map(str::trim).map(str::parse::<u32>) {
Some(Ok(0)) => None,
Some(Ok(budget)) => Some(budget),
Some(Err(_)) | None => Some(LOOKUP_BUDGET),
}
}
fn provider(value: Option<&str>) -> Provider {
match value {
Some("gitlab") => Provider::Gitlab,
Some("gitea") | Some("forgejo") => Provider::Gitea,
_ => Provider::Github,
}
}
fn api_url(provider: Provider, configured: Option<&str>) -> String {
api_url_from(provider, provider.api_url_variable(), configured)
}
fn api_url_from(provider: Provider, variable: &str, configured: Option<&str>) -> String {
configured
.map(str::to_owned)
.or_else(|| provider.default_api_url().map(str::to_owned))
.unwrap_or_else(|| {
panic!(
"{variable} must be set: a self-hosted forge has no default API root, and guessing \
one would resolve your repositories against somebody else's"
)
})
.trim_end_matches('/')
.to_owned()
}
fn is_an_authority(host: &str) -> bool {
!host.is_empty()
&& host.len() <= 255
&& host.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'[' | b']')
})
}
fn compression() -> Option<i32> {
match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
"" | "none" | "off" => None,
"zstd" => Some(3),
other => match other
.strip_prefix("zstd:")
.and_then(|level| level.parse().ok())
{
Some(level @ 1..=19) => Some(level),
_ => {
tracing::warn!(
"LFSX_COMPRESSION={other} is not a codec this server knows, storing objects as they arrive"
);
None
}
},
}
}
fn transfer_cap(value: Option<&str>) -> usize {
match value.map(str::trim).map(str::parse) {
Some(Ok(cap)) => cap,
None => TRANSFER_CAP,
Some(Err(_)) => {
tracing::warn!(
"LFSX_MAX_CONCURRENT_TRANSFERS is not a number, keeping the default of {TRANSFER_CAP}"
);
TRANSFER_CAP
}
}
}
fn bytes(variable: &str) -> Option<u64> {
let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
if configured == 0 {
tracing::warn!("{variable}=0 would refuse every upload, ignoring it");
return None;
}
Some(configured)
}
fn seconds(variable: &str) -> Option<Duration> {
std::env::var(variable)
.ok()
.and_then(|raw| raw.parse().ok())
.map(Duration::from_secs)
}
#[cfg(test)]
mod tests;