refactor: toml config

This commit is contained in:
isabel
2026-02-21 18:00:55 +00:00
committed by Tangled
parent cbd3b79f41
commit 66eb9b7dbb
76 changed files with 2165 additions and 598 deletions
+2 -2
View File
@@ -131,13 +131,13 @@ BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
# Account Registration
# =============================================================================
# Require invite codes for registration
# INVITE_CODE_REQUIRED=false
# INVITE_CODE_REQUIRED=true
# Comma-separated list of available user domains
# AVAILABLE_USER_DOMAINS=example.com
# Enable self-hosted did:web identities (default: true)
# Hosting did:web requires a long-term commitment to serve DID documents.
# Set to false if you don't want to offer this option.
# ENABLE_SELF_HOSTED_DID_WEB=true
# ENABLE_PDS_HOSTED_DID_WEB=false
# =============================================================================
# Server Metadata (returned by describeServer)
# =============================================================================
Generated
+210 -3
View File
@@ -104,6 +104,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.100"
@@ -1216,6 +1266,46 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmake"
version = "0.1.57"
@@ -1240,6 +1330,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
@@ -1280,6 +1376,29 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "confique"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f"
dependencies = [
"confique-macro",
"serde",
"toml",
]
[[package]]
name = "confique-macro"
version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d1754680cd218e7bcb4c960cc9bae3444b5197d64563dccccfdf83cab9e1a7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -2762,7 +2881,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.2",
"system-configuration",
"tokio",
"tower-service",
@@ -3059,6 +3178,12 @@ dependencies = [
"unsigned-varint 0.7.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
@@ -3715,6 +3840,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
@@ -4209,7 +4340,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.35",
"socket2 0.5.10",
"socket2 0.6.2",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -4246,7 +4377,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
@@ -4909,6 +5040,15 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -5708,6 +5848,45 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.12.1",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tonic"
version = "0.14.2"
@@ -5908,6 +6087,7 @@ dependencies = [
"sha2",
"subtle",
"totp-rs",
"tranquil-config",
"tranquil-crypto",
"urlencoding",
"uuid",
@@ -5922,6 +6102,7 @@ dependencies = [
"redis",
"tokio-util",
"tracing",
"tranquil-config",
"tranquil-infra",
"tranquil-ripple",
]
@@ -5936,10 +6117,19 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"tokio",
"tranquil-config",
"tranquil-db-traits",
"uuid",
]
[[package]]
name = "tranquil-config"
version = "0.2.1"
dependencies = [
"confique",
"serde",
]
[[package]]
name = "tranquil-crypto"
version = "0.2.1"
@@ -5997,6 +6187,7 @@ dependencies = [
"bytes",
"futures",
"thiserror 2.0.17",
"tranquil-config",
]
[[package]]
@@ -6041,6 +6232,7 @@ dependencies = [
"chrono",
"ciborium",
"cid",
"clap",
"ctor",
"dotenvy",
"ed25519-dalek",
@@ -6091,6 +6283,7 @@ dependencies = [
"tranquil-auth",
"tranquil-cache",
"tranquil-comms",
"tranquil-config",
"tranquil-crypto",
"tranquil-db",
"tranquil-db-traits",
@@ -6139,6 +6332,7 @@ dependencies = [
"tokio-util",
"tracing",
"tracing-subscriber",
"tranquil-config",
"tranquil-infra",
"uuid",
]
@@ -6171,6 +6365,7 @@ dependencies = [
"sha2",
"tokio",
"tracing",
"tranquil-config",
"tranquil-infra",
"uuid",
]
@@ -6356,6 +6551,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.19.0"
@@ -6930,6 +7131,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
[[package]]
name = "winreg"
version = "0.50.0"
+4
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/tranquil-types",
"crates/tranquil-config",
"crates/tranquil-infra",
"crates/tranquil-crypto",
"crates/tranquil-storage",
@@ -24,6 +25,7 @@ license = "AGPL-3.0-or-later"
[workspace.dependencies]
tranquil-types = { path = "crates/tranquil-types" }
tranquil-config = { path = "crates/tranquil-config" }
tranquil-infra = { path = "crates/tranquil-infra" }
tranquil-crypto = { path = "crates/tranquil-crypto" }
tranquil-storage = { path = "crates/tranquil-storage" }
@@ -52,6 +54,8 @@ bs58 = "0.5"
bytes = "1.11"
chrono = { version = "0.4", features = ["serde"] }
cid = "0.11"
clap = { version = "4", features = ["derive", "env"] }
confique = { version = "0.4", features = ["toml"] }
dotenvy = "0.15"
ed25519-dalek = { version = "2.1", features = ["pkcs8"] }
foca = { version = "1", features = ["bincode-codec", "tracing"] }
+4 -1
View File
@@ -24,7 +24,10 @@ just run
## Configuration
See `.env.example` for all configuration options.
See `example.toml` for all configuration options.
> [!NOTE]
> The order of configuration precendence is: environment variables, than a config file passed via `--config`, than `/etc/tranquil-pds/config.toml`, than the built-in defaults. So you can use environment variables, or a config file, or both.
## Development
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
tranquil-crypto = { workspace = true }
anyhow = { workspace = true }
+6 -2
View File
@@ -127,7 +127,9 @@ fn create_signed_token_with_act(
let jti = uuid::Uuid::new_v4().to_string();
let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| {
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
tranquil_config::try_get()
.map(|c| c.server.hostname.clone())
.unwrap_or_else(|| "localhost".to_string())
});
let claims = Claims {
@@ -253,7 +255,9 @@ fn create_hs256_token_with_metadata(
sub: did.to_owned(),
aud: format!(
"did:web:{}",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
tranquil_config::try_get()
.map(|c| c.server.hostname.clone())
.unwrap_or_else(|| "localhost".to_string())
),
exp: expiration,
iat: Utc::now().timestamp(),
+1
View File
@@ -9,6 +9,7 @@ default = []
valkey = ["dep:redis"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true }
tranquil-ripple = { workspace = true }
+22 -14
View File
@@ -172,28 +172,36 @@ impl DistributedRateLimiter for NoOpRateLimiter {
pub async fn create_cache(
shutdown: tokio_util::sync::CancellationToken,
) -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref());
#[cfg(feature = "valkey")]
if let Ok(url) = std::env::var("VALKEY_URL") {
match ValkeyCache::new(&url).await {
Ok(cache) => {
tracing::info!("using valkey cache at {url}");
let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection()));
return (Arc::new(cache), rate_limiter);
}
Err(e) => {
tracing::warn!("failed to connect to valkey: {e}. falling back to ripple.");
if backend == "valkey" {
if let Some(url) = valkey_url {
match ValkeyCache::new(url).await {
Ok(cache) => {
tracing::info!("using valkey cache at {url}");
let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection()));
return (Arc::new(cache), rate_limiter);
}
Err(e) => {
tracing::warn!("failed to connect to valkey: {e}. falling back to ripple.");
}
}
} else {
tracing::warn!("cache.backend is \"valkey\" but VALKEY_URL is not set. using ripple.");
}
}
#[cfg(not(feature = "valkey"))]
if std::env::var("VALKEY_URL").is_ok() {
if backend == "valkey" {
tracing::warn!(
"VALKEY_URL is set but binary was compiled without valkey feature. using ripple."
"cache.backend is \"valkey\" but binary was compiled without valkey feature. using ripple."
);
}
match tranquil_ripple::RippleConfig::from_env() {
match tranquil_ripple::RippleConfig::from_config() {
Ok(config) => {
let peer_count = config.seed_peers.len();
match tranquil_ripple::RippleEngine::start(config, shutdown).await {
@@ -205,13 +213,13 @@ pub async fn create_cache(
(cache, rate_limiter)
}
Err(e) => {
tracing::error!("ripple engine failed to start: {e}. running without cache.");
tracing::error!("ripple engine failed to start: {e:#}. running without cache.");
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
}
}
}
Err(e) => {
tracing::error!("ripple config error: {e}. running without cache.");
tracing::error!("ripple config error: {e:#}. running without cache.");
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
}
}
+2
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
reqwest = { workspace = true }
+14 -16
View File
@@ -112,20 +112,19 @@ pub struct EmailSender {
}
impl EmailSender {
pub fn new(from_address: String, from_name: String) -> Self {
pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self {
Self {
from_address,
from_name,
sendmail_path: std::env::var("SENDMAIL_PATH")
.unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
sendmail_path,
}
}
pub fn from_env() -> Option<Self> {
let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?;
let from_name =
std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string());
Some(Self::new(from_address, from_name))
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let from_address = cfg.email.from_address.clone()?;
let from_name = cfg.email.from_name.clone();
let sendmail_path = cfg.email.sendmail_path.clone();
Some(Self::new(from_address, from_name, sendmail_path))
}
pub fn format_email(&self, notification: &QueuedComms) -> String {
@@ -190,8 +189,8 @@ impl DiscordSender {
}
}
pub fn from_env() -> Option<Self> {
let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?;
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let bot_token = cfg.discord.bot_token.clone()?;
Some(Self::new(bot_token))
}
@@ -454,8 +453,8 @@ impl TelegramSender {
}
}
pub fn from_env() -> Option<Self> {
let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?;
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let bot_token = cfg.telegram.bot_token.clone()?;
Some(Self::new(bot_token))
}
@@ -586,10 +585,9 @@ impl SignalSender {
}
}
pub fn from_env() -> Option<Self> {
let signal_cli_path = std::env::var("SIGNAL_CLI_PATH")
.unwrap_or_else(|_| "/usr/local/bin/signal-cli".to_string());
let sender_number = std::env::var("SIGNAL_SENDER_NUMBER").ok()?;
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let signal_cli_path = cfg.signal.cli_path.clone();
let sender_number = cfg.signal.sender_number.clone()?;
Some(Self::new(signal_cli_path, sender_number))
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "tranquil-config"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
confique = { workspace = true }
serde = { workspace = true }
+883
View File
@@ -0,0 +1,883 @@
use confique::Config;
use std::fmt;
use std::path::PathBuf;
use std::sync::OnceLock;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
/// Errors discovered during configuration validation.
#[derive(Debug)]
pub struct ConfigError {
pub errors: Vec<String>,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "configuration validation failed:")?;
for err in &self.errors {
writeln!(f, " - {err}")?;
}
Ok(())
}
}
impl std::error::Error for ConfigError {}
/// Initialize the global configuration. Must be called once at startup before
/// any other code accesses the configuration. Panics if called more than once.
pub fn init(config: TranquilConfig) {
CONFIG
.set(config)
.expect("tranquil-config: configuration already initialized");
}
/// Returns a reference to the global configuration.
/// Panics if [`init`] has not been called yet.
pub fn get() -> &'static TranquilConfig {
CONFIG
.get()
.expect("tranquil-config: not initialized - call tranquil_config::init() first")
}
/// Returns a reference to the global configuration if it has been initialized.
pub fn try_get() -> Option<&'static TranquilConfig> {
CONFIG.get()
}
/// Load configuration from an optional TOML file path, with environment
/// variable overrides applied on top. Fields annotated with `#[config(env)]`
/// are read from the corresponding environment variables when the `.env()`
/// layer is active.
///
/// Precedence (highest to lowest):
/// 1. Environment variables
/// 2. TOML config file (if provided)
/// 3. Built-in defaults
pub fn load(config_path: Option<&PathBuf>) -> Result<TranquilConfig, confique::Error> {
let mut builder = TranquilConfig::builder().env();
if let Some(path) = config_path {
builder = builder.file(path);
}
builder.file("/etc/tranquil-pds/config.toml").load()
}
// Root configuration
#[derive(Debug, Config)]
pub struct TranquilConfig {
#[config(nested)]
pub server: ServerConfig,
#[config(nested)]
pub database: DatabaseConfig,
#[config(nested)]
pub secrets: SecretsConfig,
#[config(nested)]
pub storage: StorageConfig,
#[config(nested)]
pub backup: BackupConfig,
#[config(nested)]
pub cache: CacheConfig,
#[config(nested)]
pub plc: PlcConfig,
#[config(nested)]
pub firehose: FirehoseConfig,
#[config(nested)]
pub email: EmailConfig,
#[config(nested)]
pub discord: DiscordConfig,
#[config(nested)]
pub telegram: TelegramConfig,
#[config(nested)]
pub signal: SignalConfig,
#[config(nested)]
pub notifications: NotificationConfig,
#[config(nested)]
pub sso: SsoConfig,
#[config(nested)]
pub moderation: ModerationConfig,
#[config(nested)]
pub import: ImportConfig,
#[config(nested)]
pub scheduled: ScheduledConfig,
}
impl TranquilConfig {
/// Validate cross-field constraints that cannot be expressed through
/// confique's declarative defaults alone. Call this once after loading
/// the configuration and before [`init`].
///
/// Returns `Ok(())` when the configuration is consistent, or a
/// [`ConfigError`] listing every problem found.
pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> {
let mut errors = Vec::new();
// -- secrets ----------------------------------------------------------
if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) {
if let Some(ref s) = self.secrets.jwt_secret {
if s.len() < 32 {
errors.push(
"secrets.jwt_secret (JWT_SECRET) must be at least 32 characters"
.to_string(),
);
}
} else {
errors.push(
"secrets.jwt_secret (JWT_SECRET) is required in production \
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
.to_string(),
);
}
if let Some(ref s) = self.secrets.dpop_secret {
if s.len() < 32 {
errors.push(
"secrets.dpop_secret (DPOP_SECRET) must be at least 32 characters"
.to_string(),
);
}
} else {
errors.push(
"secrets.dpop_secret (DPOP_SECRET) is required in production \
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
.to_string(),
);
}
if let Some(ref s) = self.secrets.master_key {
if s.len() < 32 {
errors.push(
"secrets.master_key (MASTER_KEY) must be at least 32 characters"
.to_string(),
);
}
} else {
errors.push(
"secrets.master_key (MASTER_KEY) is required in production \
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
.to_string(),
);
}
}
// -- telegram ---------------------------------------------------------
if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() {
errors.push(
"telegram.bot_token is set but telegram.webhook_secret is missing; \
both are required for secure Telegram integration"
.to_string(),
);
}
// -- blob storage -----------------------------------------------------
match self.storage.backend.as_str() {
"s3" => {
if self.storage.s3_bucket.is_none() {
errors.push(
"storage.backend is \"s3\" but storage.s3_bucket (S3_BUCKET) \
is not set"
.to_string(),
);
}
}
"filesystem" => {}
other => {
errors.push(format!(
"storage.backend must be \"filesystem\" or \"s3\", got \"{other}\""
));
}
}
// -- backup storage ---------------------------------------------------
if self.backup.enabled {
match self.backup.backend.as_str() {
"s3" => {
if self.backup.s3_bucket.is_none() {
errors.push(
"backup.backend is \"s3\" but backup.s3_bucket \
(BACKUP_S3_BUCKET) is not set"
.to_string(),
);
}
}
"filesystem" => {}
other => {
errors.push(format!(
"backup.backend must be \"filesystem\" or \"s3\", got \"{other}\""
));
}
}
}
// -- SSO providers ----------------------------------------------------
self.validate_sso_provider("sso.github", &self.sso.github, &mut errors);
self.validate_sso_provider("sso.google", &self.sso.google, &mut errors);
self.validate_sso_discord(&mut errors);
self.validate_sso_with_issuer("sso.gitlab", &self.sso.gitlab, &mut errors);
self.validate_sso_with_issuer("sso.oidc", &self.sso.oidc, &mut errors);
self.validate_sso_apple(&mut errors);
// -- moderation -------------------------------------------------------
let has_url = self.moderation.report_service_url.is_some();
let has_did = self.moderation.report_service_did.is_some();
if has_url != has_did {
errors.push(
"moderation.report_service_url and moderation.report_service_did \
must both be set or both be unset"
.to_string(),
);
}
// -- cache ------------------------------------------------------------
match self.cache.backend.as_str() {
"valkey" => {
if self.cache.valkey_url.is_none() {
errors.push(
"cache.backend is \"valkey\" but cache.valkey_url (VALKEY_URL) \
is not set"
.to_string(),
);
}
}
"ripple" => {}
other => {
errors.push(format!(
"cache.backend must be \"ripple\" or \"valkey\", got \"{other}\""
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(ConfigError { errors })
}
}
fn validate_sso_provider(&self, prefix: &str, p: &SsoProviderConfig, errors: &mut Vec<String>) {
if p.enabled {
if p.client_id.is_none() {
errors.push(format!(
"{prefix}.client_id is required when {prefix}.enabled = true"
));
}
if p.client_secret.is_none() {
errors.push(format!(
"{prefix}.client_secret is required when {prefix}.enabled = true"
));
}
}
}
fn validate_sso_discord(&self, errors: &mut Vec<String>) {
let p = &self.sso.discord;
if p.enabled {
if p.client_id.is_none() {
errors.push(
"sso.discord.client_id is required when sso.discord.enabled = true".to_string(),
);
}
if p.client_secret.is_none() {
errors.push(
"sso.discord.client_secret is required when sso.discord.enabled = true"
.to_string(),
);
}
}
}
fn validate_sso_with_issuer(
&self,
prefix: &str,
p: &SsoProviderWithIssuerConfig,
errors: &mut Vec<String>,
) {
if p.enabled {
if p.client_id.is_none() {
errors.push(format!(
"{prefix}.client_id is required when {prefix}.enabled = true"
));
}
if p.client_secret.is_none() {
errors.push(format!(
"{prefix}.client_secret is required when {prefix}.enabled = true"
));
}
if p.issuer.is_none() {
errors.push(format!(
"{prefix}.issuer is required when {prefix}.enabled = true"
));
}
}
}
fn validate_sso_apple(&self, errors: &mut Vec<String>) {
let p = &self.sso.apple;
if p.enabled {
if p.client_id.is_none() {
errors.push(
"sso.apple.client_id is required when sso.apple.enabled = true".to_string(),
);
}
if p.team_id.is_none() {
errors.push(
"sso.apple.team_id is required when sso.apple.enabled = true".to_string(),
);
}
if p.key_id.is_none() {
errors
.push("sso.apple.key_id is required when sso.apple.enabled = true".to_string());
}
if p.private_key.is_none() {
errors.push(
"sso.apple.private_key is required when sso.apple.enabled = true".to_string(),
);
}
}
}
}
// ---------------------------------------------------------------------------
// Server
// ---------------------------------------------------------------------------
#[derive(Debug, Config)]
pub struct ServerConfig {
/// Public hostname of the PDS (e.g. `pds.example.com`).
#[config(env = "PDS_HOSTNAME")]
pub hostname: String,
/// Address to bind the HTTP server to.
#[config(env = "SERVER_HOST", default = "127.0.0.1")]
pub host: String,
/// Port to bind the HTTP server to.
#[config(env = "SERVER_PORT", default = 3000)]
pub port: u16,
/// List of domains for user handles.
/// Defaults to the PDS hostname when not set.
#[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)]
pub user_handle_domains: Option<Vec<String>>,
/// List of domains available for user registration.
/// Defaults to the PDS hostname when not set.
#[config(env = "AVAILABLE_USER_DOMAINS", parse_env = split_comma_list)]
pub available_user_domains: Option<Vec<String>>,
/// Enable PDS-hosted did:web identities. Hosting did:web requires a
/// long-term commitment to serve DID documents; opt-in only.
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
pub enable_pds_hosted_did_web: bool,
/// When set to true, skip age-assurance birthday prompt for all accounts.
#[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)]
pub age_assurance_override: bool,
/// Require an invite code for new account registration.
#[config(env = "INVITE_CODE_REQUIRED", default = true)]
pub invite_code_required: bool,
/// Allow HTTP (non-TLS) proxy requests. Only useful during development.
#[config(env = "ALLOW_HTTP_PROXY", default = false)]
pub allow_http_proxy: bool,
/// Disable all rate limiting. Should only be used in testing.
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
pub disable_rate_limiting: bool,
/// List of additional banned words for handle validation.
#[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)]
pub banned_words: Option<Vec<String>>,
/// URL to a privacy policy page.
#[config(env = "PRIVACY_POLICY_URL")]
pub privacy_policy_url: Option<String>,
/// URL to terms of service page.
#[config(env = "TERMS_OF_SERVICE_URL")]
pub terms_of_service_url: Option<String>,
/// Operator contact email address.
#[config(env = "CONTACT_EMAIL")]
pub contact_email: Option<String>,
/// Maximum allowed blob size in bytes (default 10 GiB).
#[config(env = "MAX_BLOB_SIZE", default = 10_737_418_240u64)]
pub max_blob_size: u64,
}
impl ServerConfig {
/// The public HTTPS URL for this PDS.
pub fn public_url(&self) -> String {
format!("https://{}", self.hostname)
}
/// Hostname without port suffix (e.g. `pds.example.com` from
/// `pds.example.com:443`).
pub fn hostname_without_port(&self) -> &str {
self.hostname.split(':').next().unwrap_or(&self.hostname)
}
/// Returns the extra banned words list, or an empty vec when unset.
pub fn banned_word_list(&self) -> Vec<String> {
self.banned_words.clone().unwrap_or_default()
}
/// Returns the available user domains, falling back to `[hostname_without_port]`.
pub fn available_user_domain_list(&self) -> Vec<String> {
self.available_user_domains
.clone()
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
}
/// Returns the user handle domains, falling back to `[hostname_without_port]`.
pub fn user_handle_domain_list(&self) -> Vec<String> {
self.user_handle_domains
.clone()
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
}
}
#[derive(Debug, Config)]
pub struct DatabaseConfig {
/// PostgreSQL connection URL.
#[config(env = "DATABASE_URL")]
pub url: String,
/// Maximum number of connections in the pool.
#[config(env = "DATABASE_MAX_CONNECTIONS", default = 100)]
pub max_connections: u32,
/// Minimum number of idle connections kept in the pool.
#[config(env = "DATABASE_MIN_CONNECTIONS", default = 10)]
pub min_connections: u32,
/// Timeout in seconds when acquiring a connection from the pool.
#[config(env = "DATABASE_ACQUIRE_TIMEOUT_SECS", default = 10)]
pub acquire_timeout_secs: u64,
}
#[derive(Config)]
pub struct SecretsConfig {
/// Secret used for signing JWTs. Must be at least 32 characters in
/// production.
#[config(env = "JWT_SECRET")]
pub jwt_secret: Option<String>,
/// Secret used for DPoP proof validation. Must be at least 32 characters
/// in production.
#[config(env = "DPOP_SECRET")]
pub dpop_secret: Option<String>,
/// Master key used for key-encryption and HKDF derivation. Must be at
/// least 32 characters in production.
#[config(env = "MASTER_KEY")]
pub master_key: Option<String>,
/// PLC rotation key (DID key). If not set, user-level keys are used.
#[config(env = "PLC_ROTATION_KEY")]
pub plc_rotation_key: Option<String>,
/// Allow insecure/test secrets. NEVER enable in production.
#[config(env = "TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", default = false)]
pub allow_insecure: bool,
}
impl std::fmt::Debug for SecretsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretsConfig")
.field(
"jwt_secret",
&self.jwt_secret.as_ref().map(|_| "[REDACTED]"),
)
.field(
"dpop_secret",
&self.dpop_secret.as_ref().map(|_| "[REDACTED]"),
)
.field(
"master_key",
&self.master_key.as_ref().map(|_| "[REDACTED]"),
)
.field(
"plc_rotation_key",
&self.plc_rotation_key.as_ref().map(|_| "[REDACTED]"),
)
.field("allow_insecure", &self.allow_insecure)
.finish()
}
}
impl SecretsConfig {
/// Resolve the JWT secret, falling back to an insecure default if
/// `allow_insecure` is true.
pub fn jwt_secret_or_default(&self) -> String {
self.jwt_secret.clone().unwrap_or_else(|| {
if cfg!(test) || self.allow_insecure {
"test-jwt-secret-not-for-production".to_string()
} else {
panic!(
"JWT_SECRET must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
);
}
})
}
/// Resolve the DPoP secret, falling back to an insecure default if
/// `allow_insecure` is true.
pub fn dpop_secret_or_default(&self) -> String {
self.dpop_secret.clone().unwrap_or_else(|| {
if cfg!(test) || self.allow_insecure {
"test-dpop-secret-not-for-production".to_string()
} else {
panic!(
"DPOP_SECRET must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
);
}
})
}
/// Resolve the master key, falling back to an insecure default if
/// `allow_insecure` is true.
pub fn master_key_or_default(&self) -> String {
self.master_key.clone().unwrap_or_else(|| {
if cfg!(test) || self.allow_insecure {
"test-master-key-not-for-production".to_string()
} else {
panic!(
"MASTER_KEY must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
);
}
})
}
}
#[derive(Debug, Config)]
pub struct StorageConfig {
/// Storage backend: `filesystem` or `s3`.
#[config(env = "BLOB_STORAGE_BACKEND", default = "filesystem")]
pub backend: String,
/// Path on disk for the filesystem blob backend.
#[config(env = "BLOB_STORAGE_PATH", default = "/var/lib/tranquil-pds/blobs")]
pub path: String,
/// S3 bucket name for blob storage.
#[config(env = "S3_BUCKET")]
pub s3_bucket: Option<String>,
/// Custom S3 endpoint URL (for MinIO, R2, etc.).
#[config(env = "S3_ENDPOINT")]
pub s3_endpoint: Option<String>,
}
#[derive(Debug, Config)]
pub struct BackupConfig {
/// Enable automatic backups.
#[config(env = "BACKUP_ENABLED", default = true)]
pub enabled: bool,
/// Backup storage backend: `filesystem` or `s3`.
#[config(env = "BACKUP_STORAGE_BACKEND", default = "filesystem")]
pub backend: String,
/// Path on disk for the filesystem backup backend.
#[config(env = "BACKUP_STORAGE_PATH", default = "/var/lib/tranquil-pds/backups")]
pub path: String,
/// S3 bucket name for backups.
#[config(env = "BACKUP_S3_BUCKET")]
pub s3_bucket: Option<String>,
/// Number of backup revisions to keep per account.
#[config(env = "BACKUP_RETENTION_COUNT", default = 7)]
pub retention_count: u32,
/// Seconds between backup runs.
#[config(env = "BACKUP_INTERVAL_SECS", default = 86400)]
pub interval_secs: u64,
}
#[derive(Debug, Config)]
pub struct CacheConfig {
/// Cache backend: `ripple` (default, built-in gossip) or `valkey`.
#[config(env = "CACHE_BACKEND", default = "ripple")]
pub backend: String,
/// Valkey / Redis connection URL. Required when `backend = "valkey"`.
#[config(env = "VALKEY_URL")]
pub valkey_url: Option<String>,
#[config(nested)]
pub ripple: RippleCacheConfig,
}
#[derive(Debug, Config)]
pub struct PlcConfig {
/// Base URL of the PLC directory.
#[config(env = "PLC_DIRECTORY_URL", default = "https://plc.directory")]
pub directory_url: String,
/// HTTP request timeout in seconds.
#[config(env = "PLC_TIMEOUT_SECS", default = 10)]
pub timeout_secs: u64,
/// TCP connect timeout in seconds.
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
pub connect_timeout_secs: u64,
/// Seconds to cache DID documents in memory.
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
pub did_cache_ttl_secs: u64,
}
#[derive(Debug, Config)]
pub struct FirehoseConfig {
/// Size of the in-memory broadcast buffer for firehose events.
#[config(env = "FIREHOSE_BUFFER_SIZE", default = 10000)]
pub buffer_size: usize,
/// How many hours of historical events to replay for cursor-based
/// firehose connections.
#[config(env = "FIREHOSE_BACKFILL_HOURS", default = 72)]
pub backfill_hours: i64,
/// Maximum number of lagged events before disconnecting a slow consumer.
#[config(env = "FIREHOSE_MAX_LAG", default = 5000)]
pub max_lag: u64,
/// List of relay / crawler notification URLs.
#[config(env = "CRAWLERS", parse_env = split_comma_list)]
pub crawlers: Option<Vec<String>>,
}
impl FirehoseConfig {
/// Returns the list of crawler URLs, falling back to `["https://bsky.network"]`
/// when none are configured.
pub fn crawler_list(&self) -> Vec<String> {
self.crawlers
.clone()
.unwrap_or_else(|| vec!["https://bsky.network".to_string()])
}
}
#[derive(Debug, Config)]
pub struct EmailConfig {
/// Sender email address. When unset, email sending is disabled.
#[config(env = "MAIL_FROM_ADDRESS")]
pub from_address: Option<String>,
/// Display name used in the `From` header.
#[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")]
pub from_name: String,
/// Path to the `sendmail` binary.
#[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")]
pub sendmail_path: String,
}
#[derive(Debug, Config)]
pub struct DiscordConfig {
/// Discord bot token. When unset, Discord integration is disabled.
#[config(env = "DISCORD_BOT_TOKEN")]
pub bot_token: Option<String>,
}
#[derive(Debug, Config)]
pub struct TelegramConfig {
/// Telegram bot token. When unset, Telegram integration is disabled.
#[config(env = "TELEGRAM_BOT_TOKEN")]
pub bot_token: Option<String>,
/// Secret token for incoming webhook verification.
#[config(env = "TELEGRAM_WEBHOOK_SECRET")]
pub webhook_secret: Option<String>,
}
#[derive(Debug, Config)]
pub struct SignalConfig {
/// Path to the `signal-cli` binary.
#[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")]
pub cli_path: String,
/// Sender phone number. When unset, Signal integration is disabled.
#[config(env = "SIGNAL_SENDER_NUMBER")]
pub sender_number: Option<String>,
}
#[derive(Debug, Config)]
pub struct NotificationConfig {
/// Polling interval in milliseconds for the comms queue.
#[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)]
pub poll_interval_ms: u64,
/// Number of notifications to process per batch.
#[config(env = "NOTIFICATION_BATCH_SIZE", default = 100)]
pub batch_size: i64,
}
#[derive(Debug, Config)]
pub struct SsoConfig {
#[config(nested)]
pub github: SsoProviderConfig,
#[config(nested)]
pub discord: SsoDiscordProviderConfig,
#[config(nested)]
pub google: SsoProviderConfig,
#[config(nested)]
pub gitlab: SsoProviderWithIssuerConfig,
#[config(nested)]
pub oidc: SsoProviderWithIssuerConfig,
#[config(nested)]
pub apple: SsoAppleConfig,
}
// Generic SSO provider (GitHub, Google)
#[derive(Debug, Config)]
pub struct SsoProviderConfig {
#[config(default = false)]
pub enabled: bool,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub display_name: Option<String>,
}
// SSO provider with custom env prefixes for Discord
// (since the nested TOML key is `sso.discord` but env vars are `SSO_DISCORD_*`)
#[derive(Debug, Config)]
pub struct SsoDiscordProviderConfig {
#[config(default = false)]
pub enabled: bool,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub display_name: Option<String>,
}
// SSO providers that require an issuer URL (GitLab, OIDC)
#[derive(Debug, Config)]
pub struct SsoProviderWithIssuerConfig {
#[config(default = false)]
pub enabled: bool,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub issuer: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Config)]
pub struct SsoAppleConfig {
#[config(env = "SSO_APPLE_ENABLED", default = false)]
pub enabled: bool,
#[config(env = "SSO_APPLE_CLIENT_ID")]
pub client_id: Option<String>,
#[config(env = "SSO_APPLE_TEAM_ID")]
pub team_id: Option<String>,
#[config(env = "SSO_APPLE_KEY_ID")]
pub key_id: Option<String>,
#[config(env = "SSO_APPLE_PRIVATE_KEY")]
pub private_key: Option<String>,
}
#[derive(Debug, Config)]
pub struct ModerationConfig {
/// External report-handling service URL.
#[config(env = "REPORT_SERVICE_URL")]
pub report_service_url: Option<String>,
/// DID of the external report-handling service.
#[config(env = "REPORT_SERVICE_DID")]
pub report_service_did: Option<String>,
}
#[derive(Debug, Config)]
pub struct ImportConfig {
/// Whether the PDS accepts repo imports.
#[config(env = "ACCEPTING_REPO_IMPORTS", default = true)]
pub accepting: bool,
/// Maximum allowed import archive size in bytes (default 1 GiB).
#[config(env = "MAX_IMPORT_SIZE", default = 1_073_741_824)]
pub max_size: u64,
/// Maximum number of blocks allowed in an import.
#[config(env = "MAX_IMPORT_BLOCKS", default = 500000)]
pub max_blocks: u64,
/// Skip CAR verification during import. Only for development/debugging.
#[config(env = "SKIP_IMPORT_VERIFICATION", default = false)]
pub skip_verification: bool,
}
/// Parse a comma-separated environment variable into a `Vec<String>`,
/// trimming whitespace and dropping empty entries.
///
/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`.
fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> {
Ok(value
.split(',')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect())
}
#[derive(Debug, Config)]
pub struct RippleCacheConfig {
/// Address to bind the Ripple gossip protocol listener.
#[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")]
pub bind_addr: String,
/// List of seed peer addresses.
#[config(env = "RIPPLE_PEERS", parse_env = split_comma_list)]
pub peers: Option<Vec<String>>,
/// Unique machine identifier. Auto-derived from hostname when not set.
#[config(env = "RIPPLE_MACHINE_ID")]
pub machine_id: Option<u64>,
/// Gossip protocol interval in milliseconds.
#[config(env = "RIPPLE_GOSSIP_INTERVAL_MS", default = 200)]
pub gossip_interval_ms: u64,
/// Maximum cache size in megabytes.
#[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)]
pub cache_max_mb: usize,
}
#[derive(Debug, Config)]
pub struct ScheduledConfig {
/// Interval in seconds between scheduled delete checks.
#[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)]
pub delete_check_interval_secs: u64,
}
/// Generate a TOML configuration template with all available options,
/// defaults, and documentation comments.
pub fn template() -> String {
confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default())
}
+2
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
+4 -6
View File
@@ -45,16 +45,14 @@ pub trait BackupStorage: Send + Sync {
}
pub fn backup_retention_count() -> u32 {
std::env::var("BACKUP_RETENTION_COUNT")
.ok()
.and_then(|v| v.parse().ok())
tranquil_config::try_get()
.map(|c| c.backup.retention_count)
.unwrap_or(7)
}
pub fn backup_interval_secs() -> u64 {
std::env::var("BACKUP_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
tranquil_config::try_get()
.map(|c| c.backup.interval_secs)
.unwrap_or(86400)
}
+2
View File
@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
tranquil-types = { workspace = true }
tranquil-config = { workspace = true }
tranquil-crypto = { workspace = true }
tranquil-storage = { workspace = true }
tranquil-cache = { workspace = true }
@@ -29,6 +30,7 @@ bs58 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
cid = { workspace = true }
clap = { workspace = true }
dotenvy = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
@@ -2,7 +2,6 @@ use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -45,7 +44,7 @@ pub async fn send_email(
let email = user.email.ok_or(ApiError::NoEmail)?;
let (user_id, handle) = (user.id, user.handle);
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let subject = input
.subject
.clone()
@@ -3,7 +3,6 @@ use crate::api::error::ApiError;
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::State,
@@ -70,7 +69,7 @@ pub async fn update_account_handle(
{
return Err(ApiError::InvalidHandle(None));
}
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let handle = if !input_handle.contains('.') {
format!("{}.{}", input_handle, hostname_for_handles)
} else {
+8 -6
View File
@@ -8,7 +8,6 @@ use crate::delegation::{
use crate::rate_limit::{AccountCreationLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle};
use crate::util::{pds_hostname, pds_hostname_without_port};
use axum::{
Json,
extract::{Query, State},
@@ -435,8 +434,8 @@ pub async fn create_delegated_account(
Err(response) => return Ok(response),
};
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_handles);
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
@@ -475,7 +474,7 @@ pub async fn create_delegated_account(
Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()),
}
} else {
let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Ok(ApiError::InviteCodeRequired.into_response());
}
@@ -497,8 +496,11 @@ pub async fn create_delegated_account(
}
};
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key));
let genesis_result = match crate::plc::create_genesis_operation(
&signing_key,
@@ -12,7 +12,7 @@ use tranquil_types::Handle;
use crate::comms::comms_repo;
use crate::state::AppState;
use crate::util::{discord_public_key, pds_hostname};
use crate::util::discord_public_key;
#[derive(Deserialize)]
struct Interaction {
@@ -185,7 +185,7 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
user_id,
tranquil_db_traits::CommsChannel::Discord,
&discord_user_id,
pds_hostname(),
&tranquil_config::get().server.hostname,
)
.await
{
+22 -15
View File
@@ -6,7 +6,6 @@ use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
use crate::rate_limit::{AccountCreationLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
use axum::{
Json,
@@ -141,7 +140,7 @@ pub async fn create_account(
}
}
let hostname_for_validation = pds_hostname_without_port();
let hostname_for_validation = tranquil_config::get().server.hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_validation);
let validated_short_handle = if !input.handle.contains('.')
@@ -233,8 +232,8 @@ pub async fn create_account(
},
})
};
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let pds_endpoint = format!("https://{}", hostname);
let suffix = format!(".{}", hostname_for_handles);
let handle = if input.handle.ends_with(&suffix) {
@@ -326,8 +325,11 @@ pub async fn create_account(
)
.into_response();
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
@@ -359,8 +361,11 @@ pub async fn create_account(
genesis_result.did
}
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
@@ -473,7 +478,7 @@ pub async fn create_account(
error!("Error creating session: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let verification_required = if let Some(ref user_email) = email {
let token = crate::auth::verification_token::generate_migration_token(
&did_typed, user_email,
@@ -543,7 +548,7 @@ pub async fn create_account(
return ApiError::HandleTaken.into_response();
}
let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let invite_code_required = tranquil_config::get().server.invite_code_required;
if invite_code_required
&& input
.invite_code
@@ -632,12 +637,14 @@ pub async fn create_account(
let rev_str = rev.as_ref().to_string();
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| {
json!({
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
Some(json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
})
});
}))
} else {
None
};
let preferred_comms_channel = verification_channel;
@@ -748,7 +755,7 @@ pub async fn create_account(
warn!("Failed to create default profile for {}: {}", did, e);
}
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if !is_migration {
if let Some(ref recipient) = verification_recipient {
let verification_token = crate::auth::verification_token::generate_signup_token(
+10 -10
View File
@@ -6,7 +6,7 @@ use crate::rate_limit::{
};
use crate::state::AppState;
use crate::types::Handle;
use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port};
use crate::util::get_header_str;
use axum::{
Json,
extract::{Path, Query, State},
@@ -122,8 +122,8 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, KeyError> {
}
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let hostname = pds_hostname();
let hostname_without_port = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let hostname_without_port = tranquil_config::get().server.hostname_without_port();
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
if host_without_port != hostname_without_port
@@ -275,8 +275,8 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
}
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let current_handle = format!("{}.{}", handle, hostname_for_handles);
let current_handle_typed: Handle = match current_handle.parse() {
Ok(h) => h,
@@ -571,7 +571,7 @@ pub async fn get_recommended_did_credentials(
ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into()))
})?;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let pds_endpoint = format!("https://{}", hostname);
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)
.map_err(|_| ApiError::InternalError(None))?;
@@ -579,9 +579,9 @@ pub async fn get_recommended_did_credentials(
let rotation_keys = if auth.did.starts_with("did:web:") {
vec![]
} else {
let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") {
Ok(key) => key,
Err(_) => {
let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key {
Some(key) => key.clone(),
None => {
warn!(
"PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"
);
@@ -675,7 +675,7 @@ pub async fn update_handle(
"Inappropriate language in handle".into(),
)));
}
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let suffix = format!(".{}", hostname_for_handles);
let is_service_domain =
crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles);
@@ -2,7 +2,6 @@ use crate::api::EmptyResponse;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Auth, Permissive};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
extract::State,
response::{IntoResponse, Response},
@@ -41,7 +40,7 @@ pub async fn request_plc_operation_signature(
.await
.log_db_err("creating PLC token")?;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -4,7 +4,6 @@ use crate::auth::{Auth, Permissive};
use crate::circuit_breaker::with_circuit_breaker;
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -42,7 +41,7 @@ pub async fn submit_plc_operation(
.map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?;
let op = &input.operation;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let public_url = format!("https://{}", hostname);
let user = state
.user_repo
@@ -70,8 +69,11 @@ pub async fn submit_plc_operation(
})?;
let user_did_key = signing_key_to_did_key(&signing_key);
let server_rotation_key =
std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone());
let server_rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| user_did_key.clone());
if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) {
let has_server_key = rotation_keys
.iter()
@@ -69,8 +69,9 @@ struct ReportServiceConfig {
}
fn get_report_service_config() -> Option<ReportServiceConfig> {
let url = std::env::var("REPORT_SERVICE_URL").ok()?;
let did = std::env::var("REPORT_SERVICE_DID").ok()?;
let cfg = tranquil_config::get();
let url = cfg.moderation.report_service_url.clone()?;
let did = cfg.moderation.report_service_did.clone()?;
if url.is_empty() || did.is_empty() {
return None;
}
@@ -1,7 +1,6 @@
use crate::api::error::ApiError;
use crate::auth::{Active, Auth};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -148,7 +147,7 @@ pub async fn request_channel_verification(
match channel {
CommsChannel::Email => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let handle_str = handle.unwrap_or("user");
crate::comms::comms_repo::enqueue_email_update(
state.infra_repo.as_ref(),
@@ -167,7 +166,7 @@ pub async fn request_channel_verification(
})?;
}
_ => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let encoded_token = urlencoding::encode(&formatted_token);
let encoded_identifier = urlencoding::encode(identifier);
let verify_link = format!(
+1 -1
View File
@@ -168,7 +168,7 @@ impl<S: Service<Request, Response = Response, Error = Infallible>> Service<Reque
}
// If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it
if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok()
if tranquil_config::get().server.age_assurance_override
&& (path.ends_with("app.bsky.ageassurance.getState")
|| path.ends_with("app.bsky.unspecced.getAgeAssuranceState"))
{
+1 -1
View File
@@ -63,7 +63,7 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?;
let scheme = parsed.scheme();
if scheme != "https" {
let allow_http = std::env::var("ALLOW_HTTP_PROXY").is_ok()
let allow_http = tranquil_config::get().server.allow_http_proxy
|| url.starts_with("http://127.0.0.1")
|| url.starts_with("http://localhost");
if !allow_http {
+2 -2
View File
@@ -3,7 +3,7 @@ use crate::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
use crate::delegation::DelegationActionType;
use crate::state::AppState;
use crate::types::{CidLink, Did};
use crate::util::{get_header_str, get_max_blob_size};
use crate::util::get_header_str;
use axum::body::Body;
use axum::{
Json,
@@ -89,7 +89,7 @@ pub async fn upload_blob(
.ok_or(ApiError::InternalError(None))?;
let temp_key = format!("temp/{}", uuid::Uuid::new_v4());
let max_size = u64::try_from(get_max_blob_size()).unwrap_or(u64::MAX);
let max_size = tranquil_config::get().server.max_blob_size;
let body_stream = body.into_data_stream();
let mapped_stream =
+5 -16
View File
@@ -18,26 +18,18 @@ use serde_json::json;
use tracing::{debug, error, info, warn};
use tranquil_types::{AtUri, CidLink};
const DEFAULT_MAX_IMPORT_SIZE: usize = 1024 * 1024 * 1024;
const DEFAULT_MAX_BLOCKS: usize = 500000;
pub async fn import_repo(
State(state): State<AppState>,
auth: Auth<NotTakendown>,
body: Bytes,
) -> Result<Response, ApiError> {
let accepting_imports = std::env::var("ACCEPTING_REPO_IMPORTS")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
let accepting_imports = tranquil_config::get().import.accepting;
if !accepting_imports {
return Err(ApiError::InvalidRequest(
"Service is not accepting repo imports".into(),
));
}
let max_size: usize = std::env::var("MAX_IMPORT_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_IMPORT_SIZE);
let max_size = tranquil_config::get().import.max_size as usize;
if body.len() > max_size {
return Err(ApiError::PayloadTooLarge(format!(
"Import size exceeds limit of {} bytes",
@@ -108,7 +100,7 @@ pub async fn import_repo(
commit_did, did
)));
}
let skip_verification = crate::util::parse_env_bool("SKIP_IMPORT_VERIFICATION");
let skip_verification = tranquil_config::get().import.skip_verification;
let is_migration = user.deactivated_at.is_some();
if skip_verification {
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
@@ -196,10 +188,7 @@ pub async fn import_repo(
}
}
}
let max_blocks: usize = std::env::var("MAX_IMPORT_BLOCKS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_BLOCKS);
let max_blocks = tranquil_config::get().import.max_blocks as usize;
let _write_lock = state.repo_write_locks.lock(user_id).await;
match apply_import(
&state.repo_repo,
@@ -324,7 +313,7 @@ pub async fn import_repo(
{
warn!("Failed to sequence import event: {:?}", e);
}
if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() {
if tranquil_config::get().server.age_assurance_override {
let birthdate_pref = json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
+1 -2
View File
@@ -1,7 +1,6 @@
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::AtIdentifier;
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::{Query, State},
@@ -19,7 +18,7 @@ pub async fn describe_repo(
State(state): State<AppState>,
Query(input): Query<DescribeRepoInput>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let user_row = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
@@ -2,7 +2,6 @@ use super::pagination::{PaginationDirection, deserialize_pagination_direction};
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::{AtIdentifier, Nsid, Rkey};
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::{Query, State},
@@ -60,7 +59,7 @@ pub async fn get_record(
_headers: HeaderMap,
Query(input): Query<GetRecordInput>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let user_id_opt = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
@@ -159,7 +158,7 @@ pub async fn list_records(
State(state): State<AppState>,
Query(input): Query<ListRecordsInput>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let user_id_opt = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
@@ -5,7 +5,6 @@ use crate::cache::Cache;
use crate::plc::PlcClient;
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -131,7 +130,7 @@ async fn assert_valid_did_document_for_service(
did: &crate::types::Did,
with_retry: bool,
) -> Result<(), ApiError> {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let expected_endpoint = format!("https://{}", hostname);
if did.as_str().starts_with("did:plc:") {
@@ -201,7 +200,7 @@ async fn assert_valid_did_document_for_service(
.await
.map_err(ApiError::InvalidRequest)?;
let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok();
let server_rotation_key = tranquil_config::get().secrets.plc_rotation_key.clone();
if let Some(ref expected_rotation_key) = server_rotation_key {
let rotation_keys = doc_data
.get("rotationKeys")
@@ -552,7 +551,7 @@ pub async fn request_account_delete(
.create_deletion_request(&confirmation_token, session_mfa.did(), expires_at)
.await
.log_db_err("creating deletion token")?;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
+3 -4
View File
@@ -3,7 +3,6 @@ use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
use crate::auth::{Auth, NotTakendown};
use crate::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -105,7 +104,7 @@ pub async fn request_email_update(
}
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_short_token_email(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -367,7 +366,7 @@ pub async fn update_email(
);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -531,7 +530,7 @@ pub async fn authorize_email_update(
info!(did = %did, "Email update authorized via link click");
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let redirect_url = format!(
"https://{}/app/verify?type=email-authorize-success",
hostname
+1 -2
View File
@@ -3,7 +3,6 @@ use crate::api::error::DbResultExt;
use crate::auth::{Admin, Auth, NotTakendown};
use crate::state::AppState;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -26,7 +25,7 @@ fn gen_random_token() -> String {
}
fn gen_invite_code() -> String {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let hostname_prefix = hostname.replace('.', "-");
format!("{}-{}", hostname_prefix, gen_random_token())
}
+13 -15
View File
@@ -1,18 +1,19 @@
use crate::state::AppState;
use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username};
use crate::util::{discord_app_id, discord_bot_username, telegram_bot_username};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json;
fn get_available_comms_channels() -> Vec<tranquil_db_traits::CommsChannel> {
use tranquil_db_traits::CommsChannel;
let cfg = tranquil_config::get();
let mut channels = vec![CommsChannel::Email];
if std::env::var("DISCORD_BOT_TOKEN").is_ok() {
if cfg.discord.bot_token.is_some() {
channels.push(CommsChannel::Discord);
}
if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() {
if cfg.telegram.bot_token.is_some() {
channels.push(CommsChannel::Telegram);
}
if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() {
if cfg.signal.sender_number.is_some() {
channels.push(CommsChannel::Signal);
}
channels
@@ -26,20 +27,17 @@ pub async fn robots_txt() -> impl IntoResponse {
)
}
pub fn is_self_hosted_did_web_enabled() -> bool {
std::env::var("ENABLE_SELF_HOSTED_DID_WEB")
.map(|v| v != "false" && v != "0")
.unwrap_or(true)
tranquil_config::get().server.enable_pds_hosted_did_web
}
pub async fn describe_server() -> impl IntoResponse {
let pds_hostname = pds_hostname();
let domains_str =
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string());
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok();
let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok();
let contact_email = std::env::var("CONTACT_EMAIL").ok();
let cfg = tranquil_config::get();
let pds_hostname = &cfg.server.hostname;
let domains = cfg.server.available_user_domain_list();
let invite_code_required = cfg.server.invite_code_required;
let privacy_policy = cfg.server.privacy_policy_url.clone();
let terms_of_service = cfg.server.terms_of_service_url.clone();
let contact_email = cfg.server.contact_email.clone();
let mut links = serde_json::Map::new();
if let Some(pp) = privacy_policy {
links.insert("privacyPolicy".to_string(), json!(pp));
@@ -2,7 +2,6 @@ use crate::api::ApiError;
use crate::api::error::DbResultExt;
use crate::auth::{Active, Auth};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -147,7 +146,7 @@ pub async fn get_did_document(
}
async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let user = match state.user_repo.get_user_for_did_doc_build(did).await {
Ok(Some(row)) => row,
@@ -24,7 +24,6 @@ use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token}
use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
fn generate_setup_token() -> String {
@@ -113,8 +112,8 @@ pub async fn create_passkey_account(
.map(|d| d.starts_with("did:web:"))
.unwrap_or(false);
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_handles);
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
@@ -153,7 +152,7 @@ pub async fn create_passkey_account(
Err(_) => return ApiError::InvalidInviteCode.into_response(),
}
} else {
let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return ApiError::InviteCodeRequired.into_response();
}
@@ -309,8 +308,11 @@ pub async fn create_passkey_account(
.into_response();
}
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| crate::plc::signing_key_to_did_key(&secret_key));
let genesis_result = match crate::plc::create_genesis_operation(
&secret_key,
@@ -401,12 +403,14 @@ pub async fn create_passkey_account(
};
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| {
json!({
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
Some(json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
})
});
}))
} else {
None
};
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
@@ -820,7 +824,7 @@ pub async fn request_passkey_recovery(
_rate_limit: RateLimited<PasswordResetLimit>,
Json(input): Json<RequestPasskeyRecoveryInput>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let identifier = input.email.trim().to_lowercase();
let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
let normalized_handle =
@@ -855,7 +859,7 @@ pub async fn request_passkey_recovery(
return ApiError::InternalError(None).into_response();
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let recovery_url = format!(
"https://{}/app/recover-passkey?did={}&token={}",
hostname,
@@ -7,7 +7,6 @@ use crate::auth::{
use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit};
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
use axum::{
Json,
@@ -38,7 +37,7 @@ pub async fn request_password_reset(
if identifier.is_empty() {
return ApiError::InvalidRequest("email or handle is required".into()).into_response();
}
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized = identifier.to_lowercase();
let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
let is_email_lookup = normalized.contains('@');
@@ -78,7 +77,7 @@ pub async fn request_password_reset(
error!("DB error setting reset code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_password_reset(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
+9 -10
View File
@@ -7,7 +7,6 @@ use crate::auth::{
use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
use crate::state::AppState;
use crate::types::{AccountState, Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use axum::{
Json,
extract::State,
@@ -66,10 +65,10 @@ pub async fn create_session(
"create_session called with identifier: {}",
input.identifier
);
let pds_host = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let pds_host = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_identifier =
NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&input.identifier, &hostname_for_handles);
info!(
"Normalized identifier: {} -> {}",
input.identifier, normalized_identifier
@@ -182,7 +181,7 @@ pub async fn create_session(
return ApiError::LegacyLoginBlocked.into_response();
}
Ok(crate::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -286,7 +285,7 @@ pub async fn create_session(
ip = %client_ip,
"Legacy login on TOTP-enabled account - sending notification"
);
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_legacy_login(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -341,7 +340,7 @@ pub async fn get_session(
let preferred_channel_verified = row
.channel_verification
.is_verified(row.preferred_comms_channel);
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let handle = full_handle(&row.handle, pds_hostname);
let account_state = AccountState::from_db_fields(
row.deactivated_at,
@@ -545,7 +544,7 @@ pub async fn refresh_session(
let preferred_channel_verified = u
.channel_verification
.is_verified(u.preferred_comms_channel);
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let handle = full_handle(&u.handle, pds_hostname);
let account_state =
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
@@ -707,7 +706,7 @@ pub async fn confirm_signup(
return ApiError::InternalError(None).into_response();
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_welcome(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -777,7 +776,7 @@ pub async fn resend_verification(
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
+1 -2
View File
@@ -9,7 +9,6 @@ use crate::auth::{
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -52,7 +51,7 @@ pub async fn create_totp_secret(
.log_db_err("fetching handle")?
.ok_or(ApiError::AccountNotFound)?;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let uri = generate_totp_uri(&secret, &handle, hostname);
let qr_code = generate_qr_png_base64(&secret, &handle, hostname).map_err(|e| {
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::state::AppState;
use crate::util::pds_hostname;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -71,7 +70,7 @@ pub async fn resend_migration_verification(
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let token = crate::auth::verification_token::generate_migration_token(&user.did, &email);
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
@@ -1,7 +1,6 @@
use crate::api::error::{ApiError, DbResultExt};
use crate::comms::comms_repo;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
@@ -162,7 +161,7 @@ async fn handle_channel_update(
user_id,
channel,
&recipient,
pds_hostname(),
&tranquil_config::get().server.hostname,
)
.await
{
@@ -260,7 +259,7 @@ async fn handle_signup_verification(
user.id,
channel,
&recipient,
pds_hostname(),
&tranquil_config::get().server.hostname,
)
.await
{
@@ -8,7 +8,6 @@ use tracing::{debug, info, warn};
use crate::comms::comms_repo;
use crate::state::AppState;
use crate::util::pds_hostname;
#[derive(Deserialize)]
struct TelegramUpdate {
@@ -32,9 +31,9 @@ pub async fn handle_telegram_webhook(
headers: HeaderMap,
body: String,
) -> impl IntoResponse {
let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
Ok(s) => s,
Err(_) => {
let expected_secret = match &tranquil_config::get().telegram.webhook_secret {
Some(s) => s.clone(),
None => {
warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured");
return StatusCode::FORBIDDEN;
}
@@ -88,7 +87,7 @@ pub async fn handle_telegram_webhook(
user_id,
tranquil_db_traits::CommsChannel::Telegram,
&from.id.to_string(),
pds_hostname(),
&tranquil_config::get().server.hostname,
)
.await
{
+3 -6
View File
@@ -64,13 +64,10 @@ pub struct DidResolver {
impl DidResolver {
pub fn new() -> Self {
let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300);
let cfg = tranquil_config::get();
let cache_ttl_secs = cfg.plc.did_cache_ttl_secs;
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string());
let plc_directory_url = cfg.plc.directory_url.clone();
let client = Client::builder()
.timeout(Duration::from_secs(10))
+2 -4
View File
@@ -1,4 +1,3 @@
use crate::util::pds_hostname;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
@@ -146,10 +145,9 @@ pub struct ServiceTokenVerifier {
impl ServiceTokenVerifier {
pub fn new() -> Self {
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string());
let plc_directory_url = tranquil_config::get().plc.directory_url.clone();
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let pds_did: Did = format!("did:web:{}", pds_hostname)
.parse()
.expect("PDS hostname produces a valid DID");
@@ -61,13 +61,7 @@ pub struct VerificationToken {
fn derive_verification_key() -> [u8; 32] {
use hkdf::Hkdf;
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-master-key-not-for-production".to_string()
} else {
panic!("MASTER_KEY must be set");
}
});
let master_key = tranquil_config::get().secrets.master_key_or_default();
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
let mut key = [0u8; 32];
hk.expand(b"tranquil-pds-verification-token-v1", &mut key)
+3 -8
View File
@@ -21,14 +21,9 @@ pub struct CommsService {
impl CommsService {
pub fn new(infra_repo: Arc<dyn InfraRepository>) -> Self {
let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000);
let batch_size: i64 = std::env::var("NOTIFICATION_BATCH_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let cfg = tranquil_config::get();
let poll_interval_ms = cfg.notifications.poll_interval_ms;
let batch_size = cfg.notifications.batch_size;
Self {
infra_repo,
senders: HashMap::new(),
+4 -48
View File
@@ -48,39 +48,10 @@ pub struct AuthConfig {
impl AuthConfig {
pub fn init() -> &'static Self {
CONFIG.get_or_init(|| {
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-jwt-secret-not-for-production".to_string()
} else {
panic!(
"JWT_SECRET environment variable must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
let secrets = &tranquil_config::get().secrets;
let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-dpop-secret-not-for-production".to_string()
} else {
panic!(
"DPOP_SECRET environment variable must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
if jwt_secret.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("JWT_SECRET must be at least 32 characters");
}
if dpop_secret.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("DPOP_SECRET must be at least 32 characters");
}
let jwt_secret = secrets.jwt_secret_or_default();
let dpop_secret = secrets.dpop_secret_or_default();
let mut hasher = Sha256::new();
hasher.update(b"oauth-signing-key-derivation:");
@@ -114,22 +85,7 @@ impl AuthConfig {
let kid_hash = kid_hasher.finalize();
let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]);
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-master-key-not-for-production".to_string()
} else {
panic!(
"MASTER_KEY environment variable must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
if master_key.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("MASTER_KEY must be at least 32 characters");
}
let master_key = secrets.master_key_or_default();
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
let mut key_encryption_key = [0u8; 32];
+3 -9
View File
@@ -1,6 +1,5 @@
use crate::circuit_breaker::CircuitBreaker;
use crate::sync::firehose::SequencedEvent;
use crate::util::pds_hostname;
use reqwest::Client;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -42,18 +41,13 @@ impl Crawlers {
self
}
pub fn from_env() -> Option<Self> {
let hostname = pds_hostname();
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let hostname = &cfg.server.hostname;
if hostname == "localhost" {
return None;
}
let crawler_urls: Vec<String> = std::env::var("CRAWLERS")
.unwrap_or_default()
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
.collect();
let crawler_urls = cfg.firehose.crawler_list();
if crawler_urls.is_empty() {
return None;
+2 -4
View File
@@ -87,13 +87,11 @@ pub async fn verify_handle_ownership(
}
}
pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
pub fn is_service_domain_handle(handle: &str, _hostname: &str) -> bool {
if !handle.contains('.') {
return true;
}
let service_domains: Vec<String> = std::env::var("PDS_SERVICE_HANDLE_DOMAINS")
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
.unwrap_or_else(|_| vec![hostname.to_string()]);
let service_domains = tranquil_config::get().server.user_handle_domain_list();
service_domains
.iter()
.any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
+3 -1
View File
@@ -658,7 +658,9 @@ pub fn app(state: AppState) -> Router {
post(api::discord_webhook::handle_discord_webhook)
.layer(DefaultBodyLimit::max(64 * 1024)),
)
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
.layer(DefaultBodyLimit::max(
tranquil_config::get().server.max_blob_size as usize,
))
.layer(axum::middleware::map_response(rewrite_422_to_400))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
+92 -22
View File
@@ -1,4 +1,6 @@
use clap::{Parser, Subcommand};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
@@ -18,10 +20,82 @@ use tranquil_pds::scheduled::{
};
use tranquil_pds::state::AppState;
#[derive(Parser)]
#[command(name = "tranquil-pds", version = BUILD_VERSION, about = "Tranquil AT Protocol PDS")]
struct Cli {
/// Path to a TOML configuration file (also settable via TRANQUIL_PDS_CONFIG env var)
#[arg(short, long, value_name = "FILE", env = "TRANQUIL_PDS_CONFIG")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Validate the configuration and exit
Validate {
/// Skip validation of secrets and database URL (useful when secrets
/// are provided at runtime via environment variables / secret files)
#[arg(long)]
ignore_secrets: bool,
},
/// Print a TOML configuration template to stdout
ConfigTemplate,
}
#[tokio::main]
async fn main() -> ExitCode {
dotenvy::dotenv().ok();
let cli = Cli::parse();
// Handle subcommands that don't need full startup
if let Some(command) = &cli.command {
return match command {
Command::ConfigTemplate => {
print!("{}", tranquil_config::template());
ExitCode::SUCCESS
}
Command::Validate { ignore_secrets } => {
let config = match tranquil_config::load(cli.config.as_ref()) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to load configuration: {e:#}");
return ExitCode::FAILURE;
}
};
match config.validate(*ignore_secrets) {
Ok(()) => {
println!("Configuration is valid.");
ExitCode::SUCCESS
}
Err(e) => {
eprint!("{e}");
ExitCode::FAILURE
}
}
}
};
}
tracing_subscriber::fmt::init();
let config = match tranquil_config::load(cli.config.as_ref()) {
Ok(c) => c,
Err(e) => {
error!("Failed to load configuration: {e:#}");
return ExitCode::FAILURE;
}
};
if let Err(e) = config.validate(false) {
error!("{e}");
return ExitCode::FAILURE;
}
tranquil_config::init(config);
tranquil_pds::metrics::init_metrics();
match run().await {
@@ -66,14 +140,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut comms_service = CommsService::new(state.infra_repo.clone());
let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None;
if let Some(email_sender) = EmailSender::from_env() {
let cfg = tranquil_config::get();
if let Some(email_sender) = EmailSender::from_config(cfg) {
info!("Email comms enabled");
comms_service = comms_service.register_sender(email_sender);
} else {
warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)");
}
if let Some(discord_sender) = DiscordSender::from_env() {
if let Some(discord_sender) = DiscordSender::from_config(cfg) {
info!("Discord comms enabled");
match discord_sender.resolve_bot_username().await {
Ok(username) => {
@@ -96,8 +172,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Some(public_key) => {
tranquil_pds::util::set_discord_public_key(public_key);
info!("Discord Ed25519 public key loaded");
let hostname = std::env::var("PDS_HOSTNAME")
.unwrap_or_else(|_| "localhost".to_string());
let hostname = &tranquil_config::get().server.hostname;
let webhook_url = format!("https://{}/webhook/discord", hostname);
match discord_sender.register_slash_command(&app_id).await {
Ok(()) => info!("Discord /start slash command registered"),
@@ -118,22 +193,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
comms_service = comms_service.register_sender(discord_sender);
}
if let Some(telegram_sender) = TelegramSender::from_env() {
let secret_token = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
Ok(s) => s,
Err(_) => {
return Err(
"TELEGRAM_BOT_TOKEN is set but TELEGRAM_WEBHOOK_SECRET is missing. Both are required for secure Telegram integration.".into()
);
}
};
if let Some(telegram_sender) = TelegramSender::from_config(cfg) {
// Safe to unwrap: validated in TranquilConfig::validate()
let secret_token = tranquil_config::get()
.telegram
.webhook_secret
.clone()
.expect("telegram.webhook_secret checked during config validation");
info!("Telegram comms enabled");
match telegram_sender.resolve_bot_username().await {
Ok(username) => {
info!(bot_username = %username, "Resolved Telegram bot username");
tranquil_pds::util::set_telegram_bot_username(username);
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = tranquil_config::get().server.hostname.clone();
let webhook_url = format!("https://{}/webhook/telegram", hostname);
match telegram_sender
.set_webhook(&webhook_url, Some(&secret_token))
@@ -150,14 +222,14 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
comms_service = comms_service.register_sender(telegram_sender);
}
if let Some(signal_sender) = SignalSender::from_env() {
if let Some(signal_sender) = SignalSender::from_config(cfg) {
info!("Signal comms enabled");
comms_service = comms_service.register_sender(signal_sender);
}
let comms_handle = tokio::spawn(comms_service.run(shutdown.clone()));
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
let crawlers_handle = if let Some(crawlers) = Crawlers::from_config(cfg) {
let crawlers = Arc::new(
crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone()),
);
@@ -197,11 +269,9 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let app = tranquil_pds::app(state);
let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("SERVER_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let cfg = tranquil_config::get();
let host = &cfg.server.host;
let port = cfg.server.port;
let addr: SocketAddr = format!("{}:{}", host, port)
.parse()
+1 -8
View File
@@ -34,14 +34,7 @@ fn get_slur_regexes() -> &'static Vec<Regex> {
}
fn get_extra_banned_words() -> &'static Vec<String> {
EXTRA_BANNED_WORDS.get_or_init(|| {
std::env::var("PDS_BANNED_WORDS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
})
EXTRA_BANNED_WORDS.get_or_init(|| tranquil_config::get().server.banned_word_list())
}
fn strip_trailing_digits(s: &str) -> &str {
@@ -10,7 +10,7 @@ use crate::rate_limit::{
};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::{extract_client_ip, pds_hostname, pds_hostname_without_port};
use crate::util::extract_client_ip;
use axum::{
Json,
extract::{Query, State},
@@ -253,8 +253,8 @@ pub async fn authorize_get(
if let Some(ref login_hint) = request_data.parameters.login_hint {
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
let hostname_for_handles = pds_hostname_without_port();
let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles);
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized = NormalizedLoginIdentifier::normalize(login_hint, &hostname_for_handles);
tracing::info!(normalized = %normalized, "Normalized login_hint");
match state
@@ -526,13 +526,13 @@ pub async fn authorize_post(
url_encode(error_msg)
))
};
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&form.username, &hostname_for_handles);
tracing::debug!(
original_username = %form.username,
normalized_username = %normalized_username,
pds_hostname = %pds_hostname(),
pds_hostname = %tranquil_config::get().server.hostname,
"Normalized username for lookup"
);
let user = match state
@@ -677,7 +677,7 @@ pub async fn authorize_post(
.await
{
Ok(challenge) => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -992,7 +992,7 @@ pub async fn authorize_select(
.await
{
Ok(challenge) => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -1116,7 +1116,7 @@ fn build_success_redirect(
'?'
};
redirect_url.push(separator);
let pds_host = pds_hostname();
let pds_host = &tranquil_config::get().server.hostname;
redirect_url.push_str(&format!(
"iss={}",
url_encode(&format!("https://{}", pds_host))
@@ -1134,7 +1134,7 @@ fn build_intermediate_redirect_url(
state: Option<&str>,
response_mode: Option<&str>,
) -> String {
let pds_host = pds_hostname();
let pds_host = &tranquil_config::get().server.hostname;
let mut url = format!(
"https://{}/oauth/authorize/redirect?redirect_uri={}&code={}",
pds_host,
@@ -1991,9 +1991,9 @@ pub async fn check_user_has_passkeys(
State(state): State<AppState>,
Query(query): Query<CheckPasskeysQuery>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let bare_identifier =
BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles);
BareLoginIdentifier::from_identifier(&query.identifier, &hostname_for_handles);
let user = state
.user_repo
@@ -2023,9 +2023,9 @@ pub async fn check_user_security_status(
State(state): State<AppState>,
Query(query): Query<CheckPasskeysQuery>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_identifier =
NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&query.identifier, &hostname_for_handles);
let user = state
.user_repo
@@ -2131,9 +2131,9 @@ pub async fn passkey_start(
.into_response();
}
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&form.identifier, &hostname_for_handles);
let user = match state
.user_repo
@@ -2602,7 +2602,7 @@ pub async fn passkey_finish(
.await
{
Ok(challenge) => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -2881,7 +2881,7 @@ pub async fn authorize_passkey_finish(
headers: HeaderMap,
Json(form): Json<AuthorizePasskeySubmit>,
) -> Response {
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let passkey_finish_request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
@@ -1,6 +1,5 @@
use crate::oauth::jwks::{JwkSet, create_jwk_set};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
@@ -58,7 +57,7 @@ pub struct AuthorizationServerMetadata {
pub async fn oauth_protected_resource(
State(_state): State<AppState>,
) -> Json<ProtectedResourceMetadata> {
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let public_url = format!("https://{}", pds_hostname);
Json(ProtectedResourceMetadata {
resource: public_url.clone(),
@@ -72,7 +71,7 @@ pub async fn oauth_protected_resource(
pub async fn oauth_authorization_server(
State(_state): State<AppState>,
) -> Json<AuthorizationServerMetadata> {
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let issuer = format!("https://{}", pds_hostname);
Json(AuthorizationServerMetadata {
issuer: issuer.clone(),
@@ -12,7 +12,6 @@ use crate::oauth::{
verify_client_auth,
};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::Json;
use axum::http::{HeaderMap, Method};
use chrono::{Duration, Utc};
@@ -101,7 +100,7 @@ pub async fn handle_authorization_code_grant(
let dpop_jkt = if let Some(proof) = &dpop_proof {
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let token_endpoint = format!("https://{}/oauth/token", pds_hostname);
let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?;
if !state
@@ -348,7 +347,7 @@ pub async fn handle_refresh_token_grant(
let dpop_jkt = if let Some(proof) = &dpop_proof {
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let token_endpoint = format!("https://{}/oauth/token", pds_hostname);
let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?;
if !state
@@ -1,6 +1,5 @@
use crate::config::AuthConfig;
use crate::oauth::OAuthError;
use crate::util::pds_hostname;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
@@ -52,7 +51,7 @@ pub fn create_access_token_with_delegation(
) -> Result<String, OAuthError> {
use serde_json::json;
let jti = uuid::Uuid::new_v4().to_string();
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let issuer = format!("https://{}", pds_hostname);
let now = Utc::now().timestamp();
let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS;
@@ -2,7 +2,6 @@ use super::helpers::extract_token_claims;
use crate::oauth::OAuthError;
use crate::rate_limit::{OAuthIntrospectLimit, OAuthRateLimited};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::extract::State;
use axum::http::StatusCode;
use axum::{Form, Json};
@@ -112,7 +111,7 @@ pub async fn introspect_token(
if token_data.expires_at < Utc::now() {
return Ok(Json(inactive_response));
}
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let issuer = format!("https://{}", pds_hostname);
Ok(Json(IntrospectResponse {
active: true,
+4 -12
View File
@@ -124,18 +124,10 @@ impl PlcClient {
}
pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> Self {
let base_url = base_url.unwrap_or_else(|| {
std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string())
});
let timeout_secs: u64 = std::env::var("PLC_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let connect_timeout_secs: u64 = std::env::var("PLC_CONNECT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5);
let cfg = tranquil_config::get();
let base_url = base_url.unwrap_or_else(|| cfg.plc.directory_url.clone());
let timeout_secs = cfg.plc.timeout_secs;
let connect_timeout_secs = cfg.plc.connect_timeout_secs;
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(connect_timeout_secs))
+2 -6
View File
@@ -438,12 +438,8 @@ pub async fn start_scheduled_tasks(
sso_repo: Arc<dyn SsoRepository>,
shutdown: CancellationToken,
) {
let check_interval = Duration::from_secs(
std::env::var("SCHEDULED_DELETE_CHECK_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3600),
);
let check_interval =
Duration::from_secs(tranquil_config::get().scheduled.delete_check_interval_secs);
info!(
check_interval_secs = check_interval.as_secs(),
+87 -73
View File
@@ -1,4 +1,3 @@
use crate::util::pds_hostname;
use std::sync::OnceLock;
use tranquil_db_traits::SsoProviderType;
@@ -34,24 +33,58 @@ pub struct SsoConfig {
impl SsoConfig {
pub fn init() -> &'static Self {
SSO_CONFIG.get_or_init(|| {
let github = Self::load_provider("GITHUB", false);
let discord = Self::load_provider("DISCORD", false);
let google = Self::load_provider("GOOGLE", false);
let gitlab = Self::load_provider("GITLAB", true);
let oidc = Self::load_provider("OIDC", true);
let apple = Self::load_apple_provider();
let config = SsoConfig {
github,
discord,
google,
gitlab,
oidc,
apple,
let sso = &tranquil_config::get().sso;
let config = SsoConfig {
github: Self::provider_from_config(
sso.github.enabled,
sso.github.client_id.as_deref(),
sso.github.client_secret.as_deref(),
None,
sso.github.display_name.as_deref(),
"GITHUB",
false,
),
discord: Self::provider_from_config(
sso.discord.enabled,
sso.discord.client_id.as_deref(),
sso.discord.client_secret.as_deref(),
None,
sso.discord.display_name.as_deref(),
"DISCORD",
false,
),
google: Self::provider_from_config(
sso.google.enabled,
sso.google.client_id.as_deref(),
sso.google.client_secret.as_deref(),
None,
sso.google.display_name.as_deref(),
"GOOGLE",
false,
),
gitlab: Self::provider_from_config(
sso.gitlab.enabled,
sso.gitlab.client_id.as_deref(),
sso.gitlab.client_secret.as_deref(),
sso.gitlab.issuer.as_deref(),
sso.gitlab.display_name.as_deref(),
"GITLAB",
true,
),
oidc: Self::provider_from_config(
sso.oidc.enabled,
sso.oidc.client_id.as_deref(),
sso.oidc.client_secret.as_deref(),
sso.oidc.issuer.as_deref(),
sso.oidc.display_name.as_deref(),
"OIDC",
true,
),
apple: Self::apple_from_config(&sso.apple),
};
if config.is_any_enabled() {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if hostname.is_empty() || hostname == "localhost" {
panic!(
"PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \
@@ -72,89 +105,70 @@ impl SsoConfig {
})
}
pub fn get_redirect_uri() -> &'static str {
SSO_REDIRECT_URI
.get()
.map(|s| s.as_str())
.expect("SSO redirect URI not initialized - call SsoConfig::init() first")
}
fn load_provider(name: &str, needs_issuer: bool) -> Option<ProviderConfig> {
let enabled = crate::util::parse_env_bool(&format!("SSO_{}_ENABLED", name));
fn provider_from_config(
enabled: bool,
client_id: Option<&str>,
client_secret: Option<&str>,
issuer: Option<&str>,
display_name: Option<&str>,
name: &str,
needs_issuer: bool,
) -> Option<ProviderConfig> {
if !enabled {
return None;
}
let client_id = client_id.filter(|s| !s.is_empty())?;
let client_secret = client_secret.filter(|s| !s.is_empty())?;
let client_id = std::env::var(format!("SSO_{}_CLIENT_ID", name)).ok()?;
let client_secret = std::env::var(format!("SSO_{}_CLIENT_SECRET", name)).ok()?;
if client_id.is_empty() || client_secret.is_empty() {
tracing::warn!(
"SSO_{} enabled but missing client_id or client_secret",
name
);
return None;
}
let issuer = if needs_issuer {
let issuer_val = std::env::var(format!("SSO_{}_ISSUER", name)).ok();
if issuer_val.is_none() || issuer_val.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
if needs_issuer {
let issuer_val = issuer.filter(|s| !s.is_empty());
if issuer_val.is_none() {
tracing::warn!("SSO_{} requires ISSUER but none provided", name);
return None;
}
issuer_val
} else {
None
};
let display_name = std::env::var(format!("SSO_{}_NAME", name)).ok();
}
Some(ProviderConfig {
client_id,
client_secret,
issuer,
display_name,
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
issuer: issuer.map(|s| s.to_string()),
display_name: display_name.map(|s| s.to_string()),
})
}
fn load_apple_provider() -> Option<AppleProviderConfig> {
let enabled = crate::util::parse_env_bool("SSO_APPLE_ENABLED");
if !enabled {
fn apple_from_config(cfg: &tranquil_config::SsoAppleConfig) -> Option<AppleProviderConfig> {
if !cfg.enabled {
return None;
}
let client_id = cfg.client_id.as_deref().filter(|s| !s.is_empty())?;
let team_id = cfg.team_id.as_deref().filter(|s| !s.is_empty())?;
let key_id = cfg.key_id.as_deref().filter(|s| !s.is_empty())?;
let private_key_pem = cfg.private_key.as_deref().filter(|s| !s.is_empty())?;
let client_id = std::env::var("SSO_APPLE_CLIENT_ID").ok()?;
let team_id = std::env::var("SSO_APPLE_TEAM_ID").ok()?;
let key_id = std::env::var("SSO_APPLE_KEY_ID").ok()?;
let private_key_pem = std::env::var("SSO_APPLE_PRIVATE_KEY").ok()?;
if client_id.is_empty() {
tracing::warn!("SSO_APPLE enabled but missing CLIENT_ID");
return None;
}
if team_id.is_empty() || team_id.len() != 10 {
if team_id.len() != 10 {
tracing::warn!("SSO_APPLE enabled but TEAM_ID is invalid (must be 10 characters)");
return None;
}
if key_id.is_empty() {
tracing::warn!("SSO_APPLE enabled but missing KEY_ID");
return None;
}
if private_key_pem.is_empty() || !private_key_pem.contains("PRIVATE KEY") {
if !private_key_pem.contains("PRIVATE KEY") {
tracing::warn!("SSO_APPLE enabled but PRIVATE_KEY is invalid");
return None;
}
Some(AppleProviderConfig {
client_id,
team_id,
key_id,
private_key_pem,
client_id: client_id.to_string(),
team_id: team_id.to_string(),
key_id: key_id.to_string(),
private_key_pem: private_key_pem.to_string(),
})
}
pub fn get_redirect_uri() -> &'static str {
SSO_REDIRECT_URI
.get()
.map(|s| s.as_str())
.expect("SSO redirect URI not initialized - call SsoConfig::init() first")
}
pub fn get() -> &'static Self {
SSO_CONFIG.get_or_init(SsoConfig::default)
}
+16 -12
View File
@@ -18,7 +18,6 @@ use crate::rate_limit::{
check_user_rate_limit_with_message,
};
use crate::state::AppState;
use crate::util::{pds_hostname, pds_hostname_without_port};
fn generate_state() -> String {
use rand::RngCore;
@@ -773,7 +772,7 @@ pub async fn check_handle_available(
}
};
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let full_handle = format!("{}.{}", validated, hostname_for_handles);
let handle_typed: crate::types::Handle = match full_handle.parse() {
Ok(h) => h,
@@ -856,8 +855,8 @@ pub async fn complete_registration(
.await?
.ok_or(ApiError::SsoSessionExpired)?;
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let handle = match crate::api::validation::validate_short_handle(&input.handle) {
Ok(h) => format!("{}.{}", h, hostname_for_handles),
@@ -948,7 +947,7 @@ pub async fn complete_registration(
Err(_) => return Err(ApiError::InvalidInviteCode),
}
} else {
let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Err(ApiError::InviteCodeRequired);
}
@@ -1006,8 +1005,11 @@ pub async fn complete_registration(
d.to_string()
}
_ => {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key));
let genesis_result = match crate::plc::create_genesis_operation(
&signing_key,
@@ -1085,12 +1087,14 @@ pub async fn complete_registration(
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| {
json!({
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
Some(json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
})
});
}))
} else {
None
};
let create_input = tranquil_db_traits::CreateSsoAccountInput {
handle: handle_typed.clone(),
@@ -1299,7 +1303,7 @@ pub async fn complete_registration(
return Err(ApiError::InternalError(None));
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_welcome(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
+12 -28
View File
@@ -1,19 +1,18 @@
use crate::appview::DidResolver;
use crate::auth::webauthn::WebAuthnConfig;
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
use crate::cache::{create_cache, Cache, DistributedRateLimiter};
use crate::circuit_breaker::CircuitBreakers;
use crate::config::AuthConfig;
use crate::rate_limit::RateLimiters;
use crate::repo::PostgresBlockStore;
use crate::repo_write_lock::RepoWriteLocks;
use crate::sso::{SsoConfig, SsoManager};
use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage};
use crate::storage::{create_backup_storage, create_blob_storage, BackupStorage, BlobStorage};
use crate::sync::firehose::SequencedEvent;
use crate::util::pds_hostname;
use sqlx::PgPool;
use std::error::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use tranquil_db::{
@@ -25,10 +24,10 @@ use tranquil_db::{
static RATE_LIMITING_DISABLED: AtomicBool = AtomicBool::new(false);
pub fn init_rate_limit_override() {
let disabled = std::env::var("DISABLE_RATE_LIMITING").is_ok();
let disabled = tranquil_config::get().server.disable_rate_limiting;
RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed);
if disabled {
tracing::warn!("rate limiting is DISABLED via DISABLE_RATE_LIMITING env var");
tracing::warn!("rate limiting is DISABLED via configuration");
}
}
@@ -205,23 +204,11 @@ impl RateLimitKind {
impl AppState {
pub async fn new(shutdown: CancellationToken) -> Result<Self, Box<dyn Error>> {
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| "DATABASE_URL environment variable must be set")?;
let max_connections: u32 = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let min_connections: u32 = std::env::var("DATABASE_MIN_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let acquire_timeout_secs: u64 = std::env::var("DATABASE_ACQUIRE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let cfg = tranquil_config::get();
let database_url = &cfg.database.url;
let max_connections = cfg.database.max_connections;
let min_connections = cfg.database.min_connections;
let acquire_timeout_secs = cfg.database.acquire_timeout_secs;
tracing::info!(
"Configuring database pool: max={}, min={}, acquire_timeout={}s",
@@ -257,10 +244,7 @@ impl AppState {
let blob_store = create_blob_storage().await;
let backup_storage = create_backup_storage().await;
let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10000);
let firehose_buffer_size = tranquil_config::get().firehose.buffer_size;
let (firehose_tx, _) = broadcast::channel(firehose_buffer_size);
let rate_limiters = Arc::new(RateLimiters::new());
@@ -271,7 +255,7 @@ impl AppState {
let sso_config = SsoConfig::init();
let sso_manager = SsoManager::from_config(sso_config);
let webauthn_config = Arc::new(
WebAuthnConfig::new(pds_hostname())
WebAuthnConfig::new(&tranquil_config::get().server.hostname)
.expect("Failed to create WebAuthn config at startup"),
);
@@ -59,10 +59,7 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, params: Subscribe
}
fn get_backfill_hours() -> i64 {
std::env::var("FIREHOSE_BACKFILL_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(72)
tranquil_config::get().firehose.backfill_hours
}
async fn handle_socket_inner(
@@ -204,10 +201,7 @@ async fn handle_socket_inner(
}
}
}
let max_lag_before_disconnect: u64 = std::env::var("FIREHOSE_MAX_LAG")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000);
let max_lag_before_disconnect: u64 = tranquil_config::get().firehose.max_lag;
loop {
tokio::select! {
result = rx.recv() => {
+1 -2
View File
@@ -145,8 +145,7 @@ impl CarVerifier {
}
async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
let plc_url = std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string());
let plc_url = tranquil_config::get().plc.directory_url.clone();
let url = format!("{}/{}", plc_url, urlencoding::encode(did));
let response = self
.http_client
+5 -31
View File
@@ -9,25 +9,12 @@ use std::str::FromStr;
use std::sync::OnceLock;
const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567";
const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024;
static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new();
static PDS_HOSTNAME: OnceLock<String> = OnceLock::new();
static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new();
static DISCORD_BOT_USERNAME: OnceLock<String> = OnceLock::new();
static DISCORD_PUBLIC_KEY: OnceLock<ed25519_dalek::VerifyingKey> = OnceLock::new();
static DISCORD_APP_ID: OnceLock<String> = OnceLock::new();
static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new();
pub fn get_max_blob_size() -> usize {
*MAX_BLOB_SIZE.get_or_init(|| {
std::env::var("MAX_BLOB_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_BLOB_SIZE)
})
}
pub fn generate_token_code() -> String {
generate_token_code_parts(2, 5)
}
@@ -109,18 +96,6 @@ pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> Strin
.unwrap_or_else(|| "unknown".to_string())
}
pub fn pds_hostname() -> &'static str {
PDS_HOSTNAME
.get_or_init(|| std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()))
}
pub fn pds_hostname_without_port() -> &'static str {
PDS_HOSTNAME_WITHOUT_PORT.get_or_init(|| {
let hostname = pds_hostname();
hostname.split(':').next().unwrap_or(hostname).to_string()
})
}
pub fn set_discord_bot_username(username: String) {
DISCORD_BOT_USERNAME.set(username).ok();
}
@@ -154,26 +129,25 @@ pub fn telegram_bot_username() -> Option<&'static str> {
}
pub fn parse_env_bool(key: &str) -> bool {
// Check the config system first, then fall back to env var for dynamic
// SSO keys that are not in the static config struct.
std::env::var(key)
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
pub fn pds_public_url() -> String {
format!("https://{}", pds_hostname())
}
pub fn build_full_url(path: &str) -> String {
let cfg = tranquil_config::get();
let normalized_path = if !path.starts_with("/xrpc/")
&& (path.starts_with("/com.atproto.")
|| path.starts_with("/app.bsky.")
|| path.starts_with("/_"))
{
format!("/xrpc{}", path)
format!("/xrpc{path}")
} else {
path.to_string()
};
format!("{}{}", pds_public_url(), normalized_path)
format!("{}{normalized_path}", cfg.server.public_url())
}
pub fn json_to_ipld(value: &JsonValue) -> Ipld {
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true }
async-trait = { workspace = true }
+23 -42
View File
@@ -15,30 +15,20 @@ pub struct RippleConfig {
pub cache_max_bytes: usize,
}
fn parse_env_with_warning<T: std::str::FromStr>(var_name: &str, raw: &str) -> Option<T> {
match raw.parse::<T>() {
Ok(v) => Some(v),
Err(_) => {
tracing::warn!(
var = var_name,
value = raw,
"invalid env var value, using default"
);
None
}
}
}
impl RippleConfig {
pub fn from_env() -> Result<Self, RippleConfigError> {
let bind_addr: SocketAddr = std::env::var("RIPPLE_BIND")
.unwrap_or_else(|_| "0.0.0.0:0".into())
pub fn from_config() -> Result<Self, RippleConfigError> {
let ripple = &tranquil_config::get().cache.ripple;
let bind_addr: SocketAddr = ripple
.bind_addr
.parse()
.map_err(|e| RippleConfigError::InvalidAddr(format!("{e}")))?;
let seed_peers: Vec<SocketAddr> = std::env::var("RIPPLE_PEERS")
.unwrap_or_default()
.split(',')
let seed_peers: Vec<SocketAddr> = ripple
.peers
.as_deref()
.unwrap_or(&[])
.iter()
.filter(|s| !s.trim().is_empty())
.map(|s| {
s.trim()
@@ -47,30 +37,21 @@ impl RippleConfig {
})
.collect::<Result<Vec<_>, _>>()?;
let machine_id: u64 = std::env::var("RIPPLE_MACHINE_ID")
.ok()
.and_then(|v| parse_env_with_warning::<u64>("RIPPLE_MACHINE_ID", &v))
.unwrap_or_else(|| {
let host_str = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| format!("pid-{}", std::process::id()));
let input = format!("{host_str}:{bind_addr}:{}", std::process::id());
fnv1a(input.as_bytes())
});
let machine_id = ripple.machine_id.unwrap_or_else(|| {
let host_str = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| format!("pid-{}", std::process::id()));
let input = format!("{host_str}:{bind_addr}:{}", std::process::id());
fnv1a(input.as_bytes())
});
let gossip_interval_ms: u64 = std::env::var("RIPPLE_GOSSIP_INTERVAL_MS")
.ok()
.and_then(|v| parse_env_with_warning::<u64>("RIPPLE_GOSSIP_INTERVAL_MS", &v))
.unwrap_or(200)
.max(50);
let gossip_interval_ms = ripple.gossip_interval_ms.max(50);
let cache_max_mb: usize = std::env::var("RIPPLE_CACHE_MAX_MB")
.ok()
.and_then(|v| parse_env_with_warning::<usize>("RIPPLE_CACHE_MAX_MB", &v))
.unwrap_or(256)
.clamp(1, 16_384);
let cache_max_bytes = cache_max_mb.saturating_mul(1024).saturating_mul(1024);
let cache_max_bytes = ripple
.cache_max_mb
.clamp(1, 16_384)
.saturating_mul(1024)
.saturating_mul(1024);
Ok(Self {
bind_addr,
+1
View File
@@ -9,6 +9,7 @@ default = []
s3 = ["dep:aws-config", "dep:aws-sdk-s3"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true }
async-trait = { workspace = true }
+34 -34
View File
@@ -121,7 +121,12 @@ mod s3 {
impl S3BlobStorage {
pub async fn new() -> Self {
let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set");
let cfg = tranquil_config::get();
let bucket = cfg
.storage
.s3_bucket
.clone()
.expect("storage.s3_bucket (S3_BUCKET) must be set");
let client = create_s3_client().await;
Self { client, bucket }
}
@@ -140,16 +145,20 @@ mod s3 {
.load()
.await;
std::env::var("S3_ENDPOINT").ok().map_or_else(
|| Client::new(&config),
|endpoint| {
let s3_config = aws_sdk_s3::config::Builder::from(&config)
.endpoint_url(endpoint)
.force_path_style(true)
.build();
Client::from_conf(s3_config)
},
)
tranquil_config::get()
.storage
.s3_endpoint
.as_deref()
.map_or_else(
|| Client::new(&config),
|endpoint| {
let s3_config = aws_sdk_s3::config::Builder::from(&config)
.endpoint_url(endpoint)
.force_path_style(true)
.build();
Client::from_conf(s3_config)
},
)
}
pub struct S3BackupStorage {
@@ -159,7 +168,7 @@ mod s3 {
impl S3BackupStorage {
pub async fn new() -> Option<Self> {
let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?;
let bucket = tranquil_config::get().backup.s3_bucket.clone()?;
let client = create_s3_client().await;
Some(Self { client, bucket })
}
@@ -499,12 +508,6 @@ impl FilesystemBlobStorage {
})
}
pub async fn from_env() -> Result<Self, StorageError> {
let path = std::env::var("BLOB_STORAGE_PATH")
.map_err(|_| StorageError::Other("BLOB_STORAGE_PATH not set".into()))?;
Self::new(path).await
}
fn resolve_path(&self, key: &str) -> Result<PathBuf, StorageError> {
validate_key(key)?;
Ok(split_cid_path(key).map_or_else(
@@ -649,12 +652,6 @@ impl FilesystemBackupStorage {
})
}
pub async fn from_env() -> Result<Self, StorageError> {
let path = std::env::var("BACKUP_STORAGE_PATH")
.map_err(|_| StorageError::Other("BACKUP_STORAGE_PATH not set".into()))?;
Self::new(path).await
}
fn resolve_path(&self, key: &str) -> Result<PathBuf, StorageError> {
validate_key(key)?;
Ok(self.base_path.join(key))
@@ -701,7 +698,8 @@ impl BackupStorage for FilesystemBackupStorage {
}
pub async fn create_blob_storage() -> Arc<dyn BlobStorage> {
let backend = std::env::var("BLOB_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into());
let cfg = tranquil_config::get();
let backend = &cfg.storage.backend;
match backend.as_str() {
#[cfg(feature = "s3")]
@@ -718,7 +716,8 @@ pub async fn create_blob_storage() -> Arc<dyn BlobStorage> {
}
_ => {
tracing::info!("Initializing filesystem blob storage");
FilesystemBlobStorage::from_env()
let path = cfg.storage.path.clone();
FilesystemBlobStorage::new(path)
.await
.unwrap_or_else(|e| {
panic!(
@@ -733,16 +732,14 @@ pub async fn create_blob_storage() -> Arc<dyn BlobStorage> {
}
pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> {
let enabled = std::env::var("BACKUP_ENABLED")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
let cfg = tranquil_config::get();
if !enabled {
tracing::info!("Backup storage disabled via BACKUP_ENABLED=false");
if !cfg.backup.enabled {
tracing::info!("Backup storage disabled via config");
return None;
}
let backend = std::env::var("BACKUP_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into());
let backend = &cfg.backup.backend;
match backend.as_str() {
#[cfg(feature = "s3")]
@@ -767,7 +764,9 @@ pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> {
);
None
}
_ => FilesystemBackupStorage::from_env().await.map_or_else(
_ => {
let path = cfg.backup.path.clone();
FilesystemBackupStorage::new(path).await.map_or_else(
|e| {
tracing::error!(
"Failed to initialize filesystem backup storage: {}. \
@@ -781,7 +780,8 @@ pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> {
tracing::info!("Initialized filesystem backup storage");
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
},
),
)
}
}
}
+1
View File
@@ -17,6 +17,7 @@ services:
MASTER_KEY: "${MASTER_KEY:?MASTER_KEY is required (min 32 chars)}"
CRAWLERS: "${CRAWLERS:-https://bsky.network}"
volumes:
- ./config.toml:/etc/tranquil-pds/config.toml:ro
- blob_data:/var/lib/tranquil/blobs
- backup_data:/var/lib/tranquil/backups
depends_on:
+1
View File
@@ -11,6 +11,7 @@ services:
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/pds
volumes:
- ./config.toml:/etc/tranquil-pds/config.toml:ro
- blob_data:/var/lib/tranquil/blobs
- backup_data:/var/lib/tranquil/backups
depends_on:
+20 -18
View File
@@ -18,10 +18,10 @@ This guide covers deploying Tranquil PDS using containers with podman.
If you just want to get running quickly:
```sh
cp .env.example .env
cp example.toml config.toml
```
Edit `.env` with your values. Generate secrets with `openssl rand -base64 48`.
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
Build and start:
```sh
@@ -59,7 +59,7 @@ Run the backend with host networking (so it can access postgres on localhost) an
```sh
podman run -d --name tranquil-pds \
--network=host \
--env-file /etc/tranquil-pds/tranquil-pds.env \
-v /etc/tranquil-pds/config.toml:/etc/tranquil-pds/config.toml:ro,Z \
-v /var/lib/tranquil:/var/lib/tranquil:Z \
tranquil-pds:latest
```
@@ -113,18 +113,22 @@ mkdir -p /etc/containers/systemd
mkdir -p /srv/tranquil-pds/{postgres,blobs,backups,certs,acme,config}
```
## Create an environment file
## Create a configuration file
```bash
cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env
chmod 600 /srv/tranquil-pds/config/tranquil-pds.env
cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml
chmod 600 /srv/tranquil-pds/config/config.toml
```
Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with:
Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with:
```bash
openssl rand -base64 48
```
> **Note:** Every config option can also be set via environment variables
> (see comments in `example.toml`). Environment variables always take
> precedence over the config file.
## Install quadlet definitions
Copy the quadlet files from the repository:
@@ -157,7 +161,6 @@ podman build -t tranquil-pds-frontend:latest ./frontend
## Create podman secrets
```bash
source /srv/tranquil-pds/config/tranquil-pds.env
echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password -
```
@@ -264,18 +267,22 @@ podman build -t tranquil-pds:latest .
podman build -t tranquil-pds-frontend:latest ./frontend
```
## Create an environment file
## Create a configuration file
```sh
cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env
chmod 600 /srv/tranquil-pds/config/tranquil-pds.env
cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml
chmod 600 /srv/tranquil-pds/config/config.toml
```
Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with:
Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with:
```sh
openssl rand -base64 48
```
> **Note:** Every config option can also be set via environment variables
> (see comments in `example.toml`). Environment variables always take
> precedence over the config file.
## Set up compose and nginx
Copy the production compose and nginx configs:
@@ -308,16 +315,11 @@ depend() {
after firewall
}
start_pre() {
set -a
. /srv/tranquil-pds/config/tranquil-pds.env
set +a
checkpath -d /srv/tranquil-pds
}
stop() {
ebegin "Stopping ${name}"
cd /srv/tranquil-pds
set -a
. /srv/tranquil-pds/config/tranquil-pds.env
set +a
podman-compose -f /srv/tranquil-pds/docker-compose.yml down
eend $?
}
+14 -5
View File
@@ -73,15 +73,25 @@ cargo build --release
```bash
mkdir -p /etc/tranquil-pds
cp /opt/tranquil-pds/.env.example /etc/tranquil-pds/tranquil-pds.env
chmod 600 /etc/tranquil-pds/tranquil-pds.env
cp /opt/tranquil-pds/example.toml /etc/tranquil-pds/config.toml
chmod 600 /etc/tranquil-pds/config.toml
```
Edit `/etc/tranquil-pds/tranquil-pds.env` and fill in your values. Generate secrets with:
Edit `/etc/tranquil-pds/config.toml` and fill in your values. Generate secrets with:
```bash
openssl rand -base64 48
```
> **Note:** Every config option can also be set via environment variables
> (see comments in `example.toml`). Environment variables always take
> precedence over the config file. You can also pass the config file path
> via the `TRANQUIL_PDS_CONFIG` env var instead of `--config`.
You can validate your configuration before starting the service:
```bash
/usr/local/bin/tranquil-pds --config /etc/tranquil-pds/config.toml validate
```
## Install frontend files
```bash
@@ -105,8 +115,7 @@ After=network.target postgresql.service
Type=simple
User=tranquil-pds
Group=tranquil-pds
EnvironmentFile=/etc/tranquil-pds/tranquil-pds.env
ExecStart=/usr/local/bin/tranquil-pds
ExecStart=/usr/local/bin/tranquil-pds --config /etc/tranquil-pds/config.toml
Restart=always
RestartSec=5
ProtectSystem=strict
+4 -1
View File
@@ -9,13 +9,16 @@ If you're reaching for kubernetes for this app, you're experienced enough to kno
You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. User handles are served as subdomains.
The container image expects:
- A TOML config file mounted at `/etc/tranquil-pds/config.toml` (or passed via `--config`)
- `DATABASE_URL` - postgres connection string
- `BLOB_STORAGE_PATH` - path to blob storage (mount a PV here)
- `BACKUP_STORAGE_PATH` - path for repo backups (optional but recommended)
- `PDS_HOSTNAME` - your PDS hostname (without protocol)
- `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48`
- `CRAWLERS` - typically `https://bsky.network`
and more, check the .env.example.
and more, check the example.toml for all options. Environment variables can override any TOML value.
You can also point to a config file via the `TRANQUIL_PDS_CONFIG` env var.
Health check: `GET /xrpc/_health`
+509
View File
@@ -0,0 +1,509 @@
[server]
# Public hostname of the PDS (e.g. `pds.example.com`).
#
# Can also be specified via environment variable `PDS_HOSTNAME`.
#
# Required! This value must be specified.
#hostname =
# Address to bind the HTTP server to.
#
# Can also be specified via environment variable `SERVER_HOST`.
#
# Default value: "127.0.0.1"
#host = "127.0.0.1"
# Port to bind the HTTP server to.
#
# Can also be specified via environment variable `SERVER_PORT`.
#
# Default value: 3000
#port = 3000
# List of domains for user handles.
# Defaults to the PDS hostname when not set.
#
# Can also be specified via environment variable `PDS_USER_HANDLE_DOMAINS`.
#user_handle_domains =
# List of domains available for user registration.
# Defaults to the PDS hostname when not set.
#
# Can also be specified via environment variable `AVAILABLE_USER_DOMAINS`.
#available_user_domains =
# Enable PDS-hosted did:web identities. Hosting did:web requires a
# long-term commitment to serve DID documents; opt-in only.
#
# Can also be specified via environment variable `ENABLE_PDS_HOSTED_DID_WEB`.
#
# Default value: false
#enable_pds_hosted_did_web = false
# When set to true, skip age-assurance birthday prompt for all accounts.
#
# Can also be specified via environment variable `PDS_AGE_ASSURANCE_OVERRIDE`.
#
# Default value: false
#age_assurance_override = false
# Require an invite code for new account registration.
#
# Can also be specified via environment variable `INVITE_CODE_REQUIRED`.
#
# Default value: true
#invite_code_required = true
# Allow HTTP (non-TLS) proxy requests. Only useful during development.
#
# Can also be specified via environment variable `ALLOW_HTTP_PROXY`.
#
# Default value: false
#allow_http_proxy = false
# Disable all rate limiting. Should only be used in testing.
#
# Can also be specified via environment variable `DISABLE_RATE_LIMITING`.
#
# Default value: false
#disable_rate_limiting = false
# List of additional banned words for handle validation.
#
# Can also be specified via environment variable `PDS_BANNED_WORDS`.
#banned_words =
# URL to a privacy policy page.
#
# Can also be specified via environment variable `PRIVACY_POLICY_URL`.
#privacy_policy_url =
# URL to terms of service page.
#
# Can also be specified via environment variable `TERMS_OF_SERVICE_URL`.
#terms_of_service_url =
# Operator contact email address.
#
# Can also be specified via environment variable `CONTACT_EMAIL`.
#contact_email =
# Maximum allowed blob size in bytes (default 10 GiB).
#
# Can also be specified via environment variable `MAX_BLOB_SIZE`.
#
# Default value: 10737418240
#max_blob_size = 10737418240
[database]
# PostgreSQL connection URL.
#
# Can also be specified via environment variable `DATABASE_URL`.
#
# Required! This value must be specified.
#url =
# Maximum number of connections in the pool.
#
# Can also be specified via environment variable `DATABASE_MAX_CONNECTIONS`.
#
# Default value: 100
#max_connections = 100
# Minimum number of idle connections kept in the pool.
#
# Can also be specified via environment variable `DATABASE_MIN_CONNECTIONS`.
#
# Default value: 10
#min_connections = 10
# Timeout in seconds when acquiring a connection from the pool.
#
# Can also be specified via environment variable `DATABASE_ACQUIRE_TIMEOUT_SECS`.
#
# Default value: 10
#acquire_timeout_secs = 10
[secrets]
# Secret used for signing JWTs. Must be at least 32 characters in
# production.
#
# Can also be specified via environment variable `JWT_SECRET`.
#jwt_secret =
# Secret used for DPoP proof validation. Must be at least 32 characters
# in production.
#
# Can also be specified via environment variable `DPOP_SECRET`.
#dpop_secret =
# Master key used for key-encryption and HKDF derivation. Must be at
# least 32 characters in production.
#
# Can also be specified via environment variable `MASTER_KEY`.
#master_key =
# PLC rotation key (DID key). If not set, user-level keys are used.
#
# Can also be specified via environment variable `PLC_ROTATION_KEY`.
#plc_rotation_key =
# Allow insecure/test secrets. NEVER enable in production.
#
# Can also be specified via environment variable `TRANQUIL_PDS_ALLOW_INSECURE_SECRETS`.
#
# Default value: false
#allow_insecure = false
[storage]
# Storage backend: `filesystem` or `s3`.
#
# Can also be specified via environment variable `BLOB_STORAGE_BACKEND`.
#
# Default value: "filesystem"
#backend = "filesystem"
# Path on disk for the filesystem blob backend.
#
# Can also be specified via environment variable `BLOB_STORAGE_PATH`.
#
# Default value: "/var/lib/tranquil-pds/blobs"
#path = "/var/lib/tranquil-pds/blobs"
# S3 bucket name for blob storage.
#
# Can also be specified via environment variable `S3_BUCKET`.
#s3_bucket =
# Custom S3 endpoint URL (for MinIO, R2, etc.).
#
# Can also be specified via environment variable `S3_ENDPOINT`.
#s3_endpoint =
[backup]
# Enable automatic backups.
#
# Can also be specified via environment variable `BACKUP_ENABLED`.
#
# Default value: true
#enabled = true
# Backup storage backend: `filesystem` or `s3`.
#
# Can also be specified via environment variable `BACKUP_STORAGE_BACKEND`.
#
# Default value: "filesystem"
#backend = "filesystem"
# Path on disk for the filesystem backup backend.
#
# Can also be specified via environment variable `BACKUP_STORAGE_PATH`.
#
# Default value: "/var/lib/tranquil-pds/backups"
#path = "/var/lib/tranquil-pds/backups"
# S3 bucket name for backups.
#
# Can also be specified via environment variable `BACKUP_S3_BUCKET`.
#s3_bucket =
# Number of backup revisions to keep per account.
#
# Can also be specified via environment variable `BACKUP_RETENTION_COUNT`.
#
# Default value: 7
#retention_count = 7
# Seconds between backup runs.
#
# Can also be specified via environment variable `BACKUP_INTERVAL_SECS`.
#
# Default value: 86400
#interval_secs = 86400
[cache]
# Cache backend: `ripple` (default, built-in gossip) or `valkey`.
#
# Can also be specified via environment variable `CACHE_BACKEND`.
#
# Default value: "ripple"
#backend = "ripple"
# Valkey / Redis connection URL. Required when `backend = "valkey"`.
#
# Can also be specified via environment variable `VALKEY_URL`.
#valkey_url =
[cache.ripple]
# Address to bind the Ripple gossip protocol listener.
#
# Can also be specified via environment variable `RIPPLE_BIND`.
#
# Default value: "0.0.0.0:0"
#bind_addr = "0.0.0.0:0"
# List of seed peer addresses.
#
# Can also be specified via environment variable `RIPPLE_PEERS`.
#peers =
# Unique machine identifier. Auto-derived from hostname when not set.
#
# Can also be specified via environment variable `RIPPLE_MACHINE_ID`.
#machine_id =
# Gossip protocol interval in milliseconds.
#
# Can also be specified via environment variable `RIPPLE_GOSSIP_INTERVAL_MS`.
#
# Default value: 200
#gossip_interval_ms = 200
# Maximum cache size in megabytes.
#
# Can also be specified via environment variable `RIPPLE_CACHE_MAX_MB`.
#
# Default value: 256
#cache_max_mb = 256
[plc]
# Base URL of the PLC directory.
#
# Can also be specified via environment variable `PLC_DIRECTORY_URL`.
#
# Default value: "https://plc.directory"
#directory_url = "https://plc.directory"
# HTTP request timeout in seconds.
#
# Can also be specified via environment variable `PLC_TIMEOUT_SECS`.
#
# Default value: 10
#timeout_secs = 10
# TCP connect timeout in seconds.
#
# Can also be specified via environment variable `PLC_CONNECT_TIMEOUT_SECS`.
#
# Default value: 5
#connect_timeout_secs = 5
# Seconds to cache DID documents in memory.
#
# Can also be specified via environment variable `DID_CACHE_TTL_SECS`.
#
# Default value: 300
#did_cache_ttl_secs = 300
[firehose]
# Size of the in-memory broadcast buffer for firehose events.
#
# Can also be specified via environment variable `FIREHOSE_BUFFER_SIZE`.
#
# Default value: 10000
#buffer_size = 10000
# How many hours of historical events to replay for cursor-based
# firehose connections.
#
# Can also be specified via environment variable `FIREHOSE_BACKFILL_HOURS`.
#
# Default value: 72
#backfill_hours = 72
# Maximum number of lagged events before disconnecting a slow consumer.
#
# Can also be specified via environment variable `FIREHOSE_MAX_LAG`.
#
# Default value: 5000
#max_lag = 5000
# List of relay / crawler notification URLs.
#
# Can also be specified via environment variable `CRAWLERS`.
#crawlers =
[email]
# Sender email address. When unset, email sending is disabled.
#
# Can also be specified via environment variable `MAIL_FROM_ADDRESS`.
#from_address =
# Display name used in the `From` header.
#
# Can also be specified via environment variable `MAIL_FROM_NAME`.
#
# Default value: "Tranquil PDS"
#from_name = "Tranquil PDS"
# Path to the `sendmail` binary.
#
# Can also be specified via environment variable `SENDMAIL_PATH`.
#
# Default value: "/usr/sbin/sendmail"
#sendmail_path = "/usr/sbin/sendmail"
[discord]
# Discord bot token. When unset, Discord integration is disabled.
#
# Can also be specified via environment variable `DISCORD_BOT_TOKEN`.
#bot_token =
[telegram]
# Telegram bot token. When unset, Telegram integration is disabled.
#
# Can also be specified via environment variable `TELEGRAM_BOT_TOKEN`.
#bot_token =
# Secret token for incoming webhook verification.
#
# Can also be specified via environment variable `TELEGRAM_WEBHOOK_SECRET`.
#webhook_secret =
[signal]
# Path to the `signal-cli` binary.
#
# Can also be specified via environment variable `SIGNAL_CLI_PATH`.
#
# Default value: "/usr/local/bin/signal-cli"
#cli_path = "/usr/local/bin/signal-cli"
# Sender phone number. When unset, Signal integration is disabled.
#
# Can also be specified via environment variable `SIGNAL_SENDER_NUMBER`.
#sender_number =
[notifications]
# Polling interval in milliseconds for the comms queue.
#
# Can also be specified via environment variable `NOTIFICATION_POLL_INTERVAL_MS`.
#
# Default value: 1000
#poll_interval_ms = 1000
# Number of notifications to process per batch.
#
# Can also be specified via environment variable `NOTIFICATION_BATCH_SIZE`.
#
# Default value: 100
#batch_size = 100
[sso]
[sso.github]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#display_name =
[sso.discord]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#display_name =
[sso.google]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#display_name =
[sso.gitlab]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#issuer =
#display_name =
[sso.oidc]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#issuer =
#display_name =
[sso.apple]
# Can also be specified via environment variable `SSO_APPLE_ENABLED`.
# Default value: false
#enabled = false
# Can also be specified via environment variable `SSO_APPLE_CLIENT_ID`.
#client_id =
# Can also be specified via environment variable `SSO_APPLE_TEAM_ID`.
#team_id =
# Can also be specified via environment variable `SSO_APPLE_KEY_ID`.
#key_id =
# Can also be specified via environment variable `SSO_APPLE_PRIVATE_KEY`.
#private_key =
[moderation]
# External report-handling service URL.
#
# Can also be specified via environment variable `REPORT_SERVICE_URL`.
#report_service_url =
# DID of the external report-handling service.
#
# Can also be specified via environment variable `REPORT_SERVICE_DID`.
#report_service_did =
[import]
# Whether the PDS accepts repo imports.
#
# Can also be specified via environment variable `ACCEPTING_REPO_IMPORTS`.
#
# Default value: true
#accepting = true
# Maximum allowed import archive size in bytes (default 1 GiB).
#
# Can also be specified via environment variable `MAX_IMPORT_SIZE`.
#
# Default value: 1073741824
#max_size = 1073741824
# Maximum number of blocks allowed in an import.
#
# Can also be specified via environment variable `MAX_IMPORT_BLOCKS`.
#
# Default value: 500000
#max_blocks = 500000
# Skip CAR verification during import. Only for development/debugging.
#
# Can also be specified via environment variable `SKIP_IMPORT_VERIFICATION`.
#
# Default value: false
#skip_verification = false
[scheduled]
# Interval in seconds between scheduled delete checks.
#
# Can also be specified via environment variable `SCHEDULED_DELETE_CHECK_INTERVAL_SECS`.
#
# Default value: 3600
#delete_check_interval_secs = 3600