caddy: on-demand TLS endpoint

Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
This commit is contained in:
Lewis
2026-08-31 10:37:19 +03:00
parent eba8167da8
commit e572cfabde
28 changed files with 699 additions and 162 deletions
Generated
+2
View File
@@ -7780,6 +7780,8 @@ name = "tranquil-config"
version = "0.6.6"
dependencies = [
"confique",
"serde",
"tranquil-types",
]
[[package]]
@@ -66,11 +66,11 @@ pub async fn update_account_handle(
{
return Err(ApiError::InvalidHandle(None));
}
let available_domains = tranquil_config::get().server.available_user_domain_list();
let handle = if !input_handle.contains('.') {
format!("{}.{}", input_handle, &available_domains[0])
} else {
let primary = tranquil_pds::handle::ServiceDomains::for_user_handles().primary();
let handle = if input_handle.contains('.') {
input_handle.to_string()
} else {
format!("{}.{}", input_handle, primary)
};
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
let user_id = state
+13 -26
View File
@@ -132,12 +132,9 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
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 {
let is_subdomain = cfg
.server
.available_user_domain_list()
.into_iter()
.chain(std::iter::once(hostname_without_port.to_string()))
.any(|d| host_without_port.ends_with(&format!(".{}", d)));
let is_subdomain = tranquil_pds::handle::ServiceDomains::served()
.split_handle(host_without_port)
.is_some();
if is_subdomain {
return serve_handle_did_doc(&state, host_without_port, hostname).await;
}
@@ -582,26 +579,16 @@ pub async fn update_handle(
"Inappropriate language in handle".into(),
)));
}
let handle_domains = tranquil_config::get().server.user_handle_domain_list();
let matched_handle_domain = handle_domains
.iter()
.filter(|d| new_handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len())
.cloned();
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
let handle: Handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
&& !is_domain_itself
{
let (short_part, full_handle) = match &matched_handle_domain {
Some(domain) => {
let suffix = format!(".{}", domain);
let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle);
(short.to_string(), new_handle.clone())
}
None => {
let primary = &handle_domains[0];
(new_handle.clone(), format!("{}.{}", new_handle, primary))
}
let handle_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
let split = handle_domains.split_handle(&new_handle);
let is_domain_itself = handle_domains.contains(&new_handle);
let handle: Handle = if (!new_handle.contains('.') || split.is_some()) && !is_domain_itself {
let (short_part, full_handle) = match split {
Some((_domain, short)) => (short.to_string(), new_handle.clone()),
None => (
new_handle.clone(),
format!("{}.{}", new_handle, handle_domains.primary()),
),
};
if full_handle == current_handle {
let handle: Handle = match full_handle.parse() {
+1 -4
View File
@@ -9,10 +9,7 @@ use tranquil_pds::api::ApiError;
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::auth::{Auth, Permissive};
use tranquil_pds::circuit_breaker::with_circuit_breaker;
use tranquil_pds::plc::{
PlcError, PlcService, create_update_op, missing_required_rotation_key, sign_operation,
signing_key_to_did_key,
};
use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation};
use tranquil_pds::state::AppState;
#[derive(Debug, Deserialize)]
+8 -2
View File
@@ -467,9 +467,15 @@ pub fn api_routes() -> axum::Router<AppState> {
pub fn well_known_api_routes() -> axum::Router<AppState> {
use axum::routing::get;
axum::Router::new()
let routes = axum::Router::new()
.route("/did.json", get(identity::well_known_did))
.route("/atproto-did", get(identity::well_known_atproto_did))
.route("/atproto-did", get(identity::well_known_atproto_did));
if tranquil_config::get().server.enable_caddy_on_demand_tls {
routes.route("/caddy/ask", get(server::caddy_ask))
} else {
routes
}
}
pub fn webhook_routes() -> axum::Router<AppState> {
+43
View File
@@ -0,0 +1,43 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use tracing::error;
use tranquil_pds::handle::ServiceDomains;
use tranquil_pds::state::AppState;
use tranquil_pds::types::Handle;
pub struct AskedDomain(Handle);
impl<'de> Deserialize<'de> for AskedDomain {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
let without_root_dot = raw.strip_suffix('.').unwrap_or(&raw);
Handle::new(without_root_dot)
.map(Self)
.map_err(D::Error::custom)
}
}
#[derive(Deserialize)]
pub struct CaddyAskQuery {
pub domain: AskedDomain,
}
pub async fn caddy_ask(
State(state): State<AppState>,
Query(ask): Query<CaddyAskQuery>,
) -> StatusCode {
let AskedDomain(handle) = ask.domain;
if ServiceDomains::served().contains(handle.as_str()) {
return StatusCode::OK;
}
match state.repos.user.get_by_handle(&handle).await {
Ok(Some(_)) => StatusCode::OK,
Ok(None) => StatusCode::NOT_FOUND,
Err(e) => {
error!("caddy ask couldn't look up handle {handle}: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
+6 -1
View File
@@ -77,7 +77,12 @@ pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServ
let pds_hostname = &cfg.server.hostname;
Json(DescribeServerOutput {
available_user_domains: cfg.server.user_handle_domain_list(),
available_user_domains: match cfg.server.user_handle_domains.as_deref() {
Some(domains) if !domains.is_empty() => {
domains.iter().map(|d| d.as_str().to_owned()).collect()
}
_ => vec![cfg.server.hostname_without_port().to_owned()],
},
invite_code_required: cfg.server.invite_code_required,
did: format!("did:web:{}", pds_hostname),
links: DescribeServerLinks {
+2
View File
@@ -1,5 +1,6 @@
pub mod account_status;
pub mod app_password;
pub mod caddy;
pub mod email;
pub mod invite;
pub mod logo;
@@ -22,6 +23,7 @@ pub use account_status::{
request_account_delete,
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use caddy::caddy_ask;
pub use email::{
authorize_email_update, check_channel_verified, check_email_in_use, check_email_update_status,
check_email_verified, confirm_email, request_email_update, update_email,
+2
View File
@@ -5,4 +5,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
tranquil-types = { workspace = true }
confique = { workspace = true }
+22 -21
View File
@@ -2,6 +2,7 @@ use confique::Config;
use std::fmt;
use std::path::PathBuf;
use std::sync::OnceLock;
use tranquil_types::Domain;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
@@ -30,7 +31,6 @@ impl fmt::Display for ConfigError {
}
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) {
@@ -224,6 +224,12 @@ impl TranquilConfig {
}
}
if let Err(e) = Domain::new(self.server.hostname_without_port()) {
errors.push(format!(
"server.hostname (PDS_HOSTNAME) must be a plain domain, {e}"
));
}
// -- email -----------------------------------------------------------
self.email
.validate(self.server.hostname_without_port(), &mut errors);
@@ -428,7 +434,7 @@ pub struct ServerConfig {
pub hostname: String,
/// Address to bind the HTTP server to.
#[config(env = "SERVER_HOST", default = "127.0.0.1")]
#[config(env = "SERVER_HOST", default = "[::1]")]
pub host: String,
/// Port to bind the HTTP server to.
@@ -438,13 +444,21 @@ pub struct ServerConfig {
/// 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>>,
pub user_handle_domains: Option<Vec<Domain>>,
/// 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,
/// The caddy on-demand TLS requires we serve
/// the endpoint `/.well-known/caddy/ask`.
/// It will be used so that caddy can create TLS
/// certs for us on the fly
/// and we don't have to do annoying wildcard certs.
#[config(env = "ENABLE_CADDY_ON_DEMAND_TLS", default = true)]
pub enable_caddy_on_demand_tls: bool,
/// iykyk!
#[config(env = "RFC_MOO_COMPLIANCE", default = false)]
pub rfc_moo_compliance: bool,
@@ -573,20 +587,6 @@ impl ServerConfig {
pub fn banned_word_list(&self) -> Vec<String> {
self.banned_words.clone().unwrap_or_default()
}
/// Returns the user handle domains, falling back to `[hostname_without_port]`.
pub fn user_handle_domain_list(&self) -> Vec<String> {
self.user_handle_domains
.as_deref()
.filter(|v| !v.is_empty())
.map(|v| v.to_vec())
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
}
/// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field).
pub fn available_user_domain_list(&self) -> Vec<String> {
self.user_handle_domain_list()
}
}
#[derive(Debug, Config)]
@@ -1484,12 +1484,13 @@ pub struct ImportConfig {
/// 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
fn split_comma_list<T: std::str::FromStr>(value: &str) -> Result<Vec<T>, T::Err> {
value
.split(',')
.map(|item| item.trim().to_string())
.map(str::trim)
.filter(|item| !item.is_empty())
.collect())
.map(T::from_str)
.collect()
}
#[derive(Debug, Config)]
@@ -789,13 +789,16 @@ pub async fn check_handle_available(
}
};
let available_domains = tranquil_config::get().server.available_user_domain_list();
if let Some(ref d) = query.domain
&& !available_domains.iter().any(|ad| ad == d)
let available_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
if let Some(d) = &query.domain
&& !available_domains.contains(d.as_str())
{
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
}
let domain = query.domain.as_deref().unwrap_or(&available_domains[0]);
let domain = query
.domain
.as_deref()
.unwrap_or_else(|| available_domains.primary().as_str());
let full_handle = format!("{}.{}", validated, domain);
let handle: tranquil_pds::types::Handle = match full_handle.parse() {
Ok(h) => h,
@@ -882,34 +885,33 @@ pub async fn complete_registration(
let cfg = tranquil_config::get();
let hostname = &cfg.server.hostname;
let available_domains = cfg.server.available_user_domain_list();
let available_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let split = available_domains.split_handle(&input.handle);
let handle: tranquil_pds::types::Handle =
if !input.handle.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input
.handle
.strip_suffix(&format!(".{}", domain))
.unwrap_or(&input.handle),
None => &input.handle,
};
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0]))
.parse()
.map_err(|_| ApiError::InvalidHandle(None))?,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
} else {
match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
let handle: tranquil_pds::types::Handle = if !input.handle.contains('.') || split.is_some() {
let handle_to_validate = match split {
Some((_domain, short)) => short,
None => input.handle.as_str(),
};
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!(
"{}.{}",
h,
split
.map(|(d, _)| d)
.unwrap_or_else(|| available_domains.primary())
)
.parse()
.map_err(|_| ApiError::InvalidHandle(None))?,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
} else {
match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
};
let verification_channel = input
.verification_channel
+1 -2
View File
@@ -763,8 +763,7 @@ impl From<crate::api::validation::HandleValidationError> for ApiError {
HandleValidationError::BannedWord => {
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
}
HandleValidationError::UnusableHandleDomain
| HandleValidationError::NoHandleDomains => Self::InternalError(Some(e.to_string())),
HandleValidationError::UnusableHandleDomain => Self::InternalError(Some(e.to_string())),
_ => Self::InvalidHandle(Some(e.to_string())),
}
}
+9 -22
View File
@@ -111,7 +111,6 @@ pub enum HandleValidationError {
InvalidSyntax,
DisallowedTld,
UnusableHandleDomain,
NoHandleDomains,
}
impl std::fmt::Display for HandleValidationError {
@@ -143,9 +142,6 @@ impl std::fmt::Display for HandleValidationError {
f,
"This server's handle domain has a reserved TLD, so no handle under it is a valid atproto handle"
),
Self::NoHandleDomains => {
write!(f, "No handle domains are configured on this server")
}
}
}
}
@@ -215,21 +211,14 @@ pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationErr
}
pub fn resolve_handle_input(input: &str) -> Result<Handle, HandleValidationError> {
let available_domains = tranquil_config::get().server.available_user_domain_list();
let matched_domain = available_domains
.iter()
.filter(|d| input.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let domains = crate::handle::ServiceDomains::for_user_handles();
let split = domains.split_handle(input);
if !input.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input.strip_suffix(&format!(".{}", domain)).unwrap_or(input),
None => input,
};
let validated = validate_short_handle(handle_to_validate)?;
let domain = matched_domain
.or_else(|| available_domains.first())
.ok_or(HandleValidationError::NoHandleDomains)?;
if !input.contains('.') || split.is_some() {
let (short, domain) = split
.map(|(domain, short)| (short, domain))
.unwrap_or((input, domains.primary()));
let validated = validate_short_handle(short)?;
let handle = Handle::new(format!("{}.{}", validated, domain))
.map_err(|_| HandleValidationError::InvalidSyntax)?;
match handle.has_disallowed_tld() {
@@ -246,11 +235,9 @@ pub fn domain_forms_valid_handles(domain: &str) -> bool {
}
pub fn warn_unusable_handle_domains() {
tranquil_config::get()
.server
.user_handle_domain_list()
crate::handle::ServiceDomains::for_user_handles()
.iter()
.filter(|domain| !domain_forms_valid_handles(domain))
.filter(|domain| !domain_forms_valid_handles(domain.as_str()))
.for_each(|domain| {
tracing::error!(
domain = %domain,
+133 -16
View File
@@ -3,8 +3,16 @@ pub mod reserved;
use crate::types::{Did, Handle};
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use std::sync::LazyLock;
use thiserror::Error;
pub use tranquil_types::Domain;
static HOSTNAME_DOMAIN: LazyLock<Domain> = LazyLock::new(|| {
Domain::new(tranquil_config::get().server.hostname_without_port())
.expect("server.hostname is validated at config load")
});
#[derive(Error, Debug)]
pub enum HandleResolutionError {
#[error("DNS lookup failed: {0}")]
@@ -85,28 +93,137 @@ pub async fn verify_handle_ownership(
}
}
pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
if !handle.contains('.') {
return true;
#[derive(Clone, Copy)]
pub struct ServiceDomains<'a> {
user_domains: &'a [Domain],
hostname: &'a Domain,
serve_hostname: bool,
}
impl ServiceDomains<'static> {
pub fn for_user_handles() -> Self {
Self::from_config(false)
}
pub fn served() -> Self {
Self::from_config(true)
}
fn from_config(serve_hostname: bool) -> Self {
let server = &tranquil_config::get().server;
Self {
user_domains: server.user_handle_domains.as_deref().unwrap_or_default(),
hostname: &HOSTNAME_DOMAIN,
serve_hostname,
}
}
}
impl<'a> ServiceDomains<'a> {
pub fn iter(&self) -> impl Iterator<Item = &'a Domain> {
let hostname = (self.serve_hostname || self.user_domains.is_empty())
.then_some(self.hostname)
.filter(|h| !self.user_domains.contains(h));
self.user_domains.iter().chain(hostname)
}
pub fn primary(&self) -> &'a Domain {
self.user_domains.first().unwrap_or(self.hostname)
}
pub fn contains(&self, name: &str) -> bool {
self.iter().any(|d| d.eq_name(name))
}
pub fn split_handle<'h>(&self, handle: &'h str) -> Option<(&'a Domain, &'h str)> {
self.iter()
.filter_map(|d| d.strip_from(handle).map(|short| (d, short)))
.max_by_key(|(d, _)| d.as_str().len())
}
let service_domains = tranquil_config::try_get()
.map(|c| c.server.user_handle_domain_list())
.unwrap_or_else(|| vec![hostname.to_string()]);
service_domains
.iter()
.any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
}
#[cfg(test)]
mod tests {
use super::*;
use super::{Domain, ServiceDomains};
use std::sync::LazyLock;
static HOST: LazyLock<Domain> = LazyLock::new(|| "pds.oyster.cafe".parse().unwrap());
fn domains(user_domains: &[Domain], serve_hostname: bool) -> ServiceDomains<'_> {
ServiceDomains {
user_domains,
hostname: &HOST,
serve_hostname,
}
}
fn owned(list: &[&str]) -> Vec<Domain> {
list.iter().map(|d| d.parse().unwrap()).collect()
}
#[test]
fn test_is_service_domain_handle() {
assert!(is_service_domain_handle("nel.oyster.cafe", "oyster.cafe"));
assert!(is_service_domain_handle("oyster.cafe", "oyster.cafe"));
assert!(is_service_domain_handle("myhandle", "oyster.cafe"));
assert!(!is_service_domain_handle("lyna.nel.pet", "oyster.cafe"));
assert!(!is_service_domain_handle("myhandle.xyz", "oyster.cafe"));
fn thostname_until_domains_are_configured() {
assert!(domains(&[], false).contains("pds.oyster.cafe"));
assert_eq!(domains(&[], false).primary(), "pds.oyster.cafe");
let configured = owned(&["oyster.cafe"]);
assert!(!domains(&configured, false).contains("pds.oyster.cafe"));
assert!(domains(&configured, false).contains("oyster.cafe"));
}
#[test]
fn served_set_covers_hostname_and_handle_domains() {
let configured = owned(&["oyster.cafe"]);
assert!(domains(&configured, true).contains("pds.oyster.cafe"));
assert!(domains(&configured, true).contains("oyster.cafe"));
}
#[test]
fn hostname_in_list_is_yielded_once() {
let configured = owned(&["pds.oyster.cafe", "oyster.cafe"]);
let served: Vec<&str> = domains(&configured, true)
.iter()
.map(Domain::as_str)
.collect();
assert_eq!(served, ["pds.oyster.cafe", "oyster.cafe"]);
let configured = owned(&["PDS.Oyster.Cafe"]);
let served: Vec<&str> = domains(&configured, true)
.iter()
.map(Domain::as_str)
.collect();
assert_eq!(served, ["pds.oyster.cafe"]);
}
#[test]
fn matching_case_insensitive() {
let configured = owned(&["oyster.cafe"]);
assert!(domains(&configured, false).contains("Oyster.Cafe"));
let (domain, short) = domains(&configured, false)
.split_handle("NEL.OYSTER.CAFE")
.unwrap();
assert_eq!(domain, "oyster.cafe");
assert_eq!(short, "NEL");
}
#[test]
fn longest_matching_domain_wins() {
let configured = owned(&["oyster.cafe", "pets.oyster.cafe"]);
let (domain, short) = domains(&configured, false)
.split_handle("nel.pets.oyster.cafe")
.unwrap();
assert_eq!(domain, "pets.oyster.cafe");
assert_eq!(short, "nel");
}
#[test]
fn split_handle_requires_a_dot() {
let configured = owned(&["oyster.cafe"]);
assert_eq!(
domains(&configured, false).split_handle("oyster.cafe"),
None
);
assert_eq!(
domains(&configured, false).split_handle("notoyster.cafe"),
None
);
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ impl PlcClient {
let timeout_secs = cfg.map_or(10, |c| c.plc.timeout_secs);
let connect_timeout_secs = cfg.map_or(5, |c| c.plc.connect_timeout_secs);
let fetch_policy = tranquil_types::ReachPolicy::from_private_fetch(
cfg.map_or(false, |c| c.server.allow_private_fetch),
cfg.is_some_and(|c| c.server.allow_private_fetch),
);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
+113
View File
@@ -0,0 +1,113 @@
mod common;
use common::*;
use futures::StreamExt;
use reqwest::StatusCode;
use serde_json::{Value, json};
#[ctor::ctor]
fn enable_on_demand_tls() {
unsafe {
std::env::set_var("ENABLE_CADDY_ON_DEMAND_TLS", "true");
std::env::set_var("PDS_USER_HANDLE_DOMAINS", "handles.pds.test");
}
}
async fn create_hosted_account() -> String {
let client = client();
let short_handle = format!("caddy{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let payload = json!({
"handle": short_handle,
"email": format!("{}@oyster.cafe", short_handle),
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await
.expect("failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res
.json()
.await
.expect("createAccount response wasn't JSON");
body["handle"]
.as_str()
.expect("createAccount didn't return a handle")
.to_string()
}
async fn ask(client: &reqwest::Client, domain: &str) -> StatusCode {
client
.get(format!("{}/.well-known/caddy/ask", base_url().await))
.query(&[("domain", domain)])
.send()
.await
.expect("failed to query ask endpoint")
.status()
}
#[tokio::test]
async fn test_caddy_ask_allows_hosted_handle() {
let client = client();
let handle = create_hosted_account().await;
assert_eq!(ask(&client, &handle).await, StatusCode::OK);
assert_eq!(ask(&client, &handle.to_uppercase()).await, StatusCode::OK);
assert_eq!(ask(&client, &format!("{handle}.")).await, StatusCode::OK);
}
#[tokio::test]
async fn test_caddy_ask_denies_unhosted_and_invalid_domains() {
let client = client();
let unknown = format!("ghost-{}.handles.pds.test", uuid::Uuid::new_v4().simple());
assert_eq!(ask(&client, &unknown).await, StatusCode::NOT_FOUND);
assert_eq!(ask(&client, "nel.pet").await, StatusCode::NOT_FOUND);
assert_eq!(
ask(&client, "!!not-a-handle").await,
StatusCode::BAD_REQUEST
);
assert_eq!(ask(&client, "").await, StatusCode::BAD_REQUEST);
let res = client
.get(format!("{}/.well-known/caddy/ask", base_url().await))
.send()
.await
.expect("failed to query ask endpoint");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_caddy_ask_allows_handle_domain_apexes() {
let client = client();
base_url().await;
futures::stream::iter(
tranquil_config::get()
.server
.user_handle_domains
.iter()
.flatten(),
)
.for_each(|domain| async {
assert_eq!(ask(&client, domain.as_str()).await, StatusCode::OK);
})
.await;
}
#[tokio::test]
async fn test_caddy_ask_allows_the_pds_hostname_beside_handle_domains() {
let client = client();
base_url().await;
let cfg = tranquil_config::get();
let hostname = cfg.server.hostname_without_port();
assert!(
!cfg.server
.user_handle_domains
.iter()
.flatten()
.any(|d| d == hostname),
"this test only means something if hostname is outside the handle domains"
);
assert_eq!(ask(&client, hostname).await, StatusCode::OK);
}
+5 -2
View File
@@ -77,9 +77,12 @@ async fn main() -> ExitCode {
}
config
.server
.user_handle_domain_list()
.user_handle_domains
.iter()
.filter(|d| !tranquil_pds::api::validation::domain_forms_valid_handles(d))
.flatten()
.filter(|d| {
!tranquil_pds::api::validation::domain_forms_valid_handles(d.as_str())
})
.for_each(|d| {
eprintln!(
"account creation under handle domain {d} will be rejected because its TLD is reserved"
+12 -4
View File
@@ -344,8 +344,12 @@ fn verify_backup_detects_checksum_mismatch() {
.unwrap();
let manifest = read_manifest(backup_dir.path()).unwrap();
let first_file = &manifest.files[0];
let file_path = backup_dir.path().join(&first_file.path);
let target = manifest
.files
.iter()
.find(|f| f.size > 0)
.expect("backup manifest must list a file with content");
let file_path = backup_dir.path().join(&target.path);
let mut data = std::fs::read(&file_path).unwrap();
data.iter_mut().take(8).for_each(|b| *b ^= 0xFF);
std::fs::write(&file_path, &data).unwrap();
@@ -901,8 +905,12 @@ fn restore_fails_cleanly_on_corrupted_backup() {
.create_backup(backup_dir.path())
.unwrap();
let first_file = &manifest.files[0];
let file_path = backup_dir.path().join(&first_file.path);
let target = manifest
.files
.iter()
.find(|f| f.size > 0)
.expect("backup manifest must list a file with content");
let file_path = backup_dir.path().join(&target.path);
let mut data = std::fs::read(&file_path).unwrap();
data.iter_mut().take(16).for_each(|b| *b ^= 0xFF);
std::fs::write(&file_path, &data).unwrap();
+32
View File
@@ -330,6 +330,38 @@ pub enum HandleError {
Invalid(String),
}
validated_string_newtype! {
pub struct Domain;
error = DomainError;
label = "domain";
validator = |s| {
let normalized = s.to_ascii_lowercase();
(normalized.len() <= 253
&& !normalized.is_empty()
&& normalized.split('.').all(|label| {
!label.is_empty()
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}))
.then_some(normalized)
.ok_or(())
};
}
impl Domain {
pub fn eq_name(&self, name: &str) -> bool {
self.as_str().eq_ignore_ascii_case(name)
}
pub fn strip_from<'h>(&self, handle: &'h str) -> Option<&'h str> {
let domain_len = self.as_str().len();
(handle.len() > domain_len + 1 && handle.as_bytes()[handle.len() - domain_len - 1] == b'.')
.then(|| &handle[..handle.len() - domain_len - 1])
.filter(|_| handle[handle.len() - domain_len..].eq_ignore_ascii_case(self.as_str()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AtIdentifier {
Did(Did),
+1 -1
View File
@@ -66,7 +66,7 @@ See [example.toml](https://tangled.org/tranquil.farm/tranquil-pds/blob/main/exam
# by default, tranquil runs on port 3000.
# You can change this with the tranquil-pds.settings.server.port option in the service config.
extraConfig = ''
reverse_proxy localhost:3000
reverse_proxy [::1]:3000
'';
};
};
+13 -2
View File
@@ -10,8 +10,8 @@
#
# Can also be specified via environment variable `SERVER_HOST`.
#
# Default value: "127.0.0.1"
#host = "127.0.0.1"
# Default value: "[::1]"
#host = "[::1]"
# Port to bind the HTTP server to.
#
@@ -34,6 +34,17 @@
# Default value: false
#enable_pds_hosted_did_web = false
# The caddy on-demand TLS requires we serve
# the endpoint `/.well-known/caddy/ask`.
# It will be used so that caddy can create TLS
# certs for us on the fly
# and we don't have to do annoying wildcard certs.
#
# Can also be specified via environment variable `ENABLE_CADDY_ON_DEMAND_TLS`.
#
# Default value: true
#enable_caddy_on_demand_tls = true
# iykyk!
#
# Can also be specified via environment variable `RFC_MOO_COMPLIANCE`.
+1
View File
@@ -24,6 +24,7 @@
devShells = forAllSystems (pkgs: {
default = pkgs.callPackage ./shell.nix { };
full = pkgs.callPackage ./shells/full.nix { };
});
nixosModules = {
+28 -15
View File
@@ -98,43 +98,56 @@ test-store-asan:
test-unit:
SQLX_OFFLINE=true cargo test --test dpop_unit --test validation_edge_cases --test scope_edge_cases
store_test := "SQLX_OFFLINE=true TRANQUIL_TEST_BACKEND=store TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 DISABLE_RATE_LIMITING=1 TRANQUIL_LEXICON_OFFLINE=1 SKIP_IMPORT_VERIFICATION=true cargo nextest run -E 'not package(tranquil-store) and not binary(store_parity)'"
test-auth:
./scripts/run-tests.sh --test oauth --test oauth_lifecycle --test oauth_scopes --test oauth_security --test jwt_security --test session_management --test change_password --test password_reset
{{store_test}} --test oauth --test oauth_lifecycle --test oauth_scopes --test oauth_security --test jwt_security --test session_management --test change_password --test password_reset
test-admin:
./scripts/run-tests.sh --test admin_email --test admin_invite --test admin_moderation --test admin_search --test admin_stats
{{store_test}} --test admin_email --test admin_invite --test admin_moderation --test admin_search --test admin_stats
test-sync:
./scripts/run-tests.sh --test sync_repo --test sync_blob --test sync_conformance --test sync_deprecated --test firehose_validation
{{store_test}} --test sync_repo --test sync_blob --test sync_conformance --test sync_deprecated --test firehose_validation
test-repo:
./scripts/run-tests.sh --test repo_batch --test repo_blob --test record_validation --test lifecycle_record
{{store_test}} --test repo_batch --test repo_blob --test record_validation --test lifecycle_record
test-identity:
./scripts/run-tests.sh --test identity --test did_web --test plc_migration --test plc_operations --test plc_validation
{{store_test}} --test identity --test did_web --test plc_migration --test plc_operations --test plc_validation
test-account:
./scripts/run-tests.sh --test lifecycle_session --test delete_account --test invite --test email_update --test account_notifications
{{store_test}} --test lifecycle_session --test delete_account --test invite --test email_update --test account_notifications
test-security:
./scripts/run-tests.sh --test security_fixes --test banned_words --test rate_limit --test moderation
{{store_test}} --test security_fixes --test banned_words --test rate_limit --test moderation
test-import:
./scripts/run-tests.sh --test import_verification --test import_with_verification
{{store_test}} --test import_verification --test import_with_verification
test-misc:
./scripts/run-tests.sh --test actor --test commit_signing --test image_processing --test lifecycle_social --test notifications --test server --test signing_key --test verify_live_commit
{{store_test}} --test actor --test commit_signing --test image_processing --test lifecycle_social --test notifications --test server --test signing_key --test verify_live_commit
test *args:
@just test-unit
./scripts/run-tests.sh {{args}}
test-embedded *args:
@just test-unit
SQLX_OFFLINE=true TRANQUIL_TEST_BACKEND=store TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 DISABLE_RATE_LIMITING=1 TRANQUIL_LEXICON_OFFLINE=1 SKIP_IMPORT_VERIFICATION=true cargo nextest run -E 'not binary(store_parity)' {{args}}
{{store_test}} {{args}}
test-one name:
./scripts/run-tests.sh --test {{name}}
{{store_test}} --test {{name}}
test-full *args:
@just test-unit
@just services-up
eval "$(tranquil-dev-services env)" && SQLX_OFFLINE=true cargo nextest run --features tranquil-pds/s3 -E 'not package(tranquil-store)' {{args}}
test-pg *args:
@just test-unit
./scripts/run-tests.sh {{args}}
services-up:
tranquil-dev-services up
services-down:
tranquil-dev-services down
infra-start:
./scripts/test-infra.sh start
+1 -1
View File
@@ -76,7 +76,7 @@ in
server = {
host = mkOption {
type = types.str;
default = "127.0.0.1";
default = "[::1]";
description = "Host for tranquil-pds to listen on";
};
-5
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INFRA_SCRIPT="$SCRIPT_DIR/test-infra.sh"
cleanup() {
echo ""
@@ -12,10 +11,6 @@ trap cleanup EXIT
"$INFRA_SCRIPT" start
source "${TMPDIR:-/tmp}/tranquil_pds_test_infra.env"
echo ""
echo "Running database migrations..."
sqlx database create 2>/dev/null || true
sqlx migrate run --source "$PROJECT_DIR/migrations"
echo ""
ulimit -n 65536
echo "Building test binaries..."
-4
View File
@@ -5,8 +5,6 @@
# repo tooling
just,
podman,
podman-compose,
# rust tooling
clippy,
@@ -38,8 +36,6 @@ mkShell {
packages = [
just
podman
podman-compose
clippy
rustfmt
+182
View File
@@ -0,0 +1,182 @@
DEV_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/tranquil-dev-services"
ENV_FILE="$DEV_DIR/services.env"
PG_DIR="$DEV_DIR/pg"
PG_LOG="$DEV_DIR/pg.log"
PG_PORT=54329
PG_DATABASE=tranquil
GARAGE_DIR="$DEV_DIR/garage"
GARAGE_CONF="$GARAGE_DIR/garage.toml"
GARAGE_LOG="$DEV_DIR/garage.log"
GARAGE_PID_FILE="$DEV_DIR/garage.pid"
GARAGE_S3_PORT=3990
GARAGE_RPC_PORT=3901
GARAGE_RPC_SECRET=6465767365637265746465767365637265746465767365637265746465767365
S3_BUCKET=tranquil-dev
LOOPBACK="::1"
if [ "$(id -u)" = 0 ]; then
echo "PostgreSQL won't run as root, use a lowerclass user" >&2
exit 1
fi
write_garage_conf() {
mkdir -p "$GARAGE_DIR/meta" "$GARAGE_DIR/data"
cat > "$GARAGE_CONF" << EOF
metadata_dir = "$GARAGE_DIR/meta"
data_dir = "$GARAGE_DIR/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "[$LOOPBACK]:$GARAGE_RPC_PORT"
rpc_public_addr = "[$LOOPBACK]:$GARAGE_RPC_PORT"
rpc_secret = "$GARAGE_RPC_SECRET"
[s3_api]
s3_region = "tranquil"
api_bind_addr = "[$LOOPBACK]:$GARAGE_S3_PORT"
root_domain = ".s3.tranquil.dev"
EOF
}
garage_cli() {
garage -c "$GARAGE_CONF" "$@"
}
port_is_open() {
echo 2>/dev/null > "/dev/tcp/$LOOPBACK/$1"
}
wait_for_port() {
local port=$1
for _ in $(seq 1 60); do
if port_is_open "$port"; then
return 0
fi
sleep 0.5
done
echo "Nothing spun up on [$LOOPBACK]:$port" >&2
return 1
}
start_postgres() {
if [ -f "$PG_DIR/postmaster.pid" ] && pg_ctl -D "$PG_DIR" status >/dev/null 2>&1; then
echo "Postgres is already running"
return
fi
if [ ! -f "$PG_DIR/PG_VERSION" ]; then
mkdir -p "$PG_DIR"
initdb -D "$PG_DIR" -U postgres --auth=trust >/dev/null
fi
if ! pg_ctl -D "$PG_DIR" -l "$PG_LOG" -w \
-o "-p $PG_PORT -k $PG_DIR -c listen_addresses=$LOOPBACK" start >/dev/null; then
echo "Postgres wouldn't start. Please inspect $PG_LOG" >&2
exit 1
fi
wait_for_port "$PG_PORT"
if ! psql -h "$LOOPBACK" -p "$PG_PORT" -U postgres -lqt 2>/dev/null | cut -d'|' -f1 | grep -qw "$PG_DATABASE"; then
createdb -h "$LOOPBACK" -p "$PG_PORT" -U postgres "$PG_DATABASE"
fi
echo "PostgreSQL is up on [$LOOPBACK]:$PG_PORT"
}
layout_version() {
garage_cli layout show 2>/dev/null |
awk '/Current cluster layout version:/{print $NF; found=1} END{if (!found) print 0}'
}
start_garage() {
if port_is_open "$GARAGE_S3_PORT"; then
echo "Garage object storage is already running"
else
write_garage_conf
garage -c "$GARAGE_CONF" server >> "$GARAGE_LOG" 2>&1 &
echo $! > "$GARAGE_PID_FILE"
wait_for_port "$GARAGE_S3_PORT"
fi
local node_id
node_id=$(garage_cli node id 2>/dev/null |
awk 'match($0, /^[0-9a-f]{64}/) {print substr($0, RSTART, RLENGTH); exit}')
if [ -z "$node_id" ]; then
echo "Couldn't read the garage node id, please inspect $GARAGE_LOG" >&2
exit 1
fi
if ! garage_cli layout show 2>/dev/null | grep -q "${node_id:0:16}"; then
garage_cli layout assign "$node_id" -z dev -c 1GB
garage_cli layout apply --version "$(($(layout_version) + 1))"
fi
if ! garage_cli bucket list | grep -qw "$S3_BUCKET"; then
garage_cli bucket create "$S3_BUCKET" >/dev/null
fi
if ! garage_cli key list | grep -qw "$S3_BUCKET"; then
garage_cli key create "$S3_BUCKET" >/dev/null
fi
garage_cli bucket allow --read --write --owner "$S3_BUCKET" --key "$S3_BUCKET" >/dev/null
echo "Garage is up on [$LOOPBACK]:$GARAGE_S3_PORT"
}
write_env() {
local access_key secret_key
access_key=$(garage_cli key info "$S3_BUCKET" | awk '/^Key ID:/{print $3}')
secret_key=$(garage_cli key info --show-secret "$S3_BUCKET" | awk '/^Secret key:/{print $3}')
if [ -z "$access_key" ] || [ -z "$secret_key" ]; then
echo "Garage didn't show credentials for key $S3_BUCKET, please see $GARAGE_LOG" >&2
exit 1
fi
cat > "$ENV_FILE" << EOF
export DATABASE_URL="postgres://postgres@[$LOOPBACK]:$PG_PORT/$PG_DATABASE"
export TRANQUIL_PDS_TEST_INFRA_READY="1"
export TRANQUIL_PDS_ALLOW_INSECURE_SECRETS="1"
export DISABLE_RATE_LIMITING="1"
export TRANQUIL_LEXICON_OFFLINE="1"
export SKIP_IMPORT_VERIFICATION="1"
export BLOB_STORAGE_BACKEND="s3"
export S3_ENDPOINT="http://[$LOOPBACK]:$GARAGE_S3_PORT"
export S3_BUCKET="$S3_BUCKET"
export AWS_ACCESS_KEY_ID="$access_key"
export AWS_SECRET_ACCESS_KEY="$secret_key"
export AWS_REGION="tranquil"
EOF
}
stop_services() {
if [ -f "$PG_DIR/postmaster.pid" ]; then
pg_ctl -D "$PG_DIR" -m fast -w stop >/dev/null 2>&1 || true
fi
if [ -f "$GARAGE_PID_FILE" ]; then
local pid
pid=$(cat "$GARAGE_PID_FILE")
if [ "$(readlink "/proc/$pid/exe" 2>/dev/null)" = "$(command -v garage)" ]; then
kill "$pid" 2>/dev/null || true
for _ in $(seq 1 60); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
kill -0 "$pid" 2>/dev/null && echo "Garage $pid is taking its sweet time to exit" >&2
fi
rm -f "$GARAGE_PID_FILE"
fi
if port_is_open "$GARAGE_S3_PORT"; then
echo "Smth is still listening on [$LOOPBACK]:$GARAGE_S3_PORT, not to do with us" >&2
fi
echo "Services have been stopped"
}
case "${1:-}" in
up)
mkdir -p "$DEV_DIR"
start_postgres
start_garage
write_env
echo "env at $ENV_FILE"
;;
down)
stop_services
;;
env)
cat "$ENV_FILE"
;;
*)
echo "usage: tranquil-dev-services <up|down|env>" >&2
exit 1
;;
esac
+33
View File
@@ -0,0 +1,33 @@
{
mkShell,
callPackage,
writeShellApplication,
postgresql,
garage_2,
coreutils,
gnugrep,
gawk,
}:
let
devServices = writeShellApplication {
name = "tranquil-dev-services";
runtimeInputs = [
postgresql
garage_2
coreutils
gnugrep
gawk
];
text = builtins.readFile ./full-services.sh;
};
in
mkShell {
inputsFrom = [ (callPackage ../shell.nix { }) ];
packages = [
devServices
postgresql
garage_2
];
}