From e572cfabdea4c8ec685af3857a3bacbd0335a204 Mon Sep 17 00:00:00 2001 From: Lewis Date: Sun, 30 Aug 2026 16:29:23 +0300 Subject: [PATCH] caddy: on-demand TLS endpoint Lewis: May this revision serve well! --- Cargo.lock | 2 + .../tranquil-api/src/admin/account/update.rs | 8 +- crates/tranquil-api/src/identity/did.rs | 39 ++-- crates/tranquil-api/src/identity/plc/sign.rs | 5 +- crates/tranquil-api/src/lib.rs | 10 +- crates/tranquil-api/src/server/caddy.rs | 43 +++++ crates/tranquil-api/src/server/meta.rs | 7 +- crates/tranquil-api/src/server/mod.rs | 2 + crates/tranquil-config/Cargo.toml | 2 + crates/tranquil-config/src/lib.rs | 43 +++-- .../src/sso_endpoints.rs | 60 +++--- crates/tranquil-pds/src/api/error.rs | 3 +- crates/tranquil-pds/src/api/validation.rs | 31 +-- crates/tranquil-pds/src/handle/mod.rs | 149 ++++++++++++-- crates/tranquil-pds/src/plc/mod.rs | 2 +- crates/tranquil-pds/tests/caddy_ask.rs | 113 +++++++++++ crates/tranquil-server/src/main.rs | 7 +- crates/tranquil-store/tests/backup.rs | 16 +- crates/tranquil-types/src/lib.rs | 32 +++ docs/2_INSTALL_NIX.md | 2 +- example.toml | 15 +- flake.nix | 1 + justfile | 43 +++-- module.nix | 2 +- scripts/run-tests.sh | 5 - shell.nix | 4 - shells/full-services.sh | 182 ++++++++++++++++++ shells/full.nix | 33 ++++ 28 files changed, 699 insertions(+), 162 deletions(-) create mode 100644 crates/tranquil-api/src/server/caddy.rs create mode 100644 crates/tranquil-pds/tests/caddy_ask.rs create mode 100644 shells/full-services.sh create mode 100644 shells/full.nix diff --git a/Cargo.lock b/Cargo.lock index d65d177..9db6ed4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7780,6 +7780,8 @@ name = "tranquil-config" version = "0.6.6" dependencies = [ "confique", + "serde", + "tranquil-types", ] [[package]] diff --git a/crates/tranquil-api/src/admin/account/update.rs b/crates/tranquil-api/src/admin/account/update.rs index a0a1bb8..51cc006 100644 --- a/crates/tranquil-api/src/admin/account/update.rs +++ b/crates/tranquil-api/src/admin/account/update.rs @@ -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 diff --git a/crates/tranquil-api/src/identity/did.rs b/crates/tranquil-api/src/identity/did.rs index 34ec86f..f80249e 100644 --- a/crates/tranquil-api/src/identity/did.rs +++ b/crates/tranquil-api/src/identity/did.rs @@ -132,12 +132,9 @@ pub async fn well_known_did(State(state): State, 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() { diff --git a/crates/tranquil-api/src/identity/plc/sign.rs b/crates/tranquil-api/src/identity/plc/sign.rs index 70c4da6..66dceca 100644 --- a/crates/tranquil-api/src/identity/plc/sign.rs +++ b/crates/tranquil-api/src/identity/plc/sign.rs @@ -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)] diff --git a/crates/tranquil-api/src/lib.rs b/crates/tranquil-api/src/lib.rs index 9419f2f..0b6ab5f 100644 --- a/crates/tranquil-api/src/lib.rs +++ b/crates/tranquil-api/src/lib.rs @@ -467,9 +467,15 @@ pub fn api_routes() -> axum::Router { pub fn well_known_api_routes() -> axum::Router { 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 { diff --git a/crates/tranquil-api/src/server/caddy.rs b/crates/tranquil-api/src/server/caddy.rs new file mode 100644 index 0000000..e98d9ee --- /dev/null +++ b/crates/tranquil-api/src/server/caddy.rs @@ -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>(deserializer: D) -> Result { + 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, + Query(ask): Query, +) -> 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 + } + } +} diff --git a/crates/tranquil-api/src/server/meta.rs b/crates/tranquil-api/src/server/meta.rs index 0f07c4a..4620ca4 100644 --- a/crates/tranquil-api/src/server/meta.rs +++ b/crates/tranquil-api/src/server/meta.rs @@ -77,7 +77,12 @@ pub async fn describe_server(State(state): State) -> Json { + 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 { diff --git a/crates/tranquil-api/src/server/mod.rs b/crates/tranquil-api/src/server/mod.rs index 0bf8b55..2322a86 100644 --- a/crates/tranquil-api/src/server/mod.rs +++ b/crates/tranquil-api/src/server/mod.rs @@ -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, diff --git a/crates/tranquil-config/Cargo.toml b/crates/tranquil-config/Cargo.toml index 468044d..8b4fa54 100644 --- a/crates/tranquil-config/Cargo.toml +++ b/crates/tranquil-config/Cargo.toml @@ -5,4 +5,6 @@ edition.workspace = true license.workspace = true [dependencies] +serde = { workspace = true } +tranquil-types = { workspace = true } confique = { workspace = true } diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index ad23d96..ec95e3e 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -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 = 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>, + pub user_handle_domains: Option>, /// 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 { 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 { - 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 { - 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`. -fn split_comma_list(value: &str) -> Result, std::convert::Infallible> { - Ok(value +fn split_comma_list(value: &str) -> Result, 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)] diff --git a/crates/tranquil-oauth-server/src/sso_endpoints.rs b/crates/tranquil-oauth-server/src/sso_endpoints.rs index db228d5..50485f2 100644 --- a/crates/tranquil-oauth-server/src/sso_endpoints.rs +++ b/crates/tranquil-oauth-server/src/sso_endpoints.rs @@ -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 diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index 7163f92..d83fa63 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -763,8 +763,7 @@ impl From 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())), } } diff --git a/crates/tranquil-pds/src/api/validation.rs b/crates/tranquil-pds/src/api/validation.rs index 6d8ef4e..3d13b5a 100644 --- a/crates/tranquil-pds/src/api/validation.rs +++ b/crates/tranquil-pds/src/api/validation.rs @@ -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 Result { - 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, diff --git a/crates/tranquil-pds/src/handle/mod.rs b/crates/tranquil-pds/src/handle/mod.rs index 0592430..2f8cf3c 100644 --- a/crates/tranquil-pds/src/handle/mod.rs +++ b/crates/tranquil-pds/src/handle/mod.rs @@ -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 = 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 { + 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 = 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 { + 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 + ); } } diff --git a/crates/tranquil-pds/src/plc/mod.rs b/crates/tranquil-pds/src/plc/mod.rs index 5ff09f1..aa2c25a 100644 --- a/crates/tranquil-pds/src/plc/mod.rs +++ b/crates/tranquil-pds/src/plc/mod.rs @@ -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)) diff --git a/crates/tranquil-pds/tests/caddy_ask.rs b/crates/tranquil-pds/tests/caddy_ask.rs new file mode 100644 index 0000000..55bc976 --- /dev/null +++ b/crates/tranquil-pds/tests/caddy_ask.rs @@ -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); +} diff --git a/crates/tranquil-server/src/main.rs b/crates/tranquil-server/src/main.rs index 5c22a9a..ab23437 100644 --- a/crates/tranquil-server/src/main.rs +++ b/crates/tranquil-server/src/main.rs @@ -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" diff --git a/crates/tranquil-store/tests/backup.rs b/crates/tranquil-store/tests/backup.rs index d7eb370..cbaf0be 100644 --- a/crates/tranquil-store/tests/backup.rs +++ b/crates/tranquil-store/tests/backup.rs @@ -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(); diff --git a/crates/tranquil-types/src/lib.rs b/crates/tranquil-types/src/lib.rs index c9866e1..37096ee 100644 --- a/crates/tranquil-types/src/lib.rs +++ b/crates/tranquil-types/src/lib.rs @@ -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), diff --git a/docs/2_INSTALL_NIX.md b/docs/2_INSTALL_NIX.md index a829936..c344eab 100644 --- a/docs/2_INSTALL_NIX.md +++ b/docs/2_INSTALL_NIX.md @@ -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 ''; }; }; diff --git a/example.toml b/example.toml index c972bc2..523a359 100644 --- a/example.toml +++ b/example.toml @@ -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`. diff --git a/flake.nix b/flake.nix index fb3d2e1..35ee330 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,7 @@ devShells = forAllSystems (pkgs: { default = pkgs.callPackage ./shell.nix { }; + full = pkgs.callPackage ./shells/full.nix { }; }); nixosModules = { diff --git a/justfile b/justfile index ebc8618..be34371 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/module.nix b/module.nix index ccf2a64..d674315 100644 --- a/module.nix +++ b/module.nix @@ -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"; }; diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index b9b6f10..6ef07e0 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -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..." diff --git a/shell.nix b/shell.nix index 234b74d..3bd1c2f 100644 --- a/shell.nix +++ b/shell.nix @@ -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 diff --git a/shells/full-services.sh b/shells/full-services.sh new file mode 100644 index 0000000..54538cc --- /dev/null +++ b/shells/full-services.sh @@ -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 " >&2 + exit 1 + ;; +esac diff --git a/shells/full.nix b/shells/full.nix new file mode 100644 index 0000000..0091b48 --- /dev/null +++ b/shells/full.nix @@ -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 + ]; +}