Something went wrong. Try again.
A minimal atproto Personal Data Server, on SQLite. github.com/eth0net/manapds
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243//! Server configuration, read from the environment.
use std::{env, net::IpAddr, num::ParseIntError, path::PathBuf};
use thiserror::Error;
use crate::crypto::{Algorithm, Keypair, PublicKey};
/// The shortest secret this server will start under.////// 128 bits written as base64 is 24 characters and as hex is 32, so the floor/// is the shorter spelling. Length is all that can be checked: a long phrase/// someone thought of still falls to a word list.const SECRET_LENGTH: usize = 24;
/// A configured value that has no business reaching a log.#[derive(Clone, Eq, PartialEq)]pub struct Secret(String);
impl Secret { /// Holds a value, so that printing the thing around it cannot spill it. pub fn new(value: impl Into<String>) -> Self { Self(value.into()) }
/// The value itself, at the one point that has to have it. #[must_use] pub fn reveal(&self) -> &str { &self.0 }
/// A fresh one, at the length the reference installer writes. #[must_use] pub fn generate() -> Self { let mut bytes = [0u8; 16]; rand::fill(&mut bytes); Self(crate::crypto::hex(&bytes)) }}
impl std::fmt::Debug for Secret { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str("<secret>") }}
/// Everything the server needs to start.#[derive(Debug, Clone)]pub struct Config { /// The public hostname. Identifies the server to clients and to the /// network, and is the default for much of the rest of this struct. pub hostname: String, pub port: u16, pub service_did: String, pub data_directory: PathBuf, /// What every session token is signed under. Losing it signs every client /// out; changing it on a running server does the same. pub jwt_secret: Secret, /// What the admin endpoints authenticate with. pub admin_password: Secret, /// The key every PLC operation this server signs is signed with. One for /// the server rather than one per account: losing it strands every /// account created here. pub plc_rotation_key: Keypair, /// Where those operations are sent. pub plc_url: String, /// A key that outranks the rotation key, written into every account this /// server creates so that whoever holds it can take one back. pub recovery_key: Option<PublicKey>, /// Suffixes an account may take a handle under, each with its leading dot. pub handle_domains: Vec<String>, /// Names this server will not hand out, added to the built-in list. pub reserved_handles: Vec<String>, pub invite_required: bool, pub blob_upload_limit: u64, pub privacy_policy_url: Option<String>, pub terms_of_service_url: Option<String>, pub contact_email: Option<String>, /// Whether to hold callers to a budget at all, which the reference leaves /// off. pub rate_limits: bool, /// A key a caller sends to be let past those budgets. pub rate_limit_bypass_key: Option<Secret>, /// Addresses let past them without a key. pub rate_limit_bypass_ips: Vec<IpAddr>,}
#[derive(Debug, Error)]pub enum ConfigError { #[error("{0} is not a number: {1}")] NotANumber(&'static str, #[source] ParseIntError), #[error("{0} is not true or false")] NotABoolean(&'static str), #[error("{0} has to be set")] Missing(&'static str), #[error("{0} has to be at least {1} characters, and random: `manapds secret` writes one")] TooShort(&'static str, usize), #[error("{0} is not a key: `manapds rotation-key` writes one")] NotAKey(&'static str), #[error("{0} is not a did:key, which is how the holder of that key publishes it")] NotADidKey(&'static str), #[error("{0} takes the name in front of a service domain, so `{1}` cannot be one")] NotALabel(&'static str, String),}
impl Config { /// Reads the environment, filling in defaults for a local server. /// /// Unknown `PDS_*` variables are ignored rather than rejected, so a /// configuration file written for the reference server starts this one. /// /// # Errors /// /// If a variable that has to be a number or a boolean isn't one, or /// `PDS_JWT_SECRET` is unset. pub fn from_env() -> Result<Self, ConfigError> { let hostname = string("PDS_HOSTNAME").unwrap_or_else(|| "localhost".to_owned());
Ok(Self { service_did: string("PDS_SERVICE_DID").unwrap_or_else(|| format!("did:web:{hostname}")), handle_domains: list("PDS_SERVICE_HANDLE_DOMAINS") .unwrap_or_else(|| vec![format!(".{hostname}")]), reserved_handles: labels("PDS_RESERVED_HANDLES")?, port: number("PDS_PORT")?.unwrap_or(2583), data_directory: string("PDS_DATA_DIRECTORY") .map_or_else(|| PathBuf::from("data"), PathBuf::from), jwt_secret: secret("PDS_JWT_SECRET")?, // No floor on this one. It is only ever guessed against a running // server, and a server being taken over arrives with whatever it // was already using. admin_password: string("PDS_ADMIN_PASSWORD") .map(Secret::new) .ok_or(ConfigError::Missing("PDS_ADMIN_PASSWORD"))?, plc_rotation_key: rotation_key("PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX")?, plc_url: string("PDS_DID_PLC_URL") .unwrap_or_else(|| "https://plc.directory".to_owned()), recovery_key: string("PDS_RECOVERY_DID_KEY") .map(|value| value.parse()) .transpose() .map_err(|_| ConfigError::NotADidKey("PDS_RECOVERY_DID_KEY"))?, invite_required: boolean("PDS_INVITE_REQUIRED")?.unwrap_or(true), blob_upload_limit: number("PDS_BLOB_UPLOAD_LIMIT")?.unwrap_or(5 * 1024 * 1024), privacy_policy_url: string("PDS_PRIVACY_POLICY_URL"), terms_of_service_url: string("PDS_TERMS_OF_SERVICE_URL"), contact_email: string("PDS_CONTACT_EMAIL_ADDRESS"), rate_limits: boolean("PDS_RATE_LIMITS_ENABLED")?.unwrap_or(false), rate_limit_bypass_key: string("PDS_RATE_LIMIT_BYPASS_KEY").map(Secret::new), // The reference takes CIDR here and keeps only the address, so a // range written out is read as the one address it starts at. rate_limit_bypass_ips: list("PDS_RATE_LIMIT_BYPASS_IPS") .unwrap_or_default() .iter() .filter_map(|value| value.split('/').next()?.trim().parse().ok()) .collect(), hostname, }) }
/// Where clients reach this server, which is what an account's document /// says about where its repository is. #[must_use] pub fn public_url(&self) -> String { if self.hostname == "localhost" { format!("http://localhost:{}", self.port) } else { format!("https://{}", self.hostname) } }}
/// An empty variable counts as unset, since that is how a `.env` file with a/// key and no value reads.fn string(key: &str) -> Option<String> { env::var(key).ok().filter(|value| !value.is_empty())}
/// A secret short enough to be found by trying is refused at startup, since/// every session on the server is signed under this one and a holder of any/// token can look for it offline.fn secret(key: &'static str) -> Result<Secret, ConfigError> { let value = string(key).ok_or(ConfigError::Missing(key))?; if value.chars().count() < SECRET_LENGTH { return Err(ConfigError::TooShort(key, SECRET_LENGTH)); } Ok(Secret::new(value))}
/// The rotation key, which the reference takes as the raw scalar in hex and/// always on secp256k1.fn rotation_key(key: &'static str) -> Result<Keypair, ConfigError> { let value = string(key).ok_or(ConfigError::Missing(key))?; let bytes = crate::crypto::unhex(&value).ok_or(ConfigError::NotAKey(key))?; Keypair::from_bytes(Algorithm::Secp256k1, &bytes).map_err(|_| ConfigError::NotAKey(key))}
fn list(key: &str) -> Option<Vec<String>> { string(key).map(|value| { value .split(',') .map(|part| part.trim().to_owned()) .collect() })}
/// A list of names, each the part in front of a service domain rather than a/// whole handle. Neither a dot nor whitespace is allowed in one;/// `docs/architecture.md` says why nothing else is checked.fn labels(key: &'static str) -> Result<Vec<String>, ConfigError> { let Some(values) = list(key) else { return Ok(Vec::new()); }; for value in &values { if value.contains('.') || value.chars().any(char::is_whitespace) { return Err(ConfigError::NotALabel(key, value.clone())); } } Ok(values)}
/// Upstream reads an unrecognized value as unset and falls back to the/// default. Here it is an error: a misspelled flag that quietly means the/// opposite of what was written is worse than a server that will not start.fn boolean(key: &'static str) -> Result<Option<bool>, ConfigError> { match string(key).as_deref() { None => Ok(None), Some("true" | "1") => Ok(Some(true)), Some("false" | "0") => Ok(Some(false)), Some(_) => Err(ConfigError::NotABoolean(key)), }}
fn number<T: std::str::FromStr<Err = ParseIntError>>( key: &'static str,) -> Result<Option<T>, ConfigError> { string(key) .map(|value| { value .parse() .map_err(|error| ConfigError::NotANumber(key, error)) }) .transpose()}