use crate::core::Column as _;
use crate::sql::FetcherPool;
use crate::sql::Pool;
use async_trait::async_trait;
use http::request::Parts;
use http::HeaderName;
use super::error::TenancyError;
use super::org::Org;
use super::resolver_cache::{Breaker, Cached, GenerationPoll, HostTtlCache};
use crate::sql::PoolId;
use std::sync::{Arc, PoisonError};
#[async_trait]
pub trait OrgResolver: Send + Sync + 'static {
async fn resolve(&self, parts: &Parts, registry: &Pool) -> Result<Option<Org>, TenancyError>;
}
pub struct SubdomainResolver {
pub apex_domain: String,
}
impl SubdomainResolver {
#[must_use]
pub fn new(apex_domain: impl Into<String>) -> Self {
Self {
apex_domain: apex_domain.into(),
}
}
}
#[async_trait]
impl OrgResolver for SubdomainResolver {
async fn resolve(&self, parts: &Parts, registry: &Pool) -> Result<Option<Org>, TenancyError> {
let Some(host) = host_from_parts(parts) else {
return Ok(None);
};
if host.eq_ignore_ascii_case(&self.apex_domain) {
return Ok(None);
}
let st = state(registry);
sync_org_generation(registry, &st).await;
match st.org_cache.get(&host) {
Cached::Miss => return Ok(None),
Cached::Hit(org) => return Ok(Some(org)),
Cached::Absent => {}
}
let found = find_active_org_by(registry, &st, Org::host_pattern.eq(host.clone())).await?;
st.org_cache.put(&host, found.clone());
Ok(found)
}
}
pub struct RegisteredHostResolver;
struct RegistryState {
org_cache: HostTtlCache<Org>,
host_cache: HostTtlCache<Org>,
org_gen: GenerationPoll<super::org::OrgGeneration>,
host_gen: GenerationPoll<super::org_host::Generation>,
registry_down: Breaker,
host_table_down: Breaker,
}
impl RegistryState {
const fn new() -> Self {
Self {
org_cache: HostTtlCache::new(CACHE_TTL, CACHE_MAX),
host_cache: HostTtlCache::new(CACHE_TTL, CACHE_MAX),
org_gen: GenerationPoll::new(),
host_gen: GenerationPoll::new(),
registry_down: Breaker::new(),
host_table_down: Breaker::new(),
}
}
fn invalidate_orgs(&self) {
self.org_cache.clear();
self.host_cache.clear();
}
}
const REGISTRY_MAX: usize = 8;
static STATES: std::sync::RwLock<Vec<(PoolId, Arc<RegistryState>)>> =
std::sync::RwLock::new(Vec::new());
fn state(registry: &Pool) -> Arc<RegistryState> {
let find = |v: &[(PoolId, Arc<RegistryState>)]| {
v.iter()
.find(|(id, _)| id.is(registry))
.map(|(_, s)| s.clone())
};
if let Some(s) = find(&STATES.read().unwrap_or_else(PoisonError::into_inner)) {
return s;
}
let mut states = STATES.write().unwrap_or_else(PoisonError::into_inner);
if let Some(s) = find(&states) {
return s;
}
if states.len() >= REGISTRY_MAX {
let victim = states
.iter()
.position(|(id, _)| id.is_closed())
.unwrap_or(0);
states.remove(victim);
}
let s = Arc::new(RegistryState::new());
states.push((PoolId::of(registry), s.clone()));
s
}
fn each_state(f: impl Fn(&RegistryState)) {
for (_, s) in STATES.read().unwrap_or_else(PoisonError::into_inner).iter() {
f(s);
}
}
fn host_table_retry_after() -> std::time::Duration {
gen_check_every().unwrap_or(GEN_CHECK_EVERY_DEFAULT)
}
pub(crate) const CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
const CACHE_MAX: usize = 1024;
pub fn invalidate_org_cache() {
each_state(RegistryState::invalidate_orgs);
}
async fn sync_org_generation(registry: &Pool, st: &RegistryState) {
st.org_gen
.sync(
gen_check_every(),
|| async {
if registry_is_down(st) {
return Ok(None);
}
super::org::generation(registry).await.map(Some)
},
|e| {
st.registry_down.open();
st.org_gen.warn_once(|| {
tracing::warn!(
target: "rustango::tenancy::resolver",
error = %e,
"could not read the rustango_orgs fingerprint; this pod will \
not notice tenants created or suspended by other processes \
until its own cache entries expire"
);
});
},
|| st.invalidate_orgs(),
)
.await;
}
#[cfg(any(test, feature = "testkit"))]
pub(crate) fn reset_org_cache() {
each_state(|s| {
s.org_gen.reset();
s.invalidate_orgs();
});
}
const GEN_CHECK_EVERY_DEFAULT: std::time::Duration = std::time::Duration::from_secs(5);
const GEN_CHECK_ENV: &str = "RUSTANGO_HOST_GEN_CHECK_SECS";
fn gen_check_every() -> Option<std::time::Duration> {
static CACHED: std::sync::OnceLock<Option<std::time::Duration>> = std::sync::OnceLock::new();
*CACHED.get_or_init(|| match std::env::var(GEN_CHECK_ENV) {
Ok(raw) => match raw.trim().parse::<u64>() {
Ok(0) => None,
Ok(secs) => Some(std::time::Duration::from_secs(secs)),
Err(_) => {
tracing::warn!(
target: "rustango::tenancy::resolver",
value = %raw,
"{GEN_CHECK_ENV} is not a whole number of seconds; \
falling back to the default interval"
);
Some(GEN_CHECK_EVERY_DEFAULT)
}
},
Err(_) => Some(GEN_CHECK_EVERY_DEFAULT),
})
}
async fn sync_generation(registry: &Pool, st: &RegistryState) {
st.host_gen
.sync(
gen_check_every(),
|| async { super::org_host::generation(registry).await.map(Some) },
|e| {
st.host_gen.warn_once(|| {
tracing::warn!(
target: "rustango::tenancy::resolver",
error = %e,
"could not read the rustango_org_hosts fingerprint; this pod \
will not notice host changes made by other processes until \
its own cache entries expire"
);
});
},
|| st.host_cache.clear(),
)
.await;
}
#[cfg(any(test, feature = "testkit"))]
pub(crate) fn reset_generation() {
each_state(|s| {
s.host_gen.reset();
s.host_table_down.close();
});
}
#[cfg(any(test, feature = "testkit"))]
pub(crate) fn expire_generation() {
let interval = gen_check_every().unwrap_or(GEN_CHECK_EVERY_DEFAULT);
each_state(|s| s.host_gen.expire(interval * 2, *PROCESS_START));
}
#[cfg(any(test, feature = "testkit"))]
static PROCESS_START: std::sync::LazyLock<std::time::Instant> =
std::sync::LazyLock::new(std::time::Instant::now);
pub fn invalidate_host_cache() {
each_state(|s| s.host_cache.clear());
}
#[async_trait]
impl OrgResolver for RegisteredHostResolver {
async fn resolve(&self, parts: &Parts, registry: &Pool) -> Result<Option<Org>, TenancyError> {
let Some(host) = host_from_parts(parts) else {
return Ok(None);
};
let st = state(registry);
sync_generation(registry, &st).await;
match st.host_cache.get(&host) {
Cached::Miss => return Ok(None),
Cached::Hit(org) => return Ok(Some(org)),
Cached::Absent => {}
}
if st.host_table_down.is_open(host_table_retry_after()) {
return Ok(None);
}
let rows = match super::OrgHost::objects()
.where_(super::OrgHost::hostname.eq(host.clone()))
.where_(super::OrgHost::enabled.eq(true))
.fetch(registry)
.await
{
Ok(rows) => {
st.host_table_down.close();
rows
}
Err(e) => {
st.host_table_down.open();
st.host_table_down.warn_once(|| {
tracing::warn!(
target: "rustango::tenancy::resolver",
error = %e,
"could not read rustango_org_hosts; extra tenant hostnames \
are inactive until `migrate` creates the table"
);
});
return Ok(None);
}
};
let Some(row) = rows.into_iter().next() else {
st.host_cache.put(&host, None);
return Ok(None);
};
let found = find_active_org_by(registry, &st, Org::id.eq(row.org_id)).await?;
st.host_cache.put(&host, found.clone());
Ok(found)
}
}
pub struct PathPrefixResolver;
#[async_trait]
impl OrgResolver for PathPrefixResolver {
async fn resolve(&self, parts: &Parts, registry: &Pool) -> Result<Option<Org>, TenancyError> {
let path = parts.uri.path();
let Some(first) = path
.trim_start_matches('/')
.split('/')
.next()
.filter(|s| !s.is_empty())
else {
return Ok(None);
};
let candidate = format!("/{first}");
find_active_org_by(registry, &state(registry), Org::path_prefix.eq(candidate)).await
}
}
#[derive(Clone)]
pub struct HeaderResolver {
pub header_name: HeaderName,
allowed_slugs: Option<std::collections::HashSet<String>>,
}
impl HeaderResolver {
#[must_use]
pub fn new(header_name: HeaderName) -> Self {
Self {
header_name,
allowed_slugs: None,
}
}
#[must_use]
pub fn allow_only<I, S>(mut self, slugs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.allowed_slugs = Some(slugs.into_iter().map(Into::into).collect());
self
}
}
impl Default for HeaderResolver {
fn default() -> Self {
Self {
header_name: HeaderName::from_static("x-org"),
allowed_slugs: None,
}
}
}
#[async_trait]
impl OrgResolver for HeaderResolver {
async fn resolve(&self, parts: &Parts, registry: &Pool) -> Result<Option<Org>, TenancyError> {
let Some(value) = parts.headers.get(&self.header_name) else {
return Ok(None);
};
let slug = match value.to_str() {
Ok(s) => s.trim(),
Err(_) => return Ok(None),
};
if slug.is_empty() {
return Ok(None);
}
if let Some(allowed) = &self.allowed_slugs {
if !allowed.contains(slug) {
return Ok(None);
}
}
find_active_org_by(registry, &state(registry), Org::slug.eq(slug.to_owned())).await
}
}
pub struct PortResolver;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ListenerPort(pub u16);
#[async_trait]
impl OrgResolver for PortResolver {
async fn resolve(&self, parts: &Parts, registry: &Pool) -> Result<Option<Org>, TenancyError> {
let Some(ListenerPort(port)) = parts.extensions.get::<ListenerPort>().copied() else {
return Ok(None);
};
find_active_org_by(registry, &state(registry), Org::port.eq(i32::from(port))).await
}
}
pub struct ChainResolver {
resolvers: Vec<Box<dyn OrgResolver>>,
}
impl ChainResolver {
#[must_use]
pub fn new() -> Self {
Self {
resolvers: Vec::new(),
}
}
#[must_use]
pub fn push<R: OrgResolver>(mut self, resolver: R) -> Self {
self.resolvers.push(Box::new(resolver));
self
}
#[must_use]
pub fn standard(apex_domain: impl Into<String>) -> Self {
Self::new()
.push(SubdomainResolver::new(apex_domain))
.push(RegisteredHostResolver)
}
}
impl Default for ChainResolver {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl OrgResolver for ChainResolver {
async fn resolve(&self, parts: &Parts, registry: &Pool) -> Result<Option<Org>, TenancyError> {
for resolver in &self.resolvers {
match resolver.resolve(parts, registry).await? {
Some(org) => {
crate::tenant_log::record(&org.slug, org.id.get().copied());
return Ok(Some(org));
}
None => continue,
}
}
Ok(None)
}
}
pub(crate) fn host_is_apex(headers: &http::HeaderMap, uri: &http::Uri, apex: &str) -> bool {
host_of(headers, uri).is_some_and(|h| h.eq_ignore_ascii_case(apex))
}
fn host_from_parts(parts: &Parts) -> Option<String> {
host_of(&parts.headers, &parts.uri)
}
fn host_of(headers: &http::HeaderMap, uri: &http::Uri) -> Option<String> {
if let Some(value) = headers.get(http::header::HOST) {
if let Ok(s) = value.to_str() {
return crate::urls::split_host_port(s).map(|(h, _)| h.to_ascii_lowercase());
}
}
uri.host().map(str::to_ascii_lowercase)
}
const REGISTRY_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(1);
fn registry_is_down(st: &RegistryState) -> bool {
st.registry_down.is_open(REGISTRY_RETRY_AFTER)
}
fn registry_unavailable() -> TenancyError {
TenancyError::Driver(sqlx::Error::PoolTimedOut)
}
async fn find_active_org_by<F>(
registry: &Pool,
st: &RegistryState,
filter: F,
) -> Result<Option<Org>, TenancyError>
where
F: Into<rustango::core::TypedFilter<Org>>,
{
if registry_is_down(st) {
return Err(registry_unavailable());
}
let typed: rustango::core::TypedFilter<Org> = filter.into();
let result: Result<Vec<Org>, _> = Org::objects()
.where_(typed)
.where_(Org::active.eq(true))
.order_by(&[("id", false)])
.fetch(registry)
.await;
match result {
Ok(rows) => {
st.registry_down.close();
Ok(rows.into_iter().next())
}
Err(e) => {
st.registry_down.open();
st.registry_down.warn_once(|| {
tracing::warn!(
target: "rustango::tenancy::resolver",
error = %e,
"registry lookup failed; tenant resolution is failing fast for \
up to {}s at a time until it recovers",
REGISTRY_RETRY_AFTER.as_secs(),
);
});
Err(TenancyError::Driver(driver_from_exec(e)))
}
}
}
#[cfg(any(test, feature = "testkit"))]
pub(crate) fn reset_registry_breaker() {
each_state(|s| s.registry_down.close());
}
#[cfg(all(test, feature = "sqlite"))]
pub(crate) async fn isolated() -> tokio::sync::MutexGuard<'static, ()> {
static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
let guard = LOCK.lock().await;
reset_registry_breaker();
reset_org_cache();
reset_generation();
invalidate_host_cache();
guard
}
fn driver_from_exec(e: rustango::sql::ExecError) -> rustango::sql::sqlx::Error {
use crate::sql::ExecError;
match e {
ExecError::Driver(err) => err,
other => rustango::sql::sqlx::Error::Protocol(format!("resolver query: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_has_no_allowlist() {
let r = HeaderResolver::default();
assert!(
r.allowed_slugs.is_none(),
"default resolver should not have an allowlist"
);
}
#[test]
fn apex_match_ignores_case_and_port() {
let mut h = http::HeaderMap::new();
h.insert(http::header::HOST, "APP.Test:8080".parse().unwrap());
let path: http::Uri = "/".parse().unwrap();
assert!(host_is_apex(&h, &path, "app.test"));
assert!(!host_is_apex(&h, &path, "acme.app.test"));
let none = http::HeaderMap::new();
assert!(!host_is_apex(&none, &path, "app.test"));
let h2: http::Uri = "https://app.test/x".parse().unwrap();
assert!(host_is_apex(&none, &h2, "app.test"));
}
#[test]
fn ipv6_host_keeps_the_address() {
let mut h = http::HeaderMap::new();
h.insert(http::header::HOST, "[::1]:8080".parse().unwrap());
let path: http::Uri = "/".parse().unwrap();
assert_eq!(host_of(&h, &path).as_deref(), Some("[::1]"));
assert!(host_is_apex(&h, &path, "[::1]"));
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn a_full_list_evicts_a_closed_pool_first() {
let _iso = isolated().await;
STATES.write().unwrap().clear();
let mut pools = Vec::new();
for _ in 0..REGISTRY_MAX {
let p = Pool::connect("sqlite::memory:").await.unwrap();
state(&p);
pools.push(p);
}
pools[3].close().await;
let extra = Pool::connect("sqlite::memory:").await.unwrap();
state(&extra);
let kept = |p: &Pool| STATES.read().unwrap().iter().any(|(id, _)| id.is(p));
assert!(kept(&pools[0]), "the oldest live pool was evicted");
assert!(!kept(&pools[3]), "the closed pool was kept");
STATES.write().unwrap().clear();
}
#[test]
fn allow_only_stores_slugs() {
let r = HeaderResolver::default().allow_only(["acme", "globex"]);
let set = r.allowed_slugs.expect("allow_only sets the slug set");
assert_eq!(set.len(), 2);
assert!(set.contains("acme"));
assert!(set.contains("globex"));
assert!(!set.contains("attacker-tenant"));
}
#[test]
fn allow_only_accepts_owned_strings() {
let slugs: Vec<String> = vec!["a".into(), "b".into()];
let r = HeaderResolver::default().allow_only(slugs);
assert_eq!(
r.allowed_slugs.expect("allow_only sets the slug set").len(),
2
);
}
}