mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-31 13:17:05 +00:00
caddy: on-demand TLS endpoint
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
This commit is contained in:
Generated
+2
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,4 +5,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
tranquil-types = { workspace = true }
|
||||
confique = { workspace = true }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
devShells = forAllSystems (pkgs: {
|
||||
default = pkgs.callPackage ./shell.nix { };
|
||||
full = pkgs.callPackage ./shells/full.nix { };
|
||||
});
|
||||
|
||||
nixosModules = {
|
||||
|
||||
@@ -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
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user